⚠️ 本仓库已弃用,停止维护。 项目已拆分为两个新仓库,请前往:
- GetOptaris/optaris-core —— 路由网关核心
- GetOptaris/optaris-desktop —— 桌面端
以下文档仅作历史存档。
用 Go 实现的 LLM API 路由网关:纯转发核心、无 GUI,对外暴露两个端口——
- AI API 端口(默认
:8080):终端用户用 OpenAI Chat / OpenAI Responses / Claude Messages / Gemini 四种原生格式调用;网关按格式识别、鉴权,在分组内按「价格 / 首 token / 速度 / 成功率」综合打分选上游,直通代理;失败时按协议(而非 HTTP 状态码)判定并重试 / 换上游。带 session 标识的同会话请求会尽量粘住同一上游以命中上游缓存(会话亲和)。 - 管理端口(默认
127.0.0.1:8081):上层业务系统通过 HTTP API 维护渠道 / 分组 / API Key 与全局设置,并拉取每次请求生成的「请求事件」。配置写入后对后续请求立即生效、无需重启。
技术基调:单实例实现、按多实例设计;热路径只读进程内存,SQLite 仅承载极少的配置写与可批量的事件写;纯 Go 驱动 modernc.org/sqlite(免 CGO);配置库与事件库分离。
各 GitHub Release 附带全平台(Linux / macOS / Windows × amd64 / arm64)预编译压缩包,已内嵌管理 UI、免装 Go 与 Node,解压即得单个可执行文件:
# 从 Releases 页面下载对应平台的包(下例为 Linux x86_64,版本号按需替换;Windows 为 .zip)
tar -xzf optaris_1.0.0_linux_amd64.tar.gz
ADMIN_KEY=secret ./optarismacOS 首次运行若被 Gatekeeper 拦截(「无法验证开发者」),到「系统设置 → 隐私与安全性」放行,或执行
xattr -d com.apple.quarantine ./optaris。
维护者发版:推送
v前缀的 tag 即由 GitHub Actions 自动交叉编译并发布,例如git tag v1.0.0 && git push origin v1.0.0。运行中的版本可经/healthz查看。
需要 Go 1.26+。
# 构建
go build ./cmd/optaris
# 运行(ADMIN_KEY 必填,缺失即拒绝启动)
ADMIN_KEY=secret go run ./cmd/optaris两个端口都提供公开探活端点(无需鉴权):
curl -s 127.0.0.1:8080/healthz # AI 端口
curl -s 127.0.0.1:8081/healthz # 管理端口
# => {"status":"ok","version":"dev","commit":"none","date":"unknown"}
# (go run / go build 时为 dev 占位;GitHub Release 的二进制会显示真实版本 / commit / 构建时间)进程收到 SIGINT / SIGTERM 时优雅关闭:停止接收新请求、drain 在途请求(含长流式响应)、flush 事件队列后退出。两个端口均为裸 HTTP,TLS 由外层反向代理终结。
仅在启动时读取,不在运行时设置接口里。
| 变量 | 含义 | 默认值 |
|---|---|---|
AI_API_ADDR |
AI API 监听地址(对外暴露) | :8080 |
ADMIN_ADDR |
管理接口监听地址(默认只绑本机/内网) | 127.0.0.1:8081 |
ADMIN_KEY |
管理接口鉴权凭证 | (必填,缺失或为空即启动失败) |
DATA_DIR |
数据目录(配置库与事件库文件均落于此) | ./data |
SHUTDOWN_TIMEOUT |
优雅关闭最长 drain 时长(time.ParseDuration 格式) |
5m |
ADMIN_KEY只来自环境变量、不落库、不写日志;轮换即改环境变量后重启。
DATA_DIR下是config.db与events.db两个独立库文件(各自 WAL,互不阻塞写);目录不存在会自动创建。WAL 模式每个库还带-wal/-shm伴生文件,故 Docker 运行时请挂载整个目录、不要去挂单个.db文件(挂单文件会导致 WAL 写入异常 / 数据损坏),例如-v optaris-data:/data -e DATA_DIR=/data。
管理端口除 HTTP API 外,还在根 / 下提供一个内嵌的 React 管理界面:前端工程在 web/,
构建产物经 go:embed 烤进同一个二进制——部署仍是单一自包含二进制,UI 与 admin API 同版本发布。
make web # 构建前端 → internal/webui/dist(需 Node)
make build # 先构建 UI 再编译二进制,UI 被 embed 进去
ADMIN_KEY=secret go run ./cmd/optaris
# 浏览器打开 http://127.0.0.1:8081/ ,填入 ADMIN_KEY 即进入前端开发态(热更新):
cd web && npm install && npm run dev # :5173,/admin 与 /healthz 经 vite proxy 转发到本地网关要点:
- 静态资源在
/、不鉴权(浏览器加载文档/JS/CSS 无法携带自定义头);bundle 内不含密钥, admin key 由用户在 UI 里手填,前端再用X-Admin-Key头调/admin/*。/admin/、/healthz作为更具体的路由优先匹配,不受根路由影响。 - 仓库只提交
internal/webui/dist/.gitignore占位(构建产物不入 git),故未跑make web时go build仍能编译,只是访问/会得到「UI 尚未构建」提示页。 - 管理端口默认绑
127.0.0.1,远程用浏览器访问需改ADMIN_ADDR或经反向代理。
启动网关后,先用管理接口造一套「渠道 / 分组 / Key」,再用拿到的密钥串发 AI 请求:
ADMIN_KEY=secret go run ./cmd/optaris &
ADMIN_KEY=secret bash scripts/admin-smoke.sh # 造渠道/分组/Key,打印出 SECRETscripts/admin-smoke.sh 会输出 SECRET=sk-...,即下面 AI 调用要用的网关 Key 密钥串。
四格式通过 URL path 区分;网关 Key 的携带位置因格式而异。选中渠道后 model 原样转发上游、不重映射。
OpenAI Chat Completions — POST /v1/chat/completions,Key 在 Authorization: Bearer,model/stream 在 body:
curl -s 127.0.0.1:8080/v1/chat/completions \
-H "Authorization: Bearer sk-xxxx" -H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"stream":false}'OpenAI Responses — POST /v1/responses,Key 同上:
curl -s 127.0.0.1:8080/v1/responses \
-H "Authorization: Bearer sk-xxxx" -H "Content-Type: application/json" \
-d '{"model":"gpt-4o","input":"hi","stream":false}'Claude Messages — POST /v1/messages,Key 在 x-api-key(缺失时回落 Authorization: Bearer;转发上游沿用同一个头):
curl -s 127.0.0.1:8080/v1/messages \
-H "x-api-key: sk-xxxx" -H "Content-Type: application/json" \
-d '{"model":"claude-3-5-sonnet","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'Gemini — model 在 path、流式是独立 endpoint、Key 在 ?key= 或 x-goog-api-key:
# 非流式
curl -s "127.0.0.1:8080/v1beta/models/gemini-2.0-flash:generateContent?key=sk-xxxx" \
-H "Content-Type: application/json" \
-d '{"contents":[{"parts":[{"text":"hi"}]}]}'
# 流式(独立 endpoint + ?alt=sse)
curl -s "127.0.0.1:8080/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=sk-xxxx" \
-H "Content-Type: application/json" \
-d '{"contents":[{"parts":[{"text":"hi"}]}]}'流式请求(OpenAI/Claude 在 body 传 "stream":true;Gemini 用 :streamGenerateContent)返回 text/event-stream,网关在收到协议「开始事件」后才提交并逐事件转发。
/v1/*、/v1beta/* 用对应格式自身的错误信封(OpenAI / Claude / Gemini 各一套),使各家 SDK 能正常识别:
| 情形 | HTTP |
|---|---|
| API Key 不存在 / 已停用 | 401 |
| 绑定分组已删除(提示改绑)/ 空分组 / 无支持该模型的渠道 | 400 |
| 请求体超过缓冲上限 | 413 |
| 全部上游失败 / 整体超时(body 附全部上游错误) | 502 |
不论成败(含上述前置失败),每个请求都会异步写一条「请求事件」,可经管理接口拉取。同时,每个响应(成功、前置失败、失败转移 502 一律)都带上响应头 X-Optaris-Request-Id(req_ 前缀),其值即该请求事件 / 抓包记录的 req_id——客户端可凭此头反查服务端对应的事件与抓包,便于排查。
带 session 标识的客户端在同一会话内的请求尽量被路由到同一个上游渠道,以持续命中上游 prompt cache(又快又省钱——换上游会丢缓存、整段历史按 input tokens 重新计费、首 token 变慢)。这是 best-effort 旁路:取不到 session 标识的请求(Gemini、未带标识的客户端)行为完全不变、绝不影响主路由;不做强一致,绑定按空闲 TTL 过期后回弹重新打分(期望行为)。
session 标识按格式提取:
| 格式 | session 来源 |
|---|---|
| OpenAI Chat / Responses(Codex CLI) | HTTP header session_id(兼容连字符 session-id) |
| Claude Messages(Claude Code) | body metadata.user_id |
| Gemini / 其它 | 无(不启用亲和) |
绑定仅在请求干净成功时建立 / 续期(client_canceled、提交后失败、全失败 502 都不绑定);绑定的渠道一旦停用 / 删除 / 进入冷却 / 本次已失败而掉出候选集,就自然回退到打分重选。两项设置可调(见下「设置」):session_affinity_enabled(默认开)、session_affinity_ttl(默认 10m,锚定上游缓存窗口;每次成功续期)。
当前为单实例内存账本,多实例部署各实例本地、不互通,最坏只是命中率打折、不出错。
所有 /admin/* 需带 X-Admin-Key 头(用网关自身的 JSON 错误结构返回错误)。下面用 ADDR=127.0.0.1:8081、K=secret。
资源 CRUD(/admin/channels、/admin/groups、/admin/keys,PUT 为全量替换):
# 建渠道
curl -s -H "X-Admin-Key:$K" -H "Content-Type:application/json" -X POST $ADDR/admin/channels -d '{
"name":"c1","base_url":"https://api.openai.com","api_key":"sk-up",
"models":["gpt-4o"],"price_weight":1,"enabled":true
}'
# 建分组(成员按渠道 id 引用)
curl -s -H "X-Admin-Key:$K" -H "Content-Type:application/json" -X POST $ADDR/admin/groups \
-d '{"name":"g1","channel_ids":["ch_xxx"]}'
# 建 Key(绑分组;响应含 sk- 密钥串,即终端用户凭证)
curl -s -H "X-Admin-Key:$K" -H "Content-Type:application/json" -X POST $ADDR/admin/keys \
-d '{"name":"k1","group_id":"grp_xxx","enabled":true}'
# 列表(包一层 {"data":[...]})
curl -s -H "X-Admin-Key:$K" $ADDR/admin/channels设置(GET|PATCH /admin/settings,PATCH 为部分更新:缺省字段不变、显式 null 重置为默认):
curl -s -H "X-Admin-Key:$K" $ADDR/admin/settings # 读全部设置
curl -s -H "X-Admin-Key:$K" -X PATCH $ADDR/admin/settings -d '{"default_retry_n":5}' # 改一项
curl -s -H "X-Admin-Key:$K" -X PATCH $ADDR/admin/settings -d '{"default_retry_n":null}' # 重置该项默认
curl -s -H "X-Admin-Key:$K" -X PATCH $ADDR/admin/settings \
-d '{"session_affinity_enabled":false,"session_affinity_ttl":"30m"}' # 关会话亲和 / 调 TTL请求事件(拉取式:业务按自己节奏拉,确认后清理):
# 按单调自增 id 游标拉取(可附 since/until/key_id/model 过滤)
curl -s -H "X-Admin-Key:$K" "$ADDR/admin/events?after=0&limit=50"
# 默认升序(最旧在前,便于游标消费);附 order=desc 则最新在前、after 作上界往更旧翻(管理面板用)
curl -s -H "X-Admin-Key:$K" "$ADDR/admin/events?after=0&limit=50&order=desc"
# 确认已处理到某 id,删除该 id 及之前的事件
curl -s -H "X-Admin-Key:$K" -X POST "$ADDR/admin/events/ack?through=123"每条事件含:收到/完成时间、是否流式、命中的 key_id/分组/model、本次尝试过的上游列表、归一化 usage(成功时;否则 null)、outcome(success/failed/client_canceled/rejected)、最终 HTTP 状态码,以及 req_id(与下面「请求抓包」一对一关联,可下钻同一请求的完整链路原文)。
范围边界:网关不实现配额 / 计费 / 团队额度(业务系统职责)。业务基于「请求事件」自行核算,额度耗尽时调管理接口停用对应 Key。
请求抓包(诊断用,默认关、按需开;与请求事件经 req_id 关联):开启后把每次请求的完整链路原文落到独立的 captures.db——A 用户→网关请求头体、每次尝试的 B 网关→上游请求头体与 C 上游→网关响应头体(含失败响应完整体,不像事件的 error_text 截 2KB)。鉴权头(用户 sk- / 上游 key / Gemini URL ?key=)落盘前打码,但请求 / 响应体为原文明文存储(含 prompt 与上游返回,可能涉敏感数据),请按需开启、用完及时 ack 清理。
# 经 PATCH /admin/settings 开启(三态部分更新;retention 单位为字节)
curl -s -H "X-Admin-Key:$K" -X PATCH $ADDR/admin/settings \
-d '{"capture_enabled":true,"capture_mode":"failed_only","capture_retention_bytes":5368709120}'
# 拉列表(投影,附 total_bytes;可按 outcome/model/key_id/req_id 过滤;同样支持 order=desc)
curl -s -H "X-Admin-Key:$K" "$ADDR/admin/captures?after=0&limit=50"
# 取单条全量详情(A 头体 + B/C 数组)
curl -s -H "X-Admin-Key:$K" $ADDR/admin/captures/1
# 确认处理到某 id,删除该 id 及之前
curl -s -H "X-Admin-Key:$K" -X POST "$ADDR/admin/captures/ack?through=123"capture_mode:failed_only(默认,只落含失败 attempt 或非success收尾的请求,换上游后成功的也落、便于对比)/all(全落)。两种模式单请求采集开销相同,failed_only只是少落盘——高 QPS 生产环境长期开启需评估内存 / 性能成本。capture_retention_bytes:按字节 FIFO 删最旧(与请求事件按条数不同),0表示不限;captures.db与 config / events 库同目录、各自 WAL。
go test ./... # 全部单测 + 端到端
go test -race ./... # 带竞态检测(CI 基线)端到端测试在 e2e/:用真实临时 SQLite + 真实装配的网关(internal/app)+ 可配置 mock 上游,覆盖四格式正常流式/非流式、重试/换上游/冷却、T1/T2/T3 超时、各错误信封、usage 注入剥离与归一化、请求事件拉取/ack、配置即时生效、优雅关闭等场景。