From 06f3443e569558518a3c47f4f8def5eda53cf66d Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 17 Jul 2026 17:38:11 +0800 Subject: [PATCH] docs(architecture): define multi-provider gateway target Document the provider-neutral gateway boundary and its terminal PostgreSQL and Redis data model, including configuration, credentials, routing, attempts, continuation, telemetry, retention, and migration contracts. Co-authored-by: Codex --- docs/multi-provider-architecture.md | 343 +++++++ docs/multi-provider-database.md | 1290 +++++++++++++++++++++++++++ 2 files changed, 1633 insertions(+) create mode 100644 docs/multi-provider-architecture.md create mode 100644 docs/multi-provider-database.md diff --git a/docs/multi-provider-architecture.md b/docs/multi-provider-architecture.md new file mode 100644 index 000000000..6a64aafd2 --- /dev/null +++ b/docs/multi-provider-architecture.md @@ -0,0 +1,343 @@ +# 多平台统一 AI 网关目标架构 + +本文定义 Codex Proxy RS 向多平台统一 AI 网关演进的目标边界。它是目标设计,不表示仓库当前已经具备这些能力;当前生效结构仍以 [architecture.md](architecture.md) 为准,终态数据模型见 [multi-provider-database.md](multi-provider-database.md)。 + +目标实现保持一个产品和一个默认部署单元。Cargo workspace 用于约束业务依赖,Provider adapter 使用静态链接,不把不同平台拆成微服务,也不引入动态 Rust 插件 ABI。 + +## 目标 + +网关最终允许客户端使用 OpenAI、Anthropic 或网关原生协议,通过同一执行链访问 OpenAI、Anthropic、Google、xAI、Azure OpenAI、Bedrock、Codex、本地模型及其他兼容平台。 + +架构必须满足: + +1. 客户端协议、上游平台、部署端点、凭据、模型和路由是独立概念。 +2. API handler 不识别具体 Provider,Provider adapter 不拥有调用方预算和跨平台路由规则。 +3. 新增 Provider 不修改核心 dispatch、已有客户端协议或通用存储结构。 +4. 新增客户端协议不修改任何 Provider。 +5. 只类型化网关需要理解的稳定语义,不建立包含所有厂商字段的万能请求结构。 +6. 流式与非流式请求共享同一执行和结算路径。 +7. 一个规则只有一个 owner;retry、commit、fallback 和 accounting 不在 adapter 中重复实现。 + +不以动态加载第三方二进制插件、跨进程 Provider 调用或将每个平台拆成独立服务为默认目标。只有出现独立扩缩容、故障隔离或组织所有权需求时,才评估进程拆分。 + +## 核心概念 + +| 概念 | 责任 | 示例 | +| --- | --- | --- | +| Client protocol | 客户端请求和响应的 wire contract | OpenAI Responses、Anthropic Messages | +| Provider | 一个上游平台的行为 adapter | OpenAI、Anthropic、xAI、Codex | +| Provider instance | 一个可配置的上游部署或端点 | Azure 香港区、公司 OpenRouter | +| Credential | Provider instance 使用的认证资源 | API Key、OAuth 账号、AWS 凭据 | +| Model target | 一个 instance 上的真实模型 | `claude-sonnet-4`、`grok-4` | +| Model route | 暴露给客户端的模型及候选目标 | `smart-code` → Codex/OpenAI/Anthropic | +| Logical request | 客户端发起的一次完整业务请求 | 一次 `/v1/responses` 调用 | +| Attempt | Logical request 的一次真实上游调用 | OpenAI key-1 返回 429 | + +这些概念不能通过模型名称前缀或一个通用 `account` 结构隐式绑定。Router 选择 Provider instance 和 model target;Provider 在自己的资源池中选择 credential。 + +## 总体结构 + +```text + Control Plane + Admin API / Catalog / Routes / Credentials + | + RuntimeSnapshot + | + v +Client protocol -> Protocol adapter -> Gateway Engine -> Route Planner + | + Attempt Coordinator + | + Provider Registry + / | \ + OpenAI Anthropic Codex ... + \ | / + Canonical Events + | +Client response <- Protocol encoder <--------+ +``` + +控制面管理配置和后台状态,数据面处理请求热路径。它们默认运行在同一进程,但通过不可变运行时快照隔离,避免每次请求临时查询和拼装数据库配置。 + +## 数据面 + +每个请求按固定顺序执行: + +1. 协议 adapter 解析外部请求并生成内部 operation。 +2. 验证下游 API Key,建立调用方、request ID、deadline 和 cancellation context。 +3. 从 operation 推导模型能力要求。 +4. Model Router 冻结本请求的 Route Plan。 +5. 预留调用方速率、并发和预算。 +6. Attempt Coordinator 依次执行允许的目标、凭据轮换和重试。 +7. Provider 将上游响应规范化为 canonical event stream。 +8. 协议 adapter 将事件编码为目标客户端协议;非流式响应由相同事件流聚合得到。 +9. 终结请求结果、用量、目录价格估算和每次 attempt 事实。 + +只有协议 adapter 处理 OpenAI、Anthropic 等 wire casing 和事件名称。Gateway Engine 不接触 Axum body、SSE 原始字节、WebSocket frame 或 Provider SDK 类型。 + +### Operation 边界 + +内部按业务能力定义 operation,而不是建立全平台字段并集: + +```rust +enum Operation { + Generate(GenerateRequest), + Embed(EmbedRequest), + Rerank(RerankRequest), + GenerateImage(ImageRequest), + Speech(SpeechRequest), +} +``` + +只类型化网关确实需要解释、路由或结算的稳定字段,例如内容、工具、输出格式、推理要求、token 限制和 usage。平台专属参数通过按 Provider 命名的 `provider_options` 传递,并由对应 adapter 校验。 + +已知但不支持的语义必须返回稳定的 unsupported 错误,不能静默删除字段。Realtime 和异步 batch 具有不同生命周期,只有实际实现时才增加各自边界,不提前塞入普通 request/response trait。 + +### Canonical event + +所有上游输出转换为少量稳定事件: + +```rust +enum GatewayEvent { + Started(ResponseMeta), + ContentAdded(ContentItem), + TextDelta(TextDelta), + ReasoningDelta(ReasoningDelta), + ToolCallDelta(ToolCallDelta), + Usage(Usage), + Completed(ResponseMeta), +} +``` + +OpenAI SSE、Anthropic SSE、Gemini stream 和 Codex WebSocket 都只解码一次。协议 encoder 从 canonical events 生成 OpenAI SSE、Anthropic SSE 或普通 JSON,避免按 Provider 和客户端协议形成 N × M 条转换路径。 + +### Provider 执行边界 + +Provider 热路径只有一个执行接口: + +```rust +type EventStream = + Pin> + Send>>; + +#[async_trait] +trait Provider: Send + Sync { + fn name(&self) -> &'static str; + + async fn execute( + &self, + request: ProviderRequest, + context: AttemptContext, + ) -> Result; +} +``` + +`ProviderStream` 同时返回 Provider instance、真实模型、匿名 resource ID、上游 request ID 和 transport。凭据租约与并发 permit 由 stream 持有,结束、取消或 drop 时释放。 + +Provider Registry 是唯一需要 `Arc` 的异构边界,event stream 也只在该边界 boxed。Provider 内部继续使用具体类型和静态分发,避免泛型与 trait object 扩散。 + +模型同步、OAuth 刷新、额度抓取和健康探测不是所有平台共享的热路径能力,不进入主 Provider trait。它们由 Provider crate 内的后台任务实现并在 bootstrap 注册。 + +## 模型路由与资源选择 + +### Route Plan + +客户端模型先解析为 route group: + +```text +smart-code +|- codex-prod / gpt-5-codex +|- anthropic-prod / claude-sonnet +`- openai-prod / gpt-5 +``` + +Router 禁止通过 `starts_with("grok")` 一类字符串启发式选择平台。每个 model target 维护真实 operation、feature、context、输出、地区和价格能力。 + +Router 依次执行: + +1. 调用方 allowlist、数据驻留和协议策略过滤。 +2. operation、tools、vision、reasoning、JSON Schema、continuation 等能力过滤。 +3. context window、输出限制和预算过滤。 +4. Provider instance 健康、熔断和可用容量过滤。 +5. 按固定优先级、权重、延迟、成本或 sticky 策略排序。 + +结果是请求级不可变 Route Plan。配置更新不改变在途请求的候选集合。 + +### Credential 归 Provider 管理 + +Credential 不是统一 API Key: + +```text +Codex -> OAuth account / Cookie / quota / WebSocket session +OpenAI/xAI -> API key +Azure -> endpoint / deployment / API key +Bedrock -> AWS credential / region / SigV4 +Gemini -> API key or service account +Ollama -> endpoint, optionally no credential +``` + +Core 只传递 Provider instance、model target 和本次排除的 resource ID。Provider 自己选择凭据并返回匿名 attempt metadata。Secret 明文只存在于 Provider 内部,不能进入日志、错误、Debug 输出或 RuntimeSnapshot 的公开部分。 + +## Retry、commit 与 fallback + +Attempt Coordinator 是 retry 和跨目标 fallback 的唯一 owner: + +- 每次可能到达上游的 payload 都对应一个独立 attempt;Provider adapter 不得隐藏 credential retry。 +- HTTP/SDK transport 的自动业务 retry 和携带 payload 的自动 redirect 默认关闭,不能让底层产生 telemetry 看不见的第二次调用。 +- Logical request 与 attempt 必须先持久化,之后才能发送可能产生费用或副作用的上游 payload。 +- Upstream `not_sent/sent/ambiguous` 与 downstream commit 是两个独立状态;ambiguous upstream send 默认禁止自动重放。 +- Request 和每个 attempt 都冻结并持久化 deadline;进程重启后的 recovery 不依赖已经变化的运行配置推断超时。 +- 首个可交付事件之前,可以切换同平台凭据或 Route Plan 中的下一个目标。 +- 响应一旦向客户端 commit,禁止重试或切换 Provider。 +- 请求语义错误和 capability mismatch 不重试。 +- 认证失败禁用或冷却当前 credential。 +- 429 按 `Retry-After` 和 Provider policy 冷却。 +- 网络错误和 5xx 只允许在 commit 前重试。 +- 非幂等 operation 默认不做跨平台 fallback。 +- 跨平台 fallback 必须由 model route 显式声明,系统不能自行猜测模型等价性。 + +Logical request 和 attempt 分开记录: + +```text +Logical request +|- Attempt 1: OpenAI / key-1 / 429 +|- Attempt 2: OpenAI / key-2 / 503 +`- Attempt 3: Anthropic / key-7 / success +``` + +Provider error 先映射为稳定的 `ProviderError`,Gateway Engine 再映射为 `GatewayError`,最后由客户端协议 adapter 编码。Provider 原始 status、code、request ID 和安全诊断可以保留,但不能直接决定客户端 contract。 + +## 对话延续 + +网关明确支持两种不同模式: + +### Native continuation + +使用上游 `previous_response_id`、conversation ID 或 connection-local state。Continuation binding 必须固定 Provider、Provider instance 和必要的 credential scope,不能把一个平台的资源 ID 发送给另一个平台。 + +Provider 必须声明 native state 是否可安全并发重用;“可重复但只能串行”的平台要提供自己的持久状态边界,不能伪装成通用 binding。单次消费 handle 的 claim/consume/ambiguous 状态由 PostgreSQL CAS 持久化,Redis 只协助短期串行化;Redis 丢失不能让已经可能到达上游的 handle 被再次发送。 + +### Portable continuation + +网关保存加密 canonical transcript,每次重新构建完整上下文。该模式允许跨 Provider 路由,但成本、上下文限制和部分平台专属语义损失必须显式可见。 + +Transcript 以不可变 snapshot 链保存:每次响应绑定一个新叶子,分支不会读到彼此未来内容;过深时可以 materialize,但不得延长正文 retention。 + +失败时不能把 native continuation 暗中降级成 portable continuation。 + +## 控制面 + +控制面负责: + +- Provider instance 与 endpoint 配置。 +- 上游 credential 配置、secret revision 与独立的高频运行状态。 +- Provider model catalog 与能力覆盖。 +- 对外模型、route group 和 route target。 +- 调用方 allowlist、速率、并发和预算策略。 +- 价格版本、健康状态和 Provider cooldown。 +- OAuth 刷新、额度同步、模型同步和健康探测任务。 + +管理修改携带调用方读到的 expected revision,先在同一事务中持久化、完成 Provider 专属校验并推进单调 config revision,再从一致性读取编译为不可变 RuntimeSnapshot,避免并发编辑静默覆盖。OAuth token 刷新和 credential availability 使用各自 revision 更新,不触发全量配置重载。无效配置不能进入热路径。单进程可以使用进程内 snapshot;多实例部署用 Redis 通知缩短传播时间,同时周期性从 PostgreSQL 对账 revision,因此丢失通知不会永久保留旧配置。 + +## Cargo workspace 终态 + +```text +backend/ +|- Cargo.toml +|- apps/ +| `- gateway/ +| `- src/ +| |- main.rs +| |- bootstrap.rs +| `- workers.rs +|- crates/ +| |- gateway-core/ +| | `- src/ +| | |- operation/ +| | |- engine/ +| | |- routing/ +| | |- policy/ +| | |- accounting/ +| | |- event.rs +| | `- error.rs +| |- gateway-api/ +| | `- src/ +| | |- openai/ +| | |- anthropic/ +| | |- native/ +| | `- admin/ +| |- gateway-protocol/ +| | `- src/ +| | |- openai/ +| | |- anthropic/ +| | `- gemini/ +| |- gateway-store/ +| | `- src/ +| | |- postgres/ +| | `- redis/ +| `- providers/ +| |- codex/ +| |- openai/ +| |- anthropic/ +| |- google/ +| |- xai/ +| |- azure-openai/ +| |- bedrock/ +| |- openrouter/ +| `- ollama/ +`- migrations/ +``` + +依赖方向固定为: + +```text +gateway binary +|- gateway-api --------> gateway-core +|- gateway-store ------> gateway-core +`- provider-* ---------> gateway-core + +gateway-core 不依赖 Axum、SQLx、reqwest、Redis、具体 Provider 或客户端协议。 +``` + +Provider crate 是业务编译边界,不是独立进程或准备发布的通用 SDK。Provider 之间禁止相互依赖;相同 wire contract 放在 `gateway-protocol` 复用,平台认证、错误、额度和 transport quirks 仍由各自 adapter 管理。 + +对于真正只需要 base URL、认证 header 和少量兼容开关的平台,可以提供配置驱动的 OpenAI-compatible adapter。Codex、Azure、Bedrock 等行为明显不同的平台不能因为 JSON 相似而塞入同一个 OpenAI client。 + +## 数据、安全与遥测边界 + +PostgreSQL 保存 Provider instance、credential metadata、模型路由、调用方策略、逻辑请求、attempt、usage、价格和 continuation binding 等持久事实,具体表结构见 [multi-provider-database.md](multi-provider-database.md)。Redis 只保存多实例限流、并发租约、短期 cooldown、affinity 和配置通知等可恢复状态。 + +下游 client API key 与上游 credential 必须使用不同存储和权限边界。Credential secret 使用环境变量、外部 secret manager 或独立加密密钥保护;身份伪名或 HMAC 密钥不能兼作 secret encryption key。 + +遥测以 logical request 和 attempt 为两个层级: + +- Logical request 记录客户端协议、公开模型、调用方结果和最终 usage。 +- Attempt 记录 Provider、instance、真实模型、匿名 resource、transport、status、延迟、commit 和 retry reason。 +- 标准化 usage 用于跨平台统计;token 使用普通列,image、audio 等非 token metric 使用版本化 registry 中的名称和固定单位,不能把 Provider 原始 usage body 当作通用 schema。 +- 价格按 Provider instance、模型、service tier 和生效时间保存不可变版本,每个版本原子包含完整 metric rates;目录价格估算区分 known、partial 和 unknown,不能把缺失费率按零费用处理或跨货币直接相加,也不能冒充 Provider invoice。 + +## 当前代码的目标归位 + +| 当前边界 | 目标边界 | +| --- | --- | +| `api/client/responses` | `gateway-api/openai/responses` | +| `dispatch` | 去除 Codex 类型后的 `gateway-core/engine` | +| `fleet` 账号池 | `providers/codex/account_pool` | +| `upstream/openai` | Codex 专属部分进入 `providers/codex`,通用 wire codec 进入 `gateway-protocol/openai` | +| `models` | 控制面 model catalog 与 routing snapshot | +| `telemetry/usage` | 通用 logical request、attempt 和 usage 事实 | +| `bootstrap` | Provider 注册、store 实现和后台任务的唯一 composition root | + +迁移期间不保留两套生产执行路径。每个阶段先用当前 Codex Provider 包住既有行为,再逐步把协议、调度和遥测边界中残留的 Codex 类型移回 adapter。 + +## 验收标准 + +目标边界完成后应满足: + +1. 新增 Anthropic 等 Provider 时,只新增 Provider crate、配置校验和 bootstrap 注册;核心 engine、OpenAI handler 和通用数据库结构不变。 +2. 新增 Anthropic 等客户端协议时,只新增 protocol adapter;Provider 不变。 +3. 新增普通 OpenAI-compatible instance 时只增加运行时配置,不增加重复业务 service。 +4. API、routing、accounting 和 telemetry 中没有 `if provider == ...` 的平台业务分支。 +5. Provider contract suite 覆盖非流式、流式事件顺序、工具、usage、错误、取消、commit 前后 retry 和 secret redaction。 +6. 跨 Provider continuation、fallback 和未知 capability 不会发生隐式语义降级。 + +如果新增一个 Provider 仍需要修改 dispatch match、给统一请求增加大量可选字段、复制一套 `create` 流程或改变通用存储结构,说明边界尚未达到本文目标。 diff --git a/docs/multi-provider-database.md b/docs/multi-provider-database.md new file mode 100644 index 000000000..176ed6d08 --- /dev/null +++ b/docs/multi-provider-database.md @@ -0,0 +1,1290 @@ +# 多平台统一 AI 网关终态数据模型 + +本文定义 [多平台统一 AI 网关目标架构](multi-provider-architecture.md) 对应的终态 PostgreSQL 与 Redis 数据边界。它是目标设计,不表示当前迁移已经完成;当前生效表仍以 [architecture.md](architecture.md#数据存储) 和仓库迁移为准。 + +本文中的表名、字段归属、事实层级、删除规则和秘密存储规则是终态约束。实际迁移可以分阶段完成,但不能长期保留两套生产事实来源。 + +## 设计决策 + +1. PostgreSQL 是持久事实的唯一权威来源;Redis 只保存可过期、可重建的协调状态。 +2. Provider 是编译进进程的代码,不创建 `providers` 表;数据库只保存可以动态配置的 Provider instance。 +3. Router 选择 Provider instance 和 model target,Provider 自己选择 credential。 +4. 调用方隔离和策略单元固定为现有 `client_api_key`,不提前引入 tenant、organization 或 project。 +5. 下游 client API key 只保存不可逆摘要;上游 API Key、OAuth token、Cookie 等可恢复秘密保存加密 envelope 或外部 secret reference。 +6. 稳定、需要查询的跨平台事实使用普通列;Provider 专属且不参与通用查询的内容使用经过 adapter 校验的 `jsonb`。 +7. 客户端的一次请求记录为 logical request;每次真实上游调用记录为 attempt。成功、失败和取消不再分散到不同事实表。 +8. logical request usage 表示客户端实际收到的用量;attempt usage 表示上游实际报告、可能产生费用的用量。 +9. 默认不保存 prompt、response body 或原始 Provider 错误正文。Portable continuation 的内容使用独立加密表。 +10. 聚合桶是派生数据,不参与调度、计费结算或请求正确性判断。 + +## 类型与命名约定 + +- ID 使用带语义前缀的应用生成 `text`,例如 `inst_`、`cred_`、`route_`、`req_`、`att_`。分页始终使用时间与 ID 组合游标,不依赖 ID 的自然顺序。 +- 所有时间点使用 `timestamptz`,应用按 UTC 读写。 +- token、次数和毫秒使用非负 `bigint`;状态码使用受范围约束的 `integer`。 +- 金额使用 `numeric(20, 10)`,禁止浮点;货币使用通过 `^[A-Z]{3}$` 校验的大写三字符代码。 +- `jsonb` 列只接受 object,并使用 `jsonb_typeof(...) = 'object'` check;空对象是否有效由字段语义决定,写入 RuntimeSnapshot 前再由 owner adapter 完整校验。 +- 不使用 PostgreSQL enum。稳定生命周期状态使用 `text + check`;Provider、协议、operation、metric 等可扩展标识由应用注册表校验。 +- 外键只表达真实所有权。配置实体优先 disable 而不是删除;历史事实不能随配置删除而消失。 +- 不为 `jsonb` 默认创建 GIN index。只有出现稳定查询并有执行计划证据时才增加。 +- `text` 不代表允许无限输入。名称、外部 ID、错误消息、URL 和 JSON 都必须由 owner 设置长度/字节上限;客户端或 Provider 提供的值在进入数据库前截断或拒绝,不能让遥测成为无界存储入口。 +- 所有数据库生命周期时间由 PostgreSQL 时钟生成;延迟由进程单调时钟计算后写入,不能用两台机器的 wall clock 相减。Provider 提供的时间只能作为带来源的观察值。 +- 所有会改变运行行为的 JSON(policy、config、capabilities、limits、options、state)必须包含正整数 `schema_version`,由唯一 owner 做版本迁移和完整校验。`metadata_json` 不是逃避 schema 演进的配置入口。 +- `__...__` 前缀保留给聚合维度中的系统 sentinel;Provider、模型、tier、protocol、operation 等外部或注册标识不得使用该前缀。 + +### 主键、外键与快照字段命名 + +- 表名使用复数名词,独立实体的主键默认命名为 `id`;只有纯关联表或严格一对一 extension table 才可用复合键或父实体 FK 作主键。 +- 外键字段使用被引用实体的完整单数名加 `_id`,例如 `provider_instance_id`、`upstream_credential_id`、`model_route_id` 和 `gateway_request_id`。 +- 同一父表存在多个语义角色时增加角色前缀,例如 `actor_admin_user_id`。 +- 复合外键字段与父键同名,例如 `(provider_instance_id, upstream_model_id)`。 +- `_id` 也可用于明确限定来源的外部协议标识,例如 `upstream_request_id` 和 `client_response_id`;这类字段必须带 `upstream`、`client` 等来源前缀,不能伪装成本地外键。 +- 配置删除后需要保留的历史维度使用 `_ref`,例如 `provider_instance_ref`、`model_route_ref` 和 `resource_ref`。`_ref` 永远不是外键。 +- 不使用含义不清的 `route_id`、`request_id`、`credential_id`、`transcript_id` 或历史 `_key` 缩写。 + +约束和索引显式命名: + +```text +pk_ +fk___ +ux_
__ +idx_
__ +ck_
__ +``` + +每个外键都必须有以该外键列开头的主键、唯一索引或普通索引,避免父行更新和删除时扫描子表。不能为了“可能查询”重复创建已经被主键或唯一约束覆盖的同列索引。 + +### 身份字段不可重绑定 + +一个 ID 一旦进入 request/attempt 历史,就不能被改造成另一项资源: + +- Provider instance 的 `provider_kind`、endpoint authority、region/issuer 等身份字段不可原位替换;变更这些内容创建新 instance。展示名称和非身份参数可以更新。 +- Credential 不能移动到另一 instance,`resource_ref` 不可修改;普通 secret 轮换只有在 adapter 确认仍是同一上游主体时才沿用原 credential,否则创建新行。 +- Provider model 的复合主键不可更新;模型重命名视为新模型。 +- Model route 的 `public_model_id` 不可改绑到另一个外部名称;需要改名时创建新 route 并显式迁移调用方策略。 +- Model route target 的 route、instance 和 upstream model 关联不可更新;切换目标时创建新 target,只有 priority、weight、enabled 和 options 可以修改。 +- 已发布 price version、request、attempt、audit 和 conversation item 都是 append/finalize 事实,不能通过 UPDATE 改写身份。 + +这些规则由 repository API 和契约测试保证;数据库 FK 只防悬空引用,不能自动判断“同一个 ID 的业务含义是否被偷换”。 + +## 关系图 + +```text +admin_users + `-- admin_audit_events +client_api_keys + |-- gateway_requests --< request_attempts -- model_price_versions + | `-- optional source --> continuation_bindings + | + `-- continuation_bindings -- source response --> gateway_requests + |-- native_state_envelope + `-- portable --> conversation_transcripts + |-- parent --> conversation_transcripts + `-- conversation_items + +provider_instances + |-- upstream_credentials + | |-- upstream_credential_states + | |-- codex_accounts + | `-- codex_account_cookies + |-- provider_models + | `-- model_price_versions + | `-- model_price_rates + `-- model_route_targets -- model_routes + +ops_events +request_metric_buckets [derived] +attempt_metric_buckets [derived] +``` + +## Writer 所有权 + +| 数据 | 唯一 writer | +| --- | --- | +| `admin_users`、管理会话 | Auth owner | +| `client_api_keys`、`runtime_settings` | Control-plane settings owner | +| `admin_audit_events` | Audit owner;控制面修改必须在业务 transaction 内追加 | +| `provider_instances`、`provider_models`、routes、prices | Control-plane config publisher | +| `upstream_credentials` secret/config | Provider credential manager | +| `upstream_credential_states` | Provider state owner | +| `codex_accounts`、`codex_account_cookies` | Codex Provider owner | +| `gateway_requests`、`request_attempts` | Gateway request/attempt lifecycle | +| `conversation_*`、`continuation_bindings` | History/continuation owner | +| `ops_events` | Ops recorder | +| 两张 metric bucket | 唯一 bucket aggregator | + +API handler、Dashboard query、Provider transport 和后台清理任务不能绕过 owner 直接拼 SQL 修改业务事实。Retention worker 只删除已经满足本文生命周期条件的数据。 + +## 身份与全局设置 + +### `admin_users` + +沿用当前管理员表: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | 管理员 ID | +| `password_hash` | `text not null` | Argon2 密码摘要 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +管理员登录会话继续保存在 Redis。`runtime_settings.admin_api_key_hash` 继续保存高熵管理 Key 的摘要,不新增另一套管理凭据表。 + +### `admin_audit_events` + +控制面修改必须生成不可变、已脱敏的审计事实: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Audit event ID | +| `actor_kind` | `text not null` | `admin_session/admin_api_key/system/anonymous` | +| `actor_admin_user_id` | `text references admin_users on delete set null` | 可选管理员 live join | +| `actor_ref` | `text not null` | Actor 历史伪名;不是 session/API Key 本身 | +| `admin_request_id` | `text` | 管理 HTTP request ID,不是外键 | +| `action` | `text not null` | create、update、disable、delete、rotate 等 | +| `entity_kind` | `text not null` | provider_instance、credential、route、price 等 | +| `entity_ref` | `text not null` | 被修改实体的历史引用,不是外键 | +| `config_revision` | `bigint` | 本次修改发布的结构配置版本 | +| `details_json` | `jsonb not null default '{}'` | 已脱敏字段变化或安全事件详情,不含 secret 值 | +| `created_at` | `timestamptz not null` | 发生时间 | + +约束和索引: + +```text +actor_kind in ('admin_session', 'admin_api_key', 'system', 'anonymous') +(created_at desc, id desc) +(entity_kind, entity_ref, created_at desc, id desc) +(actor_admin_user_id, created_at desc, id desc) + where actor_admin_user_id is not null +(actor_ref, created_at desc, id desc) +``` + +Credential 创建、替换和轮换只记录“secret 已变化”、secret reference 类型和新 revision,绝不能记录明文、密文、fingerprint 或外部 secret 路径。管理员登录成功、失败、登出、密码/API Key 轮换和强制删除也进入本表;未认证事件使用 `actor_kind = 'anonymous'`、空 actor FK 和经过速率限制的安全详情,避免审计表本身成为攻击者制造的无界写入通道。 + +### `client_api_keys` + +下游 Key 只允许在创建时返回一次完整值: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Key ID | +| `name` | `text not null` | 名称 | +| `label` | `text` | 管理标签 | +| `prefix` | `text not null` | 管理端展示前缀 | +| `key_hash` | `text not null unique` | 完整 Key 的 SHA-256 摘要 | +| `enabled` | `boolean not null default true` | 管理员开关 | +| `policy_json` | `jsonb not null` | 带 schema version 的模型 allowlist、速率、并发和预算策略 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +索引: + +```text +unique(key_hash) +(created_at desc, id desc) +``` + +该方案只接受网关生成的至少 256 bit CSPRNG Key,因此快速摘要不会把低熵口令变成可离线猜测的凭据;认证比较使用完整摘要且不记录原 Key。若未来允许用户自定义低熵 token,必须改用带独立 pepper 的 keyed hash 或 password KDF,不能继续假设普通 SHA-256 足够。 + +`policy_json` 是当前自托管产品的调用方策略边界,由 client-policy owner 校验,至少允许表达: + +```json +{ + "schema_version": 1, + "allowed_model_ids": [], + "max_concurrency": 0, + "requests_per_minute": 0, + "tokens_per_minute": 0, + "budget": { + "period": "month", + "amount": "0.0000000000", + "currency": "USD" + } +} +``` + +缺省或零值的含义由版本化 validator 明确定义,不能由各 middleware 自行解释。若未来产品真的引入多租户、共享预算或组织权限,需要单独修订本文,而不是现在增加空的 tenant/project 表。 + +预算 period 默认按 UTC calendar window 计算,边界为 `[window_start, window_end)`;不能由各应用节点本地时区推导。若未来允许调用方时区,时区 ID 本身必须进入冻结的 policy/window 事实并处理规则变更,不能只保存一个模糊的 “month”。 + +### `runtime_settings` + +只保留部署级设置: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `bigint primary key check (id = 1)` | 单例 | +| `config_revision` | `bigint not null default 1 check (> 0)` | 已发布 RuntimeSnapshot 的单调版本 | +| `admin_api_key_hash` | `text` | 管理 API Key 摘要 | +| `usage_retention_days` | `bigint not null check (> 0)` | request/attempt 原始事实保留期 | +| `ops_event_retention_days` | `bigint not null check (> 0)` | 运维事件保留期 | +| `audit_retention_days` | `bigint not null check (> 0)` | 管理审计保留期 | +| `bucket_retention_days` | `bigint not null check (> 0)` | 聚合桶保留期 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +Client policy、Provider instance、credential 结构配置、model catalog、route 或价格发生有效变化时,控制面必须在同一事务中使用 `config_revision = config_revision + 1 returning config_revision` 原子推进版本,并写入对应 audit event。数据面使用 repeatable-read 只读事务读取一次 revision 和全部配置,以该一致性快照构建相同 revision 的 RuntimeSnapshot;事务结束后再用一次独立读取与当前 revision 比较,若已经推进则立即重载。Redis 只广播版本;数据面发现版本跳变或丢失通知时同样从 PostgreSQL 重载。 + +Redis Pub/Sub 不是可靠消息队列。每个数据面实例还必须周期性轮询 `config_revision`,并轻量比较 `(upstream_credential_id, credential_revision, state_revision)` 向量;通知只用于缩短收敛时间。即使提交后进程在发布通知前崩溃,其他实例也必须在有界时间内发现变化。轮询发现版本倒退、缺口或无法解析的新 JSON schema 时保留最后一个有效 Snapshot、停止接收依赖新配置的请求并记录运维事件,不能部分套用配置。 + +所有管理写请求携带读页面时看到的 `expected_config_revision`。更新事务只在当前 revision 相等时提交,否则返回 conflict 并要求重新读取,避免两个管理员或后台同步任务发生静默 last-write-wins。后台 catalog 同步只能修改自己拥有的 `available/last_seen_at` 等字段,不能覆盖管理员拥有的 `enabled` 和手工 metadata。 + +Provider kind 或 JSON schema 的发布与滚动升级协调:先部署能理解新 schema/adapter 的二进制并确认旧实例已 drain,再允许控制面发布该配置;不能依赖“旧实例碰到未知 JSON 后大概会重载”来完成兼容。这里不为部署清单新增数据库表,兼容矩阵由发布系统和启动前 Registry 校验管理。 + +OAuth access token 刷新和 credential 运行状态变化不递增全局 `config_revision`,分别使用 `credential_revision` 和 `state_revision` 通知,避免高频刷新触发全量 Snapshot 重建。 + +`config_revision` 是一致性 token,不是假装存在的历史配置外键:配置表仍可原位更新,revision 本身不能还原一份已经过期的完整 Snapshot。终态只承诺通过 request/attempt 的 route、target、provider、model、price 和安全 decision metadata 复盘实际执行;若未来出现监管级“完整配置时点回放”要求,必须新增不可变配置归档及独立 retention,不能仅凭一个 revision 数字宣称可重现。 + +`schema_migrations` 继续沿用当前严格递增版本、名称和 SQL checksum 设计,不并入业务设置表。 + +以下当前字段不再属于全局设置: + +- `model_aliases_json` 迁入 `model_routes` 和 `model_route_targets`。 +- `rotation_strategy` 迁入 `model_routes.routing_strategy`。 +- `max_concurrent_per_account`、`request_interval_ms` 迁入 credential 或 Provider instance 配置。 +- `refresh_margin_seconds`、`refresh_concurrency` 迁入 Codex Provider 配置。 + +## Provider、凭据与模型 + +### `provider_instances` + +一行表示一个具体的上游部署、区域或 endpoint: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Instance ID | +| `provider_kind` | `text not null` | 已注册 adapter 名称 | +| `name` | `text not null` | 管理名称 | +| `base_url` | `text` | 可选 endpoint;Bedrock 等平台可为空 | +| `enabled` | `boolean not null default true` | 是否进入 RuntimeSnapshot | +| `max_concurrency` | `integer check (> 0)` | 可选 instance 总并发上限 | +| `request_interval_ms` | `bigint not null default 0 check (>= 0)` | Instance 请求间隔 | +| `config_json` | `jsonb not null` | 带 schema version 的 region、API version、非秘密 header 等配置 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +约束与索引: + +```text +unique(provider_kind, name) +(provider_kind, enabled) +``` + +`provider_kind` 不设置数据库枚举或固定 check。控制面必须确认名称存在于 Provider Registry,并由对应 adapter 校验 `base_url` 和 `config_json`。HTTP endpoint 必须 canonicalize,只允许明确支持的 scheme,拒绝 URL userinfo,并阻断云 metadata 地址;私网与 link-local endpoint 只有在本地模型等场景显式启用 trusted-network policy 后才允许。Transport 在实际连接和每次 redirect 时重新校验解析 IP,默认禁用跨 origin redirect,防止 DNS rebinding 绕过控制面校验。`config_json` 禁止保存任何秘密。 + +Instance 限制约束整个 endpoint/pool,credential 限制约束单个资源;两者同时存在时必须同时取得 permit 并遵守更晚的请求间隔。无 credential Provider 仍能使用 instance 限制。 + +### `upstream_credentials` + +一行表示 Provider instance 可选择的一项认证资源: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Credential ID | +| `provider_instance_id` | `text not null references provider_instances on delete cascade` | 所属 instance | +| `name` | `text not null` | 管理名称 | +| `credential_kind` | `text not null` | `api_key`、`oauth`、`aws`、`service_account` 等 | +| `secret_envelope` | `bytea` | AEAD 加密的 Provider 专属 secret JSON | +| `secret_key_id` | `text` | 加密密钥版本 | +| `secret_locator` | `text` | Vault、KMS 或环境变量中的外部 secret 定位符 | +| `secret_version` | `text` | 外部 secret manager 的不可逆版本/etag;envelope 模式为空 | +| `credential_fingerprint` | `text` | Adapter 从稳定 credential 身份生成的 HMAC,用于去重 | +| `resource_ref` | `text not null unique` | 遥测使用的稳定匿名资源 ID;不是外键,也不是 credential fingerprint | +| `credential_revision` | `bigint not null default 1 check (> 0)` | Secret 或 credential 配置版本 | +| `enabled` | `boolean not null default true` | 管理员开关 | +| `max_concurrency` | `integer check (> 0)` | 可选凭据级并发上限 | +| `request_interval_ms` | `bigint not null default 0 check (>= 0)` | 凭据请求间隔 | +| `metadata_json` | `jsonb not null default '{}'` | 可展示、不可含秘密的 Provider metadata | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +Secret 存储必须满足二选一: + +```text +exactly one of secret_envelope and secret_locator is not null +secret_envelope is not null <-> secret_key_id is not null +secret_envelope is not null -> secret_version is null +secret_locator is not null <-> secret_version is not null +secret_locator is not null -> secret_key_id is null +``` + +没有认证要求的 instance 不创建 credential 行。一个 instance 就是一个 credential pool;如果同一 endpoint 需要互相隔离的资源池,创建两个 instance,不新增 credential group 抽象。 + +外部 `secret_locator` 不能指向一个会在数据库不知情时任意变更的“最新值”:secret manager integration 必须把可比较的版本/etag 写入 `secret_version`,发现变化后以 CAS 推进 `credential_revision` 并通知数据面;否则 in-memory lease、attempt snapshot 和审计无法知道哪一代凭据真正被使用。 + +`credential_fingerprint` 不能直接 HMAC 整个 secret envelope:OAuth access token 和 refresh token 可能轮换。Adapter 必须选择稳定身份,例如 API Key 本身或已经验证的 Provider subject;无法获得稳定身份时保持为空,并使用 Provider 专属唯一约束。 + +`resource_ref` 在 credential 创建时由 identity pseudonym key 和经过 adapter 规范化的稳定身份生成;没有稳定身份时以随机值生成并永久复制到历史事实。它不能等于 `credential_fingerprint`,也不能从邮箱、Key prefix 等可识别字段直接拼接。Secret 轮换不改变同一 credential 的 `resource_ref`;删除后重新导入是否继承历史身份只能由 adapter 在能够安全证明同一主体时决定。 + +稳定身份的 HMAC 输入必须包含 Provider/issuer namespace,避免两个无关 OpenAI-compatible endpoint 上碰巧相同的 Key 被视为同一资源。全局 `unique(resource_ref)` 同时阻止把同一上游资源复制到多个 instance 来绕过 quota/concurrency;若以后确有一个资源合法挂载多个 endpoint 的需求,应显式引入 resource 与 instance attachment 模型并按 resource 共用 lease,不能复制 credential 行。 + +索引: + +```text +(provider_instance_id, enabled) +unique(provider_instance_id, name) +unique(provider_instance_id, credential_fingerprint) + where credential_fingerprint is not null +unique(id, provider_instance_id) +(updated_at, id) +``` + +Secret 刷新、任何 Provider 专属认证材料(例如 Cookie)变更或 credential 配置更新都使用 `credential_revision` 做 compare-and-swap,并发布 credential 级变更通知。它不修改全局 `config_revision`,除非 `enabled`、并发限制或其他会改变 Route Plan 的结构配置同时变化。 + +### `upstream_credential_states` + +Credential 配置和高频运行状态具有不同写入频率,必须分表: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `upstream_credential_id` | `text primary key references upstream_credentials on delete cascade` | Credential | +| `availability` | `text not null default 'unknown'` | `unknown/ready/cooldown/exhausted/invalid` | +| `availability_reason` | `text` | 已脱敏原因 | +| `cooldown_until` | `timestamptz` | 跨重启仍需遵守的冷却截止时间 | +| `state_json` | `jsonb not null` | 带 schema version 的 quota、reset window 等 Provider 状态 | +| `state_revision` | `bigint not null default 1 check (> 0)` | 状态 CAS 版本 | +| `observed_at` | `timestamptz not null` | 产生当前状态事实的时间 | +| `updated_at` | `timestamptz not null` | 持久化时间 | + +约束和索引: + +```text +availability in ('unknown', 'ready', 'cooldown', 'exhausted', 'invalid') +(availability = 'cooldown') = (cooldown_until is not null) +(availability, cooldown_until) +(updated_at, upstream_credential_id) +``` + +状态更新必须使用 `state_revision` CAS,并由 Provider owner 合并并发事实;旧 `observed_at` 的结果不能覆盖更新状态。只有 availability/cooldown/quota 等有效状态发生变化或专门的状态刷新任务取得新观察时才持久化,普通成功 attempt 不触碰本行。进程内 semaphore、在途 lease 和连接状态仍不写 PostgreSQL,Redis 只缓存这张表可恢复的热状态。 + +最后使用/成功/失败时间从 `request_attempts` 的 credential 索引或 metric bucket 查询,不回写本行;否则热门 credential 每个请求都更新同一行,会制造行锁热点、WAL 和 autovacuum 压力,也形成第二套可漂移事实。Client API key 的最后使用时间同理从 `gateway_requests` 查询。 + +创建 `upstream_credentials` 时必须在同一事务中创建唯一 state 行,不能用“缺少 state 行代表 unknown”形成第二套隐式状态。`credential_revision` 与 `state_revision` 各自单调递增,互不覆盖。 + +### `codex_accounts` + +Codex 是当前唯一需要可查询专属字段的 Provider,一行与一个 OAuth credential 一对一: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `upstream_credential_id` | `text primary key references upstream_credentials on delete cascade` | 对应 credential | +| `email` | `text` | ChatGPT 邮箱 | +| `chatgpt_account_id` | `text` | ChatGPT account ID | +| `chatgpt_user_id` | `text` | ChatGPT user ID | +| `plan_type` | `text` | 订阅计划 | +| `access_token_expires_at` | `timestamptz` | access token 到期时间 | +| `next_refresh_at` | `timestamptz` | 下次计划刷新时间 | + +唯一索引沿用当前身份约束: + +```text +unique(chatgpt_account_id, coalesce(chatgpt_user_id, '')) + where chatgpt_account_id is not null +``` + +Access token 和 refresh token 位于 `upstream_credentials.secret_envelope`,本表不得保存明文。Quota、Cloudflare cooldown 和 quota verification 等运行状态归入 `upstream_credential_states.state_json`,避免同一事实存在两个 writer。 + +Codex owner 在创建 account/cookie 的同一事务中验证 credential 所属 instance 的 `provider_kind = 'codex'` 且 credential kind 与认证流程匹配;普通 FK 只能证明 credential 存在,不能证明它属于正确 adapter。该跨表 invariant 必须进入数据库契约测试。 + +### `codex_account_cookies` + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Cookie ID | +| `upstream_credential_id` | `text not null references upstream_credentials on delete cascade` | Codex credential | +| `domain` | `text not null` | Domain | +| `name` | `text not null` | Cookie name | +| `value_envelope` | `bytea not null` | 加密 Cookie value | +| `secret_key_id` | `text not null` | 加密密钥版本 | +| `path` | `text not null default '/'` | Cookie path | +| `host_only` | `boolean not null` | 无 Domain attribute 时为 true,禁止错误发送给子域 | +| `secure` | `boolean not null` | 只允许通过安全 transport 发送 | +| `expires_at` | `timestamptz` | 过期时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +索引: + +```text +unique(upstream_credential_id, domain, name, path) +(expires_at) where expires_at is not null +``` + +Cookie owner 只持久化 Provider allowlist 中确有业务需要的名称。捕获 `Set-Cookie` 时,Domain 必须 domain-match 实际响应 origin 且不能是 public suffix;无 Domain attribute 时保存 `host_only = true`。重放时同时检查 domain、host-only、path、secure 和 expiry,不能因为数据库里已有一行就向另一个 Provider endpoint 发送。 + +Cookie upsert 与父 credential 的 `credential_revision` 递增必须在同一事务内完成;否则 attempt 无法准确冻结它使用的是哪一代认证材料,旧 Cookie 的失败也可能污染新会话状态。 + +其他 Provider 只有在出现真实、需要查询的专属状态时才增加自己的扩展表。普通 OpenAI、Anthropic、Google 或 xAI API Key 不创建空扩展表。 + +### `provider_models` + +一行表示一个 instance 上的真实模型: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `provider_instance_id` | `text not null references provider_instances on delete cascade` | 所属 instance | +| `upstream_model_id` | `text not null` | 上游真实模型 ID | +| `display_name` | `text` | 展示名称 | +| `catalog_source` | `text not null` | `discovered` 或 `manual` | +| `enabled` | `boolean not null default true` | 管理员开关 | +| `available` | `boolean not null default true` | 最近 catalog 是否仍存在 | +| `capabilities_json` | `jsonb not null` | 带 schema version 的 operation 和 feature 能力 | +| `limits_json` | `jsonb not null` | 带 schema version 的 context、输出等限制 | +| `metadata_json` | `jsonb not null default '{}'` | Provider 原始目录的安全子集 | +| `last_seen_at` | `timestamptz` | 最近同步出现时间 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +主键: + +```text +primary key(provider_instance_id, upstream_model_id) +catalog_source in ('discovered', 'manual') +``` + +Catalog 同步发现模型消失时设置 `available = false`,不能删除仍被 model route target 或历史价格引用的模型。只有 `enabled`、`available`、capability 或 limits 的有效值发生变化时才递增全局 `config_revision`;单纯更新 `last_seen_at` 不触发 Snapshot 重建。 + +`capabilities_json` 不是开放式能力布尔值集合,而是带来源的声明:对每项能力区分 `native/emulated/unsupported/unknown`,并记录由 adapter 代码、manual override 或 probe 得到。Router 对 unknown 默认不路由;probe 失败不会把未知能力当成支持,协议 adapter 的 emulation 也必须显式进入 Route Plan。 + +Catalog 拉取必须先得到一个完整、成功的远端 snapshot,再在单个数据库事务中 upsert 本轮模型并只对 `catalog_source = 'discovered'` 的缺失行设置 unavailable。分页中断、鉴权失败或部分响应不得把未见到的模型批量下线;手工模型也不受 discovery 缺失处理影响。 + +Router 从编译后的 capability snapshot 过滤 instance 级目标,不在请求热路径查询 JSON。某个 credential 因套餐、授权或区域无法使用模型时,由 Provider credential selector 根据专属 metadata/state 再过滤;不为此创建一张所有 Provider 都必须填充的空映射表。 + +### `model_routes` + +一行表示一个对客户端公开的模型: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Route ID | +| `public_model_id` | `text not null unique` | 客户端模型 ID | +| `description` | `text` | 管理说明 | +| `enabled` | `boolean not null default true` | 是否公开 | +| `routing_strategy` | `text not null` | `priority/weighted/latency/cost/balanced/sticky` | +| `policy_json` | `jsonb not null` | 带 schema version 的 fallback、最大 attempts、预算等策略 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +约束: + +```text +unique(id, public_model_id) +``` + +`routing_strategy` 由应用注册表校验,不设置数据库 check,以便增加组合策略时不修改数据库类型。控制面只有在 route 至少存在一个 enabled target 且所有 target/options 都通过校验后,才能发布新的 `config_revision`。 + +### `model_route_targets` + +一行表示 route 的一个候选真实模型: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Target ID | +| `model_route_id` | `text not null references model_routes on delete cascade` | 所属 route | +| `provider_instance_id` | `text not null` | 目标 instance | +| `upstream_model_id` | `text not null` | 真实模型 ID | +| `priority` | `integer not null default 100 check (>= 0)` | 数字越小优先级越高 | +| `weight` | `integer not null default 1 check (> 0)` | 加权策略权重 | +| `enabled` | `boolean not null default true` | 是否参与候选 | +| `options_json` | `jsonb not null` | 带 schema version 的 service tier 等目标级非秘密选项 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | + +约束与索引: + +```text +foreign key(provider_instance_id, upstream_model_id) + references provider_models(provider_instance_id, upstream_model_id) + on delete restrict + +unique(model_route_id, provider_instance_id, upstream_model_id) +(model_route_id, enabled, priority, id) +(provider_instance_id, upstream_model_id) +``` + +Target 不引用 credential。Provider 必须在执行 attempt 时从 instance 的 credential pool 中选择资源。 + +### `model_price_versions` + +一行表示一个模型和 service tier 从某个时间点开始生效的一整套价格。价格版本发布后不可修改: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Price version ID | +| `provider_instance_id` | `text not null` | Instance | +| `upstream_model_id` | `text not null` | 真实模型 ID | +| `service_tier` | `text not null default '__default__'` | 规范化 tier;sentinel 表示 Provider 默认档位 | +| `currency` | `text not null` | 大写货币代码 | +| `effective_from` | `timestamptz not null` | 生效时间 | +| `metadata_json` | `jsonb not null default '{}'` | 价格来源和说明 | +| `created_at` | `timestamptz not null` | 创建时间 | + +约束与索引: + +```text +foreign key(provider_instance_id, upstream_model_id) + references provider_models(provider_instance_id, upstream_model_id) + on delete restrict + +unique( + provider_instance_id, + upstream_model_id, + service_tier, + effective_from +) + +unique(id, provider_instance_id, upstream_model_id, service_tier, currency) + +currency ~ '^[A-Z]{3}$' + +``` + +一次 attempt 在发送前从当时的 RuntimeSnapshot 选择 `effective_from <= started_at` 的最新版本,并把 version ID 写入 attempt 起始行;在途请求不受后来发布或回溯生效的版本影响。没有 `effective_to`,因此不存在重叠区间;价格修正通过新增版本完成,不能回写已经被 attempt 使用的历史版本。 + +### `model_price_rates` + +价格版本中的每个计费 metric 一行: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `model_price_version_id` | `text not null references model_price_versions on delete cascade` | 所属价格版本 | +| `metric` | `text not null` | `input_token`、`image`、`audio_second` 等 | +| `unit_size` | `numeric(20, 6) not null check (> 0)` | 报价单位数量 | +| `amount` | `numeric(20, 10) not null check (>= 0)` | 每单位价格 | + +主键: + +```text +primary key(model_price_version_id, metric) +``` + +Version 与至少一条 rate 必须在一个事务中创建并完整验证,再递增 `config_revision` 发布;空版本不能发布。没有匹配版本表示 unknown;版本存在但缺少本次 usage 所需 metric 表示 partial。零价格必须由 `amount = 0` 的明确 rate 表达。不同货币永远分组展示和统计,系统没有汇率事实时禁止相加。 + +Partial 的 `estimated_cost_amount` 只表示已知 metric 的下界,不能作为完整成本参与 strict budget 释放;unknown 没有金额。只有 known(包括由明确零价 rate 算出的 known zero)可以作为完整估算展示。 + +`metric` 来自 Gateway 的版本化 normalized-usage registry,并与 request/attempt 的 `usage_metrics_json` 使用同一名称和单位。当前 price rate 只表达 `usage × unit price` 的线性定价;阶梯价、包月抵扣、承诺消费和 Provider invoice adjustment 不允许塞入 `metadata_json` 或临时公式。真正需要这些能力时应单独设计账务边界,在此之前标记 partial/unknown。 + +Price repository 对已发布版本只提供读取,不提供更新;更正必须新增 version。成本路由遇到 unknown/partial price 时不能把它当作零成本,严格预算只能选择与预算 currency 相同且费率完整的目标,否则必须按 policy 明确拒绝或降级。 + +计费计算全程使用十进制定点数,按 `usage × amount / unit_size` 计算每个 metric 后求和,只在持久化 `estimated_cost_amount` 时按 10 位小数统一舍入;Rust 业务代码禁止经过 `f32/f64`。 + +`cost_breakdown_json` 只保存 rate metric、price version、非负十进制字符串数量/金额和舍入说明;它不是 Provider 原始账单响应,也不允许 JSON 浮点。 + +这里的 `estimated_cost_amount` 是按当时价格目录计算的网关成本估算,不是 Provider 发票、付款或会计结算事实。预算可以基于该保守估算执行,但 UI 和 API 必须标注 estimated;未来若接入账单对账,应新增独立的 invoice/reconciliation 模型,不能覆盖已记录的请求价格快照来伪装成实际账单。 + +## 请求、Attempt 与用量 + +### `gateway_requests` + +请求被接受后立即插入一行,所有终止路径只 finalize 一次: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Logical request ID | +| `client_api_key_id` | `text references client_api_keys on delete set null` | 调用方 Key;内部任务可为空 | +| `client_api_key_ref` | `text` | 调用方 Key ID 历史快照;内部任务可为空 | +| `config_revision` | `bigint not null check (> 0)` | 本请求冻结 Route Plan 时的 RuntimeSnapshot 版本 | +| `protocol` | `text not null` | OpenAI Responses、Anthropic Messages 等 | +| `operation` | `text not null` | `generate/embed/rerank/...` | +| `client_transport` | `text not null` | `http_json/http_sse/websocket` 等入站 transport | +| `model_route_id` | `text` | 冻结时选中的 route;与 public model 组成复合 FK | +| `model_route_ref` | `text` | Route ID 历史快照 | +| `requested_model_id` | `text not null` | 客户端原始模型 ID 快照 | +| `public_model_id` | `text` | 成功解析后的对外模型 ID 快照;未解析时为空 | +| `continuation_binding_id` | `text` | 本请求使用的 source binding;与 client key 组成复合 FK | +| `continuation_binding_ref` | `text` | Source binding ID 历史快照 | +| `stream` | `boolean not null` | 客户端是否请求流式返回 | +| `outcome` | `text not null default 'running'` | `running/succeeded/failed/cancelled/incomplete` | +| `client_status_code` | `integer` | 最终客户端 HTTP/协议状态 | +| `client_response_id` | `text` | 网关暴露的 response ID | +| `error_kind` | `text` | 稳定 Gateway error taxonomy | +| `error_message` | `text` | 已脱敏的客户端安全消息 | +| `budget_reserved_amount` | `numeric(20, 10)` | 接入时持久化的保守预算预留 | +| `budget_limit_amount` | `numeric(20, 10)` | 本请求冻结的预算上限 | +| `budget_currency` | `text` | 预留货币 | +| `budget_window_start` | `timestamptz` | 本请求所属预算窗口起点 | +| `budget_window_end` | `timestamptz` | 本请求所属预算窗口终点(exclusive) | +| `input_tokens` | `bigint` | 客户端逻辑响应 usage | +| `output_tokens` | `bigint` | 客户端逻辑响应 usage | +| `cached_tokens` | `bigint` | 客户端逻辑响应 usage | +| `cache_write_tokens` | `bigint` | 客户端逻辑响应 usage | +| `reasoning_tokens` | `bigint` | 客户端逻辑响应 usage | +| `total_tokens` | `bigint` | Provider 报告或规范化总量 | +| `usage_metrics_json` | `jsonb not null default '{}'` | 非 token 的注册 normalized usage metric | +| `first_token_ms` | `bigint` | 从请求开始到首个客户端可见 token | +| `latency_ms` | `bigint` | 请求总耗时 | +| `metadata_json` | `jsonb not null default '{}'` | 不含正文和秘密的请求事实 | +| `started_at` | `timestamptz not null` | 开始时间 | +| `deadline_at` | `timestamptz not null` | 接入时冻结的绝对执行截止时间 | +| `completed_at` | `timestamptz` | 终止时间 | + +约束: + +```text +outcome in ('running', 'succeeded', 'failed', 'cancelled', 'incomplete') +client_status_code is null or client_status_code between 100 and 599 +foreign key(model_route_id, public_model_id) + references model_routes(id, public_model_id) + on delete set null (model_route_id) + +foreign key(continuation_binding_id, client_api_key_id) + references continuation_bindings(id, client_api_key_id) + on delete set null (continuation_binding_id) +所有 token、first_token_ms、latency_ms 为 null 或 >= 0 +预算五字段 reserved/limit/currency/window_start/window_end 必须同时为 null 或同时非 null +budget_reserved_amount is null or (budget_reserved_amount >= 0 and budget_limit_amount >= budget_reserved_amount) +budget_currency is null or budget_currency ~ '^[A-Z]{3}$' +budget_window_start is null or (client_api_key_ref is not null and budget_window_start < budget_window_end) +client_api_key_id is null or (client_api_key_ref is not null and client_api_key_ref = client_api_key_id) +model_route_id is null or (model_route_ref is not null and model_route_ref = model_route_id) +model_route_id is null or public_model_id is not null +continuation_binding_id is null or (continuation_binding_ref is not null and continuation_binding_ref = continuation_binding_id) +continuation_binding_id is null or client_api_key_id is not null +started_at <= deadline_at +running -> completed_at is null +非 running -> completed_at is not null and completed_at >= started_at +``` + +索引: + +```text +(started_at desc, id desc) +(client_api_key_id, started_at desc, id desc) +(client_api_key_ref, started_at desc, id desc) where client_api_key_ref is not null +(model_route_id, started_at desc, id desc) where model_route_id is not null +(public_model_id, started_at desc, id desc) where public_model_id is not null +(continuation_binding_id, client_api_key_id, started_at desc, id desc) + where continuation_binding_id is not null +(continuation_binding_ref, started_at desc, id desc) where continuation_binding_ref is not null +(outcome, started_at desc, id desc) +(deadline_at, id) where outcome = 'running' +(client_api_key_ref, budget_window_start, budget_window_end, budget_currency) + where budget_reserved_amount is not null +unique(client_response_id) where client_response_id is not null +unique(id, client_response_id, client_api_key_id) +``` + +`client_api_key_ref`、`model_route_ref` 和 `continuation_binding_ref` 在创建请求时与对应 live FK 使用同一个 ID;只要 live FK 非空,数据库 check 就要求对应 `_ref` 相等。配置删除后 FK 可以置空,历史维度仍然可查询。Continuation owner 只能通过当前 client key 解析 response ID 得到 `continuation_binding_id`,API handler 不能自行填入其他调用方的 binding。`requested_model_id` 是有严格长度上限、默认不建索引的诊断原值,`public_model_id` 是 Router 成功解析后的稳定业务维度,不能互相冒充。严格预算请求必须冻结完整预算窗口和上限,使 policy 在请求执行中途被修改、Key 被禁用或 Redis 丢失时,reservation 仍可从事实表确定性重建。Policy 更新以新窗口生效;若管理员要求当前窗口立即生效,控制面必须先在 PostgreSQL 中按新规则重算并成功重建 Redis,再发布新 revision。 + +`incomplete` 只表示 Gateway 无法确认完整终态(例如进程终止或持久化失败后的 recovery),不是 OpenAI `response.incomplete`、max-token finish reason 等合法 Provider 结果;后者若已按协议完整交付仍记为 succeeded,并在规范化 finish metadata 中表达。 + +Token 列以 null 表示 Provider/协议没有给出可信值,以 0 表示明确观测到零,不能互换。`usage_metrics_json` 只保存 registry 中带固定单位的 metric 到非负十进制字符串的映射,例如 `{"image.output.count":"2","audio.input.second":"12.345"}`,禁止 JSON 浮点和 Provider 原始 usage body;它与固定 token 列一起构成价格计算输入,并继续汇总到长期 bucket。 + +本表不保存 Provider、credential 或 upstream model,它们属于 attempt。本表 usage 只表示最终交付给调用方的逻辑结果,不能用于计算重试产生的上游目录价格估算。 + +成功 request 必须存在且只存在一个 downstream-committed attempt;在鉴权后但调用上游前失败的 request 可以没有 attempt。Request usage 来自客户端实际收到的 canonical 结果,不能简单复制最后 attempt 或把多次 attempt 相加,尤其是在协议转换、截断和部分流式输出场景。 + +### `request_attempts` + +Provider 选定执行资源、准备发送上游请求时插入一行;未越过 commit 也必须记录: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Attempt ID | +| `gateway_request_id` | `text not null references gateway_requests on delete cascade` | Logical request | +| `attempt_index` | `integer not null check (> 0)` | 请求内从 1 开始的序号 | +| `trigger` | `text not null` | `initial/credential_retry/target_retry/route_fallback` | +| `model_route_target_id` | `text references model_route_targets on delete set null` | 当前可关联 route target | +| `model_route_target_ref` | `text` | Target ID 历史快照 | +| `provider_instance_id` | `text references provider_instances on delete set null` | 当前可关联 instance | +| `provider_instance_ref` | `text not null` | Instance ID 历史快照 | +| `provider_kind` | `text not null` | Provider 名称快照 | +| `upstream_credential_id` | `text` | 当前可关联 credential;与 instance 组成复合 FK | +| `upstream_credential_revision` | `bigint` | 本 attempt 实际使用的 credential secret/config revision | +| `resource_ref` | `text not null` | 匿名资源快照;无 credential 时为 `__none__` | +| `upstream_model_id` | `text not null` | 实际上游模型 ID | +| `service_tier` | `text not null default '__default__'` | 规范化后的实际 tier | +| `upstream_transport` | `text not null` | HTTP/SSE/WebSocket 等上游 transport | +| `upstream_send_state` | `text not null default 'not_sent'` | `not_sent/sent/ambiguous` | +| `outcome` | `text not null default 'running'` | `running/succeeded/failed/cancelled/incomplete` | +| `downstream_committed` | `boolean not null default false` | 是否向客户端越过 commit 边界 | +| `upstream_status_code` | `integer` | 上游 HTTP/协议状态 | +| `provider_error_code` | `text` | Provider 原始稳定 code | +| `failure_kind` | `text` | 稳定 Provider failure taxonomy | +| `retry_after_ms` | `bigint` | 上游建议等待时间 | +| `upstream_request_id` | `text` | 上游 request ID | +| `upstream_response_id` | `text` | adapter 分类为非 bearer 的上游诊断 response ID | +| `input_tokens` | `bigint` | 本 attempt 上游 usage | +| `output_tokens` | `bigint` | 本 attempt 上游 usage | +| `cached_tokens` | `bigint` | 本 attempt 上游 usage | +| `cache_write_tokens` | `bigint` | 本 attempt 上游 usage | +| `reasoning_tokens` | `bigint` | 本 attempt 上游 usage | +| `total_tokens` | `bigint` | 本 attempt 上游 usage | +| `usage_metrics_json` | `jsonb not null default '{}'` | 非 token 的注册 normalized usage metric | +| `model_price_version_id` | `text references model_price_versions on delete restrict` | 使用的价格版本;与模型/tier/currency 另组成复合 FK | +| `cost_estimate_status` | `text not null default 'not_applicable'` | `not_applicable/known/partial/unknown` | +| `estimated_cost_amount` | `numeric(20, 10)` | 本 attempt 已知或部分已知的目录价格估算 | +| `estimated_cost_currency` | `text` | 估算货币 | +| `cost_breakdown_json` | `jsonb not null default '{}'` | 各 metric 的估算明细和舍入依据 | +| `headers_ms` | `bigint` | 上游 headers 延迟 | +| `first_event_ms` | `bigint` | 上游首事件延迟 | +| `first_token_ms` | `bigint` | 上游首 token 延迟 | +| `latency_ms` | `bigint` | Attempt 总耗时 | +| `metadata_json` | `jsonb not null default '{}'` | 已脱敏 transport 和诊断事实 | +| `started_at` | `timestamptz not null` | 开始时间 | +| `deadline_at` | `timestamptz not null` | 本 attempt 冻结的绝对截止时间 | +| `completed_at` | `timestamptz` | 终止时间 | + +约束: + +```text +unique(gateway_request_id, attempt_index) +unique(gateway_request_id) where downstream_committed + +foreign key(upstream_credential_id, provider_instance_id) + references upstream_credentials(id, provider_instance_id) + on delete set null (upstream_credential_id) + +foreign key(model_price_version_id, provider_instance_id, upstream_model_id, service_tier, estimated_cost_currency) + references model_price_versions(id, provider_instance_id, upstream_model_id, service_tier, currency) + on delete restrict + +trigger in ('initial', 'credential_retry', 'target_retry', 'route_fallback') +upstream_send_state in ('not_sent', 'sent', 'ambiguous') +outcome in ('running', 'succeeded', 'failed', 'cancelled', 'incomplete') +upstream_status_code is null or upstream_status_code between 100 and 599 +retry_after_ms、token 和延迟字段为 null 或 >= 0 +cost_estimate_status in ('not_applicable', 'known', 'partial', 'unknown') +cost_estimate_status == 'not_applicable' -> upstream_send_state == 'not_sent' 且 estimated_cost_amount、estimated_cost_currency、model_price_version_id 均为 null +upstream_send_state == 'not_sent' -> cost_estimate_status == 'not_applicable' +upstream_send_state in ('sent', 'ambiguous') -> cost_estimate_status != 'not_applicable' +cost_estimate_status == 'unknown' -> estimated_cost_amount、estimated_cost_currency 均为 null;model_price_version_id 可为空或记录已经找到但 usage 不足以计算的版本 +cost_estimate_status in ('known', 'partial') -> estimated_cost_amount、estimated_cost_currency、model_price_version_id 均非 null +estimated_cost_amount is null or estimated_cost_amount >= 0 +estimated_cost_currency is null or estimated_cost_currency ~ '^[A-Z]{3}$' +model_route_target_id is null or (model_route_target_ref is not null and model_route_target_ref = model_route_target_id) +provider_instance_id is null or provider_instance_ref = provider_instance_id +upstream_credential_id is not null -> provider_instance_id is not null and resource_ref != '__none__' +upstream_credential_id is not null -> upstream_credential_revision is not null +upstream_credential_revision is null or upstream_credential_revision > 0 +started_at <= deadline_at +running -> completed_at is null +非 running -> completed_at is not null and completed_at >= started_at +``` + +索引: + +```text +(model_route_target_id, started_at desc, id desc) where model_route_target_id is not null +(model_route_target_ref, started_at desc, id desc) where model_route_target_ref is not null +(provider_instance_id, started_at desc, id desc) where provider_instance_id is not null +(provider_instance_ref, started_at desc, id desc) +(upstream_credential_id, started_at desc, id desc) where upstream_credential_id is not null +(resource_ref, started_at desc, id desc) where resource_ref is not null +(upstream_model_id, started_at desc, id desc) +(upstream_transport, started_at desc, id desc) +(model_price_version_id) where model_price_version_id is not null +(failure_kind, started_at desc, id desc) where failure_kind is not null +(provider_instance_ref, upstream_request_id) where upstream_request_id is not null +(provider_instance_ref, upstream_response_id) where upstream_response_id is not null +(deadline_at, id) where outcome = 'running' +``` + +`model_route_target_ref`、`provider_instance_ref`、`provider_kind`、`resource_ref` 和 `upstream_model_id` 是刻意保存的事实快照。配置实体被删除后,历史 attempt 仍可正确统计。Attempt 从 credential 行复制 `resource_ref`;它不能包含邮箱、API Key prefix 或其他身份信息。 + +`provider_kind`、instance ref、target ref 和实际 model 一律由 Attempt Coordinator 从已验证的 Route Plan/Provider result 派生,不能信任 API handler 或 Provider 错误对象中的任意字符串。插入时若 live instance/target 仍存在,owner 必须验证它们的 Provider kind 与目标模型一致;删除后的历史只依赖快照。 + +`deadline_at` 由 parent request 的冻结 deadline 与 target/Provider 更短 timeout 取最小值,不能晚于 parent request;这个跨表约束由 Attempt Coordinator 的同一行锁事务验证,防止一个 retry 悄悄越过客户端已经放弃的执行时间。 + +Attempt 还冻结所用的 `upstream_credential_revision`。Provider state owner 收到认证/配额结果时必须把该 revision 一并比较:旧 revision 的失败可以记录诊断,但不能把已经轮换的新 secret 标成 invalid 或覆盖其 cooldown/quota 状态。 + +`upstream_send_state` 与 `downstream_committed` 是两个独立边界:前者决定上游是否可能处理或计费,后者决定网关是否还能向客户端切换 attempt。一个 downstream-committed attempt 随后仍可 `failed` 或 `incomplete`;downstream commit 不等于成功。`ambiguous` 表示 payload 可能已送达但结果未知,默认禁止自动重放。 + +发送状态采用保守转换:attempt 初始为 `not_sent/not_applicable`;紧邻实际网络 send 之前,先 CAS 持久化为 `ambiguous/unknown`;transport 明确确认发送后改为 `sent`,只有能够证明业务 payload 零字节发送时才允许回到 `not_sent/not_applicable`。因此进程在 send 边界崩溃只会留下 ambiguous,不会把可能已处理的请求误判为可安全重放。 + +`failure_kind` 使用跨 Provider 稳定分类:`invalid_request`、`unsupported`、`unauthorized`、`permission_denied`、`rate_limited`、`quota_exhausted`、`timeout`、`transport`、`protocol`、`unavailable`、`cancelled` 和 `process_terminated`。Provider 原始错误 code 只用于诊断,不能代替该分类。 + +Attempt insert 是请求级上游 exchange 的持久化屏障:只有 `gateway_requests` 和本次 `request_attempts` 行都已提交,Provider 才能发起携带本次 credential 的请求级 handshake 或发送可能产生费用、副作用的 payload。Provider 内部不得隐藏 credential retry;每一次可能到达上游的业务调用都必须对应独立 attempt。与业务请求无关、尚未发送 payload 的后台 preconnect 只记录 transport/ops telemetry,不伪造 attempt。 + +HTTP/SDK/WebSocket transport 的自动 retry 和携带 payload 的自动 redirect 默认关闭;否则底层可能产生数据库看不见的第二次上游调用。确需 redirect 时由 Provider 校验目标并交回 Attempt Coordinator,以独立 attempt 记录;仅连接建立阶段且能够证明 payload 未发送的内部重试可以留在同一 attempt。 + +创建 attempt 的事务和把 attempt 改为 `downstream_committed = true` 的事务都必须锁定对应 `gateway_requests` 行。前者确认 request 仍为 running、尚无 committed attempt,并在锁内分配下一个 `attempt_index`;后者在同一锁内再次确认没有竞争者。这样不是只依赖 partial unique index“事后发现冲突”,也不会由两个并发 retry 从 `max(attempt_index) + 1` 得到相同序号,commit 后的新 attempt 也被串行拒绝。锁只覆盖同一个 logical request,不是全局热路径锁。 + +Request 和 attempt finalize 都使用 compare-and-swap:只允许把 `running` 更新为一个 terminal outcome,重复 finalizer 不得再次写 usage 或 bucket。Downstream-committed partial unique index 只保证最多一个已提交客户端的 attempt;“commit 后不能创建新 attempt”仍由 Attempt Coordinator 的状态机和契约测试保证,不使用数据库 trigger。 + +最终 attempt、logical request、continuation binding 与 portable transcript metadata 的终态写入使用同一 PostgreSQL 事务。只有 terminal CAS 实际更新一行时,事务内才调用唯一 bucket aggregator 应用对应 delta;CAS 未命中时不能再次累加。需要 continuation 的响应在该事务成功前不能发送最后 terminal event。 + +进程异常可能留下 `running` 行。启动和 recovery worker 必须依据持久化 `deadline_at`,把已经超时且不存在活动 lease 的 request/attempt 收敛为 `incomplete`,并记录稳定的 process-termination failure;不能依赖已经变化的 timeout 配置,也不能永久把它们算作在途请求。Retention worker 只在 recovery 完成后删除终态事实,不兼任状态推断。 + +## Continuation 与可移植历史 + +### `conversation_transcripts` + +每一行是一个不可变的 portable continuation canonical transcript snapshot: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Transcript ID | +| `client_api_key_id` | `text not null references client_api_keys on delete restrict` | 所属调用方 | +| `parent_conversation_transcript_id` | `text` | 上一个 snapshot;同一调用方内的自引用复合 FK | +| `canonical_version` | `integer not null check (> 0)` | Canonical item schema 版本 | +| `metadata_json` | `jsonb not null default '{}'` | 不含对话正文的摘要 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `expires_at` | `timestamptz not null` | 自动清理时间 | + +索引: + +```text +(client_api_key_id, created_at desc, id desc) +unique(id, client_api_key_id) +(parent_conversation_transcript_id, client_api_key_id) + where parent_conversation_transcript_id is not null +(expires_at) + +foreign key(parent_conversation_transcript_id, client_api_key_id) + references conversation_transcripts(id, client_api_key_id) + on delete restrict + +parent_conversation_transcript_id is null or parent_conversation_transcript_id != id +``` + +一个 portable continuation 从前一个 binding 的 snapshot 创建新的子 snapshot,只写入本轮 canonical input/output delta;parent 一经插入不可更新,旧 snapshot 和它的 items 永不 append 或改写。因此多个 response ID 从同一历史分支继续时,互不看到对方后续内容,也不会形成循环链。读取上下文按 parent 链从根到叶拼接,并设置受控最大深度;超过阈值时 history owner 创建新的 materialized root、解密后重新加密必要 items,绝不修改已经绑定给旧 response 的 snapshot。Materialized root 继承链上最早的 `expires_at`,不能借 compaction 延长用户正文保留期。 + +子 snapshot 的 `expires_at` 不得晚于 parent,且 owner 必须拒绝跨 client API key 建链。这个跨行时间规则与 parent owner FK 一起保证 retention 可以从叶子向根删除,而不会让较新的 continuation 指向已清理历史。 + +### `conversation_items` + +对话内容以 snapshot delta 保存,不以明文 JSON 保存: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `conversation_transcript_id` | `text not null references conversation_transcripts on delete cascade` | Transcript | +| `sequence` | `bigint not null check (>= 0)` | 当前 snapshot delta 内的 Canonical 顺序 | +| `item_kind` | `text not null` | `client_input/assistant_output/tool_result/...` | +| `item_envelope` | `bytea not null` | 加密 canonical item | +| `secret_key_id` | `text not null` | 加密密钥版本 | +| `token_estimate` | `bigint check (token_estimate is null or token_estimate >= 0)` | 可选上下文预算估算 | +| `created_at` | `timestamptz not null` | 写入时间 | + +主键: + +```text +primary key(conversation_transcript_id, sequence) +``` + +图片、音频和文件只保存受控资源引用,禁止把 base64 大对象写进 `item_envelope`。 + +### `continuation_bindings` + +一行表示客户端 response ID 的后续请求如何恢复: + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Binding ID | +| `client_response_id` | `text not null unique` | 网关暴露的 response ID | +| `gateway_request_id` | `text not null` | 来源 logical request;与 response/key 组成复合 FK | +| `client_api_key_id` | `text not null references client_api_keys on delete restrict` | 所属调用方 | +| `mode` | `text not null` | `native` 或 `portable` | +| `provider_instance_id` | `text references provider_instances on delete restrict` | Native owner | +| `upstream_credential_id` | `text` | 需要固定资源时填写;与 instance 组成复合 FK | +| `native_state_envelope` | `bytea` | 加密的 Provider continuation handle/state | +| `native_state_schema_version` | `integer` | Provider native state schema 版本 | +| `secret_key_id` | `text` | Native envelope 加密密钥版本 | +| `native_reuse_mode` | `text not null default 'not_applicable'` | `reusable/single_use/not_applicable` | +| `native_use_state` | `text not null default 'not_applicable'` | single-use binding 的持久化消费状态 | +| `native_consumer_gateway_request_id` | `text references gateway_requests on delete set null` | single-use binding 当前/最后 consumer request | +| `native_use_revision` | `bigint not null default 1 check (> 0)` | single-use state CAS 版本 | +| `conversation_transcript_id` | `text` | Portable transcript;与 client key 组成复合 FK | +| `metadata_json` | `jsonb not null default '{}'` | connection scope 等非秘密绑定信息 | +| `created_at` | `timestamptz not null` | 创建时间 | +| `updated_at` | `timestamptz not null` | 修改时间 | +| `expires_at` | `timestamptz not null` | 自动清理时间 | + +模式约束: + +```text +mode in ('native', 'portable') + +foreign key(gateway_request_id, client_response_id, client_api_key_id) + references gateway_requests(id, client_response_id, client_api_key_id) + on delete restrict + +foreign key(conversation_transcript_id, client_api_key_id) + references conversation_transcripts(id, client_api_key_id) + on delete restrict + +foreign key(upstream_credential_id, provider_instance_id) + references upstream_credentials(id, provider_instance_id) + on delete restrict + +native: + provider_instance_id != null + native_state_envelope、native_state_schema_version、secret_key_id 均非 null + native_state_schema_version > 0 + conversation_transcript_id == null + native_reuse_mode in ('reusable', 'single_use') + reusable -> native_use_state = 'reusable' 且 native_consumer_gateway_request_id is null + single_use -> native_use_state in ('available', 'claimed', 'consumed', 'ambiguous') + +portable: + conversation_transcript_id != null + provider_instance_id == null + upstream_credential_id == null + native_state_envelope == null + native_state_schema_version == null + secret_key_id == null + native_reuse_mode = 'not_applicable' + native_use_state = 'not_applicable' + native_consumer_gateway_request_id == null +``` + +索引: + +```text +unique(gateway_request_id) +unique(id, client_api_key_id) +(client_api_key_id, expires_at) +(provider_instance_id, expires_at) where provider_instance_id is not null +(upstream_credential_id) where upstream_credential_id is not null +(native_consumer_gateway_request_id) where native_consumer_gateway_request_id is not null +(conversation_transcript_id) where conversation_transcript_id is not null +(expires_at) +``` + +Redis 可以缓存 binding 和 connection-local topology,但 PostgreSQL 是持久 binding 的事实源。Native 失败时不能自动改为 portable。 + +复合外键在数据库中保证 response/request、request owner、transcript owner 和 native credential/instance 一致,应用校验只是更早返回友好错误,不能作为唯一防线。API 按当前 Key 查询 binding,不能只凭可猜测的 response ID 读取其他调用方历史。删除或重新生成 client API key 不自动继承旧 Key 的 portable transcript。 + +Native binding 的 instance、可选 credential scope 和 continuation state 必须取自该 request 唯一 downstream-committed attempt 的终态结果,不能接受 API handler 传入的任意组合;portable binding 只能引用本次 finalization transaction 新建的不可变 canonical transcript snapshot。`native_state_envelope` 由对应 Provider adapter 生成和校验,可以容纳 response ID、conversation ID 或 connection scope,但通用 store 不解析其内部字段。 + +Portable binding 的 `expires_at` 不得晚于 transcript 的 `expires_at`;native binding 的有效期也不能超过 Provider 声明的上游 continuation 生命周期。Provider adapter 在写 binding 时声明 native state 是 reusable(必须能安全并发使用)还是 single-use,不能让 API 选择;“可重复但只能串行”不进入通用 binding 语义,Provider 必须提供自己的持久状态边界或禁用 native continuation。对 single-use binding,history owner 在创建 consumer request 时锁定 binding 行并 CAS `available → claimed`,同时写入 `native_consumer_gateway_request_id`;若 attempt 被证明 `not_sent` 则 CAS 释放为 available,若 sent 则变为 consumed,若 send 边界 ambiguous 则永久标为 ambiguous、禁止重用。Redis continuation lease 只缩短竞争窗口,PostgreSQL `native_use_state/native_use_revision` 才是 Redis 丢失后的正确性边界。新 response 产生新 binding,旧 binding 不原地变成新 continuation state。该跨行规则由 history owner 在同一事务中校验,并由 retention 契约测试覆盖。 + +Recovery worker 对超时 `claimed` binding 读取其 consumer request/attempt:没有 attempt 或全部已证明 not-sent 才释放,存在 sent/ambiguous attempt 则分别收敛为 consumed/ambiguous。它不能根据 Redis lease 是否过期擅自释放,否则进程崩溃会把已经到达上游的一次消费重新放行。 + +## 运维事件 + +### `ops_events` + +只记录请求事实之外的后台错误和重要运维事件。请求错误已经存在于 `gateway_requests` 和 `request_attempts`,禁止再复制一份。 + +| 字段 | 类型与约束 | 语义 | +| --- | --- | --- | +| `id` | `text primary key` | Event ID | +| `level` | `text not null` | `warning` 或 `error` | +| `component` | `text not null` | models、oauth、quota、update 等 owner | +| `operation` | `text not null` | 具体后台操作 | +| `provider_instance_id` | `text references provider_instances on delete set null` | 可选 live join | +| `provider_instance_ref` | `text` | Instance 历史快照 | +| `provider_kind` | `text` | Provider 名称快照 | +| `upstream_credential_id` | `text` | 可选 live join;与 instance 组成复合 FK | +| `resource_ref` | `text` | 资源历史快照 | +| `failure_kind` | `text not null` | 稳定错误分类 | +| `status_code` | `integer` | 可选协议状态 | +| `message` | `text not null` | 已脱敏安全消息 | +| `metadata_json` | `jsonb not null default '{}'` | 已脱敏诊断 | +| `created_at` | `timestamptz not null` | 发生时间 | + +约束: + +```text +level in ('warning', 'error') +status_code is null or status_code between 100 and 599 +foreign key(upstream_credential_id, provider_instance_id) + references upstream_credentials(id, provider_instance_id) + on delete set null (upstream_credential_id) +provider_instance_id is null or (provider_instance_ref is not null and provider_instance_ref = provider_instance_id) +upstream_credential_id is null or (provider_instance_id is not null and resource_ref is not null) +``` + +索引: + +```text +(created_at desc, id desc) +(component, created_at desc, id desc) +(provider_instance_id, created_at desc, id desc) + where provider_instance_id is not null +(provider_instance_ref, created_at desc, id desc) + where provider_instance_ref is not null +(upstream_credential_id, created_at desc, id desc) + where upstream_credential_id is not null +(resource_ref, created_at desc, id desc) + where resource_ref is not null +(failure_kind, created_at desc, id desc) +``` + +Ops recorder 对相同故障做有界速率限制并周期性写入已脱敏的 suppressed count,不能在 Provider outage 的每次后台 retry 都无限插入一行。限流只抑制重复诊断噪声,不得吞掉首次故障或管理审计事实。 + +当 live instance/credential 存在时,Ops recorder 从其受控 snapshot 填充 `provider_instance_ref`、`provider_kind` 和 `resource_ref`;错误对象只能提供经过 allowlist 的 failure/status 信息,不能反向决定这些维度。 + +## 派生聚合 + +### `request_metric_buckets` + +保存客户端视角的长期趋势,不包含 Provider 维度: + +| 字段 | 类型与约束 | +| --- | --- | +| `bucket_start` | `timestamptz not null` | +| `client_api_key_ref` | `text not null` | +| `protocol` | `text not null` | +| `operation` | `text not null` | +| `client_transport` | `text not null` | +| `model_route_ref` | `text not null` | +| `public_model_id` | `text not null` | +| `request_count` | `bigint not null default 0 check (>= 0)` | +| `success_count` | `bigint not null default 0 check (>= 0)` | +| `failure_count` | `bigint not null default 0 check (>= 0)` | +| `cancelled_count` | `bigint not null default 0 check (>= 0)` | +| `incomplete_count` | `bigint not null default 0 check (>= 0)` | +| `caller_error_count` | `bigint not null default 0 check (>= 0)` | +| `input_tokens` | `bigint not null default 0 check (>= 0)` | +| `output_tokens` | `bigint not null default 0 check (>= 0)` | +| `cached_tokens` | `bigint not null default 0 check (>= 0)` | +| `cache_write_tokens` | `bigint not null default 0 check (>= 0)` | +| `reasoning_tokens` | `bigint not null default 0 check (>= 0)` | +| `total_tokens` | `bigint not null default 0 check (>= 0)` | +| `usage_metrics_json` | `jsonb not null default '{}'` | +| `first_token_latency_sum` | `bigint not null default 0 check (>= 0)` | +| `first_token_latency_count` | `bigint not null default 0 check (>= 0)` | +| `latency_sum` | `bigint not null default 0 check (>= 0)` | +| `latency_count` | `bigint not null default 0 check (>= 0)` | +| `max_latency_ms` | `bigint not null default 0 check (>= 0)` | +| `min_latency_ms` | `bigint check (min_latency_ms is null or min_latency_ms >= 0)` | +| `updated_at` | `timestamptz not null` | + +主键: + +```text +primary key( + bucket_start, + client_api_key_ref, + protocol, + operation, + client_transport, + model_route_ref, + public_model_id +) +``` + +`client_api_key_ref` 和 `model_route_ref` 使用当时的 ID 快照;无对应实体时使用稳定的 `__none__`,未知值使用 `__unknown__`。无法解析的客户端模型统一聚合为 `public_model_id = '__unmatched__'`,绝不能把任意 `requested_model_id` 放进 bucket 主键制造无界高基数;原始有界值只留在 `gateway_requests` 保留期内。 + +### `attempt_metric_buckets` + +保存上游视角的健康、资源用量和成本: + +| 字段 | 类型与约束 | +| --- | --- | +| `bucket_start` | `timestamptz not null` | +| `provider_kind` | `text not null` | +| `provider_instance_ref` | `text not null` | +| `resource_ref` | `text not null` | +| `upstream_model_id` | `text not null` | +| `service_tier` | `text not null` | +| `upstream_transport` | `text not null` | +| `currency` | `text not null` | +| `attempt_count` | `bigint not null default 0 check (>= 0)` | +| `success_count` | `bigint not null default 0 check (>= 0)` | +| `failure_count` | `bigint not null default 0 check (>= 0)` | +| `cancelled_count` | `bigint not null default 0 check (>= 0)` | +| `incomplete_count` | `bigint not null default 0 check (>= 0)` | +| `rate_limited_count` | `bigint not null default 0 check (>= 0)` | +| `auth_failure_count` | `bigint not null default 0 check (>= 0)` | +| `provider_5xx_count` | `bigint not null default 0 check (>= 0)` | +| `not_billable_attempt_count` | `bigint not null default 0 check (>= 0)` | +| `input_tokens` | `bigint not null default 0 check (>= 0)` | +| `output_tokens` | `bigint not null default 0 check (>= 0)` | +| `cached_tokens` | `bigint not null default 0 check (>= 0)` | +| `cache_write_tokens` | `bigint not null default 0 check (>= 0)` | +| `reasoning_tokens` | `bigint not null default 0 check (>= 0)` | +| `total_tokens` | `bigint not null default 0 check (>= 0)` | +| `usage_metrics_json` | `jsonb not null default '{}'` | +| `first_token_latency_sum` | `bigint not null default 0 check (>= 0)` | +| `first_token_latency_count` | `bigint not null default 0 check (>= 0)` | +| `latency_sum` | `bigint not null default 0 check (>= 0)` | +| `latency_count` | `bigint not null default 0 check (>= 0)` | +| `max_latency_ms` | `bigint not null default 0 check (>= 0)` | +| `min_latency_ms` | `bigint check (min_latency_ms is null or min_latency_ms >= 0)` | +| `fully_priced_attempt_count` | `bigint not null default 0 check (>= 0)` | +| `partially_priced_attempt_count` | `bigint not null default 0 check (>= 0)` | +| `unpriced_attempt_count` | `bigint not null default 0 check (>= 0)` | +| `estimated_cost_amount` | `numeric(20, 10) not null default 0 check (>= 0)` | +| `updated_at` | `timestamptz not null` | + +主键: + +```text +primary key( + bucket_start, + provider_kind, + provider_instance_ref, + resource_ref, + upstream_model_id, + service_tier, + upstream_transport, + currency +) +``` + +无 credential 的 Provider 使用 `resource_ref = '__none__'`,默认 tier 使用 `service_tier = '__default__'`。`not_sent` attempt 使用 `currency = '__none__'` 并增加 `not_billable_attempt_count`;unknown estimate 使用 `currency = '__unknown__'` 并增加 `unpriced_attempt_count`;partial estimate 使用真实 currency 并增加 `partially_priced_attempt_count`。Dashboard 必须同时展示 not-applicable/known/partial/unknown 覆盖率,不能通过 `estimated_cost_amount = 0` 伪装成免费,也不能跨 currency 求和。 + +两张表都使用 UTC 对齐的固定 15 分钟 `bucket_start`,数据库 check 要求 epoch 秒数能被 900 整除;更粗粒度由查询层 roll up,不在同一表混存多种 bucket width。表中的 `request_count/attempt_count` 只在事实终态时增加,总数必须等于 success、failure、cancelled 与 incomplete 四类 terminal 计数之和;`caller_error_count` 是 failure 的子集,不参与第二次求和。Attempt 表还必须满足 `attempt_count = not_billable + fully_priced + partially_priced + unpriced`。 + +Attempt bucket 的 `currency` 只能是大写三字符代码、`__none__` 或 `__unknown__`。`__none__` 行只能累计 not-billable attempt,`__unknown__` 行只能累计 unpriced attempt,真实 currency 行只能累计 fully/partially priced attempt;这些关系使用 check constraint 固化,避免同一行把无价格和零价格混在一起。 + +`usage_metrics_json` 是注册 metric 到非负十进制字符串累计值的对象,由 bucket owner 使用十进制定点数逐 key 加法,用于在原始事实过期后仍保留 image、audio 等非 token 用量。它不接受任意 Provider key 或 JSON 浮点。Latency count 为 0 时对应 sum/max 必须为 0 且 min 为 null;count 大于 0 时 min 非空且 `min <= max`,防止“没有样本但显示 0 ms”。 + +两张 bucket 表必须可以从保留期内的原始事实重建。增量聚合与 terminal CAS 位于同一事务,避免没有 outbox/checkpoint 时的重复累计和漏算;周期性 rebuild 只负责校验或修复保留期内的派生数据。聚合只能由一个 owner 写入;API、Provider 和 Dashboard 不各自维护计数。`account_usage` 一类可漂移的累计表不进入终态。 + +Bucket delta 使用单条 PostgreSQL upsert 原子相加并检测 bigint/numeric overflow,不能先 select 再由应用回写。若 finalization 同时更新 request 与 attempt bucket,所有 writer 使用固定锁顺序,避免高流量下产生可预期的 bucket-row deadlock。 + +Rebuild 只替换已经超过最大 request deadline 与迟到宽限期的 closed bucket,并在窗口级锁内从事实表重算后原子替换;当前仍可能接收 terminal delta 的 open bucket 只做对比,不允许“先清空再重建”覆盖并发写入。 + +## PostgreSQL 删除与保留规则 + +| 数据 | 删除规则 | +| --- | --- | +| Provider instance | 先 disable 并从 Snapshot 移除;存在 route target、价格或未过期 native binding 时禁止删除 | +| Credential | 先 disable、停止新 lease 并 drain;未过期 native binding 存在时禁止删除;删除时清除 secret 和 Provider 专属子表,历史 attempt 保留快照 | +| Credential state | 随 credential 级联删除,不影响历史 attempt 快照 | +| Provider model | 缺失时设为 unavailable;被 model route target 或历史价格引用时禁止删除 | +| Model route | 删除时级联 model route targets;历史 request 的 live FK 置空 | +| Price version | 发布后不可修改;被 attempt 引用后禁止删除 | +| Client API key | 先 disable、拒绝新请求并 drain/recover running request;同一删除事务先显式删除 binding/transcript,再删除 Key,使历史 request FK 置空 | +| Gateway request | 按 retention 删除并级联 attempts;只要 binding 仍存在,FK 就拒绝删除,清理任务必须先删除已过期 binding | +| Conversation transcript | 先删除已过期 portable binding,再从 snapshot 叶子向根删除;items 随 transcript 级联删除 | +| Admin audit event | 不因 actor 或目标实体删除而删除,仅按 audit retention 清理 | +| Ops event | 按独立 retention 删除 | +| Metric bucket | 按 bucket retention 删除 | + +`gateway_requests`、`request_attempts` 和 `ops_events` 初期使用普通表与 `(time desc, id desc)` 索引。只有生产数据量证明 retention delete 或索引维护成为瓶颈时,才按月进行时间分区;不在首轮迁移提前引入分区管理。 + +配置实体的 hard delete 不是普通更新:统一执行 disable → 发布 revision → 停止新租约 → 等待 running attempt/后台任务 drain → 检查 binding/引用 → delete。超时后是否强制终止必须是显式管理操作并写 audit,不能让 `on delete cascade` 意外中断在途请求。Client API key 的删除顺序固定为 binding → transcript snapshot/items(叶子到根)→ key,避免复合 owner FK 依赖多个 referential action 的触发顺序。Retention 按小批量、有界锁时长删除,并先运行 stale recovery;备份和只读副本也必须遵守同一数据保留与密钥退役策略,否则主库删除不等于隐私数据真正到期。 + +Control-plane validator 保证 usage retention 大于最大 request deadline、迟到宽限和 bucket rebuild 安全余量之和;否则可能在 request 尚未 recovery 或 bucket 尚未关闭时先删掉唯一事实。Retention cutoff 使用数据库时间并记录每批高水位,任务重跑不能越过未处理引用。 + +## Redis 终态边界 + +Redis 保存: + +| Key 族 | 内容 | 性质 | +| --- | --- | --- | +| `admin:session:*` | 管理员会话 | TTL | +| `runtime:snapshot:*` | 配置版本通知 | 可重建 | +| `runtime:credential:*` | Credential revision 变更通知 | 可从 PostgreSQL 重载 | +| `lease:provider-instance:*` | 多实例共享的 endpoint 并发与请求间隔 | 短 TTL | +| `lease:credential:*` | 多实例 credential 并发或刷新租约 | 短 TTL | +| `lease:provider-task:*` | Catalog/health/quota 等后台任务 leader lease | 短 TTL、结果仍由数据库 CAS 防旧写 | +| `lease:continuation:*` | Provider 声明为单并发/单次消费的 native continuation | 短 TTL、fencing token | +| `cooldown:credential:*` | 热路径 cooldown 缓存 | PostgreSQL 状态的可丢失缓存 | +| `rate:client-key:*` | 调用方速率和并发计数 | 窗口 TTL | +| `budget:client-key:*` | 当前预算窗口的 estimated spend 与 in-flight reservation | 可由 request/attempt facts 重建 | +| `continuation:*` | Binding 与 connection-local topology 缓存 | 只缓存 opaque ID 或加密 envelope,PostgreSQL/上游状态可恢复 | +| `circuit:provider-instance:*` | 多实例共享熔断摘要 | TTL、可由 attempt 恢复 | + +Redis 不保存上游 secret、对话正文、唯一模型目录或唯一计费事实。Provider model catalog 的权威数据进入 PostgreSQL,RuntimeSnapshot 在进程内使用不可变结构读取。 + +预算限额在 request 接入时从 `client_api_keys.policy_json` 冻结到 request,已发生的目录价格估算来自 `request_attempts`。Redis 只原子维护当前窗口的 in-flight reservation 和热聚合;接入时按可计算的保守上界预留,终止时按全部 attempts 的估算成本调整。Unknown estimate 不能按零释放 reservation,必须按 policy 保留估算、拒绝后续请求或显式降级为 soft budget。 + +严格预算接入顺序固定为:Redis 原子检查并预留、PostgreSQL 写入带预留金额的 running request、之后才允许建立 attempt。PostgreSQL 写入失败时释放 Redis reservation;进程在两步之间崩溃只会留下带 TTL 的保守孤儿 reservation,不会产生没有持久 request 的上游消费。 + +Redis 丢失后必须先按 `client_api_key_ref + budget_window_start/end + budget_currency` 从 terminal attempt facts 与仍为 `running` 的请求重建,再恢复严格预算接入。重建过程持有窗口级 fencing token,避免一边扫描旧事实一边接受新 reservation。若无法完成重建,应 fail closed 或显式降级为 soft budget,不能静默按零消费继续放行。 + +跨实例 semaphore、request interval、rate limit 和严格预算的多 Key 原子操作必须使用同一 Redis Cluster hash tag、Redis server time 和带 fencing token 的 Lua/function;不能依赖应用节点时钟或先 GET 后 SET。Redis 不可用时,上游并发/间隔和 strict budget 默认 fail closed;只允许经过显式配置的单节点 soft-limit 降级,并把降级状态暴露到健康检查和审计中。 + +Redis epoch 变化时先暂停新的受限操作:client concurrency 从 running requests 重建,RPM/TPM 从当前窗口 request facts 重建或等待窗口自然结束,provider/credential lease 从 running attempts 与仍存活 worker 的重新注册保守恢复。恢复完成前不能把“Redis 是空的”解释成“当前使用量为零”。 + +## Secret 与隐私 + +当前表中的 `client_api_keys.key`、`accounts.access_token`、`accounts.refresh_token` 和 `account_cookies.value` 都是可直接使用的明文,终态不允许继续存在。 + +终态使用三类互相独立的密钥材料: + +1. Encryption root key:通过不同 domain 派生 AEAD key,分别加密上游 secret、Cookie、native continuation state 和 portable transcript item。 +2. Credential fingerprint key:HMAC credential 用于去重,不可用于解密。 +3. Identity pseudonym key:只用于 installation ID 和遥测资源伪名,不可兼作前两者。 + +加密 key 不存 PostgreSQL,应来自环境、KMS 或 secret manager。Envelope 自带 nonce、算法和格式版本,表中 `secret_key_id` 用于轮换;`key_hash`、credential fingerprint 和 resource ref 的文本值也携带算法/版本前缀,避免以后增加独立版本列。AEAD additional authenticated data 必须绑定用途、表、owner ID 和行主键/sequence,防止不同 credential、Cookie 或 conversation item 之间交换密文。轮换采用读旧写新,不修改历史迁移文件。 + +以下数据禁止进入任何 `metadata_json`、错误消息或日志: + +- API Key、OAuth token、Cookie、AWS secret。 +- 完整 request/response body。 +- 未脱敏 Provider 原始错误正文。 +- Portable conversation 明文内容和 capability-bearing native continuation handle。 + +每个 `metadata_json` 必须由该表 owner 使用字段 allowlist 构造,不能直接序列化外部 request、Provider response、Rust `Debug` 对象或任意 `serde_json::Value`。`request_attempts.upstream_request_id/upstream_response_id` 只允许保存 adapter 明确分类为非 bearer 的诊断 ID;任何可用于恢复、读取或劫持上游会话的值只能进入 `continuation_bindings.native_state_envelope`。需要新增可查询字段时优先增加普通列;只有确实属于 Provider 扩展且不参与通用查询时才进入 JSON。 + +## 当前表迁移归宿 + +| 当前表或字段 | 终态归宿 | +| --- | --- | +| `schema_migrations` | 原表保留,继续校验版本、名称和 checksum | +| `admin_users` | 原表保留 | +| 管理配置变更 | 新增不可变 `admin_audit_events` | +| `client_api_keys.key` | 迁移为 `key_hash`,完整 Key 只返回一次 | +| `runtime_settings.model_aliases_json` | `model_routes`、`model_route_targets` | +| `runtime_settings.rotation_strategy` | `model_routes.routing_strategy` | +| `runtime_settings.ops_error_retention_days` | `ops_event_retention_days` | +| Codex refresh/concurrency 设置 | Codex Provider config 与 credentials | +| `accounts` | `upstream_credentials` + `upstream_credential_states` + `codex_accounts` | +| `accounts.status` | 管理禁用映射到 credential `enabled`;过期、封禁和额度状态映射到 credential state `availability/state_json` | +| `accounts.quota_*`、cooldown 字段 | `upstream_credential_states.cooldown_until/state_json` | +| `accounts.access_token/refresh_token` | 加密 `secret_envelope` | +| `account_cookies` | `codex_account_cookies`,value 加密 | +| `account_usage` | 从 attempts 或 `attempt_metric_buckets` 查询,最终删除 | +| `usage_records` | `gateway_requests` + `request_attempts` | +| 请求相关 `ops_error_logs` | request/attempt failure 字段 | +| 后台 `ops_error_logs` | `ops_events` | +| `request_time_buckets` | `request_metric_buckets` + `attempt_metric_buckets` | +| Redis 模型快照 | PostgreSQL `provider_models` + 进程 RuntimeSnapshot | +| Redis response affinity | `continuation_bindings` 的缓存与 WS topology | + +迁移顺序固定为: + +1. 启动前验证独立 encryption root、credential fingerprint 和 identity pseudonym key 已就绪;密钥缺失时拒绝执行会产生明文回退的迁移。 +2. Expand:只追加新表、加密列和兼容索引,创建 Provider、credential config/state、model、route、price、request/attempt、audit、continuation 与 bucket 结构;不在同一发布中删除旧列。request 与 continuation binding 的双向 FK 先建表、回填并验证,再以 `not valid` 约束分阶段添加,避免循环依赖阻塞 rollout。 +3. Backfill:以有界批次和可恢复 checkpoint 加密 client key、token、Cookie,并回填唯一 Codex instance、账号和保留期内 telemetry。每批记录数量、checksum 和失败原因,允许安全重跑但不能重复生成事实;对仍会 OAuth 刷新的旧账号,cutover 前必须短暂冻结旧 writer、drain refresh lease 并做最后一轮 delta copy,不能让 backfill 与旧 writer 静默竞争。 +4. Verify:用独立查询逐项对账行数、credential 身份、usage、终态、引用和解密抽样;新二进制可 shadow-read 比较,但同一事实始终只有旧或新 writer,不能双写后靠时间戳择胜。 +5. Cutover:先切换 Codex RuntimeSnapshot 和请求执行读取,再在一个明确版本切换 request/attempt、continuation 和 bucket 的唯一 writer/readers;切换点持久化并可观测。 +6. Contract:至少经过一个不再支持旧 writer 的稳定发布和备份保留窗口后,删除明文/旧表/旧 Redis 事实;回滚只能回到仍理解新 schema 且不会重新写明文的版本。 + +迁移期间可以离线回填和验证,但一个生产事实在任一版本只能有一个 writer。禁止长期 dual-write 后依靠人工判断哪一份正确。DDL 必须评估表锁:大表的 default、not-null、FK validation 和索引采用分阶段 `not valid`/validate 或 concurrent build,不能把终态约束直接变成不可控停机。`CREATE INDEX CONCURRENTLY` 不能放进当前的事务型 migration runner,届时必须增加受审计、可重试的 non-transactional migration phase,而不是把命令偷偷塞进普通 migration SQL。 + +现有迁移文件已经受 checksum 保护,不能修改 `0001_initial.sql` 或 `0002_cache_write_tokens.sql`;所有变化只能追加新版本。Telemetry 回填按非空旧 `request_id` 聚合 logical request,按 `attempt_index` 和时间恢复 attempts;缺少 request ID、commit、usage 或 price 证据的旧记录使用新生成 ID 和 `legacy` metadata,并保持 unknown,不能伪造成功、目录价格估算或 continuation。`account_usage` 累计值不反向生成请求事实。 + +## 明确不创建的结构 + +- 不创建 `providers` lookup 表;Provider code 由 Registry 决定。 +- 不创建包含所有平台字段的通用 `accounts` 大表。 +- 不创建 tenant、organization、project 等当前没有业务语义的层级。 +- 不创建 Provider capability EAV 表;能力快照使用校验后的 JSON。 +- 不创建 credential group;隔离资源池使用独立 Provider instance。 +- 不创建成功表和失败表两套请求事实。 +- 不创建通用 `subject_type/subject_id` usage ledger。 +- 在只做目录价格估算的阶段不创建 invoice/payment ledger;接入真实账单对账时单独建模,不能复用或篡改 attempt estimate。 +- 不保存默认 request/response payload archive。 +- 当前不引入响应缓存;如果未来要本地命中成功响应,必须显式增加 response source/cache 事实并重写 request-success invariant,不能伪造一个上游 attempt。 +- 不预留无法完成响应 replay 的空 idempotency 表;只有协议层明确承诺幂等语义时才单独设计。 +- 不用 PostgreSQL 行锁实现热路径 semaphore、lease 或 circuit breaker。 + +## 数据与并发契约测试 + +终态迁移和 store 必须覆盖: + +1. 空库完整迁移、从每个受支持旧版本升级、migration name/checksum 篡改拒绝。 +2. 所有 FK 的 cascade、set-null 和 restrict 行为,以及每个 FK 是否有支持索引;行为 JSON 缺少/不支持 `schema_version`、超长文本/JSON 或保留 sentinel 必须被拒绝。 +3. 两个并发控制面事务只能得到不同且递增的 `config_revision`,stale expected revision 必须 conflict;Snapshot 读取只能得到一个完整 revision,丢失 Redis 通知后轮询仍能收敛。 +4. `credential_revision` 与 `state_revision` 独立 CAS,旧 `observed_at` 不能覆盖新状态;同一稳定 resource 不能复制到多个 instance 绕过限制。 +5. 上游 payload 发送前 request/attempt 行已经提交;attempt target/instance 必须属于 request 冻结的 route;`upstream_send_state` 与 `downstream_committed` 不混淆;并发 create/commit 时同一 request 最多一个 downstream-committed attempt 且 commit 后没有新 attempt,重复 finalizer 只结算一次。 +6. Stale running request/attempt 依据持久 deadline 收敛为 incomplete,不能因配置变化改变判断,也不能重复写 usage 或估算成本。 +7. Price version 在 `effective_from` 边界正确选择;known、partial、unknown、明确零价、多货币和 estimated/invoice 语义均不混淆。 +8. Native/portable continuation check、调用方 owner、response/request、credential/instance 对应关系、native state 与 transcript 加密边界由真实 FK/transaction 验证;同一 portable history 的分支不能读到彼此后续 delta,single-use native binding 在 Redis flush/进程崩溃后也不会重复消费。 +9. Client key、Provider、credential、route 和 transcript 删除后的历史快照与隐私清理结果。 +10. 从 request/attempt facts 重建 closed bucket 后,与增量聚合逐字段一致;unknown estimate 不进入已知金额,非 token metric 不丢失,任意客户端 model ID 不制造高基数 bucket。 +11. 数据库、管理 API、Debug、错误和日志样本中不存在完整 client key、上游 secret、Cookie 或 conversation 明文。 +12. Retention 先 recovery,再清理过期 binding、从 transcript leaf 向 root 清理,最后清理 request;未过期 continuation 不会被原始事实清理提前破坏,usage facts 不会早于 bucket 安全窗口删除。 +13. Redis flush/epoch 变化时,rate、concurrency、lease 和 strict budget 在重建完成前不会把空状态当成零;预算窗口可仅从冻结 request 与 attempt facts 恢复。 + +涉及并发的测试必须使用真实 PostgreSQL transaction 和唯一约束,不能只用内存 mock 证明。 + +## 验收标准 + +1. 新增普通 API Key Provider 只增加 Provider adapter 和 instance/credential 数据,不修改通用 schema。 +2. 新增 Provider 专属表时,只能承载真实可查询状态,不能复制通用 credential、model、request 或 attempt 字段。 +3. 一次客户端请求始终只有一行 `gateway_requests`,所有上游调用都能按顺序关联到 `request_attempts`。 +4. 最多一个 attempt 可以 `downstream_committed = true`,commit 后不能出现新的 attempt;ambiguous upstream send 不会自动重放。 +5. 客户端 usage、上游 usage 和上游目录价格估算可以分别对账;known、partial、unknown estimate 可区分,未知价格不会显示为零。 +6. 删除或禁用 Provider/credential 后,历史请求仍能按 Provider、instance、resource 和模型统计。 +7. PostgreSQL、备份、Redis、日志和管理 API 都不能恢复下游完整 Key 或读取上游明文秘密。 +8. Dashboard 聚合与 credential 用量可以从事实表重建,不依赖 `account_usage` 一类可漂移累计状态。 +9. Native continuation 不能跨 Provider;portable continuation 的正文始终加密并与调用方 Key 隔离。 +10. 所有 FK、外部 ID 与历史 `_ref` 均符合本文命名规则,不存在含义不明的 `route_id`、`request_id`、`credential_id` 或 `_key`。 +11. 数据库设计不要求 Gateway Engine、API adapter 或 Provider adapter 互相了解对方的存储结构。