lynkr 9.14.0 → 9.14.2

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 (49) hide show
  1. package/package.json +10 -22
  2. package/src/api/health.js +0 -1
  3. package/src/api/middleware/session.js +0 -0
  4. package/src/api/openai-router.js +94 -13
  5. package/src/api/providers-handler.js +0 -1
  6. package/src/api/router.js +6 -3
  7. package/src/cache/prompt.js +0 -7
  8. package/src/clients/bedrock-utils.js +2 -1
  9. package/src/clients/codex-process.js +1 -1
  10. package/src/clients/codex-utils.js +0 -2
  11. package/src/clients/databricks.js +59 -25
  12. package/src/clients/gpt-utils.js +1 -1
  13. package/src/clients/openai-format.js +73 -23
  14. package/src/clients/provider-capabilities.js +0 -2
  15. package/src/clients/resilience.js +0 -1
  16. package/src/clients/responses-format.js +0 -56
  17. package/src/clients/routing.js +1 -1
  18. package/src/config/index.js +0 -1
  19. package/src/context/budget.js +1 -1
  20. package/src/context/tool-result-compressor.js +3 -5
  21. package/src/dashboard/api.js +0 -1
  22. package/src/headroom/client.js +44 -20
  23. package/src/logger/oversized-error-stream.js +1 -1
  24. package/src/memory/extractor.js +3 -3
  25. package/src/memory/search.js +2 -2
  26. package/src/memory/store.js +9 -24
  27. package/src/memory/tools.js +14 -0
  28. package/src/observability/metrics.js +0 -3
  29. package/src/orchestrator/index.js +1 -80
  30. package/src/orchestrator/sse-transformer.js +68 -10
  31. package/src/policy/safe-commands.js +0 -1
  32. package/src/routing/calibration.js +21 -1
  33. package/src/routing/client-profiles.js +26 -0
  34. package/src/routing/complexity-analyzer.js +5 -8
  35. package/src/routing/confidence-scorer.js +2 -2
  36. package/src/routing/cost-optimizer.js +1 -2
  37. package/src/routing/deescalator.js +1 -1
  38. package/src/routing/embedding-cache.js +0 -1
  39. package/src/routing/intent-score.js +0 -1
  40. package/src/routing/knn-router.js +27 -5
  41. package/src/routing/latency-tracker.js +1 -1
  42. package/src/routing/regret-estimator.js +1 -1
  43. package/src/stores/file-store.js +5 -1
  44. package/src/training/trajectory-compressor.js +1 -1
  45. package/src/workers/pool.js +1 -1
  46. package/src/workers/worker.js +1 -1
  47. package/scripts/convert-routellm.py +0 -105
  48. package/src/diff/comments.js +0 -153
  49. package/src/policy/web-fallback.js +0 -33
@@ -158,7 +158,62 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
158
158
  }
159
159
  };
160
160
 
161
+ // GLM-family reasoning leak filter (streaming twin of the fix in
162
+ // convertOpenAIToAnthropic): suppresses matched <think>…</think> spans and
163
+ // strips a bare orphan closing tag from visible text deltas, holding back
164
+ // trailing partial-tag fragments so tags split across chunk boundaries
165
+ // still match. Orphan pre-text has already been emitted by the time the
166
+ // closer arrives — a stream can't retract — so only the tag is removed.
167
+ let _inThink = false;
168
+ let _tagTail = "";
169
+ const _partialTagLen = (s) => {
170
+ for (let k = Math.min(8, s.length); k > 0; k--) {
171
+ const suf = s.slice(-k);
172
+ if ("</think>".startsWith(suf) || "<think>".startsWith(suf)) return k;
173
+ }
174
+ return 0;
175
+ };
176
+ const filterThink = (text) => {
177
+ let s = _tagTail + text;
178
+ _tagTail = "";
179
+ let out = "";
180
+ for (;;) {
181
+ if (_inThink) {
182
+ const close = s.indexOf("</think>");
183
+ if (close === -1) break; // still inside thinking — discard s (tail kept below)
184
+ s = s.slice(close + 8);
185
+ _inThink = false;
186
+ } else {
187
+ const open = s.indexOf("<think>");
188
+ const close = s.indexOf("</think>");
189
+ if (close !== -1 && (open === -1 || close < open)) {
190
+ out += s.slice(0, close); // orphan closer: drop the tag only
191
+ s = s.slice(close + 8);
192
+ } else if (open !== -1) {
193
+ out += s.slice(0, open);
194
+ s = s.slice(open + 7);
195
+ _inThink = true;
196
+ } else {
197
+ break;
198
+ }
199
+ }
200
+ }
201
+ const keep = _partialTagLen(s);
202
+ if (keep > 0) _tagTail = s.slice(-keep);
203
+ if (!_inThink && s.length > keep) out += s.slice(0, s.length - keep);
204
+ return out;
205
+ };
206
+
161
207
  const closeTextBlock = function* () {
208
+ // Flush a held-back fragment that turned out not to be a tag.
209
+ if (_tagTail && !_inThink && textIndex !== null) {
210
+ yield _sse("content_block_delta", {
211
+ type: "content_block_delta",
212
+ index: textIndex,
213
+ delta: { type: "text_delta", text: _tagTail },
214
+ });
215
+ _tagTail = "";
216
+ }
162
217
  if (textIndex === null) return;
163
218
  yield _sse("content_block_stop", { type: "content_block_stop", index: textIndex });
164
219
  textIndex = null;
@@ -217,19 +272,22 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
217
272
  // Text deltas are straightforward: open a block on first text, then
218
273
  // emit a text_delta per chunk.
219
274
  if (typeof delta.content === "string" && delta.content.length > 0) {
220
- if (textIndex === null) {
221
- textIndex = nextIndex++;
222
- yield _sse("content_block_start", {
223
- type: "content_block_start",
275
+ const visible = filterThink(delta.content);
276
+ if (visible.length > 0) {
277
+ if (textIndex === null) {
278
+ textIndex = nextIndex++;
279
+ yield _sse("content_block_start", {
280
+ type: "content_block_start",
281
+ index: textIndex,
282
+ content_block: { type: "text", text: "" },
283
+ });
284
+ }
285
+ yield _sse("content_block_delta", {
286
+ type: "content_block_delta",
224
287
  index: textIndex,
225
- content_block: { type: "text", text: "" },
288
+ delta: { type: "text_delta", text: visible },
226
289
  });
227
290
  }
228
- yield _sse("content_block_delta", {
229
- type: "content_block_delta",
230
- index: textIndex,
231
- delta: { type: "text_delta", text: delta.content },
232
- });
233
291
  }
234
292
 
235
293
  // Tool-call fragments: accumulate per OpenAI tool index. Fragments of
@@ -1,4 +1,3 @@
1
- const logger = require("../logger");
2
1
  const config = require("../config");
3
2
 
4
3
  /**
@@ -19,6 +19,8 @@ const path = require('path');
19
19
 
20
20
  const DEFAULT_DAYS = 7;
21
21
  const MIN_SAMPLES = 100;
22
+ /** Narrower than this and a calibrated band is treated as collapse, not signal. */
23
+ const MIN_TIER_WIDTH = 10;
22
24
 
23
25
  /** Quality score below which a complexity bucket is "underperforming" for its tier. */
24
26
  const QUALITY_FLOOR = {
@@ -188,7 +190,24 @@ function runCalibration({ days = DEFAULT_DAYS, dryRun = false, dbPath, outputPat
188
190
  const prev = ranges[tierOrder[i - 1]];
189
191
  const cur = ranges[tierOrder[i]];
190
192
  if (cur[0] !== prev[1] + 1) cur[0] = prev[1] + 1;
191
- if (cur[0] > cur[1]) cur[1] = cur[0]; // collapsed; tier disabled in practice
193
+ if (cur[0] > cur[1]) cur[1] = cur[0]; // collapsed; see guardrail below
194
+ }
195
+
196
+ // Guardrail: a tier squeezed under MIN_TIER_WIDTH means calibration had
197
+ // degenerate signal, not a real finding — e.g. 2026-07-26 (MEDIUM →
198
+ // [20,20]) and 2026-08-22 (COMPLEX → [51,54], which re-stitched
199
+ // REASONING's floor down to 55 and sent ~all agentic traffic to the most
200
+ // expensive tier; skewed telemetry then re-taught the same bands every
201
+ // 24h). Collapsed bands revert the whole set to DEFAULT_RANGES — bands
202
+ // are interdependent after stitching, so partial reverts would leave
203
+ // gaps.
204
+ const collapsed = tierOrder.filter(
205
+ (t) => ranges[t][1] - ranges[t][0] < MIN_TIER_WIDTH
206
+ );
207
+ let guardrail = null;
208
+ if (collapsed.length > 0) {
209
+ guardrail = { reverted: true, reason: 'band_collapse', collapsed };
210
+ for (const t of tierOrder) ranges[t] = [...DEFAULT_RANGES[t]];
192
211
  }
193
212
 
194
213
  const out = {
@@ -197,6 +216,7 @@ function runCalibration({ days = DEFAULT_DAYS, dryRun = false, dbPath, outputPat
197
216
  sampleCount: rows.length,
198
217
  ranges,
199
218
  stats,
219
+ ...(guardrail ? { guardrail } : {}),
200
220
  };
201
221
 
202
222
  if (dryRun) return { ...out, dryRun: true };
@@ -114,6 +114,32 @@ const PROFILES = {
114
114
  minToolFingerprintMatch: 0.5,
115
115
  },
116
116
  },
117
+ 'opencode': {
118
+ name: 'opencode',
119
+ baselineTools: new Set([
120
+ // OpenCode 1.18.x default loadout — captured live 2026-08-22 from
121
+ // Lynkr logs (user-agent `opencode/1.18.21 ai-sdk/provider-utils/…`).
122
+ // All of these attach to every request; counting them as intent
123
+ // scored every OpenCode turn ~80 → REASONING band → session pin
124
+ // locked the whole session there (2026-08-22: 1163/1203 REASONING
125
+ // routings were pin-inherited from a floored first turn).
126
+ 'bash', 'edit', 'glob', 'grep', 'question', 'read', 'skill', 'task',
127
+ 'todowrite', 'webfetch', 'write',
128
+ // Per opencode.ai/docs/tools — enabled-by-default or common-config
129
+ // names not seen in our capture; harmless if absent (subtraction
130
+ // only removes names actually present).
131
+ 'list', 'todoread', 'websearch',
132
+ ]),
133
+ detect: {
134
+ headerPatterns: [/opencode/i],
135
+ // 0.5 (like goose): `question`/`skill`/`todo*` vary with user config,
136
+ // so a minimal loadout matches ~8 of 14 baseline names. The lowercase
137
+ // set is disjoint from claude-code's capitalized and cursor's
138
+ // snake_case names, and opencode always sends its user-agent, so the
139
+ // header pattern is the primary detector anyway.
140
+ minToolFingerprintMatch: 0.5,
141
+ },
142
+ },
117
143
  'openai-codex': {
118
144
  name: 'openai-codex',
119
145
  baselineTools: new Set([
@@ -44,7 +44,7 @@ function _effectiveTools(payload) {
44
44
  // Pre-compiled regex patterns for performance
45
45
  const PATTERNS = {
46
46
  // Greetings - always local
47
- greeting: /^(hi|hello|hey|good\s*(morning|afternoon|evening)|howdy|greetings|sup|yo|thanks?|thank\s*you)[\s\.\!\?,]*$/i,
47
+ greeting: /^(hi|hello|hey|good\s*(morning|afternoon|evening)|howdy|greetings|sup|yo|thanks?|thank\s*you)[\s.!?,]*$/i,
48
48
 
49
49
  // Simple questions - likely local
50
50
  simpleQuestion: /^(what\s+is|what's|define|who\s+is|when\s+was|where\s+is)\s+\w/i,
@@ -56,7 +56,7 @@ const PATTERNS = {
56
56
  technical: /\b(code|function|class|method|variable|api|database|server|client|module|import|export|async|await|promise|component|interface|type|struct|enum)\b/i,
57
57
 
58
58
  // File/path references
59
- fileReference: /\b(\w+\.(js|ts|py|rb|go|rs|java|cpp|c|h|jsx|tsx|vue|svelte|md|json|yaml|yml|toml|sql|sh|bash))\b|[\.\/]\w+\//i,
59
+ fileReference: /\b(\w+\.(js|ts|py|rb|go|rs|java|cpp|c|h|jsx|tsx|vue|svelte|md|json|yaml|yml|toml|sql|sh|bash))\b|[./]\w+\//i,
60
60
  };
61
61
 
62
62
  // ============================================================================
@@ -119,10 +119,10 @@ const FORCE_CLOUD_PATTERNS = [
119
119
 
120
120
  // Force local patterns - always route to local regardless of score
121
121
  const FORCE_LOCAL_PATTERNS = [
122
- /^(hi|hello|hey|thanks?|thank\s*you|bye|goodbye)[\s\.\!\?]*$/i,
122
+ /^(hi|hello|hey|thanks?|thank\s*you|bye|goodbye)[\s.!?]*$/i,
123
123
  /^what\s+(time|day|date)\s+is\s+it/i,
124
- /^(yes|no|ok|okay|sure|got\s+it|understood)[\s\.\!\?]*$/i,
125
- /^(help|menu|commands?|options?)[\s\.\!\?]*$/i,
124
+ /^(yes|no|ok|okay|sure|got\s+it|understood)[\s.!?]*$/i,
125
+ /^(help|menu|commands?|options?)[\s.!?]*$/i,
126
126
  ];
127
127
 
128
128
  // Weighted Scoring (15 Dimensions)
@@ -478,8 +478,6 @@ function scoreTools(payload) {
478
478
  * Score based on task type (0-25 points)
479
479
  */
480
480
  function scoreTaskType(content) {
481
- const contentLower = content.toLowerCase();
482
-
483
481
  // Check force patterns first (priority: REASONING > cloud > local)
484
482
  for (const pattern of FORCE_REASONING_PATTERNS) {
485
483
  if (pattern.test(content)) {
@@ -796,7 +794,6 @@ async function analyzeComplexity(payload, options = {}) {
796
794
  if (useWeighted) {
797
795
  const weighted = calculateWeightedScore(workingPayload, content);
798
796
  const threshold = getThreshold();
799
- const mode = config.smartToolSelection?.mode ?? 'heuristic';
800
797
 
801
798
  // Check force patterns
802
799
  const taskTypeResult = scoreTaskType(content);
@@ -82,8 +82,8 @@ function scoreCode(response) {
82
82
  const fenced = (text.match(/```[\s\S]*?```/g) || []).join('\n');
83
83
  if (!fenced) return 0.6; // Code-gen request without code is suspicious
84
84
  // Very basic balance check
85
- const opens = (fenced.match(/[\{\[\(]/g) || []).length;
86
- const closes = (fenced.match(/[\}\]\)]/g) || []).length;
85
+ const opens = (fenced.match(/[{[(]/g) || []).length;
86
+ const closes = (fenced.match(/[}\])]/g) || []).length;
87
87
  if (Math.abs(opens - closes) > 2) return 0.5;
88
88
  return 0.9;
89
89
  }
@@ -5,9 +5,8 @@
5
5
  */
6
6
 
7
7
  const logger = require('../logger');
8
- const config = require('../config');
9
8
  const { getModelRegistry, getModelRegistrySync } = require('./model-registry');
10
- const { getModelTierSelector, TIER_DEFINITIONS } = require('./model-tiers');
9
+ const { getModelTierSelector } = require('./model-tiers');
11
10
  const { ratioFor } = require('./output-ratios');
12
11
 
13
12
  // Session cost tracking (in-memory)
@@ -57,7 +57,7 @@ function _lowerTier(tier) {
57
57
  * @param {Function} [args.deps.now] - Override Date.now() for tests.
58
58
  * @returns {string|null} The lower tier, or null if demotion is not warranted.
59
59
  */
60
- function suggestDemotion({ tier, requestType, analysis, deps = {} } = {}) {
60
+ function suggestDemotion({ tier, requestType, analysis: _analysis, deps = {} } = {}) {
61
61
  if (!tier || !requestType) return null;
62
62
  const lower = _lowerTier(tier);
63
63
  if (!lower) return null;
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  const crypto = require('crypto');
9
- const logger = require('../logger');
10
9
 
11
10
  const DEFAULT_MAX = 5000;
12
11
 
@@ -206,7 +206,6 @@ function classify(embedding, centroids) {
206
206
  * Lexical fallback path stays clamped ≤75 (see _lexicalCleanScore).
207
207
  */
208
208
  function blendScore(sims) {
209
- const classes = Object.keys(CLASS_VALUES);
210
209
  // Frontier similarity floor: a weak topical match can't jump tiers.
211
210
  const frontierSim = sims.frontier ?? -1;
212
211
  const activeSims = { ...sims };
@@ -22,7 +22,11 @@ const logger = require('../logger');
22
22
  const { generateEmbedding } = require('../cache/embeddings');
23
23
  const { getEmbeddingCache } = require('./embedding-cache');
24
24
 
25
- const INDEX_DIR = path.join(__dirname, '../../data/knn');
25
+ // LYNKR_KNN_DIR override exists so test runs point at a scratch dir instead
26
+ // of the production index — parallel test processes saving to the real path
27
+ // produced a torn read → corrupt link-list lengths → a 135GB zero-filled
28
+ // index file (2026-08-21).
29
+ const INDEX_DIR = process.env.LYNKR_KNN_DIR || path.join(__dirname, '../../data/knn');
26
30
  const INDEX_FILE = path.join(INDEX_DIR, 'index.hnsw');
27
31
  const META_FILE = path.join(INDEX_DIR, 'meta.json');
28
32
 
@@ -85,7 +89,7 @@ class KnnRouter {
85
89
  // (Earlier Lynkr code passed MAX_ELEMENTS here — wrong type, threw on load.)
86
90
  this.index.readIndexSync(INDEX_FILE, false);
87
91
  // resize if needed so we can keep adding up to MAX_ELEMENTS
88
- try { this.index.resizeIndex(MAX_ELEMENTS); } catch (_) {}
92
+ try { this.index.resizeIndex(MAX_ELEMENTS); } catch (err) { logger.debug({ err: err.message }, '[KnnRouter] resizeIndex failed — index may not grow past loaded size'); }
89
93
  this.ready = true;
90
94
  logger.info({ size: this.size, dim: this.dim }, '[KnnRouter] Index loaded');
91
95
  return true;
@@ -99,8 +103,26 @@ class KnnRouter {
99
103
  if (!this.ready || !this.index) return;
100
104
  try {
101
105
  fs.mkdirSync(INDEX_DIR, { recursive: true });
102
- this.index.writeIndexSync(INDEX_FILE);
103
- fs.writeFileSync(META_FILE, JSON.stringify({ dim: this.dim, entries: this.meta }, null, 0));
106
+ // Atomic save: hnswlib's writeIndexSync is truncate-then-write, so a
107
+ // concurrent reader can observe a torn image and later persist a
108
+ // corrupt index. Write to a per-pid temp file and rename into place —
109
+ // readers then always see a complete image.
110
+ const tmpIndex = `${INDEX_FILE}.tmp-${process.pid}`;
111
+ const tmpMeta = `${META_FILE}.tmp-${process.pid}`;
112
+ this.index.writeIndexSync(tmpIndex);
113
+ // Sanity bound: a healthy index is ~(elements × ~3.3KB) + slack. A
114
+ // corrupted link-list length manifests as a wildly oversized file —
115
+ // refuse to install it over the good one.
116
+ const expectedMax = (this.size + 1000) * 8192 + 1_000_000;
117
+ const written = fs.statSync(tmpIndex).size;
118
+ if (written > expectedMax) {
119
+ fs.rmSync(tmpIndex, { force: true });
120
+ logger.error({ written, expectedMax, size: this.size }, '[KnnRouter] Refusing to save implausibly large index — in-memory index likely corrupt');
121
+ return;
122
+ }
123
+ fs.writeFileSync(tmpMeta, JSON.stringify({ dim: this.dim, entries: this.meta }, null, 0));
124
+ fs.renameSync(tmpIndex, INDEX_FILE);
125
+ fs.renameSync(tmpMeta, META_FILE);
104
126
  } catch (err) {
105
127
  logger.warn({ err: err.message }, '[KnnRouter] Index save failed');
106
128
  }
@@ -197,7 +219,7 @@ class KnnRouter {
197
219
 
198
220
  let best = null;
199
221
  let bestScore = -Infinity;
200
- for (const [model, agg] of byModel) {
222
+ for (const [, agg] of byModel) {
201
223
  const avgQ = agg.quality / agg.weight;
202
224
  const avgC = agg.cost / agg.weight;
203
225
  // Score = quality / log(cost+1) — reward quality, penalise cost gently
@@ -95,7 +95,7 @@ class LatencyTracker {
95
95
  }
96
96
  // Aggregate across all models for this provider
97
97
  const provEntries = [];
98
- for (const [k, entry] of this._entries) {
98
+ for (const [_k, entry] of this._entries) {
99
99
  if (entry.provider === provider) provEntries.push(entry);
100
100
  }
101
101
  if (provEntries.length === 0) return null;
@@ -68,7 +68,7 @@ async function estimate(args) {
68
68
  fs.mkdirSync(path.dirname(ALERTS_PATH), { recursive: true });
69
69
  let existing = [];
70
70
  if (fs.existsSync(ALERTS_PATH)) {
71
- try { existing = JSON.parse(fs.readFileSync(ALERTS_PATH, 'utf8')); } catch {}
71
+ try { existing = JSON.parse(fs.readFileSync(ALERTS_PATH, 'utf8')); } catch (err) { logger.debug({ err: err.message }, '[RegretEstimator] Existing alerts file unreadable — starting fresh'); }
72
72
  }
73
73
  const out = Array.isArray(existing) ? existing : [];
74
74
  out.push(...alerts);
@@ -89,7 +89,11 @@ async function getFileContent(id) {
89
89
  async function deleteFile(id) {
90
90
  const entry = metadata.get(id);
91
91
  if (!entry) return false;
92
- try { await fsp.unlink(entry.storage_path); } catch {}
92
+ try {
93
+ await fsp.unlink(entry.storage_path);
94
+ } catch (err) {
95
+ logger.debug({ err: err.message, fileId: id }, "Best-effort unlink failed during file delete");
96
+ }
93
97
  metadata.delete(id);
94
98
  persistMetadata();
95
99
  return true;
@@ -38,7 +38,7 @@ const telemetry = require("../routing/telemetry");
38
38
  const ANONYMIZE_PATTERNS = [
39
39
  // API keys and bearer tokens
40
40
  [/sk-[A-Za-z0-9_-]{20,}/g, "<API_KEY>"],
41
- [/Bearer\s+[A-Za-z0-9._\-]+/gi, "Bearer <REDACTED>"],
41
+ [/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer <REDACTED>"],
42
42
  [/dapi_[A-Za-z0-9_-]+/g, "<DATABRICKS_KEY>"],
43
43
  [/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<JWT>"],
44
44
  // AWS keys
@@ -310,7 +310,7 @@ class WorkerPool {
310
310
  logger.info('[WorkerPool] Shutting down...');
311
311
 
312
312
  // Reject all pending tasks
313
- for (const [taskId, pending] of this.pendingTasks) {
313
+ for (const [_taskId, pending] of this.pendingTasks) {
314
314
  clearTimeout(pending.timeout);
315
315
  pending.reject(new Error('Worker pool shutting down'));
316
316
  }
@@ -65,7 +65,7 @@ const handlers = {
65
65
  let tokensReduced = 0;
66
66
  const seenToolResults = new Map(); // For deduplication
67
67
 
68
- const transformed = messages.map((msg, idx) => {
68
+ const transformed = messages.map((msg) => {
69
69
  if (!msg) return msg;
70
70
 
71
71
  // Clone to avoid mutating original
@@ -1,105 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Convert routellm/gpt4_dataset (train.jsonl) → Lynkr kNN bootstrap JSONL.
4
-
5
- Stream-downloads from HuggingFace and maps mixtral_score (1-5) to your
6
- TIER_* model config so the kNN index learns to recommend YOUR real models,
7
- not RouteLLM's pool (mixtral / gpt-4).
8
-
9
- Output format matches scripts/build-knn-index.js bootstrap input:
10
- {"query": "...", "provider": "ollama", "model": "minimax-m2.5:cloud",
11
- "quality": 95, "cost": 0.001, "latency": 200, "tier": "SIMPLE"}
12
-
13
- Usage:
14
- .venv/bin/python scripts/convert-routellm.py [--limit 30000] -o routellm.jsonl
15
- """
16
- import argparse
17
- import json
18
- import sys
19
- import urllib.request
20
- import ssl
21
-
22
- # Map mixtral_score → user's tier config.
23
- # Spread is intentionally aggressive: only score=5 (Mixtral truly nailed it)
24
- # stays SIMPLE; score=4 escalates to MEDIUM so the kNN index has enough MEDIUM
25
- # examples to actually recommend non-SIMPLE tiers (vs the previous mapping
26
- # where 86% of records were SIMPLE and kNN never disagreed with the heuristic).
27
- SCORE_TO_ROUTE = {
28
- 5: {"tier": "SIMPLE", "provider": "ollama", "model": "minimax-m2.5:cloud", "quality": 95, "cost": 0.001, "latency": 200},
29
- 4: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 80, "cost": 0.003, "latency": 1500},
30
- 3: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 70, "cost": 0.003, "latency": 1500},
31
- 2: {"tier": "COMPLEX", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 55, "cost": 0.003, "latency": 1500},
32
- 1: {"tier": "REASONING", "provider": "azure-anthropic", "model": "claude-opus-4-8", "quality": 40, "cost": 0.015, "latency": 3500},
33
- }
34
-
35
- URL = "https://huggingface.co/datasets/routellm/gpt4_dataset/resolve/main/train.jsonl"
36
-
37
-
38
- def main():
39
- p = argparse.ArgumentParser()
40
- p.add_argument("--output", "-o", default="data/routellm-bootstrap.jsonl")
41
- p.add_argument("--limit", type=int, default=None,
42
- help="Only convert first N rows")
43
- args = p.parse_args()
44
-
45
- print(f"Streaming {URL} …", file=sys.stderr)
46
- # Some macOS Python builds have SSL issues with huggingface.co — fall back
47
- # to unverified context if needed. The data is public, no auth.
48
- ctx = ssl.create_default_context()
49
- try:
50
- with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
51
- _stream(resp, args.output, args.limit)
52
- except (ssl.SSLError, OSError) as e:
53
- print(f"SSL strict failed ({e}); retrying with unverified context", file=sys.stderr)
54
- ctx = ssl._create_unverified_context()
55
- with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
56
- _stream(resp, args.output, args.limit)
57
-
58
-
59
- def _stream(resp, out_path, limit):
60
- written = 0
61
- by_score = {s: 0 for s in SCORE_TO_ROUTE}
62
- skipped = 0
63
- with open(out_path, "w") as out:
64
- for line in resp:
65
- if limit and written >= limit:
66
- break
67
- try:
68
- row = json.loads(line)
69
- except Exception:
70
- skipped += 1
71
- continue
72
- score = row.get("mixtral_score")
73
- if score not in SCORE_TO_ROUTE:
74
- skipped += 1
75
- continue
76
- route = SCORE_TO_ROUTE[score]
77
- rec = {
78
- "query": row.get("prompt", ""),
79
- "provider": route["provider"],
80
- "model": route["model"],
81
- "quality": route["quality"],
82
- "cost": route["cost"],
83
- "latency": route["latency"],
84
- "tier": route["tier"],
85
- }
86
- if not rec["query"]:
87
- skipped += 1
88
- continue
89
- out.write(json.dumps(rec) + "\n")
90
- written += 1
91
- by_score[score] += 1
92
- if written % 5000 == 0:
93
- print(f" {written} written…", file=sys.stderr)
94
-
95
- print(f"\nWrote {written} records to {out_path} ({skipped} skipped)", file=sys.stderr)
96
- print(f"By mixtral_score: {by_score}", file=sys.stderr)
97
- print(f"\nTier distribution:", file=sys.stderr)
98
- for score, route in SCORE_TO_ROUTE.items():
99
- n = by_score[score]
100
- if n:
101
- print(f" {route['tier']:>10} {route['provider']}:{route['model']:30s} n={n}", file=sys.stderr)
102
-
103
-
104
- if __name__ == "__main__":
105
- main()
@@ -1,153 +0,0 @@
1
- const crypto = require("crypto");
2
- const db = require("../db");
3
- const logger = require("../logger");
4
-
5
- const insertCommentStmt = db.prepare(
6
- `INSERT INTO diff_comments (
7
- thread_id,
8
- session_id,
9
- file_path,
10
- hunk,
11
- line,
12
- comment,
13
- author,
14
- created_at,
15
- metadata
16
- ) VALUES (
17
- @thread_id,
18
- @session_id,
19
- @file_path,
20
- @hunk,
21
- @line,
22
- @comment,
23
- @author,
24
- @created_at,
25
- @metadata
26
- )`,
27
- );
28
-
29
- const deleteCommentStmt = db.prepare("DELETE FROM diff_comments WHERE id = ?");
30
-
31
- function buildSelectCommentsQuery({ filePath, threadId }) {
32
- let sql = `SELECT id,
33
- thread_id,
34
- session_id,
35
- file_path,
36
- hunk,
37
- line,
38
- comment,
39
- author,
40
- created_at,
41
- metadata
42
- FROM diff_comments`;
43
- const conditions = [];
44
- const params = [];
45
- if (filePath) {
46
- conditions.push("file_path = ?");
47
- params.push(filePath);
48
- }
49
- if (threadId) {
50
- conditions.push("thread_id = ?");
51
- params.push(threadId);
52
- }
53
- if (conditions.length) {
54
- sql += ` WHERE ${conditions.join(" AND ")}`;
55
- }
56
- sql += " ORDER BY created_at ASC, id ASC";
57
- return { sql, params };
58
- }
59
-
60
- function normaliseCommentRow(row) {
61
- return {
62
- id: row.id,
63
- threadId: row.thread_id ?? null,
64
- sessionId: row.session_id ?? null,
65
- filePath: row.file_path,
66
- hunk: row.hunk ?? null,
67
- line: typeof row.line === "number" ? row.line : null,
68
- comment: row.comment,
69
- author: row.author ?? null,
70
- createdAt: row.created_at,
71
- metadata: row.metadata ? JSON.parse(row.metadata) : null,
72
- };
73
- }
74
-
75
- function addDiffComment({
76
- threadId,
77
- sessionId,
78
- filePath,
79
- line,
80
- hunk,
81
- comment,
82
- author,
83
- metadata,
84
- }) {
85
- if (typeof filePath !== "string" || filePath.trim().length === 0) {
86
- throw new Error("Diff comment requires a file path.");
87
- }
88
- if (typeof comment !== "string" || comment.trim().length === 0) {
89
- throw new Error("Diff comment requires non-empty comment text.");
90
- }
91
-
92
- const createdAt = Date.now();
93
- const thread = threadId ?? crypto.randomUUID();
94
-
95
- const params = {
96
- thread_id: thread,
97
- session_id: sessionId ?? null,
98
- file_path: filePath,
99
- hunk: hunk ?? null,
100
- line: typeof line === "number" ? line : null,
101
- comment,
102
- author: author ?? null,
103
- created_at: createdAt,
104
- metadata: metadata ? JSON.stringify(metadata) : null,
105
- };
106
-
107
- const result = insertCommentStmt.run(params);
108
-
109
- logger.debug(
110
- {
111
- id: result.lastInsertRowid,
112
- thread,
113
- filePath,
114
- line,
115
- },
116
- "Recorded diff comment",
117
- );
118
-
119
- return normaliseCommentRow({
120
- id: result.lastInsertRowid,
121
- thread_id: thread,
122
- session_id: sessionId ?? null,
123
- file_path: filePath,
124
- hunk: hunk ?? null,
125
- line: typeof line === "number" ? line : null,
126
- comment,
127
- author: author ?? null,
128
- created_at: createdAt,
129
- metadata: metadata ? JSON.stringify(metadata) : null,
130
- });
131
- }
132
-
133
- function listDiffComments({ filePath, threadId } = {}) {
134
- const { sql, params } = buildSelectCommentsQuery({ filePath, threadId });
135
- return db
136
- .prepare(sql)
137
- .all(...params)
138
- .map(normaliseCommentRow);
139
- }
140
-
141
- function deleteDiffComment({ id }) {
142
- if (!id) {
143
- throw new Error("diff_comment_delete requires an id.");
144
- }
145
- const result = deleteCommentStmt.run(id);
146
- return result.changes > 0;
147
- }
148
-
149
- module.exports = {
150
- addDiffComment,
151
- listDiffComments,
152
- deleteDiffComment,
153
- };