@tonyclaw/agent-inspector 2.1.16 → 2.1.18

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 (55) hide show
  1. package/.output/cli.js +57 -13
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-Ccrrcx1j.js → CompareDrawer-Fqqw5Lci.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-CP_RzwW9.js +106 -0
  5. package/.output/public/assets/{ReplayDialog-C7axhr-l.js → ReplayDialog-hYhz_i6W.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-WOptg8j_.js → RequestAnatomy-CTuWbtwC.js} +1 -1
  7. package/.output/public/assets/{ResponseView-CSAcxh_M.js → ResponseView-CsZ7S5Gv.js} +2 -2
  8. package/.output/public/assets/{StreamingChunkSequence-DJS5KhPx.js → StreamingChunkSequence-PIbJMotU.js} +1 -1
  9. package/.output/public/assets/_sessionId-CUGA9qU3.js +1 -0
  10. package/.output/public/assets/{index-DMPNh46t.js → index-F7mynwuH.js} +1 -1
  11. package/.output/public/assets/index-G4dTUobw.js +1 -0
  12. package/.output/public/assets/index-gvYkgMPF.css +1 -0
  13. package/.output/public/assets/{json-viewer-inX5QSa3.js → json-viewer-DCXtNmmD.js} +1 -1
  14. package/.output/public/assets/{main-aCAKYGSD.js → main-BJZf7_Ib.js} +2 -2
  15. package/.output/server/_libs/lucide-react.mjs +220 -202
  16. package/.output/server/{_sessionId-C0fhvQnn.mjs → _sessionId-DjSHDNh8.mjs} +2 -2
  17. package/.output/server/_ssr/{CompareDrawer-CO-Ti5sg.mjs → CompareDrawer-Dal52IFo.mjs} +3 -3
  18. package/.output/server/_ssr/{ProxyViewerContainer-N4J7uBCX.mjs → ProxyViewerContainer-CVqFZGff.mjs} +1833 -1366
  19. package/.output/server/_ssr/{ReplayDialog-J1VxC0In.mjs → ReplayDialog-BOEgdMes.mjs} +4 -4
  20. package/.output/server/_ssr/{RequestAnatomy-DBH-F3rq.mjs → RequestAnatomy-gch7_vCV.mjs} +2 -2
  21. package/.output/server/_ssr/{ResponseView-DLFwV7Hd.mjs → ResponseView-lEWPy-H4.mjs} +3 -3
  22. package/.output/server/_ssr/{StreamingChunkSequence-BywmuND0.mjs → StreamingChunkSequence-DnudbJ1O.mjs} +2 -2
  23. package/.output/server/_ssr/{index-CStymIc0.mjs → index-Cza8zDrW.mjs} +2 -2
  24. package/.output/server/_ssr/index.mjs +2 -2
  25. package/.output/server/_ssr/{json-viewer-DlQu0N1x.mjs → json-viewer-C2lIVEX3.mjs} +3 -3
  26. package/.output/server/_ssr/{router-CQ4Zxhmr.mjs → router-bF9uzd1k.mjs} +939 -113
  27. package/.output/server/{_tanstack-start-manifest_v-BHeJBGfo.mjs → _tanstack-start-manifest_v-ErvW-mtr.mjs} +1 -1
  28. package/.output/server/index.mjs +74 -74
  29. package/README.md +57 -2
  30. package/package.json +1 -1
  31. package/src/cli/startupOutput.ts +16 -0
  32. package/src/cli.ts +61 -15
  33. package/src/components/ProxyViewer.tsx +395 -64
  34. package/src/components/providers/ProviderCard.tsx +132 -31
  35. package/src/components/providers/ProviderForm.tsx +43 -11
  36. package/src/components/providers/ProvidersPanel.tsx +1 -1
  37. package/src/components/proxy-viewer/ConversationHeader.tsx +133 -112
  38. package/src/components/proxy-viewer/LogEntry.tsx +109 -26
  39. package/src/components/proxy-viewer/LogEntryHeader.tsx +339 -329
  40. package/src/components/proxy-viewer/ThreadConnector.tsx +49 -46
  41. package/src/components/proxy-viewer/TurnGroup.tsx +22 -62
  42. package/src/components/proxy-viewer/bodyHydration.ts +56 -0
  43. package/src/lib/providerTestContract.ts +56 -10
  44. package/src/lib/providerTestPrompt.ts +23 -1
  45. package/src/lib/upstreamUrl.ts +105 -1
  46. package/src/mcp/server.ts +275 -0
  47. package/src/mcp/toolHandlers.ts +381 -4
  48. package/src/proxy/logSearch.ts +3 -0
  49. package/src/proxy/store.ts +40 -15
  50. package/src/routes/api/logs.ts +8 -2
  51. package/src/routes/api/providers.$providerId.test.log.ts +85 -10
  52. package/.output/public/assets/ProxyViewerContainer-DLqClc9A.js +0 -106
  53. package/.output/public/assets/_sessionId-DUSalzKH.js +0 -1
  54. package/.output/public/assets/index-BPpA21dY.css +0 -1
  55. package/.output/public/assets/index-Bt0Az2I2.js +0 -1
@@ -1,6 +1,6 @@
1
- import { type JSX, useMemo } from "react";
2
- import { cn } from "../../lib/utils";
1
+ import { type JSX, type KeyboardEvent, type MouseEvent, useMemo } from "react";
3
2
  import { isTurnBoundary, type StopReason } from "../../lib/stopReason";
3
+ import { cn } from "../../lib/utils";
4
4
  import { getCrabVariant, getInteriorCrabVariant } from "../ui/crab-variants";
5
5
 
6
6
  export type ThreadConnectorProps = {
@@ -32,13 +32,10 @@ function boundaryTitle(stopReason: StopReason): string {
32
32
  }
33
33
  }
34
34
 
35
- /**
36
- * Vertical timeline connector. Top spacer uses a calc() that
37
- * mixes rem (py-1 + half-line) and px (border) so the crab
38
- * centre-line matches the LogEntry display number regardless of the
39
- * root font-size. The bottom spacer is flex-1 so the outgoing
40
- * line adapts to whatever height the LogEntry occupies.
41
- */
35
+ function crabShellClass(interactive: boolean): string {
36
+ return interactive ? "cursor-pointer transition-transform hover:scale-110" : "";
37
+ }
38
+
42
39
  export function ThreadConnector({
43
40
  stopReason,
44
41
  isPending,
@@ -52,53 +49,55 @@ export function ThreadConnector({
52
49
  const isBoundary = isTurnBoundary(stopReason);
53
50
  const isFusedBoundary = isOnlyEntry && isTurnStart && isBoundary;
54
51
  const isRunning = isPending && !isBoundary;
52
+ const interactive = collapsible && onToggle !== undefined;
55
53
  const Crab = useMemo(() => getCrabVariant(crabIndex), [crabIndex]);
56
54
  const FusedCrab = useMemo(() => getInteriorCrabVariant(crabIndex), [crabIndex]);
57
55
 
58
- const interactiveProps =
59
- collapsible && onToggle
60
- ? ({
61
- role: "button" as const,
62
- tabIndex: 0,
63
- className: "cursor-pointer",
64
- onClick: (e: React.MouseEvent) => {
65
- e.stopPropagation();
66
- onToggle();
67
- },
68
- onKeyDown: (e: React.KeyboardEvent) => {
69
- if (e.key === "Enter" || e.key === " ") {
70
- e.preventDefault();
71
- onToggle();
72
- }
73
- },
74
- } as const)
75
- : ({} as const);
56
+ const handleCrabClick = (event: MouseEvent<HTMLSpanElement>): void => {
57
+ if (!interactive || onToggle === undefined) return;
58
+ event.stopPropagation();
59
+ onToggle();
60
+ };
61
+
62
+ const handleCrabKeyDown = (event: KeyboardEvent<HTMLSpanElement>): void => {
63
+ if (!interactive || onToggle === undefined) return;
64
+ if (event.key !== "Enter" && event.key !== " ") return;
65
+ event.preventDefault();
66
+ onToggle();
67
+ };
76
68
 
77
69
  return (
78
70
  <div className="flex flex-col items-center w-6 shrink-0 pt-0.5 pb-0.5">
79
- {/* Top spacer — crab centre must land on the LogEntry index midline.
80
- * Index midline from container top = border(1px) + py-1(0.25rem) + ½line(0.5rem)
81
- * = 1px + 0.75rem. Crab centre = pt(2px) + spacer + 7px(half crab).
82
- * ∴ spacer = 0.75rem - 8px. (at 16px root: 12-8=4px; 2+4+7=13px). */}
83
71
  <div className="flex justify-center h-[calc(0.75rem-8px)]">
84
72
  {!isFirst && <div className="w-0.5 bg-muted-foreground/30 h-full" />}
85
73
  </div>
86
74
 
87
- {/* Crab — pinned to the fixed offset above so it never drifts
88
- * when the LogEntry expands / collapses. Clickable when the
89
- * turn is collapsible (replaces the old chevron toggle). */}
90
75
  {isFusedBoundary ? (
91
- <span title="Start and end of turn">
76
+ <span
77
+ role={interactive ? "button" : undefined}
78
+ tabIndex={interactive ? 0 : undefined}
79
+ title="Single-request turn"
80
+ className={crabShellClass(interactive)}
81
+ onClick={handleCrabClick}
82
+ onKeyDown={handleCrabKeyDown}
83
+ >
92
84
  <FusedCrab
93
85
  className={cn(
94
- "size-3.5 text-cyan-300/90",
86
+ "size-3.5 text-[#80FF00]",
95
87
  "animate-crab-settle",
96
- "drop-shadow-[0_0_4px_rgba(103,232,249,0.35)]",
88
+ "drop-shadow-[0_0_4px_rgba(128,255,0,0.5)]",
97
89
  )}
98
90
  />
99
91
  </span>
100
92
  ) : isBoundary ? (
101
- <span title={boundaryTitle(stopReason)} {...interactiveProps}>
93
+ <span
94
+ role={interactive ? "button" : undefined}
95
+ tabIndex={interactive ? 0 : undefined}
96
+ title={boundaryTitle(stopReason)}
97
+ className={crabShellClass(interactive)}
98
+ onClick={handleCrabClick}
99
+ onKeyDown={handleCrabKeyDown}
100
+ >
102
101
  <Crab
103
102
  className={cn(
104
103
  "size-3.5 text-amber-400",
@@ -108,28 +107,32 @@ export function ThreadConnector({
108
107
  />
109
108
  </span>
110
109
  ) : isTurnStart ? (
111
- <span title="Start of turn" {...interactiveProps}>
110
+ <span
111
+ role={interactive ? "button" : undefined}
112
+ tabIndex={interactive ? 0 : undefined}
113
+ title="Start of turn"
114
+ className={crabShellClass(interactive)}
115
+ onClick={handleCrabClick}
116
+ onKeyDown={handleCrabKeyDown}
117
+ >
112
118
  <Crab
113
119
  className={cn(
114
- "size-3.5 text-cyan-300/85",
120
+ "size-3.5 text-emerald-400",
115
121
  "animate-crab-appear",
116
- "drop-shadow-[0_0_4px_rgba(103,232,249,0.32)]",
122
+ "drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
117
123
  )}
118
124
  />
119
125
  </span>
120
126
  ) : isRunning ? (
121
- <span title="Processing">
127
+ <span title="Processing">
122
128
  <Crab className={cn("size-3.5 text-amber-300/80", "animate-crab-crawl")} />
123
129
  </span>
124
130
  ) : (
125
- <span>
131
+ <span title="Request in turn">
126
132
  <Crab className="size-3.5 text-muted-foreground/40" />
127
133
  </span>
128
134
  )}
129
135
 
130
- {/* Bottom spacer — flex-1 fills whatever height the LogEntry
131
- * consumes (header + expanded content). Turn boundaries have
132
- * no outgoing line. */}
133
136
  <div className="flex-1 flex justify-center min-h-0">
134
137
  {!isBoundary && (
135
138
  <div
@@ -13,9 +13,7 @@ import { isTurnBoundary } from "../../lib/stopReason";
13
13
  import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
14
14
  import { cn, formatTokens } from "../../lib/utils";
15
15
  import type { CapturedLog } from "../../contracts";
16
- import { getCrabVariant } from "../ui/crab-variants";
17
16
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
18
- import { ROW_CHEVRON_SLOT_CLASS } from "./ProviderLogoStack";
19
17
  import type { CacheTrendEntry } from "./cacheTrend";
20
18
  import { LogEntry } from "./LogEntry";
21
19
  import {
@@ -145,10 +143,6 @@ export const TurnGroup = memo(function TurnGroup({
145
143
  };
146
144
  }, [entries, lastIdx]);
147
145
 
148
- // Crab variant creators for the dual-crab collapsed layout
149
- const StartCrab = useMemo(() => getCrabVariant(entries[0]?.log.id ?? 0), [entries]);
150
- const EndCrab = useMemo(() => getCrabVariant(entries[lastIdx]?.log.id ?? 0), [entries, lastIdx]);
151
-
152
146
  const bgClass = turnIndex % 2 === 0 ? "bg-white/[0.012]" : "bg-cyan-300/[0.012]";
153
147
  const aggregateIsSlow =
154
148
  aggregate.maxElapsed !== null &&
@@ -209,7 +203,6 @@ export const TurnGroup = memo(function TurnGroup({
209
203
  )}
210
204
  >
211
205
  {collapsed ? (
212
- /* ---- Collapsed: dual-crab (+ summary card for multi-log turns) ---- */
213
206
  <div
214
207
  data-nav-id={`turn-collapsed-${String(firstLogId)}`}
215
208
  data-nav-action="expand"
@@ -231,78 +224,38 @@ export const TurnGroup = memo(function TurnGroup({
231
224
  </span>
232
225
  </div>
233
226
 
234
- {/* Dual-crab connector for collapsed multi-request turns. */}
235
- <div className="w-6 shrink-0 flex flex-col items-center pt-0.5 pb-0.5">
227
+ <div className="w-6 shrink-0 flex flex-col items-center pt-1.5 pb-1.5">
236
228
  <div className="flex justify-center h-[calc(0.75rem-8px)]" />
237
- <span
238
- role="button"
239
- tabIndex={0}
240
- title="Start of turn - click to expand"
241
- className="cursor-pointer"
242
- onClick={(e) => {
243
- e.stopPropagation();
244
- toggleCollapse();
245
- }}
246
- onKeyDown={(e) => {
247
- if (e.key === "Enter" || e.key === " ") {
248
- e.preventDefault();
249
- toggleCollapse();
250
- }
251
- }}
252
- >
253
- <StartCrab
254
- className={cn(
255
- "size-3.5 text-cyan-300/85",
256
- "animate-crab-appear drop-shadow-[0_0_4px_rgba(103,232,249,0.32)]",
257
- )}
258
- />
259
- </span>
260
-
229
+ <span className="size-2 rounded-full border border-cyan-300/45 bg-cyan-300/20" />
261
230
  <div className="flex-1 flex justify-center min-h-0">
262
- <div className="w-0.5 bg-muted-foreground/30 h-full" />
231
+ <div className="w-px bg-muted-foreground/25 h-full" />
263
232
  </div>
264
-
265
233
  <span
266
- role="button"
267
- tabIndex={0}
268
- title="End of Turn - click to expand"
269
- className="cursor-pointer"
270
- onClick={(e) => {
271
- e.stopPropagation();
272
- toggleCollapse();
273
- }}
274
- onKeyDown={(e) => {
275
- if (e.key === "Enter" || e.key === " ") {
276
- e.preventDefault();
277
- toggleCollapse();
278
- }
279
- }}
280
- >
281
- <EndCrab
282
- className={cn(
283
- "size-3.5 text-amber-400",
284
- "animate-crab-settle drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
285
- )}
286
- />
287
- </span>
234
+ className={cn(
235
+ "size-2 rounded-full border",
236
+ isComplete
237
+ ? "border-amber-300/45 bg-amber-300/20"
238
+ : "border-muted-foreground/30 bg-muted/30",
239
+ )}
240
+ />
288
241
  </div>
289
242
 
290
243
  {/* Summary content - hidden for single-log turns. */}
291
244
  {entries.length > 1 && (
292
245
  <div
293
246
  className={cn(
294
- "flex-1 min-w-0 mb-0.5 rounded-lg border border-cyan-300/10 py-1.5 px-3 flex items-center gap-3 text-xs shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",
295
- "border border-border bg-card shadow-sm",
247
+ "flex-1 min-w-0 mb-0.5 rounded-[8px] py-1.5 px-3 flex items-center gap-3 text-xs shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",
248
+ "border border-border bg-card shadow-sm transition-colors hover:border-cyan-200/20",
296
249
  bgClass,
297
250
  )}
298
251
  >
299
- <span className={ROW_CHEVRON_SLOT_CLASS}>
300
- <ChevronRight className="size-4 shrink-0" />
252
+ <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70">
253
+ {turnLabel}
301
254
  </span>
302
255
 
303
256
  {/* ID range */}
304
257
  <span
305
- className="text-blue-400/80 font-mono font-semibold tabular-nums shrink-0"
258
+ className="text-blue-300/85 font-mono font-semibold tabular-nums shrink-0"
306
259
  title={`Log IDs ${String(entries[0]?.log.id ?? "?")} ~ ${String(
307
260
  entries[lastIdx]?.log.id ?? "?",
308
261
  )}`}
@@ -363,6 +316,13 @@ export const TurnGroup = memo(function TurnGroup({
363
316
 
364
317
  {/* Spacer */}
365
318
  <span className="flex-1 min-w-0" />
319
+
320
+ <span
321
+ className="border border-input bg-background inline-flex size-7 shrink-0 items-center justify-center rounded-md text-slate-200"
322
+ aria-hidden="true"
323
+ >
324
+ <ChevronRight className="size-4 shrink-0" />
325
+ </span>
366
326
  </div>
367
327
  )}
368
328
  </div>
@@ -0,0 +1,56 @@
1
+ import { ApiTimeoutError } from "../../lib/apiClient";
2
+
3
+ export type FullBodyHydrationError = {
4
+ title: string;
5
+ detail: string;
6
+ };
7
+
8
+ const DEFAULT_FULL_BODY_HYDRATION_ERROR_DETAIL =
9
+ "The compact log metadata is still available. Retry if the local server was restarting or the body store was briefly unavailable.";
10
+
11
+ export function formatBytes(bytes: number): string {
12
+ if (bytes < 1024) return `${bytes.toLocaleString()} B`;
13
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
14
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
15
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
16
+ }
17
+
18
+ export function formatBodyHydrationFootprint(
19
+ requestBytes: number | null | undefined,
20
+ responseBytes: number | null | undefined,
21
+ ): string | null {
22
+ const parts: string[] = [];
23
+
24
+ if (requestBytes !== null && requestBytes !== undefined) {
25
+ parts.push(`Request ${formatBytes(requestBytes)}`);
26
+ }
27
+
28
+ if (responseBytes !== null && responseBytes !== undefined) {
29
+ parts.push(`Response ${formatBytes(responseBytes)}`);
30
+ }
31
+
32
+ return parts.length === 0 ? null : parts.join(" · ");
33
+ }
34
+
35
+ export function describeFullBodyHydrationError(error: unknown): FullBodyHydrationError {
36
+ if (error instanceof ApiTimeoutError) {
37
+ return {
38
+ title: "Full bodies timed out",
39
+ detail:
40
+ "The log store did not answer before the UI timeout. Compact metadata is still available; retry loading the bodies.",
41
+ };
42
+ }
43
+
44
+ if (error instanceof Error) {
45
+ const message = error.message.trim();
46
+ return {
47
+ title: "Full bodies could not be loaded",
48
+ detail: message === "" ? DEFAULT_FULL_BODY_HYDRATION_ERROR_DETAIL : message,
49
+ };
50
+ }
51
+
52
+ return {
53
+ title: "Full bodies could not be loaded",
54
+ detail: DEFAULT_FULL_BODY_HYDRATION_ERROR_DETAIL,
55
+ };
56
+ }
@@ -72,22 +72,58 @@ const ProviderFormatTestResultsSchema = z.object({
72
72
  streaming: ProviderTestStateSchema,
73
73
  });
74
74
 
75
- const ModelTestResultsSchema = z.object({
76
- anthropic: ProviderFormatTestResultsSchema,
77
- openai: ProviderFormatTestResultsSchema,
78
- });
75
+ export type ProviderFormatTestResults = z.infer<typeof ProviderFormatTestResultsSchema>;
79
76
 
80
- export const ProviderTestResultsSchema = z.object({
81
- anthropic: ProviderFormatTestResultsSchema,
82
- openai: ProviderFormatTestResultsSchema,
83
- models: z.record(z.string(), ModelTestResultsSchema).optional(),
84
- testedAt: z.string().optional(),
85
- });
77
+ export function createNotConfiguredProviderFormatTestResults(): ProviderFormatTestResults {
78
+ return {
79
+ nonStreaming: { notConfigured: true },
80
+ streaming: { notConfigured: true },
81
+ };
82
+ }
83
+
84
+ const ModelTestResultsSchema = z
85
+ .object({
86
+ anthropic: ProviderFormatTestResultsSchema,
87
+ openai: ProviderFormatTestResultsSchema.optional(),
88
+ openaiChat: ProviderFormatTestResultsSchema.optional(),
89
+ openaiResponses: ProviderFormatTestResultsSchema.optional(),
90
+ })
91
+ .transform((value) => {
92
+ const openaiChat =
93
+ value.openaiChat ?? value.openai ?? createNotConfiguredProviderFormatTestResults();
94
+ return {
95
+ ...value,
96
+ openai: value.openai ?? openaiChat,
97
+ openaiChat,
98
+ openaiResponses: value.openaiResponses ?? createNotConfiguredProviderFormatTestResults(),
99
+ };
100
+ });
101
+
102
+ export const ProviderTestResultsSchema = z
103
+ .object({
104
+ anthropic: ProviderFormatTestResultsSchema,
105
+ openai: ProviderFormatTestResultsSchema.optional(),
106
+ openaiChat: ProviderFormatTestResultsSchema.optional(),
107
+ openaiResponses: ProviderFormatTestResultsSchema.optional(),
108
+ models: z.record(z.string(), ModelTestResultsSchema).optional(),
109
+ testedAt: z.string().optional(),
110
+ })
111
+ .transform((value) => {
112
+ const openaiChat =
113
+ value.openaiChat ?? value.openai ?? createNotConfiguredProviderFormatTestResults();
114
+ return {
115
+ ...value,
116
+ openai: value.openai ?? openaiChat,
117
+ openaiChat,
118
+ openaiResponses: value.openaiResponses ?? createNotConfiguredProviderFormatTestResults(),
119
+ };
120
+ });
86
121
 
87
122
  export type ProviderTestErrorType = z.infer<typeof ProviderTestErrorTypeSchema>;
88
123
  export type ProviderTestDebug = z.infer<typeof ProviderTestDebugSchema>;
89
124
  export type ProviderTestResult = z.infer<typeof ProviderTestResultSchema>;
90
125
  export type ProviderTestState = z.infer<typeof ProviderTestStateSchema>;
126
+ export type ProviderModelTestResults = z.infer<typeof ModelTestResultsSchema>;
91
127
  export type ProviderTestResults = z.infer<typeof ProviderTestResultsSchema>;
92
128
 
93
129
  export function createPendingProviderTestResults(): ProviderTestResults {
@@ -100,6 +136,14 @@ export function createPendingProviderTestResults(): ProviderTestResults {
100
136
  nonStreaming: { testing: true },
101
137
  streaming: { testing: true },
102
138
  },
139
+ openaiChat: {
140
+ nonStreaming: { testing: true },
141
+ streaming: { testing: true },
142
+ },
143
+ openaiResponses: {
144
+ nonStreaming: { testing: true },
145
+ streaming: { testing: true },
146
+ },
103
147
  };
104
148
  }
105
149
 
@@ -118,5 +162,7 @@ export function createFailedProviderTestResults(
118
162
  return {
119
163
  anthropic: createFormatResult(),
120
164
  openai: createFormatResult(),
165
+ openaiChat: createFormatResult(),
166
+ openaiResponses: createFormatResult(),
121
167
  };
122
168
  }
@@ -1,17 +1,29 @@
1
1
  export type ProviderTestMode = "non-streaming" | "streaming";
2
+ export type ProviderTestApiShape = "chat" | "responses";
2
3
 
3
4
  export type ProviderTestMessage = {
4
5
  role: "user";
5
6
  content: string;
6
7
  };
7
8
 
8
- export type ProviderTestRequestBody = {
9
+ export type ProviderTestChatRequestBody = {
9
10
  model: string;
10
11
  messages: ProviderTestMessage[];
11
12
  max_tokens: number;
12
13
  stream: boolean;
13
14
  };
14
15
 
16
+ export type ProviderTestResponsesRequestBody = {
17
+ model: string;
18
+ input: string;
19
+ max_output_tokens: number;
20
+ stream: boolean;
21
+ };
22
+
23
+ export type ProviderTestRequestBody =
24
+ | ProviderTestChatRequestBody
25
+ | ProviderTestResponsesRequestBody;
26
+
15
27
  const MEMORY_PROBE_FACTS = [
16
28
  "Project fact: Agent Inspector captures LLM traffic and turns it into Raw Trace, Session Episode, and Memory Candidate layers.",
17
29
  "Procedure: before release, run bun test, npm run typecheck, npm run lint, npm run build, and openspec validate --all --strict.",
@@ -63,7 +75,17 @@ export function buildProviderTestMessages(mode: ProviderTestMode): ProviderTestM
63
75
  export function buildProviderTestRequestBody(
64
76
  model: string,
65
77
  mode: ProviderTestMode,
78
+ apiShape: ProviderTestApiShape = "chat",
66
79
  ): ProviderTestRequestBody {
80
+ if (apiShape === "responses") {
81
+ return {
82
+ model,
83
+ input: buildProviderTestUserPrompt(mode),
84
+ max_output_tokens: maxTokensForMode(mode),
85
+ stream: mode === "streaming",
86
+ };
87
+ }
88
+
67
89
  const base = {
68
90
  model,
69
91
  messages: buildProviderTestMessages(mode),
@@ -1,9 +1,24 @@
1
1
  export const PATH_V1_CHAT_COMPLETIONS = "/v1/chat/completions";
2
2
  export const PATH_V1_RESPONSES = "/v1/responses";
3
+ export const PATH_V1_MESSAGES = "/v1/messages";
4
+
5
+ export type UpstreamUrlDiagnostic = {
6
+ severity: "info" | "warning";
7
+ message: string;
8
+ };
9
+
10
+ export type UpstreamUrlPreviewOptions = {
11
+ providerName?: string;
12
+ };
13
+
14
+ export type UpstreamUrlPreview = {
15
+ finalUrl: string;
16
+ diagnostics: UpstreamUrlDiagnostic[];
17
+ };
3
18
 
4
19
  export function buildUpstreamUrl(upstreamBase: string, normalizedPath: string): string {
5
20
  const base = upstreamBase.endsWith("/") ? upstreamBase.slice(0, -1) : upstreamBase;
6
- const endpointPath = normalizedPath.split("?")[0] ?? normalizedPath;
21
+ const endpointPath = endpointPathFor(normalizedPath);
7
22
  const queryIndex = normalizedPath.indexOf("?");
8
23
  const search = queryIndex >= 0 ? normalizedPath.slice(queryIndex) : "";
9
24
 
@@ -24,6 +39,62 @@ export function buildUpstreamUrl(upstreamBase: string, normalizedPath: string):
24
39
  return base + normalizedPath;
25
40
  }
26
41
 
42
+ export function previewUpstreamUrl(
43
+ upstreamBase: string,
44
+ normalizedPath: string,
45
+ options: UpstreamUrlPreviewOptions = {},
46
+ ): UpstreamUrlPreview {
47
+ const base = upstreamBase.trim().replace(/\/+$/, "");
48
+ const endpointPath = endpointPathFor(normalizedPath);
49
+ const finalUrl = buildUpstreamUrl(base, normalizedPath);
50
+ const diagnostics: UpstreamUrlDiagnostic[] = [];
51
+ const exactEndpoint = endpointPath !== "" && base.endsWith(endpointPath);
52
+
53
+ if (exactEndpoint) {
54
+ diagnostics.push({
55
+ severity: "info",
56
+ message: "This already includes the exact endpoint; no duplicate suffix will be appended.",
57
+ });
58
+ } else if (isZhipuVersionedOpenAIBase(base) && isVersionedChatCompletionsPath(normalizedPath)) {
59
+ diagnostics.push({
60
+ severity: "info",
61
+ message: "Zhipu bases already include the API version; only /chat/completions is appended.",
62
+ });
63
+ } else if (base.endsWith("/v1") && normalizedPath.startsWith("/v1/")) {
64
+ diagnostics.push({
65
+ severity: "info",
66
+ message: "Base URL already ends with /v1; the suffix will not repeat /v1.",
67
+ });
68
+ }
69
+
70
+ if (isResponsesPath(endpointPath) && baseEndsWithChatEndpoint(base)) {
71
+ diagnostics.push({
72
+ severity: "warning",
73
+ message: "This looks like a Chat Completions endpoint, so Responses would append after it.",
74
+ });
75
+ }
76
+
77
+ if (isChatCompletionsPath(endpointPath) && baseEndsWithResponsesEndpoint(base)) {
78
+ diagnostics.push({
79
+ severity: "warning",
80
+ message: "This looks like a Responses endpoint, so Chat Completions would append after it.",
81
+ });
82
+ }
83
+
84
+ if (isResponsesPath(endpointPath) && isLikelyDeepSeekBase(base, options.providerName)) {
85
+ diagnostics.push({
86
+ severity: "warning",
87
+ message: "DeepSeek's official API does not currently expose OpenAI Responses.",
88
+ });
89
+ }
90
+
91
+ return { finalUrl, diagnostics };
92
+ }
93
+
94
+ function endpointPathFor(normalizedPath: string): string {
95
+ return normalizedPath.split("?")[0] ?? normalizedPath;
96
+ }
97
+
27
98
  function isVersionedChatCompletionsPath(normalizedPath: string): boolean {
28
99
  return (
29
100
  normalizedPath === PATH_V1_CHAT_COMPLETIONS ||
@@ -32,6 +103,39 @@ function isVersionedChatCompletionsPath(normalizedPath: string): boolean {
32
103
  );
33
104
  }
34
105
 
106
+ function isChatCompletionsPath(endpointPath: string): boolean {
107
+ return (
108
+ endpointPath === PATH_V1_CHAT_COMPLETIONS ||
109
+ endpointPath.startsWith(`${PATH_V1_CHAT_COMPLETIONS}/`)
110
+ );
111
+ }
112
+
113
+ function isResponsesPath(endpointPath: string): boolean {
114
+ return endpointPath === PATH_V1_RESPONSES || endpointPath.startsWith(`${PATH_V1_RESPONSES}/`);
115
+ }
116
+
117
+ function baseEndsWithChatEndpoint(base: string): boolean {
118
+ const normalized = base.toLowerCase();
119
+ return normalized.endsWith("/chat/completions");
120
+ }
121
+
122
+ function baseEndsWithResponsesEndpoint(base: string): boolean {
123
+ const normalized = base.toLowerCase();
124
+ return normalized.endsWith("/responses");
125
+ }
126
+
127
+ function isLikelyDeepSeekBase(base: string, providerName: string | undefined): boolean {
128
+ const provider = providerName?.toLowerCase();
129
+ if (provider !== undefined && provider.includes("deepseek")) return true;
130
+
131
+ try {
132
+ const parsed = new URL(base);
133
+ return parsed.hostname.toLowerCase().includes("deepseek");
134
+ } catch {
135
+ return base.toLowerCase().includes("deepseek");
136
+ }
137
+ }
138
+
35
139
  function isZhipuVersionedOpenAIBase(base: string): boolean {
36
140
  try {
37
141
  const parsed = new URL(base);