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 <noreply@openai.com>
91 KiB
多平台统一 AI 网关终态数据模型
本文定义 多平台统一 AI 网关目标架构 对应的终态 PostgreSQL 与 Redis 数据边界。它是目标设计,不表示当前迁移已经完成;当前生效表仍以 architecture.md 和仓库迁移为准。
本文中的表名、字段归属、事实层级、删除规则和秘密存储规则是终态约束。实际迁移可以分阶段完成,但不能长期保留两套生产事实来源。
设计决策
- PostgreSQL 是持久事实的唯一权威来源;Redis 只保存可过期、可重建的协调状态。
- Provider 是编译进进程的代码,不创建
providers表;数据库只保存可以动态配置的 Provider instance。 - Router 选择 Provider instance 和 model target,Provider 自己选择 credential。
- 调用方隔离和策略单元固定为现有
client_api_key,不提前引入 tenant、organization 或 project。 - 下游 client API key 只保存不可逆摘要;上游 API Key、OAuth token、Cookie 等可恢复秘密保存加密 envelope 或外部 secret reference。
- 稳定、需要查询的跨平台事实使用普通列;Provider 专属且不参与通用查询的内容使用经过 adapter 校验的
jsonb。 - 客户端的一次请求记录为 logical request;每次真实上游调用记录为 attempt。成功、失败和取消不再分散到不同事实表。
- logical request usage 表示客户端实际收到的用量;attempt usage 表示上游实际报告、可能产生费用的用量。
- 默认不保存 prompt、response body 或原始 Provider 错误正文。Portable continuation 的内容使用独立加密表。
- 聚合桶是派生数据,不参与调度、计费结算或请求正确性判断。
类型与命名约定
- 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缩写。
约束和索引显式命名:
pk_<table>
fk_<child_table>__<parent_table>
ux_<table>__<columns>
idx_<table>__<columns>
ck_<table>__<rule>
每个外键都必须有以该外键列开头的主键、唯一索引或普通索引,避免父行更新和删除时扫描子表。不能为了“可能查询”重复创建已经被主键或唯一约束覆盖的同列索引。
身份字段不可重绑定
一个 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 的业务含义是否被偷换”。
关系图
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 |
发生时间 |
约束和索引:
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 |
修改时间 |
索引:
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 校验,至少允许表达:
{
"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 |
修改时间 |
约束与索引:
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 存储必须满足二选一:
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 行。
索引:
(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 |
持久化时间 |
约束和索引:
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 |
下次计划刷新时间 |
唯一索引沿用当前身份约束:
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 |
修改时间 |
索引:
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 |
修改时间 |
主键:
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 |
修改时间 |
约束:
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 |
修改时间 |
约束与索引:
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 |
创建时间 |
约束与索引:
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) |
每单位价格 |
主键:
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 |
终止时间 |
约束:
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
索引:
(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 |
终止时间 |
约束:
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
索引:
(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 |
自动清理时间 |
索引:
(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 |
写入时间 |
主键:
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 |
自动清理时间 |
模式约束:
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
索引:
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 |
发生时间 |
约束:
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)
索引:
(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 |
主键:
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 |
主键:
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 都是可直接使用的明文,终态不允许继续存在。
终态使用三类互相独立的密钥材料:
- Encryption root key:通过不同 domain 派生 AEAD key,分别加密上游 secret、Cookie、native continuation state 和 portable transcript item。
- Credential fingerprint key:HMAC credential 用于去重,不可用于解密。
- 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 |
迁移顺序固定为:
- 启动前验证独立 encryption root、credential fingerprint 和 identity pseudonym key 已就绪;密钥缺失时拒绝执行会产生明文回退的迁移。
- Expand:只追加新表、加密列和兼容索引,创建 Provider、credential config/state、model、route、price、request/attempt、audit、continuation 与 bucket 结构;不在同一发布中删除旧列。request 与 continuation binding 的双向 FK 先建表、回填并验证,再以
not valid约束分阶段添加,避免循环依赖阻塞 rollout。 - Backfill:以有界批次和可恢复 checkpoint 加密 client key、token、Cookie,并回填唯一 Codex instance、账号和保留期内 telemetry。每批记录数量、checksum 和失败原因,允许安全重跑但不能重复生成事实;对仍会 OAuth 刷新的旧账号,cutover 前必须短暂冻结旧 writer、drain refresh lease 并做最后一轮 delta copy,不能让 backfill 与旧 writer 静默竞争。
- Verify:用独立查询逐项对账行数、credential 身份、usage、终态、引用和解密抽样;新二进制可 shadow-read 比较,但同一事实始终只有旧或新 writer,不能双写后靠时间戳择胜。
- Cutover:先切换 Codex RuntimeSnapshot 和请求执行读取,再在一个明确版本切换 request/attempt、continuation 和 bucket 的唯一 writer/readers;切换点持久化并可观测。
- 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 累计值不反向生成请求事实。
明确不创建的结构
- 不创建
providerslookup 表;Provider code 由 Registry 决定。 - 不创建包含所有平台字段的通用
accounts大表。 - 不创建 tenant、organization、project 等当前没有业务语义的层级。
- 不创建 Provider capability EAV 表;能力快照使用校验后的 JSON。
- 不创建 credential group;隔离资源池使用独立 Provider instance。
- 不创建成功表和失败表两套请求事实。
- 不创建通用
subject_type/subject_idusage 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 必须覆盖:
- 空库完整迁移、从每个受支持旧版本升级、migration name/checksum 篡改拒绝。
- 所有 FK 的 cascade、set-null 和 restrict 行为,以及每个 FK 是否有支持索引;行为 JSON 缺少/不支持
schema_version、超长文本/JSON 或保留 sentinel 必须被拒绝。 - 两个并发控制面事务只能得到不同且递增的
config_revision,stale expected revision 必须 conflict;Snapshot 读取只能得到一个完整 revision,丢失 Redis 通知后轮询仍能收敛。 credential_revision与state_revision独立 CAS,旧observed_at不能覆盖新状态;同一稳定 resource 不能复制到多个 instance 绕过限制。- 上游 payload 发送前 request/attempt 行已经提交;attempt target/instance 必须属于 request 冻结的 route;
upstream_send_state与downstream_committed不混淆;并发 create/commit 时同一 request 最多一个 downstream-committed attempt 且 commit 后没有新 attempt,重复 finalizer 只结算一次。 - Stale running request/attempt 依据持久 deadline 收敛为 incomplete,不能因配置变化改变判断,也不能重复写 usage 或估算成本。
- Price version 在
effective_from边界正确选择;known、partial、unknown、明确零价、多货币和 estimated/invoice 语义均不混淆。 - Native/portable continuation check、调用方 owner、response/request、credential/instance 对应关系、native state 与 transcript 加密边界由真实 FK/transaction 验证;同一 portable history 的分支不能读到彼此后续 delta,single-use native binding 在 Redis flush/进程崩溃后也不会重复消费。
- Client key、Provider、credential、route 和 transcript 删除后的历史快照与隐私清理结果。
- 从 request/attempt facts 重建 closed bucket 后,与增量聚合逐字段一致;unknown estimate 不进入已知金额,非 token metric 不丢失,任意客户端 model ID 不制造高基数 bucket。
- 数据库、管理 API、Debug、错误和日志样本中不存在完整 client key、上游 secret、Cookie 或 conversation 明文。
- Retention 先 recovery,再清理过期 binding、从 transcript leaf 向 root 清理,最后清理 request;未过期 continuation 不会被原始事实清理提前破坏,usage facts 不会早于 bucket 安全窗口删除。
- Redis flush/epoch 变化时,rate、concurrency、lease 和 strict budget 在重建完成前不会把空状态当成零;预算窗口可仅从冻结 request 与 attempt facts 恢复。
涉及并发的测试必须使用真实 PostgreSQL transaction 和唯一约束,不能只用内存 mock 证明。
验收标准
- 新增普通 API Key Provider 只增加 Provider adapter 和 instance/credential 数据,不修改通用 schema。
- 新增 Provider 专属表时,只能承载真实可查询状态,不能复制通用 credential、model、request 或 attempt 字段。
- 一次客户端请求始终只有一行
gateway_requests,所有上游调用都能按顺序关联到request_attempts。 - 最多一个 attempt 可以
downstream_committed = true,commit 后不能出现新的 attempt;ambiguous upstream send 不会自动重放。 - 客户端 usage、上游 usage 和上游目录价格估算可以分别对账;known、partial、unknown estimate 可区分,未知价格不会显示为零。
- 删除或禁用 Provider/credential 后,历史请求仍能按 Provider、instance、resource 和模型统计。
- PostgreSQL、备份、Redis、日志和管理 API 都不能恢复下游完整 Key 或读取上游明文秘密。
- Dashboard 聚合与 credential 用量可以从事实表重建,不依赖
account_usage一类可漂移累计状态。 - Native continuation 不能跨 Provider;portable continuation 的正文始终加密并与调用方 Key 隔离。
- 所有 FK、外部 ID 与历史
_ref均符合本文命名规则,不存在含义不明的route_id、request_id、credential_id或_key。 - 数据库设计不要求 Gateway Engine、API adapter 或 Provider adapter 互相了解对方的存储结构。