Skip to content
This repository was archived by the owner on Jul 12, 2026. It is now read-only.

Repository files navigation

Optaris · LLM API 路由网关

⚠️ 本仓库已弃用,停止维护。 项目已拆分为两个新仓库,请前往:

以下文档仅作历史存档。

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 ./optaris

macOS 首次运行若被 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.dbevents.db 两个独立库文件(各自 WAL,互不阻塞写);目录不存在会自动创建。WAL 模式每个库还带 -wal / -shm 伴生文件,故 Docker 运行时请挂载整个目录、不要去挂单个 .db 文件(挂单文件会导致 WAL 写入异常 / 数据损坏),例如 -v optaris-data:/data -e DATA_DIR=/data


管理 UI(内嵌 SPA)

管理端口除 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 webgo 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,打印出 SECRET

scripts/admin-smoke.sh 会输出 SECRET=sk-...,即下面 AI 调用要用的网关 Key 密钥串。


AI API 调用示例(四格式)

四格式通过 URL path 区分;网关 Key 的携带位置因格式而异。选中渠道后 model 原样转发上游、不重映射。

OpenAI Chat CompletionsPOST /v1/chat/completions,Key 在 Authorization: Bearermodel/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 ResponsesPOST /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 MessagesPOST /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-Idreq_ 前缀),其值即该请求事件 / 抓包记录的 req_id——客户端可凭此头反查服务端对应的事件与抓包,便于排查。

会话亲和(sticky session)

带 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,锚定上游缓存窗口;每次成功续期)。

当前为单实例内存账本,多实例部署各实例本地、不互通,最坏只是命中率打折、不出错。


管理 API 示例

所有 /admin/* 需带 X-Admin-Key 头(用网关自身的 JSON 错误结构返回错误)。下面用 ADDR=127.0.0.1:8081K=secret

资源 CRUD/admin/channels/admin/groups/admin/keysPUT 为全量替换):

# 建渠道
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/settingsPATCH 为部分更新:缺省字段不变、显式 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)、outcomesuccess/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_modefailed_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、配置即时生效、优雅关闭等场景。

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages