fix: harden provider discovery flow
This commit is contained in:
parent
d6ab8f24da
commit
09d0c1f9c6
43
README.md
43
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 <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>
|
||||
```
|
||||
|
||||
|
||||
33
README_EN.md
33
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 <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.
|
||||
|
||||
@ -24,8 +24,8 @@ 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 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())
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{
|
||||
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: ip.BaseURL,
|
||||
BaseURL: baseURL,
|
||||
APIKey: ip.APIKey,
|
||||
Disabled: false,
|
||||
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 {
|
||||
|
||||
49
internal/cli/provider_model_ref.go
Normal file
49
internal/cli/provider_model_ref.go
Normal 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, ", "))
|
||||
}
|
||||
32
internal/cli/provider_model_ref_test.go
Normal file
32
internal/cli/provider_model_ref_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -44,6 +44,8 @@ type Provider struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@ -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...)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
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 := append([]Alias(nil), c.Aliases...)
|
||||
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}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
28
internal/config/provider_models.go
Normal file
28
internal/config/provider_models.go
Normal 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)
|
||||
}
|
||||
16
internal/config/provider_models_test.go
Normal file
16
internal/config/provider_models_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
65
internal/opencode/provider_models.go
Normal file
65
internal/opencode/provider_models.go
Normal 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
|
||||
}
|
||||
99
internal/opencode/provider_models_test.go
Normal file
99
internal/opencode/provider_models_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user