diff --git a/.env.example b/.env.example
index 0d6137a7c..9ee6a1b7d 100644
--- a/.env.example
+++ b/.env.example
@@ -171,6 +171,13 @@ FETCH_HEADERS_TIMEOUT=600000
FETCH_BODY_TIMEOUT=600000
MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数(含首次调用),范围 1-10,留空使用默认值 2
+# 客户端断开后的 detached stream 共享带权进程级资源预算。
+# Replay owner 申请较重的 replay lease;预算不足时降级为轻量 metering,
+# 两者都无法准入时才终止上游并按 499 结算。
+DETACHED_STREAM_MAX_CONCURRENCY=64
+DETACHED_STREAM_BUDGET_BYTES=67108864
+DETACHED_STREAM_METERING_RESERVE_BYTES=16777216
+
# 入站压缩请求体(content-encoding: zstd/gzip/deflate/br)解压上限(字节)
# 功能说明:/v1、/v1beta 代理路径不受 proxyClientMaxBodySize 钳制,这两项是入站解压的内存/CPU 兜底。
# - MAX_DECOMPRESSED_REQUEST_BYTES:解压输出上限,防御解压炸弹,超过按 413 拒绝。默认 100MB。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa6aca9c7..7b0f69a67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@
### 修复
+- 修复上游响应流发生 error 后 Node/Undici body 未完成销毁的问题:Node-to-Web adapter 和 demand-driven pump
+ 现在在源流错误终态显式取消、销毁底层流,并为异步 destroy error 保留有界保护;同时将 raw body 的兜底错误监听改为一次性监听,
+ 避免 HTTP/2 reset、客户端断开和竞速取消路径长期保留 socket 与 ArrayBuffer backing store (#1430)
+- 修复客户端断开后的后台计费 drain 继续累加完整响应正文导致的高并发内存放大:断线后切换到有界计量观察器,
+ 仅保留 usage、终止标记、模型与协议错误等结算证据,拿到终态即取消上游;Replay owner 在预算内继续保存完整
+ 客户端可见流,Replay 预算不足时降级到 metering,新增共享进程级并发与带权保留容量预算,覆盖通用流与 Gemini 透传路径 (#1430)
- 修复 Replay owner 在客户端断线后保留完整流正文和 300 秒传输资源导致的内存失控:限制 Redis
write-behind backlog,Replay 失效后按断线起点恢复 60 秒 drain,并为 Redis session response body
增加默认 5 MiB 的可配置存储上限,避免大 SSE 正文及 before/after 快照放大内存和持久化压力;
diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index 752ed7c37..f9741421e 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -551,6 +551,11 @@
"overridden": "Overridden by provider",
"tooltip": "Thinking effort in the Codex request (reasoning.effort), shown verbatim."
},
+ "reasoningEffortOpenai": {
+ "label": "Reasoning effort",
+ "overridden": "Overridden by provider",
+ "tooltip": "Thinking effort in the OpenAI chat/completions request (reasoning_effort / reasoning.effort), shown verbatim."
+ },
"logicTrace": {
"title": "Decision Chain",
"singleRouteSelectionTitle": "Provider selection under single-route protection",
diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json
index c6f6a1832..551c4e9bb 100644
--- a/messages/en/settings/config.json
+++ b/messages/en/settings/config.json
@@ -59,7 +59,8 @@
"enableOpenaiResponsesWebsocket": "Enable OpenAI Responses WebSocket",
"enableOpenaiResponsesWebsocketDesc": "When enabled, if a client opens a WebSocket connection to /v1/responses and the selected provider is a Codex type, CCH will attempt a sibling WebSocket to the upstream. If the upstream does not support WebSocket or the handshake fails, CCH gracefully falls back to standard HTTP Responses while keeping the client WebSocket open; the fallback is not counted toward circuit breakers. Non-WebSocket clients and non-Codex providers are unaffected.",
"enableHighConcurrencyMode": "Enable High-Concurrency Mode",
- "enableHighConcurrencyModeDesc": "When enabled, CCH disables part of the Redis debug snapshots and real-time session observability writes to reduce CPU and IO pressure under high RPM. Forwarding, rectifiers, fake-200 detection, billing, and quota enforcement remain unchanged, but Sessions debugging details may be reduced or delayed.",
+ "enableHighConcurrencyModeDesc": "When enabled, CCH disables memory-heavy Replay, stream gating, provider-racing loser billing, client-abort retention, and session diagnostics, in addition to Redis debug and observability writes. Forwarding, core billing, and quota enforcement remain enabled.",
+ "highConcurrencyModeWarning": "High-concurrency mode disables Replay, stream gating, racing-loser billing, client-abort retention, and session diagnostics.",
"enableResponseFixer": "Enable Response Fixer",
"enableResponseFixerDesc": "Automatically repairs common upstream response issues (encoding, SSE, truncated JSON). Enabled by default.",
"enableThinkingSignatureRectifier": "Enable Thinking Signature Rectifier",
diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json
index 0f49373bb..79b101a14 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -551,6 +551,11 @@
"overridden": "プロバイダーにより上書き",
"tooltip": "Codex リクエストの推論強度 (reasoning.effort) をそのまま表示します。"
},
+ "reasoningEffortOpenai": {
+ "label": "推論強度",
+ "overridden": "プロバイダーにより上書き",
+ "tooltip": "OpenAI chat/completions リクエストの推論強度 (reasoning_effort / reasoning.effort) をそのまま表示します。"
+ },
"logicTrace": {
"title": "決定チェーン",
"singleRouteSelectionTitle": "単一経路保護での Provider 選択",
diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json
index 27cb07459..c66612082 100644
--- a/messages/ja/settings/config.json
+++ b/messages/ja/settings/config.json
@@ -59,7 +59,8 @@
"enableOpenaiResponsesWebsocket": "OpenAI Responses WebSocket を有効化",
"enableOpenaiResponsesWebsocketDesc": "有効にすると、クライアントが /v1/responses に WebSocket 接続し、かつ Codex タイプのプロバイダーが選択された場合、CCH は上流にも WebSocket 接続を試みます。上流が WebSocket をサポートしない、またはハンドシェイクに失敗した場合は、クライアント WebSocket を開いたまま通常の HTTP Responses に優雅にフォールバックします。このフォールバックはサーキットブレーカーにカウントされません。非 WebSocket クライアントと非 Codex プロバイダーの動作は変わりません。",
"enableHighConcurrencyMode": "高並行モードを有効化",
- "enableHighConcurrencyModeDesc": "有効にすると、高 RPM 時の CPU / IO 負荷を下げるため、Redis の一部デバッグスナップショットとリアルタイム Session 観測書き込みを停止します。転送、整流、fake 200 検知、課金、制限処理は維持されますが、Sessions のデバッグ詳細は減少または遅延する場合があります。",
+ "enableHighConcurrencyModeDesc": "有効にすると、Redis のデバッグスナップショットとリアルタイム Session 観測に加え、メモリ負荷の高い Replay、ストリームゲート、競合敗者の課金、クライアント中断保持、Session 診断を停止します。転送、基本課金、制限処理は維持されます。",
+ "highConcurrencyModeWarning": "高並行モードでは Replay、ストリームゲート、競合敗者の課金、クライアント中断保持、Session 診断を無効化します。",
"enableResponseFixer": "レスポンス整流を有効化",
"enableResponseFixerDesc": "上流応答の一般的な形式問題(エンコーディング、SSE、途切れた JSON)を自動修復します(既定で有効)。",
"enableThinkingSignatureRectifier": "thinking 署名整流を有効化",
diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json
index 5b630c199..22de6672d 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -551,6 +551,11 @@
"overridden": "Переопределено провайдером",
"tooltip": "Интенсивность рассуждений в запросе Codex (reasoning.effort), показанная без изменений."
},
+ "reasoningEffortOpenai": {
+ "label": "Интенсивность рассуждений",
+ "overridden": "Переопределено провайдером",
+ "tooltip": "Интенсивность рассуждений в запросе OpenAI chat/completions (reasoning_effort / reasoning.effort), показанная без изменений."
+ },
"logicTrace": {
"title": "Цепочка решений",
"singleRouteSelectionTitle": "Выбор провайдера в режиме защиты одним маршрутом",
diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json
index 3171cce69..e2e476459 100644
--- a/messages/ru/settings/config.json
+++ b/messages/ru/settings/config.json
@@ -59,7 +59,8 @@
"enableOpenaiResponsesWebsocket": "Включить OpenAI Responses WebSocket",
"enableOpenaiResponsesWebsocketDesc": "Если включено, то когда клиент открывает WebSocket-соединение с /v1/responses и выбирается провайдер типа Codex, CCH попытается установить WebSocket-соединение с вышестоящим сервером. Если сервер не поддерживает WebSocket или рукопожатие не удастся, CCH плавно переключится на обычный HTTP Responses, сохраняя WebSocket клиента открытым; этот fallback не учитывается в circuit breaker. Клиенты без WebSocket и провайдеры, отличные от Codex, работают без изменений.",
"enableHighConcurrencyMode": "Включить режим высокой нагрузки",
- "enableHighConcurrencyModeDesc": "Если включено, CCH отключит часть Redis-снимков для отладки и записи real-time Session-наблюдения, чтобы снизить нагрузку на CPU и IO при высоком RPM. Пересылка, rectifier-логика, обнаружение fake 200, биллинг и лимиты сохраняются, но детализация отладки в Sessions может уменьшиться или запаздывать.",
+ "enableHighConcurrencyModeDesc": "При включении CCH отключает Redis-снимки для отладки и real-time-наблюдение Session, а также ресурсоёмкие Replay, stream-gate, тарификацию проигравших в гонке, сохранение при отмене клиентом и диагностику Session. Пересылка, базовый биллинг и лимиты сохраняются.",
+ "highConcurrencyModeWarning": "Режим высокой нагрузки отключает Replay, stream-gate, тарификацию проигравших в гонке, сохранение при отмене клиентом и диагностику Session.",
"enableResponseFixer": "Включить исправление ответов",
"enableResponseFixerDesc": "Автоматически исправляет распространённые проблемы ответа у провайдеров (кодировка, SSE, обрезанный JSON). Включено по умолчанию.",
"enableThinkingSignatureRectifier": "Включить исправление thinking-signature",
diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json
index 5b07a9346..5fa41c03d 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -551,6 +551,11 @@
"overridden": "已被供应商覆写",
"tooltip": "Codex 请求中的思考强度(reasoning.effort),按原值显示。"
},
+ "reasoningEffortOpenai": {
+ "label": "思考强度",
+ "overridden": "已被供应商覆写",
+ "tooltip": "OpenAI chat/completions 请求中的思考强度(reasoning_effort / reasoning.effort),按原值显示。"
+ },
"logicTrace": {
"title": "决策链",
"singleRouteSelectionTitle": "单路保护下的供应商选择",
diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json
index 6b3e4ed08..a00ba9aa9 100644
--- a/messages/zh-CN/settings/config.json
+++ b/messages/zh-CN/settings/config.json
@@ -70,7 +70,8 @@
"enableOpenaiResponsesWebsocket": "启用 OpenAI Responses WebSocket",
"enableOpenaiResponsesWebsocketDesc": "启用后,当客户端以 WebSocket 连接 /v1/responses 且选中 Codex 类型供应商时,CCH 会尝试与上游建立 WebSocket。若上游不支持或握手失败,将优雅降级到普通 HTTP Responses,客户端 WebSocket 保持打开;降级不计入熔断。非 WebSocket 客户端与非 Codex 供应商行为不变。",
"enableHighConcurrencyMode": "启用高并发模式",
- "enableHighConcurrencyModeDesc": "开启后,将关闭部分 Redis 调试快照与实时会话观测写入,以降低高并发下的 CPU 与 IO 开销。不会影响转发、整流、fake 200 检测、计费与限额,但 Sessions 调试详情会减少或延后。",
+ "enableHighConcurrencyModeDesc": "开启后,除 Redis 调试快照与实时会话观测写入外,还会关闭高内存占用的 Replay、流式门禁、竞速输家计费、客户端中断保留计费和会话诊断。转发、基础计费与限额仍会执行。",
+ "highConcurrencyModeWarning": "高并发模式将关闭 Replay、流式门禁、竞速输家计费、客户端中断保留计费和会话诊断。",
"interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)",
"interceptAnthropicWarmupRequestsDesc": "开启后,识别到 Claude Code 的 Warmup 探测请求将由 CCH 直接抢答短响应,避免访问上游供应商;该请求会记录在日志中,但不计费、不限流、不计入统计。",
"enableThinkingSignatureRectifier": "启用 thinking 签名整流器",
diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json
index b69873d4b..8caa52816 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -551,6 +551,11 @@
"overridden": "已被供應商覆寫",
"tooltip": "Codex 請求中的思考強度(reasoning.effort),按原值顯示。"
},
+ "reasoningEffortOpenai": {
+ "label": "思考強度",
+ "overridden": "已被供應商覆寫",
+ "tooltip": "OpenAI chat/completions 請求中的思考強度(reasoning_effort / reasoning.effort),按原值顯示。"
+ },
"logicTrace": {
"title": "決策鏈",
"singleRouteSelectionTitle": "單路保護下的供應商選擇",
diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json
index 45b9debf2..cca77ae4f 100644
--- a/messages/zh-TW/settings/config.json
+++ b/messages/zh-TW/settings/config.json
@@ -59,7 +59,8 @@
"enableOpenaiResponsesWebsocket": "啟用 OpenAI Responses WebSocket",
"enableOpenaiResponsesWebsocketDesc": "啟用後,當客戶端以 WebSocket 連線 /v1/responses 且命中 Codex 類型供應商時,CCH 會嘗試與上游建立 WebSocket 連線。若上游不支援或握手失敗,將優雅降級為一般 HTTP Responses,客戶端 WebSocket 保持開啟;降級不計入熔斷。非 WebSocket 客戶端與非 Codex 供應商行為不變。",
"enableHighConcurrencyMode": "啟用高並發模式",
- "enableHighConcurrencyModeDesc": "開啟後,將關閉部分 Redis 除錯快照與即時 Session 觀測寫入,以降低高並發下的 CPU 與 IO 開銷。轉發、整流、fake 200 偵測、計費與限額不受影響,但 Sessions 除錯詳情會減少或延後。",
+ "enableHighConcurrencyModeDesc": "開啟後,除 Redis 除錯快照與即時 Session 觀測寫入外,也會關閉高記憶體用量的 Replay、串流門控、競速輸家計費、客戶端中斷保留計費與 Session 診斷。轉發、基礎計費與限額仍會執行。",
+ "highConcurrencyModeWarning": "高並發模式將關閉 Replay、串流門控、競速輸家計費、客戶端中斷保留計費與 Session 診斷。",
"enableResponseFixer": "啟用回應整流",
"enableResponseFixerDesc": "自動修復上游回應中常見的編碼、SSE 與 JSON 格式問題(預設開啟)。",
"enableThinkingSignatureRectifier": "啟用 thinking 簽名整流器",
diff --git a/package.json b/package.json
index 24465dab9..b64a31e0a 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"@langfuse/otel": "^5.10.0",
"@langfuse/tracing": "^5.10.0",
"@lobehub/icons": "^5.15.0",
+ "@lobehub/ui": "^5.0.0",
"@opentelemetry/sdk-node": "^0.221.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-avatar": "^1.2.6",
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx
index ad967e6e1..61d381c87 100644
--- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx
+++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx
@@ -142,7 +142,12 @@ export function SummaryTab({
const showNoSignatureBadge =
thinkingSignatureDetection?.source === "fallback_no_signature_with_thinking";
const thinkingEffortInfo = extractThinkingEffortInfo(specialSettings);
- const effortMessageKey = thinkingEffortInfo?.source === "codex" ? "reasoningEffort" : "effort";
+ const effortMessageKey =
+ thinkingEffortInfo?.source === "codex"
+ ? "reasoningEffort"
+ : thinkingEffortInfo?.source === "openai"
+ ? "reasoningEffortOpenai"
+ : "effort";
const effortDisplay = thinkingEffortInfo
? {
requestedEffort: thinkingEffortInfo.requestedEffort,
diff --git a/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.test.tsx b/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.test.tsx
index 5a1367fc6..b71f64215 100644
--- a/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.test.tsx
+++ b/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.test.tsx
@@ -180,4 +180,51 @@ describe("ThinkingEffortDisplay", () => {
expect(html).toContain("reasoningEffort.tooltip");
expect(html).not.toContain(">medium<");
});
+
+ test("显示 OpenAI chat/completions 请求中的思考强度", () => {
+ const html = renderToStaticMarkup(
+
+ );
+
+ expect(html).toContain('data-slot="thinking-effort"');
+ expect(html).toContain("max");
+ expect(html).toContain("reasoningEffortOpenai.tooltip");
+ expect(html).not.toContain("overridden");
+ });
+
+ test("OpenAI 与 Codex 审计并存时优先展示 Codex 强度", () => {
+ const html = renderToStaticMarkup(
+
+ );
+
+ expect(html).toContain("high");
+ expect(html).toContain("reasoningEffort.tooltip");
+ expect(html).not.toContain(">max<");
+ });
});
diff --git a/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.tsx b/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.tsx
index 624b8cae2..4dce53430 100644
--- a/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.tsx
+++ b/src/app/[locale]/dashboard/logs/_components/thinking-effort-display.tsx
@@ -14,7 +14,8 @@ interface ThinkingEffortDisplayProps {
}
/**
- * 在使用记录中展示任意模型的思考强度(Codex reasoning.effort 或 Anthropic effort)。
+ * 在使用记录中展示任意模型的思考强度(Codex / OpenAI chat/completions 的
+ * reasoning.effort,或 Anthropic 的 output_config.effort)。
*
* 供应商改变强度时同时展示请求值和实际转发值,避免只看到客户端参数而误判上游行为。
*/
@@ -26,7 +27,12 @@ export function ThinkingEffortDisplay({ specialSettings }: ThinkingEffortDisplay
return -;
}
- const messageNamespace = effortInfo.source === "codex" ? "reasoningEffort" : "effort";
+ const messageNamespace =
+ effortInfo.source === "anthropic"
+ ? "effort"
+ : effortInfo.source === "openai"
+ ? "reasoningEffortOpenai"
+ : "reasoningEffort";
const showEffectiveBadge = effortInfo.isOverridden && effortInfo.effectiveEffort != null;
return (
diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx
index 789c01b02..244b37a06 100644
--- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx
+++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx
@@ -947,7 +947,10 @@ export function SystemSettingsForm({
setEnableHighConcurrencyMode(checked)}
+ onCheckedChange={(checked) => {
+ setEnableHighConcurrencyMode(checked);
+ if (checked) toast.warning(t("highConcurrencyModeWarning"));
+ }}
disabled={isPending}
/>
diff --git a/src/app/v1/_lib/proxy/client-abort-metering.test.ts b/src/app/v1/_lib/proxy/client-abort-metering.test.ts
new file mode 100644
index 000000000..5367b490b
--- /dev/null
+++ b/src/app/v1/_lib/proxy/client-abort-metering.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, it } from "vitest";
+import {
+ CLIENT_ABORT_METER_MAX_RETAINED_BYTES,
+ createClientAbortMeteringObserver,
+} from "./client-abort-metering";
+
+const encoder = new TextEncoder();
+
+describe("createClientAbortMeteringObserver", () => {
+ it("keeps only compact Responses accounting evidence", () => {
+ const observer = createClientAbortMeteringObserver("response");
+ observer.observe(
+ encoder.encode(
+ `event: response.output_text.delta\ndata: ${JSON.stringify({
+ type: "response.output_text.delta",
+ delta: "x".repeat(32 * 1024),
+ })}\n\n`
+ )
+ );
+ const result = observer.observe(
+ encoder.encode(
+ `event: response.completed\ndata: ${JSON.stringify({
+ type: "response.completed",
+ response: {
+ id: "resp_1",
+ model: "gpt-test",
+ output: [{ content: [{ text: "discard me" }] }],
+ usage: { input_tokens: 10, output_tokens: 5 },
+ },
+ })}\n\n`
+ )
+ );
+
+ const snapshot = observer.finish();
+ expect(result.billingComplete).toBe(true);
+ expect(snapshot.text).toContain("response.completed");
+ expect(snapshot.text).toContain('"input_tokens":10');
+ expect(snapshot.text).not.toContain("discard me");
+ expect(snapshot.retainedBytes).toBeLessThanOrEqual(CLIENT_ABORT_METER_MAX_RETAINED_BYTES);
+ });
+
+ it("retains Claude initial and terminal usage until message_stop", () => {
+ const observer = createClientAbortMeteringObserver("claude");
+ observer.observe(
+ encoder.encode(
+ `event: message_start\ndata: ${JSON.stringify({
+ type: "message_start",
+ message: { model: "claude-test", usage: { input_tokens: 20, output_tokens: 1 } },
+ })}\n\n`
+ )
+ );
+ expect(
+ observer.observe(
+ encoder.encode(
+ `event: message_delta\ndata: ${JSON.stringify({
+ type: "message_delta",
+ usage: { output_tokens: 7 },
+ })}\n\n`
+ )
+ ).billingComplete
+ ).toBe(false);
+ expect(
+ observer.observe(encoder.encode(`event: message_stop\ndata: {"type":"message_stop"}\n\n`))
+ .billingComplete
+ ).toBe(true);
+
+ const snapshot = observer.finish();
+ expect(snapshot.text).toContain("message_start");
+ expect(snapshot.text).toContain("message_delta");
+ expect(snapshot.text).toContain("message_stop");
+ });
+
+ it("skips an oversized content frame and resumes at the next frame boundary", () => {
+ const observer = createClientAbortMeteringObserver("response");
+ const result = observer.observe(
+ encoder.encode(
+ `event: response.output_text.delta\ndata: ${JSON.stringify({
+ type: "response.output_text.delta",
+ delta: "x".repeat(70 * 1024),
+ })}\n\nevent: response.completed\ndata: ${JSON.stringify({
+ type: "response.completed",
+ response: { usage: { input_tokens: 10, output_tokens: 5 } },
+ })}\n\n`
+ )
+ );
+
+ const snapshot = observer.finish();
+ expect(result.billingComplete).toBe(true);
+ expect(snapshot.skippedOversizedFrames).toBe(1);
+ expect(snapshot.text).toContain("response.completed");
+ });
+
+ it("requires terminal usage rather than a marker alone", () => {
+ const observer = createClientAbortMeteringObserver("openai");
+ expect(observer.observe(encoder.encode("data: [DONE]\n\n")).billingComplete).toBe(false);
+ expect(observer.finish().billingComplete).toBe(false);
+ });
+
+ it("combines an OpenAI usage chunk with a later done marker across arbitrary splits", () => {
+ const observer = createClientAbortMeteringObserver("openai");
+ const text = `data: ${JSON.stringify({
+ id: "chatcmpl_1",
+ choices: [],
+ usage: { prompt_tokens: 12, completion_tokens: 4 },
+ })}\r\n\r\ndata: [DONE]\r\n\r\n`;
+ const bytes = encoder.encode(text);
+ for (let offset = 0; offset < bytes.length; offset += 7) {
+ observer.observe(bytes.subarray(offset, offset + 7));
+ }
+
+ const snapshot = observer.finish();
+ expect(snapshot.billingComplete).toBe(true);
+ expect(snapshot.text).toContain('"prompt_tokens":12');
+ expect(snapshot.text).toContain("[DONE]");
+ });
+
+ it("uses the last Gemini NDJSON usage and finishReason as terminal evidence", () => {
+ const observer = createClientAbortMeteringObserver("gemini");
+ observer.observe(
+ encoder.encode(
+ `${JSON.stringify({
+ candidates: [{ content: { parts: [{ text: "discard" }] } }],
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 1 },
+ })}\n`
+ )
+ );
+ const result = observer.observe(
+ encoder.encode(
+ `${JSON.stringify({
+ candidates: [{ finishReason: "STOP" }],
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 8 },
+ })}\n`
+ )
+ );
+
+ const snapshot = observer.finish();
+ expect(result.billingComplete).toBe(true);
+ expect(snapshot.text).toContain('"candidatesTokenCount":8');
+ expect(snapshot.text).not.toContain("discard");
+ });
+
+ it("retains compact protocol errors without retaining content", () => {
+ const observer = createClientAbortMeteringObserver("response");
+ observer.observe(
+ encoder.encode(
+ `event: error\ndata: ${JSON.stringify({
+ type: "response.error",
+ error: { code: "upstream_failed", message: "failure" },
+ debug: "x".repeat(32 * 1024),
+ })}\n\n`
+ )
+ );
+
+ const snapshot = observer.finish();
+ expect(snapshot.billingComplete).toBe(false);
+ expect(snapshot.text).toContain("upstream_failed");
+ expect(snapshot.text).not.toContain('"debug"');
+ });
+
+ it("compacts extended usage, metadata, cache, and signature evidence", () => {
+ const observer = createClientAbortMeteringObserver("response");
+ observer.observe(
+ encoder.encode(
+ `event: response.in_progress\ndata: ${JSON.stringify({
+ id: "resp_extended",
+ model: "gpt-extended",
+ prompt_cache_key: "cache-key",
+ service_tier: "priority",
+ status: "in_progress",
+ type: "response.in_progress",
+ message: {
+ id: "message-1",
+ model: "gpt-message",
+ usage: { input_tokens: 1 },
+ },
+ delta: {
+ type: "signature_delta",
+ stop_reason: "end_turn",
+ signature: "signed-model",
+ usage: { output_tokens: 2 },
+ },
+ usage: {
+ input_tokens: 10,
+ output_tokens: 3,
+ cache_creation_input_tokens: 2,
+ cache_creation_5m_input_tokens: 1,
+ cache_creation_1h_input_tokens: 1,
+ cache_read_input_tokens: 4,
+ input_tokens_details: { cached_tokens: 4, cache_write_tokens: 2 },
+ prompt_tokens_details: { cached_tokens: 4, cache_write_tokens: 2 },
+ cache_creation: {
+ ephemeral_5m_input_tokens: 1,
+ ephemeral_1h_input_tokens: 1,
+ },
+ candidatesTokensDetails: [
+ null,
+ {},
+ { modality: "TEXT", tokenCount: 2 },
+ { tokenCount: 1 },
+ ],
+ promptTokensDetails: [{ modality: "IMAGE", tokenCount: 3 }],
+ },
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 3 },
+ choices: [null, {}, { finish_reason: "stop" }],
+ candidates: [null, {}, { finishReason: "STOP" }],
+ ignored: "not retained",
+ })}\n\n`
+ )
+ );
+ observer.observe(
+ encoder.encode(
+ `event: response.completed\ndata: ${JSON.stringify({
+ type: "response.completed",
+ response: {
+ id: "resp_extended",
+ model: "gpt-extended",
+ service_tier: "priority",
+ usage: { input_tokens: 10, output_tokens: 3 },
+ },
+ })}\n\n`
+ )
+ );
+
+ const snapshot = observer.finish();
+ expect(snapshot.billingComplete).toBe(true);
+ expect(snapshot.text).toContain('"prompt_cache_key":"cache-key"');
+ expect(snapshot.text).toContain('"signature":"signed-model"');
+ expect(snapshot.text).toContain('"cache_write_tokens":2');
+ expect(snapshot.text).toContain('"modality":"IMAGE"');
+ expect(snapshot.text).not.toContain("not retained");
+ });
+
+ it("handles comments, multi-line data, bare JSON tails, and malformed frames", () => {
+ const observer = createClientAbortMeteringObserver("gemini-cli");
+ observer.observe(new Uint8Array());
+ observer.observe(
+ encoder.encode(
+ ': keepalive\rretry: 1000\revent: message\rdata: {"usageMetadata":\rdata: {"promptTokenCount":10,"candidatesTokenCount":2}}\r\r'
+ )
+ );
+ observer.observe(encoder.encode("data: true\n\n"));
+ observer.observe(encoder.encode("data: not-json\n\n"));
+ observer.observe(encoder.encode("data: still-not-json\n\n"));
+ observer.observe(
+ encoder.encode(
+ JSON.stringify({
+ candidates: [{ finishReason: "STOP" }],
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2 },
+ })
+ )
+ );
+
+ const snapshot = observer.finish();
+ expect(snapshot.billingComplete).toBe(true);
+ expect(snapshot.protocolFailure).toEqual({
+ afterContent: false,
+ verdict: "malformed",
+ eventName: null,
+ });
+ });
+
+ it("recovers after an oversized bare JSON line and ignores post-finish input", () => {
+ const observer = createClientAbortMeteringObserver("gemini");
+ observer.observe(encoder.encode(`{"ignored":"${"x".repeat(70 * 1024)}"}\n`));
+ observer.observe(
+ encoder.encode(
+ `${JSON.stringify({
+ candidates: [{ finishReason: "STOP" }],
+ usageMetadata: { promptTokenCount: 4, candidatesTokenCount: 2 },
+ })}`
+ )
+ );
+ const first = observer.finish();
+ observer.observe(encoder.encode('{"error":true}\n'));
+ const second = observer.finish();
+
+ expect(first.billingComplete).toBe(true);
+ expect(first.skippedOversizedFrames).toBe(1);
+ expect(second).toEqual(first);
+ });
+});
diff --git a/src/app/v1/_lib/proxy/client-abort-metering.ts b/src/app/v1/_lib/proxy/client-abort-metering.ts
new file mode 100644
index 000000000..937d4ae66
--- /dev/null
+++ b/src/app/v1/_lib/proxy/client-abort-metering.ts
@@ -0,0 +1,546 @@
+import type { ClientFormat } from "./format-mapper";
+
+export const CLIENT_ABORT_METER_MAX_RETAINED_BYTES = 64 * 1024;
+export const CLIENT_ABORT_METER_MAX_FRAME_BYTES = 64 * 1024;
+
+type EvidenceSlot =
+ | "error"
+ | "initial-usage"
+ | "latest-usage"
+ | "metadata"
+ | "signature"
+ | "terminal";
+
+export interface ClientAbortMeteringSnapshot {
+ text: string;
+ billingComplete: boolean;
+ retainedBytes: number;
+ skippedOversizedFrames: number;
+ protocolFailure: {
+ afterContent: boolean;
+ verdict: "error" | "malformed";
+ eventName: string | null;
+ } | null;
+}
+
+export interface ClientAbortMeteringObserver {
+ observe(chunk: Uint8Array): { billingComplete: boolean };
+ finish(): ClientAbortMeteringSnapshot;
+}
+
+interface ParsedFrame {
+ eventName: string | null;
+ data: string;
+}
+
+const USAGE_NUMBER_FIELDS = [
+ "cachedContentTokenCount",
+ "cache_creation_1h_input_tokens",
+ "cache_creation_5m_input_tokens",
+ "cache_creation_input_tokens",
+ "cache_read_input_tokens",
+ "candidatesTokenCount",
+ "claude_cache_creation_1_h_tokens",
+ "claude_cache_creation_5_m_tokens",
+ "completion_tokens",
+ "input_tokens",
+ "output_tokens",
+ "promptTokenCount",
+ "prompt_tokens",
+ "thoughtsTokenCount",
+] as const;
+
+/** Narrows unknown JSON values to non-array records. */
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Copies finite numeric accounting fields into a compact evidence record. */
+function copyFiniteNumberFields(
+ source: Record,
+ target: Record,
+ fields: readonly string[]
+): void {
+ for (const field of fields) {
+ const value = source[field];
+ if (typeof value === "number" && Number.isFinite(value)) target[field] = value;
+ }
+}
+
+/** Compacts modality token details while discarding response content. */
+function compactTokenDetails(value: unknown): unknown {
+ if (!Array.isArray(value)) return undefined;
+ const details = value.slice(0, 16).flatMap((entry) => {
+ if (!isRecord(entry)) return [];
+ const compact: Record = {};
+ if (typeof entry.modality === "string") compact.modality = entry.modality.slice(0, 32);
+ if (typeof entry.tokenCount === "number" && Number.isFinite(entry.tokenCount)) {
+ compact.tokenCount = entry.tokenCount;
+ }
+ return Object.keys(compact).length > 0 ? [compact] : [];
+ });
+ return details.length > 0 ? details : undefined;
+}
+
+/** Retains only bounded usage and cache fields needed by billing. */
+function compactUsage(value: unknown): Record | null {
+ if (!isRecord(value)) return null;
+ const compact: Record = {};
+ copyFiniteNumberFields(value, compact, USAGE_NUMBER_FIELDS);
+
+ for (const field of ["input_tokens_details", "prompt_tokens_details"] as const) {
+ const details = value[field];
+ if (!isRecord(details)) continue;
+ const compactDetails: Record = {};
+ copyFiniteNumberFields(details, compactDetails, ["cached_tokens", "cache_write_tokens"]);
+ if (Object.keys(compactDetails).length > 0) compact[field] = compactDetails;
+ }
+
+ if (isRecord(value.cache_creation)) {
+ const cacheCreation: Record = {};
+ copyFiniteNumberFields(value.cache_creation, cacheCreation, [
+ "ephemeral_1h_input_tokens",
+ "ephemeral_5m_input_tokens",
+ ]);
+ if (Object.keys(cacheCreation).length > 0) compact.cache_creation = cacheCreation;
+ }
+
+ for (const field of ["candidatesTokensDetails", "promptTokensDetails"] as const) {
+ const details = compactTokenDetails(value[field]);
+ if (details) compact[field] = details;
+ }
+
+ return Object.keys(compact).length > 0 ? compact : null;
+}
+
+/** Retains a bounded protocol-error representation. */
+function compactError(value: unknown): unknown {
+ if (typeof value === "string") return value.slice(0, 1024);
+ if (!isRecord(value)) return value === true ? true : undefined;
+ const compact: Record = {};
+ for (const field of ["code", "message", "type"] as const) {
+ const fieldValue = value[field];
+ if (typeof fieldValue === "string") compact[field] = fieldValue.slice(0, 1024);
+ }
+ return Object.keys(compact).length > 0 ? compact : true;
+}
+
+/** Builds a bounded frame payload without retaining generated content. */
+function compactPayload(value: Record): Record {
+ const compact: Record = {};
+ for (const field of [
+ "id",
+ "model",
+ "prompt_cache_key",
+ "service_tier",
+ "status",
+ "type",
+ ] as const) {
+ const fieldValue = value[field];
+ if (typeof fieldValue === "string") compact[field] = fieldValue.slice(0, 256);
+ }
+ if (value.failed === true) compact.failed = true;
+ if (value.error !== undefined) compact.error = compactError(value.error);
+
+ for (const field of ["usage", "usageMetadata"] as const) {
+ const usage = compactUsage(value[field]);
+ if (usage) compact[field] = usage;
+ }
+
+ if (isRecord(value.message)) {
+ const message: Record = {};
+ for (const field of ["id", "model"] as const) {
+ const fieldValue = value.message[field];
+ if (typeof fieldValue === "string") message[field] = fieldValue.slice(0, 256);
+ }
+ const usage = compactUsage(value.message.usage);
+ if (usage) message.usage = usage;
+ if (Object.keys(message).length > 0) compact.message = message;
+ }
+
+ if (isRecord(value.delta)) {
+ const delta: Record = {};
+ if (typeof value.delta.type === "string") delta.type = value.delta.type.slice(0, 128);
+ if (typeof value.delta.stop_reason === "string") {
+ delta.stop_reason = value.delta.stop_reason.slice(0, 128);
+ }
+ if (typeof value.delta.signature === "string") {
+ delta.signature = value.delta.signature.slice(0, 8192);
+ }
+ const usage = compactUsage(value.delta.usage);
+ if (usage) delta.usage = usage;
+ if (Object.keys(delta).length > 0) compact.delta = delta;
+ }
+
+ if (isRecord(value.response)) compact.response = compactPayload(value.response);
+
+ if (Array.isArray(value.choices)) {
+ compact.choices = value.choices.slice(0, 16).map((choice) => {
+ if (!isRecord(choice) || typeof choice.finish_reason !== "string") return {};
+ return { finish_reason: choice.finish_reason.slice(0, 128) };
+ });
+ }
+
+ if (Array.isArray(value.candidates)) {
+ compact.candidates = value.candidates.slice(0, 16).map((candidate) => {
+ if (!isRecord(candidate) || typeof candidate.finishReason !== "string") return {};
+ return { finishReason: candidate.finishReason.slice(0, 128) };
+ });
+ }
+
+ return compact;
+}
+
+/** Checks whether compact usage contains any positive billable count. */
+function positiveUsage(value: unknown): boolean {
+ const usage = compactUsage(value);
+ if (!usage) return false;
+ const stack: unknown[] = [usage];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ if (typeof current === "number" && current > 0) return true;
+ if (Array.isArray(current)) stack.push(...current);
+ else if (isRecord(current)) stack.push(...Object.values(current));
+ }
+ return false;
+}
+
+/** Finds usage in supported provider envelopes. */
+function findUsage(value: Record): boolean {
+ if (positiveUsage(value.usage) || positiveUsage(value.usageMetadata)) return true;
+ if (isRecord(value.message) && positiveUsage(value.message.usage)) return true;
+ if (isRecord(value.delta) && positiveUsage(value.delta.usage)) return true;
+ return isRecord(value.response) && findUsage(value.response);
+}
+
+/** Detects provider protocol-error markers in a parsed frame. */
+function isProtocolError(value: Record): boolean {
+ return (
+ value.error !== undefined ||
+ value.failed === true ||
+ value.type === "error" ||
+ value.type === "response.error" ||
+ value.type === "response.failed" ||
+ (isRecord(value.response) && value.response.error !== undefined)
+ );
+}
+
+/** Detects a terminal OpenAI Chat completion choice. */
+function hasOpenAiCompletion(value: Record): boolean {
+ return (
+ Array.isArray(value.choices) &&
+ value.choices.some(
+ (choice) =>
+ isRecord(choice) &&
+ typeof choice.finish_reason === "string" &&
+ choice.finish_reason.trim().length > 0
+ )
+ );
+}
+
+/** Detects a terminal Gemini candidate finish reason. */
+function hasGeminiCompletion(value: Record): boolean {
+ const payload = isRecord(value.response) ? value.response : value;
+ return (
+ Array.isArray(payload.candidates) &&
+ payload.candidates.some(
+ (candidate) =>
+ isRecord(candidate) &&
+ typeof candidate.finishReason === "string" &&
+ candidate.finishReason.trim().length > 0
+ )
+ );
+}
+
+/** Incrementally frames SSE, data-only SSE, and bounded NDJSON input. */
+class BoundedEventFramer {
+ private readonly decoder = new TextDecoder("utf-8");
+ private line = "";
+ private lineOverflow = false;
+ private overflowedRawJsonLine = false;
+ private pendingCr = false;
+ private eventName: string | null = null;
+ private dataLines: string[] = [];
+ private frameCharacters = 0;
+ private droppingFrame = false;
+ skippedOversizedFrames = 0;
+
+ constructor(
+ private readonly maxFrameCharacters: number,
+ private readonly onFrame: (frame: ParsedFrame) => void
+ ) {}
+
+ push(chunk: Uint8Array): void {
+ if (chunk.byteLength === 0) return;
+ this.consume(this.decoder.decode(chunk, { stream: true }));
+ }
+
+ finish(): void {
+ this.consume(this.decoder.decode());
+ if (this.line.length > 0 || this.lineOverflow) this.consumeLine();
+ this.flushFrame();
+ }
+
+ private consume(text: string): void {
+ for (const character of text) {
+ if (this.pendingCr) {
+ this.pendingCr = false;
+ if (character === "\n") continue;
+ }
+ if (character === "\r") {
+ this.consumeLine();
+ this.pendingCr = true;
+ continue;
+ }
+ if (character === "\n") {
+ this.consumeLine();
+ continue;
+ }
+ if (this.lineOverflow) continue;
+ if (this.line.length >= this.maxFrameCharacters) {
+ this.overflowedRawJsonLine =
+ this.eventName === null &&
+ this.dataLines.length === 0 &&
+ this.line.trimStart().startsWith("{");
+ this.line = "";
+ this.lineOverflow = true;
+ this.dropCurrentFrame();
+ continue;
+ }
+ this.line += character;
+ }
+ }
+
+ private consumeLine(): void {
+ const line = this.line;
+ const overflowed = this.lineOverflow;
+ const overflowedRawJsonLine = this.overflowedRawJsonLine;
+ this.line = "";
+ this.lineOverflow = false;
+ this.overflowedRawJsonLine = false;
+
+ if (overflowed && overflowedRawJsonLine) {
+ this.droppingFrame = false;
+ this.resetFrame();
+ return;
+ }
+
+ if (line.length === 0 && !overflowed) {
+ if (this.droppingFrame) {
+ this.droppingFrame = false;
+ this.resetFrame();
+ } else {
+ this.flushFrame();
+ }
+ return;
+ }
+ if (this.droppingFrame || overflowed) return;
+ if (line.startsWith(":")) return;
+ if (line.startsWith("event:")) {
+ this.eventName = line.slice(6).trim().slice(0, 256);
+ this.frameCharacters += line.length;
+ this.enforceFrameLimit();
+ return;
+ }
+ if (line.startsWith("data:")) {
+ const data = line.slice(5).replace(/^\s/, "");
+ this.dataLines.push(data);
+ this.frameCharacters += data.length;
+ this.enforceFrameLimit();
+ return;
+ }
+
+ const candidate = line.trim();
+ if (this.eventName === null && this.dataLines.length === 0 && candidate.startsWith("{")) {
+ if (candidate.length <= this.maxFrameCharacters) {
+ this.onFrame({ eventName: null, data: candidate });
+ } else {
+ this.skippedOversizedFrames += 1;
+ }
+ }
+ }
+
+ private enforceFrameLimit(): void {
+ if (this.frameCharacters <= this.maxFrameCharacters) return;
+ this.dropCurrentFrame();
+ }
+
+ private dropCurrentFrame(): void {
+ if (!this.droppingFrame) this.skippedOversizedFrames += 1;
+ this.droppingFrame = true;
+ this.resetFrame();
+ }
+
+ private flushFrame(): void {
+ if (this.droppingFrame || this.dataLines.length === 0) {
+ this.resetFrame();
+ return;
+ }
+ this.onFrame({ eventName: this.eventName, data: this.dataLines.join("\n") });
+ this.resetFrame();
+ }
+
+ private resetFrame(): void {
+ this.eventName = null;
+ this.dataLines = [];
+ this.frameCharacters = 0;
+ }
+}
+
+/** Creates the bounded accounting observer used after a client disconnect. */
+export function createClientAbortMeteringObserver(
+ format: ClientFormat
+): ClientAbortMeteringObserver {
+ const evidence = new Map();
+ const evidenceBytes = new Map();
+ const evidenceValueCounts = new Map();
+ const encoder = new TextEncoder();
+ let retainedByteTotal = 0;
+ let terminalSeen = false;
+ let terminalUsageSeen = false;
+ let protocolFailure: ClientAbortMeteringSnapshot["protocolFailure"] = null;
+ let finished = false;
+
+ const setEvidence = (slot: EvidenceSlot, value: string): void => {
+ const previous = evidence.get(slot);
+ if (previous === value) return;
+ const previousBytes = evidenceBytes.get(slot) ?? 0;
+ const nextBytes = encoder.encode(value).length;
+ const previousCount = previous ? (evidenceValueCounts.get(previous) ?? 0) : 0;
+ const nextCount = evidenceValueCounts.get(value) ?? 0;
+ const nextTotal =
+ retainedByteTotal -
+ (previousCount === 1 ? previousBytes : 0) +
+ (nextCount === 0 ? nextBytes : 0);
+ if (nextTotal > CLIENT_ABORT_METER_MAX_RETAINED_BYTES) return;
+
+ if (previous) {
+ if (previousCount <= 1) {
+ evidenceValueCounts.delete(previous);
+ retainedByteTotal -= previousBytes;
+ } else {
+ evidenceValueCounts.set(previous, previousCount - 1);
+ }
+ }
+ evidence.set(slot, value);
+ evidenceBytes.set(slot, nextBytes);
+ evidenceValueCounts.set(value, nextCount + 1);
+ if (nextCount === 0) retainedByteTotal += nextBytes;
+ };
+
+ const recordFrame = (frame: ParsedFrame): void => {
+ const trimmed = frame.data.trim();
+ if (trimmed === "[DONE]") {
+ if (format === "openai") terminalSeen = true;
+ setEvidence("terminal", "data: [DONE]\n\n");
+ return;
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed) as unknown;
+ } catch {
+ protocolFailure ??= {
+ afterContent: terminalSeen,
+ verdict: "malformed",
+ eventName: frame.eventName,
+ };
+ return;
+ }
+ if (!isRecord(parsed)) return;
+
+ const hasUsage = findUsage(parsed);
+ const protocolError = isProtocolError(parsed) || frame.eventName === "error";
+ const type = typeof parsed.type === "string" ? parsed.type : null;
+ const terminal = (() => {
+ switch (format) {
+ case "response":
+ return (
+ (type === "response.completed" || type === "response.done") &&
+ (frame.eventName === null || frame.eventName === "message" || frame.eventName === type)
+ );
+ case "claude":
+ return (
+ type === "message_stop" &&
+ (frame.eventName === null ||
+ frame.eventName === "message" ||
+ frame.eventName === "message_stop")
+ );
+ case "openai":
+ return hasOpenAiCompletion(parsed);
+ case "gemini":
+ case "gemini-cli":
+ return hasGeminiCompletion(parsed);
+ }
+ })();
+
+ if (terminal) terminalSeen = true;
+ if (
+ hasUsage &&
+ (format !== "claude" || type === "message_delta" || frame.eventName === "message_delta")
+ ) {
+ terminalUsageSeen = true;
+ }
+
+ const compact = compactPayload(parsed);
+ const compactData = JSON.stringify(compact);
+ const normalized = `${frame.eventName ? `event: ${frame.eventName}\n` : ""}data: ${compactData}\n\n`;
+
+ if (protocolError) {
+ protocolFailure ??= {
+ afterContent: terminalSeen,
+ verdict: "error",
+ eventName: frame.eventName,
+ };
+ setEvidence("error", normalized);
+ }
+ if (terminal) setEvidence("terminal", normalized);
+ if (hasUsage) {
+ const isInitialClaudeUsage =
+ format === "claude" && (type === "message_start" || frame.eventName === "message_start");
+ setEvidence(isInitialClaudeUsage ? "initial-usage" : "latest-usage", normalized);
+ }
+ if (
+ isRecord(parsed.delta) &&
+ parsed.delta.type === "signature_delta" &&
+ typeof parsed.delta.signature === "string"
+ ) {
+ setEvidence("signature", normalized);
+ }
+ if (
+ typeof parsed.model === "string" ||
+ typeof parsed.prompt_cache_key === "string" ||
+ typeof parsed.service_tier === "string" ||
+ (isRecord(parsed.message) && typeof parsed.message.model === "string") ||
+ (isRecord(parsed.response) &&
+ (typeof parsed.response.model === "string" ||
+ typeof parsed.response.service_tier === "string"))
+ ) {
+ setEvidence("metadata", normalized);
+ }
+ };
+
+ const framer = new BoundedEventFramer(CLIENT_ABORT_METER_MAX_FRAME_BYTES, recordFrame);
+ const isBillingComplete = () => terminalSeen && terminalUsageSeen;
+
+ return {
+ observe(chunk): { billingComplete: boolean } {
+ if (!finished) framer.push(chunk);
+ return { billingComplete: isBillingComplete() };
+ },
+ finish(): ClientAbortMeteringSnapshot {
+ if (!finished) {
+ finished = true;
+ framer.finish();
+ }
+ const text = [...new Set(evidence.values())].join("");
+ return {
+ text,
+ billingComplete: isBillingComplete(),
+ retainedBytes: retainedByteTotal,
+ skippedOversizedFrames: framer.skippedOversizedFrames,
+ protocolFailure: protocolFailure ? { ...protocolFailure } : null,
+ };
+ },
+ };
+}
diff --git a/src/app/v1/_lib/proxy/demand-driven-response-pump.test.ts b/src/app/v1/_lib/proxy/demand-driven-response-pump.test.ts
index 965b35d62..5f0655462 100644
--- a/src/app/v1/_lib/proxy/demand-driven-response-pump.test.ts
+++ b/src/app/v1/_lib/proxy/demand-driven-response-pump.test.ts
@@ -11,6 +11,14 @@ function nextTurn(): Promise {
return new Promise((resolve) => setImmediate(resolve));
}
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+ return { promise, resolve };
+}
+
function trackReaderRelease(source: ReadableStream) {
const reader = source.getReader();
const releaseLock = vi.spyOn(reader, "releaseLock");
@@ -403,4 +411,62 @@ describe("createDemandDrivenResponsePump", () => {
});
expect(releaseLock).toHaveBeenCalledTimes(1);
});
+
+ it("cancels the source when the primed read fails", async () => {
+ const sourceError = new Error("source-read-failed");
+ const cancel = vi.fn(() => Promise.resolve());
+ const releaseLock = vi.fn();
+ const reader = {
+ read: vi.fn(() => Promise.reject(sourceError)),
+ cancel,
+ releaseLock,
+ } as unknown as ReadableStreamDefaultReader;
+ const source = {
+ getReader: vi.fn(() => reader),
+ } as unknown as ReadableStream;
+
+ const pump = createDemandDrivenResponsePump({ source, onChunk: vi.fn() });
+
+ await expect(pump.completion).resolves.toMatchObject({
+ streamEndedNormally: false,
+ error: sourceError,
+ });
+ expect(cancel).toHaveBeenCalledWith(sourceError);
+ expect(releaseLock).toHaveBeenCalledOnce();
+ });
+
+ it("finishes a metering drain without reporting a source error", async () => {
+ const cancelGate = createDeferred();
+ const cancel = vi.fn(() => cancelGate.promise);
+ const releaseLock = vi.fn();
+ const reader = {
+ read: vi.fn(() => new Promise>(() => {})),
+ cancel,
+ releaseLock,
+ } as unknown as ReadableStreamDefaultReader;
+ const source = {
+ getReader: vi.fn(() => reader),
+ } as unknown as ReadableStream;
+ const pump = createDemandDrivenResponsePump({ source, onChunk: vi.fn() });
+ let teardownSettled = false;
+ void pump.teardown.then(() => {
+ teardownSettled = true;
+ });
+
+ pump.startDrain("client detached");
+ pump.finishDrain("terminal usage captured");
+
+ await expect(pump.completion).resolves.toEqual({
+ streamEndedNormally: false,
+ clientAborted: true,
+ error: null,
+ });
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(teardownSettled).toBe(false);
+
+ cancelGate.resolve();
+ await pump.teardown;
+ expect(teardownSettled).toBe(true);
+ expect(releaseLock).toHaveBeenCalledOnce();
+ });
});
diff --git a/src/app/v1/_lib/proxy/demand-driven-response-pump.ts b/src/app/v1/_lib/proxy/demand-driven-response-pump.ts
index 137db3dd9..f3d6a886d 100644
--- a/src/app/v1/_lib/proxy/demand-driven-response-pump.ts
+++ b/src/app/v1/_lib/proxy/demand-driven-response-pump.ts
@@ -16,7 +16,9 @@ export interface DemandDrivenResponsePumpOptions {
export interface DemandDrivenResponsePump {
stream: ReadableStream;
completion: Promise;
+ teardown: Promise;
startDrain: (reason?: unknown) => void;
+ finishDrain: (reason?: unknown) => void;
cancelSource: (reason?: unknown) => void;
errorClient: (error: Error) => void;
getState: () => DemandDrivenResponsePumpState;
@@ -43,9 +45,13 @@ export function createDemandDrivenResponsePump(
let readerReleased = false;
let pendingChunkDeadlineId: ReturnType | null = null;
let resolveCompletion: (completion: DemandDrivenResponsePumpCompletion) => void = () => {};
+ let resolveTeardown = () => {};
const completion = new Promise((resolve) => {
resolveCompletion = resolve;
});
+ const teardown = new Promise((resolve) => {
+ resolveTeardown = resolve;
+ });
const releaseReader = () => {
if (readerReleased) return;
@@ -91,7 +97,11 @@ export function createDemandDrivenResponsePump(
clientController = null;
state = "closed";
resolveCompletion({ streamEndedNormally, clientAborted, error });
- void cancelPromise?.then(undefined, recordSourceCancelFailure);
+ if (cancelPromise) {
+ void cancelPromise.then(undefined, recordSourceCancelFailure).finally(resolveTeardown);
+ } else {
+ resolveTeardown();
+ }
};
const finishWithError = (error: unknown) => {
@@ -104,7 +114,10 @@ export function createDemandDrivenResponsePump(
// The downstream may have cancelled concurrently.
}
}
- settle(false, normalized);
+ // A rejected source read bypasses the Web stream cancel algorithm. Keep
+ // source ownership explicit so adapters can release the underlying Node
+ // stream, socket, and native backing store on every terminal error.
+ settle(false, normalized, normalized);
};
const finishNormally = () => {
@@ -146,6 +159,13 @@ export function createDemandDrivenResponsePump(
settle(false, normalized, normalized);
};
+ /** Completes a detached drain after metering without reporting a source error. */
+ const finishDrain = (reason?: unknown) => {
+ if (settled || state !== "draining") return;
+ const normalized = reason == null ? new Error("Background drain complete") : toError(reason);
+ settle(false, null, normalized);
+ };
+
const armPendingChunkDeadline = () => {
clearPendingChunkDeadline();
pendingChunkDeadlineId = setTimeout(() => {
@@ -264,7 +284,9 @@ export function createDemandDrivenResponsePump(
return {
stream,
completion,
+ teardown,
startDrain,
+ finishDrain,
cancelSource,
errorClient(error) {
if (settled || state !== "client-active") return;
diff --git a/src/app/v1/_lib/proxy/detached-stream-budget.test.ts b/src/app/v1/_lib/proxy/detached-stream-budget.test.ts
new file mode 100644
index 000000000..0f31c454d
--- /dev/null
+++ b/src/app/v1/_lib/proxy/detached-stream-budget.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import {
+ DetachedStreamBudget,
+ resolveDetachedStreamBudgetLimits,
+} from "@/app/v1/_lib/proxy/detached-stream-budget";
+
+function createBudget(
+ overrides: Partial["limits"]> = {}
+) {
+ return new DetachedStreamBudget(() => ({
+ maxConcurrency: 4,
+ maxReservedBytes: 1024,
+ meteringReserveBytes: 256,
+ ...overrides,
+ }));
+}
+
+describe("DetachedStreamBudget", () => {
+ it("enforces concurrency and releases weighted leases idempotently", () => {
+ const budget = createBudget({ maxConcurrency: 1 });
+ const first = budget.tryAcquire("metering", 256);
+ expect(first.acquired).toBe(true);
+ expect(budget.tryAcquire("metering", 256)).toEqual({
+ acquired: false,
+ reason: "concurrency_exhausted",
+ });
+
+ if (!first.acquired) throw new Error("expected first lease");
+ first.lease.release();
+ first.lease.release();
+ expect(budget.snapshot()).toMatchObject({
+ activeStreams: 0,
+ reservedBytes: 0,
+ activeByKind: { metering: 0, replay: 0 },
+ reservedByKind: { metering: 0, replay: 0 },
+ });
+ });
+
+ it("reserves headroom for metering when admitting Replay owners", () => {
+ const budget = createBudget();
+ const replay = budget.tryAcquire("replay", 768);
+ expect(replay.acquired).toBe(true);
+ expect(budget.tryAcquire("replay", 1)).toEqual({
+ acquired: false,
+ reason: "metering_reserve",
+ });
+ expect(budget.tryAcquire("metering", 256).acquired).toBe(true);
+ });
+
+ it("enforces the aggregate memory budget across lease kinds", () => {
+ const budget = createBudget({ meteringReserveBytes: 0 });
+ expect(budget.tryAcquire("replay", 768).acquired).toBe(true);
+ expect(budget.tryAcquire("metering", 257)).toEqual({
+ acquired: false,
+ reason: "memory_budget_exhausted",
+ });
+ });
+
+ it("tracks Replay and metering reservations independently", () => {
+ const budget = createBudget({ meteringReserveBytes: 0 });
+ expect(budget.tryAcquire("replay", 512).acquired).toBe(true);
+ expect(budget.tryAcquire("metering", 256).acquired).toBe(true);
+ expect(budget.snapshot()).toMatchObject({
+ activeStreams: 2,
+ reservedBytes: 768,
+ activeByKind: { metering: 1, replay: 1 },
+ reservedByKind: { metering: 256, replay: 512 },
+ });
+ });
+
+ it("rejects invalid reservations without mutating state", () => {
+ const budget = createBudget();
+ expect(() => budget.tryAcquire("metering", 0)).toThrow(RangeError);
+ expect(budget.snapshot().activeStreams).toBe(0);
+ });
+
+ it("uses conservative defaults when environment parsing fails", () => {
+ expect(
+ resolveDetachedStreamBudgetLimits(() => {
+ throw new Error("invalid environment");
+ })
+ ).toEqual({
+ maxConcurrency: 64,
+ maxReservedBytes: 64 * 1024 * 1024,
+ meteringReserveBytes: 16 * 1024 * 1024,
+ });
+ });
+});
diff --git a/src/app/v1/_lib/proxy/detached-stream-budget.ts b/src/app/v1/_lib/proxy/detached-stream-budget.ts
new file mode 100644
index 000000000..791311c05
--- /dev/null
+++ b/src/app/v1/_lib/proxy/detached-stream-budget.ts
@@ -0,0 +1,157 @@
+import { getEnvConfig } from "@/lib/config/env.schema";
+
+const DEFAULT_DETACHED_STREAM_MAX_CONCURRENCY = 64;
+const DEFAULT_DETACHED_STREAM_BUDGET_BYTES = 64 * 1024 * 1024;
+const DEFAULT_DETACHED_STREAM_METERING_RESERVE_BYTES = 16 * 1024 * 1024;
+
+export type DetachedStreamLeaseKind = "metering" | "replay";
+
+export interface DetachedStreamBudgetLimits {
+ maxConcurrency: number;
+ maxReservedBytes: number;
+ meteringReserveBytes: number;
+}
+
+export interface DetachedStreamBudgetSnapshot {
+ activeStreams: number;
+ reservedBytes: number;
+ activeByKind: Record;
+ reservedByKind: Record;
+ limits: DetachedStreamBudgetLimits;
+}
+
+export interface DetachedStreamLease {
+ readonly kind: DetachedStreamLeaseKind;
+ readonly reservedBytes: number;
+ release(): void;
+}
+
+export type DetachedStreamAcquireResult =
+ | { acquired: true; lease: DetachedStreamLease }
+ | {
+ acquired: false;
+ reason: "concurrency_exhausted" | "memory_budget_exhausted" | "metering_reserve";
+ };
+
+function createKindCounters(): Record {
+ return { metering: 0, replay: 0 };
+}
+
+export class DetachedStreamBudget {
+ private activeStreams = 0;
+ private reservedBytes = 0;
+ private readonly activeByKind = createKindCounters();
+ private readonly reservedByKind = createKindCounters();
+
+ /** Creates a process-local weighted detached-stream budget. */
+ constructor(private readonly resolveLimits: () => DetachedStreamBudgetLimits) {}
+
+ /** Attempts to reserve capacity for a metering or Replay detached stream. */
+ tryAcquire(kind: DetachedStreamLeaseKind, reservedBytes: number): DetachedStreamAcquireResult {
+ if (!Number.isSafeInteger(reservedBytes) || reservedBytes <= 0) {
+ throw new RangeError("Detached stream reservation must be a positive safe integer");
+ }
+
+ const limits = this.resolveLimits();
+ if (this.activeStreams >= limits.maxConcurrency) {
+ return { acquired: false, reason: "concurrency_exhausted" };
+ }
+
+ const nextReservedBytes = this.reservedBytes + reservedBytes;
+ if (nextReservedBytes > limits.maxReservedBytes) {
+ return { acquired: false, reason: "memory_budget_exhausted" };
+ }
+
+ const effectiveMeteringReserve = Math.min(
+ limits.maxReservedBytes,
+ Math.max(0, limits.meteringReserveBytes)
+ );
+ if (
+ kind === "replay" &&
+ nextReservedBytes > limits.maxReservedBytes - effectiveMeteringReserve
+ ) {
+ return { acquired: false, reason: "metering_reserve" };
+ }
+
+ this.activeStreams += 1;
+ this.reservedBytes = nextReservedBytes;
+ this.activeByKind[kind] += 1;
+ this.reservedByKind[kind] += reservedBytes;
+ let released = false;
+
+ return {
+ acquired: true,
+ lease: {
+ kind,
+ reservedBytes,
+ release: () => {
+ if (released) return;
+ released = true;
+ this.activeStreams = Math.max(0, this.activeStreams - 1);
+ this.reservedBytes = Math.max(0, this.reservedBytes - reservedBytes);
+ this.activeByKind[kind] = Math.max(0, this.activeByKind[kind] - 1);
+ this.reservedByKind[kind] = Math.max(0, this.reservedByKind[kind] - reservedBytes);
+ },
+ },
+ };
+ }
+
+ /** Returns current usage and configured limits for diagnostics and tests. */
+ snapshot(): DetachedStreamBudgetSnapshot {
+ return {
+ activeStreams: this.activeStreams,
+ reservedBytes: this.reservedBytes,
+ activeByKind: { ...this.activeByKind },
+ reservedByKind: { ...this.reservedByKind },
+ limits: { ...this.resolveLimits() },
+ };
+ }
+}
+
+const DETACHED_STREAM_BUDGET_SYMBOL = Symbol.for("cch.detachedStreamBudget");
+
+/** Resolves configured detached-stream limits, falling back if env parsing fails. */
+export function resolveDetachedStreamBudgetLimits(
+ readEnv: () => ReturnType = getEnvConfig
+): DetachedStreamBudgetLimits {
+ try {
+ const env = readEnv();
+ return {
+ maxConcurrency:
+ env.DETACHED_STREAM_MAX_CONCURRENCY ?? DEFAULT_DETACHED_STREAM_MAX_CONCURRENCY,
+ maxReservedBytes: env.DETACHED_STREAM_BUDGET_BYTES ?? DEFAULT_DETACHED_STREAM_BUDGET_BYTES,
+ meteringReserveBytes:
+ env.DETACHED_STREAM_METERING_RESERVE_BYTES ??
+ DEFAULT_DETACHED_STREAM_METERING_RESERVE_BYTES,
+ };
+ } catch {
+ return {
+ maxConcurrency: DEFAULT_DETACHED_STREAM_MAX_CONCURRENCY,
+ maxReservedBytes: DEFAULT_DETACHED_STREAM_BUDGET_BYTES,
+ meteringReserveBytes: DEFAULT_DETACHED_STREAM_METERING_RESERVE_BYTES,
+ };
+ }
+}
+
+function getDetachedStreamBudget(): DetachedStreamBudget {
+ const globalState = globalThis as typeof globalThis & {
+ [DETACHED_STREAM_BUDGET_SYMBOL]?: DetachedStreamBudget;
+ };
+ globalState[DETACHED_STREAM_BUDGET_SYMBOL] ??= new DetachedStreamBudget(
+ resolveDetachedStreamBudgetLimits
+ );
+ return globalState[DETACHED_STREAM_BUDGET_SYMBOL];
+}
+
+/** Acquires a weighted detached-stream lease from the process budget. */
+export function acquireDetachedStreamLease(
+ kind: DetachedStreamLeaseKind,
+ reservedBytes: number
+): DetachedStreamAcquireResult {
+ return getDetachedStreamBudget().tryAcquire(kind, reservedBytes);
+}
+
+/** Returns the singleton detached-stream budget snapshot. */
+export function getDetachedStreamBudgetSnapshot(): DetachedStreamBudgetSnapshot {
+ return getDetachedStreamBudget().snapshot();
+}
diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts
index 84bc26942..93118f906 100644
--- a/src/app/v1/_lib/proxy/forwarder.ts
+++ b/src/app/v1/_lib/proxy/forwarder.ts
@@ -163,6 +163,8 @@ function decodeRequestBodyAsJson(body: BodyInit | undefined): Record();
let launchedProviderCount = 0;
let settled = false;
@@ -4453,6 +4461,13 @@ export class ProxyForwarder {
// 不取消连接:读到流自然结束(或超时/容量上限)后,复用赢家相同的计费链,
// 把费用异步累加回原请求行。幂等(loserBillingStarted 守卫),失败静默。
const startLoserBilling = (attempt: StreamingHedgeAttempt) => {
+ if (typeof session.shouldBillHedgeLosers === "function" && !session.shouldBillHedgeLosers()) {
+ const cancel = attempt.reader?.cancel("high_concurrency_loser_billing_disabled");
+ cancel?.catch(() => undefined);
+ releaseAttemptAgent(attempt);
+ return;
+ }
+
if (attempt.loserBillingStarted) return;
attempt.loserBillingStarted = true;
@@ -4777,6 +4792,8 @@ export class ProxyForwarder {
// 「首个有效内容帧」。
// 级联阈值计时器保持不动——内容慢的 attempt 不提交,自动触发下一候选竞速。
const hedgeGateFamily =
+ (typeof session.shouldRunStreamContentGate !== "function" ||
+ session.shouldRunStreamContentGate()) &&
(resolveStreamGateMode() === "enforce" || session.replayState?.role === "owner") &&
session.getEndpointPolicy().kind !== "raw_passthrough"
? mapProviderTypeToFamily(attempt.provider.providerType)
@@ -5434,7 +5451,10 @@ export class ProxyForwarder {
// Discovery uses the same opt-in loser billing switch as legacy Hedge. The
// attempt is only kept alive after a winner commits when it already has a
// protocol-valid prefix and a readable response body (see cancelLosers).
- const billHedgeLosers = settings.billHedgeLosers === true && session.messageContext?.id != null;
+ const billHedgeLosers =
+ (typeof session.shouldBillHedgeLosers !== "function" || session.shouldBillHedgeLosers()) &&
+ settings.billHedgeLosers === true &&
+ session.messageContext?.id != null;
const coordinator = new DiscoveryCoordinator({ concurrency, maxRounds });
const discoveryMetrics = new DiscoveryRequestMetrics(
{
@@ -8290,7 +8310,7 @@ export class ProxyForwarder {
// ⭐ 立即为 undici body 添加错误处理,防止 uncaughtException
// 必须在任何其他操作之前设置,否则 ECONNRESET 等错误会导致 uncaughtException
const rawBody = undiciRes.body as Readable;
- rawBody.on("error", (err) => {
+ rawBody.once("error", (err) => {
const code = (err as NodeJS.ErrnoException).code;
// 客户端/上游断连是高频路径事件,降级为 debug 以减少噪音
// 集合需与下方 streamPipeline 回调中的 isExpectedDisconnect 保持一致
@@ -8307,6 +8327,17 @@ export class ProxyForwarder {
error: err.message,
errorCode: code,
});
+ // Undici normally auto-destroys BodyReadable instances, but custom
+ // dispatchers and HTTP/2 abort races can surface an error before that
+ // teardown reaches the wrapper. Keep this cleanup idempotent so the
+ // raw body cannot remain paused with its socket/backing store retained.
+ if (!rawBody.destroyed) {
+ try {
+ rawBody.destroy(err);
+ } catch {
+ // ignore
+ }
+ }
});
// 构建响应头
diff --git a/src/app/v1/_lib/proxy/message-service.test.ts b/src/app/v1/_lib/proxy/message-service.test.ts
index d46e26cb0..fc1f49412 100644
--- a/src/app/v1/_lib/proxy/message-service.test.ts
+++ b/src/app/v1/_lib/proxy/message-service.test.ts
@@ -9,7 +9,11 @@ vi.mock("@/repository/message", () => ({
import { ProxyMessageService } from "./message-service";
-function createSession(providerType: string, message: Record) {
+function createSession(
+ providerType: string,
+ message: Record,
+ endpoint: string = "/v1/responses"
+) {
const specialSettings: NonNullable> = [];
const setMessageContext = vi.fn();
const session = {
@@ -28,8 +32,8 @@ function createSession(providerType: string, message: Record) {
sessionId: "session-1",
userAgent: "codex_cli_rs/1.0.0",
clientIp: "127.0.0.1",
- getEndpoint: () => "/v1/responses",
- getManagedEndpoint: () => "/v1/responses",
+ getEndpoint: () => endpoint,
+ getManagedEndpoint: () => endpoint,
getOriginalModel: () => "gpt-5",
setOriginalModel: vi.fn(),
getSpecialSettings: () => (specialSettings.length > 0 ? specialSettings : null),
@@ -139,4 +143,123 @@ describe("ProxyMessageService Codex reasoning effort audit", () => {
expect.objectContaining({ endpoint: "/v1/responses/compact" })
);
});
+
+ test("openai-compatible chat/completions 顶层 reasoning_effort 保存审计", async () => {
+ const { session, specialSettings } = createSession(
+ "openai-compatible",
+ { model: "gpt-5.5", messages: [], reasoning_effort: "high" },
+ "/v1/chat/completions"
+ );
+
+ await ProxyMessageService.ensureContext(session);
+
+ expect(specialSettings).toContainEqual({
+ type: "openai_reasoning_effort",
+ scope: "request",
+ hit: true,
+ effort: "high",
+ source: "reasoning_effort",
+ });
+ expect(createMessageRequestMock).toHaveBeenCalledWith(
+ expect.objectContaining({ special_settings: specialSettings })
+ );
+ });
+
+ test("openai-compatible chat/completions 嵌套 reasoning.effort 保存审计", async () => {
+ const { session, specialSettings } = createSession(
+ "openai-compatible",
+ { model: "gpt-5.5", messages: [], reasoning: { effort: "low" } },
+ "/v1/chat/completions"
+ );
+
+ await ProxyMessageService.ensureContext(session);
+
+ expect(specialSettings).toContainEqual({
+ type: "openai_reasoning_effort",
+ scope: "request",
+ hit: true,
+ effort: "low",
+ source: "reasoning.effort",
+ });
+ });
+
+ test("openai-compatible 顶层与嵌套不一致时以顶层为准", async () => {
+ const { session, specialSettings } = createSession(
+ "openai-compatible",
+ {
+ model: "gpt-5.5",
+ messages: [],
+ reasoning_effort: "high",
+ reasoning: { effort: "low" },
+ },
+ "/v1/chat/completions"
+ );
+
+ await ProxyMessageService.ensureContext(session);
+
+ expect(specialSettings).toContainEqual(
+ expect.objectContaining({ effort: "high", source: "reasoning_effort" })
+ );
+ });
+
+ test("openai-compatible 非 chat/completions 端点不写入思考强度审计", async () => {
+ const { session, specialSettings } = createSession("openai-compatible", {
+ model: "gpt-5.5",
+ messages: [],
+ reasoning_effort: "high",
+ });
+
+ await ProxyMessageService.ensureContext(session);
+
+ expect(specialSettings).toEqual([]);
+ });
+
+ test("openai-compatible chat/completions 尾斜杠变体仍保存审计", async () => {
+ const { session, specialSettings } = createSession(
+ "openai-compatible",
+ { model: "gpt-5.5", messages: [], reasoning_effort: "high" },
+ "/v1/chat/completions/"
+ );
+
+ await ProxyMessageService.ensureContext(session);
+
+ expect(specialSettings).toContainEqual({
+ type: "openai_reasoning_effort",
+ scope: "request",
+ hit: true,
+ effort: "high",
+ source: "reasoning_effort",
+ });
+ });
+
+ test("openai-compatible 请求缺少 effort 时不写入空审计", async () => {
+ const { session, specialSettings } = createSession(
+ "openai-compatible",
+ { model: "gpt-5.5", messages: [] },
+ "/v1/chat/completions"
+ );
+
+ await ProxyMessageService.ensureContext(session);
+
+ expect(specialSettings).toEqual([]);
+ });
+
+ test("复用已有 openai 思考强度审计,避免重复记录", async () => {
+ const { session, specialSettings } = createSession(
+ "openai-compatible",
+ { model: "gpt-5.5", messages: [], reasoning_effort: "high" },
+ "/v1/chat/completions"
+ );
+ specialSettings.push({
+ type: "openai_reasoning_effort",
+ scope: "request",
+ hit: true,
+ effort: "high",
+ source: "reasoning_effort",
+ });
+
+ await ProxyMessageService.ensureContext(session);
+
+ expect(specialSettings).toHaveLength(1);
+ });
});
diff --git a/src/app/v1/_lib/proxy/message-service.ts b/src/app/v1/_lib/proxy/message-service.ts
index b891f902a..fb6907e38 100644
--- a/src/app/v1/_lib/proxy/message-service.ts
+++ b/src/app/v1/_lib/proxy/message-service.ts
@@ -1,5 +1,7 @@
+import { normalizeEndpointPath, V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths";
import { extractAnthropicEffortFromRequestBody } from "@/lib/utils/anthropic-effort";
import { extractCodexReasoningEffortFromRequestBody } from "@/lib/utils/codex-reasoning-effort";
+import { extractOpenAIReasoningEffortFromRequestBody } from "@/lib/utils/openai-reasoning-effort";
import { createMessageRequest } from "@/repository/message";
import type { ProxySession } from "./session";
@@ -75,6 +77,30 @@ export class ProxyMessageService {
}
}
+ // openai-compatible 供应商的 chat/completions 请求:解析并记录思考强度审计。
+ // 兼容顶层 reasoning_effort(OpenAI 官方及多数 openai-compatible 供应商)与嵌套
+ // reasoning.effort(OpenRouter / Ollama / Vercel AI Gateway 等),见 openai-reasoning-effort。
+ const hasOpenAIReasoningEffortAudit = session
+ .getSpecialSettings()
+ ?.some((setting) => setting.type === "openai_reasoning_effort");
+
+ if (
+ provider.providerType === "openai-compatible" &&
+ normalizeEndpointPath(endpoint ?? "") === V1_ENDPOINT_PATHS.CHAT_COMPLETIONS &&
+ !hasOpenAIReasoningEffortAudit
+ ) {
+ const extraction = extractOpenAIReasoningEffortFromRequestBody(session.request.message);
+ if (extraction) {
+ session.addSpecialSetting({
+ type: "openai_reasoning_effort",
+ scope: "request",
+ hit: true,
+ effort: extraction.effort,
+ source: extraction.source,
+ });
+ }
+ }
+
const messageRequest = await createMessageRequest({
provider_id: provider.id,
user_id: authState.user.id,
diff --git a/src/app/v1/_lib/proxy/node-stream-to-web.test.ts b/src/app/v1/_lib/proxy/node-stream-to-web.test.ts
index 6ef589232..da5e3b9ef 100644
--- a/src/app/v1/_lib/proxy/node-stream-to-web.test.ts
+++ b/src/app/v1/_lib/proxy/node-stream-to-web.test.ts
@@ -188,6 +188,7 @@ describe("nodeStreamToWebStreamSafe", () => {
queueMicrotask(() => node.emit("error", boom));
await expect(reader.read()).rejects.toThrow("boom");
+ await new Promise((resolve) => setImmediate(resolve));
// After error settles, listeners must be detached
expect(node.listenerCount("data")).toBe(0);
expect(node.listenerCount("end")).toBe(0);
@@ -195,6 +196,29 @@ describe("nodeStreamToWebStreamSafe", () => {
expect(node.listenerCount("error")).toBe(0);
});
+ it("destroys the underlying source when it emits an error", async () => {
+ const node = new Readable({
+ read() {
+ // Keep the source open until the explicit error below.
+ },
+ });
+ const web = nodeStreamToWebStreamSafe(node, 1, "test");
+ const reader = web.getReader();
+ const uncaughtSpy = vi.fn();
+ process.once("uncaughtException", uncaughtSpy);
+
+ const boom = new Error("destroy-after-error");
+ const pendingRead = reader.read();
+ node.emit("error", boom);
+
+ await expect(pendingRead).rejects.toBe(boom);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ process.removeListener("uncaughtException", uncaughtSpy);
+ expect(node.destroyed).toBe(true);
+ expect(uncaughtSpy).not.toHaveBeenCalled();
+ });
+
it("rejects when the source closes after conversion without reaching EOF", async () => {
const node = new Readable({
read() {
diff --git a/src/app/v1/_lib/proxy/node-stream-to-web.ts b/src/app/v1/_lib/proxy/node-stream-to-web.ts
index 82b3bbff8..d30ebeb83 100644
--- a/src/app/v1/_lib/proxy/node-stream-to-web.ts
+++ b/src/app/v1/_lib/proxy/node-stream-to-web.ts
@@ -120,6 +120,17 @@ export function nodeStreamToWebStreamSafe(
errorName: err.name,
});
detach(nodeStream);
+ if (!nodeStream.destroyed) {
+ // A source error settles the Web stream but does not invoke its
+ // cancel algorithm. Destroy the Node/Undici body explicitly so a
+ // paused transport cannot retain its socket and backing store.
+ installPendingDestroyErrorGuard(nodeStream);
+ try {
+ nodeStream.destroy(err);
+ } catch {
+ // ignore
+ }
+ }
try {
controller.error(err);
} catch {
diff --git a/src/app/v1/_lib/proxy/replay/replay-guard.ts b/src/app/v1/_lib/proxy/replay/replay-guard.ts
index 6b7415a9a..bb69c8269 100644
--- a/src/app/v1/_lib/proxy/replay/replay-guard.ts
+++ b/src/app/v1/_lib/proxy/replay/replay-guard.ts
@@ -39,6 +39,10 @@ const ATTACH_MAX_WAIT_MS = 10 * 60 * 1000;
export class ProxyReplayGuard {
static async ensure(session: ProxySession): Promise {
+ if (typeof session.shouldUseRequestReplay === "function" && !session.shouldUseRequestReplay()) {
+ return null;
+ }
+
try {
// guard 位于 provider 步骤之前:先刷新运行时覆写快照,管理端刚保存的
// replayEnabled 首个请求即生效(底层系统设置缓存有 TTL,常态为缓存命中)
diff --git a/src/app/v1/_lib/proxy/replay/replay-spool.ts b/src/app/v1/_lib/proxy/replay/replay-spool.ts
index 4582a06d4..41ce0f65b 100644
--- a/src/app/v1/_lib/proxy/replay/replay-spool.ts
+++ b/src/app/v1/_lib/proxy/replay/replay-spool.ts
@@ -44,7 +44,10 @@ function serializeDurablePersistence(operation: () => Promise): Promise
}
export interface ReplaySpoolOptions {
+ /** Shortens the detached window when the spool loses its active write role. */
onInactive?: () => void;
+ /** Runs once after completed, aborted, or disabled cleanup releases the spool. */
+ onTerminal?: () => void;
}
export function getActiveReplaySpoolCount(): number {
@@ -453,6 +456,13 @@ export class ReplaySpool {
this.released = true;
this.clearOwnerHeartbeat();
activeSpoolCount = Math.max(0, activeSpoolCount - 1);
+ try {
+ this.options.onTerminal?.();
+ } catch (error) {
+ logger.debug("[ReplaySpool] terminal callback failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
}
private clearFlushTimer(): void {
@@ -560,6 +570,10 @@ export function createReplaySpoolIfOwner(
delivery: ReplayDelivery = "stream",
options: ReplaySpoolOptions = {}
): ReplaySpool | null {
+ if (typeof session.shouldUseRequestReplay === "function" && !session.shouldUseRequestReplay()) {
+ return null;
+ }
+
const replayState = session.replayState;
if (replayState?.role !== "owner") return null;
const declineOwnership = (): null => {
diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts
index d06c75c65..e218fbb12 100644
--- a/src/app/v1/_lib/proxy/response-handler.ts
+++ b/src/app/v1/_lib/proxy/response-handler.ts
@@ -55,10 +55,20 @@ import type { GeminiResponse } from "../gemini/types";
import { extractActualResponseModelForProvider, extractJsonChunks } from "./actual-response-model";
import { recordAffinityWinner, tombstoneAffinityOnFailure } from "./affinity/affinity-recorder";
import { bindClientAbortListener } from "./client-abort-listener";
+import {
+ CLIENT_ABORT_METER_MAX_RETAINED_BYTES,
+ type ClientAbortMeteringObserver,
+ createClientAbortMeteringObserver,
+} from "./client-abort-metering";
import {
createDemandDrivenResponsePump,
type DemandDrivenResponsePump,
} from "./demand-driven-response-pump";
+import {
+ acquireDetachedStreamLease,
+ type DetachedStreamLease,
+ getDetachedStreamBudgetSnapshot,
+} from "./detached-stream-budget";
import { isDiscoveryProtocolErrorPayload } from "./discovery-validity";
import { isClientAbortError, isTransportError } from "./errors";
import {
@@ -82,6 +92,10 @@ import {
} from "./stream-gate/stream-protocol-observer";
const CLIENT_ABORT_DRAIN_MAX_MS = 60_000;
+const CLIENT_ABORT_DRAIN_RESERVATION_BYTES =
+ 3 * 1024 * 1024 + CLIENT_ABORT_METER_MAX_RETAINED_BYTES;
+const REPLAY_DRAIN_FIXED_OVERHEAD_BYTES = 5 * 1024 * 1024;
+const GEMINI_STREAM_TRANSFORM_MAX_BUFFER_CHARACTERS = 1024 * 1024;
const STREAM_STATS_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
const STREAM_STATS_HEAD_BYTES = 1024 * 1024;
const STREAM_STATS_TAIL_BYTES = STREAM_STATS_MAX_BUFFER_BYTES - STREAM_STATS_HEAD_BYTES;
@@ -93,6 +107,22 @@ function getSessionRequestOwnerKeyId(session: ProxySession): number | undefined
return session.authState?.key?.id ?? session.messageContext?.key?.id ?? undefined;
}
+/** Resolves a conservative Replay reservation for detached payload reconstruction. */
+export function resolveReplayDrainReservationBytes(
+ readEnv: () => ReturnType = getEnvConfig
+): number {
+ let payloadBytes = 8 * 1024 * 1024;
+ try {
+ payloadBytes = readEnv().REPLAY_MAX_PAYLOAD_BYTES;
+ } catch {
+ // Keep a conservative reservation when environment parsing fails.
+ }
+ // ReplaySpool keeps bounded write-back state, then terminal persistence reads
+ // the Redis chunks and joins one payload string. Reserve the payload three
+ // times for chunk strings, the joined string, and UTF-16 expansion.
+ return REPLAY_DRAIN_FIXED_OVERHEAD_BYTES + payloadBytes * 3;
+}
+
type BoundedStreamTextSnapshot = {
text: string;
truncated: boolean;
@@ -734,6 +764,17 @@ export class BoundedStreamTextAccumulator {
return this.finishedSnapshot;
}
+ discardRetainedBytes(): void {
+ this.headChunks.length = 0;
+ this.tailChunks.length = 0;
+ this.tailChunkBytes.length = 0;
+ this.headBufferedBytes = 0;
+ this.tailBufferedBytes = 0;
+ this.tailHead = 0;
+ this.tailMode = false;
+ this.finishedSnapshot = null;
+ }
+
private createSnapshotText(): string {
if (!this.tailMode) {
return this.decodeChunks(this.headChunks, 0, this.headBufferedBytes);
@@ -1831,7 +1872,12 @@ function finalizeDeferredStreamingFinalizationIfNeeded(
const billHedgeLosers = meta?.billHedgeLosers === true;
const hasDiscoveryBindingIntent =
meta?.bindingIntent === "create" || meta?.bindingIntent === "renew";
- const completionInspection = inspectStreamCompletion(allContent, session.originalFormat);
+ const parseResponseDiagnostics =
+ typeof session.shouldParseResponseDiagnostics !== "function" ||
+ session.shouldParseResponseDiagnostics();
+ const completionInspection = parseResponseDiagnostics
+ ? inspectStreamCompletion(allContent, session.originalFormat)
+ : { hasMarker: false, hasProtocolError: false };
const completionMarkerMissingForBinding =
meta?.requiresCompletionMarkerForBinding === true &&
hasDiscoveryBindingIntent &&
@@ -1914,6 +1960,11 @@ function finalizeDeferredStreamingFinalizationIfNeeded(
: bodyDetected;
let clientAbortGateUsage: FinalizeDeferredStreamingResult["clientAbortGateUsage"];
const clientAbortCompleteSuccess = (() => {
+ if (
+ typeof session.shouldRetainClientAbortBilling === "function" &&
+ !session.shouldRetainClientAbortBilling()
+ )
+ return false;
if (!clientAborted || upstreamStatusCode < 200 || upstreamStatusCode >= 300) {
return false;
}
@@ -1951,7 +2002,9 @@ function finalizeDeferredStreamingFinalizationIfNeeded(
let statusCodeInferred = false;
let statusCodeInferenceMatcherId: string | undefined;
if (detected.isError) {
- const inferred = inferUpstreamErrorStatusCodeFromText(allContent);
+ const inferred = parseResponseDiagnostics
+ ? inferUpstreamErrorStatusCodeFromText(allContent)
+ : null;
if (inferred) {
effectiveStatusCode = inferred.statusCode;
statusCodeInferred = true;
@@ -1965,8 +2018,12 @@ function finalizeDeferredStreamingFinalizationIfNeeded(
errorMessage = null;
} else if (streamEndedNormally && upstreamStatusCode >= 400) {
effectiveStatusCode = upstreamStatusCode;
- const upstreamError = detectUpstreamErrorFromSseOrJsonText(allContent);
- errorMessage = upstreamError.isError ? upstreamError.code : `HTTP ${upstreamStatusCode}`;
+ if (parseResponseDiagnostics) {
+ const upstreamError = detectUpstreamErrorFromSseOrJsonText(allContent);
+ errorMessage = upstreamError.isError ? upstreamError.code : `HTTP ${upstreamStatusCode}`;
+ } else {
+ errorMessage = `HTTP ${upstreamStatusCode}`;
+ }
} else if (clientAborted) {
effectiveStatusCode = 499;
errorMessage = "CLIENT_ABORTED";
@@ -1978,9 +2035,12 @@ function finalizeDeferredStreamingFinalizationIfNeeded(
effectiveStatusCode = upstreamStatusCode;
if (upstreamStatusCode >= 400) {
- // 非200错误状态码:解析JSON错误响应
- const detected = detectUpstreamErrorFromSseOrJsonText(allContent);
- errorMessage = detected.isError ? detected.code : `HTTP ${upstreamStatusCode}`;
+ if (parseResponseDiagnostics) {
+ const detected = detectUpstreamErrorFromSseOrJsonText(allContent);
+ errorMessage = detected.isError ? detected.code : `HTTP ${upstreamStatusCode}`;
+ } else {
+ errorMessage = `HTTP ${upstreamStatusCode}`;
+ }
} else {
// 2xx 成功状态码
errorMessage = null;
@@ -3532,9 +3592,26 @@ export class ProxyResponseHandler {
session.getEndpointPolicy().kind === "raw_passthrough"
? null
: mapProviderTypeToFamily(provider.providerType);
- const streamProtocolObserver = nativeStreamProtocolFamily
- ? createStreamProtocolObserver(nativeStreamProtocolFamily)
- : null;
+ let streamProtocolObserver =
+ nativeStreamProtocolFamily &&
+ (typeof session.shouldParseResponseDiagnostics !== "function" ||
+ session.shouldParseResponseDiagnostics())
+ ? createStreamProtocolObserver(nativeStreamProtocolFamily)
+ : null;
+ const clientAbortMeter: ClientAbortMeteringObserver =
+ typeof session.shouldRetainClientAbortBilling !== "function" ||
+ session.shouldRetainClientAbortBilling()
+ ? createClientAbortMeteringObserver(session.originalFormat)
+ : {
+ observe: () => ({ billingComplete: false }),
+ finish: () => ({
+ text: "",
+ billingComplete: false,
+ retainedBytes: 0,
+ skippedOversizedFrames: 0,
+ protocolFailure: null,
+ }),
+ };
let protocolObservedBeforeProcessing = false;
// --- GEMINI STREAM HANDLING ---
@@ -3558,7 +3635,12 @@ export class ProxyResponseHandler {
releaseReplayOwnership(session);
// F1 shadow 遥测:enforce 已在 forwarder 作用于该流量,shadow 观察同样不留盲区
- const passthroughShadowObserver = (() => {
+ let passthroughShadowObserver = (() => {
+ if (
+ typeof session.shouldParseResponseDiagnostics === "function" &&
+ !session.shouldParseResponseDiagnostics()
+ )
+ return null;
if (resolveStreamGateMode() !== "shadow") return null;
if (session.getEndpointPolicy().kind === "raw_passthrough") return null;
const family = mapProviderTypeToFamily(provider.providerType);
@@ -3583,6 +3665,8 @@ export class ProxyResponseHandler {
let abortPassthroughTransport = (_reason: Error) => {};
let passthroughPump: DemandDrivenResponsePump;
let passthroughDrainTimeoutId: ReturnType | null = null;
+ let passthroughClientDetached = false;
+ let passthroughDrainLease: DetachedStreamLease | null = null;
const clearPassthroughDrainTimeout = () => {
if (passthroughDrainTimeoutId) {
clearTimeout(passthroughDrainTimeoutId);
@@ -3590,8 +3674,58 @@ export class ProxyResponseHandler {
}
};
const startPassthroughDrain = (reason?: unknown) => {
+ if (passthroughPump.getState() === "closed") return;
+ if (
+ typeof session.shouldRetainClientAbortBilling === "function" &&
+ !session.shouldRetainClientAbortBilling()
+ ) {
+ passthroughClientDetached = true;
+ const abortError =
+ reason instanceof Error ? reason : new Error("client_detached_high_concurrency");
+ streamTextAccumulator.discardRetainedBytes();
+ streamProtocolObserver = null;
+ passthroughShadowObserver = null;
+ abortPassthroughTransport(abortError);
+ passthroughPump.startDrain(abortError);
+ passthroughPump.cancelSource(abortError);
+ return;
+ }
+ if (passthroughClientDetached) {
+ passthroughPump.startDrain(reason);
+ return;
+ }
+ passthroughClientDetached = true;
+ const admission = acquireDetachedStreamLease(
+ "metering",
+ CLIENT_ABORT_DRAIN_RESERVATION_BYTES
+ );
passthroughPump.startDrain(reason);
+ streamTextAccumulator.discardRetainedBytes();
+ streamProtocolObserver = null;
+ passthroughShadowObserver = null;
+ if (!admission.acquired) {
+ const rejection = new Error(`client_abort_drain_${admission.reason}`);
+ logger.warn("ResponseHandler: Client abort drain rejected by pool", {
+ taskId: `stream-passthrough-${messageContext.id}`,
+ providerId: provider.id,
+ messageId: messageContext.id,
+ reason: admission.reason,
+ budget: getDetachedStreamBudgetSnapshot(),
+ });
+ abortPassthroughTransport(rejection);
+ passthroughPump.cancelSource(rejection);
+ return;
+ }
+ passthroughDrainLease = admission.lease;
+ void passthroughPump.teardown.finally(() => {
+ passthroughDrainLease?.release();
+ passthroughDrainLease = null;
+ });
observePassthroughDrainStart();
+ if (clientAbortMeter.observe(new Uint8Array()).billingComplete) {
+ passthroughPump.finishDrain(new Error("client_abort_metering_complete"));
+ return;
+ }
if (passthroughDrainTimeoutId) return;
passthroughDrainTimeoutId = setTimeout(() => {
passthroughDrainTimeoutId = null;
@@ -3605,10 +3739,14 @@ export class ProxyResponseHandler {
source: response.body,
onReadStart: () => observePassthroughReadStart(),
onChunk: (value) => {
+ const metering = clientAbortMeter.observe(value);
passthroughShadowObserver?.observe(value);
streamProtocolObserver?.observe(value);
- streamTextAccumulator.pushBytes(value);
+ if (!passthroughClientDetached) streamTextAccumulator.pushBytes(value);
observePassthroughChunk(value);
+ if (passthroughClientDetached && metering.billingComplete) {
+ passthroughPump.finishDrain(new Error("client_abort_metering_complete"));
+ }
},
onClientCancel: (reason) => {
startPassthroughDrain(reason);
@@ -3746,6 +3884,18 @@ export class ProxyResponseHandler {
};
const flushAndSnapshot = (): BoundedStreamTextSnapshot => {
+ if (passthroughClientDetached) {
+ const metering = clientAbortMeter.finish();
+ const snapshot: BoundedStreamTextSnapshot = {
+ text: metering.text,
+ truncated: true,
+ totalBytes: streamTextAccumulator.totalByteCount,
+ bufferedBytes: metering.retainedBytes,
+ chunkCount: streamTextAccumulator.chunkCount,
+ };
+ lastStreamTextSnapshot = snapshot;
+ return snapshot;
+ }
const snapshot = streamTextAccumulator.finish();
lastStreamTextSnapshot = snapshot;
return snapshot;
@@ -3765,41 +3915,48 @@ export class ProxyResponseHandler {
AsyncTaskManager.touch(taskId);
};
- const releaseTransportResources = () => {
- if (transportReleased) return;
- transportReleased = true;
- clearPassthroughDrainTimeout();
- cleanupPassthroughClientAbortListener();
- cleanupTaskAbortBinding();
- clearIdleTimer();
- try {
- const wasResponseControllerAborted =
- sessionWithController.responseController?.signal.aborted ?? false;
- const clientAborted = session.clientAbortSignal?.aborted ?? false;
- const shouldClearTimeout =
- responseTimeoutCleared ||
- streamEndedNormally ||
- wasResponseControllerAborted ||
- clientAborted;
- if (shouldClearTimeout) {
- clearResponseTimeoutOnce();
- }
- } catch (error) {
- logger.warn(
- "[ResponseHandler] Gemini passthrough: Failed to clear response timeout",
- {
- taskId,
- providerId: provider.id,
- providerName: provider.name,
- error: error instanceof Error ? error.message : String(error),
+ let transportReleasePromise: Promise | null = null;
+ const releaseTransportResources = async (): Promise => {
+ if (transportReleasePromise) return transportReleasePromise;
+ transportReleasePromise = (async () => {
+ await passthroughPump.teardown;
+ if (transportReleased) return;
+ transportReleased = true;
+ clearPassthroughDrainTimeout();
+ cleanupPassthroughClientAbortListener();
+ cleanupTaskAbortBinding();
+ clearIdleTimer();
+ try {
+ const wasResponseControllerAborted =
+ sessionWithController.responseController?.signal.aborted ?? false;
+ const clientAborted = session.clientAbortSignal?.aborted ?? false;
+ const shouldClearTimeout =
+ responseTimeoutCleared ||
+ streamEndedNormally ||
+ wasResponseControllerAborted ||
+ clientAborted;
+ if (shouldClearTimeout) {
+ clearResponseTimeoutOnce();
}
- );
- }
- releaseSessionAgent(session);
+ } catch (error) {
+ logger.warn(
+ "[ResponseHandler] Gemini passthrough: Failed to clear response timeout",
+ {
+ taskId,
+ providerId: provider.id,
+ providerName: provider.name,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ );
+ }
+ releaseSessionAgent(session);
+ })();
+ return transportReleasePromise;
};
try {
const pumpCompletion = await passthroughPump.completion;
+ await passthroughPump.teardown;
streamEndedNormally = pumpCompletion.streamEndedNormally;
pumpClientAborted = pumpCompletion.clientAborted;
if (pumpCompletion.error) throw pumpCompletion.error;
@@ -3809,7 +3966,7 @@ export class ProxyResponseHandler {
const allContent = streamSnapshot.text;
const clientAborted =
pumpClientAborted || (session.clientAbortSignal?.aborted ?? false);
- releaseTransportResources();
+ await releaseTransportResources();
// 存储响应体到 Redis(5分钟过期)
if (
@@ -3839,6 +3996,7 @@ export class ProxyResponseHandler {
// 使用共享的统计处理方法
const duration = Date.now() - session.startTime;
terminalFinalizationStarted = true;
+ const meteringSnapshot = passthroughClientDetached ? clientAbortMeter.finish() : null;
const finalized = await finalizeDeferredStreamingFinalizationIfNeeded(
session,
allContent,
@@ -3846,7 +4004,15 @@ export class ProxyResponseHandler {
streamEndedNormally,
clientAborted,
discoveryLeaseLifecycle,
- streamProtocolObserver?.finish() ?? null,
+ streamProtocolObserver?.finish() ??
+ (meteringSnapshot
+ ? {
+ sawContent: false,
+ sawTerminal: meteringSnapshot.billingComplete,
+ observationIncomplete: meteringSnapshot.skippedOversizedFrames > 0,
+ failure: meteringSnapshot.protocolFailure,
+ }
+ : null),
abortReason
);
latestCommitSideEffects = finalized.commitSideEffects;
@@ -3911,6 +4077,7 @@ export class ProxyResponseHandler {
clearIdleTimer();
const allContent = flushAndJoin();
const duration = Date.now() - session.startTime;
+ const meteringSnapshot = passthroughClientDetached ? clientAbortMeter.finish() : null;
const finalized = await finalizeDeferredStreamingFinalizationIfNeeded(
session,
@@ -3919,7 +4086,14 @@ export class ProxyResponseHandler {
false,
clientAborted,
discoveryLeaseLifecycle,
- null,
+ meteringSnapshot
+ ? {
+ sawContent: false,
+ sawTerminal: meteringSnapshot.billingComplete,
+ observationIncomplete: meteringSnapshot.skippedOversizedFrames > 0,
+ failure: meteringSnapshot.protocolFailure,
+ }
+ : null,
abortReason
);
latestCommitSideEffects = finalized.commitSideEffects;
@@ -3963,7 +4137,7 @@ export class ProxyResponseHandler {
});
}
} finally {
- releaseTransportResources();
+ await releaseTransportResources();
if (!commitSideEffectsScheduled) {
void (async () => {
await latestFinalizeAttemptResources?.();
@@ -4039,6 +4213,10 @@ export class ProxyResponseHandler {
const lines = buffer.split("\n");
// Keep the last line in buffer as it might be incomplete
buffer = lines.pop() || "";
+ if (buffer.length > GEMINI_STREAM_TRANSFORM_MAX_BUFFER_CHARACTERS) {
+ buffer = "";
+ throw new Error("Gemini stream line exceeded transform buffer limit");
+ }
for (const line of lines) {
const trimmedLine = line.trim();
@@ -4091,6 +4269,14 @@ export class ProxyResponseHandler {
// 让上游响应在客户端断开后继续被缓存直至完成;非 replay 请求维持 60s 现状。
let clientAbortDrainTimeoutMs = CLIENT_ABORT_DRAIN_MAX_MS;
let responsePump: DemandDrivenResponsePump | null = null;
+ let clientAbortDrainLease: DetachedStreamLease | null = null;
+ let clientAbortReplayLease: DetachedStreamLease | null = null;
+ let clientAbortDrainMode: "metering" | "replay" | "rejected" | null = null;
+ let clientAbortFinalizing = false;
+ let streamReplayCompletionScheduled = false;
+ let shadowGateObserver: ReturnType | null = null;
+ let replaySpool: ReturnType = null;
+ let downgradeDetachedReplay = () => {};
// 提升 idleTimeoutId 到外部作用域,以便客户端断开时能清除
let idleTimeoutId: NodeJS.Timeout | null = null;
@@ -4139,11 +4325,14 @@ export class ProxyResponseHandler {
clientAbortDrainTimeoutId.unref?.();
};
const capInactiveReplayDrainWindow = () => {
- if (clientAbortDrainTimeoutMs <= CLIENT_ABORT_DRAIN_MAX_MS) return;
- clientAbortDrainTimeoutMs = CLIENT_ABORT_DRAIN_MAX_MS;
- if (clientAbortDrainStartedAt === null) return;
- const elapsedMs = Date.now() - clientAbortDrainStartedAt;
- scheduleClientAbortDrainTimeout(CLIENT_ABORT_DRAIN_MAX_MS - elapsedMs);
+ if (clientAbortDrainTimeoutMs > CLIENT_ABORT_DRAIN_MAX_MS) {
+ clientAbortDrainTimeoutMs = CLIENT_ABORT_DRAIN_MAX_MS;
+ if (clientAbortDrainStartedAt !== null) {
+ const elapsedMs = Date.now() - clientAbortDrainStartedAt;
+ scheduleClientAbortDrainTimeout(CLIENT_ABORT_DRAIN_MAX_MS - elapsedMs);
+ }
+ }
+ downgradeDetachedReplay();
};
const clearIdleTimer = () => {
if (idleTimeoutId) {
@@ -4208,15 +4397,120 @@ export class ProxyResponseHandler {
};
let cleanupClientAbortListener = () => {};
let clientDetachHandled = false;
+ const releaseDetachedLease = (lease = clientAbortDrainLease) => {
+ lease?.release();
+ if (clientAbortDrainLease === lease) clientAbortDrainLease = null;
+ };
+ const releaseDetachedReplayLease = (lease = clientAbortReplayLease) => {
+ lease?.release();
+ if (clientAbortReplayLease === lease) clientAbortReplayLease = null;
+ };
+ const rejectDetachedDrain = (reason: string) => {
+ clientAbortDrainMode = "rejected";
+ releaseDetachedLease();
+ const rejection = new Error(`client_abort_drain_${reason}`);
+ logger.warn("ResponseHandler: Detached stream rejected by budget", {
+ taskId,
+ providerId: provider.id,
+ messageId: messageContext.id,
+ reason,
+ budget: getDetachedStreamBudgetSnapshot(),
+ });
+ responsePump?.startDrain(rejection);
+ try {
+ const sessionWithController = session as typeof session & {
+ responseController?: AbortController;
+ };
+ sessionWithController.responseController?.abort(rejection);
+ } catch {
+ // The pump cancellation below remains authoritative.
+ }
+ responsePump?.cancelSource(rejection);
+ };
+ const acquireMeteringDrain = (replayAbortReason?: string): boolean => {
+ const previousLease = clientAbortDrainLease;
+ clientAbortDrainMode = "metering";
+ releaseDetachedLease(previousLease);
+ if (replayAbortReason && replaySpool && !replaySpool.isTerminal) {
+ void replaySpool.abort(replayAbortReason);
+ }
+
+ const admission = acquireDetachedStreamLease(
+ "metering",
+ CLIENT_ABORT_DRAIN_RESERVATION_BYTES
+ );
+ if (!admission.acquired) {
+ rejectDetachedDrain(admission.reason);
+ return false;
+ }
+
+ const lease = admission.lease;
+ clientAbortDrainLease = lease;
+ const activePump = responsePump;
+ if (activePump) {
+ void activePump.teardown.finally(() => releaseDetachedLease(lease));
+ }
+ return true;
+ };
+ downgradeDetachedReplay = () => {
+ if (!clientDetachHandled || clientAbortDrainMode !== "replay" || clientAbortFinalizing) {
+ return;
+ }
+ logger.info("ResponseHandler: Detached Replay became inactive, using metering drain", {
+ taskId,
+ providerId: provider.id,
+ messageId: messageContext.id,
+ });
+ acquireMeteringDrain();
+ };
const handleClientAbort = (reason?: unknown) => {
if (responsePump?.getState() === "closed") return;
- responsePump?.startDrain(reason ?? "client_detached");
- if (clientDetachHandled) return;
+ if (
+ typeof session.shouldRetainClientAbortBilling === "function" &&
+ !session.shouldRetainClientAbortBilling()
+ ) {
+ clientDetachHandled = true;
+ responsePump?.startDrain(reason ?? "client_detached_high_concurrency");
+ responsePump?.cancelSource(reason ?? "client_detached_high_concurrency");
+ return;
+ }
+ if (clientDetachHandled) {
+ responsePump?.startDrain(reason ?? "client_detached");
+ return;
+ }
clientDetachHandled = true;
+ const activeReplaySpool = replaySpool && !replaySpool.isTerminal ? replaySpool : null;
+ if (activeReplaySpool) {
+ const replayAdmission = acquireDetachedStreamLease(
+ "replay",
+ resolveReplayDrainReservationBytes()
+ );
+ if (replayAdmission.acquired) {
+ clientAbortDrainMode = "replay";
+ clientAbortReplayLease = replayAdmission.lease;
+ } else {
+ logger.info("ResponseHandler: Detached Replay budget unavailable, using metering drain", {
+ taskId,
+ providerId: provider.id,
+ messageId: messageContext.id,
+ reason: replayAdmission.reason,
+ budget: getDetachedStreamBudgetSnapshot(),
+ });
+ if (!acquireMeteringDrain(`detached_replay_${replayAdmission.reason}`)) return;
+ }
+ } else if (!acquireMeteringDrain()) {
+ return;
+ }
+
+ responsePump?.startDrain(reason ?? "client_detached");
+ streamTextAccumulator.discardRetainedBytes();
+ streamProtocolObserver = null;
+ shadowGateObserver = null;
logger.debug("ResponseHandler: Client disconnected, cleaning up", {
taskId,
providerId: provider.id,
messageId: messageContext.id,
+ drainMode: clientAbortDrainMode,
});
// Do not cancel internal accounting on pure client disconnect. Transfer
// ownership to the bounded background drain so terminal usage can still
@@ -4227,6 +4521,9 @@ export class ProxyResponseHandler {
}
clientAbortDrainStartedAt = Date.now();
scheduleClientAbortDrainTimeout(clientAbortDrainTimeoutMs);
+ if (clientAbortMeter.observe(new Uint8Array()).billingComplete) {
+ responsePump?.finishDrain(new Error("client_abort_metering_complete"));
+ }
};
// 统计/结算只保留有界的“头 + 尾”文本快照,避免长流式响应把进程堆撑满。
@@ -4239,6 +4536,17 @@ export class ProxyResponseHandler {
// 静默一直等到 60s drain 总上限。
const flushAndJoin = (): string => {
+ if (clientDetachHandled) {
+ const metering = clientAbortMeter.finish();
+ lastStreamTextSnapshot = {
+ text: metering.text,
+ truncated: true,
+ totalBytes: streamTextAccumulator.totalByteCount,
+ bufferedBytes: metering.retainedBytes,
+ chunkCount: streamTextAccumulator.chunkCount,
+ };
+ return metering.text;
+ }
const snapshot = streamTextAccumulator.finish();
lastStreamTextSnapshot = snapshot;
return snapshot.text;
@@ -4340,10 +4648,26 @@ export class ProxyResponseHandler {
abortReason?: string
): Promise => {
if (streamFinalizationPromise) return streamFinalizationPromise;
+ if (clientDetachHandled) clientAbortFinalizing = true;
streamFinalizationPromise = (async () => {
const finalizationDeadlineAtMs = Date.now() + STREAM_FINALIZATION_MAX_MS;
const awaitFinalization = (promise: Promise): Promise =>
raceWithDeadline(promise, finalizationDeadlineAtMs, "stream_finalization_timeout");
+ const detachedProtocolObservation: StreamProtocolObservation | null = (() => {
+ if (
+ typeof session.shouldParseResponseDiagnostics === "function" &&
+ !session.shouldParseResponseDiagnostics()
+ )
+ return null;
+ if (!clientDetachHandled || streamProtocolObserver) return null;
+ const metering = clientAbortMeter.finish();
+ return {
+ sawContent: false,
+ sawTerminal: metering.billingComplete,
+ observationIncomplete: metering.skippedOversizedFrames > 0,
+ failure: metering.protocolFailure,
+ };
+ })();
const finalized = finalizeDeferredStreamingFinalizationIfNeeded(
session,
allContent,
@@ -4351,7 +4675,7 @@ export class ProxyResponseHandler {
streamEndedNormally,
clientAborted,
discoveryLeaseLifecycle,
- streamProtocolObserver?.finish() ?? null,
+ streamProtocolObserver?.finish() ?? detachedProtocolObservation,
abortReason
);
latestStreamCommitSideEffects = finalized.commitSideEffects
@@ -4432,6 +4756,8 @@ export class ProxyResponseHandler {
| undefined;
if (
provider.providerType === "codex" &&
+ (typeof session.shouldParseResponseDiagnostics !== "function" ||
+ session.shouldParseResponseDiagnostics()) &&
effectiveStatusCode >= 200 &&
effectiveStatusCode < 300 &&
session.sessionId &&
@@ -4641,6 +4967,8 @@ export class ProxyResponseHandler {
// F2 终态屏障:replay completed 只能出现在计费落库(onCommitted)之后;
// 任何失败终态(假 200/中断/非 2xx)立即 abort,绝不被已完成重放命中。
if (replaySpool) {
+ const activeReplaySpool = replaySpool;
+ const detachedReplayLease = clientAbortReplayLease;
const isReplayableSuccess =
finalized.commitSideEffects !== undefined &&
effectiveStatusCode >= 200 &&
@@ -4648,19 +4976,23 @@ export class ProxyResponseHandler {
!finalized.replayIneligibleReason &&
hasStreamCompletionMarker(allContent, session.originalFormat);
if (isReplayableSuccess) {
+ streamReplayCompletionScheduled = true;
postTerminalSideEffects.push(async () => {
try {
- await replaySpool.completeAfterBilling(messageContext.id);
+ await activeReplaySpool.completeAfterBilling(messageContext.id);
} catch (err) {
logger.warn("[ResponseHandler] Replay spool completion failed:", { error: err });
}
});
} else {
- void replaySpool.abort(
- finalized.replayIneligibleReason ??
- streamErrorMessage ??
- `status_${effectiveStatusCode}`
- );
+ streamReplayCompletionScheduled = true;
+ void activeReplaySpool
+ .abort(
+ finalized.replayIneligibleReason ??
+ streamErrorMessage ??
+ `status_${effectiveStatusCode}`
+ )
+ .finally(() => releaseDetachedReplayLease(detachedReplayLease));
}
}
@@ -4744,7 +5076,11 @@ export class ProxyResponseHandler {
// 调用方原样 reject(既有传播语义不变)。
streamFinalizationPromise.catch(() => {
if (replaySpool && !replaySpool.isTerminal) {
- void replaySpool.abort("finalize_error");
+ streamReplayCompletionScheduled = true;
+ const detachedReplayLease = clientAbortReplayLease;
+ void replaySpool
+ .abort("finalize_error")
+ .finally(() => releaseDetachedReplayLease(detachedReplayLease));
}
});
return streamFinalizationPromise;
@@ -4752,7 +5088,12 @@ export class ProxyResponseHandler {
// F1 shadow 模式:旁路逐帧分类,记录「首非空字节 vs 首有效内容」的分歧与延迟差,
// 不缓冲、不 failover,仅用于 enforce 灰度前评估误判率。
- const shadowGateObserver = (() => {
+ shadowGateObserver = (() => {
+ if (
+ typeof session.shouldParseResponseDiagnostics === "function" &&
+ !session.shouldParseResponseDiagnostics()
+ )
+ return null;
if (resolveStreamGateMode() !== "shadow") return null;
if (session.getEndpointPolicy().kind === "raw_passthrough") return null;
const family = mapProviderTypeToFamily(provider.providerType);
@@ -4766,8 +5107,11 @@ export class ProxyResponseHandler {
// F2 owner spool:guard 阶段已抢到 owner 租约的请求,把客户端可见字节
// write-behind 喂入 Redis 热层,供并发/断线的相同请求 attach 跟尾。
- const replaySpool = createReplaySpoolIfOwner(session, response, "stream", {
+ replaySpool = createReplaySpoolIfOwner(session, response, "stream", {
onInactive: capInactiveReplayDrainWindow,
+ onTerminal: () => {
+ releaseDetachedReplayLease();
+ },
});
if (replaySpool) {
try {
@@ -4780,20 +5124,25 @@ export class ProxyResponseHandler {
const observeChunk = (value: Uint8Array) => {
const chunkSize = value.length;
clearIdleTimer();
- streamTextAccumulator.pushBytes(value);
+ const metering = clientAbortMeter.observe(value);
AsyncTaskManager.touch(taskId);
- shadowGateObserver?.observe(value);
- const protocolFailure = protocolObservedBeforeProcessing
- ? null
- : (streamProtocolObserver?.observe(value) ?? null);
- if (protocolFailure && replaySpool && !replaySpool.isTerminal) {
- void replaySpool.abort(
- `stream_protocol_${protocolFailure.verdict}_${
- protocolFailure.afterContent ? "after" : "before"
- }_content`
- );
+ if (!clientDetachHandled) {
+ streamTextAccumulator.pushBytes(value);
+ shadowGateObserver?.observe(value);
+ const protocolFailure = protocolObservedBeforeProcessing
+ ? null
+ : (streamProtocolObserver?.observe(value) ?? null);
+ if (protocolFailure && replaySpool && !replaySpool.isTerminal) {
+ void replaySpool.abort(
+ `stream_protocol_${protocolFailure.verdict}_${
+ protocolFailure.afterContent ? "after" : "before"
+ }_content`
+ );
+ }
+ if (!replaySpool?.isTerminal) replaySpool?.observe(value);
+ } else if (clientAbortDrainMode === "replay" && replaySpool && !replaySpool.isTerminal) {
+ replaySpool.observe(value);
}
- if (!replaySpool?.isTerminal) replaySpool?.observe(value);
logger.trace("ResponseHandler: Upstream stream chunk received", {
taskId,
@@ -4814,6 +5163,9 @@ export class ProxyResponseHandler {
});
}
}
+ if (clientDetachHandled && metering.billingComplete) {
+ responsePump?.finishDrain(new Error("client_abort_metering_complete"));
+ }
};
responsePump = createDemandDrivenResponsePump({
@@ -4850,6 +5202,7 @@ export class ProxyResponseHandler {
const runProcessingTask = async () => {
try {
const pumpCompletion = await activeResponsePump.completion;
+ await activeResponsePump.teardown;
cleanupTaskAbortBinding();
releaseSessionAgent(session);
cleanupResponseControllerAbortListener();
@@ -5150,7 +5503,18 @@ export class ProxyResponseHandler {
cleanupClientAbortListener();
clearClientAbortDrainTimer();
clearIdleTimer(); // 清除静默期计时器(防止泄漏)
+ await activeResponsePump.teardown;
releaseSessionAgent(session);
+ if (clientAbortReplayLease && !streamReplayCompletionScheduled) {
+ const detachedReplayLease = clientAbortReplayLease;
+ if (replaySpool && !replaySpool.isTerminal) {
+ void replaySpool
+ .abort("stream_task_finalized_without_replay_terminal")
+ .finally(() => releaseDetachedReplayLease(detachedReplayLease));
+ } else {
+ releaseDetachedReplayLease(detachedReplayLease);
+ }
+ }
if (!streamCommitSideEffectsScheduled) {
void (async () => {
await latestStreamFinalizeAttemptResources?.();
@@ -6097,6 +6461,12 @@ export async function finalizeHedgeLoserBilling(params: {
billingContext,
} = params;
+ if (
+ typeof loserSession.shouldBillHedgeLosers === "function" &&
+ !loserSession.shouldBillHedgeLosers()
+ )
+ return null;
+
try {
if (isNonBillingUsageEndpoint(loserSession)) {
return null;
diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts
index d0d4443f7..fff19da82 100644
--- a/src/app/v1/_lib/proxy/session.ts
+++ b/src/app/v1/_lib/proxy/session.ts
@@ -582,6 +582,27 @@ export class ProxySession {
return !this.highConcurrencyModeEnabled;
}
+ /** High-concurrency mode disables optional body-heavy coordination features. */
+ shouldUseRequestReplay(): boolean {
+ return !this.highConcurrencyModeEnabled;
+ }
+
+ shouldRunStreamContentGate(): boolean {
+ return !this.highConcurrencyModeEnabled;
+ }
+
+ shouldRetainClientAbortBilling(): boolean {
+ return !this.highConcurrencyModeEnabled;
+ }
+
+ shouldBillHedgeLosers(): boolean {
+ return !this.highConcurrencyModeEnabled;
+ }
+
+ shouldParseResponseDiagnostics(): boolean {
+ return !this.highConcurrencyModeEnabled;
+ }
+
addSpecialSetting(setting: SpecialSetting): void {
this.specialSettings.push(setting);
}
diff --git a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts
index 9a2a9ab6d..4b2ea5b8e 100644
--- a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts
+++ b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts
@@ -1,6 +1,7 @@
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
+import { parseSseBody } from "@/app/v1/_lib/proxy/stream-gate/sse-frames";
import type { Provider } from "@/types/provider";
import {
clearResponsesWsSessionsForTests,
@@ -197,6 +198,39 @@ describe("tryResponsesWebsocketUpstream", () => {
expect(body).toContain('"type":"response.completed"');
});
+ it("preserves pretty-printed multiline JSON as complete SSE events", async () => {
+ const events = [
+ { type: "response.created", response: { id: "resp_pretty" } },
+ { type: "response.output_text.delta", delta: "hello" },
+ {
+ type: "response.completed",
+ response: { id: "resp_pretty", usage: { input_tokens: 2, output_tokens: 1 } },
+ },
+ ];
+ server = await startMockServer((socket) => {
+ socket.on("message", () => {
+ for (const event of events) {
+ socket.send(JSON.stringify(event, null, 2).replace(/\n/g, "\r\n"));
+ }
+ });
+ });
+
+ const result = await tryResponsesWebsocketUpstream({
+ provider: codexProvider(),
+ upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`,
+ upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }),
+ body: { model: "gpt-5.5", input: "hi" },
+ });
+
+ expect("response" in result).toBe(true);
+ if (!("response" in result)) return;
+
+ const body = await collectSseBody(result.response);
+ const frames = parseSseBody(body);
+ expect(frames.map((frame) => JSON.parse(frame.data))).toEqual(events);
+ expect(body.match(/^data:/gm)?.length).toBeGreaterThan(events.length);
+ });
+
it("returns failure when upstream rejects the WS upgrade", async () => {
// Create a plain http server that returns 404 on /v1/responses to simulate
// providers that don't speak WS on that path.
diff --git a/src/app/v1/_lib/responses-ws/upstream-adapter.ts b/src/app/v1/_lib/responses-ws/upstream-adapter.ts
index 45d1e119a..6bf1b7924 100644
--- a/src/app/v1/_lib/responses-ws/upstream-adapter.ts
+++ b/src/app/v1/_lib/responses-ws/upstream-adapter.ts
@@ -776,12 +776,21 @@ export async function tryResponsesWebsocketUpstream(options: {
async start(controller) {
let sawTerminalEvent = false;
- const writeLine = (obj: string) => {
- controller.enqueue(encoder.encode(`data: ${obj}\n\n`));
+ const writeEvent = (payload: string) => {
+ // SSE requires every physical payload line to carry its own `data:`
+ // prefix. Upstream WebSocket implementations may pretty-print JSON;
+ // wrapping that text in a single `data:` line would dispatch only the
+ // opening `{` and make downstream parsers report malformed JSON.
+ const normalizedPayload = payload.replace(/\r\n?/g, "\n");
+ const dataLines = normalizedPayload
+ .split("\n")
+ .map((line) => `data: ${line}`)
+ .join("\n");
+ controller.enqueue(encoder.encode(`${dataLines}\n\n`));
};
const processText = (text: string): boolean => {
- writeLine(text);
+ writeEvent(text);
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed.type === "string" && TERMINAL_EVENT_TYPES.has(parsed.type)) {
diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts
index 2eb8a6097..04475acb4 100644
--- a/src/lib/config/env.schema.ts
+++ b/src/lib/config/env.schema.ts
@@ -29,6 +29,7 @@ const optionalNumber = (schema: z.ZodNumber) =>
/**
* 环境变量验证schema
*/
+// biome-ignore format: preserve the established environment schema layout
export const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
DSN: optionalPreprocessed((val) => {
@@ -192,6 +193,21 @@ export const EnvSchema = z.object({
// 超时后主动断开该输家连接,仅用已收到的内容尝试计费(通常计不出 -> 跳过)。
HEDGE_LOSER_DRAIN_TIMEOUT_MS: z.coerce.number().int().min(1000).default(120_000),
+ // 客户端断线后的 detached stream 使用进程级带权预算。
+ DETACHED_STREAM_MAX_CONCURRENCY: z.coerce.number().int().min(1).max(4096).default(64),
+ DETACHED_STREAM_BUDGET_BYTES: z.coerce
+ .number()
+ .int()
+ .min(3 * 1024 * 1024 + 64 * 1024)
+ .max(1024 * 1024 * 1024)
+ .default(64 * 1024 * 1024),
+ DETACHED_STREAM_METERING_RESERVE_BYTES: z.coerce
+ .number()
+ .int()
+ .min(64 * 1024)
+ .max(1024 * 1024 * 1024)
+ .default(16 * 1024 * 1024),
+
// ===== CCHP 网关移植功能开关 =====
// 流式内容门控:off=关闭;shadow=旁路分类只记录分歧;enforce=首个有效内容帧前缓冲+failover
STREAM_GATE_MODE: z.enum(["off", "shadow", "enforce"]).default("enforce"),
@@ -245,6 +261,14 @@ export const EnvSchema = z.object({
IP_GEO_API_TOKEN: z.string().optional(),
IP_GEO_CACHE_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(3600),
IP_GEO_TIMEOUT_MS: z.coerce.number().int().min(100).max(10000).default(1500),
+}).superRefine((env, context) => {
+ if (env.DETACHED_STREAM_METERING_RESERVE_BYTES > env.DETACHED_STREAM_BUDGET_BYTES) {
+ context.addIssue({
+ code: "custom",
+ path: ["DETACHED_STREAM_METERING_RESERVE_BYTES"],
+ message: "DETACHED_STREAM_METERING_RESERVE_BYTES cannot exceed DETACHED_STREAM_BUDGET_BYTES",
+ });
+ }
});
/**
diff --git a/src/lib/public-status/rebuild-worker.ts b/src/lib/public-status/rebuild-worker.ts
index 95e36e5ee..67d5f9c10 100644
--- a/src/lib/public-status/rebuild-worker.ts
+++ b/src/lib/public-status/rebuild-worker.ts
@@ -1,4 +1,8 @@
import { getRedisClient } from "@/lib/redis";
+import {
+ getProxyRuntimeSettings,
+ resolveRedisRetentionTtlSeconds,
+} from "@/lib/system-settings/proxy-runtime";
import { publishCurrentPublicStatusConfigProjection } from "./config-publisher";
import { readCurrentInternalPublicStatusConfigSnapshot } from "./config-snapshot";
import {
@@ -120,6 +124,7 @@ async function publishPublicStatusProjection(input: {
rollupSampleCount: number;
groups: unknown;
}): Promise {
+ await getProxyRuntimeSettings();
const snapshotKey = buildPublicStatusCurrentSnapshotKey({
intervalMinutes: input.intervalMinutes,
rangeHours: input.rangeHours,
@@ -195,19 +200,19 @@ async function publishPublicStatusProjection(input: {
input.redis,
snapshotKey,
JSON.stringify(snapshotRecord),
- GENERATION_PROJECTION_TTL_SECONDS
+ resolveRedisRetentionTtlSeconds(GENERATION_PROJECTION_TTL_SECONDS)
);
await setWithTtl(
input.redis,
seriesKey,
JSON.stringify(seriesRecord),
- GENERATION_PROJECTION_TTL_SECONDS
+ resolveRedisRetentionTtlSeconds(GENERATION_PROJECTION_TTL_SECONDS)
);
await setWithTtl(
input.redis,
versionedManifestKey,
JSON.stringify(manifestRecord),
- GENERATION_PROJECTION_TTL_SECONDS
+ resolveRedisRetentionTtlSeconds(GENERATION_PROJECTION_TTL_SECONDS)
);
if (typeof input.redis.get === "function") {
let existingCurrentManifest: { configVersion?: string; coveredTo?: string } | null = null;
@@ -221,10 +226,20 @@ async function publishPublicStatusProjection(input: {
}
if (shouldPromoteCurrentManifest(existingCurrentManifest, manifestRecord)) {
- await input.redis.set(currentManifestKey, JSON.stringify(manifestRecord));
+ await setWithTtl(
+ input.redis,
+ currentManifestKey,
+ JSON.stringify(manifestRecord),
+ resolveRedisRetentionTtlSeconds(GENERATION_PROJECTION_TTL_SECONDS)
+ );
}
} else {
- await input.redis.set(currentManifestKey, JSON.stringify(manifestRecord));
+ await setWithTtl(
+ input.redis,
+ currentManifestKey,
+ JSON.stringify(manifestRecord),
+ resolveRedisRetentionTtlSeconds(GENERATION_PROJECTION_TTL_SECONDS)
+ );
}
if (input.redis.del) {
await input.redis.del(snapshotTempKey, seriesTempKey);
diff --git a/src/lib/redis/vendor-type-circuit-breaker-state.ts b/src/lib/redis/vendor-type-circuit-breaker-state.ts
index 1c893db73..efe3578a8 100644
--- a/src/lib/redis/vendor-type-circuit-breaker-state.ts
+++ b/src/lib/redis/vendor-type-circuit-breaker-state.ts
@@ -1,6 +1,10 @@
import "server-only";
import { logger } from "@/lib/logger";
+import {
+ getProxyRuntimeSettings,
+ resolveRedisRetentionTtlSeconds,
+} from "@/lib/system-settings/proxy-runtime";
import type { ProviderType } from "@/types/provider";
import { getRedisClient } from "./client";
@@ -85,10 +89,11 @@ export async function saveVendorTypeCircuitState(
}
try {
+ await getProxyRuntimeSettings();
const key = getStateKey(vendorId, providerType);
const data = serializeState(state);
await redis.hset(key, data);
- await redis.expire(key, STATE_TTL_SECONDS);
+ await redis.expire(key, resolveRedisRetentionTtlSeconds(STATE_TTL_SECONDS));
} catch (error) {
logger.warn("[VendorTypeCircuitState] Failed to save to Redis", {
vendorId,
diff --git a/src/lib/system-settings/proxy-runtime.ts b/src/lib/system-settings/proxy-runtime.ts
index 48c17e3d6..a6e81697b 100644
--- a/src/lib/system-settings/proxy-runtime.ts
+++ b/src/lib/system-settings/proxy-runtime.ts
@@ -27,6 +27,7 @@ export interface ProxyRuntimeSettings {
// 最近一次成功读取的快照;同步热路径消费,异步读取与开机预热负责保鲜。
let lastKnown: ProxyRuntimeSettings | null = null;
+let highConcurrencyModeEnabled = false;
function envReplayDefault(): boolean {
try {
@@ -76,6 +77,7 @@ export async function getProxyRuntimeSettings(): Promise {
cacheEffectivenessEnabled:
settings.cacheEffectivenessEnabled ?? envCacheEffectivenessDefault(),
};
+ highConcurrencyModeEnabled = settings.enableHighConcurrencyMode === true;
return lastKnown;
} catch {
// getCachedSystemSettings 自身已 fail-safe;此处兜底其意外异常
@@ -94,3 +96,8 @@ export function getCachedProxyRuntimeSettings(): ProxyRuntimeSettings | null {
export function isCacheEffectivenessEnabled(): boolean {
return lastKnown?.cacheEffectivenessEnabled ?? envCacheEffectivenessDefault();
}
+
+/** Shrinks long-lived Redis projections while high-concurrency mode is active. */
+export function resolveRedisRetentionTtlSeconds(defaultTtlSeconds: number): number {
+ return highConcurrencyModeEnabled ? Math.min(defaultTtlSeconds, 24 * 60 * 60) : defaultTtlSeconds;
+}
diff --git a/src/lib/utils/openai-reasoning-effort.ts b/src/lib/utils/openai-reasoning-effort.ts
new file mode 100644
index 000000000..82e83cbdd
--- /dev/null
+++ b/src/lib/utils/openai-reasoning-effort.ts
@@ -0,0 +1,84 @@
+import type { OpenAIReasoningEffortFieldSource, SpecialSetting } from "@/types/special-settings";
+
+/** 从 OpenAI Chat Completions 请求体解析出的思考强度信息。 */
+export interface OpenAIReasoningEffortExtraction {
+ effort: string;
+ source: OpenAIReasoningEffortFieldSource;
+}
+
+/**
+ * 过滤非字符串及空白值,避免把无效参数写入审计记录。
+ * 校验用裁剪后值,但返回原始字符串,保证审计记录如实反映客户端发送的字段值。
+ */
+function normalizeOpenAIReasoningEffort(value: unknown): string | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return value.trim().length > 0 ? value : null;
+}
+
+/**
+ * 从 OpenAI Chat Completions 请求体解析思考强度。
+ *
+ * 兼容两种载体(外部兼容性调研结论):
+ * - 顶层标量 `reasoning_effort`:OpenAI 官方 Chat Completions 参数,且是
+ * DeepSeek / xAI / Mistral / Groq / Gemini(OAI-compat) / DeepInfra / OpenRouter /
+ * LiteLLM 等大多数 openai-compatible 供应商接受的 de-facto 标准 → 优先读取。
+ * - 嵌套对象 `reasoning.effort`:OpenRouter / Ollama / Vercel AI Gateway 等
+ * 在 chat/completions 端点也接受该 Responses 风格载体 → 兜底读取。
+ *
+ * 两者同时存在且不一致时以顶层为准:与 OpenRouter 官方声明「reasoning_effort 是
+ * reasoning.effort 的简写、二者不可冲突」的语义一致,避免嵌套值覆盖顶层声明。
+ * 审计层仅做非空校验、原样记录,不做取值白名单(各供应商取值域不同,白名单会丢真实值)。
+ */
+export function extractOpenAIReasoningEffortFromRequestBody(
+ requestBody: unknown
+): OpenAIReasoningEffortExtraction | null {
+ if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) {
+ return null;
+ }
+
+ const record = requestBody as Record;
+
+ const topLevel = normalizeOpenAIReasoningEffort(record.reasoning_effort);
+ if (topLevel) {
+ return { effort: topLevel, source: "reasoning_effort" };
+ }
+
+ const reasoning = record.reasoning;
+ if (!reasoning || typeof reasoning !== "object" || Array.isArray(reasoning)) {
+ return null;
+ }
+
+ const nested = normalizeOpenAIReasoningEffort("effort" in reasoning ? reasoning.effort : null);
+ if (!nested) {
+ return null;
+ }
+
+ return { effort: nested, source: "reasoning.effort" };
+}
+
+/** 从使用记录审计中读取 OpenAI Chat Completions 的思考强度。 */
+export function extractOpenAIReasoningEffortFromSpecialSettings(
+ specialSettings: SpecialSetting[] | null | undefined
+): OpenAIReasoningEffortExtraction | null {
+ if (!Array.isArray(specialSettings)) {
+ return null;
+ }
+
+ for (const setting of specialSettings) {
+ if (setting.type !== "openai_reasoning_effort") {
+ continue;
+ }
+ if (typeof setting.effort !== "string" || setting.effort.trim().length === 0) {
+ continue;
+ }
+ return {
+ effort: setting.effort,
+ source: setting.source,
+ };
+ }
+
+ return null;
+}
diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts
index 61a5e659f..8aef096c9 100644
--- a/src/lib/utils/special-settings.ts
+++ b/src/lib/utils/special-settings.ts
@@ -54,6 +54,8 @@ function buildSettingKey(setting: SpecialSetting): string {
return JSON.stringify([setting.type, setting.hit, setting.effort]);
case "codex_reasoning_effort":
return JSON.stringify([setting.type, setting.hit, setting.effort]);
+ case "openai_reasoning_effort":
+ return JSON.stringify([setting.type, setting.hit, setting.effort, setting.source]);
case "anthropic_cache_ttl_header_override":
return JSON.stringify([setting.type, setting.ttl]);
case "anthropic_context_1m_header_override":
diff --git a/src/lib/utils/thinking-effort.ts b/src/lib/utils/thinking-effort.ts
index aad4d3279..68e2b998e 100644
--- a/src/lib/utils/thinking-effort.ts
+++ b/src/lib/utils/thinking-effort.ts
@@ -1,9 +1,10 @@
import { extractAnthropicEffortInfo } from "@/lib/utils/anthropic-effort";
import { extractCodexReasoningEffortInfo } from "@/lib/utils/codex-reasoning-effort";
+import { extractOpenAIReasoningEffortFromSpecialSettings } from "@/lib/utils/openai-reasoning-effort";
import type { SpecialSetting } from "@/types/special-settings";
-/** 思考强度审计来源:Codex 的 reasoning.effort 或 Anthropic 的 output_config.effort。 */
-export type ThinkingEffortSource = "codex" | "anthropic";
+/** 思考强度审计来源:Codex 的 reasoning.effort、OpenAI chat/completions 或 Anthropic 的 output_config.effort。 */
+export type ThinkingEffortSource = "codex" | "openai" | "anthropic";
/** 任意模型统一后的思考强度展示信息,供列表列与请求详情共用。 */
export interface ThinkingEffortInfo {
@@ -18,8 +19,9 @@ export interface ThinkingEffortInfo {
/**
* 从 specialSettings 中提取任意模型的思考强度。
*
- * 复用 Codex 与 Anthropic 两个提取器并统一返回结构:Codex 审计优先,
- * 其次回退到 Anthropic effort,两者都无则返回 null。
+ * 复用 Codex、OpenAI chat/completions 与 Anthropic 三个提取器并统一返回结构:
+ * Codex 审计优先,其次 OpenAI,最后回退到 Anthropic effort,三者都无则返回 null。
+ * OpenAI chat/completions 目前无供应商级覆写机制,isOverridden 恒为 false。
*/
export function extractThinkingEffortInfo(
specialSettings: SpecialSetting[] | null | undefined
@@ -34,6 +36,16 @@ export function extractThinkingEffortInfo(
};
}
+ const openaiInfo = extractOpenAIReasoningEffortFromSpecialSettings(specialSettings);
+ if (openaiInfo) {
+ return {
+ source: "openai",
+ requestedEffort: openaiInfo.effort,
+ effectiveEffort: openaiInfo.effort,
+ isOverridden: false,
+ };
+ }
+
const anthropicInfo = extractAnthropicEffortInfo(specialSettings);
if (anthropicInfo) {
return {
diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts
index 1b14f417f..0ad2b5940 100644
--- a/src/types/special-settings.ts
+++ b/src/types/special-settings.ts
@@ -17,6 +17,7 @@ export type SpecialSetting =
| ClaudeMetadataUserIdInjectionSpecialSetting
| AnthropicEffortSpecialSetting
| CodexReasoningEffortSpecialSetting
+ | OpenAIReasoningEffortSpecialSetting
| AnthropicCacheTtlHeaderOverrideSpecialSetting
| AnthropicContext1mHeaderOverrideSpecialSetting
| LongContextPricingSpecialSetting
@@ -103,6 +104,25 @@ export type CodexReasoningEffortSpecialSetting = {
effort: string;
};
+/** OpenAI Chat Completions 请求体中思考强度(effort)的载体字段。 */
+export type OpenAIReasoningEffortFieldSource = "reasoning_effort" | "reasoning.effort";
+
+/**
+ * OpenAI Chat Completions reasoning effort 请求参数审计
+ *
+ * 记录 openai-compatible 供应商的 /v1/chat/completions 请求中客户端声明的思考强度;
+ * 兼容顶层 reasoning_effort 与嵌套 reasoning.effort 两种载体(source 标注来源字段),
+ * 便于排查客户端实际用哪种字段表达思考等级。
+ */
+export type OpenAIReasoningEffortSpecialSetting = {
+ type: "openai_reasoning_effort";
+ scope: "request";
+ hit: boolean;
+ effort: string;
+ /** 请求体中的载体字段:顶层 reasoning_effort 或嵌套 reasoning.effort。 */
+ source: OpenAIReasoningEffortFieldSource;
+};
+
/**
* Anthropic 缓存 TTL 相关标头覆写审计
*
diff --git a/tests/configs/detached-stream-budget.config.mts b/tests/configs/detached-stream-budget.config.mts
new file mode 100644
index 000000000..4562695cb
--- /dev/null
+++ b/tests/configs/detached-stream-budget.config.mts
@@ -0,0 +1,20 @@
+import { createCoverageConfig } from "../vitest.base.mts";
+
+export default createCoverageConfig({
+ name: "detached-stream-budget",
+ environment: "node",
+ testFiles: [
+ "src/app/v1/_lib/proxy/client-abort-metering.test.ts",
+ "src/app/v1/_lib/proxy/detached-stream-budget.test.ts",
+ ],
+ sourceFiles: [
+ "src/app/v1/_lib/proxy/client-abort-metering.ts",
+ "src/app/v1/_lib/proxy/detached-stream-budget.ts",
+ ],
+ thresholds: {
+ branches: 80,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
+});
diff --git a/tests/load/issue-1408-replay-oom/sample-container.sh b/tests/load/issue-1408-replay-oom/sample-container.sh
index f372b81b1..efca09ebc 100755
--- a/tests/load/issue-1408-replay-oom/sample-container.sh
+++ b/tests/load/issue-1408-replay-oom/sample-container.sh
@@ -18,10 +18,10 @@ container_cgroup_metric() {
metric="$2"
pid=$(docker inspect -f '{{.State.Pid}}' "$container" 2>/dev/null || true)
case "$pid" in
- *[!0-9]* | "" | 0) return ;;
+ *[!0-9]* | "" | 0) return 0 ;;
esac
cgroup_path=$(awk -F: '$1 == "0" { print $3 }' "/proc/$pid/cgroup" 2>/dev/null || true)
- [ -n "$cgroup_path" ] || return
+ [ -n "$cgroup_path" ] || return 0
metric_path="/sys/fs/cgroup${cgroup_path}/${metric}"
[ -r "$metric_path" ] && tr -d '\n' <"$metric_path"
}
diff --git a/tests/unit/lib/env-detached-stream-budget.test.ts b/tests/unit/lib/env-detached-stream-budget.test.ts
new file mode 100644
index 000000000..7ddb70ecb
--- /dev/null
+++ b/tests/unit/lib/env-detached-stream-budget.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { EnvSchema } from "@/lib/config/env.schema";
+
+describe("EnvSchema - detached stream budget", () => {
+ it("uses bounded defaults", () => {
+ const env = EnvSchema.parse({});
+ expect(env.DETACHED_STREAM_MAX_CONCURRENCY).toBe(64);
+ expect(env.DETACHED_STREAM_BUDGET_BYTES).toBe(64 * 1024 * 1024);
+ expect(env.DETACHED_STREAM_METERING_RESERVE_BYTES).toBe(16 * 1024 * 1024);
+ });
+
+ it("parses explicit budget limits", () => {
+ const env = EnvSchema.parse({
+ DETACHED_STREAM_MAX_CONCURRENCY: "8",
+ DETACHED_STREAM_BUDGET_BYTES: String(4 * 1024 * 1024),
+ DETACHED_STREAM_METERING_RESERVE_BYTES: String(128 * 1024),
+ });
+ expect(env.DETACHED_STREAM_MAX_CONCURRENCY).toBe(8);
+ expect(env.DETACHED_STREAM_BUDGET_BYTES).toBe(4 * 1024 * 1024);
+ expect(env.DETACHED_STREAM_METERING_RESERVE_BYTES).toBe(128 * 1024);
+ });
+
+ it("rejects a budget smaller than one metering reservation", () => {
+ expect(() =>
+ EnvSchema.parse({
+ DETACHED_STREAM_BUDGET_BYTES: String(3 * 1024 * 1024 + 64 * 1024 - 1),
+ })
+ ).toThrow();
+ });
+
+ it("rejects a metering reserve larger than the total budget", () => {
+ expect(() =>
+ EnvSchema.parse({
+ DETACHED_STREAM_BUDGET_BYTES: String(4 * 1024 * 1024),
+ DETACHED_STREAM_METERING_RESERVE_BYTES: String(5 * 1024 * 1024),
+ })
+ ).toThrow();
+ });
+
+ it("allows a metering reserve equal to the total budget", () => {
+ const budget = 4 * 1024 * 1024;
+ expect(
+ EnvSchema.parse({
+ DETACHED_STREAM_BUDGET_BYTES: String(budget),
+ DETACHED_STREAM_METERING_RESERVE_BYTES: String(budget),
+ }).DETACHED_STREAM_METERING_RESERVE_BYTES
+ ).toBe(budget);
+ });
+});
diff --git a/tests/unit/lib/system-settings/proxy-runtime-high-concurrency.test.ts b/tests/unit/lib/system-settings/proxy-runtime-high-concurrency.test.ts
new file mode 100644
index 000000000..a23aaffce
--- /dev/null
+++ b/tests/unit/lib/system-settings/proxy-runtime-high-concurrency.test.ts
@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const settingsMock = vi.hoisted(() => ({
+ current: {
+ enableHighConcurrencyMode: false,
+ streamGateMode: "enforce" as const,
+ affinityIgnoreClientSessionId: true,
+ replayEnabled: true,
+ replayCacheTtlMinutes: 30,
+ cacheEffectivenessEnabled: true,
+ },
+}));
+
+vi.mock("@/lib/config/system-settings-cache", () => ({
+ getCachedSystemSettings: vi.fn(async () => settingsMock.current),
+}));
+
+describe("proxy runtime high-concurrency Redis retention", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ settingsMock.current.enableHighConcurrencyMode = false;
+ });
+
+ it("keeps the normal retention when high-concurrency mode is disabled", async () => {
+ const { getProxyRuntimeSettings, resolveRedisRetentionTtlSeconds } = await import(
+ "@/lib/system-settings/proxy-runtime"
+ );
+
+ await getProxyRuntimeSettings();
+
+ expect(resolveRedisRetentionTtlSeconds(2_592_000)).toBe(2_592_000);
+ });
+
+ it("caps long retention at one day when high-concurrency mode is enabled", async () => {
+ settingsMock.current.enableHighConcurrencyMode = true;
+ const { getProxyRuntimeSettings, resolveRedisRetentionTtlSeconds } = await import(
+ "@/lib/system-settings/proxy-runtime"
+ );
+
+ await getProxyRuntimeSettings();
+
+ expect(resolveRedisRetentionTtlSeconds(2_592_000)).toBe(86_400);
+ expect(resolveRedisRetentionTtlSeconds(300)).toBe(300);
+ });
+});
diff --git a/tests/unit/lib/utils/openai-reasoning-effort.test.ts b/tests/unit/lib/utils/openai-reasoning-effort.test.ts
new file mode 100644
index 000000000..8a1d46d04
--- /dev/null
+++ b/tests/unit/lib/utils/openai-reasoning-effort.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, test } from "vitest";
+import {
+ extractOpenAIReasoningEffortFromRequestBody,
+ extractOpenAIReasoningEffortFromSpecialSettings,
+} from "@/lib/utils/openai-reasoning-effort";
+import type { SpecialSetting } from "@/types/special-settings";
+
+describe("extractOpenAIReasoningEffortFromRequestBody", () => {
+ test("优先读取顶层 reasoning_effort", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({
+ model: "gpt-5.5",
+ messages: [],
+ reasoning_effort: "high",
+ });
+
+ expect(result).toEqual({ effort: "high", source: "reasoning_effort" });
+ });
+
+ test("顶层缺失时兜底读取嵌套 reasoning.effort", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({
+ model: "gpt-5.5",
+ messages: [],
+ reasoning: { effort: "low" },
+ });
+
+ expect(result).toEqual({ effort: "low", source: "reasoning.effort" });
+ });
+
+ test("顶层与嵌套不一致时以顶层为准", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({
+ reasoning_effort: "max",
+ reasoning: { effort: "minimal" },
+ });
+
+ expect(result).toEqual({ effort: "max", source: "reasoning_effort" });
+ });
+
+ test("顶层与嵌套一致时读取顶层", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({
+ reasoning_effort: "medium",
+ reasoning: { effort: "medium" },
+ });
+
+ expect(result).toEqual({ effort: "medium", source: "reasoning_effort" });
+ });
+
+ test("顶层为空白时回退嵌套值", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({
+ reasoning_effort: " ",
+ reasoning: { effort: "xhigh" },
+ });
+
+ expect(result).toEqual({ effort: "xhigh", source: "reasoning.effort" });
+ });
+
+ test("保留非空值的前后空白(原样记录审计值)", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({
+ reasoning_effort: " high ",
+ });
+
+ expect(result).toEqual({ effort: " high ", source: "reasoning_effort" });
+ });
+
+ test("顶层为非法类型时回退嵌套值", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({
+ reasoning_effort: 42,
+ reasoning: { effort: "high" },
+ });
+
+ expect(result).toEqual({ effort: "high", source: "reasoning.effort" });
+ });
+
+ test("两者均缺失时返回 null", () => {
+ expect(
+ extractOpenAIReasoningEffortFromRequestBody({ model: "gpt-5.5", messages: [] })
+ ).toBeNull();
+ });
+
+ test("reasoning 非对象时返回 null", () => {
+ expect(extractOpenAIReasoningEffortFromRequestBody({ reasoning: "not-an-object" })).toBeNull();
+ });
+
+ test("非对象请求体返回 null", () => {
+ expect(extractOpenAIReasoningEffortFromRequestBody(null)).toBeNull();
+ expect(extractOpenAIReasoningEffortFromRequestBody("string")).toBeNull();
+ expect(extractOpenAIReasoningEffortFromRequestBody([1, 2])).toBeNull();
+ });
+
+ test("保留实际取值域(不做白名单过滤)", () => {
+ const result = extractOpenAIReasoningEffortFromRequestBody({ reasoning_effort: "max" });
+
+ expect(result?.effort).toBe("max");
+ });
+});
+
+describe("extractOpenAIReasoningEffortFromSpecialSettings", () => {
+ test("读取首个有效的 openai 思考强度审计", () => {
+ const settings: SpecialSetting[] = [
+ {
+ type: "openai_reasoning_effort",
+ scope: "request",
+ hit: true,
+ effort: "high",
+ source: "reasoning_effort",
+ },
+ ];
+
+ expect(extractOpenAIReasoningEffortFromSpecialSettings(settings)).toEqual({
+ effort: "high",
+ source: "reasoning_effort",
+ });
+ });
+
+ test("忽略其它类型审计", () => {
+ const settings: SpecialSetting[] = [
+ {
+ type: "codex_reasoning_effort",
+ scope: "request",
+ hit: true,
+ effort: "high",
+ },
+ ];
+
+ expect(extractOpenAIReasoningEffortFromSpecialSettings(settings)).toBeNull();
+ });
+
+ test("缺少审计数组时返回 null", () => {
+ expect(extractOpenAIReasoningEffortFromSpecialSettings(undefined)).toBeNull();
+ expect(extractOpenAIReasoningEffortFromSpecialSettings(null)).toBeNull();
+ expect(extractOpenAIReasoningEffortFromSpecialSettings([])).toBeNull();
+ });
+});
diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts
index 02a5bf89b..ce5177e10 100644
--- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts
+++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts
@@ -392,6 +392,46 @@ describe("ProxyForwarder - fake 200 HTML body", () => {
expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
});
+ test("高并发模式仍保留非流式 fake-200 核心故障切换", async () => {
+ const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
+ const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
+ const session = createSession();
+ session.setHighConcurrencyModeEnabled(true);
+ session.setProvider(provider1);
+
+ mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+ const htmlBody = "blocked";
+ const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
+
+ doForward
+ .mockResolvedValueOnce(
+ new Response(htmlBody, {
+ status: 200,
+ headers: {
+ "content-type": "text/html; charset=utf-8",
+ "content-length": String(htmlBody.length),
+ },
+ })
+ )
+ .mockResolvedValueOnce(
+ new Response(okJson, {
+ status: 200,
+ headers: { "content-type": "application/json", "content-length": String(okJson.length) },
+ })
+ );
+
+ const response = await ProxyForwarder.send(session);
+
+ await expect(response.text()).resolves.toBe(okJson);
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(mocks.recordFailure).toHaveBeenCalledWith(
+ provider1.id,
+ expect.objectContaining({ message: "FAKE_200_HTML_BODY" })
+ );
+ expect(mocks.recordSuccess).toHaveBeenCalledWith(provider2.id);
+ });
+
test("200 + text/html 但 body 是 JSON error 也应视为失败并切换供应商", async () => {
const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
diff --git a/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
index 67495901f..8bea59b9f 100644
--- a/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
+++ b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
@@ -11,6 +11,11 @@ const mocks = vi.hoisted(() => ({
getAgent: vi.fn(),
markOriginUnhealthy: vi.fn(),
})),
+ evaluateResponsesWsEligibility: vi.fn(async () => ({
+ isWebsocketClient: false,
+ eligible: false,
+ })),
+ tryResponsesWebsocketUpstream: vi.fn(),
}));
vi.mock("@/lib/config", async (importOriginal) => {
@@ -27,6 +32,24 @@ vi.mock("@/lib/proxy-agent", () => ({
getGlobalAgentPool: mocks.getGlobalAgentPool,
}));
+vi.mock("@/app/v1/_lib/responses-ws/eligibility", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ evaluateResponsesWsEligibility: mocks.evaluateResponsesWsEligibility,
+ getResponsesWsSessionId: vi.fn(() => "client-ws-session"),
+ };
+});
+
+vi.mock("@/app/v1/_lib/responses-ws/upstream-adapter", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ tryResponsesWebsocketUpstream: mocks.tryResponsesWebsocketUpstream,
+ };
+});
+
import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy";
import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
import { rectifyResponseInput } from "@/app/v1/_lib/proxy/response-input-rectifier";
@@ -124,6 +147,11 @@ function readBodyText(body: BodyInit | undefined): string | null {
describe("ProxyForwarder raw passthrough regression", () => {
beforeEach(() => {
vi.clearAllMocks();
+ mocks.evaluateResponsesWsEligibility.mockResolvedValue({
+ isWebsocketClient: false,
+ eligible: false,
+ });
+ mocks.tryResponsesWebsocketUpstream.mockReset();
});
it("raw passthrough 应优先保留原始请求体字节,而不是重新 JSON.stringify", async () => {
@@ -189,6 +217,56 @@ describe("ProxyForwarder raw passthrough regression", () => {
expect(await response.text()).toBe(upstreamSse);
});
+ it("remote compaction v2 ArrayBuffer 请求体仍通过上游 Responses WebSocket", async () => {
+ const requestBody = {
+ model: "gpt-5.5",
+ stream: true,
+ previous_response_id: "resp_previous",
+ input: [{ type: "compaction_trigger" }],
+ };
+ const originalBody = JSON.stringify(requestBody);
+ const upstreamSse =
+ 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_compact","status":"completed"}}\n\n';
+ const session = createRawPassthroughSession(originalBody, {
+ "x-codex-beta-features": "remote_compaction_v2",
+ });
+ session.requestUrl = new URL("https://proxy.example.com/v1/responses");
+ const provider = createProvider();
+
+ mocks.evaluateResponsesWsEligibility.mockResolvedValue({
+ isWebsocketClient: true,
+ eligible: true,
+ endpointId: null,
+ });
+ mocks.tryResponsesWebsocketUpstream.mockResolvedValue({
+ response: new Response(upstreamSse, {
+ status: 200,
+ headers: { "content-type": "text/event-stream" },
+ }),
+ connected: true,
+ reused: true,
+ });
+
+ const fetchWithoutAutoDecode = vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode");
+ fetchWithoutAutoDecode.mockImplementationOnce(
+ async () => new Response("unexpected HTTP fallback", { status: 500 })
+ );
+ const { doForward } = ProxyForwarder as unknown as {
+ doForward: (session: ProxySession, provider: Provider, baseUrl: string) => Promise;
+ };
+
+ const response = await doForward(session, provider, provider.url);
+
+ expect(mocks.tryResponsesWebsocketUpstream).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: requestBody,
+ sessionId: "client-ws-session",
+ })
+ );
+ expect(fetchWithoutAutoDecode).not.toHaveBeenCalled();
+ expect(await response.text()).toBe(upstreamSse);
+ });
+
it("remote compaction v2 将单对象 input 规范化后再透传", async () => {
const originalBody = '{"model":"gpt-5.5","stream":true,"input":{"type":"compaction_trigger"}}';
const session = createRawPassthroughSession(originalBody, {
diff --git a/tests/unit/proxy/replay-guard.test.ts b/tests/unit/proxy/replay-guard.test.ts
index 2842e9d3d..a5322d466 100644
--- a/tests/unit/proxy/replay-guard.test.ts
+++ b/tests/unit/proxy/replay-guard.test.ts
@@ -98,6 +98,7 @@ vi.mock("@/repository/message", () => ({
interface GuardSessionOverrides {
message?: Record;
headers?: Record;
+ highConcurrency?: boolean;
apiKey?: string | null;
sessionIdentity?: {
identity: string;
@@ -133,6 +134,7 @@ function makeSession(overrides: GuardSessionOverrides = {}): ProxySession {
getOriginalModel: () => "claude-sonnet-4",
getEndpoint: () => "/v1/messages",
getMessagesLength: () => 1,
+ shouldUseRequestReplay: () => !overrides.highConcurrency,
getSessionIdentityMetadata: () =>
overrides.sessionIdentity ?? {
identity: "sess-1",
@@ -187,6 +189,15 @@ describe("ProxyReplayGuard:放行路径", () => {
expect(session.replayState).toBeNull();
});
+ it("高并发模式直接放行,不计算 identity 或触碰 Replay 存储", async () => {
+ const session = makeSession({ highConcurrency: true });
+
+ await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull();
+ expect(storeControl.getMeta).not.toHaveBeenCalled();
+ expect(storeControl.findCompleted).not.toHaveBeenCalled();
+ expect(storeControl.tryClaimOwner).not.toHaveBeenCalled();
+ });
+
it("非流式请求不参与 replay", async () => {
const session = makeSession({ message: { stream: false, model: "claude-sonnet-4" } });
diff --git a/tests/unit/proxy/replay-spool.test.ts b/tests/unit/proxy/replay-spool.test.ts
index f07e67408..7e7f796a1 100644
--- a/tests/unit/proxy/replay-spool.test.ts
+++ b/tests/unit/proxy/replay-spool.test.ts
@@ -984,6 +984,55 @@ describe("ReplaySpool:isTerminal", () => {
});
});
+describe("ReplaySpool:terminal callback", () => {
+ it("runs exactly once after completed cleanup", async () => {
+ const onTerminal = vi.fn();
+ const spool = new ReplaySpool(
+ identity,
+ "owner-token",
+ 200,
+ { "content-type": "text/event-stream" },
+ "stream",
+ { onTerminal }
+ );
+ spool.observe(encoder.encode("data: complete\n\n"));
+
+ await spool.completeAfterBilling(1);
+ await spool.completeAfterBilling(1);
+
+ expect(onTerminal).toHaveBeenCalledTimes(1);
+ });
+
+ it("runs exactly once after aborted or disabled cleanup", async () => {
+ const abortedTerminal = vi.fn();
+ const aborted = new ReplaySpool(
+ identity,
+ "owner-token",
+ 200,
+ { "content-type": "text/event-stream" },
+ "stream",
+ { onTerminal: abortedTerminal }
+ );
+ await aborted.abort("test_abort");
+ await aborted.abort("test_abort_again");
+ expect(abortedTerminal).toHaveBeenCalledTimes(1);
+
+ envControl.maxPayloadBytes = 4;
+ const disabledTerminal = vi.fn();
+ const disabled = new ReplaySpool(
+ identity,
+ "owner-token-disabled",
+ 200,
+ { "content-type": "text/event-stream" },
+ "stream",
+ { onTerminal: disabledTerminal }
+ );
+ disabled.observe(encoder.encode("12345678"));
+ await drainWriteChain(disabled);
+ expect(disabledTerminal).toHaveBeenCalledTimes(1);
+ });
+});
+
describe("createReplaySpoolIfOwner", () => {
it("非 owner 会话返回 null(无租约可释放)", () => {
const session = { replayState: null } as unknown as ProxySession;
diff --git a/tests/unit/proxy/response-handler-client-abort-drain.test.ts b/tests/unit/proxy/response-handler-client-abort-drain.test.ts
index caf4026ae..cc9563e74 100644
--- a/tests/unit/proxy/response-handler-client-abort-drain.test.ts
+++ b/tests/unit/proxy/response-handler-client-abort-drain.test.ts
@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy";
+import {
+ acquireDetachedStreamLease,
+ getDetachedStreamBudgetSnapshot,
+} from "@/app/v1/_lib/proxy/detached-stream-budget";
import {
BoundedStreamTextAccumulator,
ProxyResponseHandler,
+ resolveReplayDrainReservationBytes,
} from "@/app/v1/_lib/proxy/response-handler";
import { ProxySession } from "@/app/v1/_lib/proxy/session";
import {
@@ -406,6 +411,45 @@ function createPullTrackedResponsesSse(): {
};
}
+function createMeteringTerminalResponsesSse(): {
+ response: Response;
+ cancel: ReturnType;
+} {
+ const encoder = new TextEncoder();
+ const chunks = [
+ `event: response.output_text.delta\ndata: ${JSON.stringify({
+ type: "response.output_text.delta",
+ delta: "x".repeat(128 * 1024),
+ })}\n\n`,
+ `event: response.completed\ndata: ${JSON.stringify({
+ type: "response.completed",
+ response: {
+ id: "resp_metered",
+ model: "gpt-5.4-mini-2026-03-17",
+ usage: { input_tokens: 463, output_tokens: 11 },
+ },
+ })}\n\n`,
+ ];
+ let index = 0;
+ const cancel = vi.fn();
+ return {
+ response: new Response(
+ new ReadableStream({
+ pull(controller) {
+ const chunk = chunks[index++];
+ if (chunk) controller.enqueue(encoder.encode(chunk));
+ },
+ cancel,
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "text/event-stream" },
+ }
+ ),
+ cancel,
+ };
+}
+
function createControllableTransportErrorResponsesSse(): {
response: Response;
fail: () => void;
@@ -1066,6 +1110,14 @@ describe("ProxyResponseHandler stream client abort finalization", () => {
);
});
+ it("uses a conservative Replay reservation when environment parsing fails", () => {
+ expect(
+ resolveReplayDrainReservationBytes(() => {
+ throw new Error("invalid environment");
+ })
+ ).toBe(29 * 1024 * 1024);
+ });
+
it("propagates unexpected registered task rejections during drain", async () => {
const failure = new Error("factory task failed");
AsyncTaskManager.register("rejecting-test-task", async () => {
@@ -2115,6 +2167,83 @@ describe("ProxyResponseHandler stream client abort finalization", () => {
);
});
+ it("stops a detached source as soon as compact terminal usage is captured", async () => {
+ const clientController = new AbortController();
+ const session = createSession(clientController.signal);
+ setDeferredStreamingFinalization(session, {
+ providerId: 1,
+ providerName: "avemujica-responses",
+ providerPriority: 1,
+ attemptNumber: 1,
+ totalProvidersAttempted: 1,
+ isFirstAttempt: true,
+ isFailoverSuccess: false,
+ endpointId: 42,
+ endpointUrl: "https://api.test.invalid/v1",
+ upstreamStatusCode: 200,
+ });
+ const metered = createMeteringTerminalResponsesSse();
+
+ await ProxyResponseHandler.dispatch(session, metered.response);
+ clientController.abort(new Error("client detached"));
+ await drainAsyncTasks();
+
+ expect(metered.cancel).toHaveBeenCalledWith(
+ expect.objectContaining({ message: "client_abort_metering_complete" })
+ );
+ expect(updateMessageRequestDetailsDurably).toHaveBeenCalledWith(
+ 123,
+ expect.objectContaining({
+ statusCode: 200,
+ inputTokens: 463,
+ outputTokens: 11,
+ }),
+ expect.objectContaining({ onCommitted: expect.any(Function) })
+ );
+ expect(getDetachedStreamBudgetSnapshot().activeStreams).toBe(0);
+ });
+
+ it("rejects a new detached drain immediately when the process budget is exhausted", async () => {
+ const budget = getDetachedStreamBudgetSnapshot();
+ const reservation = acquireDetachedStreamLease("metering", budget.limits.maxReservedBytes);
+ if (!reservation.acquired) throw new Error("expected test budget reservation");
+ const clientController = new AbortController();
+ const upstreamController = new AbortController();
+ try {
+ const session = createSession(clientController.signal);
+ Object.assign(session, { responseController: upstreamController });
+ setDeferredStreamingFinalization(session, {
+ providerId: 1,
+ providerName: "avemujica-responses",
+ providerPriority: 1,
+ attemptNumber: 1,
+ totalProvidersAttempted: 1,
+ isFirstAttempt: true,
+ isFailoverSuccess: false,
+ endpointId: 42,
+ endpointUrl: "https://api.test.invalid/v1",
+ upstreamStatusCode: 200,
+ });
+
+ await ProxyResponseHandler.dispatch(
+ session,
+ createHangingResponsesSse(upstreamController.signal)
+ );
+ clientController.abort(new Error("client detached"));
+ await drainAsyncTasks();
+
+ expect(upstreamController.signal.aborted).toBe(true);
+ expect(updateMessageRequestDetailsDurably).toHaveBeenCalledWith(
+ 123,
+ expect.objectContaining({ statusCode: 499, errorMessage: "CLIENT_ABORTED" }),
+ expect.objectContaining({ onCommitted: expect.any(Function) })
+ );
+ } finally {
+ reservation.lease.release();
+ }
+ expect(getDetachedStreamBudgetSnapshot().activeStreams).toBe(0);
+ });
+
it.each([
{ bindingIntent: "create" as const, providerId: null },
{ bindingIntent: "renew" as const, providerId: 1 },
diff --git a/tests/unit/proxy/response-handler-stream-terminal.test.ts b/tests/unit/proxy/response-handler-stream-terminal.test.ts
index 3718a28c3..8532dc255 100644
--- a/tests/unit/proxy/response-handler-stream-terminal.test.ts
+++ b/tests/unit/proxy/response-handler-stream-terminal.test.ts
@@ -1,6 +1,10 @@
import { Context } from "hono";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler";
+import {
+ acquireDetachedStreamLease,
+ getDetachedStreamBudgetSnapshot,
+} from "@/app/v1/_lib/proxy/detached-stream-budget";
import { ProxySession, type MessageContext } from "@/app/v1/_lib/proxy/session";
import { setDeferredStreamingFinalization } from "@/app/v1/_lib/proxy/stream-finalization";
import type { Key } from "@/types/key";
@@ -20,6 +24,8 @@ const mocks = vi.hoisted(() => ({
replayComplete: vi.fn(async () => {}),
replayAbort: vi.fn(async () => {}),
replayInactive: null as (() => void) | null,
+ replayTerminal: null as (() => void) | null,
+ replayTerminalState: false,
}));
vi.mock("@/app/v1/_lib/proxy/response-fixer", () => ({
@@ -65,14 +71,17 @@ vi.mock("@/app/v1/_lib/proxy/replay/replay-spool", () => ({
session: ProxySession,
_response: Response,
_delivery: string,
- options: { onInactive?: () => void } = {}
+ options: { onInactive?: () => void; onTerminal?: () => void } = {}
) => {
if (session.replayState?.role !== "owner") return null;
mocks.replayInactive = options.onInactive ?? null;
+ mocks.replayTerminal = options.onTerminal ?? null;
return {
abort: mocks.replayAbort,
completeAfterBilling: mocks.replayComplete,
- isTerminal: false,
+ get isTerminal() {
+ return mocks.replayTerminalState;
+ },
observe: mocks.replayObserve,
};
},
@@ -246,11 +255,38 @@ function setReplayOwner(session: ProxySession, suffix: string): void {
};
}
+function setReplayFinalization(session: ProxySession): void {
+ setDeferredStreamingFinalization(session, {
+ providerId: session.provider?.id ?? 0,
+ providerName: session.provider?.name ?? "provider",
+ providerPriority: session.provider?.priority ?? 0,
+ attemptNumber: 1,
+ totalProvidersAttempted: 1,
+ isFirstAttempt: true,
+ isFailoverSuccess: false,
+ endpointId: null,
+ endpointUrl: session.provider?.url ?? "",
+ upstreamStatusCode: 200,
+ bindingIntent: "none",
+ });
+}
+
describe("ProxyResponseHandler.dispatch stream terminal behavior", () => {
beforeEach(() => {
mocks.tasks.length = 0;
mocks.replayInactive = null;
+ mocks.replayTerminal = null;
+ mocks.replayTerminalState = false;
vi.clearAllMocks();
+ mocks.replayAbort.mockImplementation(async () => {
+ mocks.replayTerminalState = true;
+ mocks.replayInactive?.();
+ mocks.replayTerminal?.();
+ });
+ mocks.replayComplete.mockImplementation(async () => {
+ mocks.replayTerminalState = true;
+ mocks.replayTerminal?.();
+ });
mocks.durable.mockImplementation(async (_id, _details, options) => {
await options?.onCommitted?.();
return true;
@@ -302,6 +338,81 @@ describe("ProxyResponseHandler.dispatch stream terminal behavior", () => {
expect(releaseAgent).toHaveBeenCalledOnce();
});
+ it("keeps an admitted detached Replay complete and replayable", async () => {
+ const chunks = [
+ 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":1}}}\n\n',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"kept"}}\n\n',
+ 'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":4}}\n\n',
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n',
+ ];
+ let index = 0;
+ const source = new ReadableStream({
+ pull(controller) {
+ const chunk = chunks[index++];
+ if (chunk) controller.enqueue(new TextEncoder().encode(chunk));
+ },
+ });
+ const { session } = await createSession({});
+ setReplayOwner(session, "admitted-detached");
+ setReplayFinalization(session);
+
+ const returned = await ProxyResponseHandler.dispatch(session, sseResponse(source));
+ const reader = returned.body?.getReader();
+ await reader?.read();
+ await reader?.cancel(new Error("client disconnected"));
+ await settleTasks();
+
+ expect(mocks.replayAbort).not.toHaveBeenCalled();
+ expect(mocks.replayComplete).toHaveBeenCalledWith(MESSAGE.id);
+ const replayedText = mocks.replayObserve.mock.calls
+ .map(([chunk]) => new TextDecoder().decode(chunk as Uint8Array))
+ .join("");
+ expect(replayedText).toContain('"text":"kept"');
+ expect(replayedText).toContain("message_stop");
+ expect(getDetachedStreamBudgetSnapshot().activeStreams).toBe(0);
+ });
+
+ it("downgrades a detached Replay to metering when Replay headroom is exhausted", async () => {
+ const blocker = acquireDetachedStreamLease("replay", 20 * 1024 * 1024);
+ if (!blocker.acquired) throw new Error("expected Replay budget blocker");
+ try {
+ const chunks = [
+ 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":1}}}\n\n',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"not replayed"}}\n\n',
+ 'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":4}}\n\n',
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n',
+ ];
+ let index = 0;
+ const source = new ReadableStream({
+ pull(controller) {
+ const chunk = chunks[index++];
+ if (chunk) controller.enqueue(new TextEncoder().encode(chunk));
+ },
+ });
+ const { session } = await createSession({});
+ setReplayOwner(session, "metering-fallback");
+ setReplayFinalization(session);
+
+ const returned = await ProxyResponseHandler.dispatch(session, sseResponse(source));
+ const reader = returned.body?.getReader();
+ await reader?.read();
+ const observedBeforeDetach = mocks.replayObserve.mock.calls.length;
+ await reader?.cancel(new Error("client disconnected"));
+ await settleTasks();
+
+ expect(mocks.replayAbort).toHaveBeenCalledWith("detached_replay_metering_reserve");
+ expect(mocks.replayObserve.mock.calls.length).toBe(observedBeforeDetach);
+ expect(mocks.durable).toHaveBeenCalledWith(
+ MESSAGE.id,
+ expect.objectContaining({ statusCode: 200, inputTokens: 10, outputTokens: 4 }),
+ expect.objectContaining({ onCommitted: expect.any(Function) })
+ );
+ } finally {
+ blocker.lease.release();
+ }
+ expect(getDetachedStreamBudgetSnapshot().activeStreams).toBe(0);
+ });
+
it("caps a detached Replay drain at 60 seconds after the spool becomes inactive", async () => {
vi.useFakeTimers();
const previousReplayDetachedMs = process.env.REPLAY_MAX_DETACHED_MS;
@@ -324,10 +435,24 @@ describe("ProxyResponseHandler.dispatch stream terminal behavior", () => {
await reader?.cancel(new Error("client disconnected"));
expect(mocks.replayInactive).toEqual(expect.any(Function));
+ expect(getDetachedStreamBudgetSnapshot().activeByKind).toEqual({
+ metering: 0,
+ replay: 1,
+ });
await vi.advanceTimersByTimeAsync(59_999);
expect(responseController.signal.aborted).toBe(false);
+ mocks.replayTerminalState = true;
mocks.replayInactive?.();
+ expect(getDetachedStreamBudgetSnapshot().activeByKind).toEqual({
+ metering: 1,
+ replay: 1,
+ });
+ mocks.replayTerminal?.();
+ expect(getDetachedStreamBudgetSnapshot().activeByKind).toEqual({
+ metering: 1,
+ replay: 0,
+ });
await vi.advanceTimersByTimeAsync(1);
expect(responseController.signal.aborted).toBe(true);
@@ -336,6 +461,7 @@ describe("ProxyResponseHandler.dispatch stream terminal behavior", () => {
);
expect(cancelSource).toHaveBeenCalledOnce();
await settleTasks();
+ expect(getDetachedStreamBudgetSnapshot().activeStreams).toBe(0);
} finally {
if (previousReplayDetachedMs === undefined) {
delete process.env.REPLAY_MAX_DETACHED_MS;
@@ -489,6 +615,25 @@ describe("ProxyResponseHandler.dispatch stream terminal behavior", () => {
);
});
+ it("accepts a large Gemini chunk composed of complete short lines", async () => {
+ const { session } = await createSession({});
+ session.setProvider({ ...createProvider(), providerType: "gemini" });
+ session.originalFormat = "claude";
+ const frame = 'data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n';
+ const body = `${frame.repeat(Math.ceil((1024 * 1024 + 1) / frame.length))}data: {"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2}}\n\n`;
+
+ const returned = await ProxyResponseHandler.dispatch(session, sseResponse(body));
+ const returnedText = await returned.text();
+ expect(returnedText.length).toBeGreaterThan(1024 * 1024);
+ await settleTasks();
+
+ expect(mocks.durable).toHaveBeenCalledWith(
+ MESSAGE.id,
+ expect.objectContaining({ statusCode: 200 }),
+ expect.objectContaining({ onCommitted: expect.any(Function) })
+ );
+ });
+
it("treats malformed after wrapped Gemini content as postcommit", async () => {
const { session } = await createSession({});
session.setProvider({ ...createProvider(), providerType: "gemini" });
diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts
index 5c77a0a29..ac7195370 100644
--- a/tests/unit/proxy/session.test.ts
+++ b/tests/unit/proxy/session.test.ts
@@ -163,6 +163,28 @@ describe("ProxySession endpoint policy", () => {
});
});
+describe("ProxySession high-concurrency policy", () => {
+ it("closes optional body-heavy features while preserving the base session", () => {
+ const session = createSession({ redirectedModel: null });
+
+ expect(session.shouldUseRequestReplay()).toBe(true);
+ expect(session.shouldRunStreamContentGate()).toBe(true);
+ expect(session.shouldRetainClientAbortBilling()).toBe(true);
+ expect(session.shouldBillHedgeLosers()).toBe(true);
+ expect(session.shouldParseResponseDiagnostics()).toBe(true);
+
+ session.setHighConcurrencyModeEnabled(true);
+
+ expect(session.shouldUseRequestReplay()).toBe(false);
+ expect(session.shouldRunStreamContentGate()).toBe(false);
+ expect(session.shouldRetainClientAbortBilling()).toBe(false);
+ expect(session.shouldBillHedgeLosers()).toBe(false);
+ expect(session.shouldParseResponseDiagnostics()).toBe(false);
+ expect(session.shouldPersistSessionDebugArtifacts()).toBe(false);
+ expect(session.shouldTrackSessionObservability()).toBe(false);
+ });
+});
+
describe("ProxySession.getCachedPriceDataByBillingSource", () => {
it("配置 = original 时应优先使用原始模型", async () => {
const originalPriceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 };
diff --git a/tests/unit/settings/system-settings-form-upstream-error-message.test.tsx b/tests/unit/settings/system-settings-form-upstream-error-message.test.tsx
index 465e00561..36b170a30 100644
--- a/tests/unit/settings/system-settings-form-upstream-error-message.test.tsx
+++ b/tests/unit/settings/system-settings-form-upstream-error-message.test.tsx
@@ -205,6 +205,19 @@ describe("SystemSettingsForm upstream error message toggles", () => {
unmount();
});
+ test("开启高并发模式时提示将停用的高开销功能", () => {
+ const { unmount } = renderForm(buildSettings());
+
+ clickSwitch("enable-high-concurrency-mode");
+
+ expect(sonnerMocks.toast.warning).toHaveBeenCalledWith(
+ "High-concurrency mode disables Replay, stream gating, racing-loser billing, client-abort retention, and session diagnostics."
+ );
+ expect(getSwitch("enable-high-concurrency-mode").getAttribute("aria-checked")).toBe("true");
+
+ unmount();
+ });
+
test("旧开关仍可独立提交,不会连带修改新开关", async () => {
systemConfigActionMocks.saveSystemSettings.mockResolvedValueOnce({
ok: true,