@tonyclaw/agent-inspector 3.1.4 → 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.output/backend/nitro.json +1 -1
  2. package/.output/cli.js +35 -12
  3. package/.output/server/_ssr/index.mjs +1 -1
  4. package/.output/server/_ssr/{router-CaWyNrmN.mjs → router-DoTLo_6Y.mjs} +105 -21
  5. package/.output/server/index.mjs +1 -1
  6. package/.output/ui/assets/{CompareDrawer-DK-lVFGz.js → CompareDrawer-BpwJlrGi.js} +1 -1
  7. package/.output/ui/assets/{InspectorPet-Bd56O0p-.js → InspectorPet-Bd4R1Uqu.js} +1 -1
  8. package/.output/ui/assets/ProxyViewerContainer-BV4-7dja.js +59 -0
  9. package/.output/ui/assets/{ReplayDialog-BioFH6jD.js → ReplayDialog-Ddb7XkVq.js} +1 -1
  10. package/.output/ui/assets/{RequestAnatomy-o8amClny.js → RequestAnatomy-Ddh3k5qQ.js} +1 -1
  11. package/.output/ui/assets/{ResponseView-6gzN1iRW.js → ResponseView-isgNqP5S.js} +1 -1
  12. package/.output/ui/assets/{StreamingChunkSequence-q7_3VMUe.js → StreamingChunkSequence-DwVYkfXk.js} +1 -1
  13. package/.output/ui/assets/{_sessionId-CTk1Vhy8.js → _sessionId-BuT3c4Zb.js} +1 -1
  14. package/.output/ui/assets/{_sessionId-Cwd8Jzq8.js → _sessionId-ChDHsnEX.js} +1 -1
  15. package/.output/ui/assets/{index-hluYwfXx.js → index-BGt5G4qW.js} +1 -1
  16. package/.output/ui/assets/{index-Rb-yjfPv.js → index-CbTMnV7Y.js} +1 -1
  17. package/.output/ui/assets/{index-BHmCRmME.js → index-fCRGsdC7.js} +1 -1
  18. package/.output/ui/assets/{index-MPPOjigU.js → index-feJFJxaj.js} +2 -2
  19. package/.output/ui/assets/{json-viewer-BIqit69y.js → json-viewer-B2jL8EW8.js} +1 -1
  20. package/.output/ui/assets/{jszip.min-olAf1G9u.js → jszip.min-_tcHnamX.js} +1 -1
  21. package/.output/ui/index.html +1 -1
  22. package/.output/workers/logFinalizer.worker.js +19 -1
  23. package/.output/workers/sessionWorkerEntry.js +19 -1
  24. package/package.json +16 -11
  25. package/src/components/proxy-viewer/LogEntry.tsx +162 -24
  26. package/src/contracts/log.ts +1 -0
  27. package/src/proxy/handler.ts +36 -19
  28. package/src/proxy/logFinalizer.ts +67 -1
  29. package/src/proxy/logger.ts +21 -1
  30. package/.output/ui/assets/ProxyViewerContainer-BhmwJbt3.js +0 -59
@@ -85,6 +85,7 @@ const FULL_LOG_HYDRATION_TIMEOUT_MS = 15_000;
85
85
  const BODY_CHUNK_TIMEOUT_MS = 15_000;
86
86
  const BODY_CHUNK_BYTES = 256 * 1024;
87
87
  const CHUNKED_BODY_THRESHOLD_BYTES = 1024 * 1024;
88
+ const COLLAPSED_BODY_ANALYSIS_THRESHOLD_BYTES = 256 * 1024;
88
89
 
89
90
  const TAB_TRIGGER_CLASS =
90
91
  "h-9 flex-none rounded-md border border-transparent bg-transparent px-3 text-xs font-semibold text-muted-foreground shadow-none transition-colors hover:bg-white/[0.045] hover:text-foreground data-[state=active]:bg-cyan-300/[0.08] data-[state=active]:text-cyan-100 data-[state=active]:shadow-none";
@@ -104,6 +105,10 @@ function bodyByteCount(value: number | null | undefined): number {
104
105
  return value ?? 0;
105
106
  }
106
107
 
108
+ function bodySizeForAnalysis(body: string | null, byteCount: number | null | undefined): number {
109
+ return byteCount ?? body?.length ?? 0;
110
+ }
111
+
107
112
  function isCompactBody(log: CapturedLog): boolean {
108
113
  return log.bodyContentMode === "compact" || log.bodyContentMode === "truncated";
109
114
  }
@@ -120,6 +125,51 @@ function shouldHydrateLogBody(log: CapturedLog): boolean {
120
125
  return isCompactBody(log) && !shouldUseChunkedBodyLoading(log);
121
126
  }
122
127
 
128
+ function shouldAnalyzeLogBodies(log: CapturedLog, expanded: boolean): boolean {
129
+ if (expanded) return true;
130
+ if (isCompactBody(log)) return false;
131
+ return (
132
+ bodySizeForAnalysis(log.rawRequestBody, log.rawRequestBodyBytes) <=
133
+ COLLAPSED_BODY_ANALYSIS_THRESHOLD_BYTES &&
134
+ bodySizeForAnalysis(log.responseText, log.responseTextBytes) <=
135
+ COLLAPSED_BODY_ANALYSIS_THRESHOLD_BYTES
136
+ );
137
+ }
138
+
139
+ function tabNeedsBodyHydration(tab: string): boolean {
140
+ switch (tab) {
141
+ case "request":
142
+ case "raw-request":
143
+ case "anatomy":
144
+ case "raw":
145
+ case "parsed":
146
+ return true;
147
+ case "headers":
148
+ case "raw-headers":
149
+ case "sse":
150
+ return false;
151
+ default:
152
+ return false;
153
+ }
154
+ }
155
+
156
+ function tabNeedsParsedRequest(tab: string): boolean {
157
+ switch (tab) {
158
+ case "request":
159
+ case "anatomy":
160
+ return true;
161
+ case "headers":
162
+ case "raw-headers":
163
+ case "raw-request":
164
+ case "raw":
165
+ case "parsed":
166
+ case "sse":
167
+ return false;
168
+ default:
169
+ return false;
170
+ }
171
+ }
172
+
123
173
  function bodyPreviewErrorMessage(error: unknown): string {
124
174
  if (error instanceof ApiTimeoutError) {
125
175
  return "Body preview timed out. The log is still available on disk; try loading again.";
@@ -298,6 +348,51 @@ function BodyPreviewNotice({
298
348
  );
299
349
  }
300
350
 
351
+ function SseInlineNotice({
352
+ error,
353
+ onOpenSse,
354
+ }: {
355
+ error: string | null | undefined;
356
+ onOpenSse: () => void;
357
+ }): JSX.Element | null {
358
+ if (error === undefined || error === null) return null;
359
+ return (
360
+ <NoticeBlock tone="danger" title="SSE Error">
361
+ <div className="flex flex-wrap items-center justify-between gap-2">
362
+ <span className="font-mono">{error}</span>
363
+ <Button
364
+ type="button"
365
+ variant="outline"
366
+ size="sm"
367
+ className="border border-input bg-background hover:bg-accent hover:text-accent-foreground h-7 text-xs text-rose-100"
368
+ onClick={onOpenSse}
369
+ >
370
+ View SSE
371
+ </Button>
372
+ </div>
373
+ </NoticeBlock>
374
+ );
375
+ }
376
+
377
+ function SseChunksNotice({ onOpenSse }: { onOpenSse: () => void }): JSX.Element {
378
+ return (
379
+ <NoticeBlock tone="loading" title="SSE chunks">
380
+ <div className="flex flex-wrap items-center justify-between gap-2">
381
+ <span>Streaming chunks are available in the SSE tab.</span>
382
+ <Button
383
+ type="button"
384
+ variant="outline"
385
+ size="sm"
386
+ className="border border-input bg-background hover:bg-accent hover:text-accent-foreground h-7 text-xs text-cyan-100"
387
+ onClick={onOpenSse}
388
+ >
389
+ View SSE
390
+ </Button>
391
+ </div>
392
+ </NoticeBlock>
393
+ );
394
+ }
395
+
301
396
  function HeaderRows({
302
397
  headers,
303
398
  emptyLabel,
@@ -454,6 +549,7 @@ export const LogEntry = memo(function ({
454
549
  const requestJsonRef = useRef<HTMLDivElement | null>(null);
455
550
  const logIdRef = useRef(log.id);
456
551
  const bodyHydrationInFlightRef = useRef(false);
552
+ const userSelectedTabRef = useRef(false);
457
553
  const displayLog = hydratedLog ?? log;
458
554
  const useChunkedBody = hydratedLog === null && shouldUseChunkedBodyLoading(log);
459
555
  const fullBodyHydrationFootprint = useMemo(
@@ -476,13 +572,14 @@ export const LogEntry = memo(function ({
476
572
  bodyByteCount(responsePreview.totalBytes) <= CHUNKED_BODY_THRESHOLD_BYTES);
477
573
  const resolvedFormat = resolveLogFormat(displayLog);
478
574
  const adapter = getLogFormatAdapter(resolvedFormat);
575
+ const analyzeBodies = shouldAnalyzeLogBodies(displayLog, expanded);
479
576
  const requestAnalysis = useMemo(
480
- () => adapter.analyzeRequest(displayLog.rawRequestBody),
481
- [adapter, displayLog.rawRequestBody],
577
+ () => adapter.analyzeRequest(analyzeBodies ? displayLog.rawRequestBody : null),
578
+ [adapter, analyzeBodies, displayLog.rawRequestBody],
482
579
  );
483
580
  const responseAnalysis = useMemo(
484
- () => adapter.analyzeResponse(displayLog.responseText),
485
- [adapter, displayLog.responseText],
581
+ () => adapter.analyzeResponse(analyzeBodies ? displayLog.responseText : null),
582
+ [adapter, analyzeBodies, displayLog.responseText],
486
583
  );
487
584
  const strippedRequestBody = useMemo(() => {
488
585
  if (!strip || resolvedFormat !== "anthropic" || displayLog.rawRequestBody === null) {
@@ -492,7 +589,9 @@ export const LogEntry = memo(function ({
492
589
  }, [displayLog.rawRequestBody, resolvedFormat, strip]);
493
590
  const displayedRequestBody = strippedRequestBody ?? rawRequestBodyForView;
494
591
  const requestExpansion = useJsonBulkExpansion(
495
- requestPreviewShouldParse ? displayedRequestBody : null,
592
+ expanded && tabNeedsParsedRequest(activeTab) && requestPreviewShouldParse
593
+ ? displayedRequestBody
594
+ : null,
496
595
  );
497
596
  const rawRequestExpansion = useJsonBulkExpansion(
498
597
  activeTab === "raw-request" && requestPreviewShouldParse ? rawRequestBodyForView : null,
@@ -500,6 +599,7 @@ export const LogEntry = memo(function ({
500
599
  const responseExpansion = useJsonBulkExpansion(
501
600
  activeTab === "raw" && responsePreviewShouldParse ? responseTextForView : null,
502
601
  );
602
+ const parsedRequestForSummary = requestExpansion.parsedData ?? requestAnalysis.parsed;
503
603
 
504
604
  // Headers are rendered as a flat list, so we copy them as pretty-printed JSON.
505
605
  // Only build the string when there's at least one entry; empty headers would
@@ -653,14 +753,12 @@ export const LogEntry = memo(function ({
653
753
  );
654
754
  const anatomySegments = useMemo(
655
755
  () =>
656
- requestExpansion.parsedData !== null
657
- ? adapter.anatomySegments(requestExpansion.parsedData)
658
- : null,
659
- [adapter, requestExpansion.parsedData],
756
+ parsedRequestForSummary !== null ? adapter.anatomySegments(parsedRequestForSummary) : null,
757
+ [adapter, parsedRequestForSummary],
660
758
  );
661
759
  const requestTools = useMemo(
662
- () => parseRequestTools(requestExpansion.parsedData),
663
- [requestExpansion.parsedData],
760
+ () => parseRequestTools(parsedRequestForSummary),
761
+ [parsedRequestForSummary],
664
762
  );
665
763
  const warnings = displayLog.warnings ?? [];
666
764
  const anatomyPaths = useMemo(() => {
@@ -671,7 +769,10 @@ export const LogEntry = memo(function ({
671
769
  containerRef: requestJsonRef,
672
770
  setExpandToPath,
673
771
  ensureTabActive: () => {
674
- if (activeTab !== "request") setActiveTab("request");
772
+ if (activeTab !== "request") {
773
+ userSelectedTabRef.current = true;
774
+ setActiveTab("request");
775
+ }
675
776
  },
676
777
  });
677
778
  const loadBodyChunk = useCallback(
@@ -728,11 +829,22 @@ export const LogEntry = memo(function ({
728
829
  const handleToggleExpanded = useCallback(() => {
729
830
  const nextExpanded = !expanded;
730
831
  if (nextExpanded) {
832
+ userSelectedTabRef.current = false;
731
833
  setActiveTab(anatomySegments !== null ? "anatomy" : "request");
732
834
  }
733
835
  setExpanded(nextExpanded);
734
836
  }, [anatomySegments, expanded]);
735
837
 
838
+ const openSseTab = useCallback(() => {
839
+ userSelectedTabRef.current = true;
840
+ setActiveTab("sse");
841
+ }, []);
842
+
843
+ const handleTabChange = useCallback((value: string) => {
844
+ userSelectedTabRef.current = true;
845
+ setActiveTab(value);
846
+ }, []);
847
+
736
848
  useEffect(() => {
737
849
  logIdRef.current = log.id;
738
850
  bodyHydrationInFlightRef.current = false;
@@ -740,12 +852,22 @@ export const LogEntry = memo(function ({
740
852
  setBodyHydrationStatus("idle");
741
853
  setBodyHydrationError(null);
742
854
  setBodyHydrationRetryNonce(0);
855
+ userSelectedTabRef.current = false;
743
856
  setRequestPreview(createEmptyBodyPreviewState(log.rawRequestBodyBytes ?? null));
744
857
  setResponsePreview(createEmptyBodyPreviewState(log.responseTextBytes ?? null));
745
858
  }, [log.id, log.rawRequestBodyBytes, log.responseTextBytes]);
746
859
 
747
860
  useEffect(() => {
748
861
  if (!expanded) return;
862
+ if (userSelectedTabRef.current) return;
863
+ if (activeTab !== "request") return;
864
+ if (anatomySegments === null) return;
865
+ setActiveTab("anatomy");
866
+ }, [activeTab, anatomySegments, expanded]);
867
+
868
+ useEffect(() => {
869
+ if (!expanded) return;
870
+ if (!tabNeedsBodyHydration(activeTab)) return;
749
871
  if (!shouldHydrateLogBody(log)) return;
750
872
  if (hydratedLog !== null || bodyHydrationInFlightRef.current) return;
751
873
 
@@ -777,6 +899,7 @@ export const LogEntry = memo(function ({
777
899
  bodyHydrationInFlightRef.current = false;
778
900
  };
779
901
  }, [
902
+ activeTab,
780
903
  expanded,
781
904
  hydratedLog,
782
905
  log.bodyContentMode,
@@ -808,6 +931,7 @@ export const LogEntry = memo(function ({
808
931
  if (request === null) return;
809
932
  if (request.logId !== log.id) return;
810
933
  setExpanded(true);
934
+ userSelectedTabRef.current = true;
811
935
  setActiveTab(resolveFocusedTab(request.tab, anatomySegments));
812
936
  if (request.tab === "response" && request.toolCallIndex !== undefined) {
813
937
  setFocusedToolIndex(request.toolCallIndex);
@@ -865,7 +989,7 @@ export const LogEntry = memo(function ({
865
989
  aria-hidden="true"
866
990
  />
867
991
  <LazyFeatureBoundary feature="Log details">
868
- <Tabs value={activeTab} onValueChange={setActiveTab} className="gap-2">
992
+ <Tabs value={activeTab} onValueChange={handleTabChange} className="gap-2">
869
993
  <TabsList
870
994
  variant="line"
871
995
  className="bg-white/[0.04] inspector-scrollbar relative mx-3 h-auto w-[calc(100%-1.5rem)] justify-start overflow-x-auto rounded-[8px] p-1"
@@ -908,6 +1032,11 @@ export const LogEntry = memo(function ({
908
1032
  <TabsTrigger className={TAB_TRIGGER_CLASS} value="parsed">
909
1033
  Response
910
1034
  </TabsTrigger>
1035
+ {displayLog.streaming === true && (
1036
+ <TabsTrigger className={TAB_TRIGGER_CLASS} value="sse">
1037
+ SSE
1038
+ </TabsTrigger>
1039
+ )}
911
1040
  </TabsList>
912
1041
 
913
1042
  {shouldShowRawRequestTab(resolvedFormat, viewMode, strip) && (
@@ -1109,11 +1238,7 @@ export const LogEntry = memo(function ({
1109
1238
  onLoadMore={() => loadBodyChunk("response", responsePreview.offset)}
1110
1239
  />
1111
1240
  )}
1112
- {displayLog.error !== undefined && displayLog.error !== null && (
1113
- <NoticeBlock tone="danger" title="SSE Error">
1114
- <span className="font-mono">{displayLog.error}</span>
1115
- </NoticeBlock>
1116
- )}
1241
+ <SseInlineNotice error={displayLog.error} onOpenSse={openSseTab} />
1117
1242
  {responseTextForView !== null && responsePreviewShouldParse ? (
1118
1243
  <Suspense fallback={<TabFallback />}>
1119
1244
  <LazyJsonViewerFromString
@@ -1130,17 +1255,30 @@ export const LogEntry = memo(function ({
1130
1255
  {useChunkedBody ? "Response preview is loading" : "No response"}
1131
1256
  </EmptyState>
1132
1257
  )}
1133
- {displayLog.streaming === true && (
1258
+ {displayLog.streaming === true && <SseChunksNotice onOpenSse={openSseTab} />}
1259
+ </ExpandedPanel>
1260
+ )}
1261
+ </TabsContent>
1262
+
1263
+ {displayLog.streaming === true && (
1264
+ <TabsContent value="sse">
1265
+ {activeTab === "sse" && (
1266
+ <ExpandedPanel className="space-y-3">
1267
+ {displayLog.error !== undefined && displayLog.error !== null && (
1268
+ <NoticeBlock tone="danger" title="SSE Error">
1269
+ <span className="font-mono">{displayLog.error}</span>
1270
+ </NoticeBlock>
1271
+ )}
1134
1272
  <Suspense fallback={<TabFallback />}>
1135
1273
  <LazyStreamingChunkSequence
1136
1274
  logId={displayLog.id}
1137
1275
  truncated={displayLog.streamingChunksPath !== null}
1138
1276
  />
1139
1277
  </Suspense>
1140
- )}
1141
- </ExpandedPanel>
1142
- )}
1143
- </TabsContent>
1278
+ </ExpandedPanel>
1279
+ )}
1280
+ </TabsContent>
1281
+ )}
1144
1282
 
1145
1283
  <TabsContent value="parsed">
1146
1284
  {activeTab === "parsed" && (
@@ -1159,6 +1297,7 @@ export const LogEntry = memo(function ({
1159
1297
  onLoadMore={() => loadBodyChunk("response", responsePreview.offset)}
1160
1298
  />
1161
1299
  )}
1300
+ <SseInlineNotice error={displayLog.error} onOpenSse={openSseTab} />
1162
1301
  {responsePreviewIsActive && !responsePreviewShouldParse ? (
1163
1302
  <RawTextBlock>{responseTextForView}</RawTextBlock>
1164
1303
  ) : (
@@ -1172,7 +1311,6 @@ export const LogEntry = memo(function ({
1172
1311
  cacheCreationInputTokens={displayLog.cacheCreationInputTokens}
1173
1312
  cacheReadInputTokens={displayLog.cacheReadInputTokens}
1174
1313
  apiFormat={resolvedFormat}
1175
- error={displayLog.error}
1176
1314
  focusedToolIndex={focusedToolIndex}
1177
1315
  toolFocusNonce={toolFocusNonce}
1178
1316
  />
@@ -23,6 +23,7 @@ export const CaptureIncompleteReasonSchema = z.enum([
23
23
  "pipeline-closed",
24
24
  "write-failed",
25
25
  "finalize-failed",
26
+ "finalize-timeout",
26
27
  "upstream-stream-error",
27
28
  "shutdown",
28
29
  ]);
@@ -27,6 +27,8 @@ import { safeFetch } from "../lib/safeFetch";
27
27
  const ALLOW_LOOPBACK = process.env["AGENT_INSPECTOR_ALLOW_LOOPBACK"] === "1";
28
28
  const EMPTY_UPSTREAM_BODY_ERROR = "Upstream response did not include a body";
29
29
  const UPSTREAM_STREAM_ERROR = "Upstream stream terminated unexpectedly";
30
+ const UPSTREAM_REQUEST_FAILED = "Proxy error: upstream request failed";
31
+ const HEADER_ERROR_CODE = "x-agent-inspector-error-code";
30
32
  import { stripClaudeCodeBillingHeader } from "../lib/claudeCodeStrip";
31
33
  import { stripOpenAIOrphanToolMessages } from "./openaiOrphanToolStrip";
32
34
  import { maskApiKey } from "../lib/mask";
@@ -140,29 +142,37 @@ function configuredProviderUrlFields(provider: ProviderConfig): string[] {
140
142
  return fields;
141
143
  }
142
144
 
143
- function logUnsupportedProviderRoute(
145
+ type UnsupportedProviderRoute = {
146
+ code: "request-model-missing" | "provider-not-found" | "provider-route-mismatch";
147
+ detail: string;
148
+ };
149
+
150
+ function unsupportedProviderRoute(
144
151
  model: string | null,
145
152
  route: ReturnType<typeof describeApiRoute>,
146
153
  provider: ProviderConfig | null,
147
- ): void {
154
+ ): UnsupportedProviderRoute {
148
155
  if (model === null) {
149
- logger.warn(
150
- `[handler] Unsupported provider route: model could not be extracted; path=${route.normalizedPath}; protocol=${protocolLabel(route)}`,
151
- );
152
- return;
156
+ const detail = `model could not be extracted; path=${route.normalizedPath}; protocol=${protocolLabel(route)}`;
157
+ logger.warn(`[handler] Unsupported provider route: code=request-model-missing; ${detail}`);
158
+ return { code: "request-model-missing", detail };
153
159
  }
154
160
 
155
161
  if (provider === null) {
156
- logger.warn(
157
- `[handler] Unsupported provider route: no Provider matched model=${model}; path=${route.normalizedPath}; protocol=${protocolLabel(route)}`,
158
- );
159
- return;
162
+ const detail = `no Provider matched model=${model}; path=${route.normalizedPath}; protocol=${protocolLabel(route)}`;
163
+ logger.warn(`[handler] Unsupported provider route: code=provider-not-found; ${detail}`);
164
+ return { code: "provider-not-found", detail };
160
165
  }
161
166
 
162
167
  const configuredFields = configuredProviderUrlFields(provider);
163
- logger.warn(
164
- `[handler] Unsupported provider route: Provider "${provider.name}" (${provider.id}) matched model=${model}, but request path ${route.normalizedPath} requires ${protocolLabel(route)} via ${requiredProviderUrlField(route)}; configured URL fields=${configuredFields.length > 0 ? configuredFields.join(",") : "none"}`,
165
- );
168
+ const detail =
169
+ `Provider "${provider.name}" (${provider.id}) matched model=${model}, ` +
170
+ `but request path ${route.normalizedPath} requires ${protocolLabel(route)} via ` +
171
+ `${requiredProviderUrlField(route)}; configured URL fields=${
172
+ configuredFields.length > 0 ? configuredFields.join(",") : "none"
173
+ }`;
174
+ logger.warn(`[handler] Unsupported provider route: code=provider-route-mismatch; ${detail}`);
175
+ return { code: "provider-route-mismatch", detail };
166
176
  }
167
177
 
168
178
  type BodyReadResult = { ok: true; body: string | null } | { ok: false; reason: "too-large" };
@@ -510,8 +520,13 @@ async function handleAdmittedProxy(req: Request): Promise<Response> {
510
520
  matchedProviderConfig === null ||
511
521
  !providerSupportsApiRoute(route, matchedProviderConfig)
512
522
  ) {
513
- logUnsupportedProviderRoute(model, route, matchedProviderConfig);
514
- return new Response("Forbidden: unsupported provider", { status: STATUS_FORBIDDEN });
523
+ const unsupported = unsupportedProviderRoute(model, route, matchedProviderConfig);
524
+ return new Response("Forbidden: unsupported provider", {
525
+ status: STATUS_FORBIDDEN,
526
+ headers: {
527
+ [HEADER_ERROR_CODE]: unsupported.code,
528
+ },
529
+ });
515
530
  }
516
531
 
517
532
  const upstreamBase = selectUpstreamBase(route, matchedProviderConfig);
@@ -606,12 +621,14 @@ async function handleAdmittedProxy(req: Request): Promise<Response> {
606
621
  await persistFinalizedLogUpdate(log);
607
622
  return new Response("Client aborted", { status: 499 });
608
623
  }
609
- logger.error(`[handler] Proxy error: ${req.method} ${parsed.apiPath}`, String(err));
624
+ const upstreamError = errorMessage(err);
625
+ logger.error(`[handler] Proxy error: ${req.method} ${parsed.apiPath}`, upstreamError);
610
626
  log.responseStatus = STATUS_BAD_GATEWAY;
611
- log.responseText = String(err);
612
- appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: String(err) });
627
+ log.responseText = upstreamError;
628
+ log.error = upstreamError;
629
+ appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: upstreamError });
613
630
  await persistFinalizedLogUpdate(log);
614
- return new Response(`Proxy error: ${err}`, { status: STATUS_BAD_GATEWAY });
631
+ return new Response(UPSTREAM_REQUEST_FAILED, { status: STATUS_BAD_GATEWAY });
615
632
  }
616
633
 
617
634
  const isStream =
@@ -62,6 +62,7 @@ export type FinalizeLogResult = {
62
62
  };
63
63
 
64
64
  const DEFAULT_MAX_FINALIZE_STREAM_BYTES = 20 * 1024 * 1024;
65
+ const DEFAULT_FINALIZE_JOB_TIMEOUT_MS = 15_000;
65
66
 
66
67
  function resolveMaxFinalizeStreamBytes(): number {
67
68
  const raw = process.env["AGENT_INSPECTOR_MAX_FINALIZE_STREAM_BYTES"];
@@ -71,6 +72,14 @@ function resolveMaxFinalizeStreamBytes(): number {
71
72
  return Math.floor(parsed);
72
73
  }
73
74
 
75
+ function resolveFinalizeJobTimeoutMs(): number {
76
+ const raw = process.env["AGENT_INSPECTOR_FINALIZE_JOB_TIMEOUT_MS"];
77
+ if (raw === undefined || raw === "") return DEFAULT_FINALIZE_JOB_TIMEOUT_MS;
78
+ const parsed = Number(raw);
79
+ if (!Number.isFinite(parsed) || parsed < 100) return DEFAULT_FINALIZE_JOB_TIMEOUT_MS;
80
+ return Math.floor(parsed);
81
+ }
82
+
74
83
  export function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string, unknown> {
75
84
  return {
76
85
  timestamp: log.timestamp,
@@ -482,6 +491,44 @@ export function buildFinalizeLogResult(job: FinalizeLogJob): FinalizeLogResult {
482
491
  }
483
492
  }
484
493
 
494
+ export function buildFinalizeTimeoutResult(
495
+ job: FinalizeLogJob,
496
+ timeoutMs: number,
497
+ ): FinalizeLogResult {
498
+ const log = cloneLog(job.log);
499
+ const message = `Log finalization timed out after ${String(timeoutMs)}ms`;
500
+ log.elapsedMs = job.elapsedMs;
501
+ applyOptionalStreamingTiming(log, job);
502
+ let fallbackStatus = 499;
503
+ let cleanupRawStreamPath: string | null = null;
504
+ switch (job.type) {
505
+ case "non-streaming":
506
+ case "streaming":
507
+ fallbackStatus = job.responseStatus;
508
+ cleanupRawStreamPath =
509
+ job.type === "streaming" ? cleanupPathForRawStream(job.rawStream) : null;
510
+ break;
511
+ case "stream-abort":
512
+ cleanupRawStreamPath = cleanupPathForRawStream(job.rawStream);
513
+ break;
514
+ default: {
515
+ const _exhaustive: never = job;
516
+ logger.error(`[logFinalizer] Unhandled timeout job type: ${JSON.stringify(_exhaustive)}`);
517
+ }
518
+ }
519
+ log.responseStatus = log.responseStatus ?? fallbackStatus;
520
+ log.responseText = log.responseText ?? message;
521
+ log.captureIncomplete = true;
522
+ log.captureIncompleteReason = "finalize-timeout";
523
+ log.error = message;
524
+ return {
525
+ log,
526
+ upstreamUrl: job.upstreamUrl,
527
+ error: message,
528
+ cleanupRawStreamPath,
529
+ };
530
+ }
531
+
485
532
  export async function commitFinalizeLogResult(result: FinalizeLogResult): Promise<void> {
486
533
  const persistedError =
487
534
  result.log.captureIncompleteReason === "upstream-stream-error"
@@ -680,7 +727,26 @@ export function executeBuildInWorker(job: FinalizeLogJob): Promise<FinalizeLogRe
680
727
  }
681
728
 
682
729
  export function executeFinalizeLogJob(job: FinalizeLogJob): Promise<void> {
683
- return resolveBuildPromise(job).then(async (result) => {
730
+ const timeoutMs = resolveFinalizeJobTimeoutMs();
731
+ let timeout: ReturnType<typeof setTimeout> | null = null;
732
+ const buildPromise = resolveBuildPromise(job);
733
+ const timeoutPromise = new Promise<FinalizeLogResult>((resolve) => {
734
+ timeout = setTimeout(() => {
735
+ logger.error(
736
+ `[logFinalizer] Finalize job timed out for log #${job.log.id} after ${String(timeoutMs)}ms`,
737
+ );
738
+ resolve(buildFinalizeTimeoutResult(job, timeoutMs));
739
+ }, timeoutMs);
740
+ });
741
+ void buildPromise.catch((err: unknown) => {
742
+ logger.error(
743
+ `[logFinalizer] Late finalize build failure for log #${job.log.id}:`,
744
+ errorMessage(err),
745
+ );
746
+ });
747
+
748
+ return Promise.race([buildPromise, timeoutPromise]).then(async (result) => {
749
+ if (timeout !== null) clearTimeout(timeout);
684
750
  await commitFinalizeLogResult(result);
685
751
  if (result.error !== null && result.error !== "Client aborted") {
686
752
  return Promise.reject(new Error(result.error));
@@ -281,6 +281,9 @@ export async function initLogger(): Promise<void> {
281
281
  // File-based logger for application logs (not log entries)
282
282
  let loggerInitialization: Promise<void> | null = null;
283
283
  let applicationLogWriteQueue: Promise<void> = Promise.resolve();
284
+ let lastApplicationLogWriteFailureKey: string | null = null;
285
+ let lastApplicationLogWriteFailureAt = 0;
286
+ const APPLICATION_LOG_WRITE_FAILURE_SUPPRESS_MS = 5_000;
284
287
 
285
288
  type ApplicationLogWriteResult = { ok: true } | { ok: false; reason: unknown };
286
289
 
@@ -334,7 +337,24 @@ async function writeAppLog(message: string): Promise<void> {
334
337
  const write = async (): Promise<void> => {
335
338
  await initialization;
336
339
  const result = await appendApplicationLogLine(logPath, line, getApplicationLogPolicy());
337
- if (!result.ok) writeStderrFallback(`[logger] Failed to write to ${logPath}:`, result.reason);
340
+ if (!result.ok) {
341
+ const key = `${logPath}:${String(result.reason)}`;
342
+ const now = Date.now();
343
+ if (
344
+ key !== lastApplicationLogWriteFailureKey ||
345
+ now - lastApplicationLogWriteFailureAt >= APPLICATION_LOG_WRITE_FAILURE_SUPPRESS_MS
346
+ ) {
347
+ lastApplicationLogWriteFailureKey = key;
348
+ lastApplicationLogWriteFailureAt = now;
349
+ writeStderrFallback(`[logger] Failed to write to ${logPath}:`, result.reason);
350
+ }
351
+ return;
352
+ }
353
+ if (lastApplicationLogWriteFailureKey !== null) {
354
+ writeStderrFallback(`[logger] Application log writes recovered for ${logPath}:`, "ok");
355
+ lastApplicationLogWriteFailureKey = null;
356
+ lastApplicationLogWriteFailureAt = 0;
357
+ }
338
358
  };
339
359
 
340
360
  applicationLogWriteQueue = applicationLogWriteQueue.then(write, write);