lynkr 9.6.0 → 9.7.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.
@@ -51,7 +51,7 @@ const httpsAgent = new https.Agent({
51
51
  keepAliveMsecs: 30000,
52
52
  });
53
53
 
54
- async function performJsonRequest(url, { headers = {}, body }, providerLabel) {
54
+ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride }, providerLabel) {
55
55
  const agent = url.startsWith('https:') ? httpsAgent : httpAgent;
56
56
  const isStreaming = body.stream === true;
57
57
 
@@ -134,10 +134,11 @@ async function performJsonRequest(url, { headers = {}, body }, providerLabel) {
134
134
  maxRetries: config.apiRetry?.maxRetries || 3,
135
135
  initialDelay: config.apiRetry?.initialDelay || 1000,
136
136
  maxDelay: config.apiRetry?.maxDelay || 30000,
137
+ ...(retryableStatusesOverride ? { retryableStatuses: retryableStatusesOverride } : {}),
137
138
  });
138
139
  }
139
140
 
140
- async function invokeDatabricks(body) {
141
+ async function invokeDatabricks(body, incomingHeaders = {}) {
141
142
  if (!config.databricks?.url) {
142
143
  throw new Error("Databricks configuration is missing required URL.");
143
144
  }
@@ -181,34 +182,224 @@ async function invokeDatabricks(body) {
181
182
  return performJsonRequest(config.databricks.url, { headers, body: databricksBody }, "Databricks");
182
183
  }
183
184
 
184
- async function invokeAzureAnthropic(body) {
185
+ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
185
186
  if (!config.azureAnthropic?.endpoint) {
186
187
  throw new Error("Azure Anthropic endpoint is not configured.");
187
188
  }
188
189
 
189
- // Inject standard tools if client didn't send any (passthrough mode)
190
- if (!Array.isArray(body.tools) || body.tools.length === 0) {
191
- body.tools = STANDARD_TOOLS;
192
- logger.debug({
193
- injectedToolCount: STANDARD_TOOLS.length,
194
- injectedToolNames: STANDARD_TOOL_NAMES,
195
- reason: "Client did not send tools (passthrough mode)"
196
- }, "=== INJECTING STANDARD TOOLS (Azure Anthropic) ===");
190
+ // Copy body so we don't mutate the caller's object across agent-loop iterations.
191
+ const azureBody = { ...body };
192
+
193
+ // Tier routing wins over whatever model Claude Code sent.
194
+ if (azureBody._tierModel) {
195
+ azureBody.model = azureBody._tierModel;
197
196
  }
198
197
 
199
- const headers = {
200
- "Content-Type": "application/json",
201
- "x-api-key": config.azureAnthropic.apiKey,
202
- "anthropic-version": config.azureAnthropic.version ?? "2023-06-01",
198
+ // Strip ALL Lynkr-internal fields (convention: leading underscore). Anthropic
199
+ // rejects unknown top-level keys with "Extra inputs are not permitted", and
200
+ // the orchestrator sprinkles fields like _requestMode, _tierModel, _workspace,
201
+ // _sessionId, _tenantPolicy, _suggestionModeModel onto the payload.
202
+ for (const key of Object.keys(azureBody)) {
203
+ if (key.startsWith('_')) delete azureBody[key];
204
+ }
205
+
206
+ // Tier routing can dispatch here even when the orchestrator formatted the
207
+ // payload for a different provider (the orchestrator picks format from the
208
+ // static MODEL_PROVIDER, not the tier-resolved provider). Normalize OpenAI-style
209
+ // shapes back to Anthropic format so the API doesn't reject the request.
210
+
211
+ // 1) Tools: {type:"function", function:{...}} -> {name, description, input_schema}
212
+ if (Array.isArray(azureBody.tools)) {
213
+ azureBody.tools = azureBody.tools.map((tool) => {
214
+ if (tool?.type === "function" && tool.function) {
215
+ return {
216
+ name: tool.function.name,
217
+ description: tool.function.description,
218
+ input_schema: tool.function.parameters ?? { type: "object", properties: {} },
219
+ };
220
+ }
221
+ return tool;
222
+ });
223
+ }
224
+
225
+ // Strip Lynkr's Caveman "[brevity] …" trailer from the system prompt — it
226
+ // changes the prompt vs. what Claude Code would send to Anthropic directly,
227
+ // and Anthropic's OAuth subscription anti-abuse is sensitive to that drift.
228
+ const stripBrevity = (s) => {
229
+ if (typeof s !== 'string') return s;
230
+ const idx = s.indexOf('[brevity]');
231
+ if (idx === -1) return s;
232
+ return s.slice(0, idx).trimEnd();
203
233
  };
204
- return performJsonRequest(
234
+ if (typeof azureBody.system === 'string') {
235
+ azureBody.system = stripBrevity(azureBody.system);
236
+ } else if (Array.isArray(azureBody.system)) {
237
+ azureBody.system = azureBody.system
238
+ .map((block) => block && typeof block === 'object' && typeof block.text === 'string'
239
+ ? { ...block, text: stripBrevity(block.text) }
240
+ : block)
241
+ .filter((block) => !(block && typeof block === 'object' && block.text === ''));
242
+ }
243
+
244
+ // 2) System prompt: Anthropic wants top-level `system`, not a system message.
245
+ // Promote any leading role:"system" messages into the top-level field.
246
+ if (Array.isArray(azureBody.messages) && azureBody.messages.length > 0) {
247
+ const systemMessages = [];
248
+ while (azureBody.messages.length > 0 && azureBody.messages[0]?.role === "system") {
249
+ systemMessages.push(azureBody.messages.shift());
250
+ }
251
+ if (systemMessages.length > 0) {
252
+ const systemText = systemMessages
253
+ .map((m) => (typeof m.content === "string"
254
+ ? m.content
255
+ : Array.isArray(m.content)
256
+ ? m.content.map((b) => b?.text || "").join("\n")
257
+ : ""))
258
+ .filter(Boolean)
259
+ .join("\n\n");
260
+ // Merge with any existing top-level system (string or array).
261
+ if (azureBody.system) {
262
+ const existing = typeof azureBody.system === "string"
263
+ ? azureBody.system
264
+ : Array.isArray(azureBody.system)
265
+ ? azureBody.system.map((s) => s?.text || s).join("\n")
266
+ : "";
267
+ azureBody.system = existing ? `${existing}\n\n${systemText}` : systemText;
268
+ } else {
269
+ azureBody.system = systemText;
270
+ }
271
+ }
272
+ }
273
+
274
+ // OAuth passthrough: prefer incoming Bearer token (Claude Pro/Max subscription)
275
+ // over a configured API key.
276
+ const incomingAuth = incomingHeaders?.authorization || incomingHeaders?.Authorization;
277
+
278
+ // Headers Anthropic uses to verify client identity for subscription OAuth tokens.
279
+ // If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
280
+ // headers (its terse anti-proxy response). Forward every Anthropic-relevant
281
+ // request header from Claude Code verbatim — anthropic-beta, anthropic-version,
282
+ // user-agent, x-app, x-stainless-*, etc. Strip only hop-by-hop and proxy-control
283
+ // headers that would confuse fetch or leak Lynkr's identity.
284
+ const HOP_BY_HOP = new Set([
285
+ 'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
286
+ 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer',
287
+ 'content-length', 'accept-encoding',
288
+ ]);
289
+ const LYNKR_INTERNAL = new Set([
290
+ 'x-lynkr-tenant-id', 'x-lynkr-workspace', 'x-workspace-cwd',
291
+ 'x-session-id', 'x-request-id',
292
+ ]);
293
+
294
+ const headers = {};
295
+ for (const [name, value] of Object.entries(incomingHeaders || {})) {
296
+ if (value == null) continue;
297
+ const lower = name.toLowerCase();
298
+ if (HOP_BY_HOP.has(lower)) continue;
299
+ if (LYNKR_INTERNAL.has(lower)) continue;
300
+ // Skip authorization here; we re-add it below with our preferred source.
301
+ if (lower === 'authorization') continue;
302
+ headers[name] = value;
303
+ }
304
+
305
+ // Always set these explicitly (override anything Claude Code sent that we
306
+ // don't want to forward verbatim).
307
+ headers["Content-Type"] = "application/json";
308
+ if (!headers["anthropic-version"] && !headers["Anthropic-Version"]) {
309
+ headers["anthropic-version"] = config.azureAnthropic.version ?? "2023-06-01";
310
+ }
311
+
312
+ if (incomingAuth && incomingAuth.startsWith('Bearer ')) {
313
+ headers["Authorization"] = incomingAuth;
314
+
315
+ // Claude Code OAuth Access Tokens (sk-ant-oat01-...) require the OAuth
316
+ // anthropic-beta header to be accepted by api.anthropic.com. Without it
317
+ // Anthropic responds 429 rate_limit_error with empty rate-limit headers
318
+ // and message:"Error" — its terse anti-proxy response. Ensure it's set.
319
+ const token = incomingAuth.slice('Bearer '.length);
320
+ if (token.startsWith('sk-ant-oat')) {
321
+ const existingBeta = headers['anthropic-beta'] || headers['Anthropic-Beta'];
322
+ const oauthBeta = 'oauth-2025-04-20';
323
+ if (!existingBeta) {
324
+ headers['anthropic-beta'] = oauthBeta;
325
+ } else if (!String(existingBeta).split(',').map(s => s.trim()).includes(oauthBeta)) {
326
+ headers['anthropic-beta'] = `${existingBeta},${oauthBeta}`;
327
+ }
328
+ }
329
+ } else if (config.azureAnthropic.apiKey) {
330
+ headers["x-api-key"] = config.azureAnthropic.apiKey;
331
+ } else {
332
+ throw new Error("Azure Anthropic requires authentication (OAuth token or API key)");
333
+ }
334
+
335
+ logger.debug({
336
+ forwardedHeaderKeys: Object.keys(headers),
337
+ targetModel: azureBody.model,
338
+ }, "Azure Anthropic: header forwarding");
339
+
340
+ // Don't retry 429 for Anthropic OAuth subscription. Claude Code has its own
341
+ // backoff and UI — retrying here just amplifies the burst and trips Anthropic's
342
+ // anti-abuse, keeping us 429ed for longer. Still retry 5xx (server faults).
343
+ const result = await performJsonRequest(
205
344
  config.azureAnthropic.endpoint,
206
- { headers, body },
345
+ {
346
+ headers,
347
+ body: azureBody,
348
+ retryableStatusesOverride: [500, 502, 503, 504],
349
+ },
207
350
  "Azure Anthropic",
208
351
  );
352
+
353
+ if (!result?.ok) {
354
+ logger.warn({
355
+ status: result?.status,
356
+ error: result?.json?.error?.message || result?.text?.substring(0, 200),
357
+ model: azureBody.model,
358
+ }, "Azure Anthropic API error");
359
+ }
360
+
361
+ return result;
362
+ }
363
+
364
+ /**
365
+ * Lift any <think>...</think> tags leaked into text content blocks into proper
366
+ * Anthropic thinking content blocks. No-op if the response is already clean.
367
+ * Operates on the response shape returned by performJsonRequest (object/string).
368
+ */
369
+ function _liftLeakedThinkingBlocks(response) {
370
+ // performJsonRequest may wrap the JSON body — find it.
371
+ const payload = response?.json ?? response?.body ?? response;
372
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.content)) {
373
+ return response;
374
+ }
375
+ const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
376
+ const newContent = [];
377
+ let lifted = 0;
378
+ for (const block of payload.content) {
379
+ if (block?.type === "text" && typeof block.text === "string" && block.text.includes("<think>")) {
380
+ const thoughts = [];
381
+ let m;
382
+ while ((m = thinkRegex.exec(block.text)) !== null) thoughts.push(m[1].trim());
383
+ thinkRegex.lastIndex = 0;
384
+ const cleaned = block.text.replace(thinkRegex, "").trim();
385
+ const merged = thoughts.filter(Boolean).join("\n\n");
386
+ if (merged) {
387
+ newContent.push({ type: "thinking", thinking: merged });
388
+ lifted++;
389
+ }
390
+ if (cleaned) newContent.push({ type: "text", text: cleaned });
391
+ } else {
392
+ newContent.push(block);
393
+ }
394
+ }
395
+ if (lifted > 0) {
396
+ payload.content = newContent;
397
+ logger.debug({ lifted }, "Ollama: lifted leaked <think> tags into thinking content blocks");
398
+ }
399
+ return response;
209
400
  }
210
401
 
211
- async function invokeOllama(body) {
402
+ async function invokeOllama(body, incomingHeaders = {}) {
212
403
  if (!config.ollama?.endpoint) {
213
404
  throw new Error("Ollama endpoint is not configured.");
214
405
  }
@@ -296,14 +487,30 @@ async function invokeOllama(body) {
296
487
  logger.debug({ keepAlive: ollamaBody.keep_alive }, "Ollama keep_alive configured");
297
488
  }
298
489
 
299
- return performJsonRequest(endpoint, { headers, body: ollamaBody }, "Ollama");
490
+ const response = await performJsonRequest(endpoint, { headers, body: ollamaBody }, "Ollama");
491
+ // Even on the Anthropic-native path, Ollama Cloud's MiniMax M2.5 adapter
492
+ // sometimes leaks <think>...</think> as raw text inside content blocks
493
+ // instead of emitting a thinking content block (ollama/ollama#14220 was
494
+ // patched server-side 2026-02-13 but coverage is incomplete). Sanitize:
495
+ // pull leaked <think> tags out of text blocks and re-shape them as proper
496
+ // Anthropic thinking blocks before returning to Claude Code, otherwise
497
+ // Claude Code's loop sees stop_reason="end_turn" + empty text and halts.
498
+ return _liftLeakedThinkingBlocks(response);
300
499
  }
301
500
 
302
501
  // ---- Legacy path (Ollama < v0.14.0, /api/chat with OpenAI format) ----
303
502
  const endpoint = `${config.ollama.endpoint}/api/chat`;
304
503
  const headers = { "Content-Type": "application/json" };
305
504
 
306
- // Convert Anthropic messages to Ollama format (content blocks → strings)
505
+ // Convert Anthropic messages to Ollama format.
506
+ //
507
+ // CRITICAL for MiniMax M2/M2.5 and other interleaved-thinking models:
508
+ // assistant `thinking` blocks MUST be preserved across turns (re-emitted as
509
+ // <think>...</think> in content) and `tool_use` blocks MUST become OpenAI
510
+ // tool_calls. Dropping these is the root cause of the 5-10-call stall — see
511
+ // https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2
512
+ // and HF model card: "Do not remove the <think>...</think> part, otherwise
513
+ // the model's performance will be negatively affected."
307
514
  const convertedMessages = [];
308
515
 
309
516
  if (body.system && typeof body.system === "string" && body.system.trim().length > 0) {
@@ -311,29 +518,98 @@ async function invokeOllama(body) {
311
518
  }
312
519
 
313
520
  (body.messages || []).forEach(msg => {
314
- let content = msg.content;
315
- if (Array.isArray(content)) {
316
- content = content
317
- .filter(block => block.type === 'text')
318
- .map(block => block.text || '')
319
- .join('\n');
521
+ const content = msg.content;
522
+
523
+ // Plain string content pass through unchanged.
524
+ if (typeof content === "string") {
525
+ convertedMessages.push({ role: msg.role, content });
526
+ return;
527
+ }
528
+
529
+ if (!Array.isArray(content)) {
530
+ convertedMessages.push({ role: msg.role, content: "" });
531
+ return;
532
+ }
533
+
534
+ // Block-array content. Separate by block type.
535
+ if (msg.role === "assistant") {
536
+ const textParts = [];
537
+ const toolCalls = [];
538
+ for (const block of content) {
539
+ if (!block || typeof block !== "object") continue;
540
+ if (block.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim()) {
541
+ // Re-emit thinking as <think>...</think> so MiniMax can re-read its own reasoning.
542
+ textParts.push(`<think>${block.thinking}</think>`);
543
+ } else if (block.type === "redacted_thinking" && typeof block.data === "string") {
544
+ textParts.push(`<think>${block.data}</think>`);
545
+ } else if (block.type === "text" && typeof block.text === "string") {
546
+ textParts.push(block.text);
547
+ } else if (block.type === "tool_use") {
548
+ toolCalls.push({
549
+ id: block.id,
550
+ type: "function",
551
+ function: {
552
+ name: block.name,
553
+ arguments: typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {}),
554
+ },
555
+ });
556
+ }
557
+ }
558
+ const assistantMsg = { role: "assistant", content: textParts.join("\n") };
559
+ if (toolCalls.length > 0) assistantMsg.tool_calls = toolCalls;
560
+ convertedMessages.push(assistantMsg);
561
+ return;
562
+ }
563
+
564
+ // role === "user" — may contain tool_result blocks that need to become
565
+ // role:"tool" messages in OpenAI format (one per tool_result).
566
+ const userTextParts = [];
567
+ const toolResultMsgs = [];
568
+ for (const block of content) {
569
+ if (!block || typeof block !== "object") continue;
570
+ if (block.type === "text" && typeof block.text === "string") {
571
+ userTextParts.push(block.text);
572
+ } else if (block.type === "tool_result") {
573
+ let resultText = "";
574
+ if (typeof block.content === "string") {
575
+ resultText = block.content;
576
+ } else if (Array.isArray(block.content)) {
577
+ resultText = block.content
578
+ .map(c => (c?.type === "text" ? (c.text || "") : ""))
579
+ .join("\n");
580
+ }
581
+ toolResultMsgs.push({
582
+ role: "tool",
583
+ tool_call_id: block.tool_use_id,
584
+ content: resultText,
585
+ });
586
+ }
320
587
  }
321
- convertedMessages.push({ role: msg.role, content: content || '' });
588
+ if (userTextParts.length > 0) {
589
+ convertedMessages.push({ role: "user", content: userTextParts.join("\n") });
590
+ }
591
+ for (const tm of toolResultMsgs) convertedMessages.push(tm);
322
592
  });
323
593
 
324
- // Deduplicate consecutive messages with same role
594
+ // MERGE consecutive messages with same role (only user/assistant — never
595
+ // touch tool messages, each tool_call_id needs its own response).
596
+ //
597
+ // Previous behavior silently DROPPED the second message, which destroyed
598
+ // the user's prompt when Claude Code preceded it with a <system-reminder>
599
+ // user message — symptom: model said "I don't see a specific path".
325
600
  const deduplicated = [];
326
- let lastRole = null;
327
601
  for (const msg of convertedMessages) {
328
- if (msg.role === lastRole) {
602
+ const prev = deduplicated[deduplicated.length - 1];
603
+ if (prev && prev.role === msg.role && msg.role !== "tool" && !prev.tool_calls && !msg.tool_calls) {
604
+ const merged = [prev.content, msg.content].filter(Boolean).join("\n\n");
605
+ prev.content = merged;
329
606
  logger.debug({
330
- skippedRole: msg.role,
331
- contentPreview: msg.content.substring(0, 50)
332
- }, 'Ollama: Skipping duplicate consecutive message with same role');
607
+ role: msg.role,
608
+ mergedLen: merged.length,
609
+ }, 'Ollama: Merged consecutive same-role messages');
333
610
  continue;
334
611
  }
335
612
  deduplicated.push(msg);
336
- lastRole = msg.role;
337
613
  }
338
614
 
339
615
  const ollamaBody = {
@@ -363,7 +639,7 @@ async function invokeOllama(body) {
363
639
  return performJsonRequest(endpoint, { headers, body: ollamaBody }, "Ollama");
364
640
  }
365
641
 
366
- async function invokeOpenRouter(body) {
642
+ async function invokeOpenRouter(body, incomingHeaders = {}) {
367
643
  if (!config.openrouter?.endpoint || !config.openrouter?.apiKey) {
368
644
  throw new Error("OpenRouter endpoint or API key is not configured.");
369
645
  }
@@ -436,7 +712,7 @@ function detectAzureFormat(url) {
436
712
  }
437
713
 
438
714
 
439
- async function invokeAzureOpenAI(body) {
715
+ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
440
716
  if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
441
717
  throw new Error("Azure OpenAI endpoint or API key is not configured.");
442
718
  }
@@ -480,10 +756,17 @@ async function invokeAzureOpenAI(body) {
480
756
  const isGpt5 = /gpt-5/i.test(azureDeployment);
481
757
  const maxTokensKey = isGpt5 ? "max_completion_tokens" : "max_tokens";
482
758
 
759
+ // gpt-5 family supports much larger output budgets than 16k. The previous
760
+ // 16384 hard cap caused silent mid-stream truncations on long "explain this
761
+ // codebase" responses (Azure returns finish_reason=length → Anthropic
762
+ // stop_reason=max_tokens → Claude Code halts and asks the user to continue).
763
+ // Raise to 32768 as a sane default; respect a higher client-supplied
764
+ // body.max_tokens up to that ceiling.
765
+ const azureOpenAIMaxOutput = 32768;
483
766
  const azureBody = {
484
767
  messages,
485
768
  temperature: body.temperature ?? 0.3,
486
- [maxTokensKey]: Math.min(body.max_tokens ?? 16384, 16384),
769
+ [maxTokensKey]: Math.min(body.max_tokens ?? azureOpenAIMaxOutput, azureOpenAIMaxOutput),
487
770
  top_p: body.top_p ?? 1.0,
488
771
  stream: false,
489
772
  model: azureDeployment
@@ -841,7 +1124,7 @@ async function invokeAzureOpenAI(body) {
841
1124
  }
842
1125
 
843
1126
 
844
- async function invokeOpenAI(body) {
1127
+ async function invokeOpenAI(body, incomingHeaders = {}) {
845
1128
  if (!config.openai?.apiKey) {
846
1129
  throw new Error("OpenAI API key is not configured.");
847
1130
  }
@@ -922,7 +1205,7 @@ async function invokeOpenAI(body) {
922
1205
  return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
923
1206
  }
924
1207
 
925
- async function invokeLlamaCpp(body) {
1208
+ async function invokeLlamaCpp(body, incomingHeaders = {}) {
926
1209
  if (!config.llamacpp?.endpoint) {
927
1210
  throw new Error("llama.cpp endpoint is not configured.");
928
1211
  }
@@ -1033,7 +1316,7 @@ async function invokeLlamaCpp(body) {
1033
1316
  return performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
1034
1317
  }
1035
1318
 
1036
- async function invokeLMStudio(body) {
1319
+ async function invokeLMStudio(body, incomingHeaders = {}) {
1037
1320
  if (!config.lmstudio?.endpoint) {
1038
1321
  throw new Error("LM Studio endpoint is not configured.");
1039
1322
  }
@@ -1162,7 +1445,7 @@ function normalizeBodyForConverse(body) {
1162
1445
  return normalized;
1163
1446
  }
1164
1447
 
1165
- async function invokeBedrock(body) {
1448
+ async function invokeBedrock(body, incomingHeaders = {}) {
1166
1449
  // 1. Validate Bearer token
1167
1450
  if (!config.bedrock?.apiKey) {
1168
1451
  throw new Error(
@@ -1356,7 +1639,7 @@ async function invokeBedrock(body) {
1356
1639
  * Z.AI offers GLM models through an Anthropic-compatible API at ~1/7 the cost.
1357
1640
  * Minimal transformation needed - mostly passthrough with model mapping.
1358
1641
  */
1359
- async function invokeZai(body) {
1642
+ async function invokeZai(body, incomingHeaders = {}) {
1360
1643
  if (!config.zai?.apiKey) {
1361
1644
  throw new Error("Z.AI API key is not configured. Set ZAI_API_KEY in your .env file.");
1362
1645
  }
@@ -1546,7 +1829,7 @@ async function invokeZai(body) {
1546
1829
  * Moonshot offers Kimi models through an OpenAI-compatible chat completions API.
1547
1830
  * Uses native system role support (unlike Z.AI which merges into user message).
1548
1831
  */
1549
- async function invokeMoonshot(body) {
1832
+ async function invokeMoonshot(body, incomingHeaders = {}) {
1550
1833
  if (!config.moonshot?.apiKey) {
1551
1834
  throw new Error("Moonshot API key is not configured. Set MOONSHOT_API_KEY in your .env file.");
1552
1835
  }
@@ -1796,7 +2079,7 @@ function sanitizeSchemaForGemini(schema) {
1796
2079
  * Supports Google Gemini models through Vertex AI.
1797
2080
  * Converts Anthropic format to Gemini format and back.
1798
2081
  */
1799
- async function invokeVertex(body) {
2082
+ async function invokeVertex(body, incomingHeaders = {}) {
1800
2083
  const apiKey = config.vertex?.apiKey;
1801
2084
 
1802
2085
  if (!apiKey) {
@@ -2052,7 +2335,7 @@ function convertGeminiToAnthropic(response, requestedModel) {
2052
2335
  };
2053
2336
  }
2054
2337
 
2055
- async function invokeCodex(body) {
2338
+ async function invokeCodex(body, incomingHeaders = {}) {
2056
2339
  const { getCodexProcess } = require("./codex-process");
2057
2340
  const { convertAnthropicToCodexPrompt, convertCodexResponseToAnthropic } = require("./codex-utils");
2058
2341
 
@@ -2159,12 +2442,77 @@ function captureResponseText(resultJson) {
2159
2442
  return text ? text.slice(0, TELEMETRY_TEXT_MAXLEN) : null;
2160
2443
  }
2161
2444
 
2445
+ // Strip prior-turn Lynkr routing badges from assistant content[]. The badge
2446
+ // is injected into the response stream as a content block (see router.js paths
2447
+ // near lines 213, 1078, 1264, 1402) so the TUI renders it. Claude Code persists
2448
+ // content[] into the session transcript and resubmits it as conversation
2449
+ // history on each subsequent request, so without this strip the badge text
2450
+ // dominates the model's view of its own prior turns — which breaks M2.5's
2451
+ // interleaved-thinking continuity (HF model card requires preserved <think>
2452
+ // blocks across turns; resubmitted badges replace them and Tau²/BrowseComp
2453
+ // scores collapse). Render-side injection stays untouched; this only sanitises
2454
+ // what we forward upstream.
2455
+ // Matches a Lynkr badge string anchored at the start, e.g.
2456
+ // "*[Lynkr] SIMPLE → minimax-m2.5:cloud (ollama) · score 21*\n\n\n"
2457
+ // The badge format never contains an inner `*` until the closing one, so a
2458
+ // non-greedy lazy match is unnecessary — match up to (and including) the
2459
+ // closing `*` plus trailing whitespace.
2460
+ const LYNKR_BADGE_PREFIX_RE = /^\*\[Lynkr\][^*\n]*\*\s*/;
2461
+
2462
+ function stripLynkrBadges(messages) {
2463
+ if (!Array.isArray(messages)) return messages;
2464
+ let mutated = false;
2465
+ let badgeCount = 0;
2466
+ const out = messages.map((msg) => {
2467
+ if (msg?.role !== 'assistant') return msg;
2468
+
2469
+ // String content variant — assistant.content is a bare string. This is
2470
+ // what the orchestrator's OpenAI-format response branch produces, and
2471
+ // it's where badges actually leak in the Ollama agent loop.
2472
+ if (typeof msg.content === 'string') {
2473
+ if (!LYNKR_BADGE_PREFIX_RE.test(msg.content)) return msg;
2474
+ const stripped = msg.content.replace(LYNKR_BADGE_PREFIX_RE, '');
2475
+ mutated = true;
2476
+ badgeCount++;
2477
+ return { ...msg, content: stripped };
2478
+ }
2479
+
2480
+ // Array content variant — Anthropic-format responses keep content as an
2481
+ // array of blocks.
2482
+ if (Array.isArray(msg.content)) {
2483
+ const before = msg.content.length;
2484
+ const filtered = msg.content.filter((b) =>
2485
+ !(b?.type === 'text' && typeof b.text === 'string' && LYNKR_BADGE_PREFIX_RE.test(b.text))
2486
+ );
2487
+ if (filtered.length === before) return msg;
2488
+ mutated = true;
2489
+ badgeCount += before - filtered.length;
2490
+ // Anthropic rejects empty content[]; substitute a benign placeholder for
2491
+ // turns where the badge was the entire assistant text.
2492
+ return { ...msg, content: filtered.length ? filtered : [{ type: 'text', text: '' }] };
2493
+ }
2494
+
2495
+ return msg;
2496
+ });
2497
+ return mutated ? out : messages;
2498
+ }
2499
+
2162
2500
  async function invokeModel(body, options = {}) {
2163
2501
  const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
2164
2502
  const metricsCollector = getMetricsCollector();
2165
2503
  const registry = getCircuitBreakerRegistry();
2166
2504
  const healthTracker = getHealthTracker();
2167
2505
 
2506
+ // Extract incoming headers for OAuth passthrough
2507
+ const incomingHeaders = options.headers || {};
2508
+
2509
+ // Sanitise inbound history before any provider sees it. See stripLynkrBadges
2510
+ // comment for the M2.5-collapse rationale. Safe for all providers — the badge
2511
+ // is never legitimate prior-turn content.
2512
+ if (Array.isArray(body?.messages)) {
2513
+ body = { ...body, messages: stripLynkrBadges(body.messages) };
2514
+ }
2515
+
2168
2516
  // Determine provider via async tier routing
2169
2517
  // Thread workspace for code-graph integration (from X-Lynkr-Workspace header or body._workspace)
2170
2518
  const workspace = body._workspace || options.workspace || null;
@@ -2278,31 +2626,31 @@ async function invokeModel(body, options = {}) {
2278
2626
  // Try initial provider with circuit breaker
2279
2627
  const result = await breaker.execute(async () => {
2280
2628
  if (initialProvider === "azure-openai") {
2281
- return await invokeAzureOpenAI(body);
2629
+ return await invokeAzureOpenAI(body, incomingHeaders);
2282
2630
  } else if (initialProvider === "azure-anthropic") {
2283
- return await invokeAzureAnthropic(body);
2631
+ return await invokeAzureAnthropic(body, incomingHeaders);
2284
2632
  } else if (initialProvider === "ollama") {
2285
- return await invokeOllama(body);
2633
+ return await invokeOllama(body, incomingHeaders);
2286
2634
  } else if (initialProvider === "openrouter") {
2287
- return await invokeOpenRouter(body);
2635
+ return await invokeOpenRouter(body, incomingHeaders);
2288
2636
  } else if (initialProvider === "openai") {
2289
- return await invokeOpenAI(body);
2637
+ return await invokeOpenAI(body, incomingHeaders);
2290
2638
  } else if (initialProvider === "llamacpp") {
2291
- return await invokeLlamaCpp(body);
2639
+ return await invokeLlamaCpp(body, incomingHeaders);
2292
2640
  } else if (initialProvider === "lmstudio") {
2293
- return await invokeLMStudio(body);
2641
+ return await invokeLMStudio(body, incomingHeaders);
2294
2642
  } else if (initialProvider === "bedrock") {
2295
- return await invokeBedrock(body);
2643
+ return await invokeBedrock(body, incomingHeaders);
2296
2644
  } else if (initialProvider === "zai") {
2297
- return await invokeZai(body);
2645
+ return await invokeZai(body, incomingHeaders);
2298
2646
  } else if (initialProvider === "vertex") {
2299
- return await invokeVertex(body);
2647
+ return await invokeVertex(body, incomingHeaders);
2300
2648
  } else if (initialProvider === "moonshot") {
2301
- return await invokeMoonshot(body);
2649
+ return await invokeMoonshot(body, incomingHeaders);
2302
2650
  } else if (initialProvider === "codex") {
2303
- return await invokeCodex(body);
2651
+ return await invokeCodex(body, incomingHeaders);
2304
2652
  }
2305
- return await invokeDatabricks(body);
2653
+ return await invokeDatabricks(body, incomingHeaders);
2306
2654
  });
2307
2655
 
2308
2656
  // Record success metrics
@@ -2523,23 +2871,23 @@ async function invokeModel(body, options = {}) {
2523
2871
  // Execute fallback
2524
2872
  const fallbackResult = await fallbackBreaker.execute(async () => {
2525
2873
  if (fallbackProvider === "azure-openai") {
2526
- return await invokeAzureOpenAI(body);
2874
+ return await invokeAzureOpenAI(body, incomingHeaders);
2527
2875
  } else if (fallbackProvider === "azure-anthropic") {
2528
- return await invokeAzureAnthropic(body);
2876
+ return await invokeAzureAnthropic(body, incomingHeaders);
2529
2877
  } else if (fallbackProvider === "openrouter") {
2530
- return await invokeOpenRouter(body);
2878
+ return await invokeOpenRouter(body, incomingHeaders);
2531
2879
  } else if (fallbackProvider === "openai") {
2532
- return await invokeOpenAI(body);
2880
+ return await invokeOpenAI(body, incomingHeaders);
2533
2881
  } else if (fallbackProvider === "llamacpp") {
2534
- return await invokeLlamaCpp(body);
2882
+ return await invokeLlamaCpp(body, incomingHeaders);
2535
2883
  } else if (fallbackProvider === "zai") {
2536
- return await invokeZai(body);
2884
+ return await invokeZai(body, incomingHeaders);
2537
2885
  } else if (fallbackProvider === "vertex") {
2538
- return await invokeVertex(body);
2886
+ return await invokeVertex(body, incomingHeaders);
2539
2887
  } else if (fallbackProvider === "moonshot") {
2540
- return await invokeMoonshot(body);
2888
+ return await invokeMoonshot(body, incomingHeaders);
2541
2889
  }
2542
- return await invokeDatabricks(body);
2890
+ return await invokeDatabricks(body, incomingHeaders);
2543
2891
  });
2544
2892
 
2545
2893
  const fallbackLatency = Date.now() - fallbackStart;
@@ -2711,6 +3059,7 @@ function destroyHttpAgents() {
2711
3059
 
2712
3060
  module.exports = {
2713
3061
  invokeModel,
3062
+ stripLynkrBadges,
2714
3063
  destroyHttpAgents,
2715
3064
  normalizeBodyForConverse,
2716
3065
  };