Commit Graph
858 Commits
Author SHA1 Message Date
ZheFox ec6ddb43a7 fix(data): retain applied legacy backfill for upgrades 2026-08-25 21:22:57 +08:00
ZheFox 2f2d444f97 fix(codex): self-heal Spark-contaminated account quotas
Keep model-scoped Spark windows out of account state, preserve authoritative WHAM exhaustion flags, and repair historical cross-family quota generations without weakening stale-response guards.
2026-08-25 19:13:41 +08:00
stabeyandClaude Opus 5 42deab67b3 fix(admin): keep a model-scoped 429 reset out of the account quota slot
`parse_codex_websocket_usage_limit_error` backfills `primary_reset_at` and
`primary_reset_after_seconds` from the error body whenever the embedded headers
did not supply them. Now that a named per-model limit no longer claims the
unprefixed window headers, that absence is exactly what a Spark 429 produces —
and `resets_at` on such an error is the Spark window's reset, so the backfill
put model-scoped timing back on the account's own quota.

Gate the backfill on the same ownership rule the window parsing uses.

Reported by Cursor Bugbot on the fork PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:22:29 +08:00
stabeyandClaude Opus 5 1b1be918a9 fix(admin): keep Codex model-scoped rate-limit headers out of the account quota
`parse_codex_usage_headers` reads the unprefixed `x-codex-primary/secondary-*`
headers as the account's own quota. They are not: they carry whichever limit
governed the request, and `x-codex-active-limit` names it — `premium` for the
plan's own limit, or a metered feature such as `codex_bengalfox` for a named
per-model limit. On a request billed against a named limit the unprefixed
headers repeat that limit's windows verbatim.

So a single request to a model with its own limit writes that model's windows
into the account slots. The paid-window swap then makes it worse: a named
limit's secondary window is active, unlike the plan's disabled one, so the swap
promotes the model's weekly window into the account's weekly slot — the slot
the UI labels and the scheduler reads through `quota_usage_ratio`.

It also sticks. Both weekly windows share `window_minutes`, so
`codex_quota_same_window_identity` treats them as one window, and
`codex_quota_merge_same_window` drops an observation whose deadline is earlier
than the stored one. The two weeks start at different instants, so every later
account observation looks like a stale sample of a window that already rolled
over and is discarded until the model window's own deadline passes.

Observed on a `pro` key running both model families: one `gpt-5.3-codex-spark`
request replaced the account weekly window with the Spark weekly one, and the
~3000 plan-limit responses over the next 100 minutes were all discarded. The
account's real weekly usage never landed, and its reset time was reported nine
hours late.

The header set describes itself — every named limit announces
`x-codex-<feature>-limit-name` and carries its windows under the same prefix —
so parse the named families directly and only claim the unprefixed windows for
the account when no announced limit owns them. Responses without
`x-codex-active-limit` keep the previous behaviour.

This also stops the Spark windows from going stale: they were only ever written
by the `wham/usage` admin probe even though every response carries them.

Refs #746

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:22:29 +08:00
ZheFox 2cd20da1ec fix(gateway): support current Codex Realtime live routes 2026-08-23 02:51:49 +08:00
ZheFox acde38b8e7 fix(usage): unify OpenAI Live and WebSocket records 2026-08-21 11:53:53 +08:00
ZheFox 9996e75a34 fix(ci): align database snapshot migration cutoff 2026-08-21 11:11:45 +08:00
ZheFox 2c89202001 feat(gateway): add Codex Live and OpenAI Realtime
Implement preflighted Live/Realtime WebSocket transports, protocol-aware authentication, usage auditing, UI filtering, and legacy Codex permission migration.
2026-08-21 04:27:34 +08:00
ZheFox 4185ad1b1e feat(gateway): add Codex Live transport 2026-08-20 21:59:05 +08:00
ZheFox 654f798d25 fix(ws): harden Responses continuation state 2026-08-20 08:51:16 +08:00
ZheFox bef282cfee fix(responses): replay DeepSeek opaque reasoning state 2026-08-20 00:40:48 +08:00
ZheFox d21d8ce9f5 fix(codex): avoid replaying static config on websocket continuations 2026-08-20 00:40:40 +08:00
ZheFox c8118edf36 fix(ws): harden Responses connection lifecycle
Revalidate control policy per turn, isolate downstream credentials, and make planner/turn ownership cancellation-safe.

Preserve opaque protocol events, align configurable timeout semantics, and extend end-to-end security and settlement coverage.
2026-08-17 18:50:29 +08:00
AAEE86 4a0775c4ea style: apply cargo fmt across gateway and aether-ai crates 2026-08-17 14:53:53 +08:00
AAEE86 6fc02dad3e fix(ws): restore redacted PII in provider frames before client delivery
Responses WebSocket 只实现了脱敏的一半:请求侧 mask 之后,provider 事件帧在推给
客户端之前没有还原,于是 session 映射内的占位符以 <AETHER:EMAIL:...> 的形式直接
透给客户端。这里补齐响应侧,语义与 HTTP 路径对齐。

- 还原点是 relay loop 的最后一跳(send_client_message 之前、capture_client_frame
  之前),对应 HTTP 的 restore_sync_response_body / StreamingResponseRestorer 所在
  位置。审计与终态观测继续消费脱敏态事件,只有发往客户端的那一份拷贝被还原。
- 复用 privacy::restore_json_strings(改为 pub(crate))与
  RedactionSession::restore_text,不复制任何还原逻辑:只还原本 session mask 过的
  映射,未映射的占位符原样保留;type / model / id 等协议字段不可能命中 sentinel,
  因此不受影响。批量 {"chunks":[...]} 帧一并递归还原。
- session 生命周期:mask 仍然是 per-turn(slot 依旧每轮新建),但 session 改由连接
  持有,按有界 FIFO 留最近 8 轮。理由是 WS 的会话历史留在上游,continuation 只发
  增量输入,per-turn 释放会漏还原后续响应里回显的更早轮次占位符;HTTP 不会漏,是
  因为它每次重发整段历史、重新 mask 会派生出同一个 sentinel。被挤出窗口的轮次退回
  「占位符原样透传」,不会错误还原成别的值。
- 未命中还原时不改写字节;连接上没有任何 mask session 时(未启用脱敏)连事件 clone
  都不做。

测试:redaction.rs 新增 8 条单测(还原命中/批量帧/未映射占位符原样/未命中不改写/
无 session 不介入/空 session 不留存/审计侧入参不被改写/跨轮还原/窗口有界);
responses_websocket_e2e 新增一条用例,mock 上游回显收到的 input,断言上游只看到
占位符而客户端拿到真实邮箱。
2026-08-17 14:53:46 +08:00
AAEE86 dbf2809bd6 fix(ws): settle the previous attempt before transparent retry replanning
评审第 2 条。配额透明重试原来的顺序是「detach 旧 attempt → 规划并绑定新
attempt → 把旧 attempt 的结算排进队列」。规划因此读到的是旧 attempt 还没投射的
health / adaptive / pool 状态,而且旧 attempt 仍占着自己的 pool key lease——替代
key 的挑选看到的是一把仍被占用的 key,最坏情况下判成「无可用供应商」而放弃一次
本可以成功的重试。

普通的新 turn 早就挡住了这件事:client.rs 在处理 response.create 前调用
await_pending_turn_finalization,注释写的正是「不要让新 turn 基于陈旧的 health /
adaptive / pool 状态规划」。透明重试是同一个问题的另一条入口,漏了这一步。

现在顺序是:detach → 释放准入 → 结算旧 attempt 并等它落地 → 规划/绑定新 attempt。

新增 lifecycle::settle_turn_finalization:与 queue_turn_finalization 的区别只在于
「等」。后者把 handle 挂在连接上让 relay loop 继续跑,用在结算之后不再读取共享
状态的出口;前者用在必须先看到结算结果才能继续的路径上。

顺序用类型固定,而不是靠注释:settle_turn_finalization 返回
PreviousAttemptSettled,retry_active_turn_after_quota_exhaustion 要求这个参数。
凭证只能由 lifecycle 颁发(结算完成,或明确「没有 attempt 要结算」),所以把顺序
写反连编译都过不了。

重试失败路径随之变化:旧 attempt 已经结算,不再 resume 回去。logical turn 仍停在
Replanning,后续分支的 end() / finalize_active_turn 只清 logical turn、不交出
attempt,因此不存在重复结算。结算 outcome 取值不变(两条路径用的都是
terminal_outcome.unwrap_or_else(upstream_closed),而这条分支里 terminal_outcome
必为 Some——usage_limit_error 成立意味着有一个已解析的 error 终态帧)。

代价(都落在「重试失败」这一侧,且只影响已终态 attempt 的报告注解,不影响计费):
- 那条最终转发给客户端的 429 事件不再进旧 attempt 的 client capture;
  provider 侧 capture 早在 observe_upstream_frame 里就记下了。
- 如果转发 429 给客户端也失败,record_client_delivery_aborted 落在一个已经结算的
  attempt 上,成为 no-op。

测试:
- lifecycle:await_turn_finalization_handle 必须「等到落地」而不是「排进队列」
  (C6 依赖的性质);结算完成后规划才读状态的顺序型断言(计数器替身);结算任务
  panic 也必须放行调用方,不能卡死 relay loop。
- turn_state:Replanning 状态下 end() 不再交出第二个 attempt(无重复结算)。
- e2e 新增 provider_quota_exhaustion_transparently_retries_onto_another_key:
  mock 上游首轮只回 Codex 的 429 usage_limit_reached,网关换到第二把 key 重放同一个
  response.create;断言客户端看不到 429、上游被连两次、两次用的不是同一把 key、两个
  attempt 各留一条终态行(429 的那条 + 计费的那条)。已验证它在改动前后都通过——
  它覆盖的是整条路径可用,顺序由上面的单测确定性覆盖。
  夹具随之参数化出 ProviderFixture::CodexKeyPair:透明重试只有 Codex adapter 会
  开启,而 codex 候选要求 auth_type = oauth,所以这个夹具用未过期的 oauth 凭证。
2026-08-17 14:53:40 +08:00
AAEE86 1d3051cb89 refactor(ws): structured terminal observation without SSE text round-trips
评审第 5 条:Responses WebSocket 收到的本来就是结构化协议事件,但为了复用面向
SSE 的 push_line,观测路径要先把每个事件序列化成 data: {json}\n\n,解析器再
decode 回 Value——一次纯粹的往返。这个「伪 SSE」形状是随手拼的,一旦拼装函数
以后被加上换行或分块逻辑,观测结果就会和真实事件悄悄分叉。

aether-ai-formats:
- OpenAIResponsesProviderState::push_line 机械拆成 decode + push_event,
  push_line 现在只做解码。协议状态机一行未动,diff 里除函数签名外只有
  &value → value(value 从拥有改成借用,持有结构化事件的传输不必为了调用它
  先克隆一份)。
- StreamingStandardTerminalObserver::push_event 走 TerminalStreamParser::Standard,
  service tier 的记录方式与 push_line 完全相同。openai:image 的终态状态机按 SSE
  行做增量解析、没有结构化入口,返回 AiSurfaceFinalizeError 让调用方
  disable_with_error 标记 parser_error,而不是静默丢事件、把摘要留成「未观察到
  终态」。ProviderStreamParser 的其余三个格式同样返回 Err:机械拆分随时可做,
  但不建无调用方的接口。

WS 侧:
- 新增 responses/observation.rs 的 ResponsesStructuredTerminalObserver,直接消费
  frame.protocol_events() 借出的事件。包一层的意义是让「不再拼 SSE」成为类型层面
  的事实——这个类型没有任何接受字节的方法,改回 push_line 不可能悄悄发生。
  finish() 里的 Ok(None) / Err → disable_with_error 兜底也一并收进来。
- body capture 不动,仍然是 SSE 形状(data: 开头、\n\n 结尾):
  aether_usage_runtime::report 用 line.strip_prefix("data:") 解析被捕获的 body
  判定 StreamCapturedTerminalState,而它是 stream_report_represents_failure 的一个
  OR 项,换成结构化 JSON 会让终态判定恒为 Missing。capture_sse_event /
  capture_client_frame / websocket_event_as_sse_line 全部保留,原因写在模块文档
  注释里。这一层只换观测,不换捕获。

差分测试(8 个,aether-ai-formats):同一组事件序列分别走 push_line 与
push_event,断言 ExecutionStreamTerminalSummary 完全相等——批量 delta 序列、
completed 带 usage、合法 incomplete、error、response.failed、未知事件、
service tier、缺终态;外加 openai:image 拒绝结构化入口。两条入口不可能有
过滤差异:任何 Value 序列化出来都不会命中 decode_json_data_line 的 empty /
":" / "event:" / [DONE] 四个过滤条件。

turn.rs 里三个既有的 WS 观测测试改走结构化入口;SSE 形状的断言留在 capture 一侧。
验收:crates/aether-usage 零 diff。
2026-08-17 14:53:33 +08:00
AAEE86 247e7105a2 fix(ws): bill a provider-reached terminal even when client delivery fails
评审第 5 条后半:provider 终态已经到达、只是 gateway 写客户端 socket 失败时,
relay loop 用 client_disconnected() 覆盖了结算信号,于是一条供应商已经完成推理
并消耗了 token 的响应被记成 void billing、candidate 记 Cancelled、不投射供应商
效果、也不提交 execution report。上游成本凭空消失。

结算表只改一行:作废账单的条件从
    provider.cancelled_by_provider() || delivery.is_aborted()
收紧为
    provider.cancelled_by_provider() || (delivery.is_aborted() && !provider.is_terminal())

于是 Terminal{cancelled=false} + delivery Aborted 与 delivery Complete 落在同一侧:
Billed、candidate Success 或 Failed、投射供应商效果、提交 execution report。
状态码随之变成纯 provider 事实(不再把 200 改写成 499);作废分支的 provider
状态码本身就是 499,取值不变。

依据:供应商已经完成推理并消耗 token,客户端还能用 previous_response_id 续取
这条响应。供应商没给出终态时(客户端先走了)仍然作废,这一侧未改。

配套改动:
- connection.rs 写客户端失败处改为 record_client_delivery_aborted(reason) +
  settle_signal_for_client_delivery_failure(terminal_outcome):provider 终态已到达
  就用那条终态作结算信号,不再无条件覆盖。投递失败原因也不再谎称
  「客户端在终态前断开」。
- 投递结果记在 attempt 上而非 logical turn 上:结算按 attempt 进行,且配额透明
  重试时各 attempt 的投递结果彼此独立。
- report_context 新增 websocket_client_delivery="aborted" 与
  websocket_client_delivery_reason,只增字段不改既有字段,便于事后区分
  「客户端拿到了」和「客户端没拿到但已计费」。
- candidate error_type 新增 client_delivery_failed(原先这个场景写的是
  websocket_cancelled)。它排在供应商侧分类之前:这条记录之所以特别正是因为
  内容没送到客户端,供应商侧判定仍由 candidate_status 与 error_message 保留。
- finish_summary 改用作废判定而非「投递失败」判定:provider 终态已到达时摘要
  必须保留真实的 finish_reason 与 usage,否则计费记录会被写坏。

e2e 期望值变化:client_disconnect_mid_turn_still_settles_the_usage_row 改名为
client_disconnect_before_any_provider_output_settles_a_void_row,并补上
「不计费 + status=cancelled + status_code=499」的断言。原用例的 mock 行为是
StallAfterCreated(只发 response.created 就静默),provider 从未给出终态,所以
它走的是未改动的作废一侧;原来的文档注释说「must still be billed」与实际语义
不符,一并纠正。真正被修正的那一行无法在 e2e 里确定性触发——它取决于 relay
loop 的 select! 先观察到上游终态帧还是先观察到已关闭的客户端 socket,是构造性
竞态——因此由 relay 级单测确定性覆盖,e2e 里以注释指向这两个单测。

新增 7 个测试:结算表修正行(并与「投递成功」逐字段对照,只有 candidate 错误
分类不同)、无终态时仍作废、供应商声明取消即使送达也不计费、结算信号选择、
已记录的投递失败不被结算信号覆盖、relay 级「终态到达 + 客户端已关闭 ⇒ Billed /
Success / ProviderSuccess / 已提交 report 且 usage 完整保留」及其镜像、
report_context 只增不改。
2026-08-17 14:53:12 +08:00
AAEE86 621a528083 test(ws): Responses WebSocket 端到端套件接入 CI
补齐 aether-integration-tests 的 responses_websocket_e2e 集成测试,
并把 CI 的 scenario 任务从 --bins 改为 --bins --tests,否则该套件
不会被执行。
2026-08-17 14:51:24 +08:00
AAEE86 71b54070e8 feat(gateway): Codex/OpenAI Responses WebSocket 代理模式
在 /v1/responses 上支持 WebSocket 升级,把客户端帧中继到上游 Codex /
OpenAI Responses WebSocket 端点,同时保持既有的路由、鉴权、配额与用量
语义:

- 路由与准入:control/route/ai.rs 识别 WebSocket 升级请求;
  websocket/ingress.rs 复用 API Key 鉴权、IP 规则与并发许可,并引入
  独立的 WebSocket 连接许可
- 中继:websocket/responses/* 按 connection / session / turn 分层,
  帧解析归一化、socket 写入有界、continuation 保持调度亲和性
- 配额:orchestration/codex_quota_breaker.rs 在账号配额耗尽时熔断并
  自动恢复,不再直接断开客户端连接
- 用量:每个 turn 的终态用量落库,request_metadata 记录
  websocket_mode / websocket_transport,管理端与 usage 视图暴露
  is_websocket
- 管理端:provider 可配置 Responses WebSocket 开关
2026-08-17 14:50:33 +08:00
ZheFox 32944538e9 fix(gateway): stop candidate persistence retry storms 2026-08-17 13:49:12 +08:00
ZheFox b13d9b9b40 fix(codex): restore upstream model discovery 2026-08-15 19:36:28 +08:00
zhefox 810c3dfe2b fix(codex): serve versioned dynamic model catalogs 2026-08-14 18:41:44 +08:00
elky a1d64e5239 fix(routing): preserve allowlist edits and save state 2026-08-14 11:43:24 +08:00
elky fb33ea57b0 Merge PR #715: decouple routing model overrides 2026-08-14 11:10:21 +08:00
elky 5b0c763086 fix(codex): fence concurrent quota updates 2026-08-14 09:28:07 +08:00
elky f3a12c1008 fix(ai): preserve Codex image edit validation 2026-08-13 11:31:17 +08:00
elky ca35e09eaa Merge pull request #718 from zjm54321/fix/custom-image-edit-json 2026-08-13 11:00:15 +08:00
elky 8cf381b0c3 feat(codex): add OAuth fingerprint convergence 2026-08-13 09:57:17 +08:00
zjm54321 41e93858e1 refactor(ai): simplify image edit serialization 2026-08-11 00:36:33 +08:00
zjm54321 8d918d0459 fix(ai): serialize image edits with images array 2026-08-11 00:30:00 +08:00
zhefox 908d4f2603 style(provider): apply rustfmt to claude_code tests 2026-08-05 13:58:46 +08:00
zhefox 4d67569873 fix(gateway): support claude_code cross-format Claude messages 2026-08-05 13:57:20 +08:00
zhefox aedff9a704 fix(provider): validate mapped model reasoning effort 2026-08-03 19:22:51 +08:00
zhefox 669f4bddc5 fix: align Responses routing and model permissions 2026-08-03 18:48:01 +08:00
zbs 1a4eede34d fix(routing): decouple model overrides from allowed scope 2026-08-03 07:52:49 +08:00
elky 06f5d3c8c0 fix(gateway): complete worker registration cleanup 2026-07-31 11:32:07 +08:00
fawney19 6688ee26db Merge pull request #702 from MMEXA/fix/reconcile-auth-channel-mismatch-formats
fix(gateway): 修复批量更新 API 格式时的认证通道状态冲突
2026-07-31 10:28:37 +08:00
elky beb003b7ad feat(models): add external catalog proxy selection 2026-07-31 09:32:25 +08:00
MMEXA 6ecfe0f0a1 fix(gateway): reconcile auth mismatch formats on key update 2026-07-30 22:14:08 +08:00
ZheFox ef5f36cc2b fix(ai): satisfy response history clippy checks 2026-07-30 20:13:36 +08:00
ZheFox 84022c4d48 Merge upstream/main into main 2026-07-30 19:40:39 +08:00
ZheFox 118f441029 feat(gateway): persist OpenAI Responses continuation history 2026-07-30 19:26:52 +08:00
elky 20399b004d Merge PR #700: fix admin pool batch update body buffering
Preserve main's failover and usage metadata fixes, restore default tunnel regression coverage, and satisfy current Clippy.
2026-07-30 17:56:37 +08:00
elky 050eb77508 fix(ai): harden responses replay and failure diagnostics 2026-07-30 17:19:54 +08:00
elky 1ab4f079c9 fix(gateway): restore failover and usage diagnostics 2026-07-30 09:12:11 +08:00
MMEXA 6c733f7590 fix(usage): preserve request diagnostics in event seeds 2026-07-30 06:44:59 +08:00
MMEXA 8cf9af79da fix(ci): remove redundant usage policy update 2026-07-30 05:45:38 +08:00
elky a04673a90d feat(gateway): harden failover and payload handling
Retry pre-response transport failures across candidates with an explicit stop policy, and propagate end-to-end timing into usage records and UI diagnostics.

Remove legacy body, import, cookie, PII, and tunnel replay caps while preserving optional operator-configured gateway limits.
2026-07-30 01:03:27 +08:00
zhefox f8000012f7 fix(ci): stabilize cross-platform workflow checks 2026-07-29 21:55:43 +08:00