lynkr 9.6.0 → 9.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -177,9 +177,24 @@ function injectPromptCaching(body, provider) {
177
177
  // Gate on model capability: a provider may support cache_control in general
178
178
  // while the specific routed model does not.
179
179
  if (!modelSupportsCacheControl(body, provider)) return 0;
180
+ // If the client (e.g. Claude Code) already attached cache_control breakpoints,
181
+ // don't add more. Anthropic caps at 4 breakpoints per request and stacking ours
182
+ // on top has caused 400/429 errors on OAuth subscription requests.
183
+ if (hasExistingCacheControl(body)) return 0;
180
184
  return injectAnthropicCacheBreakpoints(body);
181
185
  }
182
186
 
187
+ function hasExistingCacheControl(body) {
188
+ if (!body) return false;
189
+ const scan = (obj) => {
190
+ if (!obj || typeof obj !== 'object') return false;
191
+ if (Array.isArray(obj)) return obj.some(scan);
192
+ if (obj.cache_control) return true;
193
+ return Object.values(obj).some(scan);
194
+ };
195
+ return scan(body.system) || scan(body.messages) || scan(body.tools);
196
+ }
197
+
183
198
  module.exports = {
184
199
  injectPromptCaching,
185
200
  injectAnthropicCacheBreakpoints,
@@ -965,22 +965,48 @@ function stripThinkingBlocks(text) {
965
965
  /**
966
966
  * Convert legacy Ollama /api/chat response to Anthropic Messages format.
967
967
  * Used when Ollama < v0.14.0 (no native Anthropic endpoint).
968
+ *
969
+ * Critical for MiniMax M2/M2.5 (and other interleaved-thinking models):
970
+ * preserve <think>...</think> from message.content AND Ollama's native
971
+ * message.thinking field as Anthropic thinking blocks. Dropping them breaks
972
+ * the model's long-horizon agent loop — vendor-quantified at Tau^2 -35.9%,
973
+ * BrowseComp -40.1% (https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2).
968
974
  */
969
975
  function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
970
976
  const message = ollamaResponse?.message ?? {};
971
- const rawContent = message.content || "";
977
+ const rawContent = typeof message.content === "string" ? message.content : "";
978
+ const nativeThinking = typeof message.thinking === "string" ? message.thinking : "";
972
979
  const toolCalls = message.tool_calls || [];
973
980
 
981
+ // Extract <think>...</think> blocks from content (concatenate if multiple).
982
+ // What remains becomes the text body.
983
+ const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
984
+ const thinkMatches = [];
985
+ let textBody = rawContent;
986
+ let m;
987
+ while ((m = thinkRegex.exec(rawContent)) !== null) {
988
+ thinkMatches.push(m[1]);
989
+ }
990
+ textBody = textBody.replace(thinkRegex, "").trim();
991
+
992
+ const combinedThinking = [nativeThinking, ...thinkMatches]
993
+ .map(s => (s || "").trim())
994
+ .filter(Boolean)
995
+ .join("\n\n");
996
+
974
997
  const contentItems = [];
975
998
 
976
- if (typeof rawContent === "string" && rawContent.trim()) {
977
- const cleanedContent = stripThinkingBlocks(rawContent);
978
- if (cleanedContent) {
979
- contentItems.push({ type: "text", text: cleanedContent });
980
- }
999
+ // 1. Thinking block FIRST (Mini-Agent reference order: thinking text → tool_use)
1000
+ if (combinedThinking) {
1001
+ contentItems.push({ type: "thinking", thinking: combinedThinking });
981
1002
  }
982
1003
 
983
- // Convert tool calls from OpenAI function-calling format to Anthropic tool_use
1004
+ // 2. Text body (after <think> tags removed)
1005
+ if (textBody) {
1006
+ contentItems.push({ type: "text", text: textBody });
1007
+ }
1008
+
1009
+ // 3. Tool calls converted to Anthropic tool_use
984
1010
  if (Array.isArray(toolCalls) && toolCalls.length > 0) {
985
1011
  for (const toolCall of toolCalls) {
986
1012
  const func = toolCall.function || {};
@@ -1008,6 +1034,9 @@ function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
1008
1034
  const inputTokens = ollamaResponse.prompt_eval_count ?? 0;
1009
1035
  const outputTokens = ollamaResponse.eval_count ?? 0;
1010
1036
 
1037
+ // stop_reason derived from tool_calls presence, NOT done_reason.
1038
+ // Ollama emits done_reason="stop" even when tool_calls are present
1039
+ // (ollama/ollama#12557) — naive mapping would falsely halt Claude Code's loop.
1011
1040
  return {
1012
1041
  id: `msg_${Date.now()}`,
1013
1042
  type: "message",
@@ -1104,7 +1133,16 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
1104
1133
 
1105
1134
  function sanitizePayload(payload) {
1106
1135
  const { clonePayloadSmart } = require("../utils/payload");
1107
- const providerType = config.modelProvider?.type ?? "databricks";
1136
+ // Honor a forceProvider marker (set by the OAuth tier-routing path) so the
1137
+ // tool-format / system-flatten / strip-thinking branches downstream match
1138
+ // the actual destination provider, not the static MODEL_PROVIDER default.
1139
+ // Without this, a TIER_SIMPLE=ollama:... user gets the "databricks" branch
1140
+ // running normaliseTools — which wraps tools in OpenAI {type:"function",...}
1141
+ // shape, leaving Ollama with tools named "function" and a model that
1142
+ // (correctly) reports no real tools available.
1143
+ const providerType = payload?._forceProvider
1144
+ || config.modelProvider?.type
1145
+ || "databricks";
1108
1146
  const willFlatten = providerType !== "azure-anthropic";
1109
1147
  const clean = clonePayloadSmart(payload ?? {}, { willFlatten });
1110
1148
  const requestedModel =
@@ -1260,55 +1298,16 @@ function sanitizePayload(payload) {
1260
1298
  }));
1261
1299
  delete clean.tool_choice;
1262
1300
  } else if (providerType === "ollama") {
1263
- // Check if model supports tools
1264
- const { modelNameSupportsTools } = require("../clients/ollama-utils");
1265
- const modelSupportsTools = modelNameSupportsTools(config.ollama?.model);
1266
-
1267
- // Check if this is a simple conversational message (no tools needed)
1268
- const isConversational = (() => {
1269
- if (!Array.isArray(clean.messages) || clean.messages.length === 0) {
1270
- return false;
1271
- }
1272
- const lastMessage = clean.messages[clean.messages.length - 1];
1273
- if (lastMessage?.role !== "user") {
1274
- return false;
1275
- }
1276
-
1277
- const content = typeof lastMessage.content === "string"
1278
- ? lastMessage.content
1279
- : "";
1280
-
1281
- const trimmed = content.trim().toLowerCase();
1282
-
1283
- // Simple greetings
1284
- if (/^(hi|hello|hey|good morning|good afternoon|good evening|howdy|greetings)[\s\.\!\?]*$/.test(trimmed)) {
1285
- return "greeting";
1286
- }
1287
-
1288
- // Conversational phrases that don't need tools (thanks, farewells, acknowledgements)
1289
- if (/^(thanks|thank you|thx|ty|bye|goodbye|see you|ok|okay|cool|nice|great|awesome|sure|got it|sounds good|no worries|np|cheers)[\s\.\!\?]*$/.test(trimmed)) {
1290
- return "conversational";
1291
- }
1292
-
1293
- return false;
1294
- })();
1295
-
1296
- if (isConversational) {
1297
- // Strip all tools for simple conversational messages
1298
- delete clean.tools;
1299
- delete clean.tool_choice;
1300
- logger.debug({
1301
- model: config.ollama?.model,
1302
- reason: isConversational,
1303
- }, "Ollama conversational mode - tools removed");
1304
- } else if (modelSupportsTools && Array.isArray(clean.tools) && clean.tools.length > 0) {
1305
- // Keep all tools — Ollama receives them in Anthropic format (native API)
1306
- // or they get converted to OpenAI format in invokeOllama (legacy API)
1301
+ // Always pass tools through to Ollama in Anthropic format when they exist.
1302
+ // Ollama (v0.14+ native /v1/messages) accepts the Anthropic tool shape; if
1303
+ // the underlying model doesn't actually emit tool_use blocks, the model
1304
+ // simply responds conversationally — which is the correct fallback. Don't
1305
+ // strip the tools array based on heuristics about user intent or a
1306
+ // hardcoded "model supports tools" check, both of which produce
1307
+ // tool-blind responses ("I don't have file system access") when the
1308
+ // client (Claude Code) is clearly in an agentic session.
1309
+ if (Array.isArray(clean.tools) && clean.tools.length > 0) {
1307
1310
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1308
- } else {
1309
- // Remove tools for models without tool support
1310
- delete clean.tools;
1311
- delete clean.tool_choice;
1312
1311
  }
1313
1312
  } else if (providerType === "openrouter") {
1314
1313
  // OpenRouter supports tools - keep them as-is
@@ -1902,8 +1901,29 @@ IMPORTANT TOOL USAGE RULES:
1902
1901
  }, 'Estimated token usage before model call');
1903
1902
  }
1904
1903
 
1905
- // Apply Headroom compression if enabled
1906
- if (isHeadroomEnabled() && cleanPayload.messages && cleanPayload.messages.length > 0) {
1904
+ // Apply Headroom compression if enabled.
1905
+ //
1906
+ // Headroom is configured for a single provider (HEADROOM_PROVIDER, default
1907
+ // 'anthropic'). Its Tool Crusher rewrites tool results compactly, Cache
1908
+ // Aligner restructures messages to maximize that provider's prompt-cache
1909
+ // hit pattern, and Smart Crusher does semantic compression — all tuned for
1910
+ // Anthropic. Sending the compressed output to a different model family
1911
+ // (Ollama, OpenAI, etc.) yields output the receiver reads as "garbled tool
1912
+ // result" and the agent loop stalls.
1913
+ //
1914
+ // Gate Headroom on providers matching HEADROOM_PROVIDER. By default that's
1915
+ // Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
1916
+ // analogous gate.
1917
+ const headroomProviderMap = {
1918
+ 'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter']),
1919
+ 'openai': new Set(['azure-openai', 'openai', 'openrouter']),
1920
+ 'google': new Set(['vertex', 'openrouter']),
1921
+ };
1922
+ const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
1923
+ const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
1924
+ const headroomCompatible = headroomSafeProviders.has(providerType);
1925
+
1926
+ if (isHeadroomEnabled() && headroomCompatible && cleanPayload.messages && cleanPayload.messages.length > 0) {
1907
1927
  try {
1908
1928
  const compressionResult = await headroomCompress(
1909
1929
  cleanPayload.messages,
@@ -1945,6 +1965,12 @@ IMPORTANT TOOL USAGE RULES:
1945
1965
  } catch (headroomErr) {
1946
1966
  logger.warn({ err: headroomErr, sessionId: session?.id ?? null }, 'Headroom compression failed, using original messages');
1947
1967
  }
1968
+ } else if (isHeadroomEnabled() && !headroomCompatible) {
1969
+ logger.debug({
1970
+ providerType,
1971
+ headroomProvider,
1972
+ reason: 'provider_mismatch',
1973
+ }, 'Headroom skipped — provider does not match HEADROOM_PROVIDER family');
1948
1974
  }
1949
1975
 
1950
1976
  // Generate correlation ID for request/response pairing
@@ -2003,15 +2029,49 @@ IMPORTANT TOOL USAGE RULES:
2003
2029
 
2004
2030
  // Caveman terse-output injection (opt-in): nudge the model toward shorter
2005
2031
  // responses to reduce output tokens.
2006
- if (config.caveman?.enabled === true) {
2032
+ //
2033
+ // Default safe-set is the Claude-family + capable instruction-following
2034
+ // models. Operators can override via LYNKR_CAVEMAN_SAFE_PROVIDERS=a,b,c.
2035
+ // (Some smaller / older models read "respond like a terse caveman" too
2036
+ // literally and produce broken telegraphic English — keep them out of the
2037
+ // set if you see that degradation.)
2038
+ const DEFAULT_CAVEMAN_SAFE = [
2039
+ 'azure-anthropic',
2040
+ 'bedrock',
2041
+ 'vertex',
2042
+ 'openrouter',
2043
+ 'ollama',
2044
+ 'openai',
2045
+ 'azure-openai',
2046
+ 'moonshot',
2047
+ 'zai',
2048
+ 'databricks',
2049
+ ];
2050
+ const cavemanSafeEnv = process.env.LYNKR_CAVEMAN_SAFE_PROVIDERS;
2051
+ const CAVEMAN_SAFE_PROVIDERS = new Set(
2052
+ cavemanSafeEnv
2053
+ ? cavemanSafeEnv.split(',').map(s => s.trim()).filter(Boolean)
2054
+ : DEFAULT_CAVEMAN_SAFE
2055
+ );
2056
+ if (config.caveman?.enabled === true && CAVEMAN_SAFE_PROVIDERS.has(providerType)) {
2007
2057
  const { injectCaveman } = require("../context/caveman");
2008
2058
  cleanPayload.system = injectCaveman(cleanPayload.system);
2059
+ } else if (config.caveman?.enabled === true) {
2060
+ logger.debug({ providerType }, 'Caveman injection skipped (provider not in safe set)');
2009
2061
  }
2010
2062
 
2011
2063
  if (agentTimer) agentTimer.mark("preInvokeModel");
2012
2064
  let databricksResponse;
2065
+ // Honor a body-level forceProvider marker (set by the OAuth tier-routing
2066
+ // path in the router) so the orchestrator's internal tier router can't
2067
+ // re-pick a different provider mid-flight.
2068
+ const invokeOpts = { headers };
2069
+ if (cleanPayload._forceProvider) {
2070
+ invokeOpts.forceProvider = cleanPayload._forceProvider;
2071
+ delete cleanPayload._forceProvider;
2072
+ }
2013
2073
  try {
2014
- databricksResponse = await invokeModel(cleanPayload);
2074
+ databricksResponse = await invokeModel(cleanPayload, invokeOpts);
2015
2075
  if (agentTimer) agentTimer.mark("invokeModel");
2016
2076
  } catch (modelError) {
2017
2077
  const isConnectionError = modelError.cause?.code === 'ECONNREFUSED'
@@ -486,7 +486,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
486
486
  : null;
487
487
  if (queryText) {
488
488
  knnResult = await getKnnRouter().query(queryText);
489
- if (knnResult && knnResult.confidence > 0.7 && knnResult.model && knnResult.model !== selectedModel) {
489
+ // Confidence thresholds (env-configurable; defaults 0.7 high / 0.4 low):
490
+ const KNN_HIGH = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_HIGH) || 0.7;
491
+ const KNN_LOW = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_LOW) || 0.4;
492
+ if (knnResult && knnResult.confidence > KNN_HIGH && knnResult.model && knnResult.model !== selectedModel) {
490
493
  // High confidence — trust kNN's model recommendation directly.
491
494
  logger.debug({
492
495
  from: `${provider}:${selectedModel}`,
@@ -496,7 +499,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
496
499
  provider = knnResult.provider;
497
500
  selectedModel = knnResult.model;
498
501
  method = method + '+knn';
499
- } else if (knnResult && knnResult.confidence > 0.4 && knnResult.confidence <= 0.7) {
502
+ } else if (knnResult && knnResult.confidence > KNN_LOW && knnResult.confidence <= KNN_HIGH) {
500
503
  // Ambiguous signal — neighbors are split, we can't trust any single model
501
504
  // recommendation. Err on quality: bump the current tier one step up so the
502
505
  // request gets a more capable model rather than risking a bad answer from
@@ -532,10 +535,26 @@ async function _determineProviderSmartInner(payload, options = {}) {
532
535
  // one with the highest estimated UCB score for the current context.
533
536
  if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
534
537
  try {
535
- // Build candidates: current selection and kNN alternative if different
538
+ // Build candidates: current selection and kNN alternative if different.
539
+ //
540
+ // Tier-aware filter: only treat the kNN suggestion as a real candidate
541
+ // if it matches a (provider, model) combo configured in ANY TIER_*
542
+ // entry. The bandit is allowed to explore freely across the user's
543
+ // configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier
544
+ // model), but is forbidden from picking a credentialed-but-untiered
545
+ // model (e.g. an Azure OpenAI deployment whose endpoint is set in .env
546
+ // for some other use, but not referenced by any TIER_*). This keeps
547
+ // tier routing as the source of truth for what's eligible while
548
+ // preserving cross-tier bandit exploration.
536
549
  const allCandidates = [{ provider, model: selectedModel }];
537
550
  if (knnResult.model !== selectedModel) {
538
- allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
551
+ const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
552
+ const inConfig = configured.some(
553
+ m => m.provider === knnResult.provider && m.model === knnResult.model
554
+ );
555
+ if (inConfig) {
556
+ allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
557
+ }
539
558
  }
540
559
 
541
560
  if (allCandidates.length > 1) {
@@ -29,7 +29,10 @@ const META_FILE = path.join(INDEX_DIR, 'meta.json');
29
29
  const MAX_ELEMENTS = 50000;
30
30
  const DIM = 768; // nomic-embed-text default
31
31
  const K = 10;
32
- const MIN_INDEX_SIZE = 1000;
32
+ // Default 1000 is a safety floor for quality; override via env when you
33
+ // want to activate kNN with less data (e.g. bootstrapping from your own
34
+ // telemetry before reaching 1k entries).
35
+ const MIN_INDEX_SIZE = Number.parseInt(process.env.LYNKR_KNN_MIN_INDEX_SIZE, 10) || 1000;
33
36
 
34
37
  let _hnsw = null;
35
38
  let _hnswLoaded = false;
@@ -72,7 +75,11 @@ class KnnRouter {
72
75
  this.meta = metaData.entries || [];
73
76
  this.size = this.meta.length;
74
77
  this.index = new hnsw.HierarchicalNSW('cosine', this.dim);
75
- this.index.readIndexSync(INDEX_FILE, MAX_ELEMENTS);
78
+ // hnswlib-node v3 API: readIndexSync(filename, allowReplaceDeleted=false).
79
+ // (Earlier Lynkr code passed MAX_ELEMENTS here — wrong type, threw on load.)
80
+ this.index.readIndexSync(INDEX_FILE, false);
81
+ // resize if needed so we can keep adding up to MAX_ELEMENTS
82
+ try { this.index.resizeIndex(MAX_ELEMENTS); } catch (_) {}
76
83
  this.ready = true;
77
84
  logger.info({ size: this.size, dim: this.dim }, '[KnnRouter] Index loaded');
78
85
  return true;
@@ -258,6 +258,40 @@ class ModelTierSelector {
258
258
  };
259
259
  }
260
260
 
261
+ /**
262
+ * Return every {provider, model} combo configured for a tier.
263
+ * Today TIER_* parses to a single provider:model, so this returns at most
264
+ * one entry. Kept as an array so callers don't have to change when
265
+ * multi-model tier syntax is added (e.g. TIER_SIMPLE=ollama:m1,ollama:m2).
266
+ */
267
+ getModelsForTier(tier) {
268
+ const tierConfig = config.modelTiers?.[tier];
269
+ if (!tierConfig) return [];
270
+ const parsed = this._parseTierConfig(tierConfig);
271
+ return parsed ? [{ provider: parsed.provider, model: parsed.model }] : [];
272
+ }
273
+
274
+ /**
275
+ * Return the union of every {provider, model} configured across all tiers,
276
+ * deduped. Used by the bandit-candidate filter to constrain exploration to
277
+ * the user's stated tier preferences — the bandit may pick any combo the
278
+ * user has configured for any tier, but never a model that isn't in any
279
+ * TIER_* entry (even if its credentials happen to be set).
280
+ */
281
+ getAllConfiguredModels() {
282
+ const seen = new Set();
283
+ const out = [];
284
+ for (const tier of ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']) {
285
+ for (const m of this.getModelsForTier(tier)) {
286
+ const key = `${m.provider}:${m.model}`;
287
+ if (seen.has(key)) continue;
288
+ seen.add(key);
289
+ out.push(m);
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+
261
295
  /**
262
296
  * Parse tier config string (format: provider:model)
263
297
  * Examples: "ollama:llama3.2", "azure-openai:gpt-5.2-chat", "openai:gpt-4o"