gsd-pi 2.18.0 → 2.19.0

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 (73) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  2. package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
  3. package/dist/resources/extensions/gsd/auto.ts +276 -19
  4. package/dist/resources/extensions/gsd/captures.ts +384 -0
  5. package/dist/resources/extensions/gsd/commands.ts +139 -3
  6. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  7. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  8. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  9. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  10. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  11. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  12. package/dist/resources/extensions/gsd/preferences.ts +73 -0
  13. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  14. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  15. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  16. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  17. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  18. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  19. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  20. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  21. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  22. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  23. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  25. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  26. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  27. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  28. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  29. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  30. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  31. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  32. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  33. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  34. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  35. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  36. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  37. package/package.json +1 -1
  38. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  39. package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
  40. package/src/resources/extensions/gsd/auto.ts +276 -19
  41. package/src/resources/extensions/gsd/captures.ts +384 -0
  42. package/src/resources/extensions/gsd/commands.ts +139 -3
  43. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  44. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  45. package/src/resources/extensions/gsd/metrics.ts +48 -0
  46. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  47. package/src/resources/extensions/gsd/model-router.ts +256 -0
  48. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/src/resources/extensions/gsd/preferences.ts +73 -0
  50. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  51. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  52. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  53. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  54. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  55. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  56. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  57. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  58. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  59. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  60. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  61. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  62. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  63. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  64. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  65. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  66. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  67. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  68. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  69. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  70. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  71. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  72. package/src/resources/extensions/remote-questions/format.ts +12 -6
  73. package/src/resources/extensions/remote-questions/manager.ts +8 -0
@@ -0,0 +1,322 @@
1
+ // GSD Extension — Complexity Classifier
2
+ // Classifies unit complexity for dynamic model routing.
3
+ // Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification.
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { gsdRoot } from "./paths.js";
8
+ import { getAdaptiveTierAdjustment } from "./routing-history.js";
9
+
10
+ // ─── Types ───────────────────────────────────────────────────────────────────
11
+
12
+ export type ComplexityTier = "light" | "standard" | "heavy";
13
+
14
+ export interface ClassificationResult {
15
+ tier: ComplexityTier;
16
+ reason: string;
17
+ downgraded: boolean; // true if budget pressure lowered the tier
18
+ }
19
+
20
+ export interface TaskMetadata {
21
+ fileCount?: number;
22
+ dependencyCount?: number;
23
+ isNewFile?: boolean;
24
+ tags?: string[];
25
+ estimatedLines?: number;
26
+ codeBlockCount?: number; // number of fenced code blocks in plan
27
+ complexityKeywords?: string[]; // detected complexity signals
28
+ }
29
+
30
+ // ─── Unit Type → Default Tier Mapping ────────────────────────────────────────
31
+
32
+ const UNIT_TYPE_TIERS: Record<string, ComplexityTier> = {
33
+ // Tier 1 — Light: structured summaries, completion, UAT
34
+ "complete-slice": "light",
35
+ "run-uat": "light",
36
+
37
+ // Tier 2 — Standard: research, routine planning
38
+ "research-milestone": "standard",
39
+ "research-slice": "standard",
40
+ "plan-milestone": "standard",
41
+ "plan-slice": "standard",
42
+
43
+ // Tier 3 — Heavy: execution, replanning (requires deep reasoning)
44
+ "execute-task": "standard", // default standard, upgraded by metadata
45
+ "replan-slice": "heavy",
46
+ "reassess-roadmap": "heavy",
47
+ };
48
+
49
+ // ─── Public API ──────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Classify unit complexity to determine which model tier to use.
53
+ *
54
+ * @param unitType The type of unit being dispatched
55
+ * @param unitId The unit ID (e.g. "M001/S01/T01")
56
+ * @param basePath Project base path (for reading task plans)
57
+ * @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget
58
+ * @param metadata Optional pre-parsed task metadata
59
+ */
60
+ export function classifyUnitComplexity(
61
+ unitType: string,
62
+ unitId: string,
63
+ basePath: string,
64
+ budgetPct?: number,
65
+ metadata?: TaskMetadata,
66
+ ): ClassificationResult {
67
+ // Hook units default to light
68
+ if (unitType.startsWith("hook/")) {
69
+ const result: ClassificationResult = { tier: "light", reason: "hook unit", downgraded: false };
70
+ return applyBudgetPressure(result, budgetPct);
71
+ }
72
+
73
+ // Start with the default tier for this unit type
74
+ let tier = UNIT_TYPE_TIERS[unitType] ?? "standard";
75
+ let reason = `unit type: ${unitType}`;
76
+
77
+ // For execute-task, analyze task metadata for complexity signals
78
+ if (unitType === "execute-task") {
79
+ const taskAnalysis = analyzeTaskComplexity(unitId, basePath, metadata);
80
+ tier = taskAnalysis.tier;
81
+ reason = taskAnalysis.reason;
82
+ }
83
+
84
+ // For plan-slice, check if the slice has many tasks (complex planning)
85
+ if (unitType === "plan-slice" || unitType === "plan-milestone") {
86
+ const planAnalysis = analyzePlanComplexity(unitId, basePath);
87
+ if (planAnalysis) {
88
+ tier = planAnalysis.tier;
89
+ reason = planAnalysis.reason;
90
+ }
91
+ }
92
+
93
+ // Adaptive learning: check if history suggests bumping the tier
94
+ const tags = metadata?.tags ?? extractTaskMetadata(unitId, basePath).tags;
95
+ const adaptiveAdjustment = getAdaptiveTierAdjustment(unitType, tier, tags);
96
+ if (adaptiveAdjustment && tierOrdinal(adaptiveAdjustment) > tierOrdinal(tier)) {
97
+ reason = `${reason} (adaptive: high failure rate at ${tier})`;
98
+ tier = adaptiveAdjustment;
99
+ }
100
+
101
+ const result: ClassificationResult = { tier, reason, downgraded: false };
102
+ return applyBudgetPressure(result, budgetPct);
103
+ }
104
+
105
+ /**
106
+ * Get a short label for the tier (for dashboard display).
107
+ */
108
+ export function tierLabel(tier: ComplexityTier): string {
109
+ switch (tier) {
110
+ case "light": return "L";
111
+ case "standard": return "S";
112
+ case "heavy": return "H";
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Get the tier ordering value (for comparison).
118
+ */
119
+ export function tierOrdinal(tier: ComplexityTier): number {
120
+ switch (tier) {
121
+ case "light": return 0;
122
+ case "standard": return 1;
123
+ case "heavy": return 2;
124
+ }
125
+ }
126
+
127
+ // ─── Task Complexity Analysis ────────────────────────────────────────────────
128
+
129
+ interface TaskAnalysis {
130
+ tier: ComplexityTier;
131
+ reason: string;
132
+ }
133
+
134
+ function analyzeTaskComplexity(
135
+ unitId: string,
136
+ basePath: string,
137
+ metadata?: TaskMetadata,
138
+ ): TaskAnalysis {
139
+ // Try to read task plan for complexity signals
140
+ const meta = metadata ?? extractTaskMetadata(unitId, basePath);
141
+
142
+ // Heavy signals
143
+ if (meta.dependencyCount && meta.dependencyCount >= 3) {
144
+ return { tier: "heavy", reason: `${meta.dependencyCount} dependencies` };
145
+ }
146
+ if (meta.fileCount && meta.fileCount >= 6) {
147
+ return { tier: "heavy", reason: `${meta.fileCount} files to modify` };
148
+ }
149
+ if (meta.estimatedLines && meta.estimatedLines >= 500) {
150
+ return { tier: "heavy", reason: `~${meta.estimatedLines} lines estimated` };
151
+ }
152
+
153
+ // Heavy signals from complexity keywords (Phase 4)
154
+ if (meta.complexityKeywords && meta.complexityKeywords.length >= 2) {
155
+ return { tier: "heavy", reason: `complex: ${meta.complexityKeywords.join(", ")}` };
156
+ }
157
+ if (meta.codeBlockCount && meta.codeBlockCount >= 5) {
158
+ return { tier: "heavy", reason: `${meta.codeBlockCount} code blocks in plan` };
159
+ }
160
+
161
+ // Standard signals from single complexity keyword
162
+ if (meta.complexityKeywords && meta.complexityKeywords.length === 1) {
163
+ return { tier: "standard", reason: `${meta.complexityKeywords[0]} task` };
164
+ }
165
+
166
+ // Light signals (simple tasks)
167
+ if (meta.tags?.some(t => /^(docs?|readme|comment|config|typo|rename)$/i.test(t))) {
168
+ return { tier: "light", reason: `simple task: ${meta.tags.join(", ")}` };
169
+ }
170
+ if (meta.fileCount !== undefined && meta.fileCount <= 1 && !meta.isNewFile) {
171
+ return { tier: "light", reason: "single file modification" };
172
+ }
173
+
174
+ // Standard by default
175
+ return { tier: "standard", reason: "standard execution task" };
176
+ }
177
+
178
+ function analyzePlanComplexity(
179
+ unitId: string,
180
+ basePath: string,
181
+ ): TaskAnalysis | null {
182
+ // Check if this is a milestone-level plan (more complex) vs single slice
183
+ const parts = unitId.split("/");
184
+ if (parts.length === 1) {
185
+ // Milestone-level planning is always at least standard
186
+ return { tier: "standard", reason: "milestone-level planning" };
187
+ }
188
+
189
+ // For slice planning, try to read the context/research to gauge complexity
190
+ // If research exists and is large, bump to heavy
191
+ const [mid, sid] = parts;
192
+ const researchPath = join(gsdRoot(basePath), mid, "slices", sid, "RESEARCH.md");
193
+ try {
194
+ if (existsSync(researchPath)) {
195
+ const content = readFileSync(researchPath, "utf-8");
196
+ const lineCount = content.split("\n").length;
197
+ if (lineCount > 200) {
198
+ return { tier: "heavy", reason: `complex slice: ${lineCount}-line research` };
199
+ }
200
+ }
201
+ } catch {
202
+ // Non-fatal
203
+ }
204
+
205
+ return null; // Use default tier
206
+ }
207
+
208
+ /**
209
+ * Extract task metadata from the task plan file on disk.
210
+ */
211
+ function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata {
212
+ const meta: TaskMetadata = {};
213
+ const parts = unitId.split("/");
214
+ if (parts.length !== 3) return meta;
215
+
216
+ const [mid, sid, tid] = parts;
217
+ const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
218
+
219
+ try {
220
+ if (!existsSync(taskPlanPath)) return meta;
221
+ const content = readFileSync(taskPlanPath, "utf-8");
222
+ const lines = content.split("\n");
223
+
224
+ // Count files mentioned in "Files:" or "- Files:" lines
225
+ const fileLines = lines.filter(l => /^\s*-?\s*files?\s*:/i.test(l));
226
+ if (fileLines.length > 0) {
227
+ // Count comma-separated or bullet-pointed files
228
+ const allFiles = new Set<string>();
229
+ for (const line of fileLines) {
230
+ const filesStr = line.replace(/^\s*-?\s*files?\s*:\s*/i, "");
231
+ const files = filesStr.split(/[,;]/).map(f => f.trim()).filter(Boolean);
232
+ files.forEach(f => allFiles.add(f));
233
+ }
234
+ meta.fileCount = allFiles.size;
235
+ }
236
+
237
+ // Check for "new file" or "create" keywords
238
+ meta.isNewFile = lines.some(l => /\b(create|new file|scaffold|bootstrap)\b/i.test(l));
239
+
240
+ // Look for tags/labels in frontmatter or content
241
+ const tags: string[] = [];
242
+ if (content.match(/\b(refactor|migration|architect)/i)) tags.push("refactor");
243
+ if (content.match(/\b(test|spec|coverage)\b/i)) tags.push("test");
244
+ if (content.match(/\b(doc|readme|comment|jsdoc)\b/i)) tags.push("docs");
245
+ if (content.match(/\b(config|env|setting)\b/i)) tags.push("config");
246
+ if (content.match(/\b(rename|typo|spelling)\b/i)) tags.push("rename");
247
+ meta.tags = tags;
248
+
249
+ // Try to extract estimated lines from content
250
+ const estimateMatch = content.match(/~?\s*(\d+)\s*lines?\b/i);
251
+ if (estimateMatch) {
252
+ meta.estimatedLines = parseInt(estimateMatch[1], 10);
253
+ }
254
+
255
+ // Phase 4: Deeper introspection signals
256
+
257
+ // Count fenced code blocks (```) — more code blocks = more complex implementation
258
+ const codeBlockMatches = content.match(/^```/gm);
259
+ meta.codeBlockCount = codeBlockMatches ? Math.floor(codeBlockMatches.length / 2) : 0;
260
+
261
+ // Detect complexity keywords that suggest harder tasks
262
+ const complexityKeywords: string[] = [];
263
+ if (content.match(/\b(migration|migrate|schema change)\b/i)) complexityKeywords.push("migration");
264
+ if (content.match(/\b(architect|design pattern|system design)\b/i)) complexityKeywords.push("architecture");
265
+ if (content.match(/\b(security|auth|encrypt|credential|vulnerability)\b/i)) complexityKeywords.push("security");
266
+ if (content.match(/\b(performance|optimize|cache|index)\b/i)) complexityKeywords.push("performance");
267
+ if (content.match(/\b(concurrent|parallel|race condition|mutex|lock)\b/i)) complexityKeywords.push("concurrency");
268
+ if (content.match(/\b(backward.?compat|breaking change|deprecat)\b/i)) complexityKeywords.push("compatibility");
269
+ meta.complexityKeywords = complexityKeywords;
270
+ } catch {
271
+ // Non-fatal — metadata extraction is best-effort
272
+ }
273
+
274
+ return meta;
275
+ }
276
+
277
+ // ─── Budget Pressure ─────────────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Apply budget pressure to a classification result.
281
+ * As budget usage increases, more aggressively downgrade tiers.
282
+ *
283
+ * - <50%: Normal classification (no change)
284
+ * - 50-75%: Tier 2 → Tier 1 where possible
285
+ * - 75-90%: Only heavy tasks keep configured model
286
+ * - >90%: Everything except replan-slice gets cheapest model
287
+ */
288
+ function applyBudgetPressure(
289
+ result: ClassificationResult,
290
+ budgetPct?: number,
291
+ ): ClassificationResult {
292
+ if (budgetPct === undefined || budgetPct < 0.5) return result;
293
+
294
+ const original = result.tier;
295
+
296
+ if (budgetPct >= 0.9) {
297
+ // >90%: almost everything goes to light
298
+ if (result.tier !== "heavy") {
299
+ result.tier = "light";
300
+ } else {
301
+ // Even heavy gets downgraded to standard
302
+ result.tier = "standard";
303
+ }
304
+ } else if (budgetPct >= 0.75) {
305
+ // 75-90%: only heavy stays, everything else goes to light
306
+ if (result.tier === "standard") {
307
+ result.tier = "light";
308
+ }
309
+ } else {
310
+ // 50-75%: standard → light
311
+ if (result.tier === "standard") {
312
+ result.tier = "light";
313
+ }
314
+ }
315
+
316
+ if (result.tier !== original) {
317
+ result.downgraded = true;
318
+ result.reason = `${result.reason} (budget pressure: ${Math.round(budgetPct * 100)}%)`;
319
+ }
320
+
321
+ return result;
322
+ }
@@ -39,6 +39,9 @@ function unitLabel(type: string): string {
39
39
  case "execute-task": return "Execute";
40
40
  case "complete-slice": return "Complete";
41
41
  case "reassess-roadmap": return "Reassess";
42
+ case "triage-captures": return "Triage";
43
+ case "quick-task": return "Quick Task";
44
+ case "replan-slice": return "Replan";
42
45
  default: return type;
43
46
  }
44
47
  }
@@ -345,6 +348,13 @@ export class GSDDashboardOverlay {
345
348
  lines.push(blank());
346
349
  }
347
350
 
351
+ // Pending captures badge — only shown when captures are waiting for triage
352
+ if (this.dashData.pendingCaptureCount > 0) {
353
+ const count = this.dashData.pendingCaptureCount;
354
+ lines.push(row(th.fg("warning", `📌 ${count} pending capture${count === 1 ? "" : "s"} awaiting triage`)));
355
+ lines.push(blank());
356
+ }
357
+
348
358
  if (this.loading) {
349
359
  lines.push(centered(th.fg("dim", "Loading dashboard…")));
350
360
  return lines;
@@ -39,6 +39,8 @@ export interface UnitMetrics {
39
39
  toolCalls: number;
40
40
  assistantMessages: number;
41
41
  userMessages: number;
42
+ tier?: string; // complexity tier (light/standard/heavy) if dynamic routing active
43
+ modelDowngraded?: boolean; // true if dynamic routing used a cheaper model
42
44
  }
43
45
 
44
46
  export interface MetricsLedger {
@@ -104,6 +106,7 @@ export function snapshotUnitMetrics(
104
106
  unitId: string,
105
107
  startedAt: number,
106
108
  model: string,
109
+ extras?: { tier?: string; modelDowngraded?: boolean },
107
110
  ): UnitMetrics | null {
108
111
  if (!ledger) return null;
109
112
 
@@ -156,6 +159,8 @@ export function snapshotUnitMetrics(
156
159
  toolCalls,
157
160
  assistantMessages,
158
161
  userMessages,
162
+ ...(extras?.tier ? { tier: extras.tier } : {}),
163
+ ...(extras?.modelDowngraded !== undefined ? { modelDowngraded: extras.modelDowngraded } : {}),
159
164
  };
160
165
 
161
166
  ledger.units.push(unit);
@@ -294,6 +299,49 @@ export function getProjectTotals(units: UnitMetrics[]): ProjectTotals {
294
299
  return totals;
295
300
  }
296
301
 
302
+ // ─── Tier Aggregation ────────────────────────────────────────────────────────
303
+
304
+ export interface TierAggregate {
305
+ tier: string;
306
+ units: number;
307
+ tokens: TokenCounts;
308
+ cost: number;
309
+ downgraded: number; // units that were downgraded by dynamic routing
310
+ }
311
+
312
+ export function aggregateByTier(units: UnitMetrics[]): TierAggregate[] {
313
+ const map = new Map<string, TierAggregate>();
314
+ for (const u of units) {
315
+ const tier = u.tier ?? "unknown";
316
+ let agg = map.get(tier);
317
+ if (!agg) {
318
+ agg = { tier, units: 0, tokens: emptyTokens(), cost: 0, downgraded: 0 };
319
+ map.set(tier, agg);
320
+ }
321
+ agg.units++;
322
+ agg.tokens = addTokens(agg.tokens, u.tokens);
323
+ agg.cost += u.cost;
324
+ if (u.modelDowngraded) agg.downgraded++;
325
+ }
326
+ const order = ["light", "standard", "heavy", "unknown"];
327
+ return order.map(t => map.get(t)).filter((a): a is TierAggregate => !!a);
328
+ }
329
+
330
+ /**
331
+ * Format a summary of savings from dynamic routing.
332
+ * Returns empty string if no units were downgraded.
333
+ */
334
+ export function formatTierSavings(units: UnitMetrics[]): string {
335
+ const downgraded = units.filter(u => u.modelDowngraded);
336
+ if (downgraded.length === 0) return "";
337
+
338
+ const downgradedCost = downgraded.reduce((sum, u) => sum + u.cost, 0);
339
+ const totalUnits = units.filter(u => u.tier).length;
340
+ const pct = totalUnits > 0 ? Math.round((downgraded.length / totalUnits) * 100) : 0;
341
+
342
+ return `Dynamic routing: ${downgraded.length}/${totalUnits} units downgraded (${pct}%), cost: ${formatCost(downgradedCost)}`;
343
+ }
344
+
297
345
  // ─── Formatting helpers ───────────────────────────────────────────────────────
298
346
 
299
347
  export function formatCost(cost: number): string {
@@ -0,0 +1,65 @@
1
+ // GSD Extension — Model Cost Table
2
+ // Static cost reference for known models, used by the dynamic router
3
+ // for cross-provider cost comparison.
4
+ //
5
+ // Costs are approximate per-1K-token rates in USD (input tokens).
6
+ // Updated with GSD releases. Users can override via preferences.
7
+
8
+ export interface ModelCostEntry {
9
+ /** Model ID (bare, without provider prefix) */
10
+ id: string;
11
+ /** Approximate cost per 1K input tokens in USD */
12
+ inputPer1k: number;
13
+ /** Approximate cost per 1K output tokens in USD */
14
+ outputPer1k: number;
15
+ /** Last updated date */
16
+ updatedAt: string;
17
+ }
18
+
19
+ /**
20
+ * Bundled cost table for known models.
21
+ * Updated periodically with GSD releases.
22
+ */
23
+ export const BUNDLED_COST_TABLE: ModelCostEntry[] = [
24
+ // Anthropic
25
+ { id: "claude-opus-4-6", inputPer1k: 0.015, outputPer1k: 0.075, updatedAt: "2025-03-15" },
26
+ { id: "claude-sonnet-4-6", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
27
+ { id: "claude-haiku-4-5", inputPer1k: 0.0008, outputPer1k: 0.004, updatedAt: "2025-03-15" },
28
+ { id: "claude-sonnet-4-5-20250514", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
29
+ { id: "claude-3-5-sonnet-latest", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
30
+ { id: "claude-3-5-haiku-latest", inputPer1k: 0.0008, outputPer1k: 0.004, updatedAt: "2025-03-15" },
31
+ { id: "claude-3-opus-latest", inputPer1k: 0.015, outputPer1k: 0.075, updatedAt: "2025-03-15" },
32
+
33
+ // OpenAI
34
+ { id: "gpt-4o", inputPer1k: 0.0025, outputPer1k: 0.01, updatedAt: "2025-03-15" },
35
+ { id: "gpt-4o-mini", inputPer1k: 0.00015, outputPer1k: 0.0006, updatedAt: "2025-03-15" },
36
+ { id: "o1", inputPer1k: 0.015, outputPer1k: 0.06, updatedAt: "2025-03-15" },
37
+ { id: "o3", inputPer1k: 0.015, outputPer1k: 0.06, updatedAt: "2025-03-15" },
38
+ { id: "gpt-4-turbo", inputPer1k: 0.01, outputPer1k: 0.03, updatedAt: "2025-03-15" },
39
+
40
+ // Google
41
+ { id: "gemini-2.0-flash", inputPer1k: 0.0001, outputPer1k: 0.0004, updatedAt: "2025-03-15" },
42
+ { id: "gemini-flash-2.0", inputPer1k: 0.0001, outputPer1k: 0.0004, updatedAt: "2025-03-15" },
43
+ { id: "gemini-2.5-pro", inputPer1k: 0.00125, outputPer1k: 0.005, updatedAt: "2025-03-15" },
44
+
45
+ // DeepSeek
46
+ { id: "deepseek-chat", inputPer1k: 0.00014, outputPer1k: 0.00028, updatedAt: "2025-03-15" },
47
+ ];
48
+
49
+ /**
50
+ * Lookup cost for a model ID. Returns undefined if not found.
51
+ */
52
+ export function lookupModelCost(modelId: string): ModelCostEntry | undefined {
53
+ const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
54
+ return BUNDLED_COST_TABLE.find(e => e.id === bareId)
55
+ ?? BUNDLED_COST_TABLE.find(e => bareId.includes(e.id) || e.id.includes(bareId));
56
+ }
57
+
58
+ /**
59
+ * Compare two models by input cost. Returns negative if a is cheaper.
60
+ */
61
+ export function compareModelCost(modelIdA: string, modelIdB: string): number {
62
+ const costA = lookupModelCost(modelIdA)?.inputPer1k ?? 999;
63
+ const costB = lookupModelCost(modelIdB)?.inputPer1k ?? 999;
64
+ return costA - costB;
65
+ }