接口参考
四种客户端协议共用一条转发链路;同协议字节直通,跨协议经 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 ≠ 1 | 401 | 令牌被禁用 |
| token.expired_at | 401 | -1 表示永不过期,否则与当前时间比较 |
| token.model_limits | 403 | 逗号分隔白名单;空表示不限模型 |
| user.status ≠ 1 | 401 | 用户被禁用,令牌再有效也没用 |
| 额度不足 | 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="")
from openai import OpenAI
client = OpenAI(base_url="https://omniaxis.cc/v1", api_key="sk-your-token")
resp = client.responses.create(
model="deepseek-flash",
input="写一首关于内核的诗",
stream=True,
)
from anthropic import Anthropic
client = Anthropic(
base_url="https://omniaxis.cc/v1", # SDK 自己拼 /v1/messages
api_key="sk-your-token", # 落到 x-api-key
)
with client.messages.stream(
model="deepseek-flash",
max_tokens=512,
messages=[{"role": "user", "content": "写一首关于内核的诗"}],
) as stream:
for text in stream.text_stream:
print(text, end="")
import { GoogleGenerativeAI } from "@google/generative-ai"
const genAI = new GoogleGenerativeAI("sk-your-token")
const model = genAI.getGenerativeModel({
model: "deepseek-flash",
baseUrl: "https://omniaxis.cc/v1beta",
})
const r = await model.generateContent("写一首关于内核的诗")
console.log(r.response.text())
跨协议流式是缓冲的
客户端与渠道协议不同时,流式请求会强制上游非流式,拿到完整 JSON 后按客户端协议合成 SSE——事件形状一定对,但首字延迟会累。同协议仍是真流式。
04网关端点
聊天四协议路由指向同一个处理函数,协议由路径前缀判定;GPT 文本优先用 /v1/responses(Codex 原生),Chat 仍可用;图片走独立的按张计费路径。
图片请求示例:{"model":"gpt-image-2","prompt":"…","n":1,"size":"1024x1024"}。响应原样透传 data[].url|b64_json;失败整笔退款,日志入口记为 images。
05跨协议矩阵
行是客户端协议,列是渠道(上游)协议。对角线是字节直通,其余经 IR 翻译。
| 客户端 ↓ · 渠道 → | OpenAI | Responses | Anthropic | Gemini |
|---|---|---|---|---|
| /v1/chat/completions | ✓ | ⇄ | ⇄ | ⇄ |
| /v1/responses | ⇄ | ✓ | — | — |
| /v1/messages | ⇄ | — | ✓ | — |
| /v1beta/…:generateContent | ⇄ | — | — | ✓ |
Gemini 作为客户端跨入真实上游已验(realxtest.mjs),作为渠道只有 mock——手上没有可用的真实 Gemini 密钥,所以对角线最后一格不能标真验。
真实上游断言清单(realchannel 19 + realtest 26 + realxtest 14 = 59 条)
| 用例 | 客户端 → 渠道 | 流式 | 断言要点 |
|---|---|---|---|
| A | Anthropic → Anthropic | 否 | 200、type=message、内容块非空、usage.input_tokens>0、stop_reason 存在 |
| B | OpenAI → Anthropic | 否 | 200、object=chat.completion、message.content 非空、Anthropic usage 归一回 prompt_tokens |
| C | Responses → Responses | 否 | 200、object=response、output[].content[].text 非空 |
| D | OpenAI → Responses | 否 | 200、译回 OpenAI 结构与文本 |
| E | Anthropic → Anthropic | 是 | 收到 message_start 与 content_block_delta,直通不缓冲 |
| F | OpenAI → Anthropic | 是 | 缓冲后合成,收到 OpenAI SSE 且含 data: [DONE] |
| F2 | OpenAI → Responses | 是 | 缓冲后合成,SSE 内含真实文本增量与 [DONE] |
为什么 A 只验结构与用量
被测模型会先输出 thinking 块再输出 text 块,小 max_tokens 下内容块顺序不可控。断言因此钉在「协议结构 + 至少一个内容块 + usage + stop_reason」,而不是钉死首个块的类型。
06流式语义
客户端只要看到自己协议该看的东西。同协议时内核不解析 SSE,原样转发;跨协议时按下面的规则合成。
| 客户端协议 | 事件 / 终止标志 | 跨协议合成来源 |
|---|---|---|
| OpenAI Chat | data: {choices:[{delta}]} … data: [DONE] | IR 文本增量 + 末尾 usage 块 |
| Responses | response.output_text.delta … response.completed | IR 增量映射到 delta 事件 |
| Anthropic | message_start / content_block_delta / message_stop | 补齐块索引与 stop_reason |
| Gemini | :streamGenerateContent 的 JSON 数组分片 | 按 candidates[].parts 合成 |
停止原因映射
| 语义 | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| 正常结束 | finish_reason=stop | stop_reason=end_turn | finishReason=STOP |
| 长度截断 | length | max_tokens | MAX_TOKENS |
| 工具调用 | tool_calls | tool_use | STOP + functionCall |
07错误语义
所有被内核拦下的错误都是同一形状,且发生在扣费或转发之前——不会产生上游费用。
| 状态 | error.type | 触发 | 账务影响 |
|---|---|---|---|
| 401 | invalid_authentication | 密钥无效 / 令牌禁用 / 过期 / 用户禁用 | 无 |
| 403 | model_not_allowed | 令牌模型白名单外 | 无 |
| 403 | room_forbidden | 房间不存在或已冻结 / 关闭 | 无 |
| 402 | insufficient_quota | 个人余额或房间池预扣不动 | 无(fail-closed) |
| 400 | invalid_request | 请求体解析失败、缺模型名等 | 预扣已退 |
| 502 | upstream_error | 所有候选渠道重试后仍失败 | 预扣全额退回 |
| 409 | room_full | 加入房间时名额抢占失败(已满) | 无 |
| 404 | not_found | 控制面记录不存在 | 无 |
| 500 | internal_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 端点
保存时反算 model_ratios(输入 $/1M ÷ 2 = 模型倍率),门户 GET /api/models 读同一份价格;模型页与首页轮播因此不会再出现“页面价 ≠ 扣费价”。
users · 8 端点
UserView 字段:
detail 的 data 是 { user, tokens } 两个键,而不是扁平视图。
tokens · 5 端点
{deleted}channels · 6 端点
room_id){deleted}test 的 data 是 { ok, latency_ms, model } 形态;上游密钥只出现在请求里,不作为响应字段返回。
room_id 决定可见性
room_id = 0 是共享渠道,任何个人 Key 与房间 Key 都能被调度到;room_id = N 是房间 N 的独享渠道,只有该房间的 Key 能被调度到,个人 Key 永远看不到它。
pricing · 7 端点
logs / stats · 3 端点
StatsView 字段:today_requests、today_quota、today_usd、total_used_quota、total_used_usd、users、active_channels。
rooms · 8 端点
RoomMemberView 字段:room_id、user_id、role(owner / member)、joined_at。keys 复用 TokenView,因此房间 Key 与个人 Key 在响应形状上完全一致。
redemptions / transactions · 4 端点
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 端点
充值比例只影响新生成兑换码(面额 × rate 到账);活动加赠在用户兑换时按最高一个 recharge_bonus 生效,不叠加。记账单位 1 USD = 500000 固定,不在运营设置里开放。
用户 API(/api/*,会话 Bearer)
字段与错误码见仓库 docs/user-api-schema.md;用户侧列表永远不返回完整 Key。
入房为什么不会超员
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 必填外都有默认值。
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 时,错误分支建议在你侧做一次映射 |