lynkr 9.10.0 → 9.11.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.
Files changed (38) hide show
  1. package/bin/lynkr-init.js +7 -0
  2. package/config/model-tiers.json +5 -5
  3. package/package.json +3 -3
  4. package/public/dashboard.html +29 -0
  5. package/src/api/router.js +34 -0
  6. package/src/clients/databricks.js +120 -65
  7. package/src/clients/gpt-utils.js +8 -1
  8. package/src/clients/ollama-utils.js +19 -10
  9. package/src/clients/prompt-cache-injection.js +93 -30
  10. package/src/config/index.js +79 -0
  11. package/src/context/compression.js +27 -8
  12. package/src/context/distill.js +2 -1
  13. package/src/dashboard/api.js +5 -1
  14. package/src/memory/distiller.js +350 -0
  15. package/src/memory/extractor.js +0 -8
  16. package/src/memory/format.js +17 -2
  17. package/src/memory/index.js +10 -0
  18. package/src/memory/retriever.js +7 -2
  19. package/src/memory/skills-cache.js +180 -0
  20. package/src/memory/tencentdb-launcher.js +459 -0
  21. package/src/memory/wiki.js +171 -0
  22. package/src/orchestrator/index.js +85 -2
  23. package/src/prompts/system.js +20 -6
  24. package/src/routing/affinity-store.js +68 -1
  25. package/src/routing/agentic-detector.js +9 -1
  26. package/src/routing/cache-economics.js +123 -0
  27. package/src/routing/cache-switch-cost.js +194 -0
  28. package/src/routing/calibration.js +8 -1
  29. package/src/routing/complexity-analyzer.js +7 -1
  30. package/src/routing/context-compressor.js +170 -0
  31. package/src/routing/difficulty-classifier.js +115 -13
  32. package/src/routing/index.js +161 -53
  33. package/src/routing/model-registry.js +35 -10
  34. package/src/routing/session-affinity.js +60 -0
  35. package/src/routing/telemetry.js +139 -2
  36. package/src/routing/verifier.js +41 -2
  37. package/src/server.js +36 -3
  38. package/src/tools/code-graph.js +6 -4
package/bin/lynkr-init.js CHANGED
@@ -236,6 +236,13 @@ const BASELINE_ENV = {
236
236
  HEADROOM_CCR: 'true',
237
237
  HEADROOM_CCR_TTL: '300',
238
238
 
239
+ // ── TencentDB-Agent-Memory sidecar (team memory hub) ──────────────────
240
+ // Launches agentmemory/memory-core + memory-hub containers on
241
+ // `lynkr start` (Panel UI :8125). First start pulls two Docker Hub
242
+ // images; skips gracefully when Docker isn't running. See .env.example
243
+ // for all knobs.
244
+ TENCENTDB_MEMORY_ENABLED: 'true',
245
+
239
246
  // ── Memory + token tracking ───────────────────────────────────────────
240
247
  MEMORY_ENABLED: 'true',
241
248
  MEMORY_RETRIEVAL_LIMIT: '5',
@@ -53,7 +53,7 @@
53
53
  "GLM-4-Flash"
54
54
  ],
55
55
  "moonshot": [
56
- "kimi-k2-turbo-preview"
56
+ "kimi-k2.6"
57
57
  ]
58
58
  }
59
59
  },
@@ -108,7 +108,7 @@
108
108
  "GLM-4.7"
109
109
  ],
110
110
  "moonshot": [
111
- "kimi-k2-turbo-preview"
111
+ "kimi-k2.6"
112
112
  ]
113
113
  }
114
114
  },
@@ -157,7 +157,7 @@
157
157
  "GLM-4.7"
158
158
  ],
159
159
  "moonshot": [
160
- "kimi-k2-turbo-preview"
160
+ "kimi-k2.6"
161
161
  ]
162
162
  }
163
163
  },
@@ -203,8 +203,8 @@
203
203
  "deepseek-r1"
204
204
  ],
205
205
  "moonshot": [
206
- "kimi-k2-thinking",
207
- "kimi-k2-turbo-preview"
206
+ "kimi-k3",
207
+ "kimi-k2.6"
208
208
  ]
209
209
  }
210
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.10.0",
3
+ "version": "9.11.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "install.sh",
@@ -36,8 +36,8 @@
36
36
  "dev": "nodemon index.js",
37
37
  "lint": "eslint src index.js",
38
38
  "test": "npm run test:unit && npm run test:performance",
39
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.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/gcf-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/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js",
40
- "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",
39
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.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/gcf-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/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js",
40
+ "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 test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
41
41
  "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",
42
42
  "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",
43
43
  "test:benchmark": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-benchmark.js",
@@ -493,6 +493,35 @@ const App = {
493
493
  ` : emptyState('No routing data for last 24h')}
494
494
  `, 'mb-6')}
495
495
 
496
+ <!-- Cache Economics (cache-aware routing receipt) -->
497
+ ${card(`
498
+ <h3 class="text-sm font-semibold text-slate-300 mb-4">Cache Economics (last ${d.window || '24h'})</h3>
499
+ ${d.cacheEconomics && d.cacheEconomics.decisions > 0 ? `
500
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
501
+ <div class="text-center">
502
+ <p class="text-2xl font-bold text-green-400">${fmt.usd(d.cacheEconomics.totalDollarsSaved)}</p>
503
+ <p class="text-xs text-slate-500 mt-1">Cache $ saved by routing</p>
504
+ <p class="text-xs text-slate-600">(holds + cleared switches)</p>
505
+ </div>
506
+ <div class="text-center">
507
+ <p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.holds)}</p>
508
+ <p class="text-xs text-slate-500 mt-1">Switches held</p>
509
+ <p class="text-xs text-slate-600">(warm prefix protected)</p>
510
+ </div>
511
+ <div class="text-center">
512
+ <p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.switches)}</p>
513
+ <p class="text-xs text-slate-500 mt-1">Switches cleared</p>
514
+ <p class="text-xs text-slate-600">(break-even ≤ remaining turns)</p>
515
+ </div>
516
+ <div class="text-center">
517
+ <p class="text-2xl font-bold text-amber-400">${fmt.usd(d.cacheEconomics.dollarsSavedByHolds)}</p>
518
+ <p class="text-xs text-slate-500 mt-1">Saved by holding pins</p>
519
+ <p class="text-xs text-slate-600">(cache re-writes avoided)</p>
520
+ </div>
521
+ </div>
522
+ ` : emptyState('No gated switch decisions yet — appears once a pinned session hits a downgrade decision')}
523
+ `, 'mb-6')}
524
+
496
525
  <!-- Provider Stats -->
497
526
  ${card(`
498
527
  <h3 class="text-sm font-semibold text-slate-300 mb-3">Provider Stats (last ${d.window || '24h'})</h3>
package/src/api/router.js CHANGED
@@ -579,6 +579,30 @@ function _buildStreamBadge(tier) {
579
579
  return `*[Lynkr] ${tier.tier || "—"} → ${tier.model || "—"} (${tier.provider || "—"}) · score ${tier.score ?? "—"}${pin}*\n\n`;
580
580
  }
581
581
 
582
+ /**
583
+ * Terminal upstream errors carry no content array — without this, the synth
584
+ * SSE writers render an empty message and the turn goes silent in the TUI
585
+ * (only the routing badge shows). Returns visible-text content blocks for an
586
+ * error result, using the fallback chain's failure trail when the provider
587
+ * layer recorded one (so "moonshot: quota exhausted" isn't hidden behind the
588
+ * last rung's unrelated error), or null for normal responses.
589
+ */
590
+ function _buildFailureBlocks(result, msg) {
591
+ if (result.terminationReason !== "api_error" && Array.isArray(msg?.content)) return null;
592
+ const trail = Array.isArray(msg?._lynkr_failure_trail) ? msg._lynkr_failure_trail : null;
593
+ const lastErr = msg?.error?.message || msg?.message
594
+ || (typeof msg === "string" ? msg : JSON.stringify(msg ?? {}).slice(0, 200));
595
+ const trailText = (trail || [String(lastErr)]).map((t) => ` · ${t}`).join("\n");
596
+ const quotaHit = result.status === 429 || /rate.?limit|quota|TPD|TPM/i.test(trailText);
597
+ return [{
598
+ type: "text",
599
+ text:
600
+ `⚠ [Lynkr] ${quotaHit ? "Provider quota exhausted — no fallback could serve this request" : "No provider could serve this request"} (HTTP ${result.status ?? "?"}).\n\n` +
601
+ `${trailText}\n` +
602
+ (quotaHit ? `\nAll fallback candidates failed after the quota hit. Wait for the quota window to reset or repoint the affected tier in .env.\n` : ""),
603
+ }];
604
+ }
605
+
582
606
  /**
583
607
  * Scan the entire payload for tool_use or tool_result content blocks.
584
608
  * Unlike session-affinity.payloadHasToolHistory (which checks only the last
@@ -1611,6 +1635,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1611
1635
  // tool_use with no tool_use block).
1612
1636
  let contentBlocks = (msg.content || []).slice();
1613
1637
 
1638
+ const failureBlocks = _buildFailureBlocks(result, msg);
1639
+ if (failureBlocks) contentBlocks = failureBlocks;
1640
+
1614
1641
  // When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
1615
1642
  // users can see which tier/provider/model handled the request inside
1616
1643
  // Claude Code's TUI (TUI only renders content blocks; unknown top-level
@@ -1807,6 +1834,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1807
1834
  // tool_use with no tool_use block).
1808
1835
  let contentBlocks = (msg.content || []).slice();
1809
1836
 
1837
+ const failureBlocks = _buildFailureBlocks(result, msg);
1838
+ if (failureBlocks) contentBlocks = failureBlocks;
1839
+
1810
1840
  // When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
1811
1841
  // users can see which tier/provider/model handled the request inside
1812
1842
  // Claude Code's TUI (TUI only renders content blocks; unknown top-level
@@ -1969,6 +1999,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1969
1999
  }
1970
2000
 
1971
2001
  metrics.recordResponse(result.status);
2002
+ // finalBody is usually a JSON string; Express defaults string bodies to
2003
+ // text/html, which Claude Code rejects as a malformed 200. Only default —
2004
+ // an upstream content-type copied from result.headers above still wins.
2005
+ if (!res.getHeader("Content-Type")) res.type("application/json");
1972
2006
  res.status(result.status).send(finalBody);
1973
2007
  } catch (error) {
1974
2008
  next(error);
@@ -560,9 +560,20 @@ async function invokeOllama(body, incomingHeaders = {}) {
560
560
  if (body.tool_choice) ollamaBody.tool_choice = body.tool_choice;
561
561
  if (body.metadata) ollamaBody.metadata = body.metadata;
562
562
 
563
- // Tools (already Anthropic format — no conversion needed)
563
+ // Tools: normalize to Anthropic shape. Callers that stayed OpenAI-format
564
+ // upstream (OpenWorker, aisuite, any client hitting /v1/chat/completions)
565
+ // otherwise ship {type:"function", function:{...}} which Ollama's
566
+ // /v1/messages endpoint rejects with 400 "can't find closing '}' symbol".
564
567
  if (supportsTools && Array.isArray(toolsToSend) && toolsToSend.length > 0) {
565
- ollamaBody.tools = toolsToSend;
568
+ ollamaBody.tools = toolsToSend.map(tool =>
569
+ tool?.type === "function" && tool.function
570
+ ? {
571
+ name: tool.function.name,
572
+ description: tool.function.description || "",
573
+ input_schema: tool.function.parameters || { type: "object", properties: {} },
574
+ }
575
+ : tool
576
+ );
566
577
  }
567
578
 
568
579
  if (config.ollama.keepAlive !== undefined) {
@@ -1934,6 +1945,21 @@ async function invokeZai(body, incomingHeaders = {}) {
1934
1945
  }, "=== INJECTING STANDARD TOOLS (Z.AI Anthropic) ===");
1935
1946
  }
1936
1947
 
1948
+ // Normalize to Anthropic tool shape — the /anthropic/v1/messages endpoint
1949
+ // rejects OpenAI-nested tools ({type:"function", function:{name,...}}) with
1950
+ // 422 missing body.tools[0].name. Callers that stayed OpenAI-format upstream
1951
+ // (OpenWorker, aisuite, any client hitting /v1/chat/completions with an
1952
+ // unusual tool shape) get unwrapped here.
1953
+ zaiBody.tools = zaiBody.tools.map(tool =>
1954
+ tool?.type === "function" && tool.function
1955
+ ? {
1956
+ name: tool.function.name,
1957
+ description: tool.function.description || "",
1958
+ input_schema: tool.function.parameters || { type: "object", properties: {} },
1959
+ }
1960
+ : tool
1961
+ );
1962
+
1937
1963
  headers = {
1938
1964
  "Content-Type": "application/json",
1939
1965
  "x-api-key": config.zai.apiKey,
@@ -2019,13 +2045,17 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2019
2045
 
2020
2046
  // Model mapping: Anthropic names → Moonshot/Kimi names
2021
2047
  const modelMap = {
2022
- "claude-sonnet-4-5-20250929": "kimi-k2-turbo-preview",
2023
- "claude-sonnet-4-5": "kimi-k2-turbo-preview",
2024
- "claude-sonnet-4.5": "kimi-k2-turbo-preview",
2025
- "claude-3-5-sonnet": "kimi-k2-turbo-preview",
2026
- "claude-haiku-4-5-20251001": "kimi-k2-turbo-preview",
2027
- "claude-haiku-4-5": "kimi-k2-turbo-preview",
2028
- "claude-3-haiku": "kimi-k2-turbo-preview",
2048
+ "claude-sonnet-4-5-20250929": "kimi-k2.6",
2049
+ "claude-sonnet-4-5": "kimi-k2.6",
2050
+ "claude-sonnet-4.5": "kimi-k2.6",
2051
+ "claude-3-5-sonnet": "kimi-k2.6",
2052
+ "claude-haiku-4-5-20251001": "kimi-k2.6",
2053
+ "claude-haiku-4-5": "kimi-k2.6",
2054
+ "claude-3-haiku": "kimi-k2.6",
2055
+ // Retired ids (gone from /v1/models as of 2026-07: "Not found the model")
2056
+ // that older tier configs may still reference.
2057
+ "kimi-k2-turbo-preview": "kimi-k2.6",
2058
+ "kimi-k2-thinking": "kimi-k3",
2029
2059
  // moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
2030
2060
  // context-size pass fails on large tool-bearing payloads). Remap to a
2031
2061
  // fixed model that's broadly available on api.moonshot.ai.
@@ -2033,7 +2063,12 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2033
2063
  };
2034
2064
 
2035
2065
  const requestedModel = body._tierModel || body.model || config.moonshot.model;
2036
- let mappedModel = modelMap[requestedModel] || config.moonshot.model || "kimi-k2-turbo-preview";
2066
+ // Honor tier-selected Moonshot ids (e.g. TIER_REASONING=moonshot:kimi-k2.6)
2067
+ // instead of silently swapping in the .env default model.
2068
+ let mappedModel = modelMap[requestedModel]
2069
+ || (/^(kimi-|moonshot-v1)/i.test(requestedModel || "") ? requestedModel : null)
2070
+ || config.moonshot.model
2071
+ || "kimi-k3";
2037
2072
  // Guard against the deprecated auto model arriving via config too.
2038
2073
  if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
2039
2074
 
@@ -2047,18 +2082,18 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2047
2082
  messages.unshift({ role: "system", content: systemContent });
2048
2083
  }
2049
2084
 
2050
- // kimi-k2.x (k2.5 / k2.6 …) are thinking models that only accept
2051
- // temperature: 1 — any other value 400s with "invalid temperature".
2052
- const isKimiThinking = /^kimi-k2/i.test(mappedModel);
2085
+ // Every current kimi-k* model (k2.5, k2.6, k2.7-code, k3, …) pins sampling
2086
+ // params: temperature must be 1 — any other value 400s with "invalid
2087
+ // temperature: only 1 is allowed for this model" (probed live 2026-07-23).
2088
+ // Only the moonshot-v1-* models accept caller-supplied values.
2089
+ const isKimiPinned = /^kimi-k/i.test(mappedModel);
2053
2090
 
2054
2091
  const moonshotBody = {
2055
2092
  model: mappedModel,
2056
2093
  messages,
2057
2094
  max_tokens: body.max_tokens || 16384,
2058
- // kimi-k2.x thinking models pin sampling params: temperature must be 1
2059
- // and top_p must be 0.95 — any other value 400s.
2060
- temperature: isKimiThinking ? 1 : (body.temperature ?? 0.7),
2061
- top_p: isKimiThinking ? 0.95 : (body.top_p ?? 1.0),
2095
+ temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
2096
+ top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
2062
2097
  stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
2063
2098
  };
2064
2099
 
@@ -2678,6 +2713,43 @@ function stripLynkrBadges(messages) {
2678
2713
  return mutated ? out : messages;
2679
2714
  }
2680
2715
 
2716
+ // Single source of truth for provider dispatch. Both the initial call and the
2717
+ // fallback ladder MUST route through invokeProvider — the previous duplicated
2718
+ // if/else chains drifted (the fallback chain was missing ollama, bedrock,
2719
+ // lmstudio and codex), and unknown providers silently dialed databricks,
2720
+ // which under tier routing is a deliberate dead number (see .env note about
2721
+ // the 2026-07-07 self-loop incident). Add new providers HERE, nowhere else;
2722
+ // the registry-coverage unit test fails if this map and
2723
+ // SUPPORTED_MODEL_PROVIDERS fall out of sync.
2724
+ const PROVIDER_INVOKERS = {
2725
+ "azure-openai": invokeAzureOpenAI,
2726
+ "azure-anthropic": invokeAzureAnthropic,
2727
+ ollama: invokeOllama,
2728
+ openrouter: invokeOpenRouter,
2729
+ edenai: invokeEdenAI,
2730
+ openai: invokeOpenAI,
2731
+ llamacpp: invokeLlamaCpp,
2732
+ lmstudio: invokeLMStudio,
2733
+ bedrock: invokeBedrock,
2734
+ zai: invokeZai,
2735
+ vertex: invokeVertex,
2736
+ moonshot: invokeMoonshot,
2737
+ codex: invokeCodex,
2738
+ };
2739
+
2740
+ function invokeProvider(provider, body, incomingHeaders) {
2741
+ const invoke = PROVIDER_INVOKERS[provider];
2742
+ if (!invoke) {
2743
+ // databricks itself, or an unknown name. Loud, not silent: under tier
2744
+ // routing this path used to swallow misconfigured providers.
2745
+ if (provider !== "databricks") {
2746
+ logger.warn({ provider }, "No invoker registered for provider — defaulting to databricks");
2747
+ }
2748
+ return invokeDatabricks(body, incomingHeaders);
2749
+ }
2750
+ return invoke(body, incomingHeaders);
2751
+ }
2752
+
2681
2753
  async function invokeModel(body, options = {}) {
2682
2754
  const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
2683
2755
  const metricsCollector = getMetricsCollector();
@@ -2834,34 +2906,7 @@ async function invokeModel(body, options = {}) {
2834
2906
 
2835
2907
  try {
2836
2908
  const result = await breaker.execute(async () => {
2837
- if (initialProvider === "azure-openai") {
2838
- return await invokeAzureOpenAI(body, incomingHeaders);
2839
- } else if (initialProvider === "azure-anthropic") {
2840
- return await invokeAzureAnthropic(body, incomingHeaders);
2841
- } else if (initialProvider === "ollama") {
2842
- return await invokeOllama(body, incomingHeaders);
2843
- } else if (initialProvider === "openrouter") {
2844
- return await invokeOpenRouter(body, incomingHeaders);
2845
- } else if (initialProvider === "edenai") {
2846
- return await invokeEdenAI(body, incomingHeaders);
2847
- } else if (initialProvider === "openai") {
2848
- return await invokeOpenAI(body, incomingHeaders);
2849
- } else if (initialProvider === "llamacpp") {
2850
- return await invokeLlamaCpp(body, incomingHeaders);
2851
- } else if (initialProvider === "lmstudio") {
2852
- return await invokeLMStudio(body, incomingHeaders);
2853
- } else if (initialProvider === "bedrock") {
2854
- return await invokeBedrock(body, incomingHeaders);
2855
- } else if (initialProvider === "zai") {
2856
- return await invokeZai(body, incomingHeaders);
2857
- } else if (initialProvider === "vertex") {
2858
- return await invokeVertex(body, incomingHeaders);
2859
- } else if (initialProvider === "moonshot") {
2860
- return await invokeMoonshot(body, incomingHeaders);
2861
- } else if (initialProvider === "codex") {
2862
- return await invokeCodex(body, incomingHeaders);
2863
- }
2864
- return await invokeDatabricks(body, incomingHeaders);
2909
+ return await invokeProvider(initialProvider, body, incomingHeaders);
2865
2910
  });
2866
2911
 
2867
2912
  const latency = Date.now() - startTime;
@@ -2954,6 +2999,7 @@ async function invokeModel(body, options = {}) {
2954
2999
  candidates: routingResult.candidates ?? null,
2955
3000
  pinned: routingResult.pinned ? 1 : 0,
2956
3001
  switch_reason: routingResult.switch_reason ?? null,
3002
+ cache_decision: routingResult._cacheDecision ?? null,
2957
3003
  });
2958
3004
 
2959
3005
  // WS5.4 — feedback loop (success path).
@@ -3035,6 +3081,14 @@ async function invokeModel(body, options = {}) {
3035
3081
  healthTracker.recordFailure(initialProvider, err, err.status);
3036
3082
  getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
3037
3083
 
3084
+ // Accumulate every provider failure for this request so the terminal
3085
+ // error can tell the client WHY nothing answered (quota exhausted vs
3086
+ // context overflow vs dead config) instead of only the last rung's
3087
+ // error. Rendered by the SSE writer when the whole chain fails.
3088
+ const failureTrail = [
3089
+ `${initialProvider}${routingDecision?.model ? ` (${routingDecision.model})` : ""}: ${err.status ? `HTTP ${err.status} — ` : ""}${err.message}`,
3090
+ ];
3091
+
3038
3092
  // undici wraps every network failure as "TypeError: fetch failed" — the
3039
3093
  // actionable detail (ENOTFOUND vs ECONNRESET vs ETIMEDOUT vs TLS) lives
3040
3094
  // in err.cause. Without this line the z-ai breaker trips on repeated
@@ -3097,6 +3151,7 @@ async function invokeModel(body, options = {}) {
3097
3151
  status: attempt.status,
3098
3152
  error: attempt.json?.error?.message || attempt.text?.slice(0, 120) || null,
3099
3153
  }, "[TierFallback] Candidate answered with an error response, trying next");
3154
+ failureTrail.push(`${cand.provider} (${cand.model}): HTTP ${attempt.status} — ${attempt.json?.error?.message || attempt.text?.slice(0, 120) || "error response"}`);
3100
3155
  continue;
3101
3156
  }
3102
3157
 
@@ -3127,6 +3182,7 @@ async function invokeModel(body, options = {}) {
3127
3182
  { toProvider: cand.provider, error: innerErr.message },
3128
3183
  "[TierFallback] Candidate failed, trying next"
3129
3184
  );
3185
+ failureTrail.push(`${cand.provider} (${cand.model}): ${innerErr.message}`);
3130
3186
  }
3131
3187
  }
3132
3188
  logger.warn(
@@ -3175,6 +3231,7 @@ async function invokeModel(body, options = {}) {
3175
3231
  candidates: routingResult.candidates ?? null,
3176
3232
  pinned: routingResult.pinned ? 1 : 0,
3177
3233
  switch_reason: routingResult.switch_reason ?? null,
3234
+ cache_decision: routingResult._cacheDecision ?? null,
3178
3235
  });
3179
3236
 
3180
3237
  // WS5.4 — feedback loop (primary-failed, no fallback). Low quality
@@ -3194,6 +3251,7 @@ async function invokeModel(body, options = {}) {
3194
3251
  },
3195
3252
  });
3196
3253
 
3254
+ err.lynkrFailureTrail = failureTrail;
3197
3255
  throw err;
3198
3256
  }
3199
3257
 
@@ -3221,26 +3279,7 @@ async function invokeModel(body, options = {}) {
3221
3279
  const fallbackStart = Date.now();
3222
3280
 
3223
3281
  const fallbackResult = await fallbackBreaker.execute(async () => {
3224
- if (fallbackProvider === "azure-openai") {
3225
- return await invokeAzureOpenAI(body, incomingHeaders);
3226
- } else if (fallbackProvider === "azure-anthropic") {
3227
- return await invokeAzureAnthropic(body, incomingHeaders);
3228
- } else if (fallbackProvider === "openrouter") {
3229
- return await invokeOpenRouter(body, incomingHeaders);
3230
- } else if (fallbackProvider === "edenai") {
3231
- return await invokeEdenAI(body, incomingHeaders);
3232
- } else if (fallbackProvider === "openai") {
3233
- return await invokeOpenAI(body, incomingHeaders);
3234
- } else if (fallbackProvider === "llamacpp") {
3235
- return await invokeLlamaCpp(body, incomingHeaders);
3236
- } else if (fallbackProvider === "zai") {
3237
- return await invokeZai(body, incomingHeaders);
3238
- } else if (fallbackProvider === "vertex") {
3239
- return await invokeVertex(body, incomingHeaders);
3240
- } else if (fallbackProvider === "moonshot") {
3241
- return await invokeMoonshot(body, incomingHeaders);
3242
- }
3243
- return await invokeDatabricks(body, incomingHeaders);
3282
+ return await invokeProvider(fallbackProvider, body, incomingHeaders);
3244
3283
  });
3245
3284
 
3246
3285
  const fallbackLatency = Date.now() - fallbackStart;
@@ -3311,6 +3350,7 @@ async function invokeModel(body, options = {}) {
3311
3350
  candidates: routingResult.candidates ?? null,
3312
3351
  pinned: routingResult.pinned ? 1 : 0,
3313
3352
  switch_reason: routingResult.switch_reason ?? null,
3353
+ cache_decision: routingResult._cacheDecision ?? null,
3314
3354
  });
3315
3355
 
3316
3356
  // WS5.4 — feedback loop (fallback success). The served provider
@@ -3330,6 +3370,16 @@ async function invokeModel(body, options = {}) {
3330
3370
  },
3331
3371
  });
3332
3372
 
3373
+ // The last rung also answered with an error — thread the whole trail
3374
+ // into the error payload so the client sees every failure, not just
3375
+ // this one (e.g. "moonshot: quota" matters more than "azure: 404").
3376
+ if (fallbackResult?.ok === false) {
3377
+ failureTrail.push(`${fallbackProvider}: HTTP ${fallbackResult.status} — ${fallbackResult.json?.error?.message || String(fallbackResult.text || "").slice(0, 120) || "error response"}`);
3378
+ if (fallbackResult.json && typeof fallbackResult.json === "object") {
3379
+ fallbackResult.json._lynkr_failure_trail = failureTrail;
3380
+ }
3381
+ }
3382
+
3333
3383
  return {
3334
3384
  ...fallbackResult,
3335
3385
  actualProvider: fallbackProvider,
@@ -3368,6 +3418,7 @@ async function invokeModel(body, options = {}) {
3368
3418
  candidates: routingResult.candidates ?? null,
3369
3419
  pinned: routingResult.pinned ? 1 : 0,
3370
3420
  switch_reason: routingResult.switch_reason ?? null,
3421
+ cache_decision: routingResult._cacheDecision ?? null,
3371
3422
  });
3372
3423
 
3373
3424
  // WS5.4 — feedback loop (double failure). quality=0 is a hard
@@ -3394,6 +3445,8 @@ async function invokeModel(body, options = {}) {
3394
3445
  }, "Both primary and fallback provider failed");
3395
3446
 
3396
3447
  // Return fallback error (more actionable than Ollama error)
3448
+ failureTrail.push(`${fallbackProvider}: ${fallbackErr.message}`);
3449
+ fallbackErr.lynkrFailureTrail = failureTrail;
3397
3450
  throw fallbackErr;
3398
3451
  }
3399
3452
  }
@@ -3459,6 +3512,8 @@ module.exports = {
3459
3512
  invokeAzureAnthropic,
3460
3513
  invokeZai,
3461
3514
  invokeOllama,
3515
+ invokeMoonshot,
3516
+ PROVIDER_INVOKERS,
3462
3517
  stripLynkrBadges,
3463
3518
  destroyHttpAgents,
3464
3519
  normalizeBodyForConverse,
@@ -61,7 +61,14 @@ function areSimilarToolCalls(call1, call2) {
61
61
 
62
62
  // Only search-style tools get fuzzy matching; mutating tools with
63
63
  // near-identical args may be intentional repeats.
64
- const searchTools = ['grep', 'glob', 'search', 'find', 'read', 'bash', 'shell'];
64
+ // 'read' is deliberately NOT fuzzy-matched: its argument is a file path, and
65
+ // absolute paths in one repo share nearly every Jaccard token
66
+ // (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8 and
67
+ // merged into one "repeated call" signature (live incident: an opencode
68
+ // code-trace reading server.js, openai-router.js and orchestrator/index.js
69
+ // was flagged as a loop). A genuine re-read of the same file is caught by
70
+ // the exact-match branch above.
71
+ const searchTools = ['grep', 'glob', 'search', 'find', 'bash', 'shell'];
65
72
  const toolName = (name1 || '').toLowerCase();
66
73
  const isSearchTool = searchTools.some(t => toolName.includes(t));
67
74
 
@@ -128,17 +128,26 @@ function convertAnthropicToolsToOllama(anthropicTools) {
128
128
  return [];
129
129
  }
130
130
 
131
- return anthropicTools.map(tool => ({
132
- type: "function",
133
- function: {
134
- name: tool.name,
135
- description: tool.description || "",
136
- parameters: tool.input_schema || {
137
- type: "object",
138
- properties: {},
131
+ return anthropicTools.map(tool => {
132
+ // Shape-detect: already OpenAI/Ollama-format tools pass through untouched.
133
+ // Without this, an OpenAI-shape tool ({type:"function", function:{...}})
134
+ // reaching this helper produces {function:{name:undefined,...}}, which
135
+ // Ollama's legacy /api/chat endpoint rejects.
136
+ if (tool?.type === "function" && tool.function) {
137
+ return tool;
138
+ }
139
+ return {
140
+ type: "function",
141
+ function: {
142
+ name: tool.name,
143
+ description: tool.description || "",
144
+ parameters: tool.input_schema || {
145
+ type: "object",
146
+ properties: {},
147
+ },
139
148
  },
140
- },
141
- }));
149
+ };
150
+ });
142
151
  }
143
152
 
144
153
  module.exports = {