lynkr 9.9.1 → 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 (93) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +11 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/public/dashboard.html +29 -0
  8. package/scripts/validate-difficulty-classifier.js +27 -6
  9. package/src/api/providers-handler.js +0 -1
  10. package/src/api/router.js +309 -160
  11. package/src/clients/databricks.js +215 -71
  12. package/src/clients/gpt-utils.js +8 -1
  13. package/src/clients/ollama-utils.js +19 -10
  14. package/src/clients/prompt-cache-injection.js +93 -30
  15. package/src/config/index.js +82 -43
  16. package/src/context/compression.js +27 -8
  17. package/src/context/distill.js +2 -1
  18. package/src/dashboard/api.js +5 -1
  19. package/src/memory/distiller.js +350 -0
  20. package/src/memory/extractor.js +0 -8
  21. package/src/memory/format.js +17 -2
  22. package/src/memory/index.js +10 -0
  23. package/src/memory/retriever.js +7 -2
  24. package/src/memory/skills-cache.js +180 -0
  25. package/src/memory/tencentdb-launcher.js +459 -0
  26. package/src/memory/wiki.js +171 -0
  27. package/src/orchestrator/index.js +202 -1237
  28. package/src/orchestrator/passthrough-stream.js +382 -0
  29. package/src/orchestrator/sse-transformer.js +408 -0
  30. package/src/prompts/system.js +20 -6
  31. package/src/routing/affinity-store.js +85 -4
  32. package/src/routing/agentic-detector.js +9 -1
  33. package/src/routing/cache-economics.js +123 -0
  34. package/src/routing/cache-switch-cost.js +194 -0
  35. package/src/routing/calibration.js +8 -1
  36. package/src/routing/complexity-analyzer.js +7 -1
  37. package/src/routing/context-compressor.js +170 -0
  38. package/src/routing/difficulty-classifier.js +161 -17
  39. package/src/routing/index.js +196 -56
  40. package/src/routing/intent-score.js +20 -2
  41. package/src/routing/model-registry.js +35 -10
  42. package/src/routing/session-affinity.js +68 -1
  43. package/src/routing/side-channel-detector.js +103 -0
  44. package/src/routing/telemetry.js +139 -2
  45. package/src/routing/verifier.js +41 -2
  46. package/src/server.js +36 -49
  47. package/src/tools/code-graph.js +6 -4
  48. package/src/agents/context-manager.js +0 -236
  49. package/src/agents/decomposition/dispatcher.js +0 -185
  50. package/src/agents/decomposition/gate.js +0 -136
  51. package/src/agents/decomposition/index.js +0 -183
  52. package/src/agents/decomposition/model-call.js +0 -75
  53. package/src/agents/decomposition/planner.js +0 -223
  54. package/src/agents/decomposition/synthesizer.js +0 -89
  55. package/src/agents/decomposition/telemetry.js +0 -55
  56. package/src/agents/definitions/loader.js +0 -653
  57. package/src/agents/executor.js +0 -457
  58. package/src/agents/index.js +0 -165
  59. package/src/agents/parallel-coordinator.js +0 -68
  60. package/src/agents/reflector.js +0 -331
  61. package/src/agents/skillbook.js +0 -331
  62. package/src/agents/store.js +0 -259
  63. package/src/edits/index.js +0 -171
  64. package/src/indexer/babel-parser.js +0 -213
  65. package/src/indexer/index.js +0 -1629
  66. package/src/indexer/navigation/index.js +0 -32
  67. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  68. package/src/indexer/parser.js +0 -443
  69. package/src/tasks/store.js +0 -349
  70. package/src/tests/coverage.js +0 -173
  71. package/src/tests/index.js +0 -171
  72. package/src/tests/store.js +0 -213
  73. package/src/tools/agent-task.js +0 -145
  74. package/src/tools/code-mode.js +0 -304
  75. package/src/tools/decompose.js +0 -91
  76. package/src/tools/edits.js +0 -94
  77. package/src/tools/execution.js +0 -171
  78. package/src/tools/git.js +0 -1346
  79. package/src/tools/index.js +0 -306
  80. package/src/tools/indexer.js +0 -360
  81. package/src/tools/lazy-loader.js +0 -366
  82. package/src/tools/mcp-remote.js +0 -88
  83. package/src/tools/mcp.js +0 -116
  84. package/src/tools/process.js +0 -167
  85. package/src/tools/smart-selection.js +0 -180
  86. package/src/tools/stubs.js +0 -55
  87. package/src/tools/tasks.js +0 -260
  88. package/src/tools/tests.js +0 -132
  89. package/src/tools/tinyfish.js +0 -358
  90. package/src/tools/truncate.js +0 -106
  91. package/src/tools/web-client.js +0 -71
  92. package/src/tools/web.js +0 -415
  93. package/src/tools/workspace.js +0 -204
@@ -299,8 +299,16 @@ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
299
299
  }
300
300
 
301
301
  // OAuth passthrough: prefer incoming Bearer token (Claude Pro/Max subscription)
302
- // over a configured API key.
303
- const incomingAuth = incomingHeaders?.authorization || incomingHeaders?.Authorization;
302
+ // over a configured API key — but ONLY when it is actually an Anthropic
303
+ // token (sk-ant-*). OpenAI-shape clients (Codex, goose) send their own
304
+ // provider's Bearer, and forwarding that to api.anthropic.com earns a
305
+ // guaranteed 401 "Invalid bearer token" (live incident 2026-07-21, Codex
306
+ // via tier fallback).
307
+ let incomingAuth = incomingHeaders?.authorization || incomingHeaders?.Authorization;
308
+ if (incomingAuth && incomingAuth.startsWith("Bearer ") && !incomingAuth.slice("Bearer ".length).startsWith("sk-ant-")) {
309
+ logger.debug({ tokenShape: incomingAuth.slice(7, 15) + "…" }, "Azure Anthropic: ignoring non-Anthropic bearer token from client");
310
+ incomingAuth = null;
311
+ }
304
312
 
305
313
  // Headers Anthropic uses to verify client identity for subscription OAuth tokens.
306
314
  // If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
@@ -552,9 +560,20 @@ async function invokeOllama(body, incomingHeaders = {}) {
552
560
  if (body.tool_choice) ollamaBody.tool_choice = body.tool_choice;
553
561
  if (body.metadata) ollamaBody.metadata = body.metadata;
554
562
 
555
- // 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".
556
567
  if (supportsTools && Array.isArray(toolsToSend) && toolsToSend.length > 0) {
557
- 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
+ );
558
577
  }
559
578
 
560
579
  if (config.ollama.keepAlive !== undefined) {
@@ -1374,13 +1393,37 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
1374
1393
  headers["Authorization"] = `Bearer ${config.llamacpp.apiKey}`;
1375
1394
  }
1376
1395
 
1377
- const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
1396
+ let messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
1378
1397
 
1379
1398
  if (body.system) {
1380
1399
  messages.unshift({ role: "system", content: body.system });
1381
1400
  }
1382
1401
 
1383
- // FIX: Deduplicate consecutive messages with same role (llama.cpp rejects this)
1402
+ // Fold EVERY system-role message into one leading system message. Claude
1403
+ // Code 2.1.216+ sends system-role entries inside `messages` (a leading one
1404
+ // plus a trailing billing-header-tagged one), and strict Jinja chat
1405
+ // templates (Qwen-family, e.g. Ornith) raise "System message must be at
1406
+ // the beginning" for any non-leading system message. Folding preserves
1407
+ // the content the old consecutive-dedup used to silently DROP.
1408
+ const systemParts = [];
1409
+ const nonSystem = [];
1410
+ for (const msg of messages) {
1411
+ if (msg.role === "system") {
1412
+ const text = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
1413
+ if (text && text.trim()) systemParts.push(text);
1414
+ } else {
1415
+ nonSystem.push(msg);
1416
+ }
1417
+ }
1418
+ if (systemParts.length > 0) {
1419
+ nonSystem.unshift({ role: "system", content: systemParts.join("\n\n") });
1420
+ if (systemParts.length > 1) {
1421
+ logger.debug({ folded: systemParts.length }, "llama.cpp: folded system messages into one leading system message");
1422
+ }
1423
+ }
1424
+ messages = nonSystem;
1425
+
1426
+ // Deduplicate consecutive messages with same role (llama.cpp rejects this)
1384
1427
  const deduplicated = [];
1385
1428
  let lastRole = null;
1386
1429
  for (const msg of messages) {
@@ -1460,7 +1503,20 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
1460
1503
  }))
1461
1504
  }, "=== LLAMA.CPP REQUEST ===");
1462
1505
 
1463
- return performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
1506
+ const result = await performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
1507
+
1508
+ // Context overflow is a CAPACITY error, not a request error: the local
1509
+ // model's slot is simply too small for this conversation (llama-server
1510
+ // splits -c across --parallel slots). Returning the 400 as-is fails the
1511
+ // request AND records a provider "success" (nothing threw), so the
1512
+ // tier-fallback ladder never rescues it. Throw instead — same pattern as
1513
+ // _throwIfOllamaError — so invokeModel escalates to a bigger-context tier.
1514
+ const _llamaErrMsg = result?.json?.error?.message || "";
1515
+ if (!result?.ok && /exceeds the available context size/i.test(_llamaErrMsg)) {
1516
+ throw new Error(`llama.cpp context overflow: ${_llamaErrMsg}`);
1517
+ }
1518
+
1519
+ return result;
1464
1520
  }
1465
1521
 
1466
1522
  async function invokeLMStudio(body, incomingHeaders = {}) {
@@ -1889,6 +1945,21 @@ async function invokeZai(body, incomingHeaders = {}) {
1889
1945
  }, "=== INJECTING STANDARD TOOLS (Z.AI Anthropic) ===");
1890
1946
  }
1891
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
+
1892
1963
  headers = {
1893
1964
  "Content-Type": "application/json",
1894
1965
  "x-api-key": config.zai.apiKey,
@@ -1974,13 +2045,17 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
1974
2045
 
1975
2046
  // Model mapping: Anthropic names → Moonshot/Kimi names
1976
2047
  const modelMap = {
1977
- "claude-sonnet-4-5-20250929": "kimi-k2-turbo-preview",
1978
- "claude-sonnet-4-5": "kimi-k2-turbo-preview",
1979
- "claude-sonnet-4.5": "kimi-k2-turbo-preview",
1980
- "claude-3-5-sonnet": "kimi-k2-turbo-preview",
1981
- "claude-haiku-4-5-20251001": "kimi-k2-turbo-preview",
1982
- "claude-haiku-4-5": "kimi-k2-turbo-preview",
1983
- "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",
1984
2059
  // moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
1985
2060
  // context-size pass fails on large tool-bearing payloads). Remap to a
1986
2061
  // fixed model that's broadly available on api.moonshot.ai.
@@ -1988,7 +2063,12 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
1988
2063
  };
1989
2064
 
1990
2065
  const requestedModel = body._tierModel || body.model || config.moonshot.model;
1991
- 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";
1992
2072
  // Guard against the deprecated auto model arriving via config too.
1993
2073
  if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
1994
2074
 
@@ -2002,18 +2082,18 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2002
2082
  messages.unshift({ role: "system", content: systemContent });
2003
2083
  }
2004
2084
 
2005
- // kimi-k2.x (k2.5 / k2.6 …) are thinking models that only accept
2006
- // temperature: 1 — any other value 400s with "invalid temperature".
2007
- 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);
2008
2090
 
2009
2091
  const moonshotBody = {
2010
2092
  model: mappedModel,
2011
2093
  messages,
2012
2094
  max_tokens: body.max_tokens || 16384,
2013
- // kimi-k2.x thinking models pin sampling params: temperature must be 1
2014
- // and top_p must be 0.95 — any other value 400s.
2015
- temperature: isKimiThinking ? 1 : (body.temperature ?? 0.7),
2016
- 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),
2017
2097
  stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
2018
2098
  };
2019
2099
 
@@ -2633,6 +2713,43 @@ function stripLynkrBadges(messages) {
2633
2713
  return mutated ? out : messages;
2634
2714
  }
2635
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
+
2636
2753
  async function invokeModel(body, options = {}) {
2637
2754
  const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
2638
2755
  const metricsCollector = getMetricsCollector();
@@ -2681,7 +2798,13 @@ async function invokeModel(body, options = {}) {
2681
2798
  _queryText: body._queryText ?? null,
2682
2799
  }
2683
2800
  : await determineProviderSmart(body, { workspace, tenantPolicy });
2684
- const initialProvider = routingResult.provider;
2801
+ // Canonicalize the provider name ONCE. Tier configs write z.ai as "z-ai";
2802
+ // every dispatch case, breaker, and health key below uses "zai". Without
2803
+ // this the dispatch chain falls through to the Databricks default — whose
2804
+ // base is a deliberate fail-fast dummy port in tier-routing setups — so
2805
+ // every "z-ai" tier request died with fetch's bad-port blocklist while
2806
+ // z.ai itself was perfectly healthy (live incident 2026-07-21/22).
2807
+ const initialProvider = routingResult.provider === "z-ai" ? "zai" : routingResult.provider;
2685
2808
  const tierSelectedModel = routingResult.model;
2686
2809
 
2687
2810
  // Inject tier-selected model into body so provider functions can use it
@@ -2783,34 +2906,7 @@ async function invokeModel(body, options = {}) {
2783
2906
 
2784
2907
  try {
2785
2908
  const result = await breaker.execute(async () => {
2786
- if (initialProvider === "azure-openai") {
2787
- return await invokeAzureOpenAI(body, incomingHeaders);
2788
- } else if (initialProvider === "azure-anthropic") {
2789
- return await invokeAzureAnthropic(body, incomingHeaders);
2790
- } else if (initialProvider === "ollama") {
2791
- return await invokeOllama(body, incomingHeaders);
2792
- } else if (initialProvider === "openrouter") {
2793
- return await invokeOpenRouter(body, incomingHeaders);
2794
- } else if (initialProvider === "edenai") {
2795
- return await invokeEdenAI(body, incomingHeaders);
2796
- } else if (initialProvider === "openai") {
2797
- return await invokeOpenAI(body, incomingHeaders);
2798
- } else if (initialProvider === "llamacpp") {
2799
- return await invokeLlamaCpp(body, incomingHeaders);
2800
- } else if (initialProvider === "lmstudio") {
2801
- return await invokeLMStudio(body, incomingHeaders);
2802
- } else if (initialProvider === "bedrock") {
2803
- return await invokeBedrock(body, incomingHeaders);
2804
- } else if (initialProvider === "zai") {
2805
- return await invokeZai(body, incomingHeaders);
2806
- } else if (initialProvider === "vertex") {
2807
- return await invokeVertex(body, incomingHeaders);
2808
- } else if (initialProvider === "moonshot") {
2809
- return await invokeMoonshot(body, incomingHeaders);
2810
- } else if (initialProvider === "codex") {
2811
- return await invokeCodex(body, incomingHeaders);
2812
- }
2813
- return await invokeDatabricks(body, incomingHeaders);
2909
+ return await invokeProvider(initialProvider, body, incomingHeaders);
2814
2910
  });
2815
2911
 
2816
2912
  const latency = Date.now() - startTime;
@@ -2903,6 +2999,7 @@ async function invokeModel(body, options = {}) {
2903
2999
  candidates: routingResult.candidates ?? null,
2904
3000
  pinned: routingResult.pinned ? 1 : 0,
2905
3001
  switch_reason: routingResult.switch_reason ?? null,
3002
+ cache_decision: routingResult._cacheDecision ?? null,
2906
3003
  });
2907
3004
 
2908
3005
  // WS5.4 — feedback loop (success path).
@@ -2984,6 +3081,29 @@ async function invokeModel(body, options = {}) {
2984
3081
  healthTracker.recordFailure(initialProvider, err, err.status);
2985
3082
  getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
2986
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
+
3092
+ // undici wraps every network failure as "TypeError: fetch failed" — the
3093
+ // actionable detail (ENOTFOUND vs ECONNRESET vs ETIMEDOUT vs TLS) lives
3094
+ // in err.cause. Without this line the z-ai breaker trips on repeated
3095
+ // "fetch failed" with nothing to diagnose (live incidents 2026-07-21/22:
3096
+ // ~5ms failures from a long-lived process while fresh processes succeed).
3097
+ logger.warn({
3098
+ provider: initialProvider,
3099
+ model: routingDecision?.model ?? null,
3100
+ error: err.message,
3101
+ causeCode: err.cause?.code ?? null,
3102
+ causeMessage: err.cause?.message ?? null,
3103
+ causeErrors: Array.isArray(err.cause?.errors) ? err.cause.errors.map((e) => e.code || e.message).slice(0, 4) : null,
3104
+ failLatencyMs: failLatency,
3105
+ }, "Provider invocation failed");
3106
+
2987
3107
  // Tier-aware escalate-then-demote fallback (TIER_FALLBACK_ENABLED).
2988
3108
  // On failure, try a MORE capable tier first (climb toward REASONING); only
2989
3109
  // if every higher tier is unavailable do we fall downward to SIMPLE/local.
@@ -3011,9 +3131,30 @@ async function invokeModel(body, options = {}) {
3011
3131
  _cascadeInner: true,
3012
3132
  workspace,
3013
3133
  tenantPolicy,
3134
+ // Forward the client's headers: azure-anthropic in wrap mode
3135
+ // authenticates with the incoming OAuth Bearer token, so a
3136
+ // fallback attempt without headers is guaranteed to fail with
3137
+ // "requires authentication".
3138
+ headers: incomingHeaders,
3014
3139
  }
3015
3140
  );
3016
3141
 
3142
+ // A candidate that answers with an error is a FAILED candidate,
3143
+ // not a serve. Provider clients return {ok:false} on 4xx instead
3144
+ // of throwing, and serving that here hands the client a 401/403
3145
+ // while healthier rungs below never get tried (live incident
3146
+ // 2026-07-21: Codex request served azure-anthropic's 401 while
3147
+ // llamacpp sat idle one rung down).
3148
+ if (attempt?.ok === false) {
3149
+ logger.warn({
3150
+ toProvider: cand.provider,
3151
+ status: attempt.status,
3152
+ error: attempt.json?.error?.message || attempt.text?.slice(0, 120) || null,
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"}`);
3155
+ continue;
3156
+ }
3157
+
3017
3158
  metricsCollector.recordFallbackAttempt(initialProvider, cand.provider, "tier_fallback");
3018
3159
  logger.warn({
3019
3160
  servedTier: cand.tier,
@@ -3041,6 +3182,7 @@ async function invokeModel(body, options = {}) {
3041
3182
  { toProvider: cand.provider, error: innerErr.message },
3042
3183
  "[TierFallback] Candidate failed, trying next"
3043
3184
  );
3185
+ failureTrail.push(`${cand.provider} (${cand.model}): ${innerErr.message}`);
3044
3186
  }
3045
3187
  }
3046
3188
  logger.warn(
@@ -3089,6 +3231,7 @@ async function invokeModel(body, options = {}) {
3089
3231
  candidates: routingResult.candidates ?? null,
3090
3232
  pinned: routingResult.pinned ? 1 : 0,
3091
3233
  switch_reason: routingResult.switch_reason ?? null,
3234
+ cache_decision: routingResult._cacheDecision ?? null,
3092
3235
  });
3093
3236
 
3094
3237
  // WS5.4 — feedback loop (primary-failed, no fallback). Low quality
@@ -3108,6 +3251,7 @@ async function invokeModel(body, options = {}) {
3108
3251
  },
3109
3252
  });
3110
3253
 
3254
+ err.lynkrFailureTrail = failureTrail;
3111
3255
  throw err;
3112
3256
  }
3113
3257
 
@@ -3135,26 +3279,7 @@ async function invokeModel(body, options = {}) {
3135
3279
  const fallbackStart = Date.now();
3136
3280
 
3137
3281
  const fallbackResult = await fallbackBreaker.execute(async () => {
3138
- if (fallbackProvider === "azure-openai") {
3139
- return await invokeAzureOpenAI(body, incomingHeaders);
3140
- } else if (fallbackProvider === "azure-anthropic") {
3141
- return await invokeAzureAnthropic(body, incomingHeaders);
3142
- } else if (fallbackProvider === "openrouter") {
3143
- return await invokeOpenRouter(body, incomingHeaders);
3144
- } else if (fallbackProvider === "edenai") {
3145
- return await invokeEdenAI(body, incomingHeaders);
3146
- } else if (fallbackProvider === "openai") {
3147
- return await invokeOpenAI(body, incomingHeaders);
3148
- } else if (fallbackProvider === "llamacpp") {
3149
- return await invokeLlamaCpp(body, incomingHeaders);
3150
- } else if (fallbackProvider === "zai") {
3151
- return await invokeZai(body, incomingHeaders);
3152
- } else if (fallbackProvider === "vertex") {
3153
- return await invokeVertex(body, incomingHeaders);
3154
- } else if (fallbackProvider === "moonshot") {
3155
- return await invokeMoonshot(body, incomingHeaders);
3156
- }
3157
- return await invokeDatabricks(body, incomingHeaders);
3282
+ return await invokeProvider(fallbackProvider, body, incomingHeaders);
3158
3283
  });
3159
3284
 
3160
3285
  const fallbackLatency = Date.now() - fallbackStart;
@@ -3225,6 +3350,7 @@ async function invokeModel(body, options = {}) {
3225
3350
  candidates: routingResult.candidates ?? null,
3226
3351
  pinned: routingResult.pinned ? 1 : 0,
3227
3352
  switch_reason: routingResult.switch_reason ?? null,
3353
+ cache_decision: routingResult._cacheDecision ?? null,
3228
3354
  });
3229
3355
 
3230
3356
  // WS5.4 — feedback loop (fallback success). The served provider
@@ -3244,6 +3370,16 @@ async function invokeModel(body, options = {}) {
3244
3370
  },
3245
3371
  });
3246
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
+
3247
3383
  return {
3248
3384
  ...fallbackResult,
3249
3385
  actualProvider: fallbackProvider,
@@ -3282,6 +3418,7 @@ async function invokeModel(body, options = {}) {
3282
3418
  candidates: routingResult.candidates ?? null,
3283
3419
  pinned: routingResult.pinned ? 1 : 0,
3284
3420
  switch_reason: routingResult.switch_reason ?? null,
3421
+ cache_decision: routingResult._cacheDecision ?? null,
3285
3422
  });
3286
3423
 
3287
3424
  // WS5.4 — feedback loop (double failure). quality=0 is a hard
@@ -3308,6 +3445,8 @@ async function invokeModel(body, options = {}) {
3308
3445
  }, "Both primary and fallback provider failed");
3309
3446
 
3310
3447
  // Return fallback error (more actionable than Ollama error)
3448
+ failureTrail.push(`${fallbackProvider}: ${fallbackErr.message}`);
3449
+ fallbackErr.lynkrFailureTrail = failureTrail;
3311
3450
  throw fallbackErr;
3312
3451
  }
3313
3452
  }
@@ -3370,6 +3509,11 @@ function destroyHttpAgents() {
3370
3509
 
3371
3510
  module.exports = {
3372
3511
  invokeModel,
3512
+ invokeAzureAnthropic,
3513
+ invokeZai,
3514
+ invokeOllama,
3515
+ invokeMoonshot,
3516
+ PROVIDER_INVOKERS,
3373
3517
  stripLynkrBadges,
3374
3518
  destroyHttpAgents,
3375
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 = {
@@ -4,9 +4,24 @@
4
4
  * Injects `cache_control` breakpoints into requests for providers
5
5
  * that support explicit prompt caching (Anthropic, Bedrock, Vertex/Gemini).
6
6
  *
7
- * Strategy: "system_and_3" — places up to 4 breakpoints:
8
- * 1. System prompt (stable across turns — highest cache hit rate)
9
- * 2-4. Last 3 non-system messages (rolling window)
7
+ * Strategy: "stable_hierarchy" — up to 4 breakpoints ordered by stability
8
+ * (Phase 5, cache-aware routing):
9
+ * 1. Tools block — never moves (tools render before system in the
10
+ * provider's prefix, so this read point survives system edits)
11
+ * 2. System prompt — never moves
12
+ * 3. Frozen history boundary — advances only every K user turns
13
+ * (K = config.memory.distillation.refreshEveryTurns, default 5; shared
14
+ * with the distiller's freeze window). Deterministic from the message
15
+ * list, so consecutive requests inside a bucket mark the same bytes.
16
+ * 4. Rolling marker on the newest message — pays the 1.25x write on the
17
+ * per-turn delta once so the next turn reads it at 0.1x. Kept
18
+ * deliberately: dropping it would re-pay full input price on
19
+ * everything after the boundary every turn until the next refresh.
20
+ *
21
+ * The previous "system_and_3" strategy rolled breakpoints 2-4 across the
22
+ * last three messages; markers moved every turn, and history-rewriting
23
+ * layers (distiller) invalidated the prefix wholesale. Stability of the
24
+ * marked bytes is what compounds hits.
10
25
  *
11
26
  * Providers with automatic caching (OpenAI, DeepSeek) need no injection.
12
27
  *
@@ -17,12 +32,64 @@ const logger = require('../logger');
17
32
 
18
33
  const CACHE_MARKER = { type: 'ephemeral' };
19
34
  const MAX_BREAKPOINTS = 4;
35
+ const DEFAULT_BOUNDARY_EVERY_TURNS = 5;
36
+
37
+ function _boundaryEveryTurns() {
38
+ try {
39
+ const config = require('../config');
40
+ const k = config.memory?.distillation?.refreshEveryTurns;
41
+ return Number.isFinite(k) && k > 0 ? k : DEFAULT_BOUNDARY_EVERY_TURNS;
42
+ } catch {
43
+ return DEFAULT_BOUNDARY_EVERY_TURNS;
44
+ }
45
+ }
46
+
47
+ /** Mark the last content block of a message; converts string content. */
48
+ function _markMessage(msg) {
49
+ if (!msg) return false;
50
+ if (typeof msg.content === 'string') {
51
+ msg.content = [{
52
+ type: 'text',
53
+ text: msg.content,
54
+ cache_control: CACHE_MARKER,
55
+ }];
56
+ return true;
57
+ }
58
+ if (Array.isArray(msg.content) && msg.content.length > 0) {
59
+ const lastBlock = msg.content[msg.content.length - 1];
60
+ if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
61
+ lastBlock.cache_control = CACHE_MARKER;
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Index of the frozen-boundary message: the bucket-th user-role message,
70
+ * where bucket = floor(userTurns / K) * K. Deterministic in the message
71
+ * list, so every request inside a K-turn bucket marks the same message —
72
+ * the marked prefix bytes stay identical until the bucket advances.
73
+ *
74
+ * @returns {number} message index, or -1 when the conversation is too
75
+ * young (bucket < K) or the boundary can't be placed.
76
+ */
77
+ function _frozenBoundaryIndex(messages, everyTurns) {
78
+ if (!Array.isArray(messages) || messages.length === 0) return -1;
79
+ const userIdx = [];
80
+ for (let i = 0; i < messages.length; i++) {
81
+ if (messages[i]?.role === 'user') userIdx.push(i);
82
+ }
83
+ const bucket = Math.floor(userIdx.length / everyTurns) * everyTurns;
84
+ if (bucket < everyTurns) return -1;
85
+ return userIdx[bucket - 1];
86
+ }
20
87
 
21
88
  /**
22
89
  * Inject cache_control breakpoints into an Anthropic-format request body.
23
90
  * Mutates the body in-place for zero-copy performance.
24
91
  *
25
- * @param {Object} body - Request body with system and messages
92
+ * @param {Object} body - Request body with system, tools, and messages
26
93
  * @returns {number} Number of breakpoints injected
27
94
  */
28
95
  function injectAnthropicCacheBreakpoints(body) {
@@ -30,7 +97,16 @@ function injectAnthropicCacheBreakpoints(body) {
30
97
 
31
98
  let injected = 0;
32
99
 
33
- // Breakpoint 1: System prompt
100
+ // Breakpoint 1: tools block — most stable prefix region.
101
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
102
+ const lastTool = body.tools[body.tools.length - 1];
103
+ if (lastTool && typeof lastTool === 'object' && !lastTool.cache_control) {
104
+ lastTool.cache_control = CACHE_MARKER;
105
+ injected++;
106
+ }
107
+ }
108
+
109
+ // Breakpoint 2: system prompt.
34
110
  if (body.system) {
35
111
  if (typeof body.system === 'string') {
36
112
  // Convert string system to array format for cache_control support
@@ -50,32 +126,19 @@ function injectAnthropicCacheBreakpoints(body) {
50
126
  }
51
127
  }
52
128
 
53
- // Breakpoints 2-4: Last 3 non-system messages
54
129
  if (Array.isArray(body.messages) && body.messages.length > 0) {
55
- const remaining = MAX_BREAKPOINTS - injected;
56
- const messagesToMark = Math.min(remaining, 3, body.messages.length);
57
-
58
- for (let i = 0; i < messagesToMark; i++) {
59
- const msgIdx = body.messages.length - 1 - i;
60
- const msg = body.messages[msgIdx];
61
- if (!msg) continue;
62
-
63
- if (typeof msg.content === 'string') {
64
- // Convert string content to array for cache_control
65
- msg.content = [{
66
- type: 'text',
67
- text: msg.content,
68
- cache_control: CACHE_MARKER,
69
- }];
70
- injected++;
71
- } else if (Array.isArray(msg.content) && msg.content.length > 0) {
72
- // Mark the last content block in this message
73
- const lastBlock = msg.content[msg.content.length - 1];
74
- if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
75
- lastBlock.cache_control = CACHE_MARKER;
76
- injected++;
77
- }
78
- }
130
+ const lastIdx = body.messages.length - 1;
131
+
132
+ // Breakpoint 3: frozen history boundary (advances every K user turns).
133
+ const boundaryIdx = _frozenBoundaryIndex(body.messages, _boundaryEveryTurns());
134
+ if (boundaryIdx >= 0 && boundaryIdx < lastIdx && injected < MAX_BREAKPOINTS) {
135
+ if (_markMessage(body.messages[boundaryIdx])) injected++;
136
+ }
137
+
138
+ // Breakpoint 4: rolling marker on the newest message — caches this
139
+ // turn's delta so the next turn reads it instead of re-paying input.
140
+ if (injected < MAX_BREAKPOINTS) {
141
+ if (_markMessage(body.messages[lastIdx])) injected++;
79
142
  }
80
143
  }
81
144