契约冻结 · 56 端点字段快照

接口参考

四种客户端协议共用一条转发链路;同协议字节直通,跨协议经 IR 翻译。管理面 56 个端点的响应字段由快照守卫钉住,改动会直接让守卫和前端类型检查失败。

01概览

内核是一个单独的 Rust 可执行文件,同时承担数据面(转发 + 计费)与控制面(Admin API)。上游密钥、账务、日志都留在你自己机器上;数据面热路径不访问数据库。

  • 数据面:/v1/* 与 /v1beta/* 六个入口路由到同一个 relay_handler,由路径判定协议。
  • 控制面:/admin/* 近四十个端点,单一 admin_token(Bearer)保护。
  • 自助面:/api/auth/*(注册/登录/会话)、/api/tokens、/api/logs、/api/usage、/api/redeem、/api/rooms;GET /api/quota 用 API Key 自查余额;GET /health 返回 ok。
  • 存储:单个 SQLite 文件(WAL + busy_timeout),额度驻内存,200ms 批量落库。

分层原则

转发链路只认三件事:客户端协议、渠道协议、模型名。其余(分组、房间、倍率)都是过滤与记账参数,不参与协议判定。因此加一种新协议只需扩 IR 两端,不动计费与调度。

02鉴权与密钥

客户端密钥按固定顺序探测,四种携带方式任一命中即可,这让各家官方 SDK 都不用改:

顺序位置形态典型来源
1请求头Authorization: Bearer sk-…OpenAI SDK / 自写客户端
2请求头x-api-key: sk-…Anthropic SDK
3请求头x-goog-api-key: sk-…Gemini SDK
4查询串?key=sk-…Gemini REST 习惯(转发时会从上游查询串剔除)

令牌与用户的双重校验

一次请求会把令牌和用户各读一次内存快照,任一不过即拒:

检查命中结果说明
key 不存在401令牌密钥未注册
token.status ≠ 1401令牌被禁用
token.expired_at401-1 表示永不过期,否则与当前时间比较
token.model_limits403逗号分隔白名单;空表示不限模型
user.status ≠ 1401用户被禁用,令牌再有效也没用
额度不足402预扣失败即拒,请求不会打到上游

remain_quota 为 -1 表示该令牌跟随用户余额;用户余额 -1 表示不限量。

03快速接入

四个协议的真实请求体。点任一标签切换。

from openai import OpenAI

client = OpenAI(
    base_url="https://omniaxis.cc/v1",
    api_key="sk-your-token",
)
for chunk in client.chat.completions.create(
    model="deepseek-flash",                       # 渠道 model_mapping 可改名
    messages=[{"role": "user", "content": "写一首关于内核的诗"}],
    stream=True,
):
    print(chunk.choices[0].delta.content or "", end="")

跨协议流式是缓冲的

客户端与渠道协议不同时,流式请求会强制上游非流式,拿到完整 JSON 后按客户端协议合成 SSE——事件形状一定对,但首字延迟会累。同协议仍是真流式。

04网关端点

聊天四协议路由指向同一个处理函数,协议由路径前缀判定;GPT 文本优先用 /v1/responses(Codex 原生),Chat 仍可用;图片走独立的按张计费路径。

POST/v1/responsesOpenAI Responses · GPT 原生 / Codex
POST/v1/chat/completionsOpenAI Chat Completions
POST/v1/messagesAnthropic Messages
POST/v1/images/generationsOpenAI Images(按张计费,n 上限 4)
POST/v1beta/models/{model}:generateContentGemini(流式加 :streamGenerateContent)
POST/v1/models/{model}:generateContentGemini 兼容路径(无 v1beta 前缀)
GET/v1/models可用模型列表(OpenAI 形)
GET/api/quotaAPI Key 自查余额
GET/health存活探针,纯文本 ok

图片请求示例:{"model":"gpt-image-2","prompt":"…","n":1,"size":"1024x1024"}。响应原样透传 data[].url|b64_json;失败整笔退款,日志入口记为 images。

05跨协议矩阵

行是客户端协议,列是渠道(上游)协议。对角线是字节直通,其余经 IR 翻译。

客户端 ↓ · 渠道 →OpenAIResponsesAnthropicGemini
/v1/chat/completions ✓ ⇄ ⇄ ⇄
/v1/responses ⇄ ✓ — —
/v1/messages ⇄ — ✓ —
/v1beta/…:generateContent ⇄ — — ✓
真实上游已验 · 7 格 仅本地 mock 已验 · 3 格 无任何断言覆盖 · 6 格

Gemini 作为客户端跨入真实上游已验(realxtest.mjs),作为渠道只有 mock——手上没有可用的真实 Gemini 密钥,所以对角线最后一格不能标真验。

真实上游断言清单(realchannel 19 + realtest 26 + realxtest 14 = 59 条)

用例客户端 → 渠道流式断言要点
AAnthropic → Anthropic否200、type=message、内容块非空、usage.input_tokens>0、stop_reason 存在
BOpenAI → Anthropic否200、object=chat.completion、message.content 非空、Anthropic usage 归一回 prompt_tokens
CResponses → Responses否200、object=response、output[].content[].text 非空
DOpenAI → Responses否200、译回 OpenAI 结构与文本
EAnthropic → Anthropic是收到 message_start 与 content_block_delta,直通不缓冲
FOpenAI → Anthropic是缓冲后合成,收到 OpenAI SSE 且含 data: [DONE]
F2OpenAI → Responses是缓冲后合成,SSE 内含真实文本增量与 [DONE]

为什么 A 只验结构与用量

被测模型会先输出 thinking 块再输出 text 块,小 max_tokens 下内容块顺序不可控。断言因此钉在「协议结构 + 至少一个内容块 + usage + stop_reason」,而不是钉死首个块的类型。

06流式语义

客户端只要看到自己协议该看的东西。同协议时内核不解析 SSE,原样转发;跨协议时按下面的规则合成。

客户端协议事件 / 终止标志跨协议合成来源
OpenAI Chatdata: {choices:[{delta}]} … data: [DONE]IR 文本增量 + 末尾 usage 块
Responsesresponse.output_text.delta … response.completedIR 增量映射到 delta 事件
Anthropicmessage_start / content_block_delta / message_stop补齐块索引与 stop_reason
Gemini:streamGenerateContent 的 JSON 数组分片按 candidates[].parts 合成

停止原因映射

语义OpenAIAnthropicGemini
正常结束finish_reason=stopstop_reason=end_turnfinishReason=STOP
长度截断lengthmax_tokensMAX_TOKENS
工具调用tool_callstool_useSTOP + functionCall

07错误语义

所有被内核拦下的错误都是同一形状,且发生在扣费或转发之前——不会产生上游费用。

状态error.type触发账务影响
401invalid_authentication密钥无效 / 令牌禁用 / 过期 / 用户禁用无
403model_not_allowed令牌模型白名单外无
403room_forbidden房间不存在或已冻结 / 关闭无
402insufficient_quota个人余额或房间池预扣不动无(fail-closed)
400invalid_request请求体解析失败、缺模型名等预扣已退
502upstream_error所有候选渠道重试后仍失败预扣全额退回
409room_full加入房间时名额抢占失败(已满)无
404not_found控制面记录不存在无
500internal_error内部异常,同时写 error 日志预扣退回

渠道侧的失败处置

  • 401 / 403 上游响应 → 渠道状态立即置为熔断,并记 banned_at。
  • 429 → 不计入失败次数,只做退避并换下一个候选渠道。
  • 其他失败 → fail_count 累加,达到 ban_threshold(默认 5)熔断。
  • 熔断渠道由 recover_banned 在 auto_recover_secs(默认 300 秒)后自动恢复。

08响应信封

控制面成功统一信封、失败统一信封,两套结构互不混杂:

成功
{
  "success": true,
  "data": { … typed view … }
}
失败
{
  "error": {
    "message": "quota exhausted",
    "type": "insufficient_quota"
  }
}

data 的字段清单由 Rust 侧 Serialize 视图类型决定,再由守卫脚本导出成 JSON 快照;前端 schema.gen.ts 从快照生成,schema.check.ts 用编译期 AssertEqual 把面板类型钉在快照上。

09Admin 契约

全部 /admin/* 走同一条中间件:请求头 Authorization: Bearer <admin_token> 必须与配置一致,否则 401。共 56 个端点,按模块列出方法与字段。

models · 5 端点

GET/admin/models模型目录 + $/1M 价目
POST/admin/models新增/更新模型
PUT/admin/models/{model}改价/上下架
DEL/admin/models/{model}删除(含倍率)
POST/admin/models/sync从渠道补齐模型

保存时反算 model_ratios(输入 $/1M ÷ 2 = 模型倍率),门户 GET /api/models 读同一份价格;模型页与首页轮播因此不会再出现“页面价 ≠ 扣费价”。

users · 8 端点

GET/admin/users列表
POST/admin/users创建
GET/admin/users/{id}详情(含令牌)
PUT/admin/users/{id}更新
POST/admin/users/{id}/quota调整额度
GET/admin/users/{id}/account登录邮箱 / 会话数
PUT/admin/users/{id}/password重置密码(踢下线)
DEL/admin/users/{id}删除

UserView 字段:

idi64 nameString group_nameString quotai64 · -1 不限 used_quotai64 statusi64 created_ati64

detail 的 data 是 { user, tokens } 两个键,而不是扁平视图。

tokens · 5 端点

GET/admin/tokens?user_id=列表(可按用户过滤)
POST/admin/tokens创建,返回明文 key
GET/admin/tokens/{id}详情
PUT/admin/tokens/{id}更新
DEL/admin/tokens/{id}删除 → {deleted}
idi64 user_idi64 nameString keyString 明文 remain_quotai64 · -1 随用户 used_quotai64 model_limitsString csv status1 启用 / 2 禁用 expired_ati64 · -1 永久 created_ati64

channels · 6 端点

GET/admin/channels列表
POST/admin/channels创建(含 room_id)
GET/admin/channels/{id}详情
PUT/admin/channels/{id}更新
POST/admin/channels/{id}/test打一次真实上游
DEL/admin/channels/{id}删除 → {deleted}
idi64 nameString protocolopenai · responses · anthropic · gemini base_urlString modelsString csv groupsString csv model_mappingString JSON weighti64 room_idi64 · 0 共享 status1 / 2 熔断 fail_counti64 banned_ati64 created_ati64

test 的 data 是 { ok, latency_ms, model } 形态;上游密钥只出现在请求里,不作为响应字段返回。

room_id 决定可见性

room_id = 0 是共享渠道,任何个人 Key 与房间 Key 都能被调度到;room_id = N 是房间 N 的独享渠道,只有该房间的 Key 能被调度到,个人 Key 永远看不到它。

pricing · 7 端点

GET/admin/pricing/models模型倍率列表
GET/admin/pricing/models/{model}单模型倍率
PUT/admin/pricing/models/{model}写入(upsert)
DEL/admin/pricing/models/{model}删除
GET/admin/pricing/groups分组倍率列表
PUT/admin/pricing/groups/{name}写入分组倍率
DEL/admin/pricing/groups/{name}删除分组
modelString model_ratiof64 = mr completion_ratiof64 = cr cached_discount_ratiof64 = cdr cache_write_ratiof64 = cwr nameString · group ratiof64 = gr

logs / stats · 3 端点

GET/admin/logs?user_id=&channel_id=&status=&model=&start=&end=消费日志(后端过滤)
GET/admin/stats聚合概览
GET/admin/stats/daily?days=7真实日序列(UTC)
idi64 user_idi64 token_idi64 channel_idi64 modelString protocolString prompt_tokensi64 completion_tokensi64 cached_tokensi64 cache_write_tokensi64 quotai64 latency_msi64 statusi64 streami64 errorString created_ati64

StatsView 字段:today_requests、today_quota、today_usd、total_used_quota、total_used_usd、users、active_channels。

rooms · 8 端点

GET/admin/rooms列表
POST/admin/rooms建房(创建者即 owner)
GET/admin/rooms/{id}详情
POST/admin/rooms/{id}/join入房(名额原子抢占)
GET/admin/rooms/{id}/members成员
POST/admin/rooms/{id}/quota调整房间额度
POST/admin/rooms/{id}/keys签发房间 Key
DEL/admin/rooms/{id}删除(级联成员)
idi64 nameString owner_idi64 status1 启用 quotai64 · -1 不限 used_quotai64 member_counti64 max_membersi64 · 默认 5 quota_usdf64 used_usdf64 created_ati64

RoomMemberView 字段:room_id、user_id、role(owner / member)、joined_at。keys 复用 TokenView,因此房间 Key 与个人 Key 在响应形状上完全一致。

redemptions / transactions · 4 端点

GET/admin/redemptions?status=兑换码列表
POST/admin/redemptions批量生成 {count, quota, prefix?}
DEL/admin/redemptions/{id}删除
GET/admin/transactions?user_id=额度流水(对账)

RedemptionView 字段:id、code、quota、quota_usd、status(1 未用 / 2 已用)、used_by、used_at、created_at。

TxView 字段:id、user_id、delta、delta_usd、balance_after、balance_usd、reason(admin_adjust / redeem / register)、code、note、created_at。充值、兑换与注册赠送都会立即落库并写一行流水。

settings / campaigns · 6 端点

GET/admin/settings运营设置
PUT/admin/settings充值比例 / 注册赠送 / 公告
GET/admin/campaigns活动列表
POST/admin/campaigns上架活动
PUT/admin/campaigns/{id}更新活动
DEL/admin/campaigns/{id}下线活动

充值比例只影响新生成兑换码(面额 × rate 到账);活动加赠在用户兑换时按最高一个 recharge_bonus 生效,不叠加。记账单位 1 USD = 500000 固定,不在运营设置里开放。

用户 API(/api/*,会话 Bearer)

GET/api/site公告 / 活动 / 充值比例(公开)
GET/api/models上架模型与 $/1M 价目(公开)
POST/api/auth/register注册并签发第一把 Key
POST/api/auth/login登录换 sess-…
GET/api/me当前账户与余额
GET/api/tokens我的令牌(掩码)
POST/api/tokens创建令牌(完整 key 仅一次)
GET/api/logs我的调用日志(Key/模型/时间/状态筛选)
GET/api/usage?days=7我的日用量
GET/api/billing?days=30&token_id=单 Key / 全部 Key 账单
GET/api/key-usage?days=30Key 体检(Bearer sk-,免登录)
POST/api/redeem兑换码充值
GET/api/rooms我加入的房间

字段与错误码见仓库 docs/user-api-schema.md;用户侧列表永远不返回完整 Key。

入房为什么不会超员

store::join_room
let got = sqlx::query(
    "UPDATE rooms SET member_count = member_count + 1
     WHERE id = ? AND status = 1 AND member_count < max_members",
)
.execute(&db).await?;
if got.rows_affected() == 0 {
    return Err(Error::RoomFull);   // 409 · room_full
}
// 抢到名额才插成员;同事务,任何一步失败整体回滚
// 表上另有 CHECK (member_count BETWEEN 1 AND max_members) 兜底

roomtest.mjs 的并发用例:10 个用户同时抢 1 个空位,断言恰好 4 个成功、6 个拿到 room_full。

契约怎么被钉住

视图类型

每个端点的 data 都是 admin/views.rs 里的 Serialize 结构,字段顺序与可空性写在类型上。

快照生成

node scripts/schemaguard.mjs --write 起内核、逐端点取样,把字段集合写进 docs/admin-api-schema.snapshot.json,同时生成 web/src/schema.gen.ts。

守卫校验

不带 --write 运行时,全部端点的字段集合与快照有任何差异即退出码非 0。

编译期钉死

web/src/schema.check.ts 用 AssertEqual<keyof 视图, typeof SCHEMA[…][number]> 让前端类型与快照不一致时 tsc 直接失败。

10配置参数

一份 TOML。除 admin_token 必填外都有默认值。

config.example.toml
listen        = "0.0.0.0:3000"
admin_token   = "REPLACE-ME"          # 必填,空即拒绝启动
database      = "kernel.db"

[relay]
max_retries                 = 3     # 单次请求最多换几个渠道
connect_timeout_secs        = 10
request_timeout_secs        = 300
auto_recover_secs           = 300   # 熔断渠道自动恢复窗口
ban_threshold               = 5     # 连续失败次数阈值
prealloc_completion_tokens  = 4096  # 预扣用的补全估算

密钥不要进版本库

config.toml 已在 .gitignore 中,仓库里只有占位符。admin_token 一旦泄露等于交出整个账务面;上游密钥只在建渠道时走一次请求体,不会出现在任何响应字段里。

11已知边界

以下是接入时建议纳入设计考量的实现边界,我们会随版本收敛。

边界现状影响
在线支付充值当前走兑换码(控制台「余额与充值」),支持对公与发票大额或长期合作请邮件联系开通
Gemini 入口协议转换已通过一致性验证,真实上游验收中接入前请先用少量流量验证
跨协议流式缓冲式合成,非逐块增量翻译首字延迟累加
房间归属字段调用日志按房间维度聚合,响应视图暂未返回 room_id房间对账请联系支持邮箱
Admin 鉴权分级管理面为单一令牌鉴权,无细粒度范围自部署时建议只在内网或加二次防护后开放
错误信封各协议统一返回 {error:{message,type}}使用 Anthropic SDK 时,错误分支建议在你侧做一次映射