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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "8.0.0",
3
+ "version": "9.0.1",
4
4
  "description": "Self-hosted Claude Code & Cursor proxy with Databricks,AWS BedRock,Azure adapters, openrouter, Ollama,llamacpp,LM Studio, workspace tooling, and MCP integration.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,13 +8,13 @@
8
8
  "lynkr-setup": "./scripts/setup.js"
9
9
  },
10
10
  "scripts": {
11
- "prestart": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom up -d headroom 2>/dev/null || echo 'Headroom skipped (disabled or Docker not running)'",
11
+ "prestart": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom up -d --build headroom 2>/dev/null || echo 'Headroom skipped (disabled or Docker not running)'",
12
12
  "start": "node index.js 2>&1 | npx pino-pretty --sync",
13
13
  "stop": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom down || echo 'Headroom skipped (disabled or Docker not running)'",
14
14
  "dev": "nodemon index.js",
15
15
  "lint": "eslint src index.js",
16
16
  "test": "npm run test:unit && npm run test:performance",
17
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
17
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js",
18
18
  "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
19
19
  "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
20
20
  "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -48,6 +48,7 @@
48
48
  "@babel/parser": "^7.29.0",
49
49
  "@babel/traverse": "^7.29.0",
50
50
  "@toon-format/toon": "^2.1.0",
51
+ "cockatiel": "^3.2.1",
51
52
  "compression": "^1.7.4",
52
53
  "diff": "^5.2.0",
53
54
  "dotenv": "^16.4.5",
@@ -27,6 +27,23 @@ const { IDE_SAFE_TOOLS } = require("../clients/standard-tools");
27
27
 
28
28
  const router = express.Router();
29
29
 
30
+ /**
31
+ * Resolve the model name for OpenAI responses.
32
+ * In OpenClaw mode, returns the actual provider/model from routing metadata.
33
+ */
34
+ function resolveResponseModel(resultBody, requestModel) {
35
+ if (config.openclaw?.enabled && resultBody?._routingMeta) {
36
+ const meta = resultBody._routingMeta;
37
+ if (meta.provider && meta.model) {
38
+ return `${meta.provider}/${meta.model}`;
39
+ }
40
+ if (meta.provider) {
41
+ return meta.provider;
42
+ }
43
+ }
44
+ return requestModel;
45
+ }
46
+
30
47
  /**
31
48
  * Client detection - identifies which AI coding tool is making the request
32
49
  * @param {Object} headers - Request headers
@@ -126,6 +143,13 @@ const CLIENT_TOOL_MAPPINGS = {
126
143
  mapArgs: (a) => ({
127
144
  query: a.query || ""
128
145
  })
146
+ },
147
+ "WebAgent": {
148
+ name: "web_agent",
149
+ mapArgs: (a) => ({
150
+ url: a.url || "",
151
+ goal: a.goal || ""
152
+ })
129
153
  }
130
154
  },
131
155
 
@@ -181,6 +205,13 @@ const CLIENT_TOOL_MAPPINGS = {
181
205
  path: a.path || a.directory || ".",
182
206
  recursive: false
183
207
  })
208
+ },
209
+ "WebAgent": {
210
+ name: "web_agent",
211
+ mapArgs: (a) => ({
212
+ url: a.url || "",
213
+ goal: a.goal || ""
214
+ })
184
215
  }
185
216
  },
186
217
 
@@ -237,6 +268,13 @@ const CLIENT_TOOL_MAPPINGS = {
237
268
  path: a.path || a.directory || ".",
238
269
  recursive: false
239
270
  })
271
+ },
272
+ "WebAgent": {
273
+ name: "web_agent",
274
+ mapArgs: (a) => ({
275
+ url: a.url || "",
276
+ goal: a.goal || ""
277
+ })
240
278
  }
241
279
  },
242
280
 
@@ -287,6 +325,13 @@ const CLIENT_TOOL_MAPPINGS = {
287
325
  mapArgs: (a) => ({
288
326
  filepath: a.path || a.directory || "."
289
327
  })
328
+ },
329
+ "WebAgent": {
330
+ name: "web_agent",
331
+ mapArgs: (a) => ({
332
+ url: a.url || "",
333
+ goal: a.goal || ""
334
+ })
290
335
  }
291
336
  }
292
337
  };
@@ -406,8 +451,10 @@ router.post("/chat/completions", async (req, res) => {
406
451
  // that have a mapping in CLIENT_TOOL_MAPPINGS — this ensures clients like
407
452
  // Codex don't see tools they can't handle (Task, WebFetch, NotebookEdit)
408
453
  // while Claude Code (unknown client) gets the full IDE_SAFE_TOOLS set.
454
+ // Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
409
455
  const clientType = detectClient(req.headers);
410
- if (!anthropicRequest.tools || anthropicRequest.tools.length === 0) {
456
+ const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
457
+ if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
411
458
  const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
412
459
  const clientTools = clientMappings
413
460
  ? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
@@ -469,7 +516,8 @@ router.post("/chat/completions", async (req, res) => {
469
516
  }
470
517
 
471
518
  // Convert to OpenAI format
472
- const openaiResponse = convertAnthropicToOpenAI(result.body, req.body.model);
519
+ const streamModel = resolveResponseModel(result.body, req.body.model);
520
+ const openaiResponse = convertAnthropicToOpenAI(result.body, streamModel);
473
521
 
474
522
  // Debug: Log what we're about to stream
475
523
  logger.debug({
@@ -509,7 +557,7 @@ router.post("/chat/completions", async (req, res) => {
509
557
  id: openaiResponse.id,
510
558
  object: "chat.completion.chunk",
511
559
  created: openaiResponse.created,
512
- model: req.body.model,
560
+ model: streamModel,
513
561
  system_fingerprint: "fp_lynkr",
514
562
  choices: [{
515
563
  index: 0,
@@ -531,7 +579,7 @@ router.post("/chat/completions", async (req, res) => {
531
579
  id: openaiResponse.id,
532
580
  object: "chat.completion.chunk",
533
581
  created: openaiResponse.created,
534
- model: req.body.model,
582
+ model: streamModel,
535
583
  system_fingerprint: "fp_lynkr",
536
584
  choices: [{
537
585
  index: 0,
@@ -551,7 +599,7 @@ router.post("/chat/completions", async (req, res) => {
551
599
  id: openaiResponse.id,
552
600
  object: "chat.completion.chunk",
553
601
  created: openaiResponse.created,
554
- model: req.body.model,
602
+ model: streamModel,
555
603
  choices: [{
556
604
  index: 0,
557
605
  delta: {
@@ -578,7 +626,7 @@ router.post("/chat/completions", async (req, res) => {
578
626
  id: openaiResponse.id,
579
627
  object: "chat.completion.chunk",
580
628
  created: openaiResponse.created,
581
- model: req.body.model,
629
+ model: streamModel,
582
630
  system_fingerprint: "fp_lynkr",
583
631
  choices: [{
584
632
  index: 0,
@@ -649,7 +697,7 @@ router.post("/chat/completions", async (req, res) => {
649
697
  }, "Orchestrator result structure");
650
698
 
651
699
  // Convert Anthropic response to OpenAI format
652
- const openaiResponse = convertAnthropicToOpenAI(result.body, req.body.model);
700
+ const openaiResponse = convertAnthropicToOpenAI(result.body, resolveResponseModel(result.body, req.body.model));
653
701
 
654
702
  // Map tool names for known IDE clients
655
703
  if (clientType !== "unknown" && openaiResponse.choices?.[0]?.message?.tool_calls?.length > 0) {
@@ -1450,8 +1498,10 @@ router.post("/responses", async (req, res) => {
1450
1498
  }, "After Chat→Anthropic conversion");
1451
1499
 
1452
1500
  // Inject tools if client didn't send any (same two-layer filtering as chat/completions).
1501
+ // Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
1453
1502
  const clientType = detectClient(req.headers);
1454
- if (!anthropicRequest.tools || anthropicRequest.tools.length === 0) {
1503
+ const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
1504
+ if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
1455
1505
  const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
1456
1506
  const clientTools = clientMappings
1457
1507
  ? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
@@ -1503,7 +1553,8 @@ router.post("/responses", async (req, res) => {
1503
1553
  }, "=== ORCHESTRATOR RESULT FOR RESPONSES API ===");
1504
1554
 
1505
1555
  // Convert back: Anthropic → OpenAI → Responses
1506
- const chatResponse = convertAnthropicToOpenAI(result.body, req.body.model);
1556
+ const responsesModel = resolveResponseModel(result.body, req.body.model);
1557
+ const chatResponse = convertAnthropicToOpenAI(result.body, responsesModel);
1507
1558
 
1508
1559
  logger.debug({
1509
1560
  chatContent: chatResponse.choices?.[0]?.message?.content?.substring(0, 200),
@@ -1565,7 +1616,7 @@ router.post("/responses", async (req, res) => {
1565
1616
  object: "response",
1566
1617
  status: "in_progress",
1567
1618
  created_at: createdAt,
1568
- model: req.body.model,
1619
+ model: responsesModel,
1569
1620
  output: [],
1570
1621
  usage: null
1571
1622
  },
@@ -1582,7 +1633,7 @@ router.post("/responses", async (req, res) => {
1582
1633
  object: "response",
1583
1634
  status: "in_progress",
1584
1635
  created_at: createdAt,
1585
- model: req.body.model,
1636
+ model: responsesModel,
1586
1637
  output: [],
1587
1638
  usage: null
1588
1639
  },
@@ -1761,7 +1812,7 @@ router.post("/responses", async (req, res) => {
1761
1812
  object: "response",
1762
1813
  status: "completed",
1763
1814
  created_at: createdAt,
1764
- model: req.body.model,
1815
+ model: responsesModel,
1765
1816
  output: outputItems,
1766
1817
  usage: {
1767
1818
  input_tokens: responsesResponse.usage?.prompt_tokens || 0,
@@ -1814,7 +1865,7 @@ router.post("/responses", async (req, res) => {
1814
1865
  });
1815
1866
 
1816
1867
  // Convert back: Anthropic → OpenAI → Responses
1817
- const chatResponse = convertAnthropicToOpenAI(result.body, req.body.model);
1868
+ const chatResponse = convertAnthropicToOpenAI(result.body, resolveResponseModel(result.body, req.body.model));
1818
1869
  const responsesResponse = convertChatToResponses(chatResponse);
1819
1870
 
1820
1871
  logger.info({
@@ -415,8 +415,8 @@ router.get("/health/providers", (req, res) => {
415
415
  // Get circuit breaker states
416
416
  const circuitBreakerStates = {};
417
417
  const allBreakers = registry.getAll();
418
- for (const [name, breaker] of Object.entries(allBreakers)) {
419
- circuitBreakerStates[name] = breaker.state;
418
+ for (const breaker of allBreakers) {
419
+ circuitBreakerStates[breaker.name] = breaker.state;
420
420
  }
421
421
 
422
422
  // Get all provider health
@@ -452,7 +452,8 @@ router.get("/health/providers/:name", (req, res) => {
452
452
 
453
453
  // Get circuit breaker state for this provider
454
454
  const allBreakers = registry.getAll();
455
- const circuitState = allBreakers[providerName]?.state || "CLOSED";
455
+ const breakerState = allBreakers.find((b) => b.name === providerName);
456
+ const circuitState = breakerState?.state || "CLOSED";
456
457
 
457
458
  // Get detailed metrics
458
459
  const metrics = healthTracker.getProviderMetrics(providerName);
@@ -477,4 +478,171 @@ router.get("/health/providers/:name", (req, res) => {
477
478
  }
478
479
  });
479
480
 
481
+ // ============================================================================
482
+ // Routing Telemetry Endpoints
483
+ // ============================================================================
484
+
485
+ const telemetry = require("../routing/telemetry");
486
+ const { getLatencyTracker } = require("../routing/latency-tracker");
487
+
488
+ /**
489
+ * GET /v1/routing/stats
490
+ *
491
+ * Aggregated routing telemetry statistics.
492
+ */
493
+ router.get("/routing/stats", (req, res) => {
494
+ try {
495
+ const since = req.query.since ? Number(req.query.since) : undefined;
496
+ const until = req.query.until ? Number(req.query.until) : undefined;
497
+ const stats = telemetry.getStats({ since, until });
498
+
499
+ if (!stats) {
500
+ return res.json({ object: "routing_stats", data: null, message: "No telemetry data available" });
501
+ }
502
+
503
+ // Merge latency tracker percentiles
504
+ const latencyTracker = getLatencyTracker();
505
+ const latencyStats = {};
506
+ for (const [provider, pStats] of latencyTracker.getAllStats()) {
507
+ latencyStats[provider] = pStats;
508
+ }
509
+
510
+ res.json({
511
+ object: "routing_stats",
512
+ data: { ...stats, latencyPercentiles: latencyStats },
513
+ timestamp: new Date().toISOString(),
514
+ });
515
+ } catch (error) {
516
+ logger.error({ error: error.message }, "Error getting routing stats");
517
+ res.status(500).json({ error: { type: "server_error", message: error.message } });
518
+ }
519
+ });
520
+
521
+ /**
522
+ * GET /v1/routing/stats/:provider
523
+ *
524
+ * Per-provider routing telemetry.
525
+ */
526
+ router.get("/routing/stats/:provider", (req, res) => {
527
+ try {
528
+ const provider = req.params.provider.toLowerCase();
529
+ const since = req.query.since ? Number(req.query.since) : undefined;
530
+ const stats = telemetry.getProviderStats(provider, { since });
531
+
532
+ if (!stats) {
533
+ return res.json({ object: "provider_routing_stats", data: null, message: `No data for ${provider}` });
534
+ }
535
+
536
+ const latencyTracker = getLatencyTracker();
537
+ const latency = latencyTracker.getStats(provider);
538
+
539
+ res.json({
540
+ object: "provider_routing_stats",
541
+ provider,
542
+ data: { ...stats, latency },
543
+ timestamp: new Date().toISOString(),
544
+ });
545
+ } catch (error) {
546
+ logger.error({ error: error.message }, "Error getting provider routing stats");
547
+ res.status(500).json({ error: { type: "server_error", message: error.message } });
548
+ }
549
+ });
550
+
551
+ /**
552
+ * GET /v1/routing/telemetry
553
+ *
554
+ * Raw telemetry records (most recent first).
555
+ */
556
+ router.get("/routing/telemetry", (req, res) => {
557
+ try {
558
+ const filters = {
559
+ provider: req.query.provider,
560
+ tier: req.query.tier,
561
+ since: req.query.since ? Number(req.query.since) : undefined,
562
+ limit: req.query.limit ? Number(req.query.limit) : 100,
563
+ };
564
+ const records = telemetry.query(filters);
565
+
566
+ res.json({ object: "telemetry_list", data: records, count: records.length });
567
+ } catch (error) {
568
+ logger.error({ error: error.message }, "Error querying telemetry");
569
+ res.status(500).json({ error: { type: "server_error", message: error.message } });
570
+ }
571
+ });
572
+
573
+ /**
574
+ * GET /v1/routing/accuracy
575
+ *
576
+ * Routing accuracy analysis (over/under-provisioned percentages).
577
+ */
578
+ router.get("/routing/accuracy", (req, res) => {
579
+ try {
580
+ const since = req.query.since ? Number(req.query.since) : undefined;
581
+ const accuracy = telemetry.getRoutingAccuracy({ since });
582
+
583
+ res.json({
584
+ object: "routing_accuracy",
585
+ data: accuracy,
586
+ timestamp: new Date().toISOString(),
587
+ });
588
+ } catch (error) {
589
+ logger.error({ error: error.message }, "Error getting routing accuracy");
590
+ res.status(500).json({ error: { type: "server_error", message: error.message } });
591
+ }
592
+ });
593
+
594
+ // ── Admin: Hot Reload Config + Reset Circuit Breakers ─────────────────
595
+
596
+ router.post("/admin/reload", (req, res) => {
597
+ try {
598
+ config.reloadConfig();
599
+ const registry = getCircuitBreakerRegistry();
600
+ const states = registry.getAll();
601
+ res.json({
602
+ object: "admin_reload",
603
+ status: "ok",
604
+ reloaded: [
605
+ "modelTiers",
606
+ "apiKeys",
607
+ "providerSettings",
608
+ "circuitBreakers",
609
+ ],
610
+ circuitBreakers: states.map(s => ({ name: s.name, state: s.state })),
611
+ timestamp: new Date().toISOString(),
612
+ });
613
+ } catch (error) {
614
+ logger.error({ error: error.message }, "Admin reload failed");
615
+ res.status(500).json({ error: { type: "server_error", message: error.message } });
616
+ }
617
+ });
618
+
619
+ router.post("/admin/circuit-breakers/reset", (req, res) => {
620
+ try {
621
+ const provider = req.query.provider || req.body?.provider;
622
+ const registry = getCircuitBreakerRegistry();
623
+
624
+ if (provider) {
625
+ const breaker = registry.breakers?.get(provider);
626
+ if (breaker) {
627
+ breaker.reset();
628
+ res.json({ object: "circuit_breaker_reset", provider, status: "reset" });
629
+ } else {
630
+ res.status(404).json({ error: { message: `No circuit breaker for provider: ${provider}` } });
631
+ }
632
+ } else {
633
+ registry.resetAll();
634
+ const states = registry.getAll();
635
+ res.json({
636
+ object: "circuit_breaker_reset",
637
+ provider: "all",
638
+ status: "reset",
639
+ breakers: states.map(s => ({ name: s.name, state: s.state })),
640
+ });
641
+ }
642
+ } catch (error) {
643
+ logger.error({ error: error.message }, "Circuit breaker reset failed");
644
+ res.status(500).json({ error: { type: "server_error", message: error.message } });
645
+ }
646
+ });
647
+
480
648
  module.exports = router;
package/src/api/router.js CHANGED
@@ -136,7 +136,7 @@ router.post("/routing/analyze", async (req, res) => {
136
136
  const { getModelTierSelector } = require("../routing/model-tiers");
137
137
  const { getModelRegistry } = require("../routing/model-registry");
138
138
 
139
- const analysis = analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
139
+ const analysis = await analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
140
140
  const agentic = getAgenticDetector().detect(req.body);
141
141
  const selector = getModelTierSelector();
142
142
  const tier = selector.getTier(analysis.score);
@@ -210,13 +210,17 @@ router.post("/api/event_logging/batch", (req, res) => {
210
210
 
211
211
  router.post("/v1/messages", rateLimiter, async (req, res, next) => {
212
212
  try {
213
+ const { createTimer } = require("../utils/perf-timer");
214
+ const timer = createTimer("POST /v1/messages");
213
215
  metrics.recordRequest();
214
216
  // Support both query parameter (?stream=true) and body parameter ({"stream": true})
215
217
  const wantsStream = Boolean(req.query?.stream === 'true' || req.body?.stream);
216
218
  const hasTools = Array.isArray(req.body?.tools) && req.body.tools.length > 0;
219
+ timer.mark("parseRequest");
217
220
 
218
221
  // Analyze complexity for routing headers (Phase 3)
219
- const complexity = analyzeComplexity(req.body);
222
+ const complexity = await analyzeComplexity(req.body);
223
+ timer.mark("analyzeComplexity");
220
224
  let preRouteProvider = 'cloud';
221
225
  if (complexity.recommendation === 'local') {
222
226
  // Use tier config to determine actual provider instead of hardcoding 'ollama'
@@ -430,6 +434,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
430
434
  }
431
435
 
432
436
  // Non-streaming or tool-based requests (buffered path)
437
+ timer.mark("preProcessMessage");
433
438
  const result = await processMessage({
434
439
  payload: req.body,
435
440
  headers: req.headers,
@@ -440,6 +445,8 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
440
445
  maxDurationMs: req.body?.max_duration_ms,
441
446
  },
442
447
  });
448
+ timer.mark("processMessage");
449
+ timer.done();
443
450
 
444
451
  // Legacy streaming wrapper (for tool-based requests that requested streaming)
445
452
  if (wantsStream && hasTools) {