thumbgate 1.29.2 → 1.31.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 (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Risk-aware model router — routes whole generation requests to an external
6
+ * provider/model tier based on task complexity, context size, risk level, and
7
+ * retry count. This is application-level routing, not a neural Mixture of
8
+ * Experts (MoE): it never routes tokens through internal expert subnetworks.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { recommendInferenceBackend, resolveModelRole } = require('./local-model-profile');
14
+ const { redactSecrets } = require('./secret-redaction');
15
+
16
+ const CONFIG_PATH = path.join(__dirname, '..', 'config', 'model-tiers.json');
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Load config
20
+ // ---------------------------------------------------------------------------
21
+
22
+ let _config;
23
+ function loadConfig() {
24
+ if (!_config) _config = require(CONFIG_PATH);
25
+ return _config;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Model tiers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const TIERS = {
33
+ nano: { label: 'nano', costMultiplier: 0.1, maxContext: 32000 },
34
+ mini: { label: 'mini', costMultiplier: 0.4, maxContext: 200000 },
35
+ frontier: { label: 'frontier', costMultiplier: 1.0, maxContext: 1000000 },
36
+ // Self-hosted open-source frontier (e.g. GLM 5.1). Zero marginal cost.
37
+ localFrontier: { label: 'local-frontier', costMultiplier: 0.0, maxContext: 1000000 },
38
+ };
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Task classification → tier mapping
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Classify a task and route it to the appropriate model tier.
46
+ *
47
+ * @param {object} task
48
+ * @param {string} task.type — task type identifier
49
+ * @param {number} [task.contextTokens] — estimated context window usage
50
+ * @param {string} [task.riskLevel] — 'low' | 'medium' | 'high'
51
+ * @param {number} [task.retryCount] — how many times this task has been retried
52
+ * @param {string[]} [task.tags] — freeform tags for classification
53
+ * @returns {{ tier: string, reason: string, escalated: boolean }}
54
+ */
55
+ function classifyTask(task = {}) {
56
+ const { type, contextTokens = 0, riskLevel, retryCount = 0, tags = [] } = task;
57
+ const config = loadConfig();
58
+ const escalation = config.escalationRules;
59
+ const archTags = escalation.architectureTags || [];
60
+
61
+ // --- Escalation checks (override normal routing) ---
62
+
63
+ // 1. Context exceeds frontier threshold
64
+ if (contextTokens > escalation.contextThreshold) {
65
+ return {
66
+ tier: 'frontier',
67
+ reason: `context size ${contextTokens} exceeds threshold ${escalation.contextThreshold}`,
68
+ escalated: true,
69
+ };
70
+ }
71
+
72
+ // 2. High risk + retried enough
73
+ if (riskLevel === 'high' && retryCount >= escalation.failureRetryThreshold) {
74
+ return {
75
+ tier: 'frontier',
76
+ reason: `high risk with ${retryCount} retries (threshold: ${escalation.failureRetryThreshold})`,
77
+ escalated: true,
78
+ };
79
+ }
80
+
81
+ // 3. Architecture / cross-file tags
82
+ const matchedTag = tags.find((t) => archTags.includes(t));
83
+ if (matchedTag) {
84
+ return {
85
+ tier: 'frontier',
86
+ reason: `tag "${matchedTag}" matches architecture escalation`,
87
+ escalated: true,
88
+ };
89
+ }
90
+
91
+ // --- Normal tier routing by task type ---
92
+
93
+ const tiers = config.tiers;
94
+ for (const tierName of ['nano', 'mini', 'frontier']) {
95
+ if (tiers[tierName].taskTypes.includes(type)) {
96
+ return {
97
+ tier: tierName,
98
+ reason: `task type "${type}" mapped to ${tierName}`,
99
+ escalated: false,
100
+ };
101
+ }
102
+ }
103
+
104
+ // Unknown type defaults to mini
105
+ return {
106
+ tier: 'mini',
107
+ reason: `unknown task type "${type}" — defaulting to mini`,
108
+ escalated: false,
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Escalation logic
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Determine whether a task should be escalated from its current tier.
118
+ *
119
+ * @param {object} task — same shape as classifyTask input
120
+ * @param {object[]} history — array of { tier, success } from previous attempts
121
+ * @returns {{ escalate: boolean, from: string, to: string, reason: string }}
122
+ */
123
+ function shouldEscalate(task = {}, history = []) {
124
+ const { contextTokens = 0, riskLevel, retryCount = 0, tags = [] } = task;
125
+ const config = loadConfig();
126
+ const rules = config.escalationRules;
127
+ const archTags = rules.architectureTags || [];
128
+
129
+ const currentTier = classifyTask(task).tier;
130
+
131
+ // 1. Context exceeds threshold
132
+ if (contextTokens > rules.contextThreshold && currentTier !== 'frontier') {
133
+ return {
134
+ escalate: true,
135
+ from: currentTier,
136
+ to: 'frontier',
137
+ reason: `context ${contextTokens} > threshold ${rules.contextThreshold}`,
138
+ };
139
+ }
140
+
141
+ // 2. High risk + retries
142
+ if (riskLevel === 'high' && retryCount >= rules.failureRetryThreshold && currentTier !== 'frontier') {
143
+ return {
144
+ escalate: true,
145
+ from: currentTier,
146
+ to: 'frontier',
147
+ reason: `high risk with ${retryCount} retries`,
148
+ };
149
+ }
150
+
151
+ // 3. Architecture tags
152
+ const matchedTag = tags.find((t) => archTags.includes(t));
153
+ if (matchedTag && currentTier !== 'frontier') {
154
+ return {
155
+ escalate: true,
156
+ from: currentTier,
157
+ to: 'frontier',
158
+ reason: `architecture tag "${matchedTag}"`,
159
+ };
160
+ }
161
+
162
+ // 4. Two consecutive failures at mini tier
163
+ if (history.length >= 2) {
164
+ const lastTwo = history.slice(-2);
165
+ if (lastTwo.every((h) => h.tier === 'mini' && !h.success)) {
166
+ return {
167
+ escalate: true,
168
+ from: 'mini',
169
+ to: 'frontier',
170
+ reason: 'two consecutive failures at mini tier',
171
+ };
172
+ }
173
+ }
174
+
175
+ return {
176
+ escalate: false,
177
+ from: currentTier,
178
+ to: currentTier,
179
+ reason: 'no escalation needed',
180
+ };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Frontier budget tracker
185
+ // ---------------------------------------------------------------------------
186
+
187
+ class FrontierBudget {
188
+ /**
189
+ * @param {object} [options]
190
+ * @param {number} [options.tokenCap] — max frontier tokens per session (default 500000)
191
+ * @param {boolean} [options.requireReason] — require a reason string for spend (default true)
192
+ */
193
+ constructor(options = {}) {
194
+ const config = loadConfig();
195
+ const defaults = config.tiers.frontier.budgetDefaults || {};
196
+ this.tokenCap = options.tokenCap ?? defaults.tokenCap ?? 500000;
197
+ this.requireReason = options.requireReason ?? defaults.requireReason ?? true;
198
+ this.spent = 0;
199
+ this.invocations = [];
200
+ }
201
+
202
+ /**
203
+ * Check whether a spend is allowed without deducting.
204
+ * @param {number} tokens
205
+ * @param {string} [reason]
206
+ * @returns {{ allowed: boolean, remaining: number, reason: string }}
207
+ */
208
+ canSpend(tokens, reason) {
209
+ if (this.requireReason && !reason) {
210
+ return {
211
+ allowed: false,
212
+ remaining: this.tokenCap - this.spent,
213
+ reason: 'reason is required for frontier spend',
214
+ };
215
+ }
216
+ const remaining = this.tokenCap - this.spent;
217
+ if (tokens > remaining) {
218
+ return {
219
+ allowed: false,
220
+ remaining,
221
+ reason: `requested ${tokens} exceeds remaining ${remaining}`,
222
+ };
223
+ }
224
+ return {
225
+ allowed: true,
226
+ remaining,
227
+ reason: 'within budget',
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Deduct tokens from the budget.
233
+ * @param {number} tokens
234
+ * @param {string} [reason]
235
+ * @returns {{ success: boolean, spent: number, remaining: number, reason: string }}
236
+ */
237
+ spend(tokens, reason) {
238
+ const check = this.canSpend(tokens, reason);
239
+ if (!check.allowed) {
240
+ return { success: false, spent: this.spent, remaining: check.remaining, reason: check.reason };
241
+ }
242
+ this.spent += tokens;
243
+ this.invocations.push({ tokens, reason, timestamp: new Date().toISOString() });
244
+ return {
245
+ success: true,
246
+ spent: this.spent,
247
+ remaining: this.tokenCap - this.spent,
248
+ reason: `spent ${tokens} tokens — ${reason}`,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Return current budget status.
254
+ * @returns {{ spent: number, remaining: number, cap: number, invocations: number }}
255
+ */
256
+ status() {
257
+ return {
258
+ spent: this.spent,
259
+ remaining: this.tokenCap - this.spent,
260
+ cap: this.tokenCap,
261
+ invocations: this.invocations.length,
262
+ };
263
+ }
264
+
265
+ /** Reset the budget for a new session. */
266
+ reset() {
267
+ this.spent = 0;
268
+ this.invocations = [];
269
+ }
270
+ }
271
+
272
+ function recommendExecutionPlan(task = {}, env = process.env) {
273
+ const classification = classifyTask(task);
274
+ const inference = recommendInferenceBackend(task, env);
275
+
276
+ // When a local GLM backend is active, frontier tasks run at zero cost.
277
+ const isLocalGlm = inference.backend.providerMode === 'local'
278
+ && inference.backend.modelFamily.startsWith('glm');
279
+ const effectiveTier = isLocalGlm && classification.tier === 'frontier'
280
+ ? 'localFrontier'
281
+ : classification.tier;
282
+
283
+ return {
284
+ architecture: 'risk-aware-model-routing',
285
+ mixtureOfExperts: false,
286
+ tier: effectiveTier,
287
+ escalated: classification.escalated,
288
+ tierReason: classification.reason,
289
+ backendId: inference.backend.id,
290
+ providerMode: inference.backend.providerMode,
291
+ modelFamily: inference.backend.modelFamily,
292
+ workloadClass: inference.workloadClass,
293
+ recommendationClass: inference.recommendationClass,
294
+ indexCacheEligible: inference.backend.indexCacheEligible,
295
+ indexCacheEnabled: inference.backend.indexCacheEnabled,
296
+ reason: `${classification.reason}; ${inference.reason}`,
297
+ };
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Executable generation routing
302
+ // ---------------------------------------------------------------------------
303
+
304
+ function inferProvider(modelId, tier) {
305
+ if (tier === 'localFrontier') return 'openai-compatible';
306
+ if (/^(gpt-|o\d)/i.test(modelId || '')) return 'openai';
307
+ if (/^claude-/i.test(modelId || '')) return 'anthropic';
308
+ if (/^(gemini|vertex)/i.test(modelId || '')) return 'gemini';
309
+ return 'custom';
310
+ }
311
+
312
+ function normalizeGenerationResult(result, fallback = {}) {
313
+ if (typeof result === 'string') {
314
+ return {
315
+ text: result,
316
+ model: fallback.model,
317
+ provider: fallback.provider,
318
+ usage: null,
319
+ costCents: null,
320
+ };
321
+ }
322
+ if (!result || typeof result !== 'object') {
323
+ throw new Error('generation adapter returned no result');
324
+ }
325
+ const rawCostCents = result.costCents;
326
+ return {
327
+ ...result,
328
+ text: String(result.text || ''),
329
+ model: result.model || fallback.model,
330
+ provider: result.provider || fallback.provider,
331
+ usage: result.usage || null,
332
+ costCents: rawCostCents === null || rawCostCents === undefined || rawCostCents === ''
333
+ ? null
334
+ : (Number.isFinite(Number(rawCostCents)) ? Number(rawCostCents) : null),
335
+ };
336
+ }
337
+
338
+ function resolveLocalGenerationModel(env, plan) {
339
+ const role = resolveModelRole('normal', env);
340
+ if (role.provider === 'local') return role.model;
341
+
342
+ const configuredModel = [
343
+ env.THUMBGATE_MODEL_ROLE_NORMAL,
344
+ env.THUMBGATE_LOCAL_MODEL,
345
+ env.THUMBGATE_MODEL_ID,
346
+ env.THUMBGATE_LOCAL_MODEL_FAMILY,
347
+ plan.modelFamily,
348
+ ].find((value) => value && String(value).trim() && String(value).trim() !== 'unknown');
349
+ if (configuredModel) return String(configuredModel).trim();
350
+ throw new Error('a local model ID or family is required for local routing');
351
+ }
352
+
353
+ function resolveGenerationTarget(plan, task, tierConfig, options, env) {
354
+ const modelOverride = options.modelOverrides?.[plan.tier];
355
+ const providerOverride = options.providerOverrides?.[plan.tier];
356
+ const localOnly = task.privacyRoute === 'local';
357
+
358
+ if (localOnly && plan.providerMode !== 'local') {
359
+ throw new Error('privacyRoute "local" requires a configured local inference backend');
360
+ }
361
+
362
+ if (plan.providerMode === 'local') {
363
+ if (providerOverride && providerOverride !== 'openai-compatible') {
364
+ throw new Error('local inference cannot use a cloud provider override');
365
+ }
366
+ return {
367
+ model: modelOverride || resolveLocalGenerationModel(env, plan),
368
+ provider: 'openai-compatible',
369
+ };
370
+ }
371
+
372
+ const model = modelOverride || tierConfig.modelId;
373
+ if (!model) throw new Error(`missing modelId for tier "${plan.tier}"`);
374
+ return {
375
+ model,
376
+ provider: providerOverride || tierConfig.provider || inferProvider(model, plan.tier),
377
+ };
378
+ }
379
+
380
+ function createOpenAiCompatibleAdapter(options = {}) {
381
+ const env = options.env || process.env;
382
+ const fetchImpl = options.fetchImpl || global.fetch;
383
+
384
+ return async function openAiCompatibleAdapter({ request, model, provider }) {
385
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable');
386
+ const local = provider === 'openai-compatible';
387
+ const baseUrl = local
388
+ ? env.THUMBGATE_LOCAL_MODEL_BASE_URL
389
+ : (env.OPENAI_BASE_URL || 'https://api.openai.com/v1');
390
+ const apiKey = local ? env.THUMBGATE_LOCAL_MODEL_API_KEY : env.OPENAI_API_KEY;
391
+ if (!baseUrl) throw new Error('THUMBGATE_LOCAL_MODEL_BASE_URL is required for local routing');
392
+ if (!local && !apiKey) throw new Error('OPENAI_API_KEY is required for OpenAI routing');
393
+
394
+ const messages = Array.isArray(request.messages) && request.messages.length > 0
395
+ ? request.messages
396
+ : [
397
+ ...(request.systemPrompt ? [{ role: 'system', content: request.systemPrompt }] : []),
398
+ { role: 'user', content: request.userPrompt || request.prompt || '' },
399
+ ];
400
+ const headers = { 'Content-Type': 'application/json' };
401
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
402
+ const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, '')}/chat/completions`, {
403
+ method: 'POST',
404
+ headers,
405
+ body: JSON.stringify({
406
+ model,
407
+ messages,
408
+ max_tokens: request.maxTokens || 1024,
409
+ temperature: Number.isFinite(request.temperature) ? request.temperature : 0,
410
+ }),
411
+ });
412
+ if (!response.ok) throw new Error(`${provider} generation failed with HTTP ${response.status}`);
413
+ const payload = await response.json();
414
+ return {
415
+ text: payload?.choices?.[0]?.message?.content || '',
416
+ model: payload?.model || model,
417
+ provider,
418
+ usage: payload?.usage || null,
419
+ costCents: null,
420
+ };
421
+ };
422
+ }
423
+
424
+ function createDefaultGenerationAdapters(options = {}) {
425
+ const openAiCompatible = createOpenAiCompatibleAdapter(options);
426
+ return {
427
+ openai: openAiCompatible,
428
+ 'openai-compatible': openAiCompatible,
429
+ };
430
+ }
431
+
432
+ function createJsonlTelemetrySink(filePath) {
433
+ if (!filePath) throw new Error('telemetry file path is required');
434
+ return (event) => {
435
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
436
+ fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`, 'utf8');
437
+ };
438
+ }
439
+
440
+ async function executeRoutedGeneration(task = {}, request = {}, options = {}) {
441
+ const env = options.env || process.env;
442
+ const config = options.config || loadConfig();
443
+ const plan = recommendExecutionPlan(task, env);
444
+ const tierConfig = config.tiers[plan.tier];
445
+ if (!tierConfig) throw new Error(`missing configuration for tier "${plan.tier}"`);
446
+ const { model, provider } = resolveGenerationTarget(plan, task, tierConfig, options, env);
447
+ const adapters = options.adapters || createDefaultGenerationAdapters({ env, fetchImpl: options.fetchImpl });
448
+ const adapter = adapters[plan.tier] || adapters[provider];
449
+ if (typeof adapter !== 'function') throw new Error(`no generation adapter registered for provider "${provider}"`);
450
+
451
+ const now = options.now || (() => Date.now());
452
+ const startedAt = now();
453
+ const baseEvent = {
454
+ timestamp: new Date().toISOString(),
455
+ architecture: plan.architecture,
456
+ taskType: task.type || 'unknown',
457
+ riskLevel: task.riskLevel || 'unspecified',
458
+ tier: plan.tier,
459
+ provider,
460
+ model,
461
+ escalated: plan.escalated,
462
+ routeReason: plan.reason,
463
+ };
464
+
465
+ try {
466
+ const raw = await adapter({ task, request, plan, model, provider });
467
+ const result = normalizeGenerationResult(raw, { model, provider });
468
+ const event = {
469
+ ...baseEvent,
470
+ latencyMs: Math.max(0, now() - startedAt),
471
+ inputTokens: Number(result.usage?.input_tokens || result.usage?.prompt_tokens || 0) || null,
472
+ outputTokens: Number(result.usage?.output_tokens || result.usage?.completion_tokens || 0) || null,
473
+ costCents: result.costCents,
474
+ outcome: 'success',
475
+ };
476
+ if (options.telemetrySink) await options.telemetrySink(event);
477
+ return { ...result, route: plan, telemetry: event };
478
+ } catch (error) {
479
+ const event = {
480
+ ...baseEvent,
481
+ latencyMs: Math.max(0, now() - startedAt),
482
+ inputTokens: null,
483
+ outputTokens: null,
484
+ costCents: null,
485
+ outcome: 'error',
486
+ error: redactSecrets(String(error?.message || error)).split('\n')[0].slice(0, 300),
487
+ };
488
+ if (options.telemetrySink) await options.telemetrySink(event);
489
+ throw error;
490
+ }
491
+ }
492
+
493
+ function average(values) {
494
+ const finite = values.filter((value) => Number.isFinite(value));
495
+ return finite.length ? finite.reduce((sum, value) => sum + value, 0) / finite.length : null;
496
+ }
497
+
498
+ async function evaluateRoutingHoldout(cases, options = {}) {
499
+ if (!Array.isArray(cases) || cases.length === 0) throw new Error('holdout cases are required');
500
+ if (typeof options.fixedGenerate !== 'function') throw new Error('fixedGenerate baseline is required');
501
+ if (typeof options.scoreOutput !== 'function') {
502
+ throw new Error('scoreOutput is required; model routing and output judging must remain separate');
503
+ }
504
+ const routedGenerate = options.routedGenerate
505
+ || ((testCase) => executeRoutedGeneration(testCase.task, testCase.request, options.routerOptions));
506
+ const results = [];
507
+
508
+ for (const testCase of cases) {
509
+ const routed = await routedGenerate(testCase);
510
+ const fixed = await options.fixedGenerate(testCase);
511
+ const routedScore = Number(await options.scoreOutput(routed, testCase, 'routed'));
512
+ const fixedScore = Number(await options.scoreOutput(fixed, testCase, 'fixed'));
513
+ if (!Number.isFinite(routedScore) || !Number.isFinite(fixedScore)) {
514
+ throw new Error(`non-numeric holdout score for case "${testCase.id || 'unknown'}"`);
515
+ }
516
+ results.push({
517
+ id: testCase.id || `case-${results.length + 1}`,
518
+ routed: {
519
+ tier: routed.route?.tier || null,
520
+ model: routed.model || null,
521
+ score: routedScore,
522
+ costCents: Number.isFinite(routed.costCents) ? routed.costCents : null,
523
+ latencyMs: Number.isFinite(routed.telemetry?.latencyMs) ? routed.telemetry.latencyMs : null,
524
+ },
525
+ fixed: {
526
+ model: fixed.model || options.fixedModel || null,
527
+ score: fixedScore,
528
+ costCents: Number.isFinite(fixed.costCents) ? fixed.costCents : null,
529
+ latencyMs: Number.isFinite(fixed.telemetry?.latencyMs) ? fixed.telemetry.latencyMs : null,
530
+ },
531
+ qualityRegret: fixedScore - routedScore,
532
+ });
533
+ }
534
+
535
+ const costPairs = results.filter((result) => (
536
+ Number.isFinite(result.routed.costCents) && Number.isFinite(result.fixed.costCents)
537
+ ));
538
+ const routedCost = average(costPairs.map((result) => result.routed.costCents));
539
+ const fixedCost = average(costPairs.map((result) => result.fixed.costCents));
540
+ const averageQualityRegret = average(results.map((result) => result.qualityRegret));
541
+ const worstCaseQualityRegret = Math.max(...results.map((result) => result.qualityRegret));
542
+ const maxQualityRegret = Number.isFinite(options.maxQualityRegret) ? options.maxQualityRegret : 0;
543
+ return {
544
+ architecture: 'risk-aware-model-routing',
545
+ judgeStage: 'external-scorer',
546
+ caseCount: results.length,
547
+ metrics: {
548
+ routedQuality: average(results.map((result) => result.routed.score)),
549
+ fixedQuality: average(results.map((result) => result.fixed.score)),
550
+ averageQualityRegret,
551
+ worstCaseQualityRegret,
552
+ routedCostCents: routedCost,
553
+ fixedCostCents: fixedCost,
554
+ costSavingsCents: routedCost === null || fixedCost === null ? null : fixedCost - routedCost,
555
+ costCoverage: { measuredCases: costPairs.length, totalCases: results.length },
556
+ },
557
+ thresholds: { maxQualityRegret },
558
+ passed: worstCaseQualityRegret <= maxQualityRegret,
559
+ results,
560
+ };
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Exports
565
+ // ---------------------------------------------------------------------------
566
+
567
+ module.exports = {
568
+ TIERS,
569
+ classifyTask,
570
+ shouldEscalate,
571
+ FrontierBudget,
572
+ recommendExecutionPlan,
573
+ inferProvider,
574
+ normalizeGenerationResult,
575
+ resolveGenerationTarget,
576
+ createOpenAiCompatibleAdapter,
577
+ createDefaultGenerationAdapters,
578
+ createJsonlTelemetrySink,
579
+ executeRoutedGeneration,
580
+ evaluateRoutingHoldout,
581
+ };
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // CLI
585
+ // ---------------------------------------------------------------------------
586
+
587
+ if (require.main === module) {
588
+ const taskType = process.argv[2] || 'code-edit';
589
+ const result = classifyTask({ type: taskType });
590
+ const execution = recommendExecutionPlan({ type: taskType });
591
+ const budget = new FrontierBudget();
592
+ console.log(JSON.stringify({ classification: result, execution, budget: budget.status() }, null, 2));
593
+ }