@tonyclaw/agent-inspector 3.1.3 → 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.
- package/.output/backend/nitro.json +1 -1
- package/.output/cli.js +36 -13
- package/.output/server/_ssr/index.mjs +1 -1
- package/.output/server/_ssr/{router-bqiXlU9G.mjs → router-DoTLo_6Y.mjs} +105 -21
- package/.output/server/index.mjs +1 -1
- package/.output/ui/assets/{CompareDrawer-zbBQV6yM.js → CompareDrawer-BpwJlrGi.js} +1 -1
- package/.output/ui/assets/{InspectorPet-BL40nCP0.js → InspectorPet-Bd4R1Uqu.js} +1 -1
- package/.output/ui/assets/ProxyViewerContainer-BV4-7dja.js +59 -0
- package/.output/ui/assets/{ReplayDialog-D5e1GTAH.js → ReplayDialog-Ddb7XkVq.js} +1 -1
- package/.output/ui/assets/{RequestAnatomy-pCQ36iYy.js → RequestAnatomy-Ddh3k5qQ.js} +1 -1
- package/.output/ui/assets/{ResponseView-DL-JIpqm.js → ResponseView-isgNqP5S.js} +1 -1
- package/.output/ui/assets/{StreamingChunkSequence-DZ8oEReb.js → StreamingChunkSequence-DwVYkfXk.js} +1 -1
- package/.output/ui/assets/{_sessionId-CxtCzJTD.js → _sessionId-BuT3c4Zb.js} +1 -1
- package/.output/ui/assets/{_sessionId-CXdE-q1L.js → _sessionId-ChDHsnEX.js} +1 -1
- package/.output/ui/assets/{index-C8cMr036.js → index-BGt5G4qW.js} +1 -1
- package/.output/ui/assets/{index-BDKxlIyD.js → index-CbTMnV7Y.js} +1 -1
- package/.output/ui/assets/{index-746BNOsa.js → index-fCRGsdC7.js} +1 -1
- package/.output/ui/assets/{index-DAjOHwn1.js → index-feJFJxaj.js} +2 -2
- package/.output/ui/assets/{json-viewer-BGe-iwFd.js → json-viewer-B2jL8EW8.js} +1 -1
- package/.output/ui/assets/{jszip.min-B3Epo1sV.js → jszip.min-_tcHnamX.js} +1 -1
- package/.output/ui/index.html +1 -1
- package/.output/workers/logFinalizer.worker.js +19 -1
- package/.output/workers/sessionWorkerEntry.js +19 -1
- package/package.json +16 -11
- package/src/cli.ts +1 -1
- package/src/components/proxy-viewer/LogEntry.tsx +162 -24
- package/src/contracts/log.ts +1 -0
- package/src/proxy/handler.ts +36 -19
- package/src/proxy/logFinalizer.ts +67 -1
- package/src/proxy/logger.ts +21 -1
- package/.output/ui/assets/ProxyViewerContainer-BbFbg6uw.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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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(
|
|
663
|
-
[
|
|
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")
|
|
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={
|
|
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
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
/>
|
package/src/contracts/log.ts
CHANGED
package/src/proxy/handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
):
|
|
154
|
+
): UnsupportedProviderRoute {
|
|
148
155
|
if (model === null) {
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
164
|
-
`
|
|
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
|
-
|
|
514
|
-
return new Response("Forbidden: unsupported provider", {
|
|
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
|
-
|
|
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 =
|
|
612
|
-
|
|
627
|
+
log.responseText = upstreamError;
|
|
628
|
+
log.error = upstreamError;
|
|
629
|
+
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: upstreamError });
|
|
613
630
|
await persistFinalizedLogUpdate(log);
|
|
614
|
-
return new Response(
|
|
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
|
-
|
|
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));
|
package/src/proxy/logger.ts
CHANGED
|
@@ -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)
|
|
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);
|