feat: track thread ids and retry stream disconnects

This commit is contained in:
2026-06-30 11:25:36 +08:00
parent ec1f8ceb16
commit a642022066
7 changed files with 244 additions and 19 deletions
+2 -1
View File
@@ -9,7 +9,7 @@ tg群:https://t.me/AI_INPUT_IM
- 保持 Codex 继续使用现有 `auth.json` - 保持 Codex 继续使用现有 `auth.json`
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 - 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
- 非流式命中 `reasoning_tokens = 516` 时返回 `502` - 非流式命中 `reasoning_tokens = 516` 时返回 `502`
- 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`),也会自动重试;重试耗尽后转成本地 `502` - 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`,以及 `stream disconnected before completion: Concurrency limit exceeded for account, please retry later`),也会自动重试;重试耗尽后转成本地 `502`
- 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况 - 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 默认同时拦截 root 路径和 `/v1` 路径: - 默认同时拦截 root 路径和 `/v1` 路径:
@@ -262,6 +262,7 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json
- 默认 `[429, 503]` - 默认 `[429, 503]`
- `retryable_error_messages` - `retryable_error_messages`
- 默认包含 `Selected model is at capacity. Please try a different model.` - 默认包含 `Selected model is at capacity. Please try a different model.`
- 也默认包含 `stream disconnected before completion: Concurrency limit exceeded for account, please retry later`
- 只要上游 JSON 错误里包含这些文案之一,gateway 就会把上游错误翻成本地 `non_stream_status_code` - 只要上游 JSON 错误里包含这些文案之一,gateway 就会把上游错误翻成本地 `non_stream_status_code`
- `endpoints` - `endpoints`
- 默认包含 root 与 `/v1` 两套路径 - 默认包含 root 与 `/v1` 两套路径
+2 -1
View File
@@ -14,7 +14,8 @@
"reasoning_equals": [516], "reasoning_equals": [516],
"retryable_status_codes": [429, 503], "retryable_status_codes": [429, 503],
"retryable_error_messages": [ "retryable_error_messages": [
"Selected model is at capacity. Please try a different model." "Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later"
], ],
"non_stream_status_code": 502, "non_stream_status_code": 502,
"stream_action": "strict_502", "stream_action": "strict_502",
+163 -3
View File
@@ -45,6 +45,7 @@ const DEFAULT_CONFIG = {
retryable_status_codes: [429, 503], retryable_status_codes: [429, 503],
retryable_error_messages: [ retryable_error_messages: [
"Selected model is at capacity. Please try a different model.", "Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
], ],
upstream_fetch_retry_attempts: 5, upstream_fetch_retry_attempts: 5,
upstream_fetch_retry_backoff_ms: 350, upstream_fetch_retry_backoff_ms: 350,
@@ -60,6 +61,37 @@ const REASONING_POINTERS = [
"/response/usage/output_tokens_details/reasoning_tokens", "/response/usage/output_tokens_details/reasoning_tokens",
"/response/usage/completion_tokens_details/reasoning_tokens", "/response/usage/completion_tokens_details/reasoning_tokens",
]; ];
const REQUEST_THREAD_ID_POINTERS = [
"/thread_id",
"/thread",
"/thread/id",
"/conversation_id",
"/conversation",
"/conversation/id",
];
const RESPONSE_THREAD_ID_POINTERS = [
"/thread_id",
"/thread",
"/thread/id",
"/conversation_id",
"/conversation",
"/conversation/id",
"/response/thread_id",
"/response/thread",
"/response/thread/id",
"/response/conversation_id",
"/response/conversation",
"/response/conversation/id",
];
const NON_STREAM_RESPONSE_ID_POINTERS = [
"/id",
"/response_id",
"/response/id",
];
const STREAM_RESPONSE_ID_POINTERS = [
"/response_id",
"/response/id",
];
function parseArgs(argv) { function parseArgs(argv) {
const args = { config: null, log: null }; const args = { config: null, log: null };
@@ -150,6 +182,49 @@ function firstInteger(...values) {
return null; return null;
} }
function firstNonEmptyString(...values) {
for (const value of values) {
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
return null;
}
function extractStringByPointers(payload, pointers) {
for (const pointer of pointers) {
const raw = jsonPointerGet(payload, pointer);
if (typeof raw !== "string") {
continue;
}
const trimmed = raw.trim();
if (trimmed) {
return trimmed;
}
}
return null;
}
function extractRequestThreadId(payload) {
return extractStringByPointers(payload, REQUEST_THREAD_ID_POINTERS);
}
function extractResponseThreadId(payload) {
return extractStringByPointers(payload, RESPONSE_THREAD_ID_POINTERS);
}
function extractNonStreamingResponseId(payload) {
return extractStringByPointers(payload, NON_STREAM_RESPONSE_ID_POINTERS);
}
function extractStreamingResponseId(payload) {
return extractStringByPointers(payload, STREAM_RESPONSE_ID_POINTERS);
}
function normalizeUsageSnapshot(payload) { function normalizeUsageSnapshot(payload) {
const usage = payload?.usage || payload?.response?.usage || null; const usage = payload?.usage || payload?.response?.usage || null;
if (!usage || typeof usage !== "object") { if (!usage || typeof usage !== "object") {
@@ -458,6 +533,8 @@ function openRequestsDatabase(dbPath) {
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
seq INTEGER PRIMARY KEY, seq INTEGER PRIMARY KEY,
request_id TEXT, request_id TEXT,
response_id TEXT,
thread_id TEXT,
started_at TEXT, started_at TEXT,
finished_at TEXT, finished_at TEXT,
duration_ms INTEGER, duration_ms INTEGER,
@@ -496,7 +573,15 @@ function openRequestsDatabase(dbPath) {
if (!requestColumnNames.has("request_id")) { if (!requestColumnNames.has("request_id")) {
db.exec("ALTER TABLE requests ADD COLUMN request_id TEXT"); db.exec("ALTER TABLE requests ADD COLUMN request_id TEXT");
} }
if (!requestColumnNames.has("response_id")) {
db.exec("ALTER TABLE requests ADD COLUMN response_id TEXT");
}
if (!requestColumnNames.has("thread_id")) {
db.exec("ALTER TABLE requests ADD COLUMN thread_id TEXT");
}
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_request_id ON requests(request_id)"); db.exec("CREATE INDEX IF NOT EXISTS idx_requests_request_id ON requests(request_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_response_id ON requests(response_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_thread_id ON requests(thread_id)");
return db; return db;
} }
@@ -528,6 +613,8 @@ function requestRowFromEntry(entry) {
return { return {
seq: entry.seq, seq: entry.seq,
request_id: entry.request_id || null, request_id: entry.request_id || null,
response_id: entry.response_id || null,
thread_id: entry.thread_id || null,
started_at: entry.started_at || null, started_at: entry.started_at || null,
finished_at: entry.finished_at || null, finished_at: entry.finished_at || null,
duration_ms: Number.isInteger(entry.duration_ms) ? entry.duration_ms : null, duration_ms: Number.isInteger(entry.duration_ms) ? entry.duration_ms : null,
@@ -560,13 +647,13 @@ function requestRowFromEntry(entry) {
function insertRequestRow(db, row) { function insertRequestRow(db, row) {
db.prepare(` db.prepare(`
INSERT OR REPLACE INTO requests ( INSERT OR REPLACE INTO requests (
seq, request_id, started_at, finished_at, duration_ms, profile_name, method, path, model, seq, request_id, response_id, thread_id, started_at, finished_at, duration_ms, profile_name, method, path, model,
requested_model, forwarded_model, request_stream, response_stream, inspected, matched, requested_model, forwarded_model, request_stream, response_stream, inspected, matched,
status_code, upstream_status_code, reasoning_tokens, input_tokens, output_tokens, status_code, upstream_status_code, reasoning_tokens, input_tokens, output_tokens,
total_tokens, cached_tokens, error, upstream_origin, upstream_path, total_tokens, cached_tokens, error, upstream_origin, upstream_path,
upstream_auth_mode, upstream_auth_source, payload_json upstream_auth_mode, upstream_auth_source, payload_json
) VALUES ( ) VALUES (
@seq, @request_id, @started_at, @finished_at, @duration_ms, @profile_name, @method, @path, @model, @seq, @request_id, @response_id, @thread_id, @started_at, @finished_at, @duration_ms, @profile_name, @method, @path, @model,
@requested_model, @forwarded_model, @request_stream, @response_stream, @inspected, @matched, @requested_model, @forwarded_model, @request_stream, @response_stream, @inspected, @matched,
@status_code, @upstream_status_code, @reasoning_tokens, @input_tokens, @output_tokens, @status_code, @upstream_status_code, @reasoning_tokens, @input_tokens, @output_tokens,
@total_tokens, @cached_tokens, @error, @upstream_origin, @upstream_path, @total_tokens, @cached_tokens, @error, @upstream_origin, @upstream_path,
@@ -627,6 +714,8 @@ function buildRequestQueryFilters({ query, filter }) {
lower(coalesce(method, '')) LIKE @query OR lower(coalesce(method, '')) LIKE @query OR
lower(coalesce(path, '')) LIKE @query OR lower(coalesce(path, '')) LIKE @query OR
lower(coalesce(request_id, '')) LIKE @query OR lower(coalesce(request_id, '')) LIKE @query OR
lower(coalesce(response_id, '')) LIKE @query OR
lower(coalesce(thread_id, '')) LIKE @query OR
lower(coalesce(model, '')) LIKE @query OR lower(coalesce(model, '')) LIKE @query OR
lower(coalesce(requested_model, '')) LIKE @query OR lower(coalesce(requested_model, '')) LIKE @query OR
lower(coalesce(forwarded_model, '')) LIKE @query OR lower(coalesce(forwarded_model, '')) LIKE @query OR
@@ -1222,6 +1311,8 @@ function buildRequestEntry({ seq, startedAt, startedMs, req, pathname, requestJs
return { return {
seq, seq,
request_id: null, request_id: null,
response_id: null,
thread_id: extractRequestThreadId(requestJson),
lifecycle_state: "sent", lifecycle_state: "sent",
started_at: startedAt.toISOString(), started_at: startedAt.toISOString(),
first_response_at: null, first_response_at: null,
@@ -2562,14 +2653,26 @@ function findRetryableStreamErrorMatch(config, parsedBody, bodyText, eventName =
return matchRetryableMessage(config, parsedBody, bodyText); return matchRetryableMessage(config, parsedBody, bodyText);
} }
function findRetryableStreamTerminationMatch(config, error) {
const message = `${error?.message || error || ""}`.trim();
if (!message) {
return null;
}
return matchRetryableMessage(config, null, message);
}
function isExpectedStreamTermination(error) { function isExpectedStreamTermination(error) {
if (!error) { if (!error) {
return false; return false;
} }
const message = `${error?.message || ""}`.trim().toLowerCase();
if (error.name === "AbortError") { if (error.name === "AbortError") {
return true; return true;
} }
return error instanceof TypeError && error.message === "terminated"; return error instanceof TypeError && (
message === "terminated" ||
message.includes("stream disconnected before completion")
);
} }
function isRetryableUpstreamFetchError(error) { function isRetryableUpstreamFetchError(error) {
@@ -2701,6 +2804,8 @@ function inspectSseChunk(state, chunk, config) {
const result = { const result = {
reasoning: null, reasoning: null,
usage: null, usage: null,
response_id: null,
thread_id: null,
retryable_upstream_error: null, retryable_upstream_error: null,
}; };
@@ -2735,6 +2840,8 @@ function inspectSseChunk(state, chunk, config) {
result.reasoning = reasoning; result.reasoning = reasoning;
} }
result.usage = mergeUsageSnapshots(result.usage, normalizeUsageSnapshot(parsed)); result.usage = mergeUsageSnapshots(result.usage, normalizeUsageSnapshot(parsed));
result.response_id = result.response_id || extractStreamingResponseId(parsed);
result.thread_id = result.thread_id || extractResponseThreadId(parsed);
} catch { } catch {
// ignore malformed SSE payloads // ignore malformed SSE payloads
} }
@@ -2770,6 +2877,11 @@ async function handleNonStreaming({
: null; : null;
const reasoning = parsed ? extractReasoningTokens(parsed) : null; const reasoning = parsed ? extractReasoningTokens(parsed) : null;
const usage = parsed ? normalizeUsageSnapshot(parsed) : null; const usage = parsed ? normalizeUsageSnapshot(parsed) : null;
const responseId = parsed ? extractNonStreamingResponseId(parsed) : null;
const threadId = firstNonEmptyString(
requestEntry.thread_id,
parsed ? extractResponseThreadId(parsed) : null,
);
const matched = reasoningMatched(config, reasoning); const matched = reasoningMatched(config, reasoning);
const retryableUpstreamError = terminalRetryableUpstreamError || findRetryableUpstreamErrorMatch( const retryableUpstreamError = terminalRetryableUpstreamError || findRetryableUpstreamErrorMatch(
config, config,
@@ -2777,6 +2889,8 @@ async function handleNonStreaming({
parsed, parsed,
bodyText, bodyText,
); );
requestEntry.response_id = responseId || requestEntry.response_id || null;
requestEntry.thread_id = threadId || requestEntry.thread_id || null;
recordInspectedResponse(monitor, reasoning, matched || Boolean(retryableUpstreamError)); recordInspectedResponse(monitor, reasoning, matched || Boolean(retryableUpstreamError));
@@ -2799,6 +2913,8 @@ async function handleNonStreaming({
upstream_status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status,
reasoning_tokens: reasoning, reasoning_tokens: reasoning,
usage, usage,
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
}; };
} }
@@ -2828,6 +2944,8 @@ async function handleNonStreaming({
usage, usage,
error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`, error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`,
match_reason: "retryable_upstream_error", match_reason: "retryable_upstream_error",
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
}; };
} }
@@ -2841,6 +2959,8 @@ async function handleNonStreaming({
upstream_status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status,
reasoning_tokens: reasoning, reasoning_tokens: reasoning,
usage, usage,
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
}; };
} }
@@ -2878,6 +2998,24 @@ async function handleStreaming({
readResult = await reader.read(); readResult = await reader.read();
} catch (error) { } catch (error) {
if (isExpectedStreamTermination(error)) { if (isExpectedStreamTermination(error)) {
const retryableTerminationError = findRetryableStreamTerminationMatch(config, error);
if (retryableTerminationError) {
return {
inspected: true,
matched: true,
retry_requested: strict502Mode || !wroteAnyChunk,
retryable_upstream_error: retryableTerminationError,
upstream_status_code: upstreamResponse.status,
reasoning_tokens: observedReasoning,
usage: observedUsage,
error: `retryable upstream error: ${retryableTerminationError.matched_pattern}`,
match_reason: "retryable_upstream_error",
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
response_bytes_received: requestEntry.response_bytes_received,
stream_chunk_count: requestEntry.stream_chunk_count,
};
}
recordInspectedResponse(monitor, observedReasoning, false); recordInspectedResponse(monitor, observedReasoning, false);
persistStreamingProgress(runtime, requestEntry, { force: true }, new Date()); persistStreamingProgress(runtime, requestEntry, { force: true }, new Date());
if (strict502Mode) { if (strict502Mode) {
@@ -2892,6 +3030,8 @@ async function handleStreaming({
reasoning_tokens: observedReasoning, reasoning_tokens: observedReasoning,
usage: observedUsage, usage: observedUsage,
error: "upstream stream terminated before completion", error: "upstream stream terminated before completion",
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
response_bytes_received: requestEntry.response_bytes_received, response_bytes_received: requestEntry.response_bytes_received,
stream_chunk_count: requestEntry.stream_chunk_count, stream_chunk_count: requestEntry.stream_chunk_count,
}; };
@@ -2905,6 +3045,8 @@ async function handleStreaming({
reasoning_tokens: observedReasoning, reasoning_tokens: observedReasoning,
usage: observedUsage, usage: observedUsage,
error: "upstream stream terminated before completion", error: "upstream stream terminated before completion",
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
response_bytes_received: requestEntry.response_bytes_received, response_bytes_received: requestEntry.response_bytes_received,
stream_chunk_count: requestEntry.stream_chunk_count, stream_chunk_count: requestEntry.stream_chunk_count,
}; };
@@ -2931,6 +3073,8 @@ async function handleStreaming({
upstream_status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status,
reasoning_tokens: observedReasoning, reasoning_tokens: observedReasoning,
usage: observedUsage, usage: observedUsage,
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
response_bytes_received: requestEntry.response_bytes_received, response_bytes_received: requestEntry.response_bytes_received,
stream_chunk_count: requestEntry.stream_chunk_count, stream_chunk_count: requestEntry.stream_chunk_count,
}; };
@@ -2953,6 +3097,8 @@ async function handleStreaming({
usage: observedUsage, usage: observedUsage,
error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`, error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`,
match_reason: "retryable_upstream_error", match_reason: "retryable_upstream_error",
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
response_bytes_received: requestEntry.response_bytes_received, response_bytes_received: requestEntry.response_bytes_received,
stream_chunk_count: requestEntry.stream_chunk_count, stream_chunk_count: requestEntry.stream_chunk_count,
}; };
@@ -2965,6 +3111,12 @@ async function handleStreaming({
if (Number.isInteger(reasoning)) { if (Number.isInteger(reasoning)) {
observedReasoning = reasoning; observedReasoning = reasoning;
} }
if (inspection.response_id) {
requestEntry.response_id = inspection.response_id;
}
if (inspection.thread_id) {
requestEntry.thread_id = inspection.thread_id;
}
updateStreamingProgress(requestEntry, { updateStreamingProgress(requestEntry, {
chunkBytes: chunkBuffer.length, chunkBytes: chunkBuffer.length,
usage: inspection.usage, usage: inspection.usage,
@@ -3006,6 +3158,8 @@ async function handleStreaming({
upstream_status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status,
reasoning_tokens: reasoning, reasoning_tokens: reasoning,
usage: observedUsage, usage: observedUsage,
response_id: requestEntry.response_id,
thread_id: requestEntry.thread_id,
response_bytes_received: requestEntry.response_bytes_received, response_bytes_received: requestEntry.response_bytes_received,
stream_chunk_count: requestEntry.stream_chunk_count, stream_chunk_count: requestEntry.stream_chunk_count,
}; };
@@ -3080,6 +3234,7 @@ async function proxyRequest(runtime, req, res) {
let totalUpstreamAttempts = 0; let totalUpstreamAttempts = 0;
requestEntry.request_body_bytes = rawRequestBody.length; requestEntry.request_body_bytes = rawRequestBody.length;
requestEntry.request_id = computeRequestId(pathname, rawRequestBody); requestEntry.request_id = computeRequestId(pathname, rawRequestBody);
requestEntry.thread_id = extractRequestThreadId(parsedRequestJson);
requestEntry.model = requestJson?.model || null; requestEntry.model = requestJson?.model || null;
requestEntry.requested_model = parsedRequestJson?.model || null; requestEntry.requested_model = parsedRequestJson?.model || null;
requestEntry.forwarded_model = forwardedModel || parsedRequestJson?.model || null; requestEntry.forwarded_model = forwardedModel || parsedRequestJson?.model || null;
@@ -3140,6 +3295,11 @@ async function proxyRequest(runtime, req, res) {
copyHeadersToClient(upstreamResponse.headers, res); copyHeadersToClient(upstreamResponse.headers, res);
res.writeHead(upstreamResponse.status); res.writeHead(upstreamResponse.status);
const body = Buffer.from(await upstreamResponse.arrayBuffer()); const body = Buffer.from(await upstreamResponse.arrayBuffer());
const parsed = isJsonContentType(upstreamResponse.headers.get("content-type"))
? parseJsonSafely(body)
: null;
requestEntry.response_id = requestEntry.response_id || (parsed ? extractNonStreamingResponseId(parsed) : null);
requestEntry.thread_id = requestEntry.thread_id || (parsed ? extractResponseThreadId(parsed) : null);
res.end(body); res.end(body);
recordRequestEntry( recordRequestEntry(
runtime, runtime,
+1
View File
@@ -428,6 +428,7 @@ export async function installForCurrentProvider({
retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]), retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]),
retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [ retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [
"Selected model is at capacity. Please try a different model.", "Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
]), ]),
non_stream_status_code: non_stream_status_code:
existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null
+4 -1
View File
@@ -200,7 +200,10 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
), ),
retryable_error_messages: normalizePhraseArray( retryable_error_messages: normalizePhraseArray(
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || existingGatewayConfig?.retryable_error_messages, profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || existingGatewayConfig?.retryable_error_messages,
["Selected model is at capacity. Please try a different model."], [
"Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
],
), ),
upstream_fetch_retry_attempts: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS upstream_fetch_retry_attempts: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10) ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10)
+47 -2
View File
@@ -232,7 +232,7 @@ function startFakeUpstream(port) {
} }
if (parsed.stream) { if (parsed.stream) {
createSseResponse(res, [ createSseResponse(res, [
'data: {"type":"response.output_text.delta","delta":"hello"}\n\n', `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "hello", response_id: "resp_stream", thread_id: parsed.thread_id || "thread_stream" })}\n\n`,
`data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`, `data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`,
"data: [DONE]\n\n", "data: [DONE]\n\n",
], parsed.test_stream_chunk_delay_ms ?? 20); ], parsed.test_stream_chunk_delay_ms ?? 20);
@@ -243,6 +243,7 @@ function startFakeUpstream(port) {
200, 200,
{ {
id: "resp_test", id: "resp_test",
thread_id: parsed.thread_id || "thread_test",
retry_attempt: parsed.test_fail_before_response_once retry_attempt: parsed.test_fail_before_response_once
? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0 ? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0
: 0, : 0,
@@ -383,7 +384,10 @@ async function run() {
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
reasoning_equals: [516], reasoning_equals: [516],
retryable_status_codes: [429, 503], retryable_status_codes: [429, 503],
retryable_error_messages: ["Selected model is at capacity. Please try a different model."], retryable_error_messages: [
"Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
],
upstream_fetch_retry_attempts: 5, upstream_fetch_retry_attempts: 5,
upstream_fetch_retry_backoff_ms: 25, upstream_fetch_retry_backoff_ms: 25,
non_stream_status_code: 502, non_stream_status_code: 502,
@@ -461,6 +465,22 @@ async function run() {
); );
assert(recoveredEntry?.request_id, "请求记录未生成 request_id"); assert(recoveredEntry?.request_id, "请求记录未生成 request_id");
const threadTrackedResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 128, thread_id: "thread_nonstream" }),
});
const threadTrackedBody = await threadTrackedResponse.json();
assert(threadTrackedResponse.status === 200, `thread non-stream 请求失败: ${threadTrackedResponse.status}`);
assert(threadTrackedBody?.id === "resp_test", "thread non-stream 返回体缺少 response id");
assert(threadTrackedBody?.thread_id === "thread_nonstream", "thread non-stream 返回体缺少 thread_id");
const threadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_nonstream")}`);
const threadRequestsPayload = await threadRequestsResponse.json();
const threadEntry = (threadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_nonstream");
assert(threadEntry?.response_id === "resp_test", "non-stream 请求记录未保留 response_id");
assert(threadEntry?.thread_id === "thread_nonstream", "non-stream 请求记录未保留 thread_id");
const sameRequestPayload = JSON.stringify({ test_reasoning_tokens: 128, test_request_id_marker: "same" }); const sameRequestPayload = JSON.stringify({ test_reasoning_tokens: 128, test_request_id_marker: "same" });
const sameRequestFirstResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, { const sameRequestFirstResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST", method: "POST",
@@ -669,6 +689,31 @@ async function run() {
); );
assert(streamCapacityResponseFailedRecoveredEntry, "stream response.failed capacity 恢复后的请求记录未保留重试次数"); assert(streamCapacityResponseFailedRecoveredEntry, "stream response.failed capacity 恢复后的请求记录未保留重试次数");
const streamThreadResponse = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}/responses`,
{ stream: true, test_reasoning_tokens: 128, thread_id: "thread_stream_ok" },
);
assert(streamThreadResponse.status === 200, `stream thread 请求失败: ${streamThreadResponse.status}`);
const streamThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_stream_ok")}`);
const streamThreadRequestsPayload = await streamThreadRequestsResponse.json();
const streamThreadEntry = (streamThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_stream_ok");
assert(streamThreadEntry?.response_id === "resp_stream", "stream 请求记录未保留 response_id");
assert(streamThreadEntry?.thread_id === "thread_stream_ok", "stream 请求记录未保留 thread_id");
const streamDisconnectedRetryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
stream: true,
test_capacity_before_success_times: 2,
test_capacity_message: "stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
test_reasoning_tokens: 128,
}),
});
const streamDisconnectedRetryText = await streamDisconnectedRetryResponse.text();
assert(streamDisconnectedRetryResponse.status === 200, `stream disconnected capacity 抖动后未自动恢复: ${streamDisconnectedRetryResponse.status}`);
assert(streamDisconnectedRetryText.includes("hello"), "stream disconnected capacity 恢复后未拿到正常 SSE 内容");
for (const streamPath of [ for (const streamPath of [
"/responses", "/responses",
"/v1/responses", "/v1/responses",
+16 -2
View File
@@ -71,6 +71,8 @@ type Usage = {
type RequestEntry = { type RequestEntry = {
seq: number; seq: number;
request_id?: string | null; request_id?: string | null;
response_id?: string | null;
thread_id?: string | null;
lifecycle_state?: string | null; lifecycle_state?: string | null;
started_at?: string; started_at?: string;
first_response_at?: string | null; first_response_at?: string | null;
@@ -303,7 +305,7 @@ const defaultProfileForm: ProfileFormState = {
model_remap: "", model_remap: "",
reasoning_equals: "516,1034,1552", reasoning_equals: "516,1034,1552",
retryable_status_codes: "429,503", retryable_status_codes: "429,503",
retryable_error_messages: "Selected model is at capacity. Please try a different model.", retryable_error_messages: "Selected model is at capacity. Please try a different model.\nstream disconnected before completion: Concurrency limit exceeded for account, please retry later",
upstream_fetch_retry_attempts: "5", upstream_fetch_retry_attempts: "5",
upstream_fetch_retry_backoff_ms: "350", upstream_fetch_retry_backoff_ms: "350",
endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions", endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions",
@@ -382,6 +384,10 @@ function cachedRatio(inputTokens?: number | null, cachedTokens?: number | null)
return Math.max(0, Math.min(1, cached / inputTokens)); return Math.max(0, Math.min(1, cached / inputTokens));
} }
function requestPrimaryId(entry: RequestEntry) {
return entry.response_id || entry.request_id || "-";
}
function splitList(value: string) { function splitList(value: string) {
return value return value
.split(/[\s,]+/) .split(/[\s,]+/)
@@ -1105,7 +1111,8 @@ export default function App() {
<div className="request-subtitle"> <div className="request-subtitle">
<code>{`${entry.method || "-"} ${entry.path || "-"}`}</code> <code>{`${entry.method || "-"} ${entry.path || "-"}`}</code>
<span className="hint">{entry.response_stream ? "stream" : "non-stream"}</span> <span className="hint">{entry.response_stream ? "stream" : "non-stream"}</span>
<span className="hint">{entry.request_id || "-"}</span> <span className="hint">id {requestPrimaryId(entry)}</span>
<span className="hint">thread {entry.thread_id || "-"}</span>
</div> </div>
</div> </div>
<div className="request-badges"> <div className="request-badges">
@@ -1148,6 +1155,13 @@ export default function App() {
<span className="token-pill token-total">total {numberFormat(usage.total_tokens)}</span> <span className="token-pill token-total">total {numberFormat(usage.total_tokens)}</span>
</div> </div>
</div> </div>
<div className="request-block">
<label>IDs</label>
<code>{`response ${entry.response_id || "-"}`}</code>
<span className="hint">{`request ${entry.request_id || "-"}`}</span>
<span className="hint">{`thread ${entry.thread_id || "-"}`}</span>
</div>
</div> </div>
</article> </article>
); );