lynkr 9.11.0 → 9.13.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.
@@ -20,16 +20,23 @@
20
20
  */
21
21
  const logger = require("../logger");
22
22
 
23
- // Upstreams whose streaming wire format is OpenAI Chat Completions SSE.
24
- // ollama is excluded (own format + buffering quirks), moonshot/zai are
25
- // excluded (their invoke fns convert buffered responses to Anthropic).
23
+ // Upstreams whose streaming streaming wire format is OpenAI Chat Completions SSE.
24
+ // ollama is excluded (own format + buffering quirks); zai is excluded (its
25
+ // invoke fn converts buffered responses to Anthropic, and its Anthropic-format
26
+ // endpoint has the native passthrough path instead). moonshot joined the list
27
+ // once invokeMoonshot learned to return the raw stream (its old forced
28
+ // stream:false predated this transformer). Caveat: reasoning_content deltas
29
+ // (kimi thinking) are not reshaped — thinking text is dropped from streamed
30
+ // responses; the buffered path still lifts it into thinking blocks.
26
31
  const DEFAULT_OPENAI_SSE_PROVIDERS = [
27
32
  "openai",
33
+ "atlas",
28
34
  "azure-openai",
29
35
  "openrouter",
30
36
  "databricks",
31
37
  "lmstudio",
32
38
  "llamacpp",
39
+ "moonshot",
33
40
  ];
34
41
 
35
42
  function _transformProviders() {
@@ -16,6 +16,7 @@ const logger = require('../logger');
16
16
  const config = require('../config');
17
17
  const codeGraph = require('../tools/code-graph');
18
18
  const clientProfiles = require('./client-profiles');
19
+ const { compressMessages, shouldCompress } = require('./context-compressor');
19
20
 
20
21
  /**
21
22
  * Return the tools the user actually added beyond the client harness's
@@ -769,13 +770,31 @@ function getThreshold() {
769
770
  * @returns {Object} Complexity analysis result
770
771
  */
771
772
  async function analyzeComplexity(payload, options = {}) {
772
- const content = extractContent(payload);
773
- const messageCount = payload?.messages?.length ?? 0;
773
+ // Phase 0: Context Compression (TencentDB-inspired)
774
+ // Compress conversation before scoring to account for token reduction
775
+ let workingPayload = payload;
776
+ let compressionStats = null;
777
+
778
+ const useCompression = options.compression ?? config.routing?.compression ?? true;
779
+ if (useCompression && shouldCompress(payload)) {
780
+ const { compressed, stats } = compressMessages(payload.messages);
781
+ compressionStats = stats;
782
+ workingPayload = { ...payload, messages: compressed };
783
+
784
+ logger.debug({
785
+ reduction: stats.reduction,
786
+ originalTokens: stats.original,
787
+ compressedTokens: stats.compressed,
788
+ }, '[complexity] Context compressed');
789
+ }
790
+
791
+ const content = extractContent(workingPayload);
792
+ const messageCount = workingPayload?.messages?.length ?? 0;
774
793
  const useWeighted = options.weighted ?? config.routing?.weightedScoring ?? false;
775
794
 
776
795
  // Use weighted scoring if enabled
777
796
  if (useWeighted) {
778
- const weighted = calculateWeightedScore(payload, content);
797
+ const weighted = calculateWeightedScore(workingPayload, content);
779
798
  const threshold = getThreshold();
780
799
  const mode = config.smartToolSelection?.mode ?? 'heuristic';
781
800
 
@@ -807,6 +826,7 @@ async function analyzeComplexity(payload, options = {}) {
807
826
  forceReason: taskTypeResult.reason?.startsWith('force_') ? taskTypeResult.reason : null,
808
827
  content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
809
828
  graphSignals: null,
829
+ compression: compressionStats, // Add compression stats
810
830
  };
811
831
 
812
832
  // Phase 5: Structural Analysis (code-review-graph, optional)
@@ -841,9 +861,9 @@ async function analyzeComplexity(payload, options = {}) {
841
861
  return result;
842
862
  }
843
863
 
844
- // Standard scoring (original logic)
845
- const tokenScore = scoreTokens(payload);
846
- const toolScore = scoreTools(payload);
864
+ // Standard scoring (original logic) - use compressed payload
865
+ const tokenScore = scoreTokens(workingPayload);
866
+ const toolScore = scoreTools(workingPayload);
847
867
  const taskTypeResult = scoreTaskType(content);
848
868
  const codeComplexityResult = scoreCodeComplexity(content);
849
869
  const reasoningResult = scoreReasoning(content);
@@ -881,8 +901,8 @@ async function analyzeComplexity(payload, options = {}) {
881
901
  mode,
882
902
  recommendation,
883
903
  breakdown: {
884
- tokens: { score: tokenScore, estimated: estimateTokens(payload) },
885
- tools: { score: toolScore, count: _effectiveTools(payload).length },
904
+ tokens: { score: tokenScore, estimated: estimateTokens(workingPayload) },
905
+ tools: { score: toolScore, count: _effectiveTools(workingPayload).length },
886
906
  taskType: taskTypeResult,
887
907
  codeComplexity: codeComplexityResult,
888
908
  reasoning: reasoningResult,
@@ -890,11 +910,12 @@ async function analyzeComplexity(payload, options = {}) {
890
910
  },
891
911
  content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
892
912
  graphSignals: null,
913
+ compression: compressionStats, // Add compression stats
893
914
  };
894
915
 
895
916
  // Phase 5: Structural Analysis (code-review-graph, optional)
896
917
  try {
897
- const filePaths = extractFilePaths(payload);
918
+ const filePaths = extractFilePaths(workingPayload);
898
919
  const graphOpts = { filePaths, workspace: options?.workspace };
899
920
  const graphAvailable = await codeGraph.isAvailable(graphOpts);
900
921
  if (graphAvailable && filePaths.length > 0) {
@@ -84,6 +84,7 @@ function _enabledProviders() {
84
84
  if (config.openrouter?.apiKey) out.push('openrouter');
85
85
  if (config.edenai?.apiKey) out.push('edenai');
86
86
  if (config.openai?.apiKey) out.push('openai');
87
+ if (config.atlas?.apiKey) out.push('atlas');
87
88
  if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai');
88
89
  if (config.ollama?.endpoint) out.push('ollama');
89
90
  if (config.llamacpp?.endpoint) out.push('llamacpp');
@@ -126,6 +127,7 @@ function getBestCloudProvider() {
126
127
  if (config.openrouter?.apiKey) return 'openrouter';
127
128
  if (config.edenai?.apiKey) return 'edenai';
128
129
  if (config.openai?.apiKey) return 'openai';
130
+ if (config.atlas?.apiKey) return 'atlas';
129
131
  if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai';
130
132
 
131
133
  return getFallbackProvider();
@@ -366,6 +366,14 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
366
366
  return { score: anchorScore, reconciled: 'up_gated' };
367
367
  }
368
368
 
369
+ // Memoize the final reconciled score per cleaned text. The classifier leg is
370
+ // live and can time out under load, sending identical text down different
371
+ // fallback paths — which made the same turn score differently between the
372
+ // router and the pin-drift checker (observed: 63 vs 77 for one prompt under
373
+ // suite-wide Ollama contention). First resolution wins for the process life.
374
+ const _scoreMemo = new Map();
375
+ const _SCORE_MEMO_MAX = 500;
376
+
369
377
  async function scoreIntent(payload, opts = {}) {
370
378
  const mode = opts.mode ?? intentScoreMode();
371
379
  if (mode === 'legacy') return null;
@@ -373,6 +381,21 @@ async function scoreIntent(payload, opts = {}) {
373
381
  const text = extractCleanUserText(payload);
374
382
  if (!text) return null;
375
383
 
384
+ // Only memoize plain calls — opts that alter scoring (custom centroids,
385
+ // embedFn, risk/context inputs) must not share cache entries.
386
+ const memoizable = !opts.centroids && !opts.embedFn && !opts.forceMatched
387
+ && !opts.riskLevel && !opts.skipClassifier && !opts.priorTurns;
388
+ if (memoizable && _scoreMemo.has(text)) return _scoreMemo.get(text);
389
+ const _memoSet = (result) => {
390
+ if (memoizable && result) {
391
+ if (_scoreMemo.size >= _SCORE_MEMO_MAX) {
392
+ _scoreMemo.delete(_scoreMemo.keys().next().value);
393
+ }
394
+ _scoreMemo.set(text, result);
395
+ }
396
+ return result;
397
+ };
398
+
376
399
  try {
377
400
  const centroids = opts.centroids !== undefined ? opts.centroids : await getDefaultCentroids();
378
401
  if (centroids) {
@@ -421,7 +444,7 @@ async function scoreIntent(payload, opts = {}) {
421
444
  finalScore: score,
422
445
  }, '[IntentScore] classifier reconciled anchor score');
423
446
  }
424
- return {
447
+ return _memoSet({
425
448
  score,
426
449
  mode: reconciled ? 'anchor+classifier' : 'anchor',
427
450
  class: cls,
@@ -431,13 +454,15 @@ async function scoreIntent(payload, opts = {}) {
431
454
  classifierTier: classifierResult?.tier ?? null,
432
455
  classifierConfidence: classifierResult?.confidence ?? null,
433
456
  reconciled,
434
- };
457
+ });
435
458
  }
436
459
  }
437
460
  } catch (err) {
438
461
  logger.debug({ err: err.message }, '[IntentScore] anchor scoring failed — lexical fallback');
439
462
  }
440
463
 
464
+ // Lexical fallback is NOT memoized: it usually means the embedding service
465
+ // was unavailable; pinning it would lock in degraded scores after recovery.
441
466
  return { score: _lexicalCleanScore(text), mode: 'lexical', text };
442
467
  }
443
468
 
@@ -333,6 +333,8 @@ class ModelTierSelector {
333
333
  return config.azureOpenAI?.deployment || null;
334
334
  case 'openai':
335
335
  return config.openai?.model || null;
336
+ case 'atlas':
337
+ return config.atlas?.model || null;
336
338
  case 'ollama':
337
339
  return config.ollama?.model || null;
338
340
  case 'openrouter':
@@ -349,6 +351,8 @@ class ModelTierSelector {
349
351
  return config.zai?.model || null;
350
352
  case 'moonshot':
351
353
  return config.moonshot?.model || null;
354
+ case 'baidu':
355
+ return config.baidu?.model || null;
352
356
  case 'codex':
353
357
  return config.codex?.model || null;
354
358
  case 'vertex':
@@ -170,6 +170,11 @@ function init() {
170
170
  // projectedSwitchCostUsd, projectedStaySavingsUsd,
171
171
  // expectedRemainingTurns} as JSON.
172
172
  ["cache_decision", "TEXT"],
173
+ // Lens dashboard — per-request cache counters from the provider's
174
+ // usage payload. NULL on rows recorded before capture began (or by
175
+ // providers that report none) — "not measured" is distinct from 0.
176
+ ["cache_read_tokens", "INTEGER"],
177
+ ["cache_creation_tokens", "INTEGER"],
173
178
  ];
174
179
  for (const [col, type] of additiveCols) {
175
180
  if (!existingCols.has(col)) {
@@ -232,7 +237,7 @@ function record(data) {
232
237
  retry_count, circuit_breaker_state, quality_score, tokens_per_second,
233
238
  cost_efficiency, request_text, response_text,
234
239
  base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
235
- cache_decision
240
+ cache_decision, cache_read_tokens, cache_creation_tokens
236
241
  ) VALUES (
237
242
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
238
243
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
@@ -241,7 +246,7 @@ function record(data) {
241
246
  @retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
242
247
  @cost_efficiency, @request_text, @response_text,
243
248
  @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
244
- @cache_decision
249
+ @cache_decision, @cache_read_tokens, @cache_creation_tokens
245
250
  )`
246
251
  );
247
252
  if (!insert) return;
@@ -292,6 +297,8 @@ function record(data) {
292
297
  : (typeof data.cache_decision === "string"
293
298
  ? data.cache_decision
294
299
  : JSON.stringify(data.cache_decision)),
300
+ cache_read_tokens: data.cache_read_tokens ?? null,
301
+ cache_creation_tokens: data.cache_creation_tokens ?? null,
295
302
  });
296
303
  } catch (err) {
297
304
  logger.debug({ err: err.message }, "Telemetry record failed");
@@ -926,6 +933,200 @@ function getExpectedRemainingTurns(currentTurns = 0) {
926
933
  }
927
934
  }
928
935
 
936
+ // ---------------------------------------------------------------------------
937
+ // Lens dashboard queries
938
+ // ---------------------------------------------------------------------------
939
+
940
+ /**
941
+ * MEASURED cache savings — backward-looking, auditable against the bill:
942
+ * for rows that carried cache counters, what did cache reads actually save
943
+ * vs. paying full input price? Distinct from the projected figures in
944
+ * getCacheEconomics. Also reports the hit ratio per (provider, model).
945
+ *
946
+ * @param {Object} [opts] {since}
947
+ * @returns {{measuredSavedUsd:number, rowsMeasured:number, perModel:Array}|null}
948
+ */
949
+ function getMeasuredCacheSavings(opts = {}) {
950
+ if (!init()) return null;
951
+ const since = opts.since ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
952
+ try {
953
+ const rows = db
954
+ .prepare(
955
+ `SELECT provider, model, COUNT(*) n,
956
+ SUM(COALESCE(cache_read_tokens,0)) rd,
957
+ SUM(COALESCE(cache_creation_tokens,0)) cr,
958
+ SUM(COALESCE(input_tokens,0)) inp
959
+ FROM routing_telemetry
960
+ WHERE timestamp > ? AND cache_read_tokens IS NOT NULL
961
+ GROUP BY provider, model`
962
+ )
963
+ .all(since);
964
+ const { resolveCacheEconomics } = require("./cache-economics");
965
+ let saved = 0;
966
+ let measured = 0;
967
+ const perModel = [];
968
+ for (const r of rows) {
969
+ measured += r.n;
970
+ const econ = resolveCacheEconomics(r.provider, r.model);
971
+ const savedUsd = econ.unknownPricing
972
+ ? null
973
+ : (r.rd * Math.max(0, econ.inputPerM - econ.cacheReadPerM)) / 1_000_000;
974
+ if (savedUsd != null) saved += savedUsd;
975
+ const total = r.rd + r.cr + r.inp;
976
+ perModel.push({
977
+ provider: r.provider,
978
+ model: r.model,
979
+ requests: r.n,
980
+ cacheReadTokens: r.rd,
981
+ hitRatio: total > 0 ? r.rd / total : null,
982
+ savedUsd,
983
+ });
984
+ }
985
+ perModel.sort((a, b) => (a.hitRatio ?? 1) - (b.hitRatio ?? 1));
986
+ return { measuredSavedUsd: saved, rowsMeasured: measured, perModel };
987
+ } catch (err) {
988
+ logger.debug({ err: err.message }, "Telemetry getMeasuredCacheSavings failed");
989
+ return null;
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Session drill-down: per-model mix, context-growth series, tool totals.
995
+ * @param {string} sessionId
996
+ */
997
+ function getSessionDetail(sessionId) {
998
+ if (!sessionId || !init()) return null;
999
+ try {
1000
+ const rows = db
1001
+ .prepare(
1002
+ `SELECT timestamp, provider, model, tier, routing_method, input_tokens,
1003
+ output_tokens, cost_usd, latency_ms, tool_count, tool_calls_made,
1004
+ cache_read_tokens, cache_creation_tokens, status_code, error_type,
1005
+ pinned, switch_reason, message_count
1006
+ FROM routing_telemetry WHERE session_id = ? ORDER BY timestamp ASC LIMIT 2000`
1007
+ )
1008
+ .all(sessionId);
1009
+ if (!rows.length) return null;
1010
+
1011
+ const perModel = new Map();
1012
+ let toolCalls = 0;
1013
+ let cost = 0;
1014
+ for (const r of rows) {
1015
+ const key = `${r.provider}:${r.model}`;
1016
+ const m = perModel.get(key) ?? {
1017
+ provider: r.provider, model: r.model, requests: 0,
1018
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0,
1019
+ };
1020
+ m.requests++;
1021
+ m.inputTokens += r.input_tokens ?? 0;
1022
+ m.outputTokens += r.output_tokens ?? 0;
1023
+ m.cacheReadTokens += r.cache_read_tokens ?? 0;
1024
+ m.costUsd += r.cost_usd ?? 0;
1025
+ perModel.set(key, m);
1026
+ toolCalls += r.tool_calls_made ?? 0;
1027
+ cost += r.cost_usd ?? 0;
1028
+ }
1029
+
1030
+ return {
1031
+ sessionId,
1032
+ requests: rows.length,
1033
+ totalCostUsd: cost,
1034
+ toolCalls,
1035
+ firstSeen: rows[0].timestamp,
1036
+ lastSeen: rows[rows.length - 1].timestamp,
1037
+ models: [...perModel.values()].sort((a, b) => b.costUsd - a.costUsd),
1038
+ // Input (context) tokens per call over the session — the distiller
1039
+ // cliff and frozen-block plateau are visible here.
1040
+ contextSeries: rows.map((r) => ({
1041
+ t: r.timestamp,
1042
+ inputTokens: r.input_tokens ?? 0,
1043
+ cacheReadTokens: r.cache_read_tokens ?? null,
1044
+ tier: r.tier,
1045
+ model: r.model,
1046
+ })),
1047
+ rows,
1048
+ };
1049
+ } catch (err) {
1050
+ logger.debug({ err: err.message }, "Telemetry getSessionDetail failed");
1051
+ return null;
1052
+ }
1053
+ }
1054
+
1055
+ // Pivot explorer — every metric/dimension is whitelisted; params never reach
1056
+ // SQL as strings.
1057
+ const ANALYTICS_METRICS = {
1058
+ spend: "SUM(COALESCE(cost_usd,0))",
1059
+ tokens: "SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0))",
1060
+ requests: "COUNT(*)",
1061
+ sessions: "COUNT(DISTINCT session_id)",
1062
+ };
1063
+ const ANALYTICS_DIMS = {
1064
+ provider: "provider",
1065
+ model: "model",
1066
+ tier: "tier",
1067
+ request_type: "request_type",
1068
+ routing_method: "routing_method",
1069
+ switch_reason: "switch_reason",
1070
+ day: "date(timestamp/1000, 'unixepoch')",
1071
+ };
1072
+
1073
+ /**
1074
+ * Generic Metric × Dimension (× Stack) pivot with KPI row and
1075
+ * period-over-period deltas (suppressed when the prior window is thin).
1076
+ *
1077
+ * @param {Object} [opts] {metric, by, stack, since, until}
1078
+ */
1079
+ function getAnalytics(opts = {}) {
1080
+ if (!init()) return null;
1081
+ const metricExpr = ANALYTICS_METRICS[opts.metric] ?? ANALYTICS_METRICS.spend;
1082
+ const metric = ANALYTICS_METRICS[opts.metric] ? opts.metric : "spend";
1083
+ const byExpr = ANALYTICS_DIMS[opts.by] ?? ANALYTICS_DIMS.provider;
1084
+ const by = ANALYTICS_DIMS[opts.by] ? opts.by : "provider";
1085
+ const stackExpr = opts.stack && ANALYTICS_DIMS[opts.stack] ? ANALYTICS_DIMS[opts.stack] : null;
1086
+ const until = opts.until ?? Date.now();
1087
+ const since = opts.since ?? until - 7 * 24 * 60 * 60 * 1000;
1088
+
1089
+ try {
1090
+ const groupCols = stackExpr ? `${byExpr} AS dim, ${stackExpr} AS stack` : `${byExpr} AS dim`;
1091
+ const groupBy = stackExpr ? "GROUP BY dim, stack" : "GROUP BY dim";
1092
+ const rows = db
1093
+ .prepare(
1094
+ `SELECT ${groupCols}, ${metricExpr} AS value
1095
+ FROM routing_telemetry
1096
+ WHERE timestamp BETWEEN ? AND ?
1097
+ ${groupBy} ORDER BY value DESC LIMIT 500`
1098
+ )
1099
+ .all(since, until);
1100
+
1101
+ const kpiRow = (lo, hi) =>
1102
+ db
1103
+ .prepare(
1104
+ `SELECT SUM(COALESCE(cost_usd,0)) spend,
1105
+ SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0)) tokens,
1106
+ COUNT(*) requests,
1107
+ COUNT(DISTINCT session_id) sessions
1108
+ FROM routing_telemetry WHERE timestamp BETWEEN ? AND ?`
1109
+ )
1110
+ .get(lo, hi);
1111
+ const kpis = kpiRow(since, until);
1112
+ const prev = kpiRow(since - (until - since), since);
1113
+ // Thin prior window → deltas are noise, suppress rather than mislead.
1114
+ const kpiDeltas = (prev?.requests ?? 0) >= 10
1115
+ ? {
1116
+ spend: kpis.spend - (prev.spend ?? 0),
1117
+ tokens: kpis.tokens - (prev.tokens ?? 0),
1118
+ requests: kpis.requests - prev.requests,
1119
+ sessions: kpis.sessions - (prev.sessions ?? 0),
1120
+ }
1121
+ : null;
1122
+
1123
+ return { metric, by, stack: stackExpr ? opts.stack : null, since, until, rows, kpis, kpiDeltas };
1124
+ } catch (err) {
1125
+ logger.debug({ err: err.message }, "Telemetry getAnalytics failed");
1126
+ return null;
1127
+ }
1128
+ }
1129
+
929
1130
  module.exports = {
930
1131
  record,
931
1132
  query,
@@ -936,6 +1137,9 @@ module.exports = {
936
1137
  getQualityByTierAndType,
937
1138
  getExpectedRemainingTurns,
938
1139
  getCacheEconomics,
1140
+ getMeasuredCacheSavings,
1141
+ getSessionDetail,
1142
+ getAnalytics,
939
1143
  recordSavings,
940
1144
  getSavingsSummary,
941
1145
  cleanup,
@@ -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()