lynkr 8.0.0 → 9.0.1

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 (128) hide show
  1. package/.lynkr/telemetry.db +0 -0
  2. package/.lynkr/telemetry.db-shm +0 -0
  3. package/.lynkr/telemetry.db-wal +0 -0
  4. package/README.md +196 -322
  5. package/lynkr-skill.tar.gz +0 -0
  6. package/package.json +4 -3
  7. package/src/api/openai-router.js +64 -13
  8. package/src/api/providers-handler.js +171 -3
  9. package/src/api/router.js +9 -2
  10. package/src/clients/circuit-breaker.js +10 -247
  11. package/src/clients/codex-process.js +342 -0
  12. package/src/clients/codex-utils.js +143 -0
  13. package/src/clients/databricks.js +210 -63
  14. package/src/clients/resilience.js +540 -0
  15. package/src/clients/retry.js +22 -167
  16. package/src/clients/standard-tools.js +23 -0
  17. package/src/config/index.js +77 -0
  18. package/src/context/compression.js +42 -9
  19. package/src/context/distill.js +492 -0
  20. package/src/orchestrator/index.js +48 -8
  21. package/src/routing/complexity-analyzer.js +258 -5
  22. package/src/routing/index.js +12 -2
  23. package/src/routing/latency-tracker.js +148 -0
  24. package/src/routing/model-tiers.js +2 -0
  25. package/src/routing/quality-scorer.js +113 -0
  26. package/src/routing/telemetry.js +464 -0
  27. package/src/server.js +13 -12
  28. package/src/tools/code-graph.js +538 -0
  29. package/src/tools/code-mode.js +304 -0
  30. package/src/tools/index.js +4 -0
  31. package/src/tools/lazy-loader.js +18 -0
  32. package/src/tools/mcp-remote.js +7 -0
  33. package/src/tools/smart-selection.js +11 -0
  34. package/src/tools/tinyfish.js +358 -0
  35. package/src/tools/truncate.js +1 -0
  36. package/src/utils/payload.js +206 -0
  37. package/src/utils/perf-timer.js +80 -0
  38. package/.github/FUNDING.yml +0 -15
  39. package/.github/workflows/README.md +0 -215
  40. package/.github/workflows/ci.yml +0 -69
  41. package/.github/workflows/index.yml +0 -62
  42. package/.github/workflows/web-tools-tests.yml +0 -56
  43. package/CITATIONS.bib +0 -6
  44. package/DEPLOYMENT.md +0 -1001
  45. package/LYNKR-TUI-PLAN.md +0 -984
  46. package/PERFORMANCE-REPORT.md +0 -866
  47. package/PLAN-per-client-model-routing.md +0 -252
  48. package/docs/42642f749da6234f41b6b425c3bb07c9.txt +0 -1
  49. package/docs/BingSiteAuth.xml +0 -4
  50. package/docs/docs-style.css +0 -478
  51. package/docs/docs.html +0 -198
  52. package/docs/google5be250e608e6da39.html +0 -1
  53. package/docs/index.html +0 -577
  54. package/docs/index.md +0 -584
  55. package/docs/robots.txt +0 -4
  56. package/docs/sitemap.xml +0 -44
  57. package/docs/style.css +0 -1223
  58. package/docs/toon-integration-spec.md +0 -130
  59. package/documentation/README.md +0 -101
  60. package/documentation/api.md +0 -806
  61. package/documentation/claude-code-cli.md +0 -679
  62. package/documentation/codex-cli.md +0 -397
  63. package/documentation/contributing.md +0 -571
  64. package/documentation/cursor-integration.md +0 -734
  65. package/documentation/docker.md +0 -874
  66. package/documentation/embeddings.md +0 -762
  67. package/documentation/faq.md +0 -713
  68. package/documentation/features.md +0 -403
  69. package/documentation/headroom.md +0 -519
  70. package/documentation/installation.md +0 -758
  71. package/documentation/memory-system.md +0 -476
  72. package/documentation/production.md +0 -636
  73. package/documentation/providers.md +0 -1009
  74. package/documentation/routing.md +0 -476
  75. package/documentation/testing.md +0 -629
  76. package/documentation/token-optimization.md +0 -325
  77. package/documentation/tools.md +0 -697
  78. package/documentation/troubleshooting.md +0 -969
  79. package/final-test.js +0 -33
  80. package/headroom-sidecar/config.py +0 -93
  81. package/headroom-sidecar/requirements.txt +0 -14
  82. package/headroom-sidecar/server.py +0 -451
  83. package/monitor-agents.sh +0 -31
  84. package/scripts/audit-log-reader.js +0 -399
  85. package/scripts/compact-dictionary.js +0 -204
  86. package/scripts/test-deduplication.js +0 -448
  87. package/src/db/database.sqlite +0 -0
  88. package/te +0 -11622
  89. package/test/README.md +0 -212
  90. package/test/azure-openai-config.test.js +0 -213
  91. package/test/azure-openai-error-resilience.test.js +0 -238
  92. package/test/azure-openai-format-conversion.test.js +0 -354
  93. package/test/azure-openai-integration.test.js +0 -287
  94. package/test/azure-openai-routing.test.js +0 -175
  95. package/test/azure-openai-streaming.test.js +0 -171
  96. package/test/bedrock-integration.test.js +0 -457
  97. package/test/comprehensive-test-suite.js +0 -928
  98. package/test/config-validation.test.js +0 -207
  99. package/test/cursor-integration.test.js +0 -484
  100. package/test/format-conversion.test.js +0 -578
  101. package/test/hybrid-routing-integration.test.js +0 -269
  102. package/test/hybrid-routing-performance.test.js +0 -428
  103. package/test/llamacpp-integration.test.js +0 -882
  104. package/test/lmstudio-integration.test.js +0 -347
  105. package/test/memory/extractor.test.js +0 -398
  106. package/test/memory/retriever.test.js +0 -613
  107. package/test/memory/retriever.test.js.bak +0 -585
  108. package/test/memory/search.test.js +0 -537
  109. package/test/memory/search.test.js.bak +0 -389
  110. package/test/memory/store.test.js +0 -344
  111. package/test/memory/store.test.js.bak +0 -312
  112. package/test/memory/surprise.test.js +0 -300
  113. package/test/memory-performance.test.js +0 -472
  114. package/test/openai-integration.test.js +0 -683
  115. package/test/openrouter-error-resilience.test.js +0 -418
  116. package/test/passthrough-mode.test.js +0 -385
  117. package/test/performance-benchmark.js +0 -351
  118. package/test/performance-tests.js +0 -528
  119. package/test/routing.test.js +0 -225
  120. package/test/toon-compression.test.js +0 -131
  121. package/test/web-tools.test.js +0 -329
  122. package/test-agents-simple.js +0 -43
  123. package/test-cli-connection.sh +0 -33
  124. package/test-learning-unit.js +0 -126
  125. package/test-learning.js +0 -112
  126. package/test-parallel-agents.sh +0 -124
  127. package/test-parallel-direct.js +0 -155
  128. package/test-subagents.sh +0 -117
@@ -2,17 +2,19 @@
2
2
  * Complexity Analyzer Module
3
3
  *
4
4
  * Analyzes request complexity to determine optimal model routing.
5
- * Implements all 4 phases of auto model selection:
5
+ * Implements all 5 phases of auto model selection:
6
6
  * - Phase 1: Basic Scoring (token count, tool count, task classification)
7
7
  * - Phase 2: Advanced Classification (code complexity, reasoning detection)
8
8
  * - Phase 3: Learning & Tracking (metrics, feedback storage)
9
9
  * - Phase 4: ML-Based (embeddings similarity)
10
+ * - Phase 5: Structural Analysis (code-review-graph blast radius & dependency signals)
10
11
  *
11
12
  * @module routing/complexity-analyzer
12
13
  */
13
14
 
14
15
  const logger = require('../logger');
15
16
  const config = require('../config');
17
+ const codeGraph = require('../tools/code-graph');
16
18
 
17
19
  // ============================================================================
18
20
  // PHASE 1: Basic Scoring Patterns
@@ -178,6 +180,189 @@ const routingMetrics = {
178
180
  },
179
181
  };
180
182
 
183
+ // ============================================================================
184
+ // PHASE 5: Structural Analysis Helpers (code-review-graph)
185
+ // ============================================================================
186
+
187
+ /** Pattern to match file paths in message content */
188
+ const FILE_PATH_PATTERN = /(?:^|\s|["'`(])([.\w/-]+\.(?:js|ts|py|rb|go|rs|java|cpp|c|h|jsx|tsx|vue|svelte|json|yaml|yml|toml|sql|sh|bash|css|scss|html))\b/gi;
189
+
190
+ /**
191
+ * Extract file paths from text using the FILE_PATH_PATTERN regex.
192
+ * @param {string} text
193
+ * @param {Set<string>} paths — accumulator set
194
+ */
195
+ function extractPathsFromText(text, paths) {
196
+ if (typeof text !== 'string') return;
197
+ for (const match of text.matchAll(FILE_PATH_PATTERN)) {
198
+ paths.add(match[1]);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Extract file paths from the full conversation payload.
204
+ *
205
+ * Supports both Anthropic and OpenAI message formats:
206
+ * - Anthropic: system (string/array), messages with tool_use/tool_result blocks
207
+ * - OpenAI: messages with role=system, tool_calls with function.arguments
208
+ * - Cursor/Windsurf: file context embedded in system prompts
209
+ * - Codex CLI / Aider: function call arguments with file paths
210
+ *
211
+ * @param {Object} payload — request payload
212
+ * @returns {string[]} deduplicated file paths
213
+ */
214
+ function extractFilePaths(payload) {
215
+ const paths = new Set();
216
+
217
+ if (!payload) return [];
218
+
219
+ // --- Anthropic system prompt (string or array of content blocks) ---
220
+ if (typeof payload.system === 'string') {
221
+ extractPathsFromText(payload.system, paths);
222
+ } else if (Array.isArray(payload.system)) {
223
+ for (const block of payload.system) {
224
+ if (block?.type === 'text' && block.text) {
225
+ extractPathsFromText(block.text, paths);
226
+ }
227
+ }
228
+ }
229
+
230
+ if (!Array.isArray(payload.messages)) return Array.from(paths);
231
+
232
+ for (const msg of payload.messages) {
233
+ // --- String content (both Anthropic and OpenAI) ---
234
+ if (typeof msg.content === 'string') {
235
+ extractPathsFromText(msg.content, paths);
236
+ } else if (Array.isArray(msg.content)) {
237
+ for (const block of msg.content) {
238
+ // Text blocks (Anthropic format)
239
+ if (block?.type === 'text' && block.text) {
240
+ extractPathsFromText(block.text, paths);
241
+ }
242
+ // Tool use blocks (Anthropic format — Claude Code, Cline, Zed)
243
+ if (block?.type === 'tool_use' && block.input) {
244
+ const input = block.input;
245
+ if (typeof input.file_path === 'string') paths.add(input.file_path);
246
+ if (typeof input.path === 'string') paths.add(input.path);
247
+ if (typeof input.command === 'string') {
248
+ extractPathsFromText(input.command, paths);
249
+ }
250
+ }
251
+ // Tool result blocks (Anthropic format)
252
+ if (block?.type === 'tool_result') {
253
+ const resultContent = Array.isArray(block.content) ? block.content : [];
254
+ for (const rc of resultContent) {
255
+ if (rc?.type === 'text' && rc.text) {
256
+ extractPathsFromText(rc.text, paths);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ // --- OpenAI tool_calls format (Codex CLI, Aider, Continue.dev) ---
264
+ if (Array.isArray(msg.tool_calls)) {
265
+ for (const tc of msg.tool_calls) {
266
+ if (tc?.function?.arguments) {
267
+ try {
268
+ // function.arguments is a JSON string in OpenAI format
269
+ const args = typeof tc.function.arguments === 'string'
270
+ ? JSON.parse(tc.function.arguments)
271
+ : tc.function.arguments;
272
+ if (typeof args.file_path === 'string') paths.add(args.file_path);
273
+ if (typeof args.path === 'string') paths.add(args.path);
274
+ if (typeof args.command === 'string') {
275
+ extractPathsFromText(args.command, paths);
276
+ }
277
+ // Also scan the full arguments text for paths
278
+ if (typeof tc.function.arguments === 'string') {
279
+ extractPathsFromText(tc.function.arguments, paths);
280
+ }
281
+ } catch {
282
+ // If arguments isn't valid JSON, scan as text
283
+ if (typeof tc.function.arguments === 'string') {
284
+ extractPathsFromText(tc.function.arguments, paths);
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ // --- OpenAI function_call format (legacy, some tools still use it) ---
292
+ if (msg.function_call?.arguments) {
293
+ try {
294
+ const args = typeof msg.function_call.arguments === 'string'
295
+ ? JSON.parse(msg.function_call.arguments)
296
+ : msg.function_call.arguments;
297
+ if (typeof args.file_path === 'string') paths.add(args.file_path);
298
+ if (typeof args.path === 'string') paths.add(args.path);
299
+ } catch {
300
+ if (typeof msg.function_call.arguments === 'string') {
301
+ extractPathsFromText(msg.function_call.arguments, paths);
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ return Array.from(paths);
308
+ }
309
+
310
+ /**
311
+ * Calculate score adjustment from Graphify complexity signals.
312
+ * Capped at +35 (increased from +25 due to richer signals).
313
+ *
314
+ * @param {{ blast_radius: number, dependency_depth: number, test_coverage_pct: number, is_infrastructure: boolean, god_node_touched: boolean, community_count: number, cohesion: number }} signals
315
+ * @returns {{ adjustment: number, reasons: string[] }}
316
+ */
317
+ function scoreGraphSignals(signals) {
318
+ let adjustment = 0;
319
+ const reasons = [];
320
+
321
+ // Blast radius — how many files are affected
322
+ if (signals.blast_radius > 30) {
323
+ adjustment += 15;
324
+ reasons.push('blast_radius_high');
325
+ } else if (signals.blast_radius > 10) {
326
+ adjustment += 10;
327
+ reasons.push('blast_radius_medium');
328
+ } else if (signals.blast_radius > 5) {
329
+ adjustment += 5;
330
+ reasons.push('blast_radius_low');
331
+ }
332
+
333
+ // Dependency depth — deep call chains are harder to reason about
334
+ if (signals.dependency_depth > 4) {
335
+ adjustment += 5;
336
+ reasons.push('deep_dependencies');
337
+ }
338
+
339
+ // Infrastructure files — config/CI/deploy changes are high-risk
340
+ if (signals.is_infrastructure) {
341
+ adjustment += 10;
342
+ reasons.push('infrastructure_file');
343
+ }
344
+
345
+ // Low test coverage — changes in untested areas are riskier
346
+ if (signals.test_coverage_pct < 30) {
347
+ adjustment += 5;
348
+ reasons.push('low_test_coverage');
349
+ }
350
+
351
+ // God node touched — editing a hub class that many things depend on
352
+ if (signals.god_node_touched) {
353
+ adjustment += 10;
354
+ reasons.push('god_node_touched');
355
+ }
356
+
357
+ // Low community cohesion — loosely coupled code is harder to change safely
358
+ if (typeof signals.cohesion === 'number' && signals.cohesion < 0.15 && signals.community_count > 1) {
359
+ adjustment += 5;
360
+ reasons.push('low_community_cohesion');
361
+ }
362
+
363
+ return { adjustment: Math.min(adjustment, 35), reasons };
364
+ }
365
+
181
366
  // ============================================================================
182
367
  // CORE ANALYSIS FUNCTIONS
183
368
  // ============================================================================
@@ -546,7 +731,7 @@ function getThreshold() {
546
731
  * @param {Object} options - Analysis options
547
732
  * @returns {Object} Complexity analysis result
548
733
  */
549
- function analyzeComplexity(payload, options = {}) {
734
+ async function analyzeComplexity(payload, options = {}) {
550
735
  const content = extractContent(payload);
551
736
  const messageCount = payload?.messages?.length ?? 0;
552
737
  const useWeighted = options.weighted ?? config.routing?.weightedScoring ?? false;
@@ -568,7 +753,7 @@ function analyzeComplexity(payload, options = {}) {
568
753
  recommendation = weighted.score >= threshold ? 'cloud' : 'local';
569
754
  }
570
755
 
571
- return {
756
+ const result = {
572
757
  score: weighted.score,
573
758
  threshold,
574
759
  mode: 'weighted',
@@ -578,7 +763,39 @@ function analyzeComplexity(payload, options = {}) {
578
763
  meta: weighted.meta,
579
764
  forceReason: taskTypeResult.reason?.startsWith('force_') ? taskTypeResult.reason : null,
580
765
  content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
766
+ graphSignals: null,
581
767
  };
768
+
769
+ // Phase 5: Structural Analysis (code-review-graph, optional)
770
+ try {
771
+ const filePaths = extractFilePaths(payload);
772
+ const graphOpts = { filePaths, workspace: options?.workspace };
773
+ const graphAvailable = await codeGraph.isAvailable(graphOpts);
774
+ if (graphAvailable && filePaths.length > 0) {
775
+ const signals = await codeGraph.getComplexitySignals(filePaths, graphOpts);
776
+ if (signals) {
777
+ const { adjustment, reasons } = scoreGraphSignals(signals);
778
+ result.score = Math.min(result.score + adjustment, 100);
779
+ result.graphSignals = { ...signals, adjustment, reasons };
780
+
781
+ // Re-evaluate recommendation with adjusted score
782
+ if (!result.forceReason) {
783
+ result.recommendation = result.score >= threshold ? 'cloud' : 'local';
784
+ }
785
+
786
+ logger.debug({
787
+ filePaths: filePaths.slice(0, 5),
788
+ signals,
789
+ adjustment,
790
+ reasons,
791
+ }, '[complexity] Phase 5: graph signals applied');
792
+ }
793
+ }
794
+ } catch (err) {
795
+ logger.debug({ err: err.message }, '[complexity] Phase 5: code-graph query failed');
796
+ }
797
+
798
+ return result;
582
799
  }
583
800
 
584
801
  // Standard scoring (original logic)
@@ -600,7 +817,7 @@ function analyzeComplexity(payload, options = {}) {
600
817
 
601
818
  // Conversation length bonus (long conversations tend to be complex)
602
819
  const conversationBonus = messageCount > 10 ? 5 : (messageCount > 5 ? 2 : 0);
603
- const adjustedScore = Math.min(totalScore + conversationBonus, 100);
820
+ let adjustedScore = Math.min(totalScore + conversationBonus, 100);
604
821
 
605
822
  // Determine recommendation
606
823
  const threshold = getThreshold();
@@ -615,7 +832,7 @@ function analyzeComplexity(payload, options = {}) {
615
832
  recommendation = adjustedScore >= threshold ? 'cloud' : 'local';
616
833
  }
617
834
 
618
- return {
835
+ const result = {
619
836
  score: adjustedScore,
620
837
  threshold,
621
838
  mode,
@@ -629,7 +846,39 @@ function analyzeComplexity(payload, options = {}) {
629
846
  conversationBonus,
630
847
  },
631
848
  content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
849
+ graphSignals: null,
632
850
  };
851
+
852
+ // Phase 5: Structural Analysis (code-review-graph, optional)
853
+ try {
854
+ const filePaths = extractFilePaths(payload);
855
+ const graphOpts = { filePaths, workspace: options?.workspace };
856
+ const graphAvailable = await codeGraph.isAvailable(graphOpts);
857
+ if (graphAvailable && filePaths.length > 0) {
858
+ const signals = await codeGraph.getComplexitySignals(filePaths, graphOpts);
859
+ if (signals) {
860
+ const { adjustment, reasons } = scoreGraphSignals(signals);
861
+ result.score = Math.min(result.score + adjustment, 100);
862
+ result.graphSignals = { ...signals, adjustment, reasons };
863
+
864
+ // Re-evaluate recommendation with adjusted score
865
+ if (taskTypeResult.reason !== 'force_local' && taskTypeResult.reason !== 'force_cloud') {
866
+ result.recommendation = result.score >= threshold ? 'cloud' : 'local';
867
+ }
868
+
869
+ logger.debug({
870
+ filePaths: filePaths.slice(0, 5),
871
+ signals,
872
+ adjustment,
873
+ reasons,
874
+ }, '[complexity] Phase 5: graph signals applied');
875
+ }
876
+ }
877
+ } catch (err) {
878
+ logger.debug({ err: err.message }, '[complexity] Phase 5: code-graph query failed');
879
+ }
880
+
881
+ return result;
633
882
  }
634
883
 
635
884
  /**
@@ -784,6 +1033,10 @@ module.exports = {
784
1033
  analyzeWithEmbeddings,
785
1034
  getContentEmbedding,
786
1035
 
1036
+ // Phase 5: Structural Analysis (code-review-graph)
1037
+ extractFilePaths,
1038
+ scoreGraphSignals,
1039
+
787
1040
  // Constants (for testing)
788
1041
  PATTERNS,
789
1042
  ADVANCED_PATTERNS,
@@ -23,6 +23,11 @@ const { getAgenticDetector, AGENT_TYPES } = require('./agentic-detector');
23
23
  const { getModelTierSelector, TIER_DEFINITIONS } = require('./model-tiers');
24
24
  const { getCostOptimizer } = require('./cost-optimizer');
25
25
 
26
+ // Telemetry modules
27
+ const telemetry = require('./telemetry');
28
+ const { scoreResponseQuality } = require('./quality-scorer');
29
+ const { getLatencyTracker } = require('./latency-tracker');
30
+
26
31
  // Local providers
27
32
  const LOCAL_PROVIDERS = ['ollama', 'llamacpp', 'lmstudio'];
28
33
 
@@ -148,9 +153,9 @@ async function determineProviderSmart(payload, options = {}) {
148
153
  return decision;
149
154
  }
150
155
 
151
- // Full complexity analysis
156
+ // Full complexity analysis (pass workspace for code-graph integration)
152
157
  const useWeightedScoring = config.routing?.weightedScoring ?? false;
153
- const analysis = analyzeComplexity(payload, { weighted: useWeightedScoring });
158
+ const analysis = await analyzeComplexity(payload, { weighted: useWeightedScoring, workspace: options.workspace });
154
159
 
155
160
  // Phase 4: Optional embeddings adjustment
156
161
  let embeddingsResult = null;
@@ -381,4 +386,9 @@ module.exports = {
381
386
  getCostOptimizer,
382
387
  AGENT_TYPES,
383
388
  TIER_DEFINITIONS,
389
+
390
+ // Telemetry
391
+ telemetry,
392
+ scoreResponseQuality,
393
+ getLatencyTracker,
384
394
  };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Rolling Latency Tracker
3
+ *
4
+ * Tracks per-provider latency using circular buffers to provide
5
+ * P50/P95/P99 percentile statistics for routing decisions.
6
+ *
7
+ * @module routing/latency-tracker
8
+ */
9
+
10
+ const logger = require("../logger");
11
+
12
+ /** Size of the circular buffer per provider */
13
+ const BUFFER_SIZE = 200;
14
+
15
+ /** Minimum sample count before penalizeScore returns a meaningful value */
16
+ const MIN_SAMPLES = 10;
17
+
18
+ /**
19
+ * @typedef {Object} LatencyStats
20
+ * @property {number} p50 - 50th percentile latency (ms)
21
+ * @property {number} p95 - 95th percentile latency (ms)
22
+ * @property {number} p99 - 99th percentile latency (ms)
23
+ * @property {number} avg - Average latency (ms)
24
+ * @property {number} count - Total measurements recorded
25
+ * @property {number} lastUpdated - Timestamp of the last recorded measurement
26
+ */
27
+
28
+ class LatencyTracker {
29
+ constructor() {
30
+ /** @type {Map<string, { buffer: number[], index: number, count: number, lastUpdated: number }>} */
31
+ this._providers = new Map();
32
+ }
33
+
34
+ /**
35
+ * Record a latency measurement for a provider.
36
+ * @param {string} provider - Provider name (e.g. "databricks", "ollama")
37
+ * @param {number} latencyMs - Measured latency in milliseconds
38
+ */
39
+ record(provider, latencyMs) {
40
+ if (!provider || typeof latencyMs !== "number" || latencyMs < 0) {
41
+ return;
42
+ }
43
+
44
+ let entry = this._providers.get(provider);
45
+ if (!entry) {
46
+ entry = {
47
+ buffer: new Array(BUFFER_SIZE).fill(0),
48
+ index: 0,
49
+ count: 0,
50
+ lastUpdated: 0,
51
+ };
52
+ this._providers.set(provider, entry);
53
+ }
54
+
55
+ entry.buffer[entry.index] = latencyMs;
56
+ entry.index = (entry.index + 1) % BUFFER_SIZE;
57
+ entry.count += 1;
58
+ entry.lastUpdated = Date.now();
59
+ }
60
+
61
+ /**
62
+ * Get latency statistics for a specific provider.
63
+ * @param {string} provider - Provider name
64
+ * @returns {LatencyStats|null} Statistics or null if no data
65
+ */
66
+ getStats(provider) {
67
+ const entry = this._providers.get(provider);
68
+ if (!entry || entry.count === 0) {
69
+ return null;
70
+ }
71
+
72
+ const sampleCount = Math.min(entry.count, BUFFER_SIZE);
73
+ const samples = entry.buffer.slice(0, sampleCount);
74
+ const sorted = samples.slice().sort((a, b) => a - b);
75
+
76
+ const sum = sorted.reduce((acc, v) => acc + v, 0);
77
+
78
+ return {
79
+ p50: sorted[Math.floor(sampleCount * 0.5)],
80
+ p95: sorted[Math.floor(sampleCount * 0.95)],
81
+ p99: sorted[Math.floor(sampleCount * 0.99)],
82
+ avg: Math.round(sum / sampleCount),
83
+ count: entry.count,
84
+ lastUpdated: entry.lastUpdated,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Calculate a routing score penalty/bonus based on provider latency.
90
+ *
91
+ * Returns a value from -5 to +10 that can be added to a routing score:
92
+ * +10 if P95 > 10000ms (very slow, penalise by boosting complexity toward cloud)
93
+ * +5 if P95 > 5000ms
94
+ * -5 if P50 < 1000ms (fast, reward)
95
+ * 0 otherwise or if insufficient data
96
+ *
97
+ * @param {string} provider - Provider name
98
+ * @returns {number} Score adjustment (-5 to +10)
99
+ */
100
+ penalizeScore(provider) {
101
+ const stats = this.getStats(provider);
102
+ if (!stats || stats.count < MIN_SAMPLES) {
103
+ return 0;
104
+ }
105
+
106
+ if (stats.p95 > 10000) return 10;
107
+ if (stats.p95 > 5000) return 5;
108
+ if (stats.p50 < 1000) return -5;
109
+
110
+ return 0;
111
+ }
112
+
113
+ /**
114
+ * Get statistics for all tracked providers.
115
+ * @returns {Map<string, LatencyStats>}
116
+ */
117
+ getAllStats() {
118
+ const result = new Map();
119
+ for (const provider of this._providers.keys()) {
120
+ const stats = this.getStats(provider);
121
+ if (stats) {
122
+ result.set(provider, stats);
123
+ }
124
+ }
125
+ return result;
126
+ }
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Singleton
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /** @type {LatencyTracker|null} */
134
+ let instance = null;
135
+
136
+ /**
137
+ * Get the singleton LatencyTracker instance.
138
+ * @returns {LatencyTracker}
139
+ */
140
+ function getLatencyTracker() {
141
+ if (!instance) {
142
+ instance = new LatencyTracker();
143
+ logger.debug("LatencyTracker initialised");
144
+ }
145
+ return instance;
146
+ }
147
+
148
+ module.exports = { LatencyTracker, getLatencyTracker };
@@ -213,6 +213,8 @@ class ModelTierSelector {
213
213
  return config.zai?.model || null;
214
214
  case 'moonshot':
215
215
  return config.moonshot?.model || null;
216
+ case 'codex':
217
+ return config.codex?.model || null;
216
218
  case 'vertex':
217
219
  return config.vertex?.model || null;
218
220
  case 'databricks':
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Quality Scorer Module
3
+ *
4
+ * Lightweight heuristic scorer that evaluates response quality on a 0-100
5
+ * scale. Used by the telemetry system to detect over/under-provisioned
6
+ * routing decisions so they can be corrected over time.
7
+ *
8
+ * @module routing/quality-scorer
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} RequestContext
13
+ * @property {string} [tier] - Routing tier (SIMPLE, MODERATE, COMPLEX, REASONING)
14
+ * @property {boolean} [hasTools] - Whether the original request included tools
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} ResponseOutcome
19
+ * @property {number} [status_code] - HTTP status code
20
+ * @property {number} [output_tokens] - Tokens produced in the response
21
+ * @property {number} [tool_calls_made] - Number of tool calls executed
22
+ * @property {boolean} [was_fallback] - Whether a fallback provider was used
23
+ * @property {number} [retry_count] - Number of retries before success
24
+ * @property {string} [error_type] - Error classification if the request failed
25
+ * @property {number} [latency_ms] - End-to-end latency in milliseconds
26
+ */
27
+
28
+ /**
29
+ * Score the quality of a routed response.
30
+ *
31
+ * Starts at 50 and applies additive/subtractive heuristics.
32
+ * Final value is clamped to [0, 100].
33
+ *
34
+ * @param {RequestContext} request - Contextual information about the request
35
+ * @param {Object} _response - Raw response object (reserved for future use)
36
+ * @param {ResponseOutcome} outcome - Measured outcome metrics
37
+ * @returns {number} Quality score in range 0-100
38
+ */
39
+ function scoreResponseQuality(request, _response, outcome) {
40
+ let score = 50;
41
+
42
+ const {
43
+ status_code,
44
+ output_tokens,
45
+ tool_calls_made,
46
+ was_fallback,
47
+ retry_count,
48
+ error_type,
49
+ latency_ms,
50
+ } = outcome || {};
51
+
52
+ const tier = request?.tier;
53
+ const hasTools = request?.hasTools ?? false;
54
+
55
+ // --- Positive signals ---
56
+
57
+ if (status_code === 200) {
58
+ score += 10;
59
+ }
60
+
61
+ if (typeof output_tokens === "number" && output_tokens > 100) {
62
+ score += 5;
63
+ }
64
+
65
+ if (typeof tool_calls_made === "number" && tool_calls_made > 0 && hasTools) {
66
+ score += 10;
67
+ }
68
+
69
+ if (!was_fallback) {
70
+ score += 5;
71
+ }
72
+
73
+ if (retry_count === 0) {
74
+ score += 5;
75
+ }
76
+
77
+ // --- Negative signals ---
78
+
79
+ if (error_type) {
80
+ score -= 30;
81
+ }
82
+
83
+ if (was_fallback) {
84
+ score -= 10;
85
+ }
86
+
87
+ if (typeof retry_count === "number" && retry_count > 1) {
88
+ score -= 10;
89
+ }
90
+
91
+ if (typeof latency_ms === "number" && latency_ms > 30000) {
92
+ score -= 10;
93
+ }
94
+
95
+ if (typeof output_tokens === "number" && output_tokens < 20 && hasTools) {
96
+ score -= 15;
97
+ }
98
+
99
+ // --- Tier mismatch signals ---
100
+
101
+ if (tier === "REASONING" && typeof output_tokens === "number" && output_tokens < 50) {
102
+ score -= 10;
103
+ }
104
+
105
+ if (tier === "COMPLEX" && typeof latency_ms === "number" && latency_ms < 500) {
106
+ score -= 5;
107
+ }
108
+
109
+ // Clamp to [0, 100]
110
+ return Math.max(0, Math.min(100, score));
111
+ }
112
+
113
+ module.exports = { scoreResponseQuality };