Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion messages/en/settings/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
"affinityIgnoreClientSessionId": "Ignore Client Session ID",
"affinityIgnoreClientSessionIdDesc": "When enabled, fingerprintable requests are forced to use longest-prefix affinity for provider stickiness (skipping client Session ID binding); non-fingerprintable requests still use session reuse. Default on.",
"streamGateMode": "Stream Content Gate",
"streamGateModeDesc": "Buffers upstream output until the first valid content frame arrives, and automatically fails over to another provider on error frames or empty streams; shadow mode only records divergence statistics without affecting forwarding. Default enabled.",
"streamGateModeDesc": "Controls the first-valid-content gate for ordinary requests: enabled mode fails over on error frames or empty streams, shadow mode only records divergence, and off mode forwards directly. Replay owners always retain the pre-content safety gate regardless of this setting. Default enabled.",
"streamGateModeOptions": {
"off": "Off",
"shadow": "Shadow mode",
Expand Down
2 changes: 1 addition & 1 deletion messages/ja/settings/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
"affinityIgnoreClientSessionId": "クライアント Session ID を無視",
"affinityIgnoreClientSessionIdDesc": "有効にすると、フィンガープリント可能なリクエストは最長プレフィックス親和性によるプロバイダー固定を強制します(クライアント Session ID バインディングをスキップ)。フィンガープリント不可能なリクエストは従来どおりセッション再利用を使用します。デフォルトで有効。",
"streamGateMode": "ストリーム内容ゲート",
"streamGateModeDesc": "最初の有効なコンテンツフレームが到着するまでバッファリングし、エラーフレームや空ストリームの場合はプロバイダーを自動的に切り替えて再試行します。シャドウモードは転送に影響せず、判定乖離の統計のみを記録します。デフォルトで有効。",
"streamGateModeDesc": "通常リクエストの最初の有効なコンテンツフレームゲートを制御します。有効時はエラーフレームや空ストリームでプロバイダーを切り替え、シャドウモードは判定乖離のみを記録し、オフ時はそのまま転送します。Replay owner は設定に関係なく、最初のコンテンツ前の安全判定を常に維持します。デフォルトで有効。",
"streamGateModeOptions": {
"off": "オフ",
"shadow": "シャドウモード",
Expand Down
2 changes: 1 addition & 1 deletion messages/ru/settings/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
"affinityIgnoreClientSessionId": "Игнорировать клиентский Session ID",
"affinityIgnoreClientSessionIdDesc": "Если включено, запросы с отпечатком принудительно используют аффинность по самому длинному префиксу для закрепления за провайдером (пропуская привязку по клиентскому Session ID); запросы без отпечатка по-прежнему используют переиспользование сессии. По умолчанию включено.",
"streamGateMode": "Шлюз потокового контента",
"streamGateModeDesc": "Буферизует вывод до появления первого валидного кадра контента и автоматически переключает провайдера при кадрах ошибок или пустых потоках; теневой режим лишь записывает статистику расхождений, не влияя на пересылку. По умолчанию включено.",
"streamGateModeDesc": "Управляет шлюзом первого валидного кадра для обычных запросов: включенный режим переключает провайдера при ошибках или пустом потоке, теневой режим только фиксирует расхождения, а выключенный режим пересылает ответ напрямую. Replay owner всегда сохраняет проверку безопасности до первого контента независимо от настройки. По умолчанию включено.",
"streamGateModeOptions": {
"off": "Выключено",
"shadow": "Теневой режим",
Expand Down
2 changes: 1 addition & 1 deletion messages/zh-CN/settings/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"allowNonConversationEndpointProviderFallback": "允许非对话端点跨供应商 fallback",
"allowNonConversationEndpointProviderFallbackDesc": "控制 /v1/messages/count_tokens 与 /v1/responses/compact 在当前供应商失败时,是否沿用现有决策链切换到兼容供应商重试。默认开启,并继续保持 raw passthrough 与非计费语义。",
"streamGateMode": "流式内容门控",
"streamGateModeDesc": "在首个有效内容帧到达前先行缓冲,遇到错误帧或空流时自动切换供应商重试;影子模式仅旁路统计判定分歧,不影响转发。默认启用。",
"streamGateModeDesc": "控制普通请求的首个有效内容帧门控:启用时遇到错误帧或空流会自动切换供应商重试,影子模式仅旁路统计判定分歧,关闭时直接转发。Replay owner 无论选择哪种模式,始终保留首内容前的安全判定。默认启用。",
"streamGateModeOptions": {
"off": "关闭",
"shadow": "影子模式",
Expand Down
2 changes: 1 addition & 1 deletion messages/zh-TW/settings/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
"affinityIgnoreClientSessionId": "忽略用戶端 Session ID",
"affinityIgnoreClientSessionIdDesc": "開啟後,可指紋化的請求強制使用最長前綴親和做供應商黏性(跳過用戶端 Session ID 綁定);不可指紋化的請求仍走會話複用。預設開啟。",
"streamGateMode": "串流內容閘控",
"streamGateModeDesc": "在首個有效內容影格到達前先行緩衝,遇到錯誤影格或空串流時自動切換供應商重試;影子模式僅旁路統計判定分歧,不影響轉發。預設啟用。",
"streamGateModeDesc": "控制一般請求的首個有效內容影格閘控:啟用時遇到錯誤影格或空串流會自動切換供應商重試,影子模式僅旁路統計判定分歧,關閉時直接轉發。Replay owner 無論選擇哪種模式,始終保留首內容前的安全判定。預設啟用。",
"streamGateModeOptions": {
"off": "關閉",
"shadow": "影子模式",
Expand Down
114 changes: 103 additions & 11 deletions src/app/v1/_lib/proxy/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ import {
validateOpenAIImageRequest,
} from "./openai-image-compat";
import { ProxyProviderResolver } from "./provider-selector";
import { abortReplayOwnership, releaseReplayOwnership } from "./replay/replay-spool";
import { isJsonResponseContentType, isMalformedJsonResponseBody } from "./response-content-type";
import { finalizeHedgeLoserBilling, hasStreamCompletionMarker } from "./response-handler";
import type { ProxySession } from "./session";
import {
Expand Down Expand Up @@ -1343,6 +1345,15 @@ function applyClaudeMetadataUserIdInjectionWithAudit(

export class ProxyForwarder {
static async send(session: ProxySession): Promise<Response> {
try {
return await ProxyForwarder.sendInternal(session);
} catch (error) {
await abortReplayOwnership(session, "forward_failed");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not await replay cleanup on forwarding failures

When an owner request fails forwarding while Redis still reports ready but commands are stalled, this awaited best-effort abort delays propagation of the original upstream error until the Redis command timeout expires (10 seconds by default and configurable up to 120 seconds). A simultaneous Redis degradation therefore adds substantial latency to every replay-owner failure even though replay is documented as fail-open; perform this cleanup asynchronously or impose a much shorter independent deadline.

Useful? React with 👍 / 👎.

throw error;
}
}

private static async sendInternal(session: ProxySession): Promise<Response> {
if (!session.provider || !session.authState?.success) {
throw new Error("代理上下文缺少供应商或鉴权信息");
}
Expand Down Expand Up @@ -1653,7 +1664,7 @@ export class ProxyForwarder {
};

try {
const response = await ProxyForwarder.doForward(
let response = await ProxyForwarder.doForward(
session,
currentProvider,
activeEndpoint.baseUrl,
Expand All @@ -1668,9 +1679,7 @@ export class ProxyForwarder {
const isHtml =
normalizedContentType.includes("text/html") ||
normalizedContentType.includes("application/xhtml+xml");
const isJson =
normalizedContentType.includes("application/json") ||
normalizedContentType.includes("+json");
const isJson = isJsonResponseContentType(contentType);

// ========== 流式响应:延迟成功判定(避免“假 200”)==========
// 背景:上游可能返回 HTTP 200,但 SSE 内容为错误 JSON(如 {"error": "..."})。
Expand All @@ -1682,7 +1691,7 @@ export class ProxyForwarder {
// 解决:Forwarder 只负责尽快把 Response 返回给下游开始透传,
// 把最终成功/失败结算延迟到 ResponseHandler:等 SSE 正常结束后再基于最终 body 补充检查并更新内部状态。
if (isSSE) {
// ========== F1 流式内容门控(enforce 模式)==========
// ========== F1 流式内容门控(enforce 或 Replay owner)==========
// 在向客户端提交响应前等待首个有效内容帧:
// - 中性前缀(ping/metadata/usage-only)缓冲后随提交一并冲刷;
// - error/malformed/空流在此抛错 -> 外层 catch 归类 -> 换供应商(客户端零字节);
Expand All @@ -1691,8 +1700,10 @@ export class ProxyForwarder {
let streamingResponse = response;
let gateChainAudit: ProviderChainItem["streamGate"];
const gateMode = resolveStreamGateMode();
const shouldRunPrecommitGate =
gateMode === "enforce" || session.replayState?.role === "owner";
if (
gateMode === "enforce" &&
shouldRunPrecommitGate &&
response.body &&
session.getEndpointPolicy().kind !== "raw_passthrough"
) {
Expand Down Expand Up @@ -1854,7 +1865,11 @@ export class ProxyForwarder {
// 因此这里在进入成功分支前做一次强信号检测:仅当 body 看起来是完整 HTML 文档时才视为错误。
let inspectedText: string | undefined;
let inspectedTruncated = false;
// 注意:这里不会对“大体积 JSON”做假 200 检测(例如 Content-Length > 32KiB)。
// Replay owner 的 buffered JSON 必须在提交前完整验证,确保 malformed body 能进入
// 现有 provider fallback,且不会被 ResponseHandler 持久化为 completed Replay。
const shouldStrictValidateReplayJson = isJson && session.replayState?.role === "owner";
let replayJsonValidationExceededLimit = false;
// 普通非 Replay 请求仍不会对“大体积 JSON”做假 200 检测(例如 Content-Length > 32KiB)。
// 原因:
// - 非流式路径需要 clone 并额外读取响应体,会带来额外的内存/延迟开销;
// - 大体积 JSON 更可能是正常响应(而不是网关/WAF 的短错误 JSON)。
Expand All @@ -1864,7 +1879,22 @@ export class ProxyForwarder {
hasValidContentLength &&
contentLengthBytes <= NON_STREAM_BODY_INSPECTION_MAX_BYTES;
const shouldInspectBody = isHtml || !hasValidContentLength || shouldInspectJson;
if (shouldInspectBody) {
if (shouldStrictValidateReplayJson) {
const validationLimit = getEnvConfig().REPLAY_MAX_PAYLOAD_BYTES;
if (contentLengthBytes !== null && contentLengthBytes > validationLimit) {
replayJsonValidationExceededLimit = true;
releaseReplayOwnership(session);
} else {
const validation = await ProxyForwarder.bufferReplayJsonResponse(
response,
validationLimit
);
response = validation.response;
inspectedText = validation.text;
replayJsonValidationExceededLimit = validation.exceededLimit;
if (validation.exceededLimit) releaseReplayOwnership(session);
}
} else if (shouldInspectBody) {
// 注意:Response.clone() 会 tee 底层 ReadableStream,可能带来一定的瞬时内存开销;
// 这里通过“最多读取 32 KiB”并在截断时 cancel 克隆分支来控制开销。
const clonedResponse = response.clone();
Expand Down Expand Up @@ -1913,9 +1943,25 @@ export class ProxyForwarder {
}
}

if (
shouldStrictValidateReplayJson &&
inspectedText !== undefined &&
isMalformedJsonResponseBody(contentType, inspectedText)
) {
const rawBodyMaxChars = 4096;
throw new ProxyError("MALFORMED_BUFFERED_JSON", 502, {
body: "Upstream returned malformed JSON",
Comment on lines +1952 to +1953

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Localize the malformed-JSON client message

When every provider returns malformed buffered JSON, buildAllProvidersUnavailableError derives its client-safe candidate from this upstreamError.body, so with upstream-message passthrough enabled this English literal can be returned to API clients. Route the message through the repository's five-locale i18n mechanism instead of hardcoding the display text.

AGENTS.md reference: AGENTS.md:L12-L14

Useful? React with 👍 / 👎.

providerId: currentProvider.id,
providerName: currentProvider.name,
rawBody: inspectedText.slice(0, rawBodyMaxChars),
rawBodyTruncated: inspectedText.length > rawBodyMaxChars,
isSyntheticFake200: true,
});
}

// 对于缺失或非法 Content-Length 的情况,需要 clone 并检查响应体
// 注意:这会增加一定的性能开销,但对于非流式响应是可接受的
if (!contentLength || !hasValidContentLength) {
if ((!contentLength || !hasValidContentLength) && !replayJsonValidationExceededLimit) {
const responseText = inspectedText ?? "";

if (!responseText || responseText.trim() === "") {
Expand Down Expand Up @@ -4721,10 +4767,11 @@ export class ProxyForwarder {
attempt.reader = response.body.getReader();

try {
// F1 门控(enforce):胜者判定从「首个非空字节」升级为「首个有效内容帧」。
// F1 门控(enforce 或 Replay owner):胜者判定从「首个非空字节」升级为
// 「首个有效内容帧」。
// 级联阈值计时器保持不动——内容慢的 attempt 不提交,自动触发下一候选竞速。
const hedgeGateFamily =
resolveStreamGateMode() === "enforce" &&
(resolveStreamGateMode() === "enforce" || session.replayState?.role === "owner") &&
session.getEndpointPolicy().kind !== "raw_passthrough"
? mapProviderTypeToFamily(attempt.provider.providerType)
: null;
Expand Down Expand Up @@ -7924,6 +7971,51 @@ export class ProxyForwarder {
}
}

private static async bufferReplayJsonResponse(
response: Response,
maxBytes: number
): Promise<{ response: Response; text: string | undefined; exceededLimit: boolean }> {
const reader = response.body?.getReader();
if (!reader) {
return { response, text: "", exceededLimit: false };
}

const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
reader.releaseLock();
const combined = chunks.length > 0 ? concatChunks(chunks) : null;
const body = combined ? new Uint8Array(combined) : new Uint8Array(0);
return {
response: new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
text: new TextDecoder().decode(body),
exceededLimit: false,
};
}
if (!value || value.byteLength === 0) continue;

chunks.push(value);
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
return {
response: new Response(ProxyForwarder.buildBufferedPrefixStream(chunks, reader), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
text: undefined,
exceededLimit: true,
};
}
}
}

private static buildBufferedPrefixStream(
prefixChunks: Uint8Array[],
reader: ReadableStreamDefaultReader<Uint8Array>
Expand Down
14 changes: 10 additions & 4 deletions src/app/v1/_lib/proxy/replay/replay-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getEnvConfig } from "@/lib/config/env.schema";
import { logger } from "@/lib/logger";
import { getProxyRuntimeSettings } from "@/lib/system-settings/proxy-runtime";
import type { ProxySession } from "../session";
import { restoreReplayResponseHeaders } from "./replay-headers";
import { deriveReplayIdentity, REPLAY_BYPASS_HEADER, type ReplayIdentity } from "./replay-identity";
import { getReplayStore, type ReplayMeta, type ReplayStore } from "./replay-store";

Expand Down Expand Up @@ -108,7 +109,7 @@ export class ProxyReplayGuard {
// 热层块已过期:落 PG
} else if (meta.status === "owning") {
const heartbeatFresh = Date.now() - meta.heartbeatAt < ATTACH_STALL_MS;
if (env.REPLAY_LIVE_DEDUP_ENABLED && heartbeatFresh) {
if (meta.delivery !== "buffered" && env.REPLAY_LIVE_DEDUP_ENABLED && heartbeatFresh) {
await ProxyReplayGuard.writeAuditRow(session, identity, meta.statusCode, "attached_live");
return ProxyReplayGuard.buildLiveAttachResponse(identity, meta, store);
}
Expand Down Expand Up @@ -226,9 +227,14 @@ export class ProxyReplayGuard {
stored: Record<string, string>,
mode: "completed" | "live"
): Headers {
const headers = new Headers();
headers.set("content-type", stored["content-type"] ?? "text/event-stream");
headers.set("cache-control", "no-cache");
const headers = restoreReplayResponseHeaders(stored);
const contentType = headers.get("content-type")?.toLowerCase() ?? "";
if (mode === "live" && !contentType) {
headers.set("content-type", "text/event-stream");
}
if (mode === "live" || contentType.includes("text/event-stream")) {
headers.set("cache-control", "no-cache");
}
headers.set("x-cch-replay", mode);
return headers;
}
Expand Down
42 changes: 42 additions & 0 deletions src/app/v1/_lib/proxy/replay/replay-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const REPLAY_EXCLUDED_RESPONSE_HEADERS = new Set([
"connection",
"content-encoding",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"set-cookie",
"set-cookie2",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);

export function captureReplayResponseHeaders(
source: Headers,
fallbackContentType?: string
): Record<string, string> {
const captured: Record<string, string> = {};
source.forEach((value, name) => {
const normalizedName = name.toLowerCase();
if (!REPLAY_EXCLUDED_RESPONSE_HEADERS.has(normalizedName)) {
captured[normalizedName] = value;
}
});
if (fallbackContentType && !captured["content-type"]) {
captured["content-type"] = fallbackContentType;
}
return captured;
}

export function restoreReplayResponseHeaders(stored: Record<string, string>): Headers {
const headers = new Headers();
for (const [name, value] of Object.entries(stored)) {
const normalizedName = name.toLowerCase();
if (!REPLAY_EXCLUDED_RESPONSE_HEADERS.has(normalizedName)) {
headers.set(normalizedName, value);
}
}
return headers;
}
Loading
Loading