lynkr 9.10.0 → 9.12.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 (50) hide show
  1. package/README.md +46 -11
  2. package/bin/lynkr-init.js +7 -0
  3. package/bin/statusline.js +80 -0
  4. package/bin/wrap.js +10 -6
  5. package/config/model-tiers.json +5 -5
  6. package/package.json +9 -4
  7. package/public/dashboard.html +354 -1
  8. package/src/api/openai-router.js +120 -2
  9. package/src/api/router.js +34 -0
  10. package/src/clients/databricks.js +148 -66
  11. package/src/clients/gpt-utils.js +8 -1
  12. package/src/clients/ollama-utils.js +19 -10
  13. package/src/clients/prompt-cache-injection.js +93 -30
  14. package/src/config/index.js +79 -0
  15. package/src/context/compression.js +27 -8
  16. package/src/context/distill.js +2 -1
  17. package/src/dashboard/api.js +82 -2
  18. package/src/dashboard/recommendations/cache-weakspots.js +88 -0
  19. package/src/dashboard/recommendations/deadweight.js +82 -0
  20. package/src/dashboard/recommendations/downsize.js +109 -0
  21. package/src/dashboard/recommendations/index.js +106 -0
  22. package/src/dashboard/recommendations/side-requests.js +68 -0
  23. package/src/dashboard/recommendations/wilson.js +24 -0
  24. package/src/dashboard/router.js +5 -0
  25. package/src/memory/distiller.js +350 -0
  26. package/src/memory/extractor.js +0 -8
  27. package/src/memory/format.js +17 -2
  28. package/src/memory/index.js +10 -0
  29. package/src/memory/retriever.js +7 -2
  30. package/src/memory/skills-cache.js +180 -0
  31. package/src/memory/tencentdb-launcher.js +459 -0
  32. package/src/memory/wiki.js +171 -0
  33. package/src/orchestrator/index.js +85 -2
  34. package/src/orchestrator/sse-transformer.js +9 -3
  35. package/src/prompts/system.js +20 -6
  36. package/src/routing/affinity-store.js +68 -1
  37. package/src/routing/agentic-detector.js +9 -1
  38. package/src/routing/cache-economics.js +123 -0
  39. package/src/routing/cache-switch-cost.js +194 -0
  40. package/src/routing/calibration.js +8 -1
  41. package/src/routing/complexity-analyzer.js +37 -10
  42. package/src/routing/context-compressor.js +170 -0
  43. package/src/routing/difficulty-classifier.js +115 -13
  44. package/src/routing/index.js +161 -53
  45. package/src/routing/model-registry.js +35 -10
  46. package/src/routing/session-affinity.js +60 -0
  47. package/src/routing/telemetry.js +343 -2
  48. package/src/routing/verifier.js +41 -2
  49. package/src/server.js +36 -3
  50. package/src/tools/code-graph.js +6 -4
@@ -310,6 +310,18 @@ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
310
310
  incomingAuth = null;
311
311
  }
312
312
 
313
+ // Anthropic OAuth tokens are only meaningful at api.anthropic.com. When this
314
+ // provider points at any OTHER Anthropic-compatible host (Azure AI Foundry
315
+ // Claude, a corporate gateway), forwarding Claude Code's sk-ant-oat token
316
+ // earns a 401 there — and the configured x-api-key below is the right auth.
317
+ // This also means a subscription token never leaves the box unless the
318
+ // endpoint is Anthropic itself.
319
+ const isAnthropicDirect = String(config.azureAnthropic.endpoint || "").includes("api.anthropic.com");
320
+ if (incomingAuth && !isAnthropicDirect) {
321
+ logger.debug({ endpoint: config.azureAnthropic.endpoint }, "Azure Anthropic: non-Anthropic endpoint — using configured API key instead of client OAuth token");
322
+ incomingAuth = null;
323
+ }
324
+
313
325
  // Headers Anthropic uses to verify client identity for subscription OAuth tokens.
314
326
  // If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
315
327
  // headers (its terse anti-proxy response). Forward every Anthropic-relevant
@@ -560,9 +572,20 @@ async function invokeOllama(body, incomingHeaders = {}) {
560
572
  if (body.tool_choice) ollamaBody.tool_choice = body.tool_choice;
561
573
  if (body.metadata) ollamaBody.metadata = body.metadata;
562
574
 
563
- // Tools (already Anthropic format — no conversion needed)
575
+ // Tools: normalize to Anthropic shape. Callers that stayed OpenAI-format
576
+ // upstream (OpenWorker, aisuite, any client hitting /v1/chat/completions)
577
+ // otherwise ship {type:"function", function:{...}} which Ollama's
578
+ // /v1/messages endpoint rejects with 400 "can't find closing '}' symbol".
564
579
  if (supportsTools && Array.isArray(toolsToSend) && toolsToSend.length > 0) {
565
- ollamaBody.tools = toolsToSend;
580
+ ollamaBody.tools = toolsToSend.map(tool =>
581
+ tool?.type === "function" && tool.function
582
+ ? {
583
+ name: tool.function.name,
584
+ description: tool.function.description || "",
585
+ input_schema: tool.function.parameters || { type: "object", properties: {} },
586
+ }
587
+ : tool
588
+ );
566
589
  }
567
590
 
568
591
  if (config.ollama.keepAlive !== undefined) {
@@ -1934,6 +1957,21 @@ async function invokeZai(body, incomingHeaders = {}) {
1934
1957
  }, "=== INJECTING STANDARD TOOLS (Z.AI Anthropic) ===");
1935
1958
  }
1936
1959
 
1960
+ // Normalize to Anthropic tool shape — the /anthropic/v1/messages endpoint
1961
+ // rejects OpenAI-nested tools ({type:"function", function:{name,...}}) with
1962
+ // 422 missing body.tools[0].name. Callers that stayed OpenAI-format upstream
1963
+ // (OpenWorker, aisuite, any client hitting /v1/chat/completions with an
1964
+ // unusual tool shape) get unwrapped here.
1965
+ zaiBody.tools = zaiBody.tools.map(tool =>
1966
+ tool?.type === "function" && tool.function
1967
+ ? {
1968
+ name: tool.function.name,
1969
+ description: tool.function.description || "",
1970
+ input_schema: tool.function.parameters || { type: "object", properties: {} },
1971
+ }
1972
+ : tool
1973
+ );
1974
+
1937
1975
  headers = {
1938
1976
  "Content-Type": "application/json",
1939
1977
  "x-api-key": config.zai.apiKey,
@@ -2019,13 +2057,17 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2019
2057
 
2020
2058
  // Model mapping: Anthropic names → Moonshot/Kimi names
2021
2059
  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",
2060
+ "claude-sonnet-4-5-20250929": "kimi-k2.6",
2061
+ "claude-sonnet-4-5": "kimi-k2.6",
2062
+ "claude-sonnet-4.5": "kimi-k2.6",
2063
+ "claude-3-5-sonnet": "kimi-k2.6",
2064
+ "claude-haiku-4-5-20251001": "kimi-k2.6",
2065
+ "claude-haiku-4-5": "kimi-k2.6",
2066
+ "claude-3-haiku": "kimi-k2.6",
2067
+ // Retired ids (gone from /v1/models as of 2026-07: "Not found the model")
2068
+ // that older tier configs may still reference.
2069
+ "kimi-k2-turbo-preview": "kimi-k2.6",
2070
+ "kimi-k2-thinking": "kimi-k3",
2029
2071
  // moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
2030
2072
  // context-size pass fails on large tool-bearing payloads). Remap to a
2031
2073
  // fixed model that's broadly available on api.moonshot.ai.
@@ -2033,7 +2075,12 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2033
2075
  };
2034
2076
 
2035
2077
  const requestedModel = body._tierModel || body.model || config.moonshot.model;
2036
- let mappedModel = modelMap[requestedModel] || config.moonshot.model || "kimi-k2-turbo-preview";
2078
+ // Honor tier-selected Moonshot ids (e.g. TIER_REASONING=moonshot:kimi-k2.6)
2079
+ // instead of silently swapping in the .env default model.
2080
+ let mappedModel = modelMap[requestedModel]
2081
+ || (/^(kimi-|moonshot-v1)/i.test(requestedModel || "") ? requestedModel : null)
2082
+ || config.moonshot.model
2083
+ || "kimi-k3";
2037
2084
  // Guard against the deprecated auto model arriving via config too.
2038
2085
  if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
2039
2086
 
@@ -2047,19 +2094,23 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2047
2094
  messages.unshift({ role: "system", content: systemContent });
2048
2095
  }
2049
2096
 
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);
2097
+ // Every current kimi-k* model (k2.5, k2.6, k2.7-code, k3, …) pins sampling
2098
+ // params: temperature must be 1 — any other value 400s with "invalid
2099
+ // temperature: only 1 is allowed for this model" (probed live 2026-07-23).
2100
+ // Only the moonshot-v1-* models accept caller-supplied values.
2101
+ const isKimiPinned = /^kimi-k/i.test(mappedModel);
2053
2102
 
2054
2103
  const moonshotBody = {
2055
2104
  model: mappedModel,
2056
2105
  messages,
2057
2106
  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),
2062
- stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
2107
+ temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
2108
+ top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
2109
+ // Streaming honored since the Phase-2b sse-transformer landed: the raw
2110
+ // OpenAI SSE stream is returned below and reshaped in flight by the
2111
+ // orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered
2112
+ // requests keep the Anthropic conversion path unchanged.
2113
+ stream: body.stream ?? false,
2063
2114
  };
2064
2115
 
2065
2116
  if (Array.isArray(body.tools) && body.tools.length > 0) {
@@ -2097,6 +2148,13 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2097
2148
  throw err;
2098
2149
  }
2099
2150
 
2151
+ // Streaming request: hand the raw OpenAI SSE stream to the orchestrator's
2152
+ // stream branch (sse-transformer reshapes it). The Anthropic conversion
2153
+ // below is buffered-only — it would consume the stream.
2154
+ if (response?.stream) {
2155
+ return response;
2156
+ }
2157
+
2100
2158
  const rawMsg = response?.json?.choices?.[0]?.message;
2101
2159
  logger.debug({
2102
2160
  responseOk: response?.ok,
@@ -2678,6 +2736,43 @@ function stripLynkrBadges(messages) {
2678
2736
  return mutated ? out : messages;
2679
2737
  }
2680
2738
 
2739
+ // Single source of truth for provider dispatch. Both the initial call and the
2740
+ // fallback ladder MUST route through invokeProvider — the previous duplicated
2741
+ // if/else chains drifted (the fallback chain was missing ollama, bedrock,
2742
+ // lmstudio and codex), and unknown providers silently dialed databricks,
2743
+ // which under tier routing is a deliberate dead number (see .env note about
2744
+ // the 2026-07-07 self-loop incident). Add new providers HERE, nowhere else;
2745
+ // the registry-coverage unit test fails if this map and
2746
+ // SUPPORTED_MODEL_PROVIDERS fall out of sync.
2747
+ const PROVIDER_INVOKERS = {
2748
+ "azure-openai": invokeAzureOpenAI,
2749
+ "azure-anthropic": invokeAzureAnthropic,
2750
+ ollama: invokeOllama,
2751
+ openrouter: invokeOpenRouter,
2752
+ edenai: invokeEdenAI,
2753
+ openai: invokeOpenAI,
2754
+ llamacpp: invokeLlamaCpp,
2755
+ lmstudio: invokeLMStudio,
2756
+ bedrock: invokeBedrock,
2757
+ zai: invokeZai,
2758
+ vertex: invokeVertex,
2759
+ moonshot: invokeMoonshot,
2760
+ codex: invokeCodex,
2761
+ };
2762
+
2763
+ function invokeProvider(provider, body, incomingHeaders) {
2764
+ const invoke = PROVIDER_INVOKERS[provider];
2765
+ if (!invoke) {
2766
+ // databricks itself, or an unknown name. Loud, not silent: under tier
2767
+ // routing this path used to swallow misconfigured providers.
2768
+ if (provider !== "databricks") {
2769
+ logger.warn({ provider }, "No invoker registered for provider — defaulting to databricks");
2770
+ }
2771
+ return invokeDatabricks(body, incomingHeaders);
2772
+ }
2773
+ return invoke(body, incomingHeaders);
2774
+ }
2775
+
2681
2776
  async function invokeModel(body, options = {}) {
2682
2777
  const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
2683
2778
  const metricsCollector = getMetricsCollector();
@@ -2834,34 +2929,7 @@ async function invokeModel(body, options = {}) {
2834
2929
 
2835
2930
  try {
2836
2931
  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);
2932
+ return await invokeProvider(initialProvider, body, incomingHeaders);
2865
2933
  });
2866
2934
 
2867
2935
  const latency = Date.now() - startTime;
@@ -2954,6 +3022,9 @@ async function invokeModel(body, options = {}) {
2954
3022
  candidates: routingResult.candidates ?? null,
2955
3023
  pinned: routingResult.pinned ? 1 : 0,
2956
3024
  switch_reason: routingResult.switch_reason ?? null,
3025
+ cache_decision: routingResult._cacheDecision ?? null,
3026
+ cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
3027
+ cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
2957
3028
  });
2958
3029
 
2959
3030
  // WS5.4 — feedback loop (success path).
@@ -3035,6 +3106,14 @@ async function invokeModel(body, options = {}) {
3035
3106
  healthTracker.recordFailure(initialProvider, err, err.status);
3036
3107
  getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
3037
3108
 
3109
+ // Accumulate every provider failure for this request so the terminal
3110
+ // error can tell the client WHY nothing answered (quota exhausted vs
3111
+ // context overflow vs dead config) instead of only the last rung's
3112
+ // error. Rendered by the SSE writer when the whole chain fails.
3113
+ const failureTrail = [
3114
+ `${initialProvider}${routingDecision?.model ? ` (${routingDecision.model})` : ""}: ${err.status ? `HTTP ${err.status} — ` : ""}${err.message}`,
3115
+ ];
3116
+
3038
3117
  // undici wraps every network failure as "TypeError: fetch failed" — the
3039
3118
  // actionable detail (ENOTFOUND vs ECONNRESET vs ETIMEDOUT vs TLS) lives
3040
3119
  // in err.cause. Without this line the z-ai breaker trips on repeated
@@ -3097,6 +3176,7 @@ async function invokeModel(body, options = {}) {
3097
3176
  status: attempt.status,
3098
3177
  error: attempt.json?.error?.message || attempt.text?.slice(0, 120) || null,
3099
3178
  }, "[TierFallback] Candidate answered with an error response, trying next");
3179
+ failureTrail.push(`${cand.provider} (${cand.model}): HTTP ${attempt.status} — ${attempt.json?.error?.message || attempt.text?.slice(0, 120) || "error response"}`);
3100
3180
  continue;
3101
3181
  }
3102
3182
 
@@ -3127,6 +3207,7 @@ async function invokeModel(body, options = {}) {
3127
3207
  { toProvider: cand.provider, error: innerErr.message },
3128
3208
  "[TierFallback] Candidate failed, trying next"
3129
3209
  );
3210
+ failureTrail.push(`${cand.provider} (${cand.model}): ${innerErr.message}`);
3130
3211
  }
3131
3212
  }
3132
3213
  logger.warn(
@@ -3175,6 +3256,7 @@ async function invokeModel(body, options = {}) {
3175
3256
  candidates: routingResult.candidates ?? null,
3176
3257
  pinned: routingResult.pinned ? 1 : 0,
3177
3258
  switch_reason: routingResult.switch_reason ?? null,
3259
+ cache_decision: routingResult._cacheDecision ?? null,
3178
3260
  });
3179
3261
 
3180
3262
  // WS5.4 — feedback loop (primary-failed, no fallback). Low quality
@@ -3194,6 +3276,7 @@ async function invokeModel(body, options = {}) {
3194
3276
  },
3195
3277
  });
3196
3278
 
3279
+ err.lynkrFailureTrail = failureTrail;
3197
3280
  throw err;
3198
3281
  }
3199
3282
 
@@ -3221,26 +3304,7 @@ async function invokeModel(body, options = {}) {
3221
3304
  const fallbackStart = Date.now();
3222
3305
 
3223
3306
  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);
3307
+ return await invokeProvider(fallbackProvider, body, incomingHeaders);
3244
3308
  });
3245
3309
 
3246
3310
  const fallbackLatency = Date.now() - fallbackStart;
@@ -3311,6 +3375,9 @@ async function invokeModel(body, options = {}) {
3311
3375
  candidates: routingResult.candidates ?? null,
3312
3376
  pinned: routingResult.pinned ? 1 : 0,
3313
3377
  switch_reason: routingResult.switch_reason ?? null,
3378
+ cache_decision: routingResult._cacheDecision ?? null,
3379
+ cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
3380
+ cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
3314
3381
  });
3315
3382
 
3316
3383
  // WS5.4 — feedback loop (fallback success). The served provider
@@ -3330,6 +3397,16 @@ async function invokeModel(body, options = {}) {
3330
3397
  },
3331
3398
  });
3332
3399
 
3400
+ // The last rung also answered with an error — thread the whole trail
3401
+ // into the error payload so the client sees every failure, not just
3402
+ // this one (e.g. "moonshot: quota" matters more than "azure: 404").
3403
+ if (fallbackResult?.ok === false) {
3404
+ failureTrail.push(`${fallbackProvider}: HTTP ${fallbackResult.status} — ${fallbackResult.json?.error?.message || String(fallbackResult.text || "").slice(0, 120) || "error response"}`);
3405
+ if (fallbackResult.json && typeof fallbackResult.json === "object") {
3406
+ fallbackResult.json._lynkr_failure_trail = failureTrail;
3407
+ }
3408
+ }
3409
+
3333
3410
  return {
3334
3411
  ...fallbackResult,
3335
3412
  actualProvider: fallbackProvider,
@@ -3368,6 +3445,7 @@ async function invokeModel(body, options = {}) {
3368
3445
  candidates: routingResult.candidates ?? null,
3369
3446
  pinned: routingResult.pinned ? 1 : 0,
3370
3447
  switch_reason: routingResult.switch_reason ?? null,
3448
+ cache_decision: routingResult._cacheDecision ?? null,
3371
3449
  });
3372
3450
 
3373
3451
  // WS5.4 — feedback loop (double failure). quality=0 is a hard
@@ -3394,6 +3472,8 @@ async function invokeModel(body, options = {}) {
3394
3472
  }, "Both primary and fallback provider failed");
3395
3473
 
3396
3474
  // Return fallback error (more actionable than Ollama error)
3475
+ failureTrail.push(`${fallbackProvider}: ${fallbackErr.message}`);
3476
+ fallbackErr.lynkrFailureTrail = failureTrail;
3397
3477
  throw fallbackErr;
3398
3478
  }
3399
3479
  }
@@ -3459,6 +3539,8 @@ module.exports = {
3459
3539
  invokeAzureAnthropic,
3460
3540
  invokeZai,
3461
3541
  invokeOllama,
3542
+ invokeMoonshot,
3543
+ PROVIDER_INVOKERS,
3462
3544
  stripLynkrBadges,
3463
3545
  destroyHttpAgents,
3464
3546
  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