diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b86a691 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/ops +/dist/ +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..df70935 --- /dev/null +++ b/README.md @@ -0,0 +1,486 @@ +# opencode-provider-switch (`ops`) + +`ops` 是一个给 OpenCode 使用的本地代理。 + +它解决的问题很简单: + +- 你在 OpenCode 里只使用一个稳定的模型名,例如 `ops/gpt-5.4` +- `ops` 在本地把这个别名映射到多个上游 `provider/model` +- 按你配置的顺序依次尝试上游 +- 如果主上游在响应开始前失败,自动切到下一个上游 + +当前实现只支持 OpenAI Responses 协议,也就是 `POST /v1/responses`,并且支持流式响应。 + +## 适合什么场景 + +- 你有多个 OpenAI 兼容上游 +- 你不想在 OpenCode 里频繁切换 provider +- 你希望用一个固定别名承接多个备用上游 +- 你希望失败切换行为是确定的、可预期的 + +## 当前能力 + +- 本地维护 `ops` 配置文件:上游 provider、alias、监听地址 +- 支持手动添加 provider +- 支持从 OpenCode 配置导入 `@ai-sdk/openai` 自定义 provider +- 支持创建 alias,并按顺序绑定多个上游 target +- 支持把 alias 同步到 OpenCode 的 `provider.ops.models` +- 支持本地代理 `POST /v1/responses` +- 支持流式透传 +- 支持首字节前失败切换 +- 返回调试响应头,便于确认这次请求实际落到了哪个上游 + +## 不支持的内容 + +- Anthropic 原生协议 +- 多协议路由 +- 自动按延迟、价格、提示词类型选路 +- 中途流式拼接切换 +- SQLite、管理后台、计费统计 +- 完整接管 OpenCode 所有配置层 +- 从 `auth.json` 自动导入 provider 定义 + +## 安装 + +```bash +go build -o ops ./cmd/ops +``` + +如果你只想临时运行,也可以直接: + +```bash +go run ./cmd/ops --help +``` + +## 5 分钟快速上手 + +### 1. 添加上游 provider + +`ops` 要求上游是 OpenAI 兼容接口,并且 `--base-url` 需要带上 `/v1`。 + +```bash +ops provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-xxx +ops provider add --id codex --base-url https://api-vip.codex-for.me/v1 --api-key sk-yyy +``` + +如果某个上游还需要额外请求头,可以重复传 `--header`: + +```bash +ops provider add \ + --id relay \ + --base-url https://example.com/v1 \ + --api-key sk-zzz \ + --header "X-Custom-Token=abc" \ + --header "X-Workspace=my-team" +``` + +查看当前 provider: + +```bash +ops provider list +``` + +### 2. 创建 alias,并绑定多个上游 target + +下面这个例子表示:当你使用 `ops/gpt-5.4` 时,优先走 `su8/gpt-5.4`,失败后再走 `codex/GPT-5.4`。 + +```bash +ops alias add --name gpt-5.4 --display-name "GPT 5.4" +ops alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 +ops alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 +``` + +查看当前 alias: + +```bash +ops alias list +``` + +注意: + +- target 的顺序就是失败切换顺序 +- enabled alias 必须至少有一个 enabled target +- `ops alias bind` 在 alias 不存在时会自动创建一个 enabled alias + +### 3. 先做一次静态检查 + +```bash +ops doctor +``` + +`ops doctor` 只做静态校验,不会真的请求上游,不会消耗额度。 + +它会检查: + +- 本地 `ops` 配置能不能正常加载 +- alias 是否引用了不存在的 provider +- enabled alias 是否至少有一个 enabled target +- 本地代理监听地址是否合理 +- 默认会同步到哪个 OpenCode 配置文件 + +### 4. 把 alias 同步到 OpenCode + +```bash +ops opencode sync +``` + +这个命令会做一件事:把当前 alias 列表同步进 OpenCode 的 `provider.ops.models`。 + +默认行为: + +- 优先复用全局 OpenCode 配置文件:`opencode.jsonc` > `opencode.json` > `config.json` +- 如果都不存在,就创建 `~/.config/opencode/opencode.jsonc` +- 只更新 `provider.ops` +- 不会修改顶层 `model` +- 不会修改顶层 `small_model` + +如果你希望顺手把默认模型也切到 `ops`,需要显式指定: + +```bash +ops opencode sync --set-model ops/gpt-5.4 +``` + +如果你还有小模型 alias,也可以这样: + +```bash +ops opencode sync \ + --set-model ops/gpt-5.4 \ + --set-small-model ops/gpt-5.4-mini +``` + +先预览不写入: + +```bash +ops opencode sync --dry-run +``` + +写到指定 OpenCode 配置文件: + +```bash +ops opencode sync --target /path/to/opencode.jsonc +``` + +### 5. 启动本地代理 + +```bash +ops serve +``` + +默认监听地址: + +- `127.0.0.1:9982` +- 本地 API Key:`ops-local` + +启动后,本地代理地址是: + +```text +http://127.0.0.1:9982/v1 +``` + +### 6. 在 OpenCode 里使用 + +完成 `ops opencode sync` 后,你应该能在 OpenCode 里看到 `ops/`。 + +例如: + +- `ops/gpt-5.4` + +如果你执行了: + +```bash +ops opencode sync --set-model ops/gpt-5.4 +``` + +那么 OpenCode 默认模型也会直接切到这个 alias。 + +## 直接验证本地代理 + +如果你想先不走 OpenCode,直接验证 `ops` 是否能正常代理,可以自己发一个请求: + +```bash +curl -sN -X POST http://127.0.0.1:9982/v1/responses \ + -H "Authorization: Bearer ops-local" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-5.4","stream":true,"input":"hello"}' +``` + +注意这里请求体里的 `model` 是 alias 本身,例如 `gpt-5.4`,不是 `ops/gpt-5.4`。 + +因为 `ops/gpt-5.4` 是 OpenCode 侧的模型选择写法;真正发到本地 provider 的请求里,模型名会是 alias 自身。 + +## 从现有 OpenCode 配置导入 provider + +如果你原来已经在 OpenCode 里配置过一些自定义 provider,可以直接导入: + +```bash +ops provider import-opencode +``` + +或者指定导入源: + +```bash +ops provider import-opencode --from ./examples/opencode.jsonc +``` + +支持范围只有这一类: + +- `npm: @ai-sdk/openai` +- 有 `options.baseURL` +- 有 `options.apiKey` + +注意: + +- 这不是完整迁移工具 +- 当前只导入 provider 的基本连接信息 +- 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `ops provider add --header ...` 补齐 +- `ops` 自己不会被反向导入 + +覆盖已存在的 provider: + +```bash +ops provider import-opencode --overwrite +``` + +## 常用命令 + +### provider + +添加或更新 provider: + +```bash +ops provider add --id --base-url --api-key +``` + +查看 provider: + +```bash +ops provider list +``` + +删除 provider: + +```bash +ops provider remove +``` + +注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`ops doctor` 会报错。 + +### alias + +创建或更新 alias: + +```bash +ops alias add --name +``` + +给 alias 追加一个 target: + +```bash +ops alias bind --alias --provider --model +``` + +解绑 target: + +```bash +ops alias unbind --alias --provider --model +``` + +查看 alias: + +```bash +ops alias list +``` + +删除 alias: + +```bash +ops alias remove +``` + +### 其他 + +静态检查: + +```bash +ops doctor +``` + +启动代理: + +```bash +ops serve +``` + +同步到 OpenCode: + +```bash +ops opencode sync +``` + +全局帮助: + +```bash +ops --help +ops provider --help +ops alias --help +ops opencode sync --help +``` + +## 配置文件说明 + +本地 `ops` 配置文件默认路径: + +- 如果设置了 `OPS_CONFIG`,优先使用它 +- 否则使用 `$XDG_CONFIG_HOME/ops/config.json` +- 再否则使用 `~/.config/ops/config.json` + +也可以对每个命令显式指定: + +```bash +ops --config /path/to/config.json doctor +``` + +一个最小配置示例: + +```json +{ + "server": { + "host": "127.0.0.1", + "port": 9982, + "api_key": "ops-local" + }, + "providers": [ + { + "id": "su8", + "name": "SU8", + "base_url": "https://cn2.su8.codes/v1", + "api_key": "sk-xxx" + }, + { + "id": "codex", + "name": "Codex", + "base_url": "https://api-vip.codex-for.me/v1", + "api_key": "sk-yyy" + } + ], + "aliases": [ + { + "alias": "gpt-5.4", + "display_name": "GPT 5.4", + "enabled": true, + "targets": [ + { + "provider": "su8", + "model": "gpt-5.4", + "enabled": true + }, + { + "provider": "codex", + "model": "GPT-5.4", + "enabled": true + } + ] + } + ] +} +``` + +## 失败切换规则 + +`ops` 的切换规则很保守,也很容易理解。 + +会切换到下一个 target 的情况: + +- 连接失败 +- DNS / 网络错误 +- 上游在返回首字节前超时或断开 +- 上游返回 `429` +- 上游返回 `5xx` + +不会切换的情况: + +- alias 不存在 +- alias 被禁用 +- alias 没有 enabled target +- 上游返回 `400` +- 上游返回 `401` +- 上游返回 `403` +- 上游返回 `404` +- 响应已经开始向客户端写出后才出错 + +重点是: + +- 只有在“还没向下游写出任何字节”之前,才允许切换 +- 一旦开始向客户端输出流,当前上游就锁定了 +- 不支持中途把一半流接到另一个 provider 上继续输出 + +## 调试响应头 + +每次成功代理或透传上游错误时,响应里都会附带这些头: + +- `X-OPS-Alias` +- `X-OPS-Provider` +- `X-OPS-Remote-Model` +- `X-OPS-Attempt` +- `X-OPS-Failover-Count` + +你可以用它们确认: + +- 本次请求命中了哪个 alias +- 实际走了哪个 provider +- 实际转发成了哪个上游 model +- 这是第几次尝试 +- 前面已经失败切换过几次 + +## 常见问题 + +### 为什么 `opencode models` 里看不到 `ops/`? + +先检查这几件事: + +1. 你是否执行过 `ops opencode sync` +2. 你的 alias 是否是 enabled 状态 +3. alias 是否至少绑定了一个 enabled target +4. OpenCode 当前实际使用的配置文件,是否就是 `ops opencode sync` 写入的那个文件 +5. 执行一次 `ops doctor`,看输出里的 `opencode config target` + +### 为什么 `ops doctor` 报 alias 没有 enabled target? + +因为当前实现要求:只要 alias 是 enabled,就必须至少有一个 enabled target。 + +你可以: + +- 给它绑定 target +- 或者把这个 alias 改成 disabled 后再保存 + +### 删除 provider 后为什么还有报错? + +因为 alias 里的 target 还是旧引用。需要继续执行: + +```bash +ops alias unbind --alias --provider --model +``` + +### 本地代理鉴权是什么? + +默认是静态 key: + +```text +ops-local +``` + +OpenCode 在 `provider.ops.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。 + +## 安全说明 + +- 默认只监听 `127.0.0.1` +- 上游凭据保存在本地 `ops` 配置文件中 +- 本项目当前没有做多用户或远程网络安全保证 + +所以请把本地配置文件当成敏感文件处理。 + +## 英文版 README + +原英文版文档已保存在: + +- `README_EN.md` diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..902c826 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,87 @@ +# opencode-provider-switch (`ops`) + +A tiny local proxy for [OpenCode](https://opencode.ai) that gives you **one +stable model alias** routed to **multiple upstream providers** with +**deterministic failover**. + +- Expose one custom provider `ops` to OpenCode. +- Configure logical aliases (`ops/gpt-5.4`, etc.). +- Each alias has an ordered list of upstream `provider/model` targets. +- When the primary upstream returns `5xx`/`429`/connect error *before* any + stream bytes are flushed, `ops` transparently retries the next target. +- Once a stream has started, the upstream is locked for the rest of that + request — no mid-stream splicing. + +Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported. + +## Install + +```bash +go build -o ops ./cmd/ops +``` + +## Quick start + +```bash +# 1. add upstream providers +ops provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-... +ops 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 +ops alias add --name gpt-5.4 +ops alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4 +ops alias bind --alias gpt-5.4 --provider codex --model GPT-5.4 + +# 3. push alias exposure into OpenCode global config +ops opencode sync + +# 4. run the proxy +ops serve +``` + +Inside OpenCode you can now pick `ops/gpt-5.4`. + +### Import providers from an existing OpenCode config + +```bash +ops provider import-opencode # reads global OpenCode config +ops provider import-opencode --from ./examples/opencode.jsonc +``` + +Only `@ai-sdk/openai` custom providers with a `baseURL` and `apiKey` are +imported. Everything else is out of MVP scope. + +### Doctor (static) + +```bash +ops doctor +``` + +Runs structural checks only — never issues real upstream requests. + +## CLI reference + +- `ops serve` — run the proxy +- `ops doctor` — validate config +- `ops provider {add,list,remove,import-opencode}` +- `ops alias {add,list,bind,unbind,remove}` +- `ops opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]` + +Global flag: `--config PATH` (default `$XDG_CONFIG_HOME/ops/config.json`). + +## Debug headers + +Every proxied response includes: + +- `X-OPS-Alias` +- `X-OPS-Provider` +- `X-OPS-Remote-Model` +- `X-OPS-Attempt` +- `X-OPS-Failover-Count` + +## Scope + +Out of MVP: Anthropic native, multi-protocol routing, dashboard, billing, +latency-based routing, `/v1/models` discovery, full OpenCode config takeover. +See `.trellis/tasks/archive/2026-04/04-17-04-17-ops-mvp-design-review/prd.md` +for the authoritative design notes. diff --git a/cmd/ops/main.go b/cmd/ops/main.go new file mode 100644 index 0000000..dc59085 --- /dev/null +++ b/cmd/ops/main.go @@ -0,0 +1,20 @@ +// Command ops: local alias + failover proxy for OpenCode. +package main + +import ( + "fmt" + "os" + + "github.com/anomalyco/opencode-provider-switch/internal/cli" +) + +// version is overridden at build time via -ldflags "-X main.version=...". +var version = "dev" + +func main() { + if err := cli.NewRootCmd(version).Execute(); err != nil { + // cobra already prints the error; ensure non-zero exit + fmt.Fprintln(os.Stderr) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..da80ada --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/anomalyco/opencode-provider-switch + +go 1.22.2 + +require ( + github.com/spf13/cobra v1.8.1 + github.com/tidwall/jsonc v0.3.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a230ae4 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= +github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/alias.go b/internal/cli/alias.go new file mode 100644 index 0000000..de04c4b --- /dev/null +++ b/internal/cli/alias.go @@ -0,0 +1,179 @@ +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/anomalyco/opencode-provider-switch/internal/config" +) + +func newAliasCmd() *cobra.Command { + c := &cobra.Command{ + Use: "alias", + Short: "Manage logical aliases routed by ops", + } + c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd()) + return c +} + +func newAliasAddCmd() *cobra.Command { + var name, display string + var disabled bool + cmd := &cobra.Command{ + Use: "add", + Short: "Create or update an alias (without targets)", + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + cfg, err := loadCfg() + if err != nil { + return err + } + existing := cfg.FindAlias(name) + a := config.Alias{Alias: name, DisplayName: display, Enabled: !disabled} + if existing != nil { + a.Targets = existing.Targets + } + cfg.UpsertAlias(a) + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "saved alias %q (enabled=%v)\n", name, a.Enabled) + return nil + }, + } + cmd.Flags().StringVar(&name, "name", "", "alias name exposed as ops/ in OpenCode (required)") + cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name") + cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state") + return cmd +} + +func newAliasListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List aliases and their target chains", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + aliases := append([]config.Alias(nil), cfg.Aliases...) + sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias }) + if len(aliases) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "(no aliases)") + return nil + } + for _, a := range aliases { + state := "enabled" + if !a.Enabled { + state = "disabled" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s [%s]\n", a.Alias, state) + for i, t := range a.Targets { + mark := " " + if !t.Enabled { + mark = "x" + } + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %d. %s/%s\n", mark, i+1, t.Provider, t.Model) + } + } + return nil + }, + } +} + +func newAliasBindCmd() *cobra.Command { + var alias, provider, model string + var disabled bool + cmd := &cobra.Command{ + Use: "bind", + Short: "Append a target (provider/model) to an alias in failover order", + RunE: func(cmd *cobra.Command, args []string) error { + if alias == "" || provider == "" || model == "" { + return fmt.Errorf("--alias, --provider and --model are required") + } + cfg, err := loadCfg() + if err != nil { + return err + } + if cfg.FindProvider(provider) == nil { + return fmt.Errorf("provider %q does not exist; add it first", provider) + } + 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 { + return err + } + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "bound %s → %s/%s\n", alias, provider, model) + return nil + }, + } + 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().BoolVar(&disabled, "disabled", false, "add target in disabled state") + return cmd +} + +func newAliasUnbindCmd() *cobra.Command { + var alias, provider, model string + cmd := &cobra.Command{ + Use: "unbind", + Short: "Remove a target from an alias", + RunE: func(cmd *cobra.Command, args []string) error { + if alias == "" || provider == "" || model == "" { + return fmt.Errorf("--alias, --provider and --model are required") + } + cfg, err := loadCfg() + if err != nil { + return err + } + if err := cfg.RemoveTarget(alias, provider, model); err != nil { + return err + } + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "unbound %s → %s/%s\n", alias, provider, model) + return nil + }, + } + 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)") + return cmd +} + +func newAliasRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Args: cobra.ExactArgs(1), + Short: "Delete an alias entirely", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + if !cfg.RemoveAlias(args[0]) { + return fmt.Errorf("alias %q not found", args[0]) + } + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "removed alias %q\n", args[0]) + return nil + }, + } +} + +// unused helper kept for clarity if future commands want to normalize lists +var _ = strings.TrimSpace diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..9c736e5 --- /dev/null +++ b/internal/cli/doctor.go @@ -0,0 +1,48 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/anomalyco/opencode-provider-switch/internal/opencode" +) + +func newDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Validate ops config (static checks, no upstream requests)", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + issues := cfg.Validate() + ok := len(issues) == 0 + if ok { + fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", cfg.Path()) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "✗ config has %d issue(s):\n", len(issues)) + for _, e := range issues { + fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", e) + } + } + fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", len(cfg.Providers)) + fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", len(cfg.Aliases)) + + // Preview resolved opencode config target + path, existed := opencode.ResolveGlobalConfigPath() + marker := "(will be created)" + if existed { + marker = "(exists)" + } + fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker) + + fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port) + if !ok { + return fmt.Errorf("%d config issue(s)", len(issues)) + } + return nil + }, + } +} diff --git a/internal/cli/opencode.go b/internal/cli/opencode.go new file mode 100644 index 0000000..a16643c --- /dev/null +++ b/internal/cli/opencode.go @@ -0,0 +1,101 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/anomalyco/opencode-provider-switch/internal/opencode" +) + +func newOpencodeCmd() *cobra.Command { + c := &cobra.Command{ + Use: "opencode", + Short: "OpenCode integration commands", + } + c.AddCommand(newOpencodeSyncCmd()) + return c +} + +func newOpencodeSyncCmd() *cobra.Command { + var target string + var setModel string + var setSmallModel string + var dryRun bool + cmd := &cobra.Command{ + Use: "sync", + Short: "Update provider.ops in the global OpenCode config to match current aliases", + Long: `ops opencode sync writes provider.ops into the target OpenCode config. + +By default it targets the global user config (~/.config/opencode), picking the +existing file in precedence order opencode.jsonc > opencode.json > config.json, +or creating opencode.jsonc if none exists. It does NOT touch the top-level +"model" or "small_model" unless --set-model / --set-small-model are given.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + if errs := cfg.Validate(); len(errs) > 0 { + for _, e := range errs { + cmd.PrintErrln("config error:", e) + } + return errs[0] + } + path := target + if path == "" { + p, _ := opencode.ResolveGlobalConfigPath() + path = p + } + raw, err := opencode.Load(path) + if err != nil { + return err + } + aliasNames := []string{} + for _, a := range cfg.Aliases { + if !a.Enabled { + continue + } + aliasNames = append(aliasNames, a.Alias) + } + baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) + changed := opencode.EnsureOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) + if setModel != "" { + if raw["model"] != setModel { + raw["model"] = setModel + changed = true + } + } + if setSmallModel != "" { + if raw["small_model"] != setSmallModel { + raw["small_model"] = setSmallModel + changed = true + } + } + if !changed { + fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", path) + return nil + } + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", path) + return nil + } + if err := opencode.Save(path, raw); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ops into %s (%d alias(es))\n", path, len(aliasNames)) + if setModel != "" { + fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel) + } + if setSmallModel != "" { + fmt.Fprintf(cmd.OutOrStdout(), " small_model = %s\n", setSmallModel) + } + return nil + }, + } + cmd.Flags().StringVar(&target, "target", "", "explicit opencode config file to write (default: global)") + cmd.Flags().StringVar(&setModel, "set-model", "", "also set top-level model (opt-in only)") + cmd.Flags().StringVar(&setSmallModel, "set-small-model", "", "also set top-level small_model (opt-in only)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "do not write, just report intent") + return cmd +} diff --git a/internal/cli/provider.go b/internal/cli/provider.go new file mode 100644 index 0000000..71adc62 --- /dev/null +++ b/internal/cli/provider.go @@ -0,0 +1,180 @@ +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/anomalyco/opencode-provider-switch/internal/config" + "github.com/anomalyco/opencode-provider-switch/internal/opencode" +) + +func newProviderCmd() *cobra.Command { + c := &cobra.Command{ + Use: "provider", + Short: "Manage upstream providers", + } + c.AddCommand(newProviderAddCmd(), newProviderListCmd(), newProviderRemoveCmd(), newProviderImportCmd()) + return c +} + +func newProviderAddCmd() *cobra.Command { + var id, name, baseURL, apiKey string + var headers []string + cmd := &cobra.Command{ + Use: "add", + Short: "Add or update an upstream provider", + RunE: func(cmd *cobra.Command, args []string) error { + if id == "" || baseURL == "" { + return fmt.Errorf("--id and --base-url are required") + } + cfg, err := loadCfg() + if err != nil { + return err + } + 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) + } + cfg.UpsertProvider(config.Provider{ + ID: id, + Name: name, + BaseURL: baseURL, + APIKey: apiKey, + Headers: hdrs, + }) + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "saved provider %q → %s\n", id, baseURL) + return nil + }, + } + cmd.Flags().StringVar(&id, "id", "", "provider id (required)") + cmd.Flags().StringVar(&name, "name", "", "display name") + 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)") + return cmd +} + +func newProviderListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List configured providers", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + providers := append([]config.Provider(nil), cfg.Providers...) + sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) + if len(providers) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "(no providers)") + return nil + } + for _, p := range providers { + key := "(none)" + if p.APIKey != "" { + key = maskKey(p.APIKey) + } + fmt.Fprintf(cmd.OutOrStdout(), "%-20s %s apiKey=%s\n", p.ID, p.BaseURL, key) + } + return nil + }, + } +} + +func newProviderRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Args: cobra.ExactArgs(1), + Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + if !cfg.RemoveProvider(args[0]) { + return fmt.Errorf("provider %q not found", args[0]) + } + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "removed provider %q\n", args[0]) + return nil + }, + } +} + +func newProviderImportCmd() *cobra.Command { + var srcPath string + var overwrite bool + cmd := &cobra.Command{ + Use: "import-opencode", + Short: "Import @ai-sdk/openai custom providers from an OpenCode config file", + RunE: func(cmd *cobra.Command, args []string) error { + if srcPath == "" { + p, existed := opencode.ResolveGlobalConfigPath() + if !existed { + return fmt.Errorf("no OpenCode config found at %s; use --from to specify", p) + } + srcPath = p + } + raw, err := opencode.Load(srcPath) + if err != nil { + return err + } + imports := opencode.ImportCustomProviders(raw) + if len(imports) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "no importable @ai-sdk/openai providers found in %s\n", srcPath) + return nil + } + cfg, err := loadCfg() + if err != nil { + return err + } + imported := 0 + skipped := 0 + for _, ip := range imports { + if !overwrite && cfg.FindProvider(ip.ID) != nil { + skipped++ + 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, + }) + imported++ + fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, ip.BaseURL, strings.Join(ip.Models, ",")) + } + if imported > 0 { + if err := cfg.Save(); err != nil { + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "imported=%d skipped=%d\n", imported, skipped) + return nil + }, + } + cmd.Flags().StringVar(&srcPath, "from", "", "OpenCode config to read (default: global user config)") + cmd.Flags().BoolVar(&overwrite, "overwrite", false, "overwrite existing provider entries") + return cmd +} + +// maskKey redacts middle characters of an API key for display. +func maskKey(k string) string { + if len(k) <= 8 { + return "***" + } + return k[:4] + "…" + k[len(k)-4:] +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..9b765e9 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,44 @@ +// Package cli wires the ops cobra command tree. +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/anomalyco/opencode-provider-switch/internal/config" +) + +// configPath is populated from the global --config flag. +var configPath string + +// loadCfg opens the active ops config, with the selected path. +func loadCfg() (*config.Config, error) { + return config.Load(configPath) +} + +// NewRootCmd builds the root ops command. +func NewRootCmd(version string) *cobra.Command { + root := &cobra.Command{ + Use: "ops", + Short: "opencode-provider-switch: local alias + failover proxy for OpenCode", + SilenceUsage: true, + SilenceErrors: false, + Version: version, + } + root.PersistentFlags().StringVar(&configPath, "config", "", "path to ops config.json (default: $XDG_CONFIG_HOME/ops/config.json)") + + root.AddCommand(newServeCmd()) + root.AddCommand(newDoctorCmd()) + root.AddCommand(newProviderCmd()) + root.AddCommand(newAliasCmd()) + root.AddCommand(newOpencodeCmd()) + return root +} + +// fail prints to stderr and exits 1. +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) + os.Exit(1) +} diff --git a/internal/cli/serve.go b/internal/cli/serve.go new file mode 100644 index 0000000..fea1cd7 --- /dev/null +++ b/internal/cli/serve.go @@ -0,0 +1,33 @@ +package cli + +import ( + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/anomalyco/opencode-provider-switch/internal/proxy" +) + +func newServeCmd() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Run the local ops proxy (alias → failover upstream)", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + if errs := cfg.Validate(); len(errs) > 0 { + for _, e := range errs { + cmd.PrintErrln("config error:", e) + } + return errs[0] + } + srv := proxy.New(cfg) + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + return srv.ListenAndServe(ctx) + }, + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4db04b1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,322 @@ +// Package config manages the local ops JSON config file. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" +) + +// Target is one concrete upstream candidate for an alias. +type Target struct { + Provider string `json:"provider"` + Model string `json:"model"` + Enabled bool `json:"enabled"` +} + +// Alias maps a logical model name to ordered upstream targets. +type Alias struct { + Alias string `json:"alias"` + DisplayName string `json:"display_name,omitempty"` + Enabled bool `json:"enabled"` + Targets []Target `json:"targets"` +} + +// 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"` +} + +// Server holds proxy listen settings. +type Server struct { + Host string `json:"host"` + Port int `json:"port"` + APIKey string `json:"api_key"` +} + +// Config is the on-disk ops config. +type Config struct { + Server Server `json:"server"` + Providers []Provider `json:"providers"` + Aliases []Alias `json:"aliases"` + + path string + mu sync.RWMutex +} + +// Default returns an empty config with sane defaults. +func Default() *Config { + return &Config{ + Server: Server{ + Host: "127.0.0.1", + Port: 9982, + APIKey: "ops-local", + }, + Providers: []Provider{}, + Aliases: []Alias{}, + } +} + +// DefaultPath returns ~/.config/ops/config.json (XDG aware). +func DefaultPath() string { + if p := os.Getenv("OPS_CONFIG"); p != "" { + return p + } + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "ops", "config.json") + } + home, err := os.UserHomeDir() + if err != nil { + return "ops-config.json" + } + return filepath.Join(home, ".config", "ops", "config.json") +} + +// Load reads the config at path. Missing file returns a default config anchored to path. +func Load(path string) (*Config, error) { + if path == "" { + path = DefaultPath() + } + c := Default() + c.path = path + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return c, nil + } + return nil, fmt.Errorf("read config: %w", err) + } + if len(data) == 0 { + return c, nil + } + if err := json.Unmarshal(data, c); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if c.Server.Host == "" { + c.Server.Host = "127.0.0.1" + } + if c.Server.Port == 0 { + c.Server.Port = 9982 + } + if c.Server.APIKey == "" { + c.Server.APIKey = "ops-local" + } + c.path = path + return c, nil +} + +// Path returns the on-disk path of this config. +func (c *Config) Path() string { return c.path } + +// Save writes config atomically. +func (c *Config) Save() error { + c.mu.RLock() + defer c.mu.RUnlock() + if c.path == "" { + c.path = DefaultPath() + } + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + // sort for stability + 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} + data, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + data = append(data, '\n') + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write tmp: %w", err) + } + return os.Rename(tmp, c.path) +} + +// FindProvider returns the provider with matching id or nil. +func (c *Config) FindProvider(id string) *Provider { + c.mu.RLock() + defer c.mu.RUnlock() + for i := range c.Providers { + if c.Providers[i].ID == id { + return &c.Providers[i] + } + } + return nil +} + +// UpsertProvider adds or replaces a provider by id. +func (c *Config) UpsertProvider(p Provider) { + c.mu.Lock() + defer c.mu.Unlock() + for i := range c.Providers { + if c.Providers[i].ID == p.ID { + c.Providers[i] = p + return + } + } + c.Providers = append(c.Providers, p) +} + +// RemoveProvider deletes a provider and returns true if removed. +func (c *Config) RemoveProvider(id string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for i := range c.Providers { + if c.Providers[i].ID == id { + c.Providers = append(c.Providers[:i], c.Providers[i+1:]...) + return true + } + } + return false +} + +// FindAlias returns the alias record with matching name or nil. +func (c *Config) FindAlias(name string) *Alias { + c.mu.RLock() + defer c.mu.RUnlock() + for i := range c.Aliases { + if c.Aliases[i].Alias == name { + return &c.Aliases[i] + } + } + return nil +} + +// UpsertAlias adds or replaces an alias. +func (c *Config) UpsertAlias(a Alias) { + c.mu.Lock() + defer c.mu.Unlock() + for i := range c.Aliases { + if c.Aliases[i].Alias == a.Alias { + c.Aliases[i] = a + return + } + } + c.Aliases = append(c.Aliases, a) +} + +// RemoveAlias deletes an alias. +func (c *Config) RemoveAlias(name string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for i := range c.Aliases { + if c.Aliases[i].Alias == name { + c.Aliases = append(c.Aliases[:i], c.Aliases[i+1:]...) + return true + } + } + return false +} + +// AddTarget appends a target to an alias; creates alias if missing. +func (c *Config) AddTarget(alias string, t Target) error { + c.mu.Lock() + defer c.mu.Unlock() + for i := range c.Aliases { + if c.Aliases[i].Alias == alias { + // prevent exact duplicate + for _, x := range c.Aliases[i].Targets { + if x.Provider == t.Provider && x.Model == t.Model { + return fmt.Errorf("target %s/%s already bound to alias %s", t.Provider, t.Model, alias) + } + } + c.Aliases[i].Targets = append(c.Aliases[i].Targets, t) + return nil + } + } + return fmt.Errorf("alias %q not found", alias) +} + +// RemoveTarget removes a target by provider+model. +func (c *Config) RemoveTarget(alias, provider, model string) error { + c.mu.Lock() + defer c.mu.Unlock() + for i := range c.Aliases { + if c.Aliases[i].Alias != alias { + continue + } + out := c.Aliases[i].Targets[:0] + found := false + for _, t := range c.Aliases[i].Targets { + if t.Provider == provider && t.Model == model { + found = true + continue + } + out = append(out, t) + } + if !found { + return fmt.Errorf("target %s/%s not found on alias %s", provider, model, alias) + } + c.Aliases[i].Targets = out + return nil + } + return fmt.Errorf("alias %q not found", alias) +} + +// Validate returns a non-nil error slice for every structural issue found. +func (c *Config) Validate() []error { + c.mu.RLock() + defer c.mu.RUnlock() + var errs []error + ids := map[string]bool{} + for _, p := range c.Providers { + if p.ID == "" { + errs = append(errs, fmt.Errorf("provider with empty id")) + continue + } + if ids[p.ID] { + errs = append(errs, fmt.Errorf("duplicate provider id %q", p.ID)) + } + ids[p.ID] = true + if p.BaseURL == "" { + errs = append(errs, fmt.Errorf("provider %q missing base_url", p.ID)) + } + } + seen := map[string]bool{} + for _, a := range c.Aliases { + if a.Alias == "" { + errs = append(errs, fmt.Errorf("alias with empty name")) + continue + } + if seen[a.Alias] { + errs = append(errs, fmt.Errorf("duplicate alias %q", a.Alias)) + } + seen[a.Alias] = true + enabled := 0 + for _, t := range a.Targets { + if t.Provider == "" || t.Model == "" { + errs = append(errs, fmt.Errorf("alias %q has malformed target", a.Alias)) + continue + } + if !ids[t.Provider] { + errs = append(errs, fmt.Errorf("alias %q references unknown provider %q", a.Alias, t.Provider)) + } + if t.Enabled { + enabled++ + } + } + if a.Enabled && enabled == 0 { + errs = append(errs, fmt.Errorf("alias %q has no enabled targets", a.Alias)) + } + } + if c.Server.Port <= 0 || c.Server.Port > 65535 { + errs = append(errs, fmt.Errorf("invalid server port %d", c.Server.Port)) + } + return errs +} diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go new file mode 100644 index 0000000..f5844b0 --- /dev/null +++ b/internal/opencode/opencode.go @@ -0,0 +1,248 @@ +// Package opencode reads and writes OpenCode config files, including the +// `provider.ops` sync path. Files may be JSON or JSONC; we preserve the +// detected extension on write. +package opencode + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/tidwall/jsonc" +) + +// ConfigFileCandidates is the precedence order inside the global config dir. +var ConfigFileCandidates = []string{"opencode.jsonc", "opencode.json", "config.json"} + +// GlobalConfigDir returns the default user-global OpenCode config directory. +func GlobalConfigDir() string { + if dir := os.Getenv("OPENCODE_CONFIG_DIR"); dir != "" { + return dir + } + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "opencode") + } + home, err := os.UserHomeDir() + if err != nil { + return "." + } + return filepath.Join(home, ".config", "opencode") +} + +// ResolveGlobalConfigPath returns the existing global config file if any, +// otherwise the default target path (opencode.jsonc). +func ResolveGlobalConfigPath() (path string, existed bool) { + dir := GlobalConfigDir() + for _, name := range ConfigFileCandidates { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return filepath.Join(dir, "opencode.jsonc"), false +} + +// Raw is the JSON object form of an OpenCode config. We treat it as a generic +// map so unknown fields pass through untouched on write. +type Raw map[string]any + +// Load reads an OpenCode config file. Missing files yield an empty Raw. +// JSONC (line/block comments, trailing commas) is supported on read, but the +// write side always emits plain JSON (comments are not preserved). +func Load(path string) (Raw, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Raw{}, nil + } + return nil, fmt.Errorf("read %s: %w", path, err) + } + if len(data) == 0 { + return Raw{}, nil + } + stripped := jsonc.ToJSON(data) + out := Raw{} + if err := json.Unmarshal(stripped, &out); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return out, nil +} + +// Save writes Raw back to path. Parent dirs are created. Writes are atomic. +func Save(path string, raw Raw) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + // ensure $schema first; tidwall/jsonc indent not needed — plain JSON is fine. + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + data = append(data, '\n') + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write tmp: %w", err) + } + return os.Rename(tmp, path) +} + +// EnsureOpsProvider updates (or creates) the provider.ops entry with the given +// local base URL, local api key and alias set. Existing keys on provider.ops +// are preserved unless they conflict with the sync intent. Returns true if the +// file would actually change. +func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool { + changed := false + if _, ok := raw["$schema"]; !ok { + raw["$schema"] = "https://opencode.ai/config.json" + changed = true + } + provRaw, _ := raw["provider"].(map[string]any) + if provRaw == nil { + provRaw = map[string]any{} + raw["provider"] = provRaw + changed = true + } + opsRaw, _ := provRaw["ops"].(map[string]any) + if opsRaw == nil { + opsRaw = map[string]any{} + provRaw["ops"] = opsRaw + changed = true + } + if setIfDiff(opsRaw, "npm", "@ai-sdk/openai") { + changed = true + } + if setIfDiff(opsRaw, "name", "OPS") { + changed = true + } + opts, _ := opsRaw["options"].(map[string]any) + if opts == nil { + opts = map[string]any{} + opsRaw["options"] = opts + changed = true + } + if setIfDiff(opts, "baseURL", baseURL) { + changed = true + } + if setIfDiff(opts, "apiKey", apiKey) { + changed = true + } + // Build models map from alias list. Preserve any existing per-model extras + // if the alias key matches; drop aliases removed locally. + existingModels, _ := opsRaw["models"].(map[string]any) + newModels := map[string]any{} + aliasSet := map[string]bool{} + for _, a := range aliases { + aliasSet[a] = true + if existing, ok := existingModels[a].(map[string]any); ok { + // make sure "name" stays consistent with alias key + if setIfDiff(existing, "name", a) { + changed = true + } + newModels[a] = existing + } else { + newModels[a] = map[string]any{"name": a} + changed = true + } + } + // removed entries? + for k := range existingModels { + if !aliasSet[k] { + changed = true + } + } + if !mapsEqualShallow(existingModels, newModels) { + opsRaw["models"] = newModels + } + return changed +} + +// setIfDiff assigns key=val and returns true if the value actually changed. +func setIfDiff(m map[string]any, key string, val any) bool { + cur, ok := m[key] + if ok && cur == val { + return false + } + m[key] = val + return true +} + +// ImportableProvider is a subset extracted from an OpenCode custom provider. +type ImportableProvider struct { + ID string + Name string + BaseURL string + APIKey string + Models []string +} + +// ImportCustomProviders scans raw for @ai-sdk/openai custom providers that +// declare baseURL and an apiKey-compatible setting. The `ops` id itself is +// skipped so sync output is not re-imported. +func ImportCustomProviders(raw Raw) []ImportableProvider { + out := []ImportableProvider{} + provRaw, _ := raw["provider"].(map[string]any) + for id, v := range provRaw { + if id == "ops" { + continue + } + m, ok := v.(map[string]any) + if !ok { + continue + } + npm, _ := m["npm"].(string) + if npm != "@ai-sdk/openai" { + continue + } + opts, _ := m["options"].(map[string]any) + if opts == nil { + continue + } + baseURL, _ := opts["baseURL"].(string) + apiKey, _ := opts["apiKey"].(string) + if baseURL == "" { + continue + } + ip := ImportableProvider{ + ID: id, + BaseURL: baseURL, + APIKey: apiKey, + } + if n, ok := m["name"].(string); ok { + ip.Name = n + } + if models, ok := m["models"].(map[string]any); ok { + for k := range models { + ip.Models = append(ip.Models, k) + } + } + out = append(out, ip) + } + return out +} + +// mapsEqualShallow compares two string-keyed maps for structural equality. +func mapsEqualShallow(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + bv, ok := b[k] + if !ok { + return false + } + am, amOk := v.(map[string]any) + bm, bmOk := bv.(map[string]any) + if amOk && bmOk { + if !mapsEqualShallow(am, bm) { + return false + } + continue + } + if v != bv { + return false + } + } + return true +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go new file mode 100644 index 0000000..30ece2a --- /dev/null +++ b/internal/proxy/server.go @@ -0,0 +1,357 @@ +// Package proxy implements the local `/v1/responses` HTTP server that resolves +// ops aliases and forwards requests to upstream providers with deterministic +// pre-first-byte failover. +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/anomalyco/opencode-provider-switch/internal/config" +) + +// Server is the local ops HTTP proxy. +type Server struct { + cfg *config.Config + client *http.Client + logger *log.Logger +} + +// New constructs a Server from cfg. +func New(cfg *config.Config) *Server { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + // no response buffering so streams flow through immediately + DisableCompression: false, + ForceAttemptHTTP2: true, + } + return &Server{ + cfg: cfg, + client: &http.Client{ + Transport: transport, + Timeout: 0, // streaming, no overall timeout + }, + logger: log.New(log.Writer(), "[ops] ", log.LstdFlags|log.Lmicroseconds), + } +} + +// ListenAndServe starts the HTTP listener until ctx is cancelled. +func (s *Server) ListenAndServe(ctx context.Context) error { + addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port) + mux := http.NewServeMux() + mux.HandleFunc("/v1/responses", s.handleResponses) + mux.HandleFunc("/v1/models", s.handleModels) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe() }() + s.logger.Printf("listening on http://%s", addr) + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +// handleModels exposes a minimal /v1/models listing of alias names. OpenCode +// does not rely on this, but clients sometimes probe it for connectivity. +func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) { + if !s.authorize(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + data := []map[string]any{} + for _, a := range s.cfg.Aliases { + if !a.Enabled { + continue + } + data = append(data, map[string]any{ + "id": a.Alias, + "object": "model", + "owned_by": "ops", + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data}) +} + +// authorize enforces the static local api key when one is configured. +func (s *Server) authorize(r *http.Request) bool { + expected := s.cfg.Server.APIKey + if expected == "" { + return true + } + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") == expected + } + if k := r.Header.Get("X-Api-Key"); k != "" { + return k == expected + } + return false +} + +var reqCounter uint64 + +// handleResponses is the main alias→failover proxy entry. +func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { + reqID := atomic.AddUint64(&reqCounter, 1) + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !s.authorize(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 50<<20)) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) + return + } + aliasName, _ := payload["model"].(string) + if aliasName == "" { + http.Error(w, "missing model field", http.StatusBadRequest) + return + } + alias := s.cfg.FindAlias(aliasName) + if alias == nil { + http.Error(w, fmt.Sprintf("alias %q not found", aliasName), http.StatusNotFound) + return + } + if !alias.Enabled { + http.Error(w, fmt.Sprintf("alias %q is disabled", aliasName), http.StatusNotFound) + return + } + var targets []config.Target + for _, t := range alias.Targets { + if t.Enabled { + targets = append(targets, t) + } + } + if len(targets) == 0 { + http.Error(w, fmt.Sprintf("alias %q has no enabled targets", aliasName), http.StatusFailedDependency) + return + } + + failoverCount := 0 + for attempt, t := range targets { + p := s.cfg.FindProvider(t.Provider) + if p == nil { + s.logger.Printf("req=%d alias=%s attempt=%d target provider %q missing, skipping", reqID, aliasName, attempt+1, t.Provider) + failoverCount++ + continue + } + // rewrite payload.model for this upstream + cloned := cloneMap(payload) + cloned["model"] = t.Model + newBody, err := json.Marshal(cloned) + if err != nil { + s.logger.Printf("req=%d marshal error: %v", reqID, err) + http.Error(w, "marshal error", http.StatusInternalServerError) + return + } + + ok, retryable, upstreamErr := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount) + if ok { + return + } + if !retryable { + // final — response was either already committed or unrecoverable + s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr) + return + } + s.logger.Printf("req=%d alias=%s attempt=%d retryable: %v", reqID, aliasName, attempt+1, upstreamErr) + failoverCount++ + } + + // exhausted all targets with retryable failures + http.Error(w, fmt.Sprintf("all upstream targets failed for alias %q", aliasName), http.StatusBadGateway) +} + +// tryOnce proxies one attempt. Returns (ok, retryable, err). +// ok=true means successful response fully/partially written to client. +// retryable=true means failure happened before any bytes flushed downstream. +func (s *Server) tryOnce( + ctx context.Context, + w http.ResponseWriter, + clientReq *http.Request, + provider *config.Provider, + target config.Target, + body []byte, + aliasName string, + attempt int, + failoverCount int, +) (ok bool, retryable bool, err error) { + upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses" + upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) + if err != nil { + return false, false, fmt.Errorf("build request: %w", err) + } + copyForwardHeaders(upReq.Header, clientReq.Header) + upReq.Header.Set("Content-Type", "application/json") + upReq.Header.Set("Accept", clientReq.Header.Get("Accept")) + if provider.APIKey != "" { + upReq.Header.Set("Authorization", "Bearer "+provider.APIKey) + } + for k, v := range provider.Headers { + upReq.Header.Set(k, v) + } + upReq.ContentLength = int64(len(body)) + + resp, err := s.client.Do(upReq) + if err != nil { + return false, true, fmt.Errorf("upstream dial/transport: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 500 || resp.StatusCode == 429 { + // drain up to small cap for logging context + peek, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(peek), 200)) + } + if resp.StatusCode >= 400 { + // non-retryable: forward status + body to client + s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount) + copyResponseHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + return true, false, fmt.Errorf("upstream %d", resp.StatusCode) + } + + // 2xx: start streaming pass-through. From this point no failover is allowed. + s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount) + copyResponseHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + flusher, _ := w.(http.Flusher) + buf := make([]byte, 16<<10) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return true, false, werr + } + if flusher != nil { + flusher.Flush() + } + } + if rerr != nil { + if errors.Is(rerr, io.EOF) { + return true, false, nil + } + // mid-stream error — already committed, cannot failover + return true, false, rerr + } + } +} + +// writeDebugHeaders sets the X-OPS-* debug headers before WriteHeader. +func (s *Server) writeDebugHeaders(w http.ResponseWriter, alias, provider, remoteModel string, attempt, failoverCount int) { + h := w.Header() + h.Set("X-OPS-Alias", alias) + h.Set("X-OPS-Provider", provider) + h.Set("X-OPS-Remote-Model", remoteModel) + h.Set("X-OPS-Attempt", fmt.Sprintf("%d", attempt)) + h.Set("X-OPS-Failover-Count", fmt.Sprintf("%d", failoverCount)) +} + +// hopByHopHeaders lists headers that must not be forwarded per RFC 7230. +var hopByHopHeaders = map[string]bool{ + "Connection": true, + "Proxy-Connection": true, + "Keep-Alive": true, + "Proxy-Authenticate": true, + "Proxy-Authorization": true, + "Te": true, + "Trailer": true, + "Transfer-Encoding": true, + "Upgrade": true, +} + +// copyForwardHeaders copies safe request headers from client to upstream, +// dropping Authorization (the upstream key replaces it) and hop-by-hop headers. +func copyForwardHeaders(dst, src http.Header) { + for k, vs := range src { + ck := http.CanonicalHeaderKey(k) + if hopByHopHeaders[ck] { + continue + } + switch ck { + case "Authorization", "X-Api-Key", "Host", "Content-Length": + continue + } + for _, v := range vs { + dst.Add(ck, v) + } + } +} + +// copyResponseHeaders copies upstream response headers into client response. +func copyResponseHeaders(dst, src http.Header) { + for k, vs := range src { + ck := http.CanonicalHeaderKey(k) + if hopByHopHeaders[ck] { + continue + } + if ck == "Content-Length" { + // We may transform nothing, but streaming responses often omit this. + continue + } + for _, v := range vs { + dst.Add(ck, v) + } + } +} + +// cloneMap performs a shallow copy of a top-level map. +func cloneMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// truncate returns s truncated to at most n bytes (best-effort). +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +}