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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +143 -14
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +154 -36
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +215 -185
- package/config/model-tiers.json +7 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/glama.json +6 -0
- package/package.json +94 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +22 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +40 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +143 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/public/whitepaper.html +189 -0
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +359 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +359 -189
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +98 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +375 -32
- package/scripts/lesson-search.js +48 -11
- package/scripts/llm-client.js +304 -15
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/model-tier-router.js +593 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/published-cli.js +11 -1
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +95 -1
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- 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
|
+
}
|