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.
- package/README.md +32 -302
- package/bin/cli.js +6 -0
- package/bin/lynkr-init.js +679 -0
- package/bin/wrap.js +686 -0
- package/install.sh +26 -81
- package/package.json +4 -3
- package/scripts/build-knn-index.js +1 -1
- package/scripts/convert-routellm.py +105 -0
- package/src/api/router.js +694 -21
- package/src/auth-mode.js +116 -0
- package/src/clients/databricks.js +416 -67
- package/src/clients/prompt-cache-injection.js +15 -0
- package/src/orchestrator/index.js +120 -60
- package/src/routing/index.js +23 -4
- package/src/routing/knn-router.js +9 -2
- package/src/routing/model-tiers.js +34 -0
|
@@ -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
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
}, 'Ollama:
|
|
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 ??
|
|
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
|
};
|