@tonyclaw/agent-inspector 2.0.9 → 2.0.11

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 (43) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-Cb4RH049.js → CompareDrawer-C9OWdxHM.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-CNzay4u8.js +114 -0
  4. package/.output/public/assets/{ReplayDialog-B3y8E7IE.js → ReplayDialog-CwE6I1Pe.js} +1 -1
  5. package/.output/public/assets/RequestAnatomy-DEehC3yz.js +1 -0
  6. package/.output/public/assets/ResponseView-CE5UsuUq.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-CLcLZ7-W.js +1 -0
  8. package/.output/public/assets/_sessionId-DE__tPTo.js +1 -0
  9. package/.output/public/assets/index-BFO4Jmw5.js +1 -0
  10. package/.output/public/assets/index-CDzZ7l_S.css +1 -0
  11. package/.output/public/assets/{main-Bww0Bc88.js → main-D1Xanzu7.js} +2 -2
  12. package/.output/server/{_sessionId-BnCF8Ito.mjs → _sessionId-D3IyPpws.mjs} +3 -3
  13. package/.output/server/_ssr/{CompareDrawer-jPU14EI3.mjs → CompareDrawer-BIMThqxy.mjs} +2 -2
  14. package/.output/server/_ssr/{ProxyViewerContainer-xesEFppM.mjs → ProxyViewerContainer-BiBdKWtj.mjs} +159 -14
  15. package/.output/server/_ssr/{ReplayDialog-DQvW2zoW.mjs → ReplayDialog-D_wiDyM2.mjs} +3 -3
  16. package/.output/server/_ssr/{RequestAnatomy-BilEphjP.mjs → RequestAnatomy-5UiMTESr.mjs} +399 -44
  17. package/.output/server/_ssr/{ResponseView-DB5PPcWf.mjs → ResponseView-Drk5ghmL.mjs} +2 -2
  18. package/.output/server/_ssr/{StreamingChunkSequence-Dnrgn2AP.mjs → StreamingChunkSequence-F889rmG0.mjs} +2 -2
  19. package/.output/server/_ssr/{index-YPp821bH.mjs → index-CaPniq9z.mjs} +2 -2
  20. package/.output/server/_ssr/index.mjs +2 -2
  21. package/.output/server/_ssr/{router-BvuK0vQv.mjs → router-C7S8FRth.mjs} +531 -111
  22. package/.output/server/{_tanstack-start-manifest_v-DGw3vVOm.mjs → _tanstack-start-manifest_v-CVTkFOdT.mjs} +1 -1
  23. package/.output/server/index.mjs +59 -59
  24. package/package.json +1 -1
  25. package/src/components/providers/ProviderCard.tsx +59 -5
  26. package/src/components/providers/ProviderForm.tsx +42 -0
  27. package/src/components/providers/ProvidersPanel.tsx +68 -0
  28. package/src/components/proxy-viewer/LogEntry.tsx +6 -1
  29. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +179 -4
  30. package/src/components/proxy-viewer/anatomy/contextIntelligence.ts +346 -41
  31. package/src/lib/providerContract.ts +12 -0
  32. package/src/lib/providerModelMetadata.ts +327 -0
  33. package/src/proxy/providers.ts +112 -52
  34. package/src/routes/api/providers.$providerId.model-metadata.ts +134 -0
  35. package/src/routes/api/providers.$providerId.ts +3 -0
  36. package/src/routes/api/providers.ts +2 -0
  37. package/.output/public/assets/ProxyViewerContainer-Cmr2TANl.js +0 -114
  38. package/.output/public/assets/RequestAnatomy-C7BQYj95.js +0 -1
  39. package/.output/public/assets/ResponseView-Bylsp4WJ.js +0 -1
  40. package/.output/public/assets/StreamingChunkSequence-CJH13VD3.js +0 -1
  41. package/.output/public/assets/_sessionId-3e8W68GL.js +0 -1
  42. package/.output/public/assets/index-Bo1dGS4R.css +0 -1
  43. package/.output/public/assets/index-Cz5aqmzE.js +0 -1
@@ -1,9 +1,14 @@
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";
10
+
11
+ export type ContextHealthLevel = "ok" | "optimizable" | "risk";
7
12
 
8
13
  export type ContextWindow = {
9
14
  tokens: number | null;
@@ -25,6 +30,30 @@ export type ContextDiagnostic = {
25
30
  detail: string;
26
31
  };
27
32
 
33
+ export type DuplicateContextSegment = {
34
+ role: AnatomyRole;
35
+ label: string;
36
+ path: string;
37
+ tokens: number;
38
+ characters: number;
39
+ };
40
+
41
+ export type DuplicateContextGroup = {
42
+ key: string;
43
+ count: number;
44
+ totalTokens: number;
45
+ repeatedTokens: number;
46
+ preview: string;
47
+ firstLabel: string;
48
+ segments: DuplicateContextSegment[];
49
+ };
50
+
51
+ export type ContextHealth = {
52
+ level: ContextHealthLevel;
53
+ opportunityCount: number;
54
+ reclaimableTokens: number;
55
+ };
56
+
28
57
  export type ContextIntelligence = {
29
58
  model: string | null;
30
59
  contextWindow: ContextWindow;
@@ -38,6 +67,8 @@ export type ContextIntelligence = {
38
67
  roleUsages: RoleUsage[];
39
68
  largestRole: RoleUsage | null;
40
69
  diagnostics: ContextDiagnostic[];
70
+ duplicateGroups: DuplicateContextGroup[];
71
+ health: ContextHealth;
41
72
  };
42
73
 
43
74
  type MatchMode = "prefix" | "includes";
@@ -49,17 +80,45 @@ type ModelContextRule = {
49
80
  patterns: readonly string[];
50
81
  };
51
82
 
83
+ type DuplicateCandidate = {
84
+ role: AnatomyRole;
85
+ label: string;
86
+ path: string;
87
+ tokens: number;
88
+ characters: number;
89
+ text: string;
90
+ };
91
+
52
92
  const WATCH_USAGE_PERCENT = 0.75;
53
93
  const DANGER_USAGE_PERCENT = 0.9;
54
94
  const LONG_TOOL_SCHEMA_TOKENS = 8_000;
55
95
  const LARGE_ROLE_PERCENT = 0.35;
56
96
  const DUPLICATE_MIN_TOKENS = 40;
57
97
  const DUPLICATE_MIN_CHARS = 160;
98
+ const DUPLICATE_NESTED_MAX_DEPTH = 5;
99
+ const DUPLICATE_NESTED_MAX_CANDIDATES_PER_SEGMENT = 8;
58
100
  const HISTORY_BLOAT_SEGMENTS = 18;
59
101
  const HISTORY_BLOAT_TOKENS = 60_000;
60
102
  const HISTORY_BLOAT_PERCENT = 0.7;
61
103
  const SYSTEM_HEAVY_TOKENS = 3_000;
62
104
  const SYSTEM_HEAVY_PERCENT = 0.3;
105
+ const DUPLICATE_PREVIEW_CHARS = 160;
106
+ const DUPLICATE_NESTED_FIELD_NAMES = new Set([
107
+ "answer",
108
+ "body",
109
+ "content",
110
+ "data",
111
+ "error",
112
+ "message",
113
+ "markdown",
114
+ "output",
115
+ "response",
116
+ "result",
117
+ "stderr",
118
+ "stdout",
119
+ "text",
120
+ "transcript",
121
+ ]);
63
122
 
64
123
  const EXPLICIT_CONTEXT_WINDOW_FIELDS = [
65
124
  "context_window",
@@ -106,6 +165,12 @@ const MODEL_CONTEXT_RULES: readonly ModelContextRule[] = [
106
165
  mode: "prefix",
107
166
  patterns: ["claude-"],
108
167
  },
168
+ {
169
+ family: "DeepSeek V4",
170
+ tokens: 1_000_000,
171
+ mode: "prefix",
172
+ patterns: ["deepseek-v4-pro", "deepseek-v4-flash"],
173
+ },
109
174
  {
110
175
  family: "DeepSeek",
111
176
  tokens: 64_000,
@@ -113,10 +178,16 @@ const MODEL_CONTEXT_RULES: readonly ModelContextRule[] = [
113
178
  patterns: ["deepseek-"],
114
179
  },
115
180
  {
116
- family: "MiniMax",
181
+ family: "MiniMax M3",
117
182
  tokens: 1_000_000,
118
183
  mode: "includes",
119
- patterns: ["minimax"],
184
+ patterns: ["minimax-m3"],
185
+ },
186
+ {
187
+ family: "MiniMax M2",
188
+ tokens: 204_800,
189
+ mode: "includes",
190
+ patterns: ["minimax-m2.7", "minimax-m2.5", "minimax-m2.1"],
120
191
  },
121
192
  ];
122
193
 
@@ -166,7 +237,11 @@ function matchesRule(model: string, rule: ModelContextRule): boolean {
166
237
  }
167
238
  }
168
239
 
169
- export function resolveContextWindow(model: string | null, parsed: unknown): ContextWindow {
240
+ export function resolveContextWindow(
241
+ model: string | null,
242
+ parsed: unknown,
243
+ providers: readonly ProviderConfig[] = [],
244
+ ): ContextWindow {
170
245
  const explicitWindow = readPositiveIntegerField(parsed, EXPLICIT_CONTEXT_WINDOW_FIELDS);
171
246
  if (explicitWindow !== null) {
172
247
  return {
@@ -176,6 +251,15 @@ export function resolveContextWindow(model: string | null, parsed: unknown): Con
176
251
  };
177
252
  }
178
253
 
254
+ const providerWindow = resolveProviderContextWindow(model, providers);
255
+ if (providerWindow !== null) {
256
+ return {
257
+ tokens: providerWindow.tokens,
258
+ label: `${providerWindow.providerName} config`,
259
+ source: "provider",
260
+ };
261
+ }
262
+
179
263
  if (model === null) {
180
264
  return {
181
265
  tokens: null,
@@ -184,12 +268,12 @@ export function resolveContextWindow(model: string | null, parsed: unknown): Con
184
268
  };
185
269
  }
186
270
 
187
- const normalized = model.trim().toLowerCase();
271
+ const normalized = model.trim().toLowerCase().replace(/\s+/g, "-");
188
272
  for (const rule of MODEL_CONTEXT_RULES) {
189
273
  if (matchesRule(normalized, rule)) {
190
274
  return {
191
275
  tokens: rule.tokens,
192
- label: rule.family,
276
+ label: `${rule.family} local rule`,
193
277
  source: "model-rule",
194
278
  };
195
279
  }
@@ -241,51 +325,230 @@ function normalizeDuplicateText(text: string): string {
241
325
  return text.replace(/\s+/g, " ").trim().toLowerCase();
242
326
  }
243
327
 
244
- function duplicateDiagnostic(segments: readonly AnatomySegment[]): ContextDiagnostic | null {
328
+ function duplicatePreview(text: string): string {
329
+ const singleLine = text.replace(/\s+/g, " ").trim();
330
+ if (singleLine.length <= DUPLICATE_PREVIEW_CHARS) return singleLine;
331
+ return `${singleLine.slice(0, DUPLICATE_PREVIEW_CHARS - 3)}...`;
332
+ }
333
+
334
+ function duplicateSegmentRef(candidate: DuplicateCandidate): DuplicateContextSegment {
335
+ return {
336
+ role: candidate.role,
337
+ label: candidate.label,
338
+ path: candidate.path,
339
+ tokens: candidate.tokens,
340
+ characters: candidate.characters,
341
+ };
342
+ }
343
+
344
+ function duplicateCandidateFromSegment(segment: AnatomySegment): DuplicateCandidate {
345
+ return {
346
+ role: segment.role,
347
+ label: segment.label,
348
+ path: segment.path,
349
+ tokens: segment.size,
350
+ characters: segment.characters,
351
+ text: segment.text,
352
+ };
353
+ }
354
+
355
+ function jsonLikeText(text: string): boolean {
356
+ const trimmed = text.trim();
357
+ return trimmed.startsWith("{") || trimmed.startsWith("[");
358
+ }
359
+
360
+ function parseJsonLikeText(text: string): unknown | null {
361
+ if (!jsonLikeText(text)) return null;
362
+ try {
363
+ const parsed: unknown = JSON.parse(text);
364
+ return parsed;
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
370
+ function normalizePathFieldName(field: string): string {
371
+ return field.toLowerCase().replace(/[^a-z0-9]/g, "");
372
+ }
373
+
374
+ function lastNamedPathField(path: readonly string[]): string | null {
375
+ for (let index = path.length - 1; index >= 0; index -= 1) {
376
+ const field = path[index] ?? "";
377
+ if (field.startsWith("[") && field.endsWith("]")) continue;
378
+ return field;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ function nestedDuplicateFieldPath(path: readonly string[]): boolean {
384
+ const field = lastNamedPathField(path);
385
+ if (field === null) return false;
386
+ return DUPLICATE_NESTED_FIELD_NAMES.has(normalizePathFieldName(field));
387
+ }
388
+
389
+ function formatNestedDuplicatePath(path: readonly string[]): string {
390
+ return path
391
+ .map((part) => (part.startsWith("[") ? part : `.${part}`))
392
+ .join("")
393
+ .replace(/^\./, "");
394
+ }
395
+
396
+ function duplicateCandidateFromNestedText(
397
+ segment: AnatomySegment,
398
+ text: string,
399
+ path: readonly string[],
400
+ ): DuplicateCandidate | null {
401
+ const characters = countCharacters(text);
402
+ if (characters < DUPLICATE_MIN_CHARS) return null;
403
+ const tokens = estimateTokens(text);
404
+ if (tokens < DUPLICATE_MIN_TOKENS) return null;
405
+ return {
406
+ role: segment.role,
407
+ label: `${segment.label} ${formatNestedDuplicatePath(path)}`,
408
+ path: segment.path,
409
+ tokens,
410
+ characters,
411
+ text,
412
+ };
413
+ }
414
+
415
+ function collectNestedDuplicateCandidates({
416
+ value,
417
+ segment,
418
+ path,
419
+ depth,
420
+ candidates,
421
+ }: {
422
+ value: unknown;
423
+ segment: AnatomySegment;
424
+ path: readonly string[];
425
+ depth: number;
426
+ candidates: DuplicateCandidate[];
427
+ }): void {
428
+ if (candidates.length >= DUPLICATE_NESTED_MAX_CANDIDATES_PER_SEGMENT) return;
429
+ if (depth > DUPLICATE_NESTED_MAX_DEPTH) return;
430
+
431
+ if (typeof value === "string") {
432
+ if (!nestedDuplicateFieldPath(path)) return;
433
+ const candidate = duplicateCandidateFromNestedText(segment, value, path);
434
+ if (candidate !== null) candidates.push(candidate);
435
+ return;
436
+ }
437
+
438
+ if (Array.isArray(value)) {
439
+ value.forEach((item, index) =>
440
+ collectNestedDuplicateCandidates({
441
+ value: item,
442
+ segment,
443
+ path: [...path, `[${String(index)}]`],
444
+ depth: depth + 1,
445
+ candidates,
446
+ }),
447
+ );
448
+ return;
449
+ }
450
+
451
+ if (!isPlainRecord(value)) return;
452
+ Object.entries(value).forEach(([key, child]) =>
453
+ collectNestedDuplicateCandidates({
454
+ value: child,
455
+ segment,
456
+ path: [...path, key],
457
+ depth: depth + 1,
458
+ candidates,
459
+ }),
460
+ );
461
+ }
462
+
463
+ function duplicateCandidatesForSegment(segment: AnatomySegment): DuplicateCandidate[] {
464
+ const segmentCandidate = duplicateCandidateFromSegment(segment);
465
+ const parsed = parseJsonLikeText(segment.text);
466
+ if (parsed === null) return [segmentCandidate];
467
+
468
+ const candidates: DuplicateCandidate[] = [];
469
+ collectNestedDuplicateCandidates({
470
+ value: parsed,
471
+ segment,
472
+ path: [],
473
+ depth: 0,
474
+ candidates,
475
+ });
476
+
477
+ const normalizedSegment = normalizeDuplicateText(segment.text);
478
+ const seen = new Set([normalizedSegment]);
479
+ const uniqueNested = candidates.filter((candidate) => {
480
+ const normalized = normalizeDuplicateText(candidate.text);
481
+ if (normalized === "") return false;
482
+ if (seen.has(normalized)) return false;
483
+ seen.add(normalized);
484
+ return true;
485
+ });
486
+
487
+ return [segmentCandidate, ...uniqueNested];
488
+ }
489
+
490
+ function duplicateContextGroups(segments: readonly AnatomySegment[]): DuplicateContextGroup[] {
245
491
  type DuplicateBucket = {
246
- count: number;
247
492
  tokens: number;
248
- firstTokens: number;
249
- label: string;
493
+ candidates: DuplicateCandidate[];
250
494
  };
251
495
 
252
496
  const buckets = new Map<string, DuplicateBucket>();
253
497
  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) {
498
+ const candidates = duplicateCandidatesForSegment(segment);
499
+ for (const candidate of candidates) {
500
+ if (candidate.tokens < DUPLICATE_MIN_TOKENS) continue;
501
+ if (candidate.characters < DUPLICATE_MIN_CHARS) continue;
502
+ const normalized = normalizeDuplicateText(candidate.text);
503
+ if (normalized === "") continue;
504
+ const existing = buckets.get(normalized);
505
+ if (existing === undefined) {
506
+ buckets.set(normalized, {
507
+ tokens: candidate.tokens,
508
+ candidates: [candidate],
509
+ });
510
+ continue;
511
+ }
260
512
  buckets.set(normalized, {
261
- count: 1,
262
- tokens: segment.size,
263
- firstTokens: segment.size,
264
- label: segment.label,
513
+ tokens: existing.tokens + candidate.tokens,
514
+ candidates: [...existing.candidates, candidate],
265
515
  });
266
- continue;
267
516
  }
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
517
  }
275
518
 
276
- let repeatedGroups = 0;
277
- let repeatedSegments = 0;
278
- let repeatedTokens = 0;
279
- let largest: DuplicateBucket | null = null;
519
+ const groups: DuplicateContextGroup[] = [];
280
520
  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;
521
+ if (bucket.candidates.length < 2) continue;
522
+ const firstCandidate = bucket.candidates[0] ?? null;
523
+ if (firstCandidate === null) continue;
524
+ const repeatedTokens = bucket.tokens - firstCandidate.tokens;
525
+ groups.push({
526
+ key: `${firstCandidate.path}:${firstCandidate.label}:${String(
527
+ bucket.candidates.length,
528
+ )}:${String(bucket.tokens)}`,
529
+ count: bucket.candidates.length,
530
+ totalTokens: bucket.tokens,
531
+ repeatedTokens,
532
+ preview: duplicatePreview(firstCandidate.text),
533
+ firstLabel: firstCandidate.label,
534
+ segments: bucket.candidates.map(duplicateSegmentRef),
535
+ });
286
536
  }
287
537
 
288
- if (repeatedGroups === 0 || largest === null) return null;
538
+ return groups.sort(
539
+ (left, right) =>
540
+ right.repeatedTokens - left.repeatedTokens || right.totalTokens - left.totalTokens,
541
+ );
542
+ }
543
+
544
+ function duplicateDiagnostic(
545
+ duplicateGroups: readonly DuplicateContextGroup[],
546
+ ): ContextDiagnostic | null {
547
+ if (duplicateGroups.length === 0) return null;
548
+
549
+ const repeatedGroups = duplicateGroups.length;
550
+ const repeatedSegments = duplicateGroups.reduce((sum, group) => sum + group.count, 0);
551
+ const repeatedTokens = duplicateGroups.reduce((sum, group) => sum + group.repeatedTokens, 0);
289
552
 
290
553
  return {
291
554
  kind: "duplicate",
@@ -297,6 +560,10 @@ function duplicateDiagnostic(segments: readonly AnatomySegment[]): ContextDiagno
297
560
  };
298
561
  }
299
562
 
563
+ function duplicateReclaimableTokens(duplicateGroups: readonly DuplicateContextGroup[]): number {
564
+ return duplicateGroups.reduce((sum, group) => sum + group.repeatedTokens, 0);
565
+ }
566
+
300
567
  function diagnosticsForRolePressure(
301
568
  roleUsages: readonly RoleUsage[],
302
569
  total: number,
@@ -352,22 +619,51 @@ function diagnosticsForRolePressure(
352
619
  return diagnostics;
353
620
  }
354
621
 
622
+ function contextHealth({
623
+ risk,
624
+ contextWindow,
625
+ diagnostics,
626
+ duplicateGroups,
627
+ }: {
628
+ risk: ContextRiskLevel;
629
+ contextWindow: ContextWindow;
630
+ diagnostics: readonly ContextDiagnostic[];
631
+ duplicateGroups: readonly DuplicateContextGroup[];
632
+ }): ContextHealth {
633
+ const diagnosticKinds = new Set(diagnostics.map((diagnostic) => diagnostic.kind));
634
+ let opportunityCount = diagnosticKinds.size;
635
+ if (risk === "watch" || risk === "danger") opportunityCount += 1;
636
+ if (contextWindow.source === "unknown") opportunityCount += 1;
637
+
638
+ const hasDangerDiagnostic = diagnostics.some((diagnostic) => diagnostic.severity === "danger");
639
+ const level: ContextHealthLevel =
640
+ risk === "danger" || hasDangerDiagnostic ? "risk" : opportunityCount > 0 ? "optimizable" : "ok";
641
+
642
+ return {
643
+ level,
644
+ opportunityCount,
645
+ reclaimableTokens: duplicateReclaimableTokens(duplicateGroups),
646
+ };
647
+ }
648
+
355
649
  export function analyzeContextIntelligence({
356
650
  segments,
357
651
  inputTokens,
358
652
  parsed,
359
653
  model,
654
+ providers = [],
360
655
  }: {
361
656
  segments: readonly AnatomySegment[];
362
657
  inputTokens: number | null;
363
658
  parsed: unknown;
364
659
  model: string | null;
660
+ providers?: readonly ProviderConfig[];
365
661
  }): ContextIntelligence {
366
662
  const totalEstimatedTokens = segments.reduce((sum, segment) => sum + segment.size, 0);
367
663
  const requestModel = model ?? readRequestModel(parsed);
368
664
  const estimatedInputTokens = inputTokens ?? totalEstimatedTokens;
369
665
  const outputReserveTokens = readPositiveIntegerField(parsed, OUTPUT_RESERVE_FIELDS);
370
- const contextWindow = resolveContextWindow(requestModel, parsed);
666
+ const contextWindow = resolveContextWindow(requestModel, parsed, providers);
371
667
  const reservedTokens = outputReserveTokens ?? 0;
372
668
  const windowUsedTokens =
373
669
  contextWindow.tokens === null ? null : estimatedInputTokens + reservedTokens;
@@ -384,13 +680,15 @@ export function analyzeContextIntelligence({
384
680
  const roleUsages = buildRoleUsages(segments, totalEstimatedTokens);
385
681
  const largestRole = roleUsages[0] ?? null;
386
682
  const messageSegmentCount = segments.filter((segment) => segment.role !== "tools").length;
387
- const duplicate = duplicateDiagnostic(segments);
683
+ const duplicateGroups = duplicateContextGroups(segments);
684
+ const duplicate = duplicateDiagnostic(duplicateGroups);
388
685
  const diagnostics = diagnosticsForRolePressure(
389
686
  roleUsages,
390
687
  totalEstimatedTokens,
391
688
  messageSegmentCount,
392
689
  );
393
690
  if (duplicate !== null) diagnostics.unshift(duplicate);
691
+ const risk = riskLevel(usagePercent);
394
692
 
395
693
  return {
396
694
  model: requestModel,
@@ -401,10 +699,17 @@ export function analyzeContextIntelligence({
401
699
  remainingInputTokens,
402
700
  remainingAfterReserveTokens,
403
701
  usagePercent,
404
- riskLevel: riskLevel(usagePercent),
702
+ riskLevel: risk,
405
703
  roleUsages,
406
704
  largestRole,
407
705
  diagnostics,
706
+ duplicateGroups,
707
+ health: contextHealth({
708
+ risk,
709
+ contextWindow,
710
+ diagnostics,
711
+ duplicateGroups,
712
+ }),
408
713
  };
409
714
  }
410
715
 
@@ -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>;