@tonyclaw/agent-inspector 3.0.23 → 3.0.24

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 (47) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-CFedvZHU.js → CompareDrawer-BC52FYwn.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-WuChOrSS.js +127 -0
  4. package/.output/public/assets/{ReplayDialog-X-fRSKAu.js → ReplayDialog-BcKsarKt.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-S8qB5nVg.js → RequestAnatomy-DR0uGpCj.js} +1 -1
  6. package/.output/public/assets/{ResponseView-_rcOWSwJ.js → ResponseView-kxAAPfmn.js} +2 -2
  7. package/.output/public/assets/{StreamingChunkSequence-Bc15AVpN.js → StreamingChunkSequence-YQR0PjQj.js} +1 -1
  8. package/.output/public/assets/{_sessionId-Cz-HW2Wf.js → _sessionId-DRl-Y9om.js} +1 -1
  9. package/.output/public/assets/{index-CEgwWY3E.js → index-CQbrc-hi.js} +71 -7
  10. package/.output/public/assets/{index-CXYBU0j8.js → index-CcSIBI4L.js} +1 -1
  11. package/.output/public/assets/index-DQeXi4Rv.css +1 -0
  12. package/.output/public/assets/{index-ClKXooQd.js → index-ZrMgyEZV.js} +1 -1
  13. package/.output/public/assets/{json-viewer-QfU4qGdd.js → json-viewer-CS5pwnEA.js} +1 -1
  14. package/.output/server/_libs/lucide-react.mjs +266 -234
  15. package/.output/server/{_sessionId-9FAmYZVf.mjs → _sessionId-BLk2xPkR.mjs} +2 -2
  16. package/.output/server/_ssr/{CompareDrawer-BLKjOzwD.mjs → CompareDrawer-Cw7Dseo3.mjs} +3 -3
  17. package/.output/server/_ssr/{ProxyViewerContainer-Dcs2Mq8l.mjs → ProxyViewerContainer-D8SB0zyQ.mjs} +1124 -877
  18. package/.output/server/_ssr/{ReplayDialog-oyiF4LIo.mjs → ReplayDialog-B-YMhZkE.mjs} +4 -4
  19. package/.output/server/_ssr/{RequestAnatomy-DkyQXSuj.mjs → RequestAnatomy-a9d8UTYZ.mjs} +2 -2
  20. package/.output/server/_ssr/{ResponseView-3NTDEiDh.mjs → ResponseView-PzaZS4CN.mjs} +3 -3
  21. package/.output/server/_ssr/{StreamingChunkSequence-DRq-5Bgn.mjs → StreamingChunkSequence-C21_TCXk.mjs} +2 -2
  22. package/.output/server/_ssr/{index-dtMiNUa1.mjs → index-CrCeNP7H.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-BhUwicvp.mjs → json-viewer-Di2gxs9H.mjs} +3 -3
  25. package/.output/server/_ssr/{router-DO0OntUK.mjs → router-BJzdUbcH.mjs} +296 -91
  26. package/.output/server/{_tanstack-start-manifest_v-DO5v3BHY.mjs → _tanstack-start-manifest_v-CGBTdAYC.mjs} +1 -1
  27. package/.output/server/index.mjs +76 -76
  28. package/package.json +3 -2
  29. package/src/components/ProxyViewer.tsx +136 -460
  30. package/src/components/ProxyViewerContainer.tsx +5 -47
  31. package/src/components/alerts/AlertsDialog.tsx +6 -2
  32. package/src/components/providers/ProviderCard.tsx +20 -579
  33. package/src/components/providers/ProviderTestResultsView.tsx +732 -0
  34. package/src/components/proxy-viewer/LogEntry.tsx +5 -0
  35. package/src/components/proxy-viewer/LogEntryHeader.tsx +7 -0
  36. package/src/components/proxy-viewer/ToolTraceEvents.tsx +33 -36
  37. package/src/components/proxy-viewer/TurnGroup.tsx +1 -5
  38. package/src/components/proxy-viewer/proxyViewerContainerLogic.ts +46 -0
  39. package/src/components/proxy-viewer/proxyViewerLogic.ts +469 -0
  40. package/src/lib/themeMode.ts +45 -0
  41. package/src/lib/uiScale.ts +112 -0
  42. package/src/lib/useThemeMode.ts +40 -0
  43. package/src/lib/useUiScale.ts +52 -0
  44. package/src/routes/__root.tsx +78 -1
  45. package/styles/globals.css +305 -23
  46. package/.output/public/assets/ProxyViewerContainer-BlCrBvdm.js +0 -127
  47. package/.output/public/assets/index-B_SIQ47i.css +0 -1
@@ -0,0 +1,732 @@
1
+ import { type JSX, type ReactNode, useState } from "react";
2
+ import {
3
+ AlertCircle,
4
+ CheckCircle,
5
+ ChevronDown,
6
+ ChevronRight,
7
+ Clock,
8
+ Gauge,
9
+ HelpCircle,
10
+ Lock,
11
+ Minus,
12
+ RotateCw,
13
+ Server,
14
+ Wifi,
15
+ WifiOff,
16
+ } from "lucide-react";
17
+ import type {
18
+ ProviderFormatTestResults,
19
+ ProviderModelTestResults,
20
+ ProviderTestErrorType as ErrorType,
21
+ ProviderTestResult as TestResult,
22
+ ProviderTestResults as TestResults,
23
+ ProviderTestState,
24
+ } from "../../lib/providerTestContract";
25
+ import type { ProviderConfig } from "../../proxy/providers";
26
+ import { cn } from "../../lib/utils";
27
+ import {
28
+ Dialog,
29
+ DialogContent,
30
+ DialogDescription,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ DialogTrigger,
34
+ } from "../ui/dialog";
35
+
36
+ type ProviderTestResultSet = {
37
+ anthropic: ProviderFormatTestResults;
38
+ openaiChat: ProviderFormatTestResults;
39
+ openaiResponses: ProviderFormatTestResults;
40
+ };
41
+
42
+ type ProbeTone = "success" | "failure" | "pending" | "quiet";
43
+
44
+ export type ProviderProbePresentation = {
45
+ tone: ProbeTone;
46
+ label: string;
47
+ metric: string | null;
48
+ hasDetails: boolean;
49
+ };
50
+
51
+ export type ProviderTestSummary = {
52
+ total: number;
53
+ passed: number;
54
+ failed: number;
55
+ testing: number;
56
+ };
57
+
58
+ function hasSuccessField(result: ProviderTestState): result is TestResult {
59
+ return Object.prototype.hasOwnProperty.call(result, "success");
60
+ }
61
+
62
+ function isNotConfiguredState(result: ProviderTestState): result is { notConfigured: true } {
63
+ return Object.prototype.hasOwnProperty.call(result, "notConfigured");
64
+ }
65
+
66
+ function providerHasEndpoint(value: string | undefined): value is string {
67
+ return value !== undefined && value !== "";
68
+ }
69
+
70
+ function hasText(value: string | undefined): value is string {
71
+ return value !== undefined && value.trim() !== "";
72
+ }
73
+
74
+ function formatElapsed(ms: number): string {
75
+ if (ms < 1000) return `${ms}ms`;
76
+ return `${(ms / 1000).toFixed(1)}s`;
77
+ }
78
+
79
+ function formatTokenRate(tokensPerSecond: number): string {
80
+ if (tokensPerSecond >= 10) return `${tokensPerSecond.toFixed(0)}/s`;
81
+ return `${tokensPerSecond.toFixed(1)}/s`;
82
+ }
83
+
84
+ function errorLabel(type: ErrorType): string {
85
+ switch (type) {
86
+ case "timeout":
87
+ return "Timeout";
88
+ case "network_unreachable":
89
+ return "Offline";
90
+ case "connection_refused":
91
+ return "Refused";
92
+ case "auth_failed":
93
+ return "Auth";
94
+ case "rate_limited":
95
+ return "Rate limit";
96
+ case "server_error":
97
+ return "Server";
98
+ case "invalid_response":
99
+ return "Invalid";
100
+ case "unknown":
101
+ return "Failed";
102
+ }
103
+ }
104
+
105
+ function compactSuccessMetric(result: TestResult): string | null {
106
+ if (result.firstChunkMs !== undefined && result.firstChunkMs !== null) {
107
+ return formatElapsed(result.firstChunkMs);
108
+ }
109
+ if (result.latencyMs !== undefined) return formatElapsed(result.latencyMs);
110
+ if (result.totalStreamMs !== undefined && result.totalStreamMs !== null) {
111
+ return formatElapsed(result.totalStreamMs);
112
+ }
113
+ if (
114
+ result.tokensPerSecond !== undefined &&
115
+ result.tokensPerSecond !== null &&
116
+ result.tokensPerSecond > 0
117
+ ) {
118
+ return formatTokenRate(result.tokensPerSecond);
119
+ }
120
+ return null;
121
+ }
122
+
123
+ export function presentProviderProbe(result: ProviderTestState): ProviderProbePresentation {
124
+ if (isNotConfiguredState(result)) {
125
+ return { tone: "quiet", label: "N/A", metric: null, hasDetails: false };
126
+ }
127
+ if (!hasSuccessField(result)) {
128
+ return { tone: "pending", label: "Running", metric: null, hasDetails: false };
129
+ }
130
+ if (result.success) {
131
+ return {
132
+ tone: "success",
133
+ label: "Pass",
134
+ metric: compactSuccessMetric(result),
135
+ hasDetails: true,
136
+ };
137
+ }
138
+ return {
139
+ tone: "failure",
140
+ label: errorLabel(result.error?.type ?? "unknown"),
141
+ metric: null,
142
+ hasDetails: true,
143
+ };
144
+ }
145
+
146
+ function configuredFormatResults(
147
+ provider: ProviderConfig,
148
+ testResults: TestResults | undefined,
149
+ ): ProviderFormatTestResults[] {
150
+ if (testResults === undefined) return [];
151
+ const results: ProviderFormatTestResults[] = [];
152
+ if (providerHasEndpoint(provider.anthropicBaseUrl)) results.push(testResults.anthropic);
153
+ if (providerHasEndpoint(provider.openaiBaseUrl)) results.push(testResults.openaiChat);
154
+ if (providerHasEndpoint(provider.openaiResponsesBaseUrl)) {
155
+ results.push(testResults.openaiResponses);
156
+ }
157
+ return results;
158
+ }
159
+
160
+ export function summarizeProviderTestResults(
161
+ provider: ProviderConfig,
162
+ testResults: TestResults | undefined,
163
+ ): ProviderTestSummary {
164
+ const summary: ProviderTestSummary = { total: 0, passed: 0, failed: 0, testing: 0 };
165
+ for (const results of configuredFormatResults(provider, testResults)) {
166
+ for (const state of [results.nonStreaming, results.streaming]) {
167
+ if (isNotConfiguredState(state)) continue;
168
+ summary.total += 1;
169
+ if (!hasSuccessField(state)) {
170
+ summary.testing += 1;
171
+ } else if (state.success) {
172
+ summary.passed += 1;
173
+ } else {
174
+ summary.failed += 1;
175
+ }
176
+ }
177
+ }
178
+ return summary;
179
+ }
180
+
181
+ function formatHeaders(headers: Record<string, string> | undefined): string | undefined {
182
+ if (headers === undefined) return undefined;
183
+ const lines = Object.entries(headers).map(([name, value]) => `${name}: ${value}`);
184
+ return lines.length === 0 ? undefined : lines.join("\n");
185
+ }
186
+
187
+ function DiagnosticSection({
188
+ title,
189
+ children,
190
+ }: {
191
+ title: string;
192
+ children: ReactNode;
193
+ }): JSX.Element {
194
+ return (
195
+ <section className="space-y-1.5">
196
+ <h4 className="text-xs font-medium text-foreground/80">{title}</h4>
197
+ {children}
198
+ </section>
199
+ );
200
+ }
201
+
202
+ function DiagnosticPre({ value }: { value: string }): JSX.Element {
203
+ return (
204
+ <pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted/40 p-3 font-mono text-[11px] leading-relaxed text-foreground/80">
205
+ {value}
206
+ </pre>
207
+ );
208
+ }
209
+
210
+ function ResultMetric({ label, value }: { label: string; value: string }): JSX.Element {
211
+ return (
212
+ <div className="min-w-0 rounded-md bg-muted/35 px-3 py-2">
213
+ <dt className="text-[10px] text-muted-foreground">{label}</dt>
214
+ <dd className="mt-1 truncate font-mono text-xs font-semibold text-foreground" title={value}>
215
+ {value}
216
+ </dd>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ function ProviderTestMetrics({ result }: { result: TestResult }): JSX.Element | null {
222
+ const hasTokens = result.inputTokens !== undefined || result.outputTokens !== undefined;
223
+ const hasCache =
224
+ result.cacheCreationInputTokens !== undefined || result.cacheReadInputTokens !== undefined;
225
+ const hasMetrics =
226
+ hasText(result.model) ||
227
+ hasTokens ||
228
+ hasCache ||
229
+ result.latencyMs !== undefined ||
230
+ (result.firstChunkMs !== undefined && result.firstChunkMs !== null) ||
231
+ (result.totalStreamMs !== undefined && result.totalStreamMs !== null) ||
232
+ (result.tokensPerSecond !== undefined &&
233
+ result.tokensPerSecond !== null &&
234
+ result.tokensPerSecond > 0);
235
+ if (!hasMetrics) return null;
236
+
237
+ return (
238
+ <dl className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
239
+ {hasText(result.model) && <ResultMetric label="Model" value={result.model} />}
240
+ {result.latencyMs !== undefined && (
241
+ <ResultMetric label="Latency" value={formatElapsed(result.latencyMs)} />
242
+ )}
243
+ {result.firstChunkMs !== undefined && result.firstChunkMs !== null && (
244
+ <ResultMetric label="First chunk" value={formatElapsed(result.firstChunkMs)} />
245
+ )}
246
+ {result.totalStreamMs !== undefined && result.totalStreamMs !== null && (
247
+ <ResultMetric label="Stream total" value={formatElapsed(result.totalStreamMs)} />
248
+ )}
249
+ {result.tokensPerSecond !== undefined &&
250
+ result.tokensPerSecond !== null &&
251
+ result.tokensPerSecond > 0 && (
252
+ <ResultMetric
253
+ label="Token rate"
254
+ value={`${formatTokenRate(result.tokensPerSecond)} tok`}
255
+ />
256
+ )}
257
+ {hasTokens && (
258
+ <ResultMetric
259
+ label="Tokens in / out"
260
+ value={`${String(result.inputTokens ?? 0)} / ${String(result.outputTokens ?? 0)}`}
261
+ />
262
+ )}
263
+ {hasCache && (
264
+ <ResultMetric
265
+ label="Cache create / read"
266
+ value={`${String(result.cacheCreationInputTokens ?? 0)} / ${String(result.cacheReadInputTokens ?? 0)}`}
267
+ />
268
+ )}
269
+ </dl>
270
+ );
271
+ }
272
+
273
+ function ProviderTestDiagnostics({ result }: { result: TestResult }): JSX.Element | null {
274
+ const debug = result.debug;
275
+ const requestUrl = debug?.requestUrl;
276
+ const requestMethod = debug?.requestMethod ?? "POST";
277
+ const requestHeaders = formatHeaders(debug?.requestHeaders);
278
+ const responseHeaders = formatHeaders(debug?.responseHeaders);
279
+ const responseStatus =
280
+ debug?.responseStatus === undefined
281
+ ? undefined
282
+ : `${String(debug.responseStatus)}${
283
+ hasText(debug.responseStatusText) ? ` ${debug.responseStatusText}` : ""
284
+ }`;
285
+ const responseBody = debug?.responseBody ?? result.rawResponse;
286
+ const hasDiagnostics =
287
+ hasText(result.error?.hint) ||
288
+ hasText(result.error?.details) ||
289
+ hasText(requestUrl) ||
290
+ requestHeaders !== undefined ||
291
+ hasText(debug?.requestBody) ||
292
+ hasText(responseStatus) ||
293
+ responseHeaders !== undefined ||
294
+ hasText(responseBody);
295
+ if (!hasDiagnostics) return null;
296
+
297
+ return (
298
+ <div className="space-y-4 border-t border-border/70 pt-4 text-xs text-muted-foreground">
299
+ {hasText(result.error?.hint) && (
300
+ <DiagnosticSection title="Hint">
301
+ <p className="break-words text-foreground/80">{result.error.hint}</p>
302
+ </DiagnosticSection>
303
+ )}
304
+ {hasText(result.error?.details) && (
305
+ <DiagnosticSection title="Error details">
306
+ <DiagnosticPre value={result.error.details} />
307
+ </DiagnosticSection>
308
+ )}
309
+ {hasText(requestUrl) && (
310
+ <DiagnosticSection title="Request">
311
+ <DiagnosticPre value={`${requestMethod} ${requestUrl}`} />
312
+ </DiagnosticSection>
313
+ )}
314
+ {requestHeaders !== undefined && (
315
+ <DiagnosticSection title="Request headers">
316
+ <DiagnosticPre value={requestHeaders} />
317
+ </DiagnosticSection>
318
+ )}
319
+ {hasText(debug?.requestBody) && (
320
+ <DiagnosticSection title="Request body">
321
+ <DiagnosticPre value={debug.requestBody} />
322
+ </DiagnosticSection>
323
+ )}
324
+ {hasText(responseStatus) && (
325
+ <DiagnosticSection title="Response status">
326
+ <DiagnosticPre value={responseStatus} />
327
+ </DiagnosticSection>
328
+ )}
329
+ {responseHeaders !== undefined && (
330
+ <DiagnosticSection title="Response headers">
331
+ <DiagnosticPre value={responseHeaders} />
332
+ </DiagnosticSection>
333
+ )}
334
+ {hasText(responseBody) && (
335
+ <DiagnosticSection title="Response body">
336
+ <DiagnosticPre value={responseBody} />
337
+ </DiagnosticSection>
338
+ )}
339
+ </div>
340
+ );
341
+ }
342
+
343
+ function ProviderTestResultDialog({
344
+ result,
345
+ probeLabel,
346
+ children,
347
+ }: {
348
+ result: TestResult;
349
+ probeLabel: string;
350
+ children: ReactNode;
351
+ }): JSX.Element {
352
+ const errorMessage = result.error?.message ?? "Connection failed";
353
+ return (
354
+ <Dialog>
355
+ <DialogTrigger asChild>{children}</DialogTrigger>
356
+ <DialogContent className="max-h-[82vh] max-w-2xl gap-0 overflow-hidden p-0">
357
+ <DialogHeader className="border-b border-border/70 px-5 py-4 pr-12">
358
+ <div className="flex items-center gap-2">
359
+ {result.success ? (
360
+ <CheckCircle className="size-4 text-cyan-300" />
361
+ ) : (
362
+ <AlertCircle className="size-4 text-red-300" />
363
+ )}
364
+ <DialogTitle className="text-base">{probeLabel}</DialogTitle>
365
+ </div>
366
+ <DialogDescription>
367
+ {result.success ? "Connection probe passed." : errorMessage}
368
+ </DialogDescription>
369
+ </DialogHeader>
370
+ <div className="space-y-4 overflow-y-auto p-5">
371
+ {!result.success && (
372
+ <div className="rounded-md border border-red-400/20 bg-red-400/[0.06] px-3 py-2 text-sm text-red-200">
373
+ <div className="font-medium">{errorLabel(result.error?.type ?? "unknown")}</div>
374
+ <div className="mt-1 break-words text-xs text-muted-foreground">{errorMessage}</div>
375
+ </div>
376
+ )}
377
+ <ProviderTestMetrics result={result} />
378
+ <ProviderTestDiagnostics result={result} />
379
+ </div>
380
+ </DialogContent>
381
+ </Dialog>
382
+ );
383
+ }
384
+
385
+ function errorIcon(type: ErrorType): JSX.Element {
386
+ const className = "size-3 shrink-0";
387
+ switch (type) {
388
+ case "timeout":
389
+ return <Clock className={className} />;
390
+ case "network_unreachable":
391
+ return <WifiOff className={className} />;
392
+ case "connection_refused":
393
+ return <Wifi className={className} />;
394
+ case "auth_failed":
395
+ return <Lock className={className} />;
396
+ case "rate_limited":
397
+ return <Gauge className={className} />;
398
+ case "server_error":
399
+ return <Server className={className} />;
400
+ case "invalid_response":
401
+ return <HelpCircle className={className} />;
402
+ case "unknown":
403
+ return <AlertCircle className={className} />;
404
+ }
405
+ }
406
+
407
+ const PROBE_TONE_CLASSES: Record<ProbeTone, string> = {
408
+ success: "bg-cyan-300/[0.07] text-cyan-100 hover:bg-cyan-300/[0.11]",
409
+ failure: "bg-red-400/[0.07] text-red-200 hover:bg-red-400/[0.11]",
410
+ pending: "bg-muted/40 text-foreground/80",
411
+ quiet: "bg-transparent text-muted-foreground/70",
412
+ };
413
+
414
+ function ProviderProbeStatus({
415
+ result,
416
+ probeLabel,
417
+ }: {
418
+ result: ProviderTestState;
419
+ probeLabel: string;
420
+ }): JSX.Element {
421
+ const presentation = presentProviderProbe(result);
422
+ const icon = isNotConfiguredState(result) ? (
423
+ <Minus className="size-3 shrink-0" />
424
+ ) : !hasSuccessField(result) ? (
425
+ <RotateCw className="size-3 shrink-0 animate-spin" />
426
+ ) : result.success ? (
427
+ <CheckCircle className="size-3 shrink-0" />
428
+ ) : (
429
+ errorIcon(result.error?.type ?? "unknown")
430
+ );
431
+ const content = (
432
+ <>
433
+ {icon}
434
+ <span className="min-w-0 truncate">{presentation.label}</span>
435
+ {presentation.metric !== null && (
436
+ <span className="ml-auto shrink-0 font-mono text-[10px] opacity-75">
437
+ {presentation.metric}
438
+ </span>
439
+ )}
440
+ </>
441
+ );
442
+ const className = cn(
443
+ "inline-flex h-7 w-full min-w-0 items-center gap-1.5 rounded-md px-2 text-left text-xs transition-colors",
444
+ PROBE_TONE_CLASSES[presentation.tone],
445
+ );
446
+
447
+ if (hasSuccessField(result) && presentation.hasDetails) {
448
+ return (
449
+ <ProviderTestResultDialog result={result} probeLabel={probeLabel}>
450
+ <button
451
+ type="button"
452
+ className={className}
453
+ aria-label={`Open ${probeLabel} result: ${presentation.label}`}
454
+ >
455
+ {content}
456
+ </button>
457
+ </ProviderTestResultDialog>
458
+ );
459
+ }
460
+
461
+ return <div className={className}>{content}</div>;
462
+ }
463
+
464
+ function ProbePlaceholder({ label }: { label: string }): JSX.Element {
465
+ return (
466
+ <div className="inline-flex h-7 w-full min-w-0 items-center gap-1.5 rounded-md px-2 text-xs text-muted-foreground/70">
467
+ <Minus className="size-3 shrink-0" />
468
+ <span className="truncate">{label}</span>
469
+ </div>
470
+ );
471
+ }
472
+
473
+ function ProviderFormatRow({
474
+ label,
475
+ results,
476
+ configured = true,
477
+ }: {
478
+ label: string;
479
+ results?: ProviderFormatTestResults;
480
+ configured?: boolean;
481
+ }): JSX.Element {
482
+ return (
483
+ <div className="grid min-w-0 grid-cols-[minmax(6.5rem,1fr)_minmax(6rem,0.9fr)_minmax(6rem,0.9fr)] items-center gap-2 border-t border-border/60 px-2 py-2">
484
+ <div className="min-w-0 truncate text-xs font-medium text-foreground/85" title={label}>
485
+ {label}
486
+ </div>
487
+ {configured && results !== undefined ? (
488
+ <ProviderProbeStatus result={results.nonStreaming} probeLabel={`${label} non-stream`} />
489
+ ) : (
490
+ <ProbePlaceholder label={configured ? "Not tested" : "Off"} />
491
+ )}
492
+ {configured && results !== undefined ? (
493
+ <ProviderProbeStatus result={results.streaming} probeLabel={`${label} stream`} />
494
+ ) : (
495
+ <ProbePlaceholder label={configured ? "Not tested" : "Off"} />
496
+ )}
497
+ </div>
498
+ );
499
+ }
500
+
501
+ export function ProviderTestMatrix({
502
+ provider,
503
+ results,
504
+ }: {
505
+ provider: ProviderConfig;
506
+ results?: ProviderTestResultSet;
507
+ }): JSX.Element {
508
+ const hasAnthropic = providerHasEndpoint(provider.anthropicBaseUrl);
509
+ const hasChat = providerHasEndpoint(provider.openaiBaseUrl);
510
+ const hasResponses = providerHasEndpoint(provider.openaiResponsesBaseUrl);
511
+ if (!hasAnthropic && !hasChat && !hasResponses) {
512
+ return <div className="text-xs text-muted-foreground">No testable endpoint configured.</div>;
513
+ }
514
+
515
+ return (
516
+ <div className="min-w-0 overflow-x-auto rounded-md border border-border/70">
517
+ <div className="min-w-[20rem]">
518
+ <div className="grid grid-cols-[minmax(6.5rem,1fr)_minmax(6rem,0.9fr)_minmax(6rem,0.9fr)] gap-2 bg-muted/25 px-2 py-1.5 text-[10px] text-muted-foreground">
519
+ <span>Protocol</span>
520
+ <span>Non-stream</span>
521
+ <span>Stream</span>
522
+ </div>
523
+ {hasAnthropic && <ProviderFormatRow label="Anthropic" results={results?.anthropic} />}
524
+ {hasChat && <ProviderFormatRow label="OpenAI Chat" results={results?.openaiChat} />}
525
+ <ProviderFormatRow
526
+ label="OpenAI Responses"
527
+ results={results?.openaiResponses}
528
+ configured={hasResponses}
529
+ />
530
+ </div>
531
+ </div>
532
+ );
533
+ }
534
+
535
+ function summarizeFormat(results: ProviderFormatTestResults): ProviderTestSummary {
536
+ const summary: ProviderTestSummary = { total: 0, passed: 0, failed: 0, testing: 0 };
537
+ for (const state of [results.nonStreaming, results.streaming]) {
538
+ if (isNotConfiguredState(state)) continue;
539
+ summary.total += 1;
540
+ if (!hasSuccessField(state)) summary.testing += 1;
541
+ else if (state.success) summary.passed += 1;
542
+ else summary.failed += 1;
543
+ }
544
+ return summary;
545
+ }
546
+
547
+ function FormatSummaryBadge({
548
+ label,
549
+ results,
550
+ }: {
551
+ label: string;
552
+ results: ProviderFormatTestResults;
553
+ }): JSX.Element {
554
+ const summary = summarizeFormat(results);
555
+ const className =
556
+ summary.failed > 0
557
+ ? "border-red-400/20 bg-red-400/[0.06] text-red-200"
558
+ : summary.testing > 0
559
+ ? "border-amber-300/20 bg-amber-300/[0.06] text-amber-100"
560
+ : summary.total > 0 && summary.passed === summary.total
561
+ ? "border-cyan-300/20 bg-cyan-300/[0.06] text-cyan-100"
562
+ : "border-border/60 bg-transparent text-muted-foreground";
563
+ const value =
564
+ summary.failed > 0
565
+ ? `${summary.failed} fail`
566
+ : summary.testing > 0
567
+ ? "running"
568
+ : summary.total === 0
569
+ ? "off"
570
+ : `${summary.passed}/${summary.total}`;
571
+ return (
572
+ <span className={cn("rounded border px-1.5 py-0.5 text-[10px]", className)}>
573
+ {label} {value}
574
+ </span>
575
+ );
576
+ }
577
+
578
+ function ModelResultRow({
579
+ provider,
580
+ modelName,
581
+ results,
582
+ expanded,
583
+ onToggle,
584
+ }: {
585
+ provider: ProviderConfig;
586
+ modelName: string;
587
+ results: ProviderModelTestResults;
588
+ expanded: boolean;
589
+ onToggle: () => void;
590
+ }): JSX.Element {
591
+ return (
592
+ <div className="min-w-0">
593
+ <button
594
+ type="button"
595
+ onClick={onToggle}
596
+ className="flex w-full min-w-0 flex-wrap items-center gap-2 px-2 py-2 text-left transition-colors hover:bg-muted/20"
597
+ aria-expanded={expanded}
598
+ >
599
+ {expanded ? (
600
+ <ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
601
+ ) : (
602
+ <ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
603
+ )}
604
+ <span className="min-w-[8rem] flex-1 truncate text-xs font-medium" title={modelName}>
605
+ {modelName}
606
+ </span>
607
+ <span className="flex flex-wrap items-center justify-end gap-1">
608
+ {providerHasEndpoint(provider.anthropicBaseUrl) && (
609
+ <FormatSummaryBadge label="A" results={results.anthropic} />
610
+ )}
611
+ {providerHasEndpoint(provider.openaiBaseUrl) && (
612
+ <FormatSummaryBadge label="Chat" results={results.openaiChat} />
613
+ )}
614
+ {providerHasEndpoint(provider.openaiResponsesBaseUrl) && (
615
+ <FormatSummaryBadge label="Resp" results={results.openaiResponses} />
616
+ )}
617
+ </span>
618
+ </button>
619
+ {expanded && (
620
+ <div className="px-2 pb-3">
621
+ <ProviderTestMatrix provider={provider} results={results} />
622
+ </div>
623
+ )}
624
+ </div>
625
+ );
626
+ }
627
+
628
+ export function ProviderModelTestResultsView({
629
+ provider,
630
+ testResults,
631
+ }: {
632
+ provider: ProviderConfig;
633
+ testResults: TestResults;
634
+ }): JSX.Element | null {
635
+ const entries = Object.entries(testResults.models ?? {});
636
+ const [showModels, setShowModels] = useState(false);
637
+ const [expandedModelName, setExpandedModelName] = useState<string | null>(null);
638
+ if (entries.length === 0) return null;
639
+
640
+ return (
641
+ <section className="space-y-2">
642
+ <button
643
+ type="button"
644
+ onClick={() => setShowModels((value) => !value)}
645
+ className="flex w-full items-center gap-1.5 text-left text-xs font-medium text-foreground/80 transition-colors hover:text-foreground"
646
+ aria-expanded={showModels}
647
+ >
648
+ {showModels ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
649
+ Model probes
650
+ <span className="rounded bg-muted/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
651
+ {entries.length}
652
+ </span>
653
+ </button>
654
+ {showModels && (
655
+ <div className="min-w-0 divide-y divide-border/60 rounded-md border border-border/70">
656
+ {entries.map(([modelName, modelResults]) => (
657
+ <ModelResultRow
658
+ key={modelName}
659
+ provider={provider}
660
+ modelName={modelName}
661
+ results={modelResults}
662
+ expanded={expandedModelName === modelName}
663
+ onToggle={() =>
664
+ setExpandedModelName((current) => (current === modelName ? null : modelName))
665
+ }
666
+ />
667
+ ))}
668
+ </div>
669
+ )}
670
+ </section>
671
+ );
672
+ }
673
+
674
+ export function ProviderTestPill({
675
+ provider,
676
+ testResults,
677
+ isTesting,
678
+ }: {
679
+ provider: ProviderConfig;
680
+ testResults: TestResults | undefined;
681
+ isTesting: boolean;
682
+ }): JSX.Element {
683
+ const hasEndpoint =
684
+ providerHasEndpoint(provider.anthropicBaseUrl) ||
685
+ providerHasEndpoint(provider.openaiBaseUrl) ||
686
+ providerHasEndpoint(provider.openaiResponsesBaseUrl);
687
+ const summary = summarizeProviderTestResults(provider, testResults);
688
+
689
+ if (isTesting) {
690
+ return (
691
+ <span className="inline-flex items-center gap-1 rounded border border-cyan-300/25 bg-cyan-300/[0.08] px-2 py-1 text-xs text-cyan-100">
692
+ <RotateCw className="size-3 animate-spin" />
693
+ Testing
694
+ </span>
695
+ );
696
+ }
697
+ if (!hasEndpoint) {
698
+ return (
699
+ <span className="rounded border border-border/70 bg-muted/20 px-2 py-1 text-xs text-muted-foreground">
700
+ No endpoint
701
+ </span>
702
+ );
703
+ }
704
+ if (testResults === undefined || summary.total === 0) {
705
+ return (
706
+ <span className="rounded border border-border/70 bg-muted/20 px-2 py-1 text-xs text-muted-foreground">
707
+ Not tested
708
+ </span>
709
+ );
710
+ }
711
+ if (summary.failed > 0) {
712
+ return (
713
+ <span className="inline-flex items-center gap-1 rounded border border-red-400/25 bg-red-500/[0.08] px-2 py-1 text-xs text-red-200">
714
+ <AlertCircle className="size-3" />
715
+ {summary.failed} failed
716
+ </span>
717
+ );
718
+ }
719
+ if (summary.testing > 0 || summary.passed < summary.total) {
720
+ return (
721
+ <span className="rounded border border-amber-300/25 bg-amber-400/[0.08] px-2 py-1 text-xs text-amber-100">
722
+ {summary.passed}/{summary.total}
723
+ </span>
724
+ );
725
+ }
726
+ return (
727
+ <span className="inline-flex items-center gap-1 rounded border border-cyan-300/25 bg-cyan-300/[0.08] px-2 py-1 text-xs text-cyan-100">
728
+ <CheckCircle className="size-3" />
729
+ {summary.passed}/{summary.total}
730
+ </span>
731
+ );
732
+ }