From a6420220669cc743f316136cf8b50db5673ee256 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Tue, 30 Jun 2026 11:25:36 +0800 Subject: [PATCH] feat: track thread ids and retry stream disconnects --- README.md | 3 +- config.example.json | 3 +- gateway.mjs | 166 ++++++++++++++++++++++++++++++++++- scripts/admin-lib.mjs | 1 + scripts/run-profile.mjs | 5 +- scripts/test-gateway-e2e.mjs | 49 ++++++++++- ui-src/src/App.tsx | 36 +++++--- 7 files changed, 244 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 045b23c..c59b628 100644 --- a/README.md +++ b/README.md @@ -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` 两套路径 diff --git a/config.example.json b/config.example.json index c1caafe..84aa739 100644 --- a/config.example.json +++ b/config.example.json @@ -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", diff --git a/gateway.mjs b/gateway.mjs index 1915953..44d3384 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -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, diff --git a/scripts/admin-lib.mjs b/scripts/admin-lib.mjs index 6a07393..7cef7ef 100644 --- a/scripts/admin-lib.mjs +++ b/scripts/admin-lib.mjs @@ -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 diff --git a/scripts/run-profile.mjs b/scripts/run-profile.mjs index 22828dd..d9bdcb5 100644 --- a/scripts/run-profile.mjs +++ b/scripts/run-profile.mjs @@ -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) diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index 2d952d3..f8be654 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -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", diff --git a/ui-src/src/App.tsx b/ui-src/src/App.tsx index 916caf7..7fc50bf 100644 --- a/ui-src/src/App.tsx +++ b/ui-src/src/App.tsx @@ -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() {
{`${entry.method || "-"} ${entry.path || "-"}`} {entry.response_stream ? "stream" : "non-stream"} - {entry.request_id || "-"} + id {requestPrimaryId(entry)} + thread {entry.thread_id || "-"}
@@ -1138,16 +1145,23 @@ export default function App() {
-
- -
- in {numberFormat(effectiveInput)} - out {numberFormat(usage.output_tokens)} - cached {numberFormat(usage.cached_tokens)}{cachedHitRatio !== null ? ` (${percent(cachedHitRatio)})` : ""} - reasoning {numberFormat(entry.reasoning_tokens ?? usage.reasoning_tokens)} - total {numberFormat(usage.total_tokens)} +
+ +
+ in {numberFormat(effectiveInput)} + out {numberFormat(usage.output_tokens)} + cached {numberFormat(usage.cached_tokens)}{cachedHitRatio !== null ? ` (${percent(cachedHitRatio)})` : ""} + reasoning {numberFormat(entry.reasoning_tokens ?? usage.reasoning_tokens)} + total {numberFormat(usage.total_tokens)} +
+
+ +
+ + {`response ${entry.response_id || "-"}`} + {`request ${entry.request_id || "-"}`} + {`thread ${entry.thread_id || "-"}`}
-
);