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,