@tonyclaw/agent-inspector 2.0.8 → 2.0.10

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 (44) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-Djb4XFZS.js → CompareDrawer-BWMipEjS.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-DIAgurux.js +114 -0
  4. package/.output/public/assets/{ReplayDialog-DBPMIs2I.js → ReplayDialog-DaSzZsvM.js} +1 -1
  5. package/.output/public/assets/RequestAnatomy-BfQmPHCd.js +1 -0
  6. package/.output/public/assets/ResponseView-CblEWDMx.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-B4atnL99.js +1 -0
  8. package/.output/public/assets/_sessionId-DUzYnZXV.js +1 -0
  9. package/.output/public/assets/index-CxhRsekH.css +1 -0
  10. package/.output/public/assets/index-jagRsQaV.js +1 -0
  11. package/.output/public/assets/{main-CwhqfJqM.js → main-DE6Y4-wV.js} +2 -2
  12. package/.output/server/{_sessionId-DzBy9gLa.mjs → _sessionId-61HO9rUI.mjs} +3 -3
  13. package/.output/server/_ssr/{CompareDrawer-C5NBvE2e.mjs → CompareDrawer-CSPqneYm.mjs} +2 -2
  14. package/.output/server/_ssr/{ProxyViewerContainer-DiZjOTQT.mjs → ProxyViewerContainer-DrSMa5O7.mjs} +159 -14
  15. package/.output/server/_ssr/{ReplayDialog-EpvTwFvp.mjs → ReplayDialog-Dn3naENv.mjs} +3 -3
  16. package/.output/server/_ssr/{RequestAnatomy-DaQjhixp.mjs → RequestAnatomy-D1X3f3AX.mjs} +336 -42
  17. package/.output/server/_ssr/{ResponseView-D8tGHCGY.mjs → ResponseView-CwXAWOoE.mjs} +2 -2
  18. package/.output/server/_ssr/{StreamingChunkSequence-DGQnb1QE.mjs → StreamingChunkSequence-DRSR2FfN.mjs} +2 -2
  19. package/.output/server/_ssr/{index-N25hqa7t.mjs → index-D4zn5CAg.mjs} +2 -2
  20. package/.output/server/_ssr/index.mjs +2 -2
  21. package/.output/server/_ssr/{router-Bf0m6F0G.mjs → router-CSRaa_tu.mjs} +531 -111
  22. package/.output/server/{_tanstack-start-manifest_v-DlAyJ5DB.mjs → _tanstack-start-manifest_v-CfvVUYYj.mjs} +1 -1
  23. package/.output/server/index.mjs +60 -60
  24. package/bin/agent-inspector.js +2 -0
  25. package/package.json +3 -2
  26. package/src/components/providers/ProviderCard.tsx +59 -5
  27. package/src/components/providers/ProviderForm.tsx +42 -0
  28. package/src/components/providers/ProvidersPanel.tsx +68 -0
  29. package/src/components/proxy-viewer/LogEntry.tsx +6 -1
  30. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +134 -3
  31. package/src/components/proxy-viewer/anatomy/contextIntelligence.ts +298 -40
  32. package/src/lib/providerContract.ts +12 -0
  33. package/src/lib/providerModelMetadata.ts +327 -0
  34. package/src/proxy/providers.ts +112 -52
  35. package/src/routes/api/providers.$providerId.model-metadata.ts +134 -0
  36. package/src/routes/api/providers.$providerId.ts +3 -0
  37. package/src/routes/api/providers.ts +2 -0
  38. package/.output/public/assets/ProxyViewerContainer-CWdkPZsJ.js +0 -114
  39. package/.output/public/assets/RequestAnatomy-FBfRNQN7.js +0 -1
  40. package/.output/public/assets/ResponseView-Db4pJL7U.js +0 -1
  41. package/.output/public/assets/StreamingChunkSequence-BH175O6R.js +0 -1
  42. package/.output/public/assets/_sessionId-3ipVoQtW.js +0 -1
  43. package/.output/public/assets/index-Bo1dGS4R.css +0 -1
  44. package/.output/public/assets/index-CJgaCJo8.js +0 -1
@@ -1,11 +1,14 @@
1
1
  import { Info } from "lucide-react";
2
2
  import { type JSX, useMemo, useState } from "react";
3
3
  import { cn, formatTokens } from "../../../lib/utils";
4
+ import { useProviders } from "../../../lib/useProviders";
4
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../ui/tooltip";
5
6
  import {
6
7
  analyzeContextIntelligence,
7
8
  CONTEXT_USAGE_THRESHOLDS,
8
9
  type ContextDiagnostic,
10
+ type DuplicateContextGroup,
11
+ type DuplicateContextSegment,
9
12
  type ContextIntelligence,
10
13
  type ContextRiskLevel,
11
14
  } from "./contextIntelligence";
@@ -29,6 +32,8 @@ type ContextViewMode = "role" | "segment";
29
32
 
30
33
  const DIVERGENCE_AMBER_THRESHOLD = 0.25;
31
34
  const TOP_CONTRIBUTOR_COUNT = 5;
35
+ const DUPLICATE_GROUP_DISPLAY_COUNT = 4;
36
+ const DUPLICATE_SEGMENT_DISPLAY_COUNT = 6;
32
37
  const ROLE_ORDER: AnatomyRole[] = ["system", "user", "assistant", "tool", "tools"];
33
38
 
34
39
  const ROLE_DESCRIPTIONS: Record<AnatomyRole, string> = {
@@ -85,6 +90,8 @@ function formatContextSource(intelligence: ContextIntelligence): string {
85
90
  switch (intelligence.contextWindow.source) {
86
91
  case "request":
87
92
  return intelligence.contextWindow.label;
93
+ case "provider":
94
+ return intelligence.contextWindow.label;
88
95
  case "model-rule":
89
96
  return intelligence.contextWindow.label;
90
97
  case "unknown":
@@ -115,6 +122,10 @@ function ContextIntelligenceStrip({
115
122
  ? "No output reserve"
116
123
  : `Output reserve ${formatTokens(intelligence.outputReserveTokens)}`;
117
124
  const largestRole = intelligence.largestRole;
125
+ const visibleDiagnostics =
126
+ intelligence.duplicateGroups.length === 0
127
+ ? intelligence.diagnostics
128
+ : intelligence.diagnostics.filter((diagnostic) => diagnostic.kind !== "duplicate");
118
129
 
119
130
  return (
120
131
  <div className="space-y-2">
@@ -199,9 +210,9 @@ function ContextIntelligenceStrip({
199
210
  </div>
200
211
  )}
201
212
 
202
- {intelligence.diagnostics.length > 0 && (
213
+ {visibleDiagnostics.length > 0 && (
203
214
  <div className="grid gap-1.5 sm:grid-cols-2">
204
- {intelligence.diagnostics.map((diagnostic) => (
215
+ {visibleDiagnostics.map((diagnostic) => (
205
216
  <div
206
217
  key={`${diagnostic.kind}-${diagnostic.title}`}
207
218
  className={cn("rounded border px-2 py-1.5 text-xs", diagnosticTone(diagnostic))}
@@ -216,6 +227,116 @@ function ContextIntelligenceStrip({
216
227
  );
217
228
  }
218
229
 
230
+ function resolveDuplicateSegment(
231
+ duplicateSegment: DuplicateContextSegment,
232
+ segments: readonly AnatomySegment[],
233
+ ): AnatomySegment | null {
234
+ return segments.find((segment) => segment.path === duplicateSegment.path) ?? null;
235
+ }
236
+
237
+ function DuplicateContentPanel({
238
+ groups,
239
+ segments,
240
+ onSegmentActivate,
241
+ }: {
242
+ groups: readonly DuplicateContextGroup[];
243
+ segments: readonly AnatomySegment[];
244
+ onSegmentActivate?: (segment: AnatomySegment) => void;
245
+ }): JSX.Element | null {
246
+ if (groups.length === 0) return null;
247
+
248
+ const visibleGroups = groups.slice(0, DUPLICATE_GROUP_DISPLAY_COUNT);
249
+ const hiddenGroupCount = groups.length - visibleGroups.length;
250
+ const totalRepeatedTokens = groups.reduce((sum, group) => sum + group.repeatedTokens, 0);
251
+
252
+ return (
253
+ <section className="space-y-2 rounded border border-border/70 bg-muted/15 p-2">
254
+ <div className="flex flex-wrap items-center justify-between gap-2">
255
+ <div className="text-xs font-medium text-foreground">Duplicate Content</div>
256
+ <div className="font-mono text-[11px] text-muted-foreground">
257
+ {String(groups.length)} group{groups.length === 1 ? "" : "s"} | ~
258
+ {formatTokens(totalRepeatedTokens)} repeated
259
+ </div>
260
+ </div>
261
+
262
+ <div className="space-y-2">
263
+ {visibleGroups.map((group, groupIndex) => {
264
+ const visibleSegments = group.segments.slice(0, DUPLICATE_SEGMENT_DISPLAY_COUNT);
265
+ const hiddenSegmentCount = group.segments.length - visibleSegments.length;
266
+ return (
267
+ <div
268
+ key={group.key}
269
+ className="space-y-1.5 border-t border-border/50 pt-2 first:border-t-0 first:pt-0"
270
+ >
271
+ <div className="flex min-w-0 items-center gap-2 text-xs">
272
+ <span className="w-5 shrink-0 text-right font-mono text-muted-foreground/70">
273
+ {String(groupIndex + 1)}
274
+ </span>
275
+ <span className="min-w-0 flex-1 truncate text-foreground">{group.firstLabel}</span>
276
+ <span className="shrink-0 font-mono text-muted-foreground">
277
+ x{String(group.count)} | ~{formatTokens(group.repeatedTokens)} saved
278
+ </span>
279
+ </div>
280
+
281
+ {group.preview.length > 0 && (
282
+ <div className="ml-7 line-clamp-2 break-words rounded bg-background/40 px-2 py-1 font-mono text-[11px] leading-4 text-muted-foreground">
283
+ {group.preview}
284
+ </div>
285
+ )}
286
+
287
+ <div className="ml-7 flex flex-wrap gap-1">
288
+ {visibleSegments.map((duplicateSegment, segmentIndex) => {
289
+ const target = resolveDuplicateSegment(duplicateSegment, segments);
290
+ const activate =
291
+ target === null || onSegmentActivate === undefined
292
+ ? undefined
293
+ : onSegmentActivate;
294
+ const label = `${duplicateSegment.label} ~${formatTokens(
295
+ duplicateSegment.tokens,
296
+ )}`;
297
+ if (activate === undefined || target === null) {
298
+ return (
299
+ <span
300
+ key={`${duplicateSegment.path}-${segmentIndex}`}
301
+ className="inline-flex h-6 max-w-40 items-center rounded border border-border/60 px-1.5 text-[11px] text-muted-foreground"
302
+ title={label}
303
+ >
304
+ <span className="truncate">{label}</span>
305
+ </span>
306
+ );
307
+ }
308
+ return (
309
+ <button
310
+ key={`${duplicateSegment.path}-${segmentIndex}`}
311
+ type="button"
312
+ onClick={() => activate(target)}
313
+ className="inline-flex h-6 max-w-40 items-center rounded border border-border/60 px-1.5 text-[11px] text-muted-foreground hover:border-border hover:bg-muted/40 hover:text-foreground"
314
+ title="Jump to this duplicate block"
315
+ >
316
+ <span className="truncate">{label}</span>
317
+ </button>
318
+ );
319
+ })}
320
+ {hiddenSegmentCount > 0 && (
321
+ <span className="inline-flex h-6 items-center rounded border border-border/40 px-1.5 font-mono text-[11px] text-muted-foreground/70">
322
+ +{String(hiddenSegmentCount)}
323
+ </span>
324
+ )}
325
+ </div>
326
+ </div>
327
+ );
328
+ })}
329
+ </div>
330
+
331
+ {hiddenGroupCount > 0 && (
332
+ <div className="border-t border-border/50 pt-2 font-mono text-[11px] text-muted-foreground">
333
+ +{String(hiddenGroupCount)} more duplicate group{hiddenGroupCount === 1 ? "" : "s"}
334
+ </div>
335
+ )}
336
+ </section>
337
+ );
338
+ }
339
+
219
340
  function aggregateByRole(segments: AnatomySegment[]): AnatomySegment[] {
220
341
  const result: AnatomySegment[] = [];
221
342
  for (const role of ROLE_ORDER) {
@@ -254,6 +375,7 @@ export function RequestAnatomy({
254
375
  onSegmentActivate,
255
376
  segments: providedSegments,
256
377
  }: RequestAnatomyProps): JSX.Element | null {
378
+ const { providers } = useProviders();
257
379
  const [viewMode, setViewMode] = useState<ContextViewMode>("role");
258
380
  const segments = useMemo(() => providedSegments ?? null, [providedSegments]);
259
381
  const total = useMemo(
@@ -277,8 +399,9 @@ export function RequestAnatomy({
277
399
  inputTokens,
278
400
  parsed,
279
401
  model,
402
+ providers,
280
403
  }),
281
- [inputTokens, model, parsed, segments],
404
+ [inputTokens, model, parsed, providers, segments],
282
405
  );
283
406
 
284
407
  const showDivergenceWarning = useMemo(() => {
@@ -368,6 +491,14 @@ export function RequestAnatomy({
368
491
 
369
492
  {intelligence !== null && <ContextIntelligenceStrip intelligence={intelligence} />}
370
493
 
494
+ {intelligence !== null && (
495
+ <DuplicateContentPanel
496
+ groups={intelligence.duplicateGroups}
497
+ segments={segments}
498
+ onSegmentActivate={onSegmentActivate}
499
+ />
500
+ )}
501
+
371
502
  <SegmentBar
372
503
  segments={displayedSegments}
373
504
  totalTokens={displayedTotal}
@@ -1,9 +1,12 @@
1
- import { safeGetOwnProperty } from "../../../lib/objectUtils";
1
+ import { isPlainRecord, safeGetOwnProperty } from "../../../lib/objectUtils";
2
+ import type { ProviderConfig } from "../../../lib/providerContract";
3
+ import { resolveProviderContextWindow } from "../../../lib/providerModelMetadata";
4
+ import { countCharacters, estimateTokens } from "./tokenEstimate";
2
5
  import type { AnatomyRole, AnatomySegment } from "./types";
3
6
 
4
7
  export type ContextRiskLevel = "unknown" | "ok" | "watch" | "danger";
5
8
 
6
- export type ContextWindowSource = "request" | "model-rule" | "unknown";
9
+ export type ContextWindowSource = "request" | "provider" | "model-rule" | "unknown";
7
10
 
8
11
  export type ContextWindow = {
9
12
  tokens: number | null;
@@ -25,6 +28,24 @@ export type ContextDiagnostic = {
25
28
  detail: string;
26
29
  };
27
30
 
31
+ export type DuplicateContextSegment = {
32
+ role: AnatomyRole;
33
+ label: string;
34
+ path: string;
35
+ tokens: number;
36
+ characters: number;
37
+ };
38
+
39
+ export type DuplicateContextGroup = {
40
+ key: string;
41
+ count: number;
42
+ totalTokens: number;
43
+ repeatedTokens: number;
44
+ preview: string;
45
+ firstLabel: string;
46
+ segments: DuplicateContextSegment[];
47
+ };
48
+
28
49
  export type ContextIntelligence = {
29
50
  model: string | null;
30
51
  contextWindow: ContextWindow;
@@ -38,6 +59,7 @@ export type ContextIntelligence = {
38
59
  roleUsages: RoleUsage[];
39
60
  largestRole: RoleUsage | null;
40
61
  diagnostics: ContextDiagnostic[];
62
+ duplicateGroups: DuplicateContextGroup[];
41
63
  };
42
64
 
43
65
  type MatchMode = "prefix" | "includes";
@@ -49,17 +71,45 @@ type ModelContextRule = {
49
71
  patterns: readonly string[];
50
72
  };
51
73
 
74
+ type DuplicateCandidate = {
75
+ role: AnatomyRole;
76
+ label: string;
77
+ path: string;
78
+ tokens: number;
79
+ characters: number;
80
+ text: string;
81
+ };
82
+
52
83
  const WATCH_USAGE_PERCENT = 0.75;
53
84
  const DANGER_USAGE_PERCENT = 0.9;
54
85
  const LONG_TOOL_SCHEMA_TOKENS = 8_000;
55
86
  const LARGE_ROLE_PERCENT = 0.35;
56
87
  const DUPLICATE_MIN_TOKENS = 40;
57
88
  const DUPLICATE_MIN_CHARS = 160;
89
+ const DUPLICATE_NESTED_MAX_DEPTH = 5;
90
+ const DUPLICATE_NESTED_MAX_CANDIDATES_PER_SEGMENT = 8;
58
91
  const HISTORY_BLOAT_SEGMENTS = 18;
59
92
  const HISTORY_BLOAT_TOKENS = 60_000;
60
93
  const HISTORY_BLOAT_PERCENT = 0.7;
61
94
  const SYSTEM_HEAVY_TOKENS = 3_000;
62
95
  const SYSTEM_HEAVY_PERCENT = 0.3;
96
+ const DUPLICATE_PREVIEW_CHARS = 160;
97
+ const DUPLICATE_NESTED_FIELD_NAMES = new Set([
98
+ "answer",
99
+ "body",
100
+ "content",
101
+ "data",
102
+ "error",
103
+ "message",
104
+ "markdown",
105
+ "output",
106
+ "response",
107
+ "result",
108
+ "stderr",
109
+ "stdout",
110
+ "text",
111
+ "transcript",
112
+ ]);
63
113
 
64
114
  const EXPLICIT_CONTEXT_WINDOW_FIELDS = [
65
115
  "context_window",
@@ -106,6 +156,12 @@ const MODEL_CONTEXT_RULES: readonly ModelContextRule[] = [
106
156
  mode: "prefix",
107
157
  patterns: ["claude-"],
108
158
  },
159
+ {
160
+ family: "DeepSeek V4",
161
+ tokens: 1_000_000,
162
+ mode: "prefix",
163
+ patterns: ["deepseek-v4-pro", "deepseek-v4-flash"],
164
+ },
109
165
  {
110
166
  family: "DeepSeek",
111
167
  tokens: 64_000,
@@ -113,10 +169,16 @@ const MODEL_CONTEXT_RULES: readonly ModelContextRule[] = [
113
169
  patterns: ["deepseek-"],
114
170
  },
115
171
  {
116
- family: "MiniMax",
172
+ family: "MiniMax M3",
117
173
  tokens: 1_000_000,
118
174
  mode: "includes",
119
- patterns: ["minimax"],
175
+ patterns: ["minimax-m3"],
176
+ },
177
+ {
178
+ family: "MiniMax M2",
179
+ tokens: 204_800,
180
+ mode: "includes",
181
+ patterns: ["minimax-m2.7", "minimax-m2.5", "minimax-m2.1"],
120
182
  },
121
183
  ];
122
184
 
@@ -166,7 +228,11 @@ function matchesRule(model: string, rule: ModelContextRule): boolean {
166
228
  }
167
229
  }
168
230
 
169
- export function resolveContextWindow(model: string | null, parsed: unknown): ContextWindow {
231
+ export function resolveContextWindow(
232
+ model: string | null,
233
+ parsed: unknown,
234
+ providers: readonly ProviderConfig[] = [],
235
+ ): ContextWindow {
170
236
  const explicitWindow = readPositiveIntegerField(parsed, EXPLICIT_CONTEXT_WINDOW_FIELDS);
171
237
  if (explicitWindow !== null) {
172
238
  return {
@@ -176,6 +242,15 @@ export function resolveContextWindow(model: string | null, parsed: unknown): Con
176
242
  };
177
243
  }
178
244
 
245
+ const providerWindow = resolveProviderContextWindow(model, providers);
246
+ if (providerWindow !== null) {
247
+ return {
248
+ tokens: providerWindow.tokens,
249
+ label: `${providerWindow.providerName} config`,
250
+ source: "provider",
251
+ };
252
+ }
253
+
179
254
  if (model === null) {
180
255
  return {
181
256
  tokens: null,
@@ -184,12 +259,12 @@ export function resolveContextWindow(model: string | null, parsed: unknown): Con
184
259
  };
185
260
  }
186
261
 
187
- const normalized = model.trim().toLowerCase();
262
+ const normalized = model.trim().toLowerCase().replace(/\s+/g, "-");
188
263
  for (const rule of MODEL_CONTEXT_RULES) {
189
264
  if (matchesRule(normalized, rule)) {
190
265
  return {
191
266
  tokens: rule.tokens,
192
- label: rule.family,
267
+ label: `${rule.family} local rule`,
193
268
  source: "model-rule",
194
269
  };
195
270
  }
@@ -241,51 +316,230 @@ function normalizeDuplicateText(text: string): string {
241
316
  return text.replace(/\s+/g, " ").trim().toLowerCase();
242
317
  }
243
318
 
244
- function duplicateDiagnostic(segments: readonly AnatomySegment[]): ContextDiagnostic | null {
319
+ function duplicatePreview(text: string): string {
320
+ const singleLine = text.replace(/\s+/g, " ").trim();
321
+ if (singleLine.length <= DUPLICATE_PREVIEW_CHARS) return singleLine;
322
+ return `${singleLine.slice(0, DUPLICATE_PREVIEW_CHARS - 3)}...`;
323
+ }
324
+
325
+ function duplicateSegmentRef(candidate: DuplicateCandidate): DuplicateContextSegment {
326
+ return {
327
+ role: candidate.role,
328
+ label: candidate.label,
329
+ path: candidate.path,
330
+ tokens: candidate.tokens,
331
+ characters: candidate.characters,
332
+ };
333
+ }
334
+
335
+ function duplicateCandidateFromSegment(segment: AnatomySegment): DuplicateCandidate {
336
+ return {
337
+ role: segment.role,
338
+ label: segment.label,
339
+ path: segment.path,
340
+ tokens: segment.size,
341
+ characters: segment.characters,
342
+ text: segment.text,
343
+ };
344
+ }
345
+
346
+ function jsonLikeText(text: string): boolean {
347
+ const trimmed = text.trim();
348
+ return trimmed.startsWith("{") || trimmed.startsWith("[");
349
+ }
350
+
351
+ function parseJsonLikeText(text: string): unknown | null {
352
+ if (!jsonLikeText(text)) return null;
353
+ try {
354
+ const parsed: unknown = JSON.parse(text);
355
+ return parsed;
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ function normalizePathFieldName(field: string): string {
362
+ return field.toLowerCase().replace(/[^a-z0-9]/g, "");
363
+ }
364
+
365
+ function lastNamedPathField(path: readonly string[]): string | null {
366
+ for (let index = path.length - 1; index >= 0; index -= 1) {
367
+ const field = path[index] ?? "";
368
+ if (field.startsWith("[") && field.endsWith("]")) continue;
369
+ return field;
370
+ }
371
+ return null;
372
+ }
373
+
374
+ function nestedDuplicateFieldPath(path: readonly string[]): boolean {
375
+ const field = lastNamedPathField(path);
376
+ if (field === null) return false;
377
+ return DUPLICATE_NESTED_FIELD_NAMES.has(normalizePathFieldName(field));
378
+ }
379
+
380
+ function formatNestedDuplicatePath(path: readonly string[]): string {
381
+ return path
382
+ .map((part) => (part.startsWith("[") ? part : `.${part}`))
383
+ .join("")
384
+ .replace(/^\./, "");
385
+ }
386
+
387
+ function duplicateCandidateFromNestedText(
388
+ segment: AnatomySegment,
389
+ text: string,
390
+ path: readonly string[],
391
+ ): DuplicateCandidate | null {
392
+ const characters = countCharacters(text);
393
+ if (characters < DUPLICATE_MIN_CHARS) return null;
394
+ const tokens = estimateTokens(text);
395
+ if (tokens < DUPLICATE_MIN_TOKENS) return null;
396
+ return {
397
+ role: segment.role,
398
+ label: `${segment.label} ${formatNestedDuplicatePath(path)}`,
399
+ path: segment.path,
400
+ tokens,
401
+ characters,
402
+ text,
403
+ };
404
+ }
405
+
406
+ function collectNestedDuplicateCandidates({
407
+ value,
408
+ segment,
409
+ path,
410
+ depth,
411
+ candidates,
412
+ }: {
413
+ value: unknown;
414
+ segment: AnatomySegment;
415
+ path: readonly string[];
416
+ depth: number;
417
+ candidates: DuplicateCandidate[];
418
+ }): void {
419
+ if (candidates.length >= DUPLICATE_NESTED_MAX_CANDIDATES_PER_SEGMENT) return;
420
+ if (depth > DUPLICATE_NESTED_MAX_DEPTH) return;
421
+
422
+ if (typeof value === "string") {
423
+ if (!nestedDuplicateFieldPath(path)) return;
424
+ const candidate = duplicateCandidateFromNestedText(segment, value, path);
425
+ if (candidate !== null) candidates.push(candidate);
426
+ return;
427
+ }
428
+
429
+ if (Array.isArray(value)) {
430
+ value.forEach((item, index) =>
431
+ collectNestedDuplicateCandidates({
432
+ value: item,
433
+ segment,
434
+ path: [...path, `[${String(index)}]`],
435
+ depth: depth + 1,
436
+ candidates,
437
+ }),
438
+ );
439
+ return;
440
+ }
441
+
442
+ if (!isPlainRecord(value)) return;
443
+ Object.entries(value).forEach(([key, child]) =>
444
+ collectNestedDuplicateCandidates({
445
+ value: child,
446
+ segment,
447
+ path: [...path, key],
448
+ depth: depth + 1,
449
+ candidates,
450
+ }),
451
+ );
452
+ }
453
+
454
+ function duplicateCandidatesForSegment(segment: AnatomySegment): DuplicateCandidate[] {
455
+ const segmentCandidate = duplicateCandidateFromSegment(segment);
456
+ const parsed = parseJsonLikeText(segment.text);
457
+ if (parsed === null) return [segmentCandidate];
458
+
459
+ const candidates: DuplicateCandidate[] = [];
460
+ collectNestedDuplicateCandidates({
461
+ value: parsed,
462
+ segment,
463
+ path: [],
464
+ depth: 0,
465
+ candidates,
466
+ });
467
+
468
+ const normalizedSegment = normalizeDuplicateText(segment.text);
469
+ const seen = new Set([normalizedSegment]);
470
+ const uniqueNested = candidates.filter((candidate) => {
471
+ const normalized = normalizeDuplicateText(candidate.text);
472
+ if (normalized === "") return false;
473
+ if (seen.has(normalized)) return false;
474
+ seen.add(normalized);
475
+ return true;
476
+ });
477
+
478
+ return [segmentCandidate, ...uniqueNested];
479
+ }
480
+
481
+ function duplicateContextGroups(segments: readonly AnatomySegment[]): DuplicateContextGroup[] {
245
482
  type DuplicateBucket = {
246
- count: number;
247
483
  tokens: number;
248
- firstTokens: number;
249
- label: string;
484
+ candidates: DuplicateCandidate[];
250
485
  };
251
486
 
252
487
  const buckets = new Map<string, DuplicateBucket>();
253
488
  for (const segment of segments) {
254
- if (segment.size < DUPLICATE_MIN_TOKENS) continue;
255
- if (segment.characters < DUPLICATE_MIN_CHARS) continue;
256
- const normalized = normalizeDuplicateText(segment.text);
257
- if (normalized === "") continue;
258
- const existing = buckets.get(normalized);
259
- if (existing === undefined) {
489
+ const candidates = duplicateCandidatesForSegment(segment);
490
+ for (const candidate of candidates) {
491
+ if (candidate.tokens < DUPLICATE_MIN_TOKENS) continue;
492
+ if (candidate.characters < DUPLICATE_MIN_CHARS) continue;
493
+ const normalized = normalizeDuplicateText(candidate.text);
494
+ if (normalized === "") continue;
495
+ const existing = buckets.get(normalized);
496
+ if (existing === undefined) {
497
+ buckets.set(normalized, {
498
+ tokens: candidate.tokens,
499
+ candidates: [candidate],
500
+ });
501
+ continue;
502
+ }
260
503
  buckets.set(normalized, {
261
- count: 1,
262
- tokens: segment.size,
263
- firstTokens: segment.size,
264
- label: segment.label,
504
+ tokens: existing.tokens + candidate.tokens,
505
+ candidates: [...existing.candidates, candidate],
265
506
  });
266
- continue;
267
507
  }
268
- buckets.set(normalized, {
269
- count: existing.count + 1,
270
- tokens: existing.tokens + segment.size,
271
- firstTokens: existing.firstTokens,
272
- label: existing.label,
273
- });
274
508
  }
275
509
 
276
- let repeatedGroups = 0;
277
- let repeatedSegments = 0;
278
- let repeatedTokens = 0;
279
- let largest: DuplicateBucket | null = null;
510
+ const groups: DuplicateContextGroup[] = [];
280
511
  for (const bucket of buckets.values()) {
281
- if (bucket.count < 2) continue;
282
- repeatedGroups += 1;
283
- repeatedSegments += bucket.count;
284
- repeatedTokens += bucket.tokens - bucket.firstTokens;
285
- if (largest === null || bucket.tokens > largest.tokens) largest = bucket;
512
+ if (bucket.candidates.length < 2) continue;
513
+ const firstCandidate = bucket.candidates[0] ?? null;
514
+ if (firstCandidate === null) continue;
515
+ const repeatedTokens = bucket.tokens - firstCandidate.tokens;
516
+ groups.push({
517
+ key: `${firstCandidate.path}:${firstCandidate.label}:${String(
518
+ bucket.candidates.length,
519
+ )}:${String(bucket.tokens)}`,
520
+ count: bucket.candidates.length,
521
+ totalTokens: bucket.tokens,
522
+ repeatedTokens,
523
+ preview: duplicatePreview(firstCandidate.text),
524
+ firstLabel: firstCandidate.label,
525
+ segments: bucket.candidates.map(duplicateSegmentRef),
526
+ });
286
527
  }
287
528
 
288
- if (repeatedGroups === 0 || largest === null) return null;
529
+ return groups.sort(
530
+ (left, right) =>
531
+ right.repeatedTokens - left.repeatedTokens || right.totalTokens - left.totalTokens,
532
+ );
533
+ }
534
+
535
+ function duplicateDiagnostic(
536
+ duplicateGroups: readonly DuplicateContextGroup[],
537
+ ): ContextDiagnostic | null {
538
+ if (duplicateGroups.length === 0) return null;
539
+
540
+ const repeatedGroups = duplicateGroups.length;
541
+ const repeatedSegments = duplicateGroups.reduce((sum, group) => sum + group.count, 0);
542
+ const repeatedTokens = duplicateGroups.reduce((sum, group) => sum + group.repeatedTokens, 0);
289
543
 
290
544
  return {
291
545
  kind: "duplicate",
@@ -357,17 +611,19 @@ export function analyzeContextIntelligence({
357
611
  inputTokens,
358
612
  parsed,
359
613
  model,
614
+ providers = [],
360
615
  }: {
361
616
  segments: readonly AnatomySegment[];
362
617
  inputTokens: number | null;
363
618
  parsed: unknown;
364
619
  model: string | null;
620
+ providers?: readonly ProviderConfig[];
365
621
  }): ContextIntelligence {
366
622
  const totalEstimatedTokens = segments.reduce((sum, segment) => sum + segment.size, 0);
367
623
  const requestModel = model ?? readRequestModel(parsed);
368
624
  const estimatedInputTokens = inputTokens ?? totalEstimatedTokens;
369
625
  const outputReserveTokens = readPositiveIntegerField(parsed, OUTPUT_RESERVE_FIELDS);
370
- const contextWindow = resolveContextWindow(requestModel, parsed);
626
+ const contextWindow = resolveContextWindow(requestModel, parsed, providers);
371
627
  const reservedTokens = outputReserveTokens ?? 0;
372
628
  const windowUsedTokens =
373
629
  contextWindow.tokens === null ? null : estimatedInputTokens + reservedTokens;
@@ -384,7 +640,8 @@ export function analyzeContextIntelligence({
384
640
  const roleUsages = buildRoleUsages(segments, totalEstimatedTokens);
385
641
  const largestRole = roleUsages[0] ?? null;
386
642
  const messageSegmentCount = segments.filter((segment) => segment.role !== "tools").length;
387
- const duplicate = duplicateDiagnostic(segments);
643
+ const duplicateGroups = duplicateContextGroups(segments);
644
+ const duplicate = duplicateDiagnostic(duplicateGroups);
388
645
  const diagnostics = diagnosticsForRolePressure(
389
646
  roleUsages,
390
647
  totalEstimatedTokens,
@@ -405,6 +662,7 @@ export function analyzeContextIntelligence({
405
662
  roleUsages,
406
663
  largestRole,
407
664
  diagnostics,
665
+ duplicateGroups,
408
666
  };
409
667
  }
410
668
 
@@ -1,5 +1,14 @@
1
1
  import { z } from "zod";
2
2
 
3
+ export const ProviderModelMetadataSchema = z.object({
4
+ model: z.string().min(1),
5
+ contextWindow: z.number().int().positive().optional(),
6
+ outputLimit: z.number().int().positive().optional(),
7
+ source: z.enum(["manual", "registry"]).optional(),
8
+ sourceUrl: z.string().optional(),
9
+ updatedAt: z.string().optional(),
10
+ });
11
+
3
12
  /**
4
13
  * Persisted provider shape shared by the server store and browser API clients.
5
14
  *
@@ -18,9 +27,12 @@ export const ProviderConfigSchema = z.object({
18
27
  openaiBaseUrl: z.string().optional(),
19
28
  authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
20
29
  apiDocsUrl: z.string().optional(),
30
+ modelMetadataUrl: z.string().optional(),
31
+ modelMetadata: z.array(ProviderModelMetadataSchema).optional(),
21
32
  source: z.enum(["company", "personal"]).optional(),
22
33
  createdAt: z.string(),
23
34
  updatedAt: z.string(),
24
35
  });
25
36
 
37
+ export type ProviderModelMetadata = z.infer<typeof ProviderModelMetadataSchema>;
26
38
  export type ProviderConfig = z.infer<typeof ProviderConfigSchema>;