fix: harden provider discovery flow

This commit is contained in:
apale7 2026-04-18 15:28:35 +08:00
parent d6ab8f24da
commit 09d0c1f9c6
16 changed files with 1744 additions and 109 deletions

View File

@ -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 <id> --base-url <url-with-/v1> --api-key <key>
ocswitch provider add --id <id> --base-url <url-with-/v1> --api-key ""
ocswitch provider add --id <id> --base-url <url-with-/v1> --clear-headers
ocswitch provider add --id <id> --base-url <url-with-/v1> --skip-models
```
如果你需要把已保存的上游 API key 清空,显式传 `--api-key ""` 即可。
如果你需要把已保存的额外 header 清空,显式传 `--clear-headers` 即可。
查看 provider
```bash
@ -293,12 +307,14 @@ ocswitch alias add --name <alias>
给 alias 追加一个 target
```bash
ocswitch alias bind --alias <alias> --model <provider-id>/<upstream-model>
ocswitch alias bind --alias <alias> --provider <provider-id> --model <upstream-model>
```
解绑 target
```bash
ocswitch alias unbind --alias <alias> --model <provider-id>/<upstream-model>
ocswitch alias unbind --alias <alias> --provider <provider-id> --model <upstream-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 <alias> --model <provider-id>/<model>
ocswitch alias unbind --alias <alias> --provider <provider-id> --model <model>
```

View File

@ -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 <alias> --model <provider>/<model>` when `--provider` is omitted.
The legacy `--provider <id> --model <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.

View File

@ -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 <id> --model <name> or in the more natural combined form --model
<provider>/<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 <provider>/<model> 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 <id> --model <name> or in the
recommended combined form --model <provider>/<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 <provider>/<model> 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
}

View File

@ -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())
}
}

View File

@ -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 {

View File

@ -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, ", "))
}

View File

@ -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)
}
})
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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")
}
})
}