feat: track thread ids and retry stream disconnects
This commit is contained in:
@@ -9,7 +9,7 @@ tg群:https://t.me/AI_INPUT_IM
|
||||
- 保持 Codex 继续使用现有 `auth.json`
|
||||
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
|
||||
- 非流式命中 `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` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
|
||||
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
|
||||
- 默认同时拦截 root 路径和 `/v1` 路径:
|
||||
@@ -262,6 +262,7 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json
|
||||
- 默认 `[429, 503]`
|
||||
- `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`
|
||||
- 只要上游 JSON 错误里包含这些文案之一,gateway 就会把上游错误翻成本地 `non_stream_status_code`
|
||||
- `endpoints`
|
||||
- 默认包含 root 与 `/v1` 两套路径
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"reasoning_equals": [516],
|
||||
"retryable_status_codes": [429, 503],
|
||||
"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,
|
||||
"stream_action": "strict_502",
|
||||
|
||||
+163
-3
@@ -45,6 +45,7 @@ const DEFAULT_CONFIG = {
|
||||
retryable_status_codes: [429, 503],
|
||||
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_backoff_ms: 350,
|
||||
@@ -60,6 +61,37 @@ const REASONING_POINTERS = [
|
||||
"/response/usage/output_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) {
|
||||
const args = { config: null, log: null };
|
||||
@@ -150,6 +182,49 @@ function firstInteger(...values) {
|
||||
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) {
|
||||
const usage = payload?.usage || payload?.response?.usage || null;
|
||||
if (!usage || typeof usage !== "object") {
|
||||
@@ -458,6 +533,8 @@ function openRequestsDatabase(dbPath) {
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
request_id TEXT,
|
||||
response_id TEXT,
|
||||
thread_id TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
@@ -496,7 +573,15 @@ function openRequestsDatabase(dbPath) {
|
||||
if (!requestColumnNames.has("request_id")) {
|
||||
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_response_id ON requests(response_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_thread_id ON requests(thread_id)");
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -528,6 +613,8 @@ function requestRowFromEntry(entry) {
|
||||
return {
|
||||
seq: entry.seq,
|
||||
request_id: entry.request_id || null,
|
||||
response_id: entry.response_id || null,
|
||||
thread_id: entry.thread_id || null,
|
||||
started_at: entry.started_at || null,
|
||||
finished_at: entry.finished_at || null,
|
||||
duration_ms: Number.isInteger(entry.duration_ms) ? entry.duration_ms : null,
|
||||
@@ -560,13 +647,13 @@ function requestRowFromEntry(entry) {
|
||||
function insertRequestRow(db, row) {
|
||||
db.prepare(`
|
||||
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,
|
||||
status_code, upstream_status_code, reasoning_tokens, input_tokens, output_tokens,
|
||||
total_tokens, cached_tokens, error, upstream_origin, upstream_path,
|
||||
upstream_auth_mode, upstream_auth_source, payload_json
|
||||
) 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,
|
||||
@status_code, @upstream_status_code, @reasoning_tokens, @input_tokens, @output_tokens,
|
||||
@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(path, '')) 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(requested_model, '')) LIKE @query OR
|
||||
lower(coalesce(forwarded_model, '')) LIKE @query OR
|
||||
@@ -1222,6 +1311,8 @@ function buildRequestEntry({ seq, startedAt, startedMs, req, pathname, requestJs
|
||||
return {
|
||||
seq,
|
||||
request_id: null,
|
||||
response_id: null,
|
||||
thread_id: extractRequestThreadId(requestJson),
|
||||
lifecycle_state: "sent",
|
||||
started_at: startedAt.toISOString(),
|
||||
first_response_at: null,
|
||||
@@ -2562,14 +2653,26 @@ function findRetryableStreamErrorMatch(config, parsedBody, bodyText, eventName =
|
||||
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) {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
const message = `${error?.message || ""}`.trim().toLowerCase();
|
||||
if (error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
return error instanceof TypeError && error.message === "terminated";
|
||||
return error instanceof TypeError && (
|
||||
message === "terminated" ||
|
||||
message.includes("stream disconnected before completion")
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableUpstreamFetchError(error) {
|
||||
@@ -2701,6 +2804,8 @@ function inspectSseChunk(state, chunk, config) {
|
||||
const result = {
|
||||
reasoning: null,
|
||||
usage: null,
|
||||
response_id: null,
|
||||
thread_id: null,
|
||||
retryable_upstream_error: null,
|
||||
};
|
||||
|
||||
@@ -2735,6 +2840,8 @@ function inspectSseChunk(state, chunk, config) {
|
||||
result.reasoning = reasoning;
|
||||
}
|
||||
result.usage = mergeUsageSnapshots(result.usage, normalizeUsageSnapshot(parsed));
|
||||
result.response_id = result.response_id || extractStreamingResponseId(parsed);
|
||||
result.thread_id = result.thread_id || extractResponseThreadId(parsed);
|
||||
} catch {
|
||||
// ignore malformed SSE payloads
|
||||
}
|
||||
@@ -2770,6 +2877,11 @@ async function handleNonStreaming({
|
||||
: null;
|
||||
const reasoning = parsed ? extractReasoningTokens(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 retryableUpstreamError = terminalRetryableUpstreamError || findRetryableUpstreamErrorMatch(
|
||||
config,
|
||||
@@ -2777,6 +2889,8 @@ async function handleNonStreaming({
|
||||
parsed,
|
||||
bodyText,
|
||||
);
|
||||
requestEntry.response_id = responseId || requestEntry.response_id || null;
|
||||
requestEntry.thread_id = threadId || requestEntry.thread_id || null;
|
||||
|
||||
recordInspectedResponse(monitor, reasoning, matched || Boolean(retryableUpstreamError));
|
||||
|
||||
@@ -2799,6 +2913,8 @@ async function handleNonStreaming({
|
||||
upstream_status_code: upstreamResponse.status,
|
||||
reasoning_tokens: reasoning,
|
||||
usage,
|
||||
response_id: requestEntry.response_id,
|
||||
thread_id: requestEntry.thread_id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2828,6 +2944,8 @@ async function handleNonStreaming({
|
||||
usage,
|
||||
error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`,
|
||||
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,
|
||||
reasoning_tokens: reasoning,
|
||||
usage,
|
||||
response_id: requestEntry.response_id,
|
||||
thread_id: requestEntry.thread_id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2878,6 +2998,24 @@ async function handleStreaming({
|
||||
readResult = await reader.read();
|
||||
} catch (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);
|
||||
persistStreamingProgress(runtime, requestEntry, { force: true }, new Date());
|
||||
if (strict502Mode) {
|
||||
@@ -2892,6 +3030,8 @@ async function handleStreaming({
|
||||
reasoning_tokens: observedReasoning,
|
||||
usage: observedUsage,
|
||||
error: "upstream stream terminated before completion",
|
||||
response_id: requestEntry.response_id,
|
||||
thread_id: requestEntry.thread_id,
|
||||
response_bytes_received: requestEntry.response_bytes_received,
|
||||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||||
};
|
||||
@@ -2905,6 +3045,8 @@ async function handleStreaming({
|
||||
reasoning_tokens: observedReasoning,
|
||||
usage: observedUsage,
|
||||
error: "upstream stream terminated before completion",
|
||||
response_id: requestEntry.response_id,
|
||||
thread_id: requestEntry.thread_id,
|
||||
response_bytes_received: requestEntry.response_bytes_received,
|
||||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||||
};
|
||||
@@ -2931,6 +3073,8 @@ async function handleStreaming({
|
||||
upstream_status_code: upstreamResponse.status,
|
||||
reasoning_tokens: observedReasoning,
|
||||
usage: observedUsage,
|
||||
response_id: requestEntry.response_id,
|
||||
thread_id: requestEntry.thread_id,
|
||||
response_bytes_received: requestEntry.response_bytes_received,
|
||||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||||
};
|
||||
@@ -2953,6 +3097,8 @@ async function handleStreaming({
|
||||
usage: observedUsage,
|
||||
error: `retryable upstream error: ${retryableUpstreamError.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,
|
||||
};
|
||||
@@ -2965,6 +3111,12 @@ async function handleStreaming({
|
||||
if (Number.isInteger(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, {
|
||||
chunkBytes: chunkBuffer.length,
|
||||
usage: inspection.usage,
|
||||
@@ -3006,6 +3158,8 @@ async function handleStreaming({
|
||||
upstream_status_code: upstreamResponse.status,
|
||||
reasoning_tokens: reasoning,
|
||||
usage: observedUsage,
|
||||
response_id: requestEntry.response_id,
|
||||
thread_id: requestEntry.thread_id,
|
||||
response_bytes_received: requestEntry.response_bytes_received,
|
||||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||||
};
|
||||
@@ -3080,6 +3234,7 @@ async function proxyRequest(runtime, req, res) {
|
||||
let totalUpstreamAttempts = 0;
|
||||
requestEntry.request_body_bytes = rawRequestBody.length;
|
||||
requestEntry.request_id = computeRequestId(pathname, rawRequestBody);
|
||||
requestEntry.thread_id = extractRequestThreadId(parsedRequestJson);
|
||||
requestEntry.model = requestJson?.model || null;
|
||||
requestEntry.requested_model = parsedRequestJson?.model || null;
|
||||
requestEntry.forwarded_model = forwardedModel || parsedRequestJson?.model || null;
|
||||
@@ -3140,6 +3295,11 @@ async function proxyRequest(runtime, req, res) {
|
||||
copyHeadersToClient(upstreamResponse.headers, res);
|
||||
res.writeHead(upstreamResponse.status);
|
||||
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);
|
||||
recordRequestEntry(
|
||||
runtime,
|
||||
|
||||
@@ -428,6 +428,7 @@ export async function installForCurrentProvider({
|
||||
retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]),
|
||||
retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.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",
|
||||
]),
|
||||
non_stream_status_code:
|
||||
existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null
|
||||
|
||||
@@ -200,7 +200,10 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
||||
),
|
||||
retryable_error_messages: normalizePhraseArray(
|
||||
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
|
||||
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10)
|
||||
|
||||
@@ -232,7 +232,7 @@ function startFakeUpstream(port) {
|
||||
}
|
||||
if (parsed.stream) {
|
||||
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: [DONE]\n\n",
|
||||
], parsed.test_stream_chunk_delay_ms ?? 20);
|
||||
@@ -243,6 +243,7 @@ function startFakeUpstream(port) {
|
||||
200,
|
||||
{
|
||||
id: "resp_test",
|
||||
thread_id: parsed.thread_id || "thread_test",
|
||||
retry_attempt: parsed.test_fail_before_response_once
|
||||
? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0
|
||||
: 0,
|
||||
@@ -383,7 +384,10 @@ async function run() {
|
||||
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||
reasoning_equals: [516],
|
||||
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_backoff_ms: 25,
|
||||
non_stream_status_code: 502,
|
||||
@@ -461,6 +465,22 @@ async function run() {
|
||||
);
|
||||
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 sameRequestFirstResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||
method: "POST",
|
||||
@@ -669,6 +689,31 @@ async function run() {
|
||||
);
|
||||
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 [
|
||||
"/responses",
|
||||
"/v1/responses",
|
||||
|
||||
+16
-2
@@ -71,6 +71,8 @@ type Usage = {
|
||||
type RequestEntry = {
|
||||
seq: number;
|
||||
request_id?: string | null;
|
||||
response_id?: string | null;
|
||||
thread_id?: string | null;
|
||||
lifecycle_state?: string | null;
|
||||
started_at?: string;
|
||||
first_response_at?: string | null;
|
||||
@@ -303,7 +305,7 @@ const defaultProfileForm: ProfileFormState = {
|
||||
model_remap: "",
|
||||
reasoning_equals: "516,1034,1552",
|
||||
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_backoff_ms: "350",
|
||||
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));
|
||||
}
|
||||
|
||||
function requestPrimaryId(entry: RequestEntry) {
|
||||
return entry.response_id || entry.request_id || "-";
|
||||
}
|
||||
|
||||
function splitList(value: string) {
|
||||
return value
|
||||
.split(/[\s,]+/)
|
||||
@@ -1105,7 +1111,8 @@ export default function App() {
|
||||
<div className="request-subtitle">
|
||||
<code>{`${entry.method || "-"} ${entry.path || "-"}`}</code>
|
||||
<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 className="request-badges">
|
||||
@@ -1148,6 +1155,13 @@ export default function App() {
|
||||
<span className="token-pill token-total">total {numberFormat(usage.total_tokens)}</span>
|
||||
</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>
|
||||
</article>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user