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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ STORE_SESSION_MESSAGES=false # 会话消息存储模式(默认:fa
# - false:存储请求/响应体但对 message 内容脱敏 [REDACTED]
# - true:原样存储 message 内容(注意隐私和存储空间影响)
# 警告:启用后会增加 Redis/DB 存储空间,且包含敏感信息
STORE_SESSION_RESPONSE_BODY=true # 是否在 Redis 中存储会话响应体(默认:true)
# - true:存储(SSE/JSON),用于调试/定位问题(Redis 临时缓存)
# - false:不存储响应体(注意:不影响本次请求处理;仅影响后续查看 response body)
# 说明:该开关不影响内部统计读取响应体(tokens/费用统计、SSE 假 200 检测仍会进行)

# 熔断器配置
# 功能说明:控制网络错误是否计入熔断器失败计数
Expand Down
5 changes: 3 additions & 2 deletions messages/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"billingRedirected": "billing: redirected"
},
"errorMessage": "Error Message",
"fake200ForwardedNotice": "Note: For streaming requests, this failure may be detected only after the stream ends; the response content may already have been forwarded to the client.",
"filteredProviders": "Filtered Providers",
"providerChain": {
"title": "Provider Decision Chain Timeline",
Expand Down Expand Up @@ -493,7 +494,7 @@
"providers": "Providers",
"models": "Models",
"noDetailedData": "No detailed data available",
"storageTip": "No detailed data found. To view request details, please check if the environment variable STORE_SESSION_MESSAGES is set to true. Note: Enabling this increases Redis memory usage and may include sensitive information.",
"storageTip": "No detailed data found. Possible reasons: Redis is disabled/unavailable (REDIS_URL + ENABLE_RATE_LIMIT=true), the data expired (SESSION_TTL, default 300s), or response body storage is disabled (STORE_SESSION_RESPONSE_BODY=false, affects response body only). To store unredacted messages, set STORE_SESSION_MESSAGES=true.",
"clientInfo": "Client Info",
"requestHeaders": "Request Headers",
"requestBody": "Request Body",
Expand Down Expand Up @@ -571,7 +572,7 @@
"fetchFailed": "Fetch Failed",
"unknownError": "Unknown Error",
"storageNotEnabled": "Not Stored",
"storageNotEnabledHint": "Tip: Set environment variable STORE_SESSION_MESSAGES=true to enable messages storage"
"storageNotEnabledHint": "Tip: Check REDIS_URL and ENABLE_RATE_LIMIT=true (session details cache). To store unredacted messages, set STORE_SESSION_MESSAGES=true."
},
"errors": {
"copyFailed": "Copy Failed"
Expand Down
5 changes: 3 additions & 2 deletions messages/ja/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"billingRedirected": "課金: 実際"
},
"errorMessage": "エラーメッセージ",
"fake200ForwardedNotice": "注意:ストリーミング要求では、失敗判定がストリーム終了後になる場合があります。応答内容は既にクライアントへ転送されている可能性があります。",
"filteredProviders": "フィルタされたプロバイダー",
"providerChain": {
"title": "プロバイダー決定チェーンタイムライン",
Expand Down Expand Up @@ -493,7 +494,7 @@
"providers": "プロバイダー",
"models": "モデル",
"noDetailedData": "詳細データなし",
"storageTip": "詳細データが見つかりません。リクエストの詳細を表示するには、環境変数 STORE_SESSION_MESSAGES が true に設定されているか確認してください。注意:有効にすると Redis のメモリ使用量が増加し、機密情報が含まれる可能性があります。",
"storageTip": "詳細データが見つかりません。原因の例:Redis が未設定/利用不可 (REDIS_URL + ENABLE_RATE_LIMIT=true)、データの期限切れ (SESSION_TTL、既定 300 秒)、または応答本文の保存を無効化 (STORE_SESSION_RESPONSE_BODY=false、応答本文のみ)。未マスクの messages を保存するには STORE_SESSION_MESSAGES=true を設定してください。",
"clientInfo": "クライアント情報",
"requestHeaders": "リクエストヘッダー",
"requestBody": "リクエストボディ",
Expand Down Expand Up @@ -571,7 +572,7 @@
"fetchFailed": "取得失敗",
"unknownError": "不明なエラー",
"storageNotEnabled": "未保存",
"storageNotEnabledHint": "ヒント: メッセージの保存を有効にするには、環境変数 STORE_SESSION_MESSAGES=true を設定してください"
"storageNotEnabledHint": "ヒント: REDIS_URL と ENABLE_RATE_LIMIT=true を確認してください (セッション詳細キャッシュ)。未マスクの messages を保存するには STORE_SESSION_MESSAGES=true を設定してください"
},
"errors": {
"copyFailed": "コピー失敗"
Expand Down
5 changes: 3 additions & 2 deletions messages/ru/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"billingRedirected": "оплата: факт."
},
"errorMessage": "Сообщение об ошибке",
"fake200ForwardedNotice": "Примечание: для потоковых запросов эта ошибка может быть обнаружена только после завершения потока; содержимое ответа могло уже быть передано клиенту.",
"filteredProviders": "Отфильтрованные поставщики",
"providerChain": {
"title": "Хронология цепочки решений поставщика",
Expand Down Expand Up @@ -493,7 +494,7 @@
"providers": "Поставщики",
"models": "Модели",
"noDetailedData": "Подробные данные отсутствуют",
"storageTip": "Подробные данные не найдены. Чтобы просмотреть детали запроса, проверьте, установлена ли переменная окружения STORE_SESSION_MESSAGES в значение true. Примечание: включение увеличит использование памяти Redis и может содержать конфиденциальную информацию.",
"storageTip": "Подробные данные не найдены. Возможные причины: Redis отключен/недоступен (REDIS_URL + ENABLE_RATE_LIMIT=true), данные истекли (SESSION_TTL, по умолчанию 300с), или сохранение тела ответа отключено (STORE_SESSION_RESPONSE_BODY=false, влияет только на тело ответа). Чтобы сохранять сообщения без маскировки, установите STORE_SESSION_MESSAGES=true.",
"clientInfo": "Информация о клиенте",
"requestHeaders": "Заголовки запроса",
"requestBody": "Тело запроса",
Expand Down Expand Up @@ -571,7 +572,7 @@
"fetchFailed": "Не удалось получить",
"unknownError": "Неизвестная ошибка",
"storageNotEnabled": "Не сохранено",
"storageNotEnabledHint": "Подсказка: установите переменную окружения STORE_SESSION_MESSAGES=true, чтобы включить сохранение сообщений"
"storageNotEnabledHint": "Подсказка: проверьте REDIS_URL и ENABLE_RATE_LIMIT=true (кэш деталей сессии). Чтобы сохранять сообщения без маскировки, установите STORE_SESSION_MESSAGES=true."
},
"errors": {
"copyFailed": "Не удалось скопировать"
Expand Down
5 changes: 3 additions & 2 deletions messages/zh-CN/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"billingRedirected": "计费: 实际"
},
"errorMessage": "错误信息",
"fake200ForwardedNotice": "提示:对于流式请求,该失败可能在流结束后才被识别;响应内容可能已原样透传给客户端。",
"filteredProviders": "被过滤的供应商",
"providerChain": {
"title": "供应商决策链时间线",
Expand Down Expand Up @@ -493,7 +494,7 @@
"providers": "供应商",
"models": "模型",
"noDetailedData": "暂无详细数据",
"storageTip": "未找到详细数据。如需查看请求详情,请检查环境变量 STORE_SESSION_MESSAGES 是否已设置为 true。注意:启用后会增加 Redis 内存使用,且可能包含敏感信息。",
"storageTip": "未找到详细数据。可能原因:Redis 未配置/不可用(REDIS_URL + ENABLE_RATE_LIMIT=true)、数据已过期(SESSION_TTL,默认 300 秒),或已禁用响应体存储(STORE_SESSION_RESPONSE_BODY=false,仅影响响应体)。如需保存未脱敏 messages,请设置 STORE_SESSION_MESSAGES=true。",
"clientInfo": "客户端信息",
"requestHeaders": "请求头",
"requestBody": "请求体",
Expand Down Expand Up @@ -571,7 +572,7 @@
"fetchFailed": "获取失败",
"unknownError": "未知错误",
"storageNotEnabled": "未存储",
"storageNotEnabledHint": "提示:请设置环境变量 STORE_SESSION_MESSAGES=true 以启用 messages 存储"
"storageNotEnabledHint": "提示:请检查 REDIS_URL 与 ENABLE_RATE_LIMIT=true(用于会话详情缓存);如需保存未脱敏 messages,请设置 STORE_SESSION_MESSAGES=true。"
},
"errors": {
"copyFailed": "复制失败"
Expand Down
5 changes: 3 additions & 2 deletions messages/zh-TW/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"billingRedirected": "計費: 實際"
},
"errorMessage": "錯誤訊息",
"fake200ForwardedNotice": "提示:對於串流請求,此失敗可能在串流結束後才被識別;回應內容可能已原樣透傳給用戶端。",
"filteredProviders": "被過濾的供應商",
"providerChain": {
"title": "供應商決策鏈時間軸",
Expand Down Expand Up @@ -493,7 +494,7 @@
"providers": "供應商",
"models": "Model",
"noDetailedData": "暫無詳細資料",
"storageTip": "未找到詳細資料。如需查看請求詳情,請檢查環境變數 STORE_SESSION_MESSAGES 是否已設定為 true。注意:啟用後會增加 Redis 記憶體使用,且可能包含敏感資訊。",
"storageTip": "未找到詳細資料。可能原因:Redis 未設定/不可用(REDIS_URL + ENABLE_RATE_LIMIT=true)、資料已過期(SESSION_TTL,預設 300 秒),或已停用回應本文儲存(STORE_SESSION_RESPONSE_BODY=false,僅影響回應本文)。如需儲存未脫敏的 messages,請設定 STORE_SESSION_MESSAGES=true。",
"clientInfo": "用戶端資訊",
"requestHeaders": "請求頭",
"requestBody": "請求體",
Expand Down Expand Up @@ -571,7 +572,7 @@
"fetchFailed": "取得失敗",
"unknownError": "未知錯誤",
"storageNotEnabled": "未儲存",
"storageNotEnabledHint": "提示:請設定環境變數 STORE_SESSION_MESSAGES=true 以啟用訊息儲存"
"storageNotEnabledHint": "提示:請檢查 REDIS_URL 與 ENABLE_RATE_LIMIT=true(用於 Session 詳情快取);如需儲存未脫敏的 messages,請設定 STORE_SESSION_MESSAGES=true。"
},
"errors": {
"copyFailed": "複製失敗"
Expand Down
1 change: 1 addition & 0 deletions scripts/deploy.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ ENABLE_RATE_LIMIT=true
# Session Configuration
SESSION_TTL=300
STORE_SESSION_MESSAGES=false
STORE_SESSION_RESPONSE_BODY=true

# Cookie Security
ENABLE_SECURE_COOKIES=$secureCookies
Expand Down
1 change: 1 addition & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ ENABLE_RATE_LIMIT=true
# Session Configuration
SESSION_TTL=300
STORE_SESSION_MESSAGES=false
STORE_SESSION_RESPONSE_BODY=true

# Cookie Security
ENABLE_SECURE_COOKIES=${secure_cookies}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ const messages = {
default: "No error",
},
errorMessage: "Error message",
fake200ForwardedNotice: "Note: detected after stream end; payload may have been forwarded",
viewDetails: "View details",
filteredProviders: "Filtered providers",
providerChain: {
Expand Down Expand Up @@ -325,6 +326,21 @@ function parseHtml(html: string) {
}

describe("error-details-dialog layout", () => {
test("renders fake-200 forwarded notice when errorMessage is a FAKE_200_* code", () => {
const html = renderWithIntl(
<ErrorDetailsDialog
externalOpen
statusCode={502}
errorMessage={"FAKE_200_EMPTY_BODY"}
providerChain={null}
sessionId={null}
/>
);

expect(html).toContain("FAKE_200_EMPTY_BODY");
expect(html).toContain("Note: detected after stream end; payload may have been forwarded");
});

test("renders special settings section when specialSettings exists", () => {
const html = renderWithIntl(
<ErrorDetailsDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DollarSign,
ExternalLink,
Globe,
InfoIcon,
Loader2,
Monitor,
Settings2,
Expand Down Expand Up @@ -64,6 +65,8 @@ export function SummaryTab({
const hasRedirect = originalModel && currentModel && originalModel !== currentModel;
const specialSettingsContent =
specialSettings && specialSettings.length > 0 ? JSON.stringify(specialSettings, null, 2) : null;
const isFake200PostStreamFailure =
typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_");

return (
<div className="space-y-6">
Expand Down Expand Up @@ -423,6 +426,13 @@ export function SummaryTab({
<p className="text-xs text-rose-800 dark:text-rose-200 line-clamp-3 font-mono">
{errorMessage.length > 200 ? `${errorMessage.slice(0, 200)}...` : errorMessage}
</p>
{/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端,因此需要提示用户避免误解。 */}
{isFake200PostStreamFailure && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-[11px] text-amber-800 dark:border-amber-800 dark:bg-amber-950/20 dark:text-amber-200">
<InfoIcon className="h-3.5 w-3.5 shrink-0 mt-0.5" aria-hidden="true" />
<span>{t("fake200ForwardedNotice")}</span>
</div>
)}
{errorMessage.length > 200 && onViewLogicTrace && (
<Button
variant="link"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const messages = {
},
details: {
clickStatusCode: "Click status code",
fake200ForwardedNotice: "Note: payload may have been forwarded",
},
},
},
Expand Down Expand Up @@ -256,6 +257,25 @@ describe("provider-chain-popover group badges", () => {
});

describe("provider-chain-popover layout", () => {
test("renders fake-200 forwarded notice when chain has FAKE_200_* errorMessage", () => {
const html = renderWithIntl(
<ProviderChainPopover
chain={[
{
id: 1,
name: "p1",
reason: "retry_failed",
statusCode: 502,
errorMessage: "FAKE_200_EMPTY_BODY",
},
]}
finalProvider="p1"
/>
);

expect(html).toContain("Note: payload may have been forwarded");
});

test("requestCount<=1 branch keeps truncation container shrinkable", () => {
const html = renderWithIntl(
<ProviderChainPopover
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export function ProviderChainPopover({
const t = useTranslations("dashboard");
const tChain = useTranslations("provider-chain");

// “假 200”识别发生在 SSE 流式结束后:此时响应内容可能已透传给客户端,但内部会按失败统计/熔断。
const hasFake200PostStreamFailure = chain.some(
(item) => typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_")
);

// Calculate actual request count (excluding intermediate states)
const requestCount = chain.filter(isActualRequest).length;

Expand Down Expand Up @@ -152,6 +157,14 @@ export function ProviderChainPopover({
{/* Provider name */}
<div className="font-medium text-xs">{displayName}</div>

{/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端。 */}
{hasFake200PostStreamFailure && (
<div className="flex items-start gap-1.5 text-[10px] text-amber-500 dark:text-amber-400">
<InfoIcon className="h-3 w-3 shrink-0 mt-0.5" aria-hidden="true" />
<span>{t("logs.details.fake200ForwardedNotice")}</span>
</div>
)}

{/* Session reuse detailed info */}
{isSessionReuse && (
<div className="space-y-1.5 pt-1 border-t border-zinc-600 dark:border-zinc-300">
Expand Down Expand Up @@ -453,6 +466,12 @@ export function ProviderChainPopover({
</div>

<div className="p-2 border-t bg-muted/30">
{hasFake200PostStreamFailure && (
<div className="flex items-start justify-center gap-1.5 text-[10px] text-amber-700 dark:text-amber-300 px-2 pb-1">
<InfoIcon className="h-3 w-3 shrink-0 mt-0.5" aria-hidden="true" />
<span className="text-center">{t("logs.details.fake200ForwardedNotice")}</span>
</div>
)}
<p className="text-[10px] text-muted-foreground text-center">
{onChainItemClick
? t("logs.providerChain.clickItemForDetails")
Expand Down
35 changes: 35 additions & 0 deletions src/app/v1/_lib/proxy/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
import { ModelRedirector } from "./model-redirector";
import { ProxyProviderResolver } from "./provider-selector";
import type { ProxySession } from "./session";
import { setDeferredStreamingFinalization } from "./stream-finalization";
import {
detectThinkingBudgetRectifierTrigger,
rectifyThinkingBudget,
Expand Down Expand Up @@ -377,6 +378,40 @@ export class ProxyForwarder {
const contentType = response.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream");

// ========== 流式响应:延迟成功判定(避免“假 200”)==========
// 背景:上游可能返回 HTTP 200,但 SSE 内容为错误 JSON(如 {"error": "..."})。
// 如果在“收到响应头”时就立刻记录 success / 更新 session 绑定:
// - 会把会话粘到一个实际不可用的 provider;
// - 熔断/故障转移统计被误记为成功;
// - 客户端下一次自动重试可能仍复用到同一 provider,导致“假 200”让重试失效。
//
// 解决:Forwarder 只负责尽快把 Response 返回给下游开始透传,
// 把最终成功/失败结算延迟到 ResponseHandler:等 SSE 正常结束后再基于最终 body 补充检查并更新内部状态。
if (isSSE) {
setDeferredStreamingFinalization(session, {
providerId: currentProvider.id,
providerName: currentProvider.name,
providerPriority: currentProvider.priority || 0,
attemptNumber: attemptCount,
totalProvidersAttempted,
isFirstAttempt: totalProvidersAttempted === 1 && attemptCount === 1,
isFailoverSuccess: totalProvidersAttempted > 1,
endpointId: activeEndpoint.endpointId,
endpointUrl: endpointAudit.endpointUrl,
upstreamStatusCode: response.status,
});

logger.info("ProxyForwarder: Streaming response received, deferring finalization", {
providerId: currentProvider.id,
providerName: currentProvider.name,
attemptNumber: attemptCount,
totalProvidersAttempted,
statusCode: response.status,
});

return response;
}

if (!isSSE) {
// 非流式响应:检测空响应
const contentLength = response.headers.get("content-length");
Expand Down
Loading
Loading