feat: add ops MVP failover proxy
This commit is contained in:
parent
3c3212e603
commit
29190a806e
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/ops
|
||||
/dist/
|
||||
*.tmp
|
||||
486
README.md
Normal file
486
README.md
Normal 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
87
README_EN.md
Normal 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
20
cmd/ops/main.go
Normal 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
13
go.mod
Normal 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
12
go.sum
Normal 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
179
internal/cli/alias.go
Normal 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
48
internal/cli/doctor.go
Normal 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
101
internal/cli/opencode.go
Normal 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
180
internal/cli/provider.go
Normal 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
44
internal/cli/root.go
Normal 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
33
internal/cli/serve.go
Normal 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
322
internal/config/config.go
Normal 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
|
||||
}
|
||||
248
internal/opencode/opencode.go
Normal file
248
internal/opencode/opencode.go
Normal 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
357
internal/proxy/server.go
Normal 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] + "…"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user