lynkr 9.5.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;
320
532
  }
321
- convertedMessages.push({ role: msg.role, content: content || '' });
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
+ }
587
+ }
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
  }
@@ -1104,7 +1387,65 @@ async function invokeLMStudio(body) {
1104
1387
  return performJsonRequest(endpoint, { headers, body: lmstudioBody }, "LM Studio");
1105
1388
  }
1106
1389
 
1107
- async function invokeBedrock(body) {
1390
+ /**
1391
+ * Flatten an Anthropic-style content value into a plain string for the
1392
+ * Bedrock Converse API.
1393
+ *
1394
+ * Prompt-cache injection (injectPromptCaching) rewrites string `system`
1395
+ * fields and message `content` into arrays of `{ type, text, cache_control }`
1396
+ * blocks. The Converse API has no `cache_control` concept and expects
1397
+ * `system: [{ text: "<string>" }]` and message content blocks shaped as
1398
+ * `{ text: "<string>" }`. Passing the injected array through unchanged would
1399
+ * either drop the cache markers silently or nest an array under `text`,
1400
+ * producing a ValidationException.
1401
+ *
1402
+ * @param {string|Array|undefined} value - String or array of content blocks
1403
+ * @returns {string} Concatenated plain text
1404
+ */
1405
+ function flattenContentToText(value) {
1406
+ if (value == null) return "";
1407
+ if (typeof value === "string") return value;
1408
+ if (Array.isArray(value)) {
1409
+ return value
1410
+ .map(block => {
1411
+ if (typeof block === "string") return block;
1412
+ if (block && typeof block === "object") return block.text || block.content || "";
1413
+ return "";
1414
+ })
1415
+ .join("");
1416
+ }
1417
+ return String(value);
1418
+ }
1419
+
1420
+ /**
1421
+ * Normalize a request body for the Bedrock Converse API.
1422
+ *
1423
+ * Strips `cache_control` markers and flattens any array-shaped `system` /
1424
+ * message `content` (left behind by prompt-cache injection) back into the
1425
+ * plain strings the Converse API expects. Returns a shallow copy with a
1426
+ * normalized `messages` array; the original body is not mutated.
1427
+ *
1428
+ * @param {Object} body - Anthropic-format request body
1429
+ * @returns {Object} Body safe for Converse request construction
1430
+ */
1431
+ function normalizeBodyForConverse(body) {
1432
+ const normalized = { ...body };
1433
+
1434
+ if (normalized.system !== undefined) {
1435
+ normalized.system = flattenContentToText(normalized.system);
1436
+ }
1437
+
1438
+ if (Array.isArray(normalized.messages)) {
1439
+ normalized.messages = normalized.messages.map(msg => ({
1440
+ ...msg,
1441
+ content: flattenContentToText(msg.content),
1442
+ }));
1443
+ }
1444
+
1445
+ return normalized;
1446
+ }
1447
+
1448
+ async function invokeBedrock(body, incomingHeaders = {}) {
1108
1449
  // 1. Validate Bearer token
1109
1450
  if (!config.bedrock?.apiKey) {
1110
1451
  throw new Error(
@@ -1130,7 +1471,10 @@ async function invokeBedrock(body) {
1130
1471
  }, "=== INJECTING STANDARD TOOLS (Bedrock) ===");
1131
1472
  }
1132
1473
 
1133
- const bedrockBody = { ...body, tools: toolsToSend };
1474
+ // Normalize away cache_control / array shapes that prompt-cache injection
1475
+ // may have applied: the Converse API expects plain-string system and
1476
+ // message content, not Anthropic cache_control blocks.
1477
+ const bedrockBody = { ...normalizeBodyForConverse(body), tools: toolsToSend };
1134
1478
 
1135
1479
  // 4. Detect model family and convert format
1136
1480
  const modelId = body._tierModel || config.bedrock.modelId;
@@ -1295,7 +1639,7 @@ async function invokeBedrock(body) {
1295
1639
  * Z.AI offers GLM models through an Anthropic-compatible API at ~1/7 the cost.
1296
1640
  * Minimal transformation needed - mostly passthrough with model mapping.
1297
1641
  */
1298
- async function invokeZai(body) {
1642
+ async function invokeZai(body, incomingHeaders = {}) {
1299
1643
  if (!config.zai?.apiKey) {
1300
1644
  throw new Error("Z.AI API key is not configured. Set ZAI_API_KEY in your .env file.");
1301
1645
  }
@@ -1485,7 +1829,7 @@ async function invokeZai(body) {
1485
1829
  * Moonshot offers Kimi models through an OpenAI-compatible chat completions API.
1486
1830
  * Uses native system role support (unlike Z.AI which merges into user message).
1487
1831
  */
1488
- async function invokeMoonshot(body) {
1832
+ async function invokeMoonshot(body, incomingHeaders = {}) {
1489
1833
  if (!config.moonshot?.apiKey) {
1490
1834
  throw new Error("Moonshot API key is not configured. Set MOONSHOT_API_KEY in your .env file.");
1491
1835
  }
@@ -1735,7 +2079,7 @@ function sanitizeSchemaForGemini(schema) {
1735
2079
  * Supports Google Gemini models through Vertex AI.
1736
2080
  * Converts Anthropic format to Gemini format and back.
1737
2081
  */
1738
- async function invokeVertex(body) {
2082
+ async function invokeVertex(body, incomingHeaders = {}) {
1739
2083
  const apiKey = config.vertex?.apiKey;
1740
2084
 
1741
2085
  if (!apiKey) {
@@ -1991,7 +2335,7 @@ function convertGeminiToAnthropic(response, requestedModel) {
1991
2335
  };
1992
2336
  }
1993
2337
 
1994
- async function invokeCodex(body) {
2338
+ async function invokeCodex(body, incomingHeaders = {}) {
1995
2339
  const { getCodexProcess } = require("./codex-process");
1996
2340
  const { convertAnthropicToCodexPrompt, convertCodexResponseToAnthropic } = require("./codex-utils");
1997
2341
 
@@ -2098,12 +2442,77 @@ function captureResponseText(resultJson) {
2098
2442
  return text ? text.slice(0, TELEMETRY_TEXT_MAXLEN) : null;
2099
2443
  }
2100
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
+
2101
2500
  async function invokeModel(body, options = {}) {
2102
2501
  const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
2103
2502
  const metricsCollector = getMetricsCollector();
2104
2503
  const registry = getCircuitBreakerRegistry();
2105
2504
  const healthTracker = getHealthTracker();
2106
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
+
2107
2516
  // Determine provider via async tier routing
2108
2517
  // Thread workspace for code-graph integration (from X-Lynkr-Workspace header or body._workspace)
2109
2518
  const workspace = body._workspace || options.workspace || null;
@@ -2124,6 +2533,13 @@ async function invokeModel(body, options = {}) {
2124
2533
  const { injectPromptCaching } = require('./prompt-cache-injection');
2125
2534
  injectPromptCaching(body, initialProvider);
2126
2535
 
2536
+ // Always-on markdown formatting guard. Stops formatting-weak backends
2537
+ // (Moonshot/Kimi, Ollama, etc.) from emitting mangled ASCII box-drawing
2538
+ // "diagrams". Keyed off the routing-resolved provider/model; skipped for
2539
+ // Claude-family backends which already format cleanly.
2540
+ const { injectFormatGuard } = require('../context/output-format-guard');
2541
+ injectFormatGuard(body, { provider: initialProvider, model: tierSelectedModel });
2542
+
2127
2543
  // Build routing decision object for response headers
2128
2544
  const routingDecision = {
2129
2545
  provider: initialProvider,
@@ -2210,31 +2626,31 @@ async function invokeModel(body, options = {}) {
2210
2626
  // Try initial provider with circuit breaker
2211
2627
  const result = await breaker.execute(async () => {
2212
2628
  if (initialProvider === "azure-openai") {
2213
- return await invokeAzureOpenAI(body);
2629
+ return await invokeAzureOpenAI(body, incomingHeaders);
2214
2630
  } else if (initialProvider === "azure-anthropic") {
2215
- return await invokeAzureAnthropic(body);
2631
+ return await invokeAzureAnthropic(body, incomingHeaders);
2216
2632
  } else if (initialProvider === "ollama") {
2217
- return await invokeOllama(body);
2633
+ return await invokeOllama(body, incomingHeaders);
2218
2634
  } else if (initialProvider === "openrouter") {
2219
- return await invokeOpenRouter(body);
2635
+ return await invokeOpenRouter(body, incomingHeaders);
2220
2636
  } else if (initialProvider === "openai") {
2221
- return await invokeOpenAI(body);
2637
+ return await invokeOpenAI(body, incomingHeaders);
2222
2638
  } else if (initialProvider === "llamacpp") {
2223
- return await invokeLlamaCpp(body);
2639
+ return await invokeLlamaCpp(body, incomingHeaders);
2224
2640
  } else if (initialProvider === "lmstudio") {
2225
- return await invokeLMStudio(body);
2641
+ return await invokeLMStudio(body, incomingHeaders);
2226
2642
  } else if (initialProvider === "bedrock") {
2227
- return await invokeBedrock(body);
2643
+ return await invokeBedrock(body, incomingHeaders);
2228
2644
  } else if (initialProvider === "zai") {
2229
- return await invokeZai(body);
2645
+ return await invokeZai(body, incomingHeaders);
2230
2646
  } else if (initialProvider === "vertex") {
2231
- return await invokeVertex(body);
2647
+ return await invokeVertex(body, incomingHeaders);
2232
2648
  } else if (initialProvider === "moonshot") {
2233
- return await invokeMoonshot(body);
2649
+ return await invokeMoonshot(body, incomingHeaders);
2234
2650
  } else if (initialProvider === "codex") {
2235
- return await invokeCodex(body);
2651
+ return await invokeCodex(body, incomingHeaders);
2236
2652
  }
2237
- return await invokeDatabricks(body);
2653
+ return await invokeDatabricks(body, incomingHeaders);
2238
2654
  });
2239
2655
 
2240
2656
  // Record success metrics
@@ -2323,6 +2739,71 @@ async function invokeModel(body, options = {}) {
2323
2739
  healthTracker.recordFailure(initialProvider, err, err.status);
2324
2740
  getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
2325
2741
 
2742
+ // Tier-aware escalate-then-demote fallback (TIER_FALLBACK_ENABLED).
2743
+ // On failure, try a MORE capable tier first (climb toward REASONING); only
2744
+ // if every higher tier is unavailable do we fall downward to SIMPLE/local.
2745
+ // Runs before the flat global fallback below and is never silent.
2746
+ if (config.tierFallback?.enabled && !options._tierFallbackInner && routingDecision.tier) {
2747
+ const { getFallbackChain } = require("../routing/tier-fallback");
2748
+ const chain = getFallbackChain(routingDecision.tier);
2749
+ for (const cand of chain) {
2750
+ try {
2751
+ logger.warn({
2752
+ fromTier: routingDecision.tier,
2753
+ fromProvider: initialProvider,
2754
+ toTier: cand.tier,
2755
+ toProvider: cand.provider,
2756
+ toModel: cand.model,
2757
+ direction: cand.direction,
2758
+ }, "[TierFallback] Primary tier failed — attempting tier fallback");
2759
+
2760
+ const attempt = await invokeModel(
2761
+ { ...body, _tierModel: cand.model },
2762
+ {
2763
+ forceProvider: cand.provider,
2764
+ _tierFallbackInner: true,
2765
+ disableFallback: true,
2766
+ _cascadeInner: true,
2767
+ workspace,
2768
+ tenantPolicy,
2769
+ }
2770
+ );
2771
+
2772
+ metricsCollector.recordFallbackAttempt(initialProvider, cand.provider, "tier_fallback");
2773
+ logger.warn({
2774
+ servedTier: cand.tier,
2775
+ servedProvider: cand.provider,
2776
+ fromTier: routingDecision.tier,
2777
+ direction: cand.direction,
2778
+ }, "[TierFallback] Served by tier fallback");
2779
+
2780
+ return {
2781
+ ...attempt,
2782
+ actualProvider: cand.provider,
2783
+ routingDecision: {
2784
+ ...routingDecision,
2785
+ provider: cand.provider,
2786
+ model: cand.model,
2787
+ servedTier: cand.tier,
2788
+ fromTier: routingDecision.tier,
2789
+ fallback: true,
2790
+ fallbackDirection: cand.direction,
2791
+ method: "tier_fallback",
2792
+ },
2793
+ };
2794
+ } catch (innerErr) {
2795
+ logger.warn(
2796
+ { toProvider: cand.provider, error: innerErr.message },
2797
+ "[TierFallback] Candidate failed, trying next"
2798
+ );
2799
+ }
2800
+ }
2801
+ logger.warn(
2802
+ { fromTier: routingDecision.tier },
2803
+ "[TierFallback] All tier candidates exhausted — falling through"
2804
+ );
2805
+ }
2806
+
2326
2807
  // Check if we should fallback (any provider can fall back, not just ollama)
2327
2808
  const shouldFallback =
2328
2809
  isFallbackEnabled() &&
@@ -2390,23 +2871,23 @@ async function invokeModel(body, options = {}) {
2390
2871
  // Execute fallback
2391
2872
  const fallbackResult = await fallbackBreaker.execute(async () => {
2392
2873
  if (fallbackProvider === "azure-openai") {
2393
- return await invokeAzureOpenAI(body);
2874
+ return await invokeAzureOpenAI(body, incomingHeaders);
2394
2875
  } else if (fallbackProvider === "azure-anthropic") {
2395
- return await invokeAzureAnthropic(body);
2876
+ return await invokeAzureAnthropic(body, incomingHeaders);
2396
2877
  } else if (fallbackProvider === "openrouter") {
2397
- return await invokeOpenRouter(body);
2878
+ return await invokeOpenRouter(body, incomingHeaders);
2398
2879
  } else if (fallbackProvider === "openai") {
2399
- return await invokeOpenAI(body);
2880
+ return await invokeOpenAI(body, incomingHeaders);
2400
2881
  } else if (fallbackProvider === "llamacpp") {
2401
- return await invokeLlamaCpp(body);
2882
+ return await invokeLlamaCpp(body, incomingHeaders);
2402
2883
  } else if (fallbackProvider === "zai") {
2403
- return await invokeZai(body);
2884
+ return await invokeZai(body, incomingHeaders);
2404
2885
  } else if (fallbackProvider === "vertex") {
2405
- return await invokeVertex(body);
2886
+ return await invokeVertex(body, incomingHeaders);
2406
2887
  } else if (fallbackProvider === "moonshot") {
2407
- return await invokeMoonshot(body);
2888
+ return await invokeMoonshot(body, incomingHeaders);
2408
2889
  }
2409
- return await invokeDatabricks(body);
2890
+ return await invokeDatabricks(body, incomingHeaders);
2410
2891
  });
2411
2892
 
2412
2893
  const fallbackLatency = Date.now() - fallbackStart;
@@ -2578,5 +3059,7 @@ function destroyHttpAgents() {
2578
3059
 
2579
3060
  module.exports = {
2580
3061
  invokeModel,
3062
+ stripLynkrBadges,
2581
3063
  destroyHttpAgents,
3064
+ normalizeBodyForConverse,
2582
3065
  };