feat: add ops MVP failover proxy

This commit is contained in:
apale7 2026-04-17 07:51:42 +08:00
parent 3c3212e603
commit 29190a806e
15 changed files with 2133 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/ops
/dist/
*.tmp

486
README.md Normal file
View File

@ -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/<alias>`
例如:
- `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 <id> --base-url <url-with-/v1> --api-key <key>
```
查看 provider
```bash
ops provider list
```
删除 provider
```bash
ops provider remove <id>
```
注意:删除 provider 不会自动帮你清理 alias 里的引用。引用还在的话,`ops doctor` 会报错。
### alias
创建或更新 alias
```bash
ops alias add --name <alias>
```
给 alias 追加一个 target
```bash
ops alias bind --alias <alias> --provider <provider-id> --model <upstream-model>
```
解绑 target
```bash
ops alias unbind --alias <alias> --provider <provider-id> --model <upstream-model>
```
查看 alias
```bash
ops alias list
```
删除 alias
```bash
ops alias remove <alias>
```
### 其他
静态检查:
```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/<alias>`
先检查这几件事:
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 <alias> --provider <provider-id> --model <model>
```
### 本地代理鉴权是什么?
默认是静态 key
```text
ops-local
```
OpenCode 在 `provider.ops.options.apiKey` 里会使用这个值。直接手工请求本地代理时,也要带上这个 key。
## 安全说明
- 默认只监听 `127.0.0.1`
- 上游凭据保存在本地 `ops` 配置文件中
- 本项目当前没有做多用户或远程网络安全保证
所以请把本地配置文件当成敏感文件处理。
## 英文版 README
原英文版文档已保存在:
- `README_EN.md`

87
README_EN.md Normal file
View File

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

20
cmd/ops/main.go Normal file
View File

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

13
go.mod Normal file
View File

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

12
go.sum Normal file
View File

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

179
internal/cli/alias.go Normal file
View File

@ -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/<name> 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 <alias>",
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

48
internal/cli/doctor.go Normal file
View File

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

101
internal/cli/opencode.go Normal file
View File

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

180
internal/cli/provider.go Normal file
View File

@ -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 <id>",
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:]
}

44
internal/cli/root.go Normal file
View File

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

33
internal/cli/serve.go Normal file
View File

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

322
internal/config/config.go Normal file
View File

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

View File

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

357
internal/proxy/server.go Normal file
View File

@ -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] + "…"
}