diff --git a/README.md b/README.md index 3d948cb..ddc6d59 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ English README: `README_EN.md` ## 当前能力 - 本地维护 `ocswitch` 配置文件:上游 provider、alias、监听地址 -- 支持手动添加 provider +- 支持手动添加 provider,并自动发现其 `/v1/models` 模型列表 - 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider - 支持创建 alias,并按顺序绑定多个上游 target - 支持把 alias 同步到 OpenCode 的 `provider.ocswitch.models` @@ -58,8 +58,7 @@ go run ./cmd/ocswitch --help ### 1. 添加上游 provider -`ocswitch` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。 - +`ocswitch` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。默认会自动调用上游 `/v1/models` 拉取模型列表并缓存到本地配置,后续绑定 alias 时可用于校验模型名、减少手写 typo;如果发现失败,只会输出 warning,不会阻止连接信息保存。如果某些 provider 不开放该接口,可以显式加 `--skip-models` 仅保存连接信息。 ```bash ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy @@ -76,6 +75,12 @@ ocswitch provider add \ --header "X-Workspace=my-team" ``` +如果你之后想把这些额外 header 全部清空,可以显式传: + +```bash +ocswitch provider add --id relay --base-url https://example.com/v1 --clear-headers --skip-models +``` + 查看当前 provider: ```bash @@ -84,14 +89,13 @@ ocswitch provider list ### 2. 创建 alias,并绑定多个上游 target -下面这个例子表示:当你使用 `ocswitch/gpt-5.4` 时,优先走 `su8/gpt-5.4`,失败后再走 `codex/GPT-5.4`。 +下面这个例子表示:当你使用 `ocswitch/gpt-5.4` 时,优先走 `su8/gpt-5.4`,失败后再走 `codex/GPT-5.4`。推荐在未传 `--provider` 时直接把上游 target 写成 `Provider/Model`;旧的 `--provider` + `--model` 写法仍然保留作为兼容兜底,而且 `model` 本身即使包含 `/` 也不会被强行改判。 ```bash ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4" -ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 -ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 +ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4 +ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4 ``` - 查看当前 alias: ```bash @@ -208,9 +212,9 @@ curl -sN -X POST http://127.0.0.1:9982/v1/responses \ -d '{"model":"gpt-5.4","stream":true,"input":"hello"}' ``` -注意这里请求体里的 `model` 是 alias 本身,例如 `gpt-5.4`,不是 `ocswitch/gpt-5.4`。 +注意这里请求体里的 `model` 可以直接写 alias 本身,例如 `gpt-5.4`;也兼容 `ocswitch/gpt-5.4` 这种带前缀的写法。 -因为 `ocswitch/gpt-5.4` 是 OpenCode 侧的模型选择写法;真正发到本地 provider 的请求里,模型名会是 alias 自身。 +因为 `ocswitch/gpt-5.4` 是 OpenCode 侧常见的模型选择写法;真正路由到本地时,工具会统一解析成 alias 自身。 ## 从现有 OpenCode 配置导入 provider @@ -230,14 +234,18 @@ ocswitch provider import-opencode --from ./examples/opencode.jsonc - `npm: @ai-sdk/openai` - 有 `options.baseURL` -- `options.apiKey` 可以为空;导入后由 `ocswitch doctor` / `serve` 前校验帮助你发现风险 +- `options.apiKey` 可以为空;当前静态校验不会单独拦截空上游 API Key,这类问题通常会在真实请求上游时暴露 注意: - 这不是完整迁移工具 - 默认导入源也只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR` - 如果你要导入别的 OpenCode 配置文件,请显式传 `--from` -- 当前只导入 provider 的基本连接信息 -- 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `ocswitch provider add --header ...` 补齐 +- 导入时仍要求 `baseURL` 满足 `/v1` 约束;不合法的 provider 会被跳过 +- 如果源 OpenCode 配置已经声明 `models`,导入时会一并保留,但这类导入值默认只作为展示/迁移信息,不会像主动发现到的 catalog 那样做硬校验 +- 如果你修改了 provider 的连接信息,但本次 discovery 被跳过、失败或返回空列表,旧 catalog 会降级成“不可信元数据”:仍保留在配置里,但不会继续用于强校验 +- 如果你的 provider 不开放 `/v1/models`,后续可用 `ocswitch provider add --skip-models ...` 仅保存连接信息 +- `--overwrite` 会更新导入到的基本连接信息,但仍保留本地的 disabled 状态、额外 header,以及仍然可信的 discovered catalog +- 如果你的旧配置依赖额外自定义 header,首次导入后仍可能需要自己用 `ocswitch provider add --header ...` 补齐 - `ocswitch` 自己不会被反向导入 覆盖已存在的 provider: @@ -254,8 +262,14 @@ ocswitch provider import-opencode --overwrite ```bash ocswitch provider add --id --base-url --api-key +ocswitch provider add --id --base-url --api-key "" +ocswitch provider add --id --base-url --clear-headers +ocswitch provider add --id --base-url --skip-models ``` +如果你需要把已保存的上游 API key 清空,显式传 `--api-key ""` 即可。 +如果你需要把已保存的额外 header 清空,显式传 `--clear-headers` 即可。 + 查看 provider: ```bash @@ -293,12 +307,14 @@ ocswitch alias add --name 给 alias 追加一个 target: ```bash +ocswitch alias bind --alias --model / ocswitch alias bind --alias --provider --model ``` 解绑 target: ```bash +ocswitch alias unbind --alias --model / ocswitch alias unbind --alias --provider --model ``` @@ -436,7 +452,7 @@ ocswitch --config /path/to/config.json doctor ## 调试响应头 -每次成功代理或透传上游错误时,响应里都会附带这些头: +当请求已经选定某个上游并开始向客户端返回该次尝试的结果时,响应里会附带这些头: - `X-OCSWITCH-Alias` - `X-OCSWITCH-Provider` @@ -491,6 +507,7 @@ ocswitch --config /path/to/config.json doctor 因为 alias 里的 target 还是旧引用。需要继续执行: ```bash +ocswitch alias unbind --alias --model / ocswitch alias unbind --alias --provider --model ``` diff --git a/README_EN.md b/README_EN.md index 9bc5c24..9350eee 100644 --- a/README_EN.md +++ b/README_EN.md @@ -24,14 +24,14 @@ go build -o ocswitch ./cmd/ocswitch ## Quick start ```bash -# 1. add upstream providers +# 1. add upstream providers (models are discovered from /v1/models by default; warnings do not block saving) ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-... ocswitch provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-... -# 2. create alias and bind targets in priority order +# 2. create alias and bind targets in priority order (preferred Provider/Model form) ocswitch alias add --name gpt-5.4 -ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 -ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 +ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4 +ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4 # 3. push alias exposure into OpenCode global config ocswitch opencode sync @@ -42,7 +42,6 @@ ocswitch provider disable su8 # 4. run the proxy ocswitch serve ``` - Inside OpenCode you can now pick `ocswitch/gpt-5.4`. ### Import providers from an existing OpenCode config @@ -58,7 +57,15 @@ different file. Only `@ai-sdk/openai` custom providers with a `baseURL` are imported. An empty `apiKey` is allowed and kept as-is so you can complete credentials later. -### Doctor (static) +Imported provider model lists are preserved when the source config already +declares `models`, and `provider add` will otherwise refresh them from +`/v1/models` by default. Imported lists are kept for migration context, while +hard typo validation only uses catalogs actively discovered from `/v1/models`. +If connection details change but discovery is skipped, fails, or returns an +empty list, any old catalog is retained only as untrusted metadata and no longer +used for strict validation. + +### Validate before serving ```bash ocswitch doctor @@ -100,11 +107,18 @@ is the authoritative local execution contract. - `ocswitch alias {add,list,bind,unbind,remove}` - `ocswitch opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]` -Global flag: `--config PATH` (default `$OCSWITCH_CONFIG`, else `$XDG_CONFIG_HOME/ocswitch/config.json`). +Preferred bind form: `ocswitch alias bind --alias --model /` when `--provider` is omitted. +The legacy `--provider --model ` form is still accepted as a fallback, including models whose names already contain `/`. +The same combined form also works for `alias unbind`. + +To clear a previously saved upstream API key, pass `--api-key ""` explicitly on `provider add`. +To clear previously saved extra provider headers, pass `--clear-headers` on `provider add`. + +Global flag: `--config PATH` (default `$OCSWITCH_CONFIG`, else `$XDG_CONFIG_HOME/ocswitch/config.json`, else `~/.config/ocswitch/config.json`). ## Debug headers -Every proxied response includes: +Responses include these debug headers once a concrete upstream attempt is being returned to the client: - `X-OCSWITCH-Alias` - `X-OCSWITCH-Provider` @@ -115,7 +129,6 @@ Every proxied response includes: ## Scope Out of MVP: Anthropic native, multi-protocol routing, dashboard, billing, -latency-based routing, full `/v1/models` provider discovery, full OpenCode -config takeover. +latency-based routing, and full OpenCode config takeover. See `.trellis/tasks/archive/2026-04/04-17-04-17-ops-mvp-design-review/prd.md` for the authoritative design notes. diff --git a/internal/cli/alias.go b/internal/cli/alias.go index 15d5477..57eada2 100644 --- a/internal/cli/alias.go +++ b/internal/cli/alias.go @@ -24,9 +24,9 @@ response bytes are sent downstream. Common workflow: create an alias, bind primary and fallback targets, inspect the result with alias list, then run doctor and opencode sync.`, Example: ` ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4" - ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 - ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 - ocswitch alias list`, + ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4 + ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4 + ocswitch alias list`, } c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd()) return c @@ -146,27 +146,42 @@ func newAliasBindCmd() *cobra.Command { chain in local ocswitch config. The provider must already exist. If the alias does not exist yet, this command -auto-creates an enabled alias for convenience. Binding does not test upstream -health or credentials. +auto-creates an enabled alias for convenience. You can pass the target either as +--provider --model or in the more natural combined form --model +/ when --provider is omitted; the combined form is recommended +and the explicit --provider flag remains as fallback compatibility. If the +provider has a stored model catalog discovered from /v1/models, bind validates +the model name against that discovered list. Order matters: the first bound target is tried first, the second is fallback, and so on. Typical next step: inspect with alias list, then run doctor.`, - Example: ` ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 - ocswitch alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 + Example: ` ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4 + ocswitch alias bind --alias gpt-5.4 --model codex/GPT-5.4 ocswitch alias bind --alias gpt-5.4 --provider relay --model gpt-5.4 --disabled`, RunE: func(cmd *cobra.Command, args []string) error { - if alias == "" || provider == "" || model == "" { - return fmt.Errorf("--alias, --provider and --model are required") + if alias == "" || model == "" { + return fmt.Errorf("--alias and --model are required") + } + combinedProvider, combinedModel, combined := parseProviderModelRef(model) + if provider == "" { + if !combined { + return fmt.Errorf("--model must use / when --provider is omitted") + } + provider = combinedProvider + model = combinedModel } cfg, err := loadCfg() if err != nil { return err } - if cfg.FindProvider(provider) == nil { + p := cfg.FindProvider(provider) + if p == nil { return fmt.Errorf("provider %q does not exist; add it first", provider) } + if err := validateProviderModelKnown(provider, p.Models, p.ModelsSource, model); err != nil { + return err + } if cfg.FindAlias(alias) == nil { - // auto-create enabled alias for ergonomics cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true}) } if err := cfg.AddTarget(alias, config.Target{Provider: provider, Model: model, Enabled: !disabled}); err != nil { @@ -180,12 +195,11 @@ and so on. Typical next step: inspect with alias list, then run doctor.`, }, } cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)") - cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (required)") - cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)") + cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (fallback; prefer --model provider/model)") + cmd.Flags().StringVar(&model, "model", "", "upstream target model, or provider/model when --provider is omitted (required)") cmd.Flags().BoolVar(&disabled, "disabled", false, "add target in disabled state") return cmd } - func newAliasUnbindCmd() *cobra.Command { var alias, provider, model string cmd := &cobra.Command{ @@ -198,13 +212,26 @@ It does not delete the alias itself. Removing a target can leave the alias with no routable targets, which doctor and opencode sync will then treat as invalid or unavailable. +You can identify the target either as --provider --model or in the +recommended combined form --model / when --provider is omitted. +The explicit --provider flag remains available as a compatibility fallback. + Typical next step: run alias list or doctor to confirm the remaining target chain.`, - Example: ` ocswitch alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4 + Example: ` ocswitch alias unbind --alias gpt-5.4 --model codex/GPT-5.4 + ocswitch alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4 ocswitch doctor`, RunE: func(cmd *cobra.Command, args []string) error { - if alias == "" || provider == "" || model == "" { - return fmt.Errorf("--alias, --provider and --model are required") + if alias == "" || model == "" { + return fmt.Errorf("--alias and --model are required") + } + combinedProvider, combinedModel, combined := parseProviderModelRef(model) + if provider == "" { + if !combined { + return fmt.Errorf("--model must use / when --provider is omitted") + } + provider = combinedProvider + model = combinedModel } cfg, err := loadCfg() if err != nil { @@ -221,8 +248,8 @@ chain.`, }, } cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)") - cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (required)") - cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)") + cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (fallback; prefer --model provider/model)") + cmd.Flags().StringVar(&model, "model", "", "upstream target model, or provider/model when --provider is omitted (required)") return cmd } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 997cbc2..2d1aa05 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,8 @@ package cli import ( "bytes" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -33,7 +35,7 @@ func TestProviderAddPreservesExistingFields(t *testing.T) { } cmd := newProviderAddCmd() - cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://new.example.com/v1"}) + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://new.example.com/v1", "--skip-models"}) if err := cmd.Execute(); err != nil { t.Fatalf("execute provider add: %v", err) } @@ -82,6 +84,575 @@ func TestProviderAddRejectsInvalidBaseURL(t *testing.T) { } } +func TestProviderAddDiscoverySuccessStoresCatalog(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/v1/models" { + t.Fatalf("path = %s, want /v1/models", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1"},{"id":"gpt-4o"}]}`)) + })) + defer srv.Close() + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", srv.URL + "/v1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if got := strings.Join(p.Models, ","); got != "gpt-4.1,gpt-4o" { + t.Fatalf("Models = %q", got) + } + if p.ModelsSource != "discovered" { + t.Fatalf("ModelsSource = %q, want discovered", p.ModelsSource) + } +} + +func TestProviderAddDiscoveryFailureStillSavesProvider(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + })) + defer srv.Close() + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", srv.URL + "/v1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + if !strings.Contains(stderr.String(), "warning: could not discover provider models") { + t.Fatalf("stderr = %q", stderr.String()) + } + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if p.BaseURL != srv.URL+"/v1" { + t.Fatalf("BaseURL = %q", p.BaseURL) + } +} + +func TestProviderAddDiscoveryFailureMarksCatalogUntrustedAfterConnectionChange(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://old.example.com/v1", + Models: []string{"old-model"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + })) + defer srv.Close() + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", srv.URL + "/v1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if got := strings.Join(p.Models, ","); got != "old-model" { + t.Fatalf("Models = %q, want old-model preserved", got) + } + if p.ModelsSource != "" { + t.Fatalf("ModelsSource = %q, want empty to disable strict validation", p.ModelsSource) + } + if !strings.Contains(stderr.String(), "keeping existing model catalog as untrusted") { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestProviderAddSkipModelsMarksCatalogUntrustedAfterConnectionChange(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://old.example.com/v1", + Models: []string{"old-model"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://new.example.com/v1", "--skip-models"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if got := strings.Join(p.Models, ","); got != "old-model" { + t.Fatalf("Models = %q, want old-model preserved", got) + } + if p.ModelsSource != "" { + t.Fatalf("ModelsSource = %q, want empty to disable strict validation", p.ModelsSource) + } + if !strings.Contains(stderr.String(), "connection changed with --skip-models") || !strings.Contains(stderr.String(), "untrusted") { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestProviderAddSkipModelsKeepsDiscoveredCatalogWhenConnectionEquivalent(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: " https://example.com/v1/ ", + Models: []string{"old-model"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/v1", "--skip-models"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + if strings.Contains(stderr.String(), "untrusted") { + t.Fatalf("stderr = %q", stderr.String()) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if got := strings.Join(p.Models, ","); got != "old-model" { + t.Fatalf("Models = %q", got) + } + if p.ModelsSource != "discovered" { + t.Fatalf("ModelsSource = %q, want discovered", p.ModelsSource) + } + if p.BaseURL != "https://example.com/v1" { + t.Fatalf("BaseURL = %q, want normalized", p.BaseURL) + } +} + +func TestProviderAddSkipModelsKeepsDiscoveredCatalogWhenHeadersEquivalent(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + Headers: nil, + Models: []string{"old-model"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/v1", "--skip-models"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + if strings.Contains(stderr.String(), "untrusted") { + t.Fatalf("stderr = %q", stderr.String()) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if p.ModelsSource != "discovered" { + t.Fatalf("ModelsSource = %q, want discovered", p.ModelsSource) + } +} + +func TestProviderAddDiscoveryEmptyKeepsDiscoveredCatalogWhenConnectionUnchanged(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: srv.URL + "/v1", + Models: []string{"gpt-4.1"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", srv.URL + "/v1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + if !strings.Contains(stderr.String(), "keeping existing model catalog") || strings.Contains(stderr.String(), "untrusted") { + t.Fatalf("stderr = %q", stderr.String()) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if got := strings.Join(p.Models, ","); got != "gpt-4.1" { + t.Fatalf("Models = %q", got) + } + if p.ModelsSource != "discovered" { + t.Fatalf("ModelsSource = %q, want discovered", p.ModelsSource) + } +} + +func TestProviderAddDiscoveryEmptyMarksCatalogUntrustedAfterConnectionChange(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://old.example.com/v1", + Models: []string{"gpt-4.1"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", srv.URL + "/v1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + if !strings.Contains(stderr.String(), "keeping existing model catalog") { + t.Fatalf("stderr = %q", stderr.String()) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if got := strings.Join(p.Models, ","); got != "gpt-4.1" { + t.Fatalf("Models = %q", got) + } + if p.ModelsSource != "" { + t.Fatalf("ModelsSource = %q, want empty to disable strict validation after connection change", p.ModelsSource) + } +} + +func TestProviderAddRejectsEmptyHeaderName(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + Headers: map[string]string{"X-Test": "1"}, + Models: []string{"known-model"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/v1", "--header", "=x"}) + err = cmd.Execute() + if err == nil { + t.Fatal("expected invalid --header error") + } + if got := err.Error(); got != `invalid --header "=x" (header name must not be empty)` { + t.Fatalf("error = %q", got) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil || p.Headers["X-Test"] != "1" || p.ModelsSource != "discovered" { + t.Fatalf("provider after failed header parse = %#v", p) + } +} + +func TestProviderAddLastHeaderWinsCaseInsensitive(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Token"); got != "b" { + t.Fatalf("X-Token = %q, want b", got) + } + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1"}]}`)) + })) + defer srv.Close() + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", srv.URL + "/v1", "--header", "X-Token=a", "--header", "x-token=b"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if got := p.Headers["x-token"]; got != "b" { + t.Fatalf("Headers = %#v, want lower-cased x-token=b", p.Headers) + } + if len(p.Headers) != 1 { + t.Fatalf("Headers = %#v, want single merged header", p.Headers) + } +} + +func TestProviderAddAllowsClearingAPIKey(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ID: "p1", BaseURL: "https://example.com/v1", APIKey: "sk-old"}) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/v1", "--api-key", "", "--skip-models"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if p.APIKey != "" { + t.Fatalf("APIKey = %q, want cleared empty string", p.APIKey) + } +} + +func TestProviderAddAllowsClearingHeaders(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + Headers: map[string]string{"x-token": "abc", "x-workspace": "team"}, + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/v1", "--clear-headers", "--skip-models"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if p.Headers != nil { + t.Fatalf("Headers = %#v, want nil after --clear-headers", p.Headers) + } +} + +func TestProviderAddAllowsExplicitEnableViaDisabledFalse(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ID: "p1", BaseURL: "https://example.com/v1", Disabled: true}) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/v1", "--disabled=false", "--skip-models"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if p.Disabled { + t.Fatal("Disabled = true, want explicit false applied") + } +} + +func TestProviderAddSkipModelsKeepsDiscoveredCatalogWhenHeaderCaseChangesOnly(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + Headers: map[string]string{"X-Test": "1"}, + Models: []string{"old-model"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/v1", "--header", "x-test=1", "--skip-models"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + if strings.Contains(stderr.String(), "untrusted") { + t.Fatalf("stderr = %q", stderr.String()) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil || p.ModelsSource != "discovered" { + t.Fatalf("provider after update = %#v", p) + } +} + func TestAliasAddPreservesExistingFields(t *testing.T) { t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) configPath = "" @@ -281,17 +852,23 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) { wantLong: []string{"Typical workflow:", "prefer command-local --help over README summaries"}, wantExample: []string{"ocswitch provider add", "ocswitch serve"}, }, + { + name: "alias root", + cmd: newAliasCmd(), + wantLong: []string{"ordered target chain", "Common workflow:"}, + wantExample: []string{"--model su8/gpt-5.4", "--model codex/GPT-5.4"}, + }, { name: "provider add", cmd: newProviderAddCmd(), - wantLong: []string{"--base-url must point at an", "omitted mutable fields keep their current", "values: name, api key, headers"}, - wantExample: []string{"ocswitch provider add --id relay", "--header X-Workspace=my-team"}, + wantLong: []string{"/v1/models endpoint", "stores the discovered", "Use --clear-headers", "Use\n--skip-models"}, + wantExample: []string{"--skip-models", "--header X-Workspace=my-team"}, }, { name: "alias bind", cmd: newAliasBindCmd(), - wantLong: []string{"auto-creates an enabled alias", "Order matters:"}, - wantExample: []string{"--provider codex --model GPT-5.4", "--disabled"}, + wantLong: []string{"combined form is recommended", "stored model catalog", "Order matters:"}, + wantExample: []string{"--model codex/GPT-5.4", "--disabled"}, }, { name: "opencode sync", @@ -321,7 +898,6 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) { } }) } - root := NewRootCmd("test") flag := root.PersistentFlags().Lookup("config") if flag == nil { @@ -333,3 +909,307 @@ func TestHelpTextIncludesOperationalGuidance(t *testing.T) { } } } + +func TestAliasBindAcceptsSlashModelWithExplicitProvider(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ID: "relay", BaseURL: "https://example.com/v1", Models: []string{"openrouter/google/gemini-2.5-pro"}, ModelsSource: "discovered"}) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newAliasBindCmd() + cmd.SetArgs([]string{"--alias", "gemini", "--provider", "relay", "--model", "openrouter/google/gemini-2.5-pro"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute alias bind: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + a := cfg.FindAlias("gemini") + if a == nil || len(a.Targets) != 1 || a.Targets[0].Model != "openrouter/google/gemini-2.5-pro" { + t.Fatalf("alias targets = %#v", a) + } +} + +func TestAliasUnbindAcceptsSlashModelWithExplicitProvider(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ID: "relay", BaseURL: "https://example.com/v1"}) + cfg.UpsertAlias(config.Alias{Alias: "gemini", Enabled: true, Targets: []config.Target{{Provider: "relay", Model: "openrouter/google/gemini-2.5-pro", Enabled: true}}}) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newAliasUnbindCmd() + cmd.SetArgs([]string{"--alias", "gemini", "--provider", "relay", "--model", "openrouter/google/gemini-2.5-pro"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute alias unbind: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + a := cfg.FindAlias("gemini") + if a == nil || len(a.Targets) != 0 { + t.Fatalf("alias targets = %#v", a) + } +} + +func TestImportedModelsDoNotBlockAliasBind(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + Models: []string{"subset-only"}, + ModelsSource: "imported", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newAliasBindCmd() + cmd.SetArgs([]string{"--alias", "gpt", "--provider", "p1", "--model", "different-real-model"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute alias bind: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + a := cfg.FindAlias("gpt") + if a == nil || len(a.Targets) != 1 || a.Targets[0].Model != "different-real-model" { + t.Fatalf("alias targets = %#v", a) + } +} + +func TestProviderImportOpencodeSkipsInvalidBaseURL(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + src := filepath.Join(t.TempDir(), "opencode.json") + data := `{ + "provider": { + "bad": { + "npm": "@ai-sdk/openai", + "options": {"baseURL": "https://example.com/api", "apiKey": "sk-test"} + } + } + }` + if err := os.WriteFile(src, []byte(data), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + + cmd := newProviderImportCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--from", src}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider import-opencode: %v", err) + } + if !strings.Contains(stdout.String(), `skip "bad" (invalid baseURL`) { + t.Fatalf("stdout = %q", stdout.String()) + } + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + if cfg.FindProvider("bad") != nil { + t.Fatal("invalid imported provider should not be saved") + } +} + +func TestProviderImportOpencodeMissingFromFileReturnsError(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + missing := filepath.Join(t.TempDir(), "missing.jsonc") + cmd := newProviderImportCmd() + cmd.SetArgs([]string{"--from", missing}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected missing --from file error") + } + if !strings.Contains(err.Error(), missing) || !strings.Contains(err.Error(), "no such file or directory") { + t.Fatalf("error = %q", err.Error()) + } +} + +func TestProviderImportOpencodeOverwritePreservesLocalStateAndDemotesCatalogAfterAPIKeyChange(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + Name: "Local Name", + BaseURL: "https://old.example.com/v1", + APIKey: "sk-old", + Headers: map[string]string{"X-Test": "1"}, + Models: []string{"discovered-model"}, + ModelsSource: "discovered", + Disabled: true, + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + src := filepath.Join(t.TempDir(), "opencode.json") + data := `{ + "provider": { + "p1": { + "npm": "@ai-sdk/openai", + "name": "Imported Name", + "options": {"baseURL": "https://old.example.com/v1", "apiKey": "sk-new"}, + "models": {"imported-model": {}} + } + } + }` + if err := os.WriteFile(src, []byte(data), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + + cmd := newProviderImportCmd() + cmd.SetArgs([]string{"--from", src, "--overwrite"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider import-opencode: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if !p.Disabled { + t.Fatal("Disabled = false, want preserved true") + } + if p.Headers["X-Test"] != "1" { + t.Fatalf("Headers = %#v, want preserved local headers", p.Headers) + } + if p.Name != "Imported Name" { + t.Fatalf("Name = %q, want imported name", p.Name) + } + if p.APIKey != "sk-new" { + t.Fatalf("APIKey = %q, want imported value", p.APIKey) + } + if got := strings.Join(p.Models, ","); got != "imported-model" { + t.Fatalf("Models = %q, want imported catalog after API key change", got) + } + if p.ModelsSource != "imported" { + t.Fatalf("ModelsSource = %q, want imported after API key change", p.ModelsSource) + } +} + +func TestProviderImportOpencodeOverwriteClearsRemovedImportedModels(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + Models: []string{"old-imported-model"}, + ModelsSource: "imported", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + src := filepath.Join(t.TempDir(), "opencode.json") + data := `{ + "provider": { + "p1": { + "npm": "@ai-sdk/openai", + "options": {"baseURL": "https://example.com/v1", "apiKey": "sk-test"} + } + } + }` + if err := os.WriteFile(src, []byte(data), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + + cmd := newProviderImportCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--from", src, "--overwrite"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider import-opencode: %v", err) + } + if !strings.Contains(stdout.String(), `import "p1" → https://example.com/v1 (models: )`) { + t.Fatalf("stdout = %q", stdout.String()) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if len(p.Models) != 0 { + t.Fatalf("Models = %#v, want empty after imported models removed", p.Models) + } + if p.ModelsSource != "" { + t.Fatalf("ModelsSource = %q, want empty when imported models removed", p.ModelsSource) + } +} + +func TestDiscoveredModelsStillBlockUnknownAliasBind(t *testing.T) { + t.Setenv(config.ConfigEnvVar, filepath.Join(t.TempDir(), "ocswitch.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + Models: []string{"known-model"}, + ModelsSource: "discovered", + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newAliasBindCmd() + cmd.SetArgs([]string{"--alias", "gpt", "--provider", "p1", "--model", "unknown-model"}) + err = cmd.Execute() + if err == nil { + t.Fatal("expected alias bind error") + } + if !strings.Contains(err.Error(), `model "unknown-model" is not in provider "p1" discovered models`) { + t.Fatalf("error = %q", err.Error()) + } +} diff --git a/internal/cli/provider.go b/internal/cli/provider.go index 1b70a2f..174b411 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -2,6 +2,8 @@ package cli import ( "fmt" + "os" + "reflect" "sort" "strings" @@ -42,7 +44,9 @@ then bind them to aliases with ocswitch alias bind.`, func newProviderAddCmd() *cobra.Command { var id, name, baseURL, apiKey string var headers []string + var clearHeaders bool var disabled bool + var skipModels bool cmd := &cobra.Command{ Use: "add", Short: "Add or update an upstream provider", @@ -50,18 +54,27 @@ func newProviderAddCmd() *cobra.Command { config. It writes only the ocswitch config file. --base-url must point at an -OpenAI-compatible /v1 root. The command validates that shape, but it does not -contact the upstream or test credentials. +OpenAI-compatible /v1 root. By default the command also calls the upstream +/v1/models endpoint with the supplied credentials and stores the discovered +model list so later bind operations can catch typos early. Discovery failures +only emit warnings and do not block saving connection settings. Use +--skip-models when the upstream blocks model discovery or you only want to save +connection settings. When updating an existing provider, omitted mutable fields keep their current -values: name, api key, headers, and disabled state are preserved unless you -explicitly pass new values. Repeated --header KEY=VALUE entries replace the +values: name, api key, headers, and disabled state are preserved unless the +corresponding flag is explicitly passed. Use --clear-headers to remove all saved +extra headers before storing the updated provider. Discovered model catalogs are refreshed when +possible. If connection details changed but discovery was skipped or failed, any +existing model catalog is kept only as untrusted metadata so later validation no +longer relies on stale entries. Repeated --header KEY=VALUE entries replace the stored header map for this command invocation. Typical next step: run ocswitch provider list or bind the provider to an alias.`, Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example ocswitch provider add --id relay --base-url https://example.com/v1 --api-key sk-example --header X-Token=abc --header X-Workspace=my-team + ocswitch provider add --id relay --base-url https://example.com/v1 --skip-models ocswitch provider add --id su8 --base-url https://new.example.com/v1`, RunE: func(cmd *cobra.Command, args []string) error { if id == "" || baseURL == "" { @@ -74,35 +87,76 @@ Typical next step: run ocswitch provider list or bind the provider to an alias.` if err != nil { return err } - hdrs := map[string]string{} + apiKeyChanged := cmd.Flags().Changed("api-key") + headersChanged := cmd.Flags().Changed("header") + clearHeadersRequested := cmd.Flags().Changed("clear-headers") && clearHeaders + disabledChanged := cmd.Flags().Changed("disabled") + var hdrs map[string]string for _, h := range headers { k, v, ok := strings.Cut(h, "=") if !ok { return fmt.Errorf("invalid --header %q (want KEY=VALUE)", h) } - hdrs[strings.TrimSpace(k)] = strings.TrimSpace(v) + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + return fmt.Errorf("invalid --header %q (header name must not be empty)", h) + } + if hdrs == nil { + hdrs = make(map[string]string, len(headers)) + } + hdrs[key] = strings.TrimSpace(v) } p := config.Provider{ ID: id, Name: name, - BaseURL: baseURL, + BaseURL: config.NormalizeProviderBaseURL(baseURL), APIKey: apiKey, - Headers: hdrs, + Headers: normalizeProviderHeaders(hdrs), Disabled: disabled, } + connectionChanged := false if existing := cfg.FindProvider(id); existing != nil { if p.Name == "" { p.Name = existing.Name } - if p.APIKey == "" { + if !apiKeyChanged { p.APIKey = existing.APIKey } - if len(headers) == 0 && len(existing.Headers) > 0 { + if !headersChanged && !clearHeadersRequested && len(existing.Headers) > 0 { p.Headers = cloneHeaders(existing.Headers) } - if !disabled { + if !disabledChanged { p.Disabled = existing.Disabled } + p.Models = append([]string(nil), existing.Models...) + p.ModelsSource = existing.ModelsSource + connectionChanged = !providerConnectionEqual(*existing, p) + } + if !skipModels { + models, err := opencode.FetchProviderModels(p.BaseURL, p.APIKey, p.Headers) + if err != nil { + if connectionChanged { + p.Models = append([]string(nil), p.Models...) + p.ModelsSource = "" + fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed and model discovery failed; keeping existing model catalog as untrusted") + } + fmt.Fprintf(cmd.ErrOrStderr(), "warning: could not discover provider models: %v\n", err) + } else if normalized := config.NormalizeProviderModels(models); len(normalized) > 0 { + p.Models = normalized + p.ModelsSource = "discovered" + } else { + if connectionChanged { + p.Models = append([]string(nil), p.Models...) + p.ModelsSource = "" + fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed and model discovery returned no models; keeping existing model catalog as untrusted") + } else { + fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider model discovery returned no models; keeping existing model catalog") + } + } + } else if connectionChanged { + p.Models = append([]string(nil), p.Models...) + p.ModelsSource = "" + fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed with --skip-models; keeping existing model catalog as untrusted") } cfg.UpsertProvider(p) if err := cfg.Save(); err != nil { @@ -113,6 +167,9 @@ Typical next step: run ocswitch provider list or bind the provider to an alias.` state = "disabled" } fmt.Fprintf(cmd.OutOrStdout(), "saved provider %q [%s] → %s\n", id, state, baseURL) + if !skipModels && p.ModelsSource == "discovered" { + fmt.Fprintf(cmd.OutOrStdout(), " discovered %d model(s)\n", len(p.Models)) + } return nil }, } @@ -121,10 +178,14 @@ Typical next step: run ocswitch provider list or bind the provider to an alias.` cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)") cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key") cmd.Flags().StringArrayVar(&headers, "header", nil, "extra header KEY=VALUE (repeatable)") + cmd.Flags().BoolVar(&clearHeaders, "clear-headers", false, "remove all saved extra headers before applying updates") cmd.Flags().BoolVar(&disabled, "disabled", false, "save provider in disabled state") + cmd.Flags().BoolVar(&skipModels, "skip-models", false, "skip provider /v1/models discovery") + if err := cmd.Flags().SetAnnotation("skip-models", cobra.BashCompOneRequiredFlag, []string{"false"}); err != nil { + panic(err) + } return cmd } - func cloneHeaders(in map[string]string) map[string]string { if len(in) == 0 { return nil @@ -271,20 +332,26 @@ aware). It does not follow OPENCODE_CONFIG_DIR for this default source; use --from when you want a different file. Only config-defined @ai-sdk/openai custom providers with baseURL are imported. -Providers with an empty apiKey are allowed and kept as-is. Unsupported provider -shapes are skipped by design. Existing ocswitch providers are skipped unless ---overwrite is given. +Imported baseURL values must still satisfy the local /v1 requirement. Providers +with an empty apiKey are allowed and kept as-is. Unsupported provider shapes are +skipped by design. Existing ocswitch providers are skipped unless --overwrite is +given. Typical next step: run ocswitch provider list, then create aliases and bindings.`, Example: ` ocswitch provider import-opencode ocswitch provider import-opencode --from /path/to/opencode.jsonc ocswitch provider import-opencode --overwrite`, RunE: func(cmd *cobra.Command, args []string) error { + fromChanged := cmd.Flags().Changed("from") if srcPath == "" { p, existed := opencode.ResolveGlobalConfigPath() if !existed { return fmt.Errorf("no OpenCode config found at %s; use --from to specify", p) } srcPath = p + } else if fromChanged { + if _, err := os.Stat(srcPath); err != nil { + return fmt.Errorf("read %s: %w", srcPath, err) + } } raw, err := opencode.Load(srcPath) if err != nil { @@ -307,15 +374,23 @@ Typical next step: run ocswitch provider list, then create aliases and bindings. fmt.Fprintf(cmd.OutOrStdout(), "skip %q (already exists, use --overwrite)\n", ip.ID) continue } - cfg.UpsertProvider(config.Provider{ - ID: ip.ID, - Name: ip.Name, - BaseURL: ip.BaseURL, - APIKey: ip.APIKey, - Disabled: false, + baseURL := config.NormalizeProviderBaseURL(ip.BaseURL) + if err := config.ValidateProviderBaseURL(baseURL); err != nil { + skipped++ + fmt.Fprintf(cmd.OutOrStdout(), "skip %q (invalid baseURL %q: %v)\n", ip.ID, ip.BaseURL, err) + continue + } + existing := cfg.FindProvider(ip.ID) + merged := mergeImportedProvider(existing, opencode.ImportableProvider{ + ID: ip.ID, + Name: ip.Name, + BaseURL: baseURL, + APIKey: ip.APIKey, + Models: ip.Models, }) + cfg.UpsertProvider(merged) imported++ - fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, ip.BaseURL, strings.Join(ip.Models, ",")) + fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, baseURL, strings.Join(merged.Models, ",")) } if imported > 0 { if err := cfg.Save(); err != nil { @@ -331,6 +406,79 @@ Typical next step: run ocswitch provider list, then create aliases and bindings. return cmd } +func normalizeProviderHeaders(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + continue + } + out[key] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil + } + return out +} + +func providerConnectionEqual(a, b config.Provider) bool { + return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) && + a.APIKey == b.APIKey && + reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers)) +} + +func providerCatalogEndpointEqual(a, b config.Provider) bool { + return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) && + a.APIKey == b.APIKey && + reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers)) +} + +func mergeImportedProvider(existing *config.Provider, ip opencode.ImportableProvider) config.Provider { + importedModels := config.NormalizeProviderModels(ip.Models) + merged := config.Provider{ + ID: ip.ID, + Name: ip.Name, + BaseURL: config.NormalizeProviderBaseURL(ip.BaseURL), + APIKey: ip.APIKey, + Models: importedModels, + ModelsSource: "imported", + } + if len(importedModels) == 0 { + merged.ModelsSource = "" + } + if existing == nil { + return merged + } + merged.Headers = cloneHeaders(existing.Headers) + merged.Disabled = existing.Disabled + if merged.Name == "" { + merged.Name = existing.Name + } + if existing.ModelsSource == "discovered" { + prospective := merged + prospective.Headers = cloneHeaders(existing.Headers) + prospective.Disabled = existing.Disabled + if providerCatalogEndpointEqual(*existing, prospective) { + merged.Models = append([]string(nil), existing.Models...) + merged.ModelsSource = existing.ModelsSource + return merged + } + if len(importedModels) == 0 { + merged.Models = append([]string(nil), existing.Models...) + merged.ModelsSource = "" + return merged + } + } + if len(importedModels) == 0 { + merged.Models = nil + merged.ModelsSource = "" + } + return merged +} + // maskKey redacts middle characters of an API key for display. func maskKey(k string) string { if len(k) <= 8 { diff --git a/internal/cli/provider_model_ref.go b/internal/cli/provider_model_ref.go new file mode 100644 index 0000000..3d28949 --- /dev/null +++ b/internal/cli/provider_model_ref.go @@ -0,0 +1,49 @@ +package cli + +import ( + "fmt" + "sort" + "strings" +) + +func parseProviderModelRef(value string) (provider string, model string, ok bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", "", false + } + provider, model, ok = strings.Cut(trimmed, "/") + if !ok { + return "", "", false + } + provider = strings.TrimSpace(provider) + model = strings.TrimSpace(model) + if provider == "" || model == "" || strings.Contains(provider, "/") { + return "", "", false + } + return provider, model, true +} + +func availableProviderModelRefs(models []string, providerID string) []string { + if len(models) == 0 { + return nil + } + refs := make([]string, 0, len(models)) + for _, model := range models { + refs = append(refs, providerID+"/"+model) + } + sort.Strings(refs) + return refs +} + +func validateProviderModelKnown(providerID string, known []string, source string, model string) error { + if source != "discovered" || len(known) == 0 { + return nil + } + for _, item := range known { + if item == model { + return nil + } + } + choices := availableProviderModelRefs(known, providerID) + return fmt.Errorf("model %q is not in provider %q discovered models; available: %s", model, providerID, strings.Join(choices, ", ")) +} diff --git a/internal/cli/provider_model_ref_test.go b/internal/cli/provider_model_ref_test.go new file mode 100644 index 0000000..86835d1 --- /dev/null +++ b/internal/cli/provider_model_ref_test.go @@ -0,0 +1,32 @@ +package cli + +import "testing" + +func TestParseProviderModelRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantProvider string + wantModel string + wantOK bool + }{ + {name: "simple", input: "codex/GPT-5.4", wantProvider: "codex", wantModel: "GPT-5.4", wantOK: true}, + {name: "trimmed", input: " codex / GPT-5.4 ", wantProvider: "codex", wantModel: "GPT-5.4", wantOK: true}, + {name: "model contains slash", input: "relay/openrouter/google/gemini-2.5-pro", wantProvider: "relay", wantModel: "openrouter/google/gemini-2.5-pro", wantOK: true}, + {name: "missing slash", input: "gpt-5.4", wantOK: false}, + {name: "missing provider", input: "/gpt-5.4", wantOK: false}, + {name: "missing model", input: "codex/", wantOK: false}, + {name: "nested provider", input: "a/b/c", wantProvider: "a", wantModel: "b/c", wantOK: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, model, ok := parseProviderModelRef(tt.input) + if ok != tt.wantOK || provider != tt.wantProvider || model != tt.wantModel { + t.Fatalf("parseProviderModelRef(%q) = %q, %q, %v", tt.input, provider, model, ok) + } + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d6a5281..4c01513 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -38,7 +38,7 @@ writes OpenCode config. For exact command behavior, defaults, and side effects, prefer command-local --help over README summaries.`, Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example ocswitch alias add --name gpt-5.4 --display-name "GPT 5.4" - ocswitch alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 + ocswitch alias bind --alias gpt-5.4 --model su8/gpt-5.4 ocswitch doctor ocswitch opencode sync ocswitch serve diff --git a/internal/config/config.go b/internal/config/config.go index c151d3c..6624996 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,12 +39,14 @@ type Alias struct { // Provider is one upstream OpenAI-compatible endpoint. type Provider struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Headers map[string]string `json:"headers,omitempty"` - Disabled bool `json:"disabled,omitempty"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Headers map[string]string `json:"headers,omitempty"` + Models []string `json:"models,omitempty"` + ModelsSource string `json:"models_source,omitempty"` + Disabled bool `json:"disabled,omitempty"` } // Server holds proxy listen settings. @@ -69,10 +71,16 @@ func (p Provider) IsEnabled() bool { return !p.Disabled } +// NormalizeProviderBaseURL canonicalizes equivalent /v1 roots for comparisons +// and persisted config values. +func NormalizeProviderBaseURL(baseURL string) string { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") +} + // ValidateProviderBaseURL checks the MVP requirement that upstream base URLs // point at an OpenAI-compatible /v1 root. func ValidateProviderBaseURL(baseURL string) error { - trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") + trimmed := NormalizeProviderBaseURL(baseURL) if trimmed == "" { return fmt.Errorf("missing base_url") } @@ -144,34 +152,38 @@ func Load(path string) (*Config, error) { } // Path returns the on-disk path of this config. -func (c *Config) Path() string { return c.path } +func (c *Config) Path() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.path +} // Save writes config atomically. func (c *Config) Save() error { - c.mu.RLock() + c.mu.Lock() + defer c.mu.Unlock() if c.path == "" { c.path = DefaultPath() } path := c.path - providers := append([]Provider(nil), c.Providers...) - sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) - aliases := append([]Alias(nil), c.Aliases...) - sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias }) - snap := struct { - Server Server `json:"server"` - Providers []Provider `json:"providers"` - Aliases []Alias `json:"aliases"` - }{c.Server, providers, aliases} - c.mu.RUnlock() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("mkdir: %w", err) } - data, err := json.MarshalIndent(snap, "", " ") - if err != nil { - return fmt.Errorf("marshal: %w", err) - } - data = append(data, '\n') return fileutil.WithLockedFile(path, func() error { + providers := cloneProviders(c.Providers) + sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) + aliases := cloneAliases(c.Aliases) + sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias }) + snap := struct { + Server Server `json:"server"` + Providers []Provider `json:"providers"` + Aliases []Alias `json:"aliases"` + }{c.Server, providers, aliases} + data, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + data = append(data, '\n') return fileutil.AtomicWriteFile(path, data, 0o600) }) } @@ -180,7 +192,12 @@ func (c *Config) Save() error { func (c *Config) FindProvider(id string) *Provider { c.mu.RLock() defer c.mu.RUnlock() - return c.findProviderLocked(id) + p := c.findProviderLocked(id) + if p == nil { + return nil + } + clone := cloneProvider(*p) + return &clone } func (c *Config) findProviderLocked(id string) *Provider { @@ -196,13 +213,14 @@ func (c *Config) findProviderLocked(id string) *Provider { func (c *Config) UpsertProvider(p Provider) { c.mu.Lock() defer c.mu.Unlock() + cloned := cloneProvider(p) for i := range c.Providers { if c.Providers[i].ID == p.ID { - c.Providers[i] = p + c.Providers[i] = cloned return } } - c.Providers = append(c.Providers, p) + c.Providers = append(c.Providers, cloned) } // RemoveProvider deletes a provider and returns true if removed. @@ -224,7 +242,8 @@ func (c *Config) FindAlias(name string) *Alias { defer c.mu.RUnlock() for i := range c.Aliases { if c.Aliases[i].Alias == name { - return &c.Aliases[i] + clone := cloneAlias(c.Aliases[i]) + return &clone } } return nil @@ -234,13 +253,14 @@ func (c *Config) FindAlias(name string) *Alias { func (c *Config) UpsertAlias(a Alias) { c.mu.Lock() defer c.mu.Unlock() + cloned := cloneAlias(a) for i := range c.Aliases { if c.Aliases[i].Alias == a.Alias { - c.Aliases[i] = a + c.Aliases[i] = cloned return } } - c.Aliases = append(c.Aliases, a) + c.Aliases = append(c.Aliases, cloned) } // RemoveAlias deletes an alias. @@ -364,6 +384,26 @@ func (c *Config) Validate() []error { if err := ValidateProviderBaseURL(p.BaseURL); err != nil { errs = append(errs, fmt.Errorf("provider %q %s", p.ID, err)) } + seenModels := map[string]bool{} + for _, model := range p.Models { + trimmed := strings.TrimSpace(model) + if trimmed == "" { + errs = append(errs, fmt.Errorf("provider %q has empty model entry", p.ID)) + continue + } + if seenModels[trimmed] { + errs = append(errs, fmt.Errorf("provider %q has duplicate model %q", p.ID, trimmed)) + continue + } + seenModels[trimmed] = true + } + validModelsSource := p.ModelsSource == "" || p.ModelsSource == "discovered" || p.ModelsSource == "imported" + if !validModelsSource { + errs = append(errs, fmt.Errorf("provider %q has invalid models_source %q", p.ID, p.ModelsSource)) + } + if validModelsSource && p.ModelsSource != "" && len(p.Models) == 0 { + errs = append(errs, fmt.Errorf("provider %q has models_source %q but no models", p.ID, p.ModelsSource)) + } } seen := map[string]bool{} for _, a := range c.Aliases { @@ -406,3 +446,53 @@ func isLoopbackHost(host string) bool { ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } + +func cloneProvider(p Provider) Provider { + p.Headers = cloneStringMap(p.Headers) + p.Models = cloneStrings(p.Models) + return p +} + +func cloneAlias(a Alias) Alias { + a.Targets = cloneTargets(a.Targets) + return a +} + +func cloneProviders(in []Provider) []Provider { + out := make([]Provider, len(in)) + for i := range in { + out[i] = cloneProvider(in[i]) + } + return out +} + +func cloneAliases(in []Alias) []Alias { + out := make([]Alias, len(in)) + for i := range in { + out[i] = cloneAlias(in[i]) + } + return out +} + +func cloneTargets(in []Target) []Target { + out := make([]Target, len(in)) + copy(out, in) + return out +} + +func cloneStrings(in []string) []string { + out := make([]string, len(in)) + copy(out, in) + return out +} + +func cloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5a9824c..7f5547f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,8 +1,14 @@ package config import ( + "encoding/json" + "os" + "path/filepath" "strings" + "sync" + "syscall" "testing" + "time" ) func TestValidateProviderBaseURL(t *testing.T) { @@ -40,6 +46,14 @@ func TestValidateProviderBaseURL(t *testing.T) { } } +func TestNormalizeProviderBaseURL(t *testing.T) { + t.Parallel() + + if got := NormalizeProviderBaseURL(" https://example.com/v1/ "); got != "https://example.com/v1" { + t.Fatalf("NormalizeProviderBaseURL() = %q", got) + } +} + func TestAvailableTargetsSkipsDisabledAndMissingProviders(t *testing.T) { t.Parallel() @@ -127,3 +141,134 @@ func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) { t.Fatalf("Validate() error = %q", got) } } + +func TestValidateRejectsInvalidModelsSource(t *testing.T) { + t.Parallel() + + cfg := &Config{ + Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: DefaultLocalAPIKey}, + Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", ModelsSource: "manual"}}, + } + + err := cfg.Validate() + if len(err) != 1 { + t.Fatalf("Validate() errors = %v, want 1 error", err) + } + if got := err[0].Error(); got != `provider "p1" has invalid models_source "manual"` { + t.Fatalf("Validate() error = %q", got) + } +} + +func TestValidateRejectsModelsSourceWithoutModels(t *testing.T) { + t.Parallel() + + cfg := &Config{ + Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: DefaultLocalAPIKey}, + Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", ModelsSource: "discovered"}}, + } + + err := cfg.Validate() + if len(err) != 1 { + t.Fatalf("Validate() errors = %v, want 1 error", err) + } + if got := err[0].Error(); got != `provider "p1" has models_source "discovered" but no models` { + t.Fatalf("Validate() error = %q", got) + } +} + +func TestSavePreservesEmptyCollectionsAsArrays(t *testing.T) { + t.Parallel() + + cfg := Default() + cfg.path = t.TempDir() + "/config.json" + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load(cfg.path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.Providers == nil || loaded.Aliases == nil { + t.Fatalf("round-trip nil slices: providers=%#v aliases=%#v", loaded.Providers, loaded.Aliases) + } + + var raw map[string]any + data, err := os.ReadFile(cfg.path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if _, ok := raw["providers"].([]any); !ok { + t.Fatalf("providers JSON = %#v, want array", raw["providers"]) + } + if _, ok := raw["aliases"].([]any); !ok { + t.Fatalf("aliases JSON = %#v, want array", raw["aliases"]) + } +} + +func TestSaveLinearizesConcurrentWriters(t *testing.T) { + t.Parallel() + + cfg := Default() + cfg.path = filepath.Join(t.TempDir(), "config.json") + lockFile, err := os.OpenFile(cfg.path+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatalf("OpenFile(lock): %v", err) + } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + t.Fatalf("Flock(lock): %v", err) + } + + startedFirst := make(chan struct{}) + secondDone := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + cfg.UpsertProvider(Provider{ID: "first", BaseURL: "https://first.example.com/v1"}) + close(startedFirst) + if err := cfg.Save(); err != nil { + t.Errorf("first Save() error = %v", err) + } + }() + + <-startedFirst + time.Sleep(20 * time.Millisecond) + + go func() { + defer wg.Done() + cfg.UpsertProvider(Provider{ID: "second", BaseURL: "https://second.example.com/v1"}) + if err := cfg.Save(); err != nil { + t.Errorf("second Save() error = %v", err) + } + close(secondDone) + }() + + select { + case <-secondDone: + t.Fatal("second Save() completed before external file lock was released") + case <-time.After(20 * time.Millisecond): + } + + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil { + t.Fatalf("Flock(unlock): %v", err) + } + + wg.Wait() + + loaded, err := Load(cfg.path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.FindProvider("first") == nil { + t.Fatalf("final config = %#v, want first provider persisted", loaded.Providers) + } + if loaded.FindProvider("second") == nil { + t.Fatalf("final config = %#v, want latest provider persisted", loaded.Providers) + } +} diff --git a/internal/config/provider_models.go b/internal/config/provider_models.go new file mode 100644 index 0000000..c39113b --- /dev/null +++ b/internal/config/provider_models.go @@ -0,0 +1,28 @@ +package config + +import ( + "slices" + "sort" + "strings" +) + +func NormalizeProviderModels(models []string) []string { + if len(models) == 0 { + return nil + } + seen := make(map[string]bool, len(models)) + out := make([]string, 0, len(models)) + for _, model := range models { + trimmed := strings.TrimSpace(model) + if trimmed == "" || seen[trimmed] { + continue + } + seen[trimmed] = true + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + sort.Strings(out) + return slices.Clip(out) +} diff --git a/internal/config/provider_models_test.go b/internal/config/provider_models_test.go new file mode 100644 index 0000000..233449e --- /dev/null +++ b/internal/config/provider_models_test.go @@ -0,0 +1,16 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestNormalizeProviderModels(t *testing.T) { + t.Parallel() + + got := NormalizeProviderModels([]string{" GPT-5.4 ", "gpt-5.4", "", "GPT-5.4"}) + want := []string{"GPT-5.4", "gpt-5.4"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("NormalizeProviderModels() = %#v, want %#v", got, want) + } +} diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go index 8c77a5a..fcbd7d5 100644 --- a/internal/opencode/opencode.go +++ b/internal/opencode/opencode.go @@ -600,6 +600,7 @@ func ImportCustomProviders(raw Raw) []ImportableProvider { for k := range models { ip.Models = append(ip.Models, k) } + sort.Strings(ip.Models) } out = append(out, ip) } diff --git a/internal/opencode/opencode_test.go b/internal/opencode/opencode_test.go index a41dd92..2ecbb4a 100644 --- a/internal/opencode/opencode_test.go +++ b/internal/opencode/opencode_test.go @@ -205,7 +205,7 @@ func TestValidateOcswitchProviderAllowsCustomModelMetadata(t *testing.T) { } } -func TestRenderSaveDataReplacesExistingProviderOnly(t *testing.T) { +func TestPatchProviderDocumentReplacesExistingProviderOnly(t *testing.T) { raw := Raw{ "$schema": "https://opencode.ai/config.json", "model": "ocswitch/gpt-5.4", @@ -246,7 +246,7 @@ func TestRenderSaveDataReplacesExistingProviderOnly(t *testing.T) { } } -func TestRenderSaveDataInsertsOcswitchWithoutReorderingProviderKeys(t *testing.T) { +func TestPatchProviderDocumentInsertsOcswitchWithoutReorderingProviderKeys(t *testing.T) { raw := Raw{ "provider": map[string]any{ "anthropic": map[string]any{"npm": "@ai-sdk/anthropic"}, @@ -273,7 +273,7 @@ func TestRenderSaveDataInsertsOcswitchWithoutReorderingProviderKeys(t *testing.T assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"ocswitch"`}) } -func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) { +func TestPatchProviderDocumentInsertsProviderAtTopLevelEnd(t *testing.T) { raw := Raw{ "model": "ocswitch/gpt-5.4", "provider": map[string]any{ @@ -300,7 +300,7 @@ func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) { assertStringOrder(t, string(got), []string{`"model"`, `"small_model"`, `"provider"`}) } -func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) { +func TestPatchProviderDocumentAcceptsJSONCAndProducesValidJSON(t *testing.T) { raw := Raw{ "provider": map[string]any{ ProviderKey: map[string]any{ @@ -351,7 +351,32 @@ func TestImportCustomProvidersAllowsEmptyAPIKey(t *testing.T) { t.Fatalf("api key = %q, want empty", imports[0].APIKey) } } -func TestRenderSaveDataRejectsInvalidJSONC(t *testing.T) { + +func TestImportCustomProvidersSortsModels(t *testing.T) { + raw := Raw{ + "provider": map[string]any{ + "p1": map[string]any{ + "npm": "@ai-sdk/openai", + "options": map[string]any{ + "baseURL": "https://example.com/v1", + }, + "models": map[string]any{ + "z-model": map[string]any{}, + "a-model": map[string]any{}, + }, + }, + }, + } + + imports := ImportCustomProviders(raw) + if len(imports) != 1 { + t.Fatalf("len(imports) = %d, want 1", len(imports)) + } + if got := strings.Join(imports[0].Models, ","); got != "a-model,z-model" { + t.Fatalf("Models = %q", got) + } +} +func TestPatchProviderDocumentRejectsInvalidJSONC(t *testing.T) { raw := Raw{ "provider": map[string]any{ ProviderKey: map[string]any{ @@ -371,7 +396,7 @@ func TestRenderSaveDataRejectsInvalidJSONC(t *testing.T) { } } -func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) { +func TestPatchProviderDocumentRejectsNonObjectProvider(t *testing.T) { raw := Raw{} EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}) @@ -380,7 +405,7 @@ func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) { } } -func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) { +func TestPatchProviderDocumentRejectsNonObjectTopLevel(t *testing.T) { raw := Raw{} EnsureOcswitchProvider(raw, "http://127.0.0.1:9982/v1", "ocswitch-local", []string{"gpt-5.4"}) @@ -392,7 +417,7 @@ func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) { } } -func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) { +func TestSaveWritesValidJSONToDisk(t *testing.T) { path := filepath.Join(t.TempDir(), "opencode.jsonc") if err := os.WriteFile(path, []byte("{\n \"model\": \"ocswitch/gpt-5.4\",\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n }\n}\n"), 0o600); err != nil { t.Fatalf("write seed config: %v", err) diff --git a/internal/opencode/provider_models.go b/internal/opencode/provider_models.go new file mode 100644 index 0000000..2c23910 --- /dev/null +++ b/internal/opencode/provider_models.go @@ -0,0 +1,65 @@ +package opencode + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "time" +) + +type ModelListResponse struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` +} + +func FetchProviderModels(baseURL, apiKey string, headers map[string]string) ([]string, error) { + url := strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/models" + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + for key, value := range headers { + key = strings.TrimSpace(key) + if key == "" { + continue + } + req.Header.Set(key, value) + } + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var body struct { + Error any `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Error != nil { + return nil, fmt.Errorf("request %s: unexpected status %s: %v", url, resp.Status, body.Error) + } + return nil, fmt.Errorf("request %s: unexpected status %s", url, resp.Status) + } + var payload ModelListResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decode %s: %w", url, err) + } + models := make([]string, 0, len(payload.Data)) + seen := map[string]bool{} + for _, item := range payload.Data { + id := strings.TrimSpace(item.ID) + if id == "" || seen[id] { + continue + } + seen[id] = true + models = append(models, id) + } + sort.Strings(models) + return models, nil +} diff --git a/internal/opencode/provider_models_test.go b/internal/opencode/provider_models_test.go new file mode 100644 index 0000000..6cd0f53 --- /dev/null +++ b/internal/opencode/provider_models_test.go @@ -0,0 +1,99 @@ +package opencode + +import ( + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestFetchProviderModels(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + var auth string + var custom string + var method string + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method = r.Method + path = r.URL.Path + auth = r.Header.Get("Authorization") + custom = r.Header.Get("X-Test") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1"},{"id":"gpt-4.1"},{"id":"gpt-4o"}]}`)) + })) + defer srv.Close() + + models, err := FetchProviderModels(srv.URL+"/v1", "sk-test", map[string]string{"X-Test": "1"}) + if err != nil { + t.Fatalf("FetchProviderModels() error = %v", err) + } + if auth != "Bearer sk-test" { + t.Fatalf("Authorization = %q", auth) + } + if method != http.MethodGet { + t.Fatalf("Method = %q", method) + } + if path != "/v1/models" { + t.Fatalf("Path = %q", path) + } + if custom != "1" { + t.Fatalf("X-Test = %q", custom) + } + want := []string{"gpt-4.1", "gpt-4o"} + if !reflect.DeepEqual(models, want) { + t.Fatalf("models = %#v, want %#v", models, want) + } + }) + + t.Run("status error", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"error":"bad key"}`) + })) + defer srv.Close() + + _, err := FetchProviderModels(srv.URL+"/v1", "", nil) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "bad key") { + t.Fatalf("error = %q", err.Error()) + } + }) + + t.Run("empty data", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer srv.Close() + + models, err := FetchProviderModels(srv.URL+"/v1", "", nil) + if err != nil { + t.Fatalf("FetchProviderModels() error = %v", err) + } + if len(models) != 0 { + t.Fatalf("models = %#v, want empty", models) + } + }) + + t.Run("invalid json", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{bad`) + })) + defer srv.Close() + + _, err := FetchProviderModels(srv.URL+"/v1", "", nil) + if err == nil { + t.Fatal("expected error") + } + }) +}