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.
@@ -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
- // For continuation requests, use minimal system prompt to avoid content filter
1069
- // Azure's jailbreak detection triggers on security-related text in continuations
1070
- if (hasToolResults) {
1071
- responsesInput.push({
1072
- type: "message",
1073
- role: "developer",
1074
- content: "You are a helpful coding assistant. Continue helping the user based on the tool results."
1075
- });
1076
- } else {
1077
- // Initial request - use full system prompt
1078
- responsesInput.push({
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() || `call_${Date.now()}`;
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
- // For continuation requests, strip system-reminder tags to avoid jailbreak filter
1097
- const textContent = hasToolResults ? stripSystemReminders(block.text || "") : (block.text || "");
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
- // For continuation requests, strip system-reminder tags to avoid jailbreak filter
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
- if (hasToolResults) {
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 || `call_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
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 || `call_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
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() || `call_${Date.now()}`;
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
- max_output_tokens: azureBody.max_tokens,
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, body, incomingHeaders);
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);