lynkr 9.12.0 → 9.14.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.
- package/README.md +24 -10
- package/bin/lynkr-init.js +25 -2
- package/package.json +3 -2
- package/src/api/middleware/load-shedding.js +76 -13
- package/src/api/openai-router.js +79 -4
- package/src/api/providers-handler.js +28 -0
- package/src/clients/databricks.js +359 -40
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/resilience.js +9 -0
- package/src/config/index.js +37 -3
- package/src/context/tool-result-compressor.js +6 -3
- package/src/orchestrator/index.js +71 -7
- package/src/orchestrator/sse-transformer.js +28 -0
- package/src/routing/index.js +2 -0
- package/src/routing/intent-score.js +27 -2
- package/src/routing/model-tiers.js +4 -0
|
@@ -77,7 +77,7 @@ function _stripInternalFields(body) {
|
|
|
77
77
|
return cleaned || body;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride }, providerLabel) {
|
|
80
|
+
async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride }, providerLabel) {
|
|
81
81
|
const agent = url.startsWith('https:') ? httpsAgent : httpAgent;
|
|
82
82
|
body = _stripInternalFields(body);
|
|
83
83
|
const isStreaming = body.stream === true;
|
|
@@ -158,7 +158,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
|
|
|
158
158
|
|
|
159
159
|
return result;
|
|
160
160
|
}, {
|
|
161
|
-
maxRetries: config.apiRetry?.maxRetries || 3,
|
|
161
|
+
maxRetries: maxRetriesOverride ?? (config.apiRetry?.maxRetries || 3),
|
|
162
162
|
initialDelay: config.apiRetry?.initialDelay || 1000,
|
|
163
163
|
maxDelay: config.apiRetry?.maxDelay || 30000,
|
|
164
164
|
...(retryableStatusesOverride ? { retryableStatuses: retryableStatusesOverride } : {}),
|
|
@@ -888,6 +888,23 @@ function detectAzureFormat(url) {
|
|
|
888
888
|
}
|
|
889
889
|
|
|
890
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Stable per-conversation cache key for GPT-5.6 declared prompt caching.
|
|
893
|
+
* Prefers the session id; falls back to hashing the first user message,
|
|
894
|
+
* which is identical across every turn of the same conversation.
|
|
895
|
+
*/
|
|
896
|
+
function derivePromptCacheKey(body) {
|
|
897
|
+
if (body._sessionId) return String(body._sessionId).slice(0, 64);
|
|
898
|
+
const first = (body.messages || []).find(m => m.role === "user");
|
|
899
|
+
let text = "";
|
|
900
|
+
if (first) {
|
|
901
|
+
text = typeof first.content === "string"
|
|
902
|
+
? first.content
|
|
903
|
+
: JSON.stringify(first.content);
|
|
904
|
+
}
|
|
905
|
+
return "conv-" + crypto.createHash("sha1").update(text.slice(0, 4000)).digest("hex").slice(0, 32);
|
|
906
|
+
}
|
|
907
|
+
|
|
891
908
|
async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
892
909
|
if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
|
|
893
910
|
throw new Error("Azure OpenAI endpoint or API key is not configured.");
|
|
@@ -1004,6 +1021,16 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1004
1021
|
const responsesInput = [];
|
|
1005
1022
|
// Track function call IDs for matching with outputs
|
|
1006
1023
|
const pendingCallIds = [];
|
|
1024
|
+
// Fallback IDs must be DETERMINISTIC: the same history must render
|
|
1025
|
+
// byte-identically on every turn, or provider prompt caching never hits.
|
|
1026
|
+
// (Was Date.now()+Math.random(), which re-randomized history each turn.)
|
|
1027
|
+
let stableIdCounter = 0;
|
|
1028
|
+
const stableCallId = (name, args) => {
|
|
1029
|
+
const h = crypto.createHash("sha1")
|
|
1030
|
+
.update(`${name || ""}|${args || ""}|${stableIdCounter++}`)
|
|
1031
|
+
.digest("hex").slice(0, 16);
|
|
1032
|
+
return `call_${h}`;
|
|
1033
|
+
};
|
|
1007
1034
|
|
|
1008
1035
|
// Detect if this is a continuation request (has tool results)
|
|
1009
1036
|
// Azure content filter triggers on full system prompt in continuations
|
|
@@ -1065,36 +1092,32 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1065
1092
|
|
|
1066
1093
|
for (const msg of azureBody.messages) {
|
|
1067
1094
|
if (msg.role === "system") {
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
type: "message",
|
|
1080
|
-
role: "developer",
|
|
1081
|
-
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
|
1082
|
-
});
|
|
1083
|
-
}
|
|
1095
|
+
// The system prompt must be IDENTICAL on every turn of a conversation:
|
|
1096
|
+
// (a) swapping it per turn breaks provider prompt-cache prefixes, and
|
|
1097
|
+
// (b) replacing it on continuations silently dropped the client
|
|
1098
|
+
// agent's actual instructions mid-task. Strip system-reminder blocks
|
|
1099
|
+
// uniformly (that was the content-filter trigger, not the prompt).
|
|
1100
|
+
const sysText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
1101
|
+
responsesInput.push({
|
|
1102
|
+
type: "message",
|
|
1103
|
+
role: "developer",
|
|
1104
|
+
content: stripSystemReminders(sysText) || sysText
|
|
1105
|
+
});
|
|
1084
1106
|
} else if (msg.role === "user") {
|
|
1085
1107
|
// Check if content contains tool_result blocks (Anthropic format)
|
|
1086
1108
|
if (Array.isArray(msg.content)) {
|
|
1087
1109
|
for (const block of msg.content) {
|
|
1088
1110
|
if (block.type === "tool_result") {
|
|
1089
|
-
const callId = block.tool_use_id || pendingCallIds.shift() ||
|
|
1111
|
+
const callId = block.tool_use_id || pendingCallIds.shift() || stableCallId("result", block.content);
|
|
1090
1112
|
responsesInput.push({
|
|
1091
1113
|
type: "function_call_output",
|
|
1092
1114
|
call_id: callId,
|
|
1093
1115
|
output: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || "")
|
|
1094
1116
|
});
|
|
1095
1117
|
} else if (block.type === "text") {
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1118
|
+
// Strip system-reminder tags on EVERY turn (uniformly), so the
|
|
1119
|
+
// same message renders identically across turns (cache prefix).
|
|
1120
|
+
const textContent = stripSystemReminders(block.text || "");
|
|
1098
1121
|
if (textContent) { // Only add if there's content after stripping
|
|
1099
1122
|
responsesInput.push({
|
|
1100
1123
|
type: "message",
|
|
@@ -1105,11 +1128,9 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1105
1128
|
}
|
|
1106
1129
|
}
|
|
1107
1130
|
} else {
|
|
1108
|
-
//
|
|
1131
|
+
// Strip system-reminder tags uniformly on every turn (cache prefix).
|
|
1109
1132
|
let userContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
1110
|
-
|
|
1111
|
-
userContent = stripSystemReminders(userContent);
|
|
1112
|
-
}
|
|
1133
|
+
userContent = stripSystemReminders(userContent);
|
|
1113
1134
|
if (userContent) { // Only add if there's content after stripping
|
|
1114
1135
|
responsesInput.push({
|
|
1115
1136
|
type: "message",
|
|
@@ -1123,7 +1144,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1123
1144
|
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
1124
1145
|
// OpenAI format: tool_calls array
|
|
1125
1146
|
for (const tc of msg.tool_calls) {
|
|
1126
|
-
const callId = tc.id ||
|
|
1147
|
+
const callId = tc.id || stableCallId(tc.function?.name || tc.name, tc.function?.arguments);
|
|
1127
1148
|
pendingCallIds.push(callId);
|
|
1128
1149
|
responsesInput.push({
|
|
1129
1150
|
type: "function_call",
|
|
@@ -1138,7 +1159,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1138
1159
|
// Anthropic format: content is array of blocks
|
|
1139
1160
|
for (const block of msg.content) {
|
|
1140
1161
|
if (block.type === "tool_use") {
|
|
1141
|
-
const callId = block.id ||
|
|
1162
|
+
const callId = block.id || stableCallId(block.name, block.input);
|
|
1142
1163
|
pendingCallIds.push(callId);
|
|
1143
1164
|
responsesInput.push({
|
|
1144
1165
|
type: "function_call",
|
|
@@ -1164,7 +1185,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1164
1185
|
}
|
|
1165
1186
|
} else if (msg.role === "tool") {
|
|
1166
1187
|
// Tool results become function_call_output
|
|
1167
|
-
const callId = msg.tool_call_id || pendingCallIds.shift() ||
|
|
1188
|
+
const callId = msg.tool_call_id || pendingCallIds.shift() || stableCallId("tool", msg.content);
|
|
1168
1189
|
responsesInput.push({
|
|
1169
1190
|
type: "function_call_output",
|
|
1170
1191
|
call_id: callId,
|
|
@@ -1173,12 +1194,26 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1173
1194
|
}
|
|
1174
1195
|
}
|
|
1175
1196
|
|
|
1197
|
+
// Reasoning effort: honor the client's request, else the env default.
|
|
1198
|
+
// Without this, gpt-5.x reasoning models run at their shallowest setting.
|
|
1199
|
+
const reasoningEffort = body.reasoning_effort
|
|
1200
|
+
?? body.reasoning?.effort
|
|
1201
|
+
?? process.env.AZURE_OPENAI_REASONING_EFFORT
|
|
1202
|
+
?? null;
|
|
1176
1203
|
const responsesBody = {
|
|
1177
1204
|
input: responsesInput,
|
|
1178
1205
|
model: azureBody.model,
|
|
1179
|
-
|
|
1206
|
+
// gpt-5.x deployments store the cap under max_completion_tokens.
|
|
1207
|
+
max_output_tokens: azureBody.max_completion_tokens ?? azureBody.max_tokens,
|
|
1180
1208
|
tools: responsesTools,
|
|
1181
1209
|
tool_choice: azureBody.tool_choice,
|
|
1210
|
+
...(isGpt5 && reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}),
|
|
1211
|
+
// GPT-5.6 caching is declaration-based: a stable per-conversation key
|
|
1212
|
+
// makes prefix cache matching reliable (~90% discount on agent loops).
|
|
1213
|
+
// _sessionId is empty on the agentic path, so fall back to a hash of
|
|
1214
|
+
// the conversation's first user message — stable across every turn of
|
|
1215
|
+
// the same task. Keep per-key traffic under ~15 req/min.
|
|
1216
|
+
...(isGpt5 ? { prompt_cache_key: derivePromptCacheKey(body) } : {}),
|
|
1182
1217
|
stream: false
|
|
1183
1218
|
};
|
|
1184
1219
|
logger.debug({
|
|
@@ -1277,6 +1312,11 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1277
1312
|
completion_tokens: result.json.usage.completion_tokens ?? result.json.usage.output_tokens ?? 0,
|
|
1278
1313
|
total_tokens: result.json.usage.total_tokens
|
|
1279
1314
|
?? ((result.json.usage.input_tokens ?? 0) + (result.json.usage.output_tokens ?? 0)),
|
|
1315
|
+
// Provider-side prompt-cache hits (Responses API: input_tokens_details,
|
|
1316
|
+
// Chat Completions: prompt_tokens_details) — telemetry reads this name.
|
|
1317
|
+
cache_read_input_tokens: result.json.usage.input_tokens_details?.cached_tokens
|
|
1318
|
+
?? result.json.usage.prompt_tokens_details?.cached_tokens
|
|
1319
|
+
?? null,
|
|
1280
1320
|
} : undefined
|
|
1281
1321
|
};
|
|
1282
1322
|
|
|
@@ -1385,6 +1425,69 @@ async function invokeOpenAI(body, incomingHeaders = {}) {
|
|
|
1385
1425
|
return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
|
|
1386
1426
|
}
|
|
1387
1427
|
|
|
1428
|
+
async function invokeAtlas(body) {
|
|
1429
|
+
if (!config.atlas?.apiKey) {
|
|
1430
|
+
throw new Error("Atlas Cloud API key is not configured.");
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const {
|
|
1434
|
+
convertAnthropicToolsToOpenRouter,
|
|
1435
|
+
convertAnthropicMessagesToOpenRouter
|
|
1436
|
+
} = require("./openrouter-utils");
|
|
1437
|
+
|
|
1438
|
+
const endpoint = config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions";
|
|
1439
|
+
const headers = {
|
|
1440
|
+
"Authorization": `Bearer ${config.atlas.apiKey}`,
|
|
1441
|
+
"Content-Type": "application/json",
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
1445
|
+
if (body.system) {
|
|
1446
|
+
messages.unshift({ role: "system", content: body.system });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const atlasBody = {
|
|
1450
|
+
model: body._suggestionModeModel || body._tierModel || config.atlas.model || "qwen/qwen3.8-max",
|
|
1451
|
+
messages,
|
|
1452
|
+
temperature: body.temperature ?? 0.7,
|
|
1453
|
+
max_tokens: body.max_tokens ?? 16384,
|
|
1454
|
+
top_p: body.top_p ?? 1.0,
|
|
1455
|
+
stream: body.stream ?? false,
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
let toolsToSend = body.tools;
|
|
1459
|
+
let toolsInjected = false;
|
|
1460
|
+
if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
|
|
1461
|
+
toolsToSend = STANDARD_TOOLS;
|
|
1462
|
+
toolsInjected = true;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (toolsToSend.length > 0) {
|
|
1466
|
+
atlasBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
|
|
1467
|
+
atlasBody.parallel_tool_calls = false;
|
|
1468
|
+
atlasBody.tool_choice = "auto";
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
logger.debug({
|
|
1472
|
+
endpoint,
|
|
1473
|
+
model: atlasBody.model,
|
|
1474
|
+
hasTools: !!atlasBody.tools,
|
|
1475
|
+
toolCount: atlasBody.tools?.length || 0,
|
|
1476
|
+
toolsInjected,
|
|
1477
|
+
temperature: atlasBody.temperature,
|
|
1478
|
+
max_tokens: atlasBody.max_tokens,
|
|
1479
|
+
}, "=== ATLAS CLOUD REQUEST ===");
|
|
1480
|
+
|
|
1481
|
+
// Chat completions are billable POSTs, so Atlas requests are never replayed
|
|
1482
|
+
// automatically. Callers can retry explicitly with their own idempotency policy.
|
|
1483
|
+
return performJsonRequest(endpoint, {
|
|
1484
|
+
headers,
|
|
1485
|
+
body: atlasBody,
|
|
1486
|
+
maxRetriesOverride: 0,
|
|
1487
|
+
retryableStatusesOverride: [],
|
|
1488
|
+
}, "Atlas Cloud");
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1388
1491
|
async function invokeLlamaCpp(body, incomingHeaders = {}) {
|
|
1389
1492
|
if (!config.llamacpp?.endpoint) {
|
|
1390
1493
|
throw new Error("llama.cpp endpoint is not configured.");
|
|
@@ -1947,6 +2050,12 @@ async function invokeZai(body, incomingHeaders = {}) {
|
|
|
1947
2050
|
zaiBody = { ...body };
|
|
1948
2051
|
zaiBody.model = mappedModel;
|
|
1949
2052
|
|
|
2053
|
+
// Force buffered mode: with stream:true this endpoint returns ANTHROPIC
|
|
2054
|
+
// SSE, but the downstream transformer parses OPENAI SSE — every chunk is
|
|
2055
|
+
// unreadable and the client receives an empty completion. Buffered JSON
|
|
2056
|
+
// converts correctly; the router synthesizes client-side SSE as usual.
|
|
2057
|
+
zaiBody.stream = false;
|
|
2058
|
+
|
|
1950
2059
|
// Inject standard tools if client didn't send any (passthrough mode)
|
|
1951
2060
|
if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) {
|
|
1952
2061
|
zaiBody.tools = STANDARD_TOOLS;
|
|
@@ -2191,6 +2300,129 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2191
2300
|
return response;
|
|
2192
2301
|
}
|
|
2193
2302
|
|
|
2303
|
+
/**
|
|
2304
|
+
* Baidu Qianfan (ERNIE) Provider
|
|
2305
|
+
*
|
|
2306
|
+
* Qianfan's v2 endpoint is an OpenAI-compatible Chat Completions API
|
|
2307
|
+
* (bearer-token auth, key format "bce-v3/ALTAK-..."). Modeled on
|
|
2308
|
+
* invokeMoonshot: request side reuses the shared openrouter-utils
|
|
2309
|
+
* converters, response is converted to Anthropic shape locally so the
|
|
2310
|
+
* orchestrator branch is a plain passthrough.
|
|
2311
|
+
*
|
|
2312
|
+
* NOTE: sampling-param quirks and the exact model roster below are best
|
|
2313
|
+
* effort from public docs, not yet probed against a live key. Revisit the
|
|
2314
|
+
* modelMap and pinned-params logic (see Moonshot's kimi-k* precedent) once
|
|
2315
|
+
* real traffic surfaces 400s.
|
|
2316
|
+
*/
|
|
2317
|
+
async function invokeBaidu(body, incomingHeaders = {}) {
|
|
2318
|
+
if (!config.baidu?.apiKey) {
|
|
2319
|
+
throw new Error("Baidu API key is not configured. Set BAIDU_API_KEY in your .env file.");
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const {
|
|
2323
|
+
convertAnthropicToolsToOpenRouter,
|
|
2324
|
+
convertAnthropicMessagesToOpenRouter
|
|
2325
|
+
} = require("./openrouter-utils");
|
|
2326
|
+
|
|
2327
|
+
const endpoint = config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions";
|
|
2328
|
+
|
|
2329
|
+
// Model mapping: Anthropic names → Baidu ERNIE names.
|
|
2330
|
+
// Starting point only — refine once TIER_* mappings and live-probe results
|
|
2331
|
+
// are known (see file-level NOTE above).
|
|
2332
|
+
const modelMap = {
|
|
2333
|
+
"claude-sonnet-4-5-20250929": "ernie-4.5-turbo-128k",
|
|
2334
|
+
"claude-sonnet-4-5": "ernie-4.5-turbo-128k",
|
|
2335
|
+
"claude-sonnet-4.5": "ernie-4.5-turbo-128k",
|
|
2336
|
+
"claude-3-5-sonnet": "ernie-4.5-turbo-128k",
|
|
2337
|
+
"claude-opus-4-5": "ernie-x1.1",
|
|
2338
|
+
"claude-haiku-4-5-20251001": "ernie-speed-8k",
|
|
2339
|
+
"claude-haiku-4-5": "ernie-speed-8k",
|
|
2340
|
+
"claude-3-haiku": "ernie-speed-8k",
|
|
2341
|
+
};
|
|
2342
|
+
|
|
2343
|
+
const requestedModel = body._tierModel || body.model || config.baidu.model;
|
|
2344
|
+
// Honor tier-selected ERNIE ids (e.g. TIER_REASONING=baidu:ernie-x1.1)
|
|
2345
|
+
// instead of silently swapping in the .env default model.
|
|
2346
|
+
const mappedModel = modelMap[requestedModel]
|
|
2347
|
+
|| (/^ernie-/i.test(requestedModel || "") ? requestedModel : null)
|
|
2348
|
+
|| config.baidu.model
|
|
2349
|
+
|| "ernie-4.5-turbo-128k";
|
|
2350
|
+
|
|
2351
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
2352
|
+
|
|
2353
|
+
// Qianfan's OpenAI-compatible endpoint supports the system role natively.
|
|
2354
|
+
if (body.system) {
|
|
2355
|
+
const systemContent = Array.isArray(body.system)
|
|
2356
|
+
? body.system.map(s => s.text || s).join("\n")
|
|
2357
|
+
: body.system;
|
|
2358
|
+
messages.unshift({ role: "system", content: systemContent });
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const baiduBody = {
|
|
2362
|
+
model: mappedModel,
|
|
2363
|
+
messages,
|
|
2364
|
+
max_tokens: body.max_tokens || 16384,
|
|
2365
|
+
temperature: body.temperature ?? 0.7,
|
|
2366
|
+
top_p: body.top_p ?? 1.0,
|
|
2367
|
+
// Streaming honored once "baidu" is added to DEFAULT_OPENAI_SSE_PROVIDERS
|
|
2368
|
+
// (sse-transformer.js) and confirmed to match OpenAI SSE shape. Buffered
|
|
2369
|
+
// requests use the Anthropic conversion path below regardless.
|
|
2370
|
+
stream: body.stream ?? false,
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
2374
|
+
baiduBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
|
|
2375
|
+
baiduBody.tool_choice = "auto";
|
|
2376
|
+
baiduBody.parallel_tool_calls = false;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const headers = {
|
|
2380
|
+
"Content-Type": "application/json",
|
|
2381
|
+
"Authorization": `Bearer ${config.baidu.apiKey}`,
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2384
|
+
logger.debug({
|
|
2385
|
+
endpoint,
|
|
2386
|
+
model: baiduBody.model,
|
|
2387
|
+
originalModel: requestedModel,
|
|
2388
|
+
messageCount: baiduBody.messages?.length || 0,
|
|
2389
|
+
hasTools: !!baiduBody.tools,
|
|
2390
|
+
toolCount: baiduBody.tools?.length || 0,
|
|
2391
|
+
}, "=== Baidu Qianfan REQUEST ===");
|
|
2392
|
+
|
|
2393
|
+
const response = await performJsonRequest(endpoint, {
|
|
2394
|
+
headers,
|
|
2395
|
+
body: baiduBody,
|
|
2396
|
+
retryableStatusesOverride: [500, 502, 503, 504],
|
|
2397
|
+
}, "Baidu");
|
|
2398
|
+
|
|
2399
|
+
if (!response.ok && response.status === 429) {
|
|
2400
|
+
const err = new Error(`Baidu Qianfan rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
|
|
2401
|
+
err.status = 429;
|
|
2402
|
+
throw err;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Streaming request: hand the raw stream to the orchestrator's stream
|
|
2406
|
+
// branch. The Anthropic conversion below is buffered-only.
|
|
2407
|
+
if (response?.stream) {
|
|
2408
|
+
return response;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (response?.ok && response?.json) {
|
|
2412
|
+
const anthropicJson = convertOpenAIToAnthropic(response.json);
|
|
2413
|
+
return {
|
|
2414
|
+
ok: response.ok,
|
|
2415
|
+
status: response.status,
|
|
2416
|
+
json: anthropicJson,
|
|
2417
|
+
text: JSON.stringify(anthropicJson),
|
|
2418
|
+
contentType: "application/json",
|
|
2419
|
+
headers: response.headers,
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
return response;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2194
2426
|
/**
|
|
2195
2427
|
* Convert OpenAI response to Anthropic format
|
|
2196
2428
|
*/
|
|
@@ -2267,6 +2499,10 @@ function convertOpenAIToAnthropic(response) {
|
|
|
2267
2499
|
usage: {
|
|
2268
2500
|
input_tokens: response.usage?.prompt_tokens || 0,
|
|
2269
2501
|
output_tokens: response.usage?.completion_tokens || 0,
|
|
2502
|
+
// Provider-side prompt-cache hits — telemetry reads this field name.
|
|
2503
|
+
cache_read_input_tokens: response.usage?.cache_read_input_tokens
|
|
2504
|
+
?? response.usage?.prompt_tokens_details?.cached_tokens
|
|
2505
|
+
?? null,
|
|
2270
2506
|
}
|
|
2271
2507
|
};
|
|
2272
2508
|
}
|
|
@@ -2751,6 +2987,7 @@ const PROVIDER_INVOKERS = {
|
|
|
2751
2987
|
openrouter: invokeOpenRouter,
|
|
2752
2988
|
edenai: invokeEdenAI,
|
|
2753
2989
|
openai: invokeOpenAI,
|
|
2990
|
+
atlas: invokeAtlas,
|
|
2754
2991
|
llamacpp: invokeLlamaCpp,
|
|
2755
2992
|
lmstudio: invokeLMStudio,
|
|
2756
2993
|
bedrock: invokeBedrock,
|
|
@@ -2758,6 +2995,7 @@ const PROVIDER_INVOKERS = {
|
|
|
2758
2995
|
vertex: invokeVertex,
|
|
2759
2996
|
moonshot: invokeMoonshot,
|
|
2760
2997
|
codex: invokeCodex,
|
|
2998
|
+
baidu: invokeBaidu,
|
|
2761
2999
|
};
|
|
2762
3000
|
|
|
2763
3001
|
function invokeProvider(provider, body, incomingHeaders) {
|
|
@@ -3303,12 +3541,101 @@ async function invokeModel(body, options = {}) {
|
|
|
3303
3541
|
|
|
3304
3542
|
const fallbackStart = Date.now();
|
|
3305
3543
|
|
|
3544
|
+
// Strip the failed primary's routing stamps before re-dispatch: every
|
|
3545
|
+
// provider client prefers body._tierModel over its own configured
|
|
3546
|
+
// model, so leaving the failed tier's model on the body makes the
|
|
3547
|
+
// fallback provider serve a model it doesn't have — azure-openai
|
|
3548
|
+
// treats it as the deployment id and 404s with DeploymentNotFound
|
|
3549
|
+
// (live incident 2026-08-19). The fallback provider must fall through
|
|
3550
|
+
// to its own configured model.
|
|
3551
|
+
const fallbackBody = { ...body };
|
|
3552
|
+
delete fallbackBody._tierModel;
|
|
3553
|
+
delete fallbackBody._suggestionModeModel;
|
|
3554
|
+
|
|
3306
3555
|
const fallbackResult = await fallbackBreaker.execute(async () => {
|
|
3307
|
-
return await invokeProvider(fallbackProvider,
|
|
3556
|
+
return await invokeProvider(fallbackProvider, fallbackBody, incomingHeaders);
|
|
3308
3557
|
});
|
|
3309
3558
|
|
|
3310
3559
|
const fallbackLatency = Date.now() - fallbackStart;
|
|
3311
3560
|
|
|
3561
|
+
// Provider clients return {ok:false} on 4xx/5xx instead of throwing,
|
|
3562
|
+
// so a fallback rung that answered with an error body used to sail
|
|
3563
|
+
// through the success path below — logging "Fallback to cloud
|
|
3564
|
+
// provider succeeded" and recording status_code:200 telemetry for
|
|
3565
|
+
// what was actually a double failure. Treat it as one.
|
|
3566
|
+
if (fallbackResult?.ok === false) {
|
|
3567
|
+
const fbErrMessage = fallbackResult.json?.error?.message || String(fallbackResult.text || "").slice(0, 120) || "error response";
|
|
3568
|
+
|
|
3569
|
+
metricsCollector.recordFallbackFailure();
|
|
3570
|
+
metricsCollector.recordDatabricksRequest(false, retries);
|
|
3571
|
+
healthTracker.recordFailure(fallbackProvider, new Error(fbErrMessage), fallbackResult.status);
|
|
3572
|
+
|
|
3573
|
+
logger.warn({
|
|
3574
|
+
originalProvider: initialProvider,
|
|
3575
|
+
fallbackProvider,
|
|
3576
|
+
status: fallbackResult.status,
|
|
3577
|
+
error: fbErrMessage,
|
|
3578
|
+
}, "Fallback provider answered with an error response");
|
|
3579
|
+
|
|
3580
|
+
// Thread the whole trail into the error payload so the client sees
|
|
3581
|
+
// every failure, not just this one (e.g. "moonshot: quota" matters
|
|
3582
|
+
// more than "azure: 404").
|
|
3583
|
+
failureTrail.push(`${fallbackProvider}: HTTP ${fallbackResult.status} — ${fbErrMessage}`);
|
|
3584
|
+
if (fallbackResult.json && typeof fallbackResult.json === "object") {
|
|
3585
|
+
fallbackResult.json._lynkr_failure_trail = failureTrail;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
const fbFailLatency = Date.now() - startTime;
|
|
3589
|
+
telemetry.record({
|
|
3590
|
+
request_id: crypto.randomUUID(),
|
|
3591
|
+
session_id: body._sessionId || null,
|
|
3592
|
+
timestamp: Date.now(),
|
|
3593
|
+
complexity_score: routingResult.score ?? null,
|
|
3594
|
+
tier: routingDecision.tier,
|
|
3595
|
+
provider: fallbackProvider,
|
|
3596
|
+
model: routingDecision.model ?? body._tierModel ?? null,
|
|
3597
|
+
routing_method: "fallback",
|
|
3598
|
+
was_fallback: true,
|
|
3599
|
+
latency_ms: fbFailLatency,
|
|
3600
|
+
status_code: fallbackResult.status || null,
|
|
3601
|
+
error_type: "fallback_error_response",
|
|
3602
|
+
quality_score: 0,
|
|
3603
|
+
base_tier: routingResult.base_tier ?? null,
|
|
3604
|
+
escalation_source: routingResult.escalation_source ?? null,
|
|
3605
|
+
propensity: routingResult.propensity ?? null,
|
|
3606
|
+
candidates: routingResult.candidates ?? null,
|
|
3607
|
+
pinned: routingResult.pinned ? 1 : 0,
|
|
3608
|
+
switch_reason: routingResult.switch_reason ?? null,
|
|
3609
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3610
|
+
});
|
|
3611
|
+
|
|
3612
|
+
// WS5.4 — feedback loop (fallback answered with an error). Same
|
|
3613
|
+
// hard-negative signal as a double failure.
|
|
3614
|
+
recordFeedbackOutcome({
|
|
3615
|
+
routingResult,
|
|
3616
|
+
body,
|
|
3617
|
+
outcome: {
|
|
3618
|
+
qualityScore: 0,
|
|
3619
|
+
costUsd: null,
|
|
3620
|
+
latencyMs: fbFailLatency,
|
|
3621
|
+
statusCode: fallbackResult.status || null,
|
|
3622
|
+
errorType: "fallback_error_response",
|
|
3623
|
+
wasFallback: true,
|
|
3624
|
+
},
|
|
3625
|
+
});
|
|
3626
|
+
|
|
3627
|
+
return {
|
|
3628
|
+
...fallbackResult,
|
|
3629
|
+
actualProvider: fallbackProvider,
|
|
3630
|
+
routingDecision: {
|
|
3631
|
+
...routingDecision,
|
|
3632
|
+
provider: fallbackProvider,
|
|
3633
|
+
method: 'fallback',
|
|
3634
|
+
fallbackReason: reason,
|
|
3635
|
+
},
|
|
3636
|
+
};
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3312
3639
|
metricsCollector.recordFallbackSuccess(fallbackLatency);
|
|
3313
3640
|
metricsCollector.recordDatabricksRequest(true, retries);
|
|
3314
3641
|
healthTracker.recordSuccess(fallbackProvider, fallbackLatency);
|
|
@@ -3397,16 +3724,6 @@ async function invokeModel(body, options = {}) {
|
|
|
3397
3724
|
},
|
|
3398
3725
|
});
|
|
3399
3726
|
|
|
3400
|
-
// The last rung also answered with an error — thread the whole trail
|
|
3401
|
-
// into the error payload so the client sees every failure, not just
|
|
3402
|
-
// this one (e.g. "moonshot: quota" matters more than "azure: 404").
|
|
3403
|
-
if (fallbackResult?.ok === false) {
|
|
3404
|
-
failureTrail.push(`${fallbackProvider}: HTTP ${fallbackResult.status} — ${fallbackResult.json?.error?.message || String(fallbackResult.text || "").slice(0, 120) || "error response"}`);
|
|
3405
|
-
if (fallbackResult.json && typeof fallbackResult.json === "object") {
|
|
3406
|
-
fallbackResult.json._lynkr_failure_trail = failureTrail;
|
|
3407
|
-
}
|
|
3408
|
-
}
|
|
3409
|
-
|
|
3410
3727
|
return {
|
|
3411
3728
|
...fallbackResult,
|
|
3412
3729
|
actualProvider: fallbackProvider,
|
|
@@ -3540,6 +3857,8 @@ module.exports = {
|
|
|
3540
3857
|
invokeZai,
|
|
3541
3858
|
invokeOllama,
|
|
3542
3859
|
invokeMoonshot,
|
|
3860
|
+
invokeBaidu,
|
|
3861
|
+
invokeAtlas,
|
|
3543
3862
|
PROVIDER_INVOKERS,
|
|
3544
3863
|
stripLynkrBadges,
|
|
3545
3864
|
destroyHttpAgents,
|
|
@@ -11,7 +11,7 @@ const NATIVE_THINKING_BEDROCK_MODELS = [
|
|
|
11
11
|
"claude-haiku",
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
-
const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai"]);
|
|
14
|
+
const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai", "atlas"]);
|
|
15
15
|
|
|
16
16
|
function supportsNativeThinking(providerType, model) {
|
|
17
17
|
if (NATIVE_THINKING_PROVIDERS.has(providerType)) return true;
|
|
@@ -381,6 +381,15 @@ async function withCockatielRetry(fn, options = {}) {
|
|
|
381
381
|
const retryAfterDate = new Date(retryAfter);
|
|
382
382
|
delay = retryAfterDate.getTime() - Date.now();
|
|
383
383
|
}
|
|
384
|
+
// Retry-After is a floor, not the full answer: Azure sends
|
|
385
|
+
// "Retry-After: 1" on token-window 429s that need far longer to
|
|
386
|
+
// drain, so honoring it literally re-hammers the window every
|
|
387
|
+
// second and it never recovers (2026-08-19 incident). Back off
|
|
388
|
+
// exponentially on top of the header, with jitter.
|
|
389
|
+
const floorMs = Math.max(0, delay || 0);
|
|
390
|
+
const backoffMs = 2000 * Math.pow(config.backoffMultiplier, attempt);
|
|
391
|
+
const cappedMs = Math.min(Math.max(floorMs, backoffMs), 60000);
|
|
392
|
+
delay = cappedMs + cappedMs * config.jitterFactor * Math.random();
|
|
384
393
|
} else {
|
|
385
394
|
// Exponential backoff with longer delays for rate limiting
|
|
386
395
|
const baseDelay = 2000 * Math.pow(config.backoffMultiplier, attempt);
|