# 多平台统一 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 互相了解对方的存储结构。