From b948ee34e8cedc86fb1ebe890912152abc1baf31 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:26:08 +0000 Subject: [PATCH 1/2] Configure DeepSeek Harness through Requesty Co-Authored-By: Daniel Trugman --- README.md | 10 +- internal/harnesses/deepseek.go | 267 ++++++++++++++++++++++++++++ internal/harnesses/deepseek_test.go | 199 +++++++++++++++++++++ internal/harnesses/harnesses.go | 6 + 4 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 internal/harnesses/deepseek.go create mode 100644 internal/harnesses/deepseek_test.go diff --git a/README.md b/README.md index 7dd3c4a..a0261c3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/requestyai/cli/blob/main/LICENSE) -> Point Claude Code, Codex, OpenCode, Pi and Hermes at [Requesty](https://requesty.ai) from one terminal app. +> Point Claude Code, Codex, OpenCode, Pi, Hermes and DeepSeek Harness at [Requesty](https://requesty.ai) from one terminal app. `requesty` finds the AI coding harnesses installed on your machine and rewrites their own configuration so every request goes through the Requesty gateway. You get 300+ models behind a @@ -68,7 +68,7 @@ Harnesses found on your `PATH` are listed first. The ones that are not installed cannot be configured. The footer always shows the keys available on the current screen. ```text - harnesses on this machine 0 of 5 routing through requesty + harnesses on this machine 0 of 6 routing through requesty HARNESS CONFIG STATUS ──────────────────────────────────────────────────────────────────────────── @@ -77,6 +77,7 @@ cannot be configured. The footer always shows the keys available on the current [ ] OpenCode /home/you/.config/opencode/opencode.jsoninactive [ ] Pi /home/you/.pi/agent/models.json inactive [ ] Hermes /home/you/.hermes/config.yaml inactive + [ ] DeepSeek Harness /home/you/.dsh/settings.yaml inactive ╭──────────────────────────────────────────────────────────────────────────╮ │ Claude Code /home/you/.claude/settings.json │ @@ -95,8 +96,8 @@ the files, the row flips to `[✓] active`, and the header count goes up. **Restart the harness** -Harnesses read their configuration at startup. Restart `claude`, `codex`, `opencode`, `pi` or -`hermes` and it is talking to Requesty. +Harnesses read their configuration at startup. Restart `claude`, `codex`, `opencode`, `pi`, +`hermes` or `dsh` and it is talking to Requesty. ## Supported harnesses @@ -107,6 +108,7 @@ Harnesses read their configuration at startup. Restart `claude`, `codex`, `openc | [OpenCode](https://docs.requesty.ai/integrations/opencode) | `opencode` on `PATH` | `~/.config/opencode/opencode.json` | A `requesty` provider on `.../v1` plus the model as `requesty/` | | [Pi](https://docs.requesty.ai/integrations/pi) | `pi` on `PATH` | `~/.pi/agent/models.json` | A `requesty` provider using the native Anthropic Messages API | | [Hermes](https://docs.requesty.ai/integrations/hermes) | `hermes` on `PATH` | `~/.hermes/config.yaml` | A `requesty` entry in `custom_providers` and `model.default` | +| [DeepSeek Harness](https://docs.requesty.ai/integrations/deepseek-harness) | `dsh` on `PATH` | `$DSH_HOME/settings.yaml`, `$DSH_HOME/.credentials.yaml` (`~/.dsh` by default) | A `requesty` provider on `.../v1` under `llm-pi-ai`, the selected model as `agent-default-model`, and the key stored as `REQUESTY_API_KEY` | Pi and Hermes are configured against the native Anthropic Messages format, which is what lets Requesty apply [automatic prompt caching](https://docs.requesty.ai/features/auto-caching) to diff --git a/internal/harnesses/deepseek.go b/internal/harnesses/deepseek.go new file mode 100644 index 0000000..61a786f --- /dev/null +++ b/internal/harnesses/deepseek.go @@ -0,0 +1,267 @@ +package harnesses + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/requestyai/cli/internal/config" + "gopkg.in/yaml.v3" +) + +const ( + deepseekProvider = "requesty" + + // deepseekAPI is the OpenAI Chat Completions format, which the harness + // speaks against a base URL that keeps the /v1 suffix. + deepseekAPI = "openai-completions" + + // deepseekCredentialRef names the credential in the harness credentials + // file. The settings file references it instead of holding the key. + deepseekCredentialRef = "REQUESTY_API_KEY" + + deepseekContextWindow = 200000 + deepseekMaxTokens = 8192 +) + +type deepseekSettings struct { + DefaultModel deepseekDefaultModel `yaml:"agent-default-model"` + PiAI deepseekPiAI `yaml:"llm-pi-ai"` +} + +type deepseekDefaultModel struct { + Provider string `yaml:"provider"` + Model string `yaml:"model"` +} + +type deepseekPiAI struct { + Providers map[string]deepseekProviderConfig `yaml:"providers"` +} + +type deepseekProviderConfig struct { + DisplayName string `yaml:"displayName"` + APIKeyEnv string `yaml:"apiKeyEnv"` + API string `yaml:"api"` + BaseURL string `yaml:"baseURL"` + DefaultContextWindow int `yaml:"defaultContextWindow"` + DefaultMaxTokens int `yaml:"defaultMaxTokens"` + Headers map[string]string `yaml:"headers,omitempty"` + Models []deepseekModelConfig `yaml:"models"` +} + +type deepseekModelConfig struct { + ID string `yaml:"id"` +} + +type DeepSeekHarness struct { + config config.Config + configDir string +} + +// DefaultConfigDirDeepSeek is the DeepSeek Harness home, which holds its +// settings and credentials. The harness reads DSH_HOME first, so an explicit +// home wins over the default one. +func DefaultConfigDirDeepSeek() (string, error) { + if home := os.Getenv("DSH_HOME"); home != "" { + return home, nil + } + + return configDirInHome(".dsh") +} + +func NewDeepSeekHarness(config config.Config, configDir string) *DeepSeekHarness { + return &DeepSeekHarness{ + config: config, + configDir: configDir, + } +} + +func (d *DeepSeekHarness) Name() string { + return "DeepSeek Harness" +} + +func (d *DeepSeekHarness) Description() []string { + return []string{ + "takes a backup of settings.yaml and .credentials.yaml", + "writes settings.yaml and .credentials.yaml to route through Requesty", + } +} + +func (d *DeepSeekHarness) Status() (Status, error) { + status := Status{} + + if _, err := exec.LookPath("dsh"); err == nil { + status.Executable = true + } else if !errors.Is(err, exec.ErrNotFound) { + return status, fmt.Errorf("failed to find executable: %w", err) + } + + settingsPath := d.settingsPath() + credentialsPath := d.credentialsPath() + status.Files = append(status.Files, settingsPath, credentialsPath) + + settingsExists, err := pathExists(settingsPath) + if err != nil { + return status, fmt.Errorf("failed to check file exists: %w", err) + } + + credentialsExists, err := pathExists(credentialsPath) + if err != nil { + return status, fmt.Errorf("failed to check file exists: %w", err) + } + + if !settingsExists || !credentialsExists { + status.Configured = false + return status, nil + } + + settingsBytes, err := os.ReadFile(settingsPath) + if err != nil { + return status, fmt.Errorf("failed to read file: %w", err) + } + + var settings deepseekSettings + if err := yaml.Unmarshal(settingsBytes, &settings); err != nil { + return status, fmt.Errorf("failed to unmarshal: %w", err) + } + + credentialsBytes, err := os.ReadFile(credentialsPath) + if err != nil { + return status, fmt.Errorf("failed to read credentials file: %w", err) + } + + credentials := make(map[string]string) + if err := yaml.Unmarshal(credentialsBytes, &credentials); err != nil { + return status, fmt.Errorf("failed to unmarshal credentials: %w", err) + } + + status.Configured = settings.PiAI.Providers[deepseekProvider].BaseURL == d.routerBaseURL() && + settings.DefaultModel.Provider == deepseekProvider && + credentials[deepseekCredentialRef] != "" + + return status, nil +} + +func (d *DeepSeekHarness) Configure(opts ConfigureOptions) error { + if opts.Overwrite { + return d.configureOverwrite(opts) + } + + return d.configureMerge(opts) +} + +func (d *DeepSeekHarness) configureMerge(opts ConfigureOptions) error { + settingsPath := d.settingsPath() + + settings, err := mergeOrCreateYAMLConfigFile(settingsPath, map[string]any{ + "agent-default-model": map[string]any{ + "provider": deepseekProvider, + "model": opts.Model, + }, + "llm-pi-ai": map[string]any{ + "providers": map[string]any{ + deepseekProvider: map[string]any{ + "displayName": "Requesty", + "apiKeyEnv": deepseekCredentialRef, + "api": deepseekAPI, + "baseURL": d.routerBaseURL(), + "defaultContextWindow": deepseekContextWindow, + "defaultMaxTokens": deepseekMaxTokens, + "headers": map[string]any{ + "HTTP-Referer": "https://requesty.ai", + "X-Title": "DeepSeek Harness", + }, + "models": []any{ + map[string]any{"id": opts.Model}, + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to merge settings file: %w", err) + } + + if err := backupAndWriteConfigFileAsYAML(settingsPath, &settings); err != nil { + return fmt.Errorf("failed to write settings file: %w", err) + } + + if err := d.writeCredentials(); err != nil { + return err + } + + return nil +} + +func (d *DeepSeekHarness) configureOverwrite(opts ConfigureOptions) error { + settings := deepseekSettings{ + DefaultModel: deepseekDefaultModel{ + Provider: deepseekProvider, + Model: opts.Model, + }, + PiAI: deepseekPiAI{ + Providers: map[string]deepseekProviderConfig{ + deepseekProvider: { + DisplayName: "Requesty", + APIKeyEnv: deepseekCredentialRef, + API: deepseekAPI, + BaseURL: d.routerBaseURL(), + DefaultContextWindow: deepseekContextWindow, + DefaultMaxTokens: deepseekMaxTokens, + Headers: map[string]string{ + "HTTP-Referer": "https://requesty.ai", + "X-Title": "DeepSeek Harness", + }, + Models: []deepseekModelConfig{ + {ID: opts.Model}, + }, + }, + }, + }, + } + + if err := backupAndWriteConfigFileAsYAML(d.settingsPath(), &settings); err != nil { + return fmt.Errorf("failed to write settings file: %w", err) + } + + if err := d.writeCredentials(); err != nil { + return err + } + + return nil +} + +// writeCredentials stores the API key in the harness credentials file, which +// the settings file reaches through apiKeyEnv. Other stored credentials are +// preserved. +func (d *DeepSeekHarness) writeCredentials() error { + credentialsPath := d.credentialsPath() + + credentials, err := mergeOrCreateYAMLConfigFile(credentialsPath, map[string]any{ + deepseekCredentialRef: d.config.APIKey, + }) + if err != nil { + return fmt.Errorf("failed to merge credentials file: %w", err) + } + + if err := backupAndWriteConfigFileAsYAML(credentialsPath, &credentials); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + + return nil +} + +// routerBaseURL keeps the /v1 suffix the OpenAI Chat Completions format needs. +func (d *DeepSeekHarness) routerBaseURL() string { + return fmt.Sprintf("%s/v1", d.config.RouterBaseURL) +} + +func (d *DeepSeekHarness) settingsPath() string { + return filepath.Join(d.configDir, "settings.yaml") +} + +func (d *DeepSeekHarness) credentialsPath() string { + return filepath.Join(d.configDir, ".credentials.yaml") +} diff --git a/internal/harnesses/deepseek_test.go b/internal/harnesses/deepseek_test.go new file mode 100644 index 0000000..e1d092f --- /dev/null +++ b/internal/harnesses/deepseek_test.go @@ -0,0 +1,199 @@ +package harnesses + +import ( + "os" + "path/filepath" + "testing" + + "github.com/requestyai/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestDeepSeekHarnessRoundTrip(t *testing.T) { + config := config.Config{ + RouterBaseURL: "https://router.requesty.ai", + APIKey: "my-api-key", + } + + // Simulate a machine with DeepSeek Harness installed but not integrated. + configDir := t.TempDir() + settingsPath := filepath.Join(configDir, "settings.yaml") + credentialsPath := filepath.Join(configDir, ".credentials.yaml") + require.NoError(t, os.WriteFile(settingsPath, []byte(`agent-default-model: + provider: deepseek-official + model: deepseek-v4-pro +llm-deepseek: + apiKeyEnv: DEEPSEEK_API_KEY +`), 0o600)) + require.NoError(t, os.WriteFile(credentialsPath, []byte("DEEPSEEK_API_KEY: keep-me\n"), 0o600)) + + harness := NewDeepSeekHarness(config, configDir) + + status, err := harness.Status() + require.NoError(t, err) + assert.Contains(t, status.Files, settingsPath) + assert.Contains(t, status.Files, credentialsPath) + assert.Equal(t, false, status.Configured) + + // Configure the machine. + err = harness.Configure(ConfigureOptions{ + Model: "deepseek/deepseek-v4-pro-0813", + }) + require.NoError(t, err) + + // Validate DeepSeek Harness is integrated with Requesty. + status, err = harness.Status() + require.NoError(t, err) + assert.Equal(t, true, status.Configured) + + settingsBytes, err := os.ReadFile(settingsPath) + require.NoError(t, err) + + settings := make(map[string]any) + require.NoError(t, yaml.Unmarshal(settingsBytes, &settings)) + assert.Equal(t, map[string]any{ + "agent-default-model": map[string]any{ + "provider": "requesty", + "model": "deepseek/deepseek-v4-pro-0813", + }, + "llm-deepseek": map[string]any{ + "apiKeyEnv": "DEEPSEEK_API_KEY", + }, + "llm-pi-ai": map[string]any{ + "providers": map[string]any{ + "requesty": map[string]any{ + "displayName": "Requesty", + "apiKeyEnv": "REQUESTY_API_KEY", + "api": "openai-completions", + "baseURL": "https://router.requesty.ai/v1", + "defaultContextWindow": 200000, + "defaultMaxTokens": 8192, + "headers": map[string]any{ + "HTTP-Referer": "https://requesty.ai", + "X-Title": "DeepSeek Harness", + }, + "models": []any{ + map[string]any{"id": "deepseek/deepseek-v4-pro-0813"}, + }, + }, + }, + }, + }, settings) + + // The harness reads keys by name, so other stored credentials survive. + credentialsBytes, err := os.ReadFile(credentialsPath) + require.NoError(t, err) + + credentials := make(map[string]any) + require.NoError(t, yaml.Unmarshal(credentialsBytes, &credentials)) + assert.Equal(t, map[string]any{ + "DEEPSEEK_API_KEY": "keep-me", + "REQUESTY_API_KEY": "my-api-key", + }, credentials) + + // The harness rejects a credentials file other users can read. + info, err := os.Stat(credentialsPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestDeepSeekHarnessConfigureCreatesMissingFiles(t *testing.T) { + config := config.Config{ + RouterBaseURL: "https://router.eu.requesty.ai", + APIKey: "my-api-key", + } + + configDir := t.TempDir() + harness := NewDeepSeekHarness(config, configDir) + + require.NoError(t, harness.Configure(ConfigureOptions{ + Model: "anthropic/claude-fable-5", + })) + + settingsBytes, err := os.ReadFile(filepath.Join(configDir, "settings.yaml")) + require.NoError(t, err) + + var settings deepseekSettings + require.NoError(t, yaml.Unmarshal(settingsBytes, &settings)) + assert.Equal(t, "requesty", settings.DefaultModel.Provider) + assert.Equal(t, "anthropic/claude-fable-5", settings.DefaultModel.Model) + assert.Equal(t, deepseekProviderConfig{ + DisplayName: "Requesty", + APIKeyEnv: "REQUESTY_API_KEY", + API: "openai-completions", + BaseURL: "https://router.eu.requesty.ai/v1", + DefaultContextWindow: 200000, + DefaultMaxTokens: 8192, + Headers: map[string]string{ + "HTTP-Referer": "https://requesty.ai", + "X-Title": "DeepSeek Harness", + }, + Models: []deepseekModelConfig{ + {ID: "anthropic/claude-fable-5"}, + }, + }, settings.PiAI.Providers["requesty"]) + + credentialsBytes, err := os.ReadFile(filepath.Join(configDir, ".credentials.yaml")) + require.NoError(t, err) + assert.Equal(t, "REQUESTY_API_KEY: my-api-key\n", string(credentialsBytes)) + + status, err := harness.Status() + require.NoError(t, err) + assert.Equal(t, true, status.Configured) +} + +func TestDeepSeekHarnessConfigureOverwriteReplacesSettings(t *testing.T) { + config := config.Config{ + RouterBaseURL: "https://router.requesty.ai", + APIKey: "my-api-key", + } + + configDir := t.TempDir() + settingsPath := filepath.Join(configDir, "settings.yaml") + require.NoError(t, os.WriteFile(settingsPath, []byte(`llm-deepseek: + apiKeyEnv: DEEPSEEK_API_KEY +`), 0o600)) + + harness := NewDeepSeekHarness(config, configDir) + require.NoError(t, harness.Configure(ConfigureOptions{ + Model: "deepseek/deepseek-v4-pro-0813", + Overwrite: true, + })) + + settingsBytes, err := os.ReadFile(settingsPath) + require.NoError(t, err) + + settings := make(map[string]any) + require.NoError(t, yaml.Unmarshal(settingsBytes, &settings)) + assert.NotContains(t, settings, "llm-deepseek") + + // The replaced settings are kept in the backup. + backupBytes, err := os.ReadFile(settingsPath + ".requesty.bak") + require.NoError(t, err) + assert.Contains(t, string(backupBytes), "DEEPSEEK_API_KEY") + + status, err := harness.Status() + require.NoError(t, err) + assert.Equal(t, true, status.Configured) +} + +func TestDeepSeekHarnessDefaultConfigDir(t *testing.T) { + homePath, err := os.UserHomeDir() + require.NoError(t, err) + + t.Setenv("DSH_HOME", "") + + configDir, err := DefaultConfigDirDeepSeek() + require.NoError(t, err) + assert.Equal(t, filepath.Join(homePath, ".dsh"), configDir) +} + +func TestDeepSeekHarnessDefaultConfigDirFromEnvironment(t *testing.T) { + t.Setenv("DSH_HOME", filepath.Join("custom", "harness-home")) + + configDir, err := DefaultConfigDirDeepSeek() + require.NoError(t, err) + assert.Equal(t, filepath.Join("custom", "harness-home"), configDir) +} diff --git a/internal/harnesses/harnesses.go b/internal/harnesses/harnesses.go index 6967236..0b0ac63 100644 --- a/internal/harnesses/harnesses.go +++ b/internal/harnesses/harnesses.go @@ -50,11 +50,17 @@ func Harnesses(config config.Config) ([]Harness, error) { return nil, fmt.Errorf("failed to get Hermes config directory: %w", err) } + deepseekConfigDir, err := DefaultConfigDirDeepSeek() + if err != nil { + return nil, fmt.Errorf("failed to get DeepSeek Harness config directory: %w", err) + } + return []Harness{ NewClaudeHarness(config, claudeCodeConfigDir), NewCodexHarness(config, codexConfigDir), NewOpenCodeHarness(config, openCodeConfigDir), NewPiHarness(config, piConfigDir), NewHermesHarness(config, hermesConfigDir), + NewDeepSeekHarness(config, deepseekConfigDir), }, nil } From 1d8eb61c6126c58aa1884766a96eac0b1eb97719 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:11:59 +0000 Subject: [PATCH 2/2] Keep DeepSeek Harness models added in the harness Co-Authored-By: Daniel Trugman --- README.md | 5 +++ internal/harnesses/deepseek.go | 60 ++++++++++++++++++++++++++-- internal/harnesses/deepseek_test.go | 61 +++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a0261c3..8830674 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,11 @@ those harnesses. Where a harness supports custom headers, the CLI also sets an `X-Title` header naming the tool, so the [Requesty dashboard](https://app.requesty.ai/analytics) can break spend down per harness. +DeepSeek Harness needs the models of a custom provider listed in its own settings, so the CLI +writes the model you selected. You can add more Requesty models later inside the harness, under +Settings, Models, Requesty, either with `Fetch available models` or with `Add model`. In merge mode +a later run of the CLI keeps those entries and only adds the model you selected if it is missing. + ## Usage Above the harness list, the CLI shows spend, requests and tokens for the last 30 days for the key diff --git a/internal/harnesses/deepseek.go b/internal/harnesses/deepseek.go index 61a786f..0519bc5 100644 --- a/internal/harnesses/deepseek.go +++ b/internal/harnesses/deepseek.go @@ -55,6 +55,16 @@ type deepseekModelConfig struct { ID string `yaml:"id"` } +// deepseekRawSettings reads the model entries without dropping fields the +// harness writes for them, such as display names and input capacities. +type deepseekRawSettings struct { + PiAI struct { + Providers map[string]struct { + Models []map[string]any `yaml:"models"` + } `yaml:"providers"` + } `yaml:"llm-pi-ai"` +} + type DeepSeekHarness struct { config config.Config configDir string @@ -155,6 +165,11 @@ func (d *DeepSeekHarness) Configure(opts ConfigureOptions) error { func (d *DeepSeekHarness) configureMerge(opts ConfigureOptions) error { settingsPath := d.settingsPath() + models, err := d.mergedModels(settingsPath, opts.Model) + if err != nil { + return err + } + settings, err := mergeOrCreateYAMLConfigFile(settingsPath, map[string]any{ "agent-default-model": map[string]any{ "provider": deepseekProvider, @@ -173,9 +188,7 @@ func (d *DeepSeekHarness) configureMerge(opts ConfigureOptions) error { "HTTP-Referer": "https://requesty.ai", "X-Title": "DeepSeek Harness", }, - "models": []any{ - map[string]any{"id": opts.Model}, - }, + "models": models, }, }, }, @@ -233,6 +246,47 @@ func (d *DeepSeekHarness) configureOverwrite(opts ConfigureOptions) error { return nil } +// mergedModels keeps the model entries already listed for the Requesty +// provider, including any the user added through the harness itself, and adds +// the selected model when it is missing. The harness needs an explicit catalog +// because a custom provider ships none, so the list cannot be dropped, and a +// later run must not discard models the user picked in the harness. +func (d *DeepSeekHarness) mergedModels(settingsPath, model string) ([]any, error) { + settingsBytes, err := os.ReadFile(settingsPath) + if errors.Is(err, os.ErrNotExist) { + return []any{map[string]any{"id": model}}, nil + } else if err != nil { + return nil, fmt.Errorf("failed to read settings file: %w", err) + } + + var settings deepseekRawSettings + if err := yaml.Unmarshal(settingsBytes, &settings); err != nil { + return nil, fmt.Errorf("failed to unmarshal settings file: %w", err) + } + + models := make([]any, 0, len(settings.PiAI.Providers[deepseekProvider].Models)+1) + selected := false + + for _, entry := range settings.PiAI.Providers[deepseekProvider].Models { + id, ok := entry["id"].(string) + if !ok || id == "" { + continue + } + + if id == model { + selected = true + } + + models = append(models, entry) + } + + if !selected { + models = append(models, map[string]any{"id": model}) + } + + return models, nil +} + // writeCredentials stores the API key in the harness credentials file, which // the settings file reaches through apiKeyEnv. Other stored credentials are // preserved. diff --git a/internal/harnesses/deepseek_test.go b/internal/harnesses/deepseek_test.go index e1d092f..3a6859a 100644 --- a/internal/harnesses/deepseek_test.go +++ b/internal/harnesses/deepseek_test.go @@ -179,6 +179,67 @@ func TestDeepSeekHarnessConfigureOverwriteReplacesSettings(t *testing.T) { assert.Equal(t, true, status.Configured) } +func TestDeepSeekHarnessConfigureKeepsModelsAddedInHarness(t *testing.T) { + config := config.Config{ + RouterBaseURL: "https://router.requesty.ai", + APIKey: "my-api-key", + } + + // Simulate a user who added models through the harness settings UI after a + // first run of the CLI. + configDir := t.TempDir() + settingsPath := filepath.Join(configDir, "settings.yaml") + require.NoError(t, os.WriteFile(settingsPath, []byte(`llm-pi-ai: + providers: + requesty: + models: + - id: deepseek/deepseek-v4-pro-0813 + - id: anthropic/claude-sonnet-4-6 + name: Claude Sonnet 4.6 + input: + - text + - image +`), 0o600)) + + harness := NewDeepSeekHarness(config, configDir) + require.NoError(t, harness.Configure(ConfigureOptions{ + Model: "openai/gpt-5.4", + })) + + settingsBytes, err := os.ReadFile(settingsPath) + require.NoError(t, err) + + settings := make(map[string]any) + require.NoError(t, yaml.Unmarshal(settingsBytes, &settings)) + + providers := settings["llm-pi-ai"].(map[string]any)["providers"].(map[string]any) + provider := providers["requesty"].(map[string]any) + assert.Equal(t, []any{ + map[string]any{"id": "deepseek/deepseek-v4-pro-0813"}, + map[string]any{ + "id": "anthropic/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "input": []any{"text", "image"}, + }, + map[string]any{"id": "openai/gpt-5.4"}, + }, provider["models"]) + + // A second run with an already listed model leaves the list unchanged. + require.NoError(t, harness.Configure(ConfigureOptions{ + Model: "openai/gpt-5.4", + })) + + settingsBytes, err = os.ReadFile(settingsPath) + require.NoError(t, err) + + settings = make(map[string]any) + require.NoError(t, yaml.Unmarshal(settingsBytes, &settings)) + + providers = settings["llm-pi-ai"].(map[string]any)["providers"].(map[string]any) + provider = providers["requesty"].(map[string]any) + assert.Len(t, provider["models"], 3) +} + func TestDeepSeekHarnessDefaultConfigDir(t *testing.T) { homePath, err := os.UserHomeDir() require.NoError(t, err)