lynkr 9.11.0 → 9.13.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 } : {}),
@@ -310,6 +310,18 @@ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
310
310
  incomingAuth = null;
311
311
  }
312
312
 
313
+ // Anthropic OAuth tokens are only meaningful at api.anthropic.com. When this
314
+ // provider points at any OTHER Anthropic-compatible host (Azure AI Foundry
315
+ // Claude, a corporate gateway), forwarding Claude Code's sk-ant-oat token
316
+ // earns a 401 there — and the configured x-api-key below is the right auth.
317
+ // This also means a subscription token never leaves the box unless the
318
+ // endpoint is Anthropic itself.
319
+ const isAnthropicDirect = String(config.azureAnthropic.endpoint || "").includes("api.anthropic.com");
320
+ if (incomingAuth && !isAnthropicDirect) {
321
+ logger.debug({ endpoint: config.azureAnthropic.endpoint }, "Azure Anthropic: non-Anthropic endpoint — using configured API key instead of client OAuth token");
322
+ incomingAuth = null;
323
+ }
324
+
313
325
  // Headers Anthropic uses to verify client identity for subscription OAuth tokens.
314
326
  // If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
315
327
  // headers (its terse anti-proxy response). Forward every Anthropic-relevant
@@ -876,6 +888,23 @@ function detectAzureFormat(url) {
876
888
  }
877
889
 
878
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
+
879
908
  async function invokeAzureOpenAI(body, incomingHeaders = {}) {
880
909
  if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
881
910
  throw new Error("Azure OpenAI endpoint or API key is not configured.");
@@ -992,6 +1021,16 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
992
1021
  const responsesInput = [];
993
1022
  // Track function call IDs for matching with outputs
994
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
+ };
995
1034
 
996
1035
  // Detect if this is a continuation request (has tool results)
997
1036
  // Azure content filter triggers on full system prompt in continuations
@@ -1053,36 +1092,32 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1053
1092
 
1054
1093
  for (const msg of azureBody.messages) {
1055
1094
  if (msg.role === "system") {
1056
- // For continuation requests, use minimal system prompt to avoid content filter
1057
- // Azure's jailbreak detection triggers on security-related text in continuations
1058
- if (hasToolResults) {
1059
- responsesInput.push({
1060
- type: "message",
1061
- role: "developer",
1062
- content: "You are a helpful coding assistant. Continue helping the user based on the tool results."
1063
- });
1064
- } else {
1065
- // Initial request - use full system prompt
1066
- responsesInput.push({
1067
- type: "message",
1068
- role: "developer",
1069
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
1070
- });
1071
- }
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
+ });
1072
1106
  } else if (msg.role === "user") {
1073
1107
  // Check if content contains tool_result blocks (Anthropic format)
1074
1108
  if (Array.isArray(msg.content)) {
1075
1109
  for (const block of msg.content) {
1076
1110
  if (block.type === "tool_result") {
1077
- const callId = block.tool_use_id || pendingCallIds.shift() || `call_${Date.now()}`;
1111
+ const callId = block.tool_use_id || pendingCallIds.shift() || stableCallId("result", block.content);
1078
1112
  responsesInput.push({
1079
1113
  type: "function_call_output",
1080
1114
  call_id: callId,
1081
1115
  output: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || "")
1082
1116
  });
1083
1117
  } else if (block.type === "text") {
1084
- // For continuation requests, strip system-reminder tags to avoid jailbreak filter
1085
- 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 || "");
1086
1121
  if (textContent) { // Only add if there's content after stripping
1087
1122
  responsesInput.push({
1088
1123
  type: "message",
@@ -1093,11 +1128,9 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1093
1128
  }
1094
1129
  }
1095
1130
  } else {
1096
- // For continuation requests, strip system-reminder tags to avoid jailbreak filter
1131
+ // Strip system-reminder tags uniformly on every turn (cache prefix).
1097
1132
  let userContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
1098
- if (hasToolResults) {
1099
- userContent = stripSystemReminders(userContent);
1100
- }
1133
+ userContent = stripSystemReminders(userContent);
1101
1134
  if (userContent) { // Only add if there's content after stripping
1102
1135
  responsesInput.push({
1103
1136
  type: "message",
@@ -1111,7 +1144,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1111
1144
  if (msg.tool_calls && msg.tool_calls.length > 0) {
1112
1145
  // OpenAI format: tool_calls array
1113
1146
  for (const tc of msg.tool_calls) {
1114
- 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);
1115
1148
  pendingCallIds.push(callId);
1116
1149
  responsesInput.push({
1117
1150
  type: "function_call",
@@ -1126,7 +1159,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1126
1159
  // Anthropic format: content is array of blocks
1127
1160
  for (const block of msg.content) {
1128
1161
  if (block.type === "tool_use") {
1129
- const callId = block.id || `call_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1162
+ const callId = block.id || stableCallId(block.name, block.input);
1130
1163
  pendingCallIds.push(callId);
1131
1164
  responsesInput.push({
1132
1165
  type: "function_call",
@@ -1152,7 +1185,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1152
1185
  }
1153
1186
  } else if (msg.role === "tool") {
1154
1187
  // Tool results become function_call_output
1155
- const callId = msg.tool_call_id || pendingCallIds.shift() || `call_${Date.now()}`;
1188
+ const callId = msg.tool_call_id || pendingCallIds.shift() || stableCallId("tool", msg.content);
1156
1189
  responsesInput.push({
1157
1190
  type: "function_call_output",
1158
1191
  call_id: callId,
@@ -1161,12 +1194,26 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1161
1194
  }
1162
1195
  }
1163
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;
1164
1203
  const responsesBody = {
1165
1204
  input: responsesInput,
1166
1205
  model: azureBody.model,
1167
- 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,
1168
1208
  tools: responsesTools,
1169
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) } : {}),
1170
1217
  stream: false
1171
1218
  };
1172
1219
  logger.debug({
@@ -1265,6 +1312,11 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1265
1312
  completion_tokens: result.json.usage.completion_tokens ?? result.json.usage.output_tokens ?? 0,
1266
1313
  total_tokens: result.json.usage.total_tokens
1267
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,
1268
1320
  } : undefined
1269
1321
  };
1270
1322
 
@@ -1373,6 +1425,69 @@ async function invokeOpenAI(body, incomingHeaders = {}) {
1373
1425
  return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
1374
1426
  }
1375
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
+
1376
1491
  async function invokeLlamaCpp(body, incomingHeaders = {}) {
1377
1492
  if (!config.llamacpp?.endpoint) {
1378
1493
  throw new Error("llama.cpp endpoint is not configured.");
@@ -1935,6 +2050,12 @@ async function invokeZai(body, incomingHeaders = {}) {
1935
2050
  zaiBody = { ...body };
1936
2051
  zaiBody.model = mappedModel;
1937
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
+
1938
2059
  // Inject standard tools if client didn't send any (passthrough mode)
1939
2060
  if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) {
1940
2061
  zaiBody.tools = STANDARD_TOOLS;
@@ -2094,7 +2215,11 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2094
2215
  max_tokens: body.max_tokens || 16384,
2095
2216
  temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
2096
2217
  top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
2097
- stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
2218
+ // Streaming honored since the Phase-2b sse-transformer landed: the raw
2219
+ // OpenAI SSE stream is returned below and reshaped in flight by the
2220
+ // orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered
2221
+ // requests keep the Anthropic conversion path unchanged.
2222
+ stream: body.stream ?? false,
2098
2223
  };
2099
2224
 
2100
2225
  if (Array.isArray(body.tools) && body.tools.length > 0) {
@@ -2132,6 +2257,13 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2132
2257
  throw err;
2133
2258
  }
2134
2259
 
2260
+ // Streaming request: hand the raw OpenAI SSE stream to the orchestrator's
2261
+ // stream branch (sse-transformer reshapes it). The Anthropic conversion
2262
+ // below is buffered-only — it would consume the stream.
2263
+ if (response?.stream) {
2264
+ return response;
2265
+ }
2266
+
2135
2267
  const rawMsg = response?.json?.choices?.[0]?.message;
2136
2268
  logger.debug({
2137
2269
  responseOk: response?.ok,
@@ -2168,6 +2300,129 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2168
2300
  return response;
2169
2301
  }
2170
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
+
2171
2426
  /**
2172
2427
  * Convert OpenAI response to Anthropic format
2173
2428
  */
@@ -2244,6 +2499,10 @@ function convertOpenAIToAnthropic(response) {
2244
2499
  usage: {
2245
2500
  input_tokens: response.usage?.prompt_tokens || 0,
2246
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,
2247
2506
  }
2248
2507
  };
2249
2508
  }
@@ -2728,6 +2987,7 @@ const PROVIDER_INVOKERS = {
2728
2987
  openrouter: invokeOpenRouter,
2729
2988
  edenai: invokeEdenAI,
2730
2989
  openai: invokeOpenAI,
2990
+ atlas: invokeAtlas,
2731
2991
  llamacpp: invokeLlamaCpp,
2732
2992
  lmstudio: invokeLMStudio,
2733
2993
  bedrock: invokeBedrock,
@@ -2735,6 +2995,7 @@ const PROVIDER_INVOKERS = {
2735
2995
  vertex: invokeVertex,
2736
2996
  moonshot: invokeMoonshot,
2737
2997
  codex: invokeCodex,
2998
+ baidu: invokeBaidu,
2738
2999
  };
2739
3000
 
2740
3001
  function invokeProvider(provider, body, incomingHeaders) {
@@ -3000,6 +3261,8 @@ async function invokeModel(body, options = {}) {
3000
3261
  pinned: routingResult.pinned ? 1 : 0,
3001
3262
  switch_reason: routingResult.switch_reason ?? null,
3002
3263
  cache_decision: routingResult._cacheDecision ?? null,
3264
+ cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
3265
+ cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
3003
3266
  });
3004
3267
 
3005
3268
  // WS5.4 — feedback loop (success path).
@@ -3351,6 +3614,8 @@ async function invokeModel(body, options = {}) {
3351
3614
  pinned: routingResult.pinned ? 1 : 0,
3352
3615
  switch_reason: routingResult.switch_reason ?? null,
3353
3616
  cache_decision: routingResult._cacheDecision ?? null,
3617
+ cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
3618
+ cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
3354
3619
  });
3355
3620
 
3356
3621
  // WS5.4 — feedback loop (fallback success). The served provider
@@ -3513,6 +3778,8 @@ module.exports = {
3513
3778
  invokeZai,
3514
3779
  invokeOllama,
3515
3780
  invokeMoonshot,
3781
+ invokeBaidu,
3782
+ invokeAtlas,
3516
3783
  PROVIDER_INVOKERS,
3517
3784
  stripLynkrBadges,
3518
3785
  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;
@@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) {
62
62
  return path.resolve(normalised);
63
63
  }
64
64
 
65
- const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot"]);
65
+ const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu"]);
66
66
  const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
67
67
 
68
68
  // Validate MODEL_PROVIDER early with a clear error message
@@ -115,6 +115,11 @@ const openAIModel = process.env.OPENAI_MODEL?.trim() || "gpt-4o";
115
115
  const openAIEndpoint = process.env.OPENAI_ENDPOINT?.trim() || "https://api.openai.com/v1/chat/completions";
116
116
  const openAIOrganization = process.env.OPENAI_ORGANIZATION?.trim() || null;
117
117
 
118
+ // Atlas Cloud configuration (OpenAI-compatible Chat Completions)
119
+ const atlasCloudApiKey = process.env.ATLASCLOUD_API_KEY?.trim() || null;
120
+ const atlasCloudModel = process.env.ATLASCLOUD_MODEL?.trim() || "qwen/qwen3.8-max";
121
+ const atlasCloudEndpoint = process.env.ATLASCLOUD_ENDPOINT?.trim() || "https://api.atlascloud.ai/v1/chat/completions";
122
+
118
123
  // llama.cpp configuration
119
124
  const llamacppEndpoint = process.env.LLAMACPP_ENDPOINT?.trim() || "http://localhost:8080";
120
125
  const llamacppModel = process.env.LLAMACPP_MODEL?.trim() || "default";
@@ -143,6 +148,11 @@ const moonshotApiKey = process.env.MOONSHOT_API_KEY?.trim() || null;
143
148
  const moonshotEndpoint = process.env.MOONSHOT_ENDPOINT?.trim() || "https://api.moonshot.ai/v1/chat/completions";
144
149
  const moonshotModel = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
145
150
 
151
+ // Baidu Qianfan (ERNIE) configuration - OpenAI-compatible v2 Chat Completions API
152
+ const baiduApiKey = process.env.BAIDU_API_KEY?.trim() || null;
153
+ const baiduEndpoint = process.env.BAIDU_ENDPOINT?.trim() || "https://qianfan.baidubce.com/v2/chat/completions";
154
+ const baiduModel = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
155
+
146
156
  // Vertex AI (Google Gemini) configuration
147
157
  const vertexApiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
148
158
  const vertexModel = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
@@ -292,6 +302,12 @@ if (modelProvider === "openai" && !openAIApiKey) {
292
302
  );
293
303
  }
294
304
 
305
+ if (modelProvider === "atlas" && !atlasCloudApiKey) {
306
+ throw new Error(
307
+ "Set ATLASCLOUD_API_KEY before starting the proxy.",
308
+ );
309
+ }
310
+
295
311
  if (modelProvider === "ollama") {
296
312
  try {
297
313
  new URL(ollamaEndpoint);
@@ -340,7 +356,7 @@ const tiersConfigured = !!(
340
356
  if (fallbackEnabled && tiersConfigured) {
341
357
  const localProviders = ["ollama", "llamacpp", "lmstudio"];
342
358
  if (localProviders.includes(fallbackProvider)) {
343
- throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, edenai, openai, bedrock`);
359
+ throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, edenai, openai, atlas, bedrock`);
344
360
  }
345
361
  let fallbackMisconfigured = false;
346
362
  if (fallbackProvider === "databricks" && (!rawBaseUrl || !apiKey)) {
@@ -355,6 +371,9 @@ if (fallbackEnabled && tiersConfigured) {
355
371
  if (fallbackProvider === "bedrock" && !bedrockApiKey) {
356
372
  fallbackMisconfigured = true;
357
373
  }
374
+ if (fallbackProvider === "atlas" && !atlasCloudApiKey) {
375
+ fallbackMisconfigured = true;
376
+ }
358
377
  if (fallbackMisconfigured) {
359
378
  console.warn(`[WARN] FALLBACK_PROVIDER='${fallbackProvider}' is enabled but missing credentials. Fallback will not work until configured.`);
360
379
  }
@@ -602,6 +621,11 @@ var config = {
602
621
  endpoint: openAIEndpoint,
603
622
  organization: openAIOrganization,
604
623
  },
624
+ atlas: {
625
+ apiKey: atlasCloudApiKey,
626
+ model: atlasCloudModel,
627
+ endpoint: atlasCloudEndpoint,
628
+ },
605
629
  llamacpp: {
606
630
  endpoint: llamacppEndpoint,
607
631
  model: llamacppModel,
@@ -634,6 +658,11 @@ var config = {
634
658
  endpoint: moonshotEndpoint,
635
659
  model: moonshotModel,
636
660
  },
661
+ baidu: {
662
+ apiKey: baiduApiKey,
663
+ endpoint: baiduEndpoint,
664
+ model: baiduModel,
665
+ },
637
666
  codex: {
638
667
  enabled: process.env.CODEX_ENABLED !== "false",
639
668
  binaryPath: process.env.CODEX_BINARY_PATH?.trim() || null,
@@ -654,7 +683,7 @@ var config = {
654
683
  fallbackProvider,
655
684
  },
656
685
  toolResultCompression: {
657
- enabled: true,
686
+ enabled: process.env.TOOL_RESULT_COMPRESSION_ENABLED !== "false",
658
687
  },
659
688
  caveman: {
660
689
  enabled: cavemanEnabled,
@@ -1085,6 +1114,9 @@ function reloadConfig() {
1085
1114
  config.edenai.model = process.env.EDENAI_MODEL ?? "openai/gpt-4o-mini";
1086
1115
  config.azureOpenAI.apiKey = process.env.AZURE_OPENAI_API_KEY?.trim() || null;
1087
1116
  config.openai.apiKey = process.env.OPENAI_API_KEY?.trim() || null;
1117
+ config.atlas.apiKey = process.env.ATLASCLOUD_API_KEY?.trim() || null;
1118
+ config.atlas.model = process.env.ATLASCLOUD_MODEL?.trim() || "qwen/qwen3.8-max";
1119
+ config.atlas.endpoint = process.env.ATLASCLOUD_ENDPOINT?.trim() || "https://api.atlascloud.ai/v1/chat/completions";
1088
1120
  config.bedrock.apiKey = process.env.AWS_BEDROCK_API_KEY?.trim() || null;
1089
1121
  config.zai.apiKey = process.env.ZAI_API_KEY?.trim() || null;
1090
1122
  config.zai.model = process.env.ZAI_MODEL?.trim() || "GLM-4.7";
@@ -1092,6 +1124,8 @@ function reloadConfig() {
1092
1124
  config.vertex.model = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
1093
1125
  config.moonshot.apiKey = process.env.MOONSHOT_API_KEY?.trim() || null;
1094
1126
  config.moonshot.model = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
1127
+ config.baidu.apiKey = process.env.BAIDU_API_KEY?.trim() || null;
1128
+ config.baidu.model = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
1095
1129
 
1096
1130
  // Model provider settings
1097
1131
  const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
@@ -9,20 +9,23 @@
9
9
  */
10
10
 
11
11
  const logger = require("../logger");
12
+ const crypto = require("crypto");
12
13
 
13
14
  // ── Tee Recovery Cache ───────────────────────────────────────────────
14
15
 
15
16
  const teeCache = new Map();
16
17
  const TEE_MAX_SIZE = 200;
17
18
  const TEE_TTL_MS = 5 * 60 * 1000; // 5 minutes
18
- let teeCounter = 0;
19
19
 
20
20
  function teeStore(original) {
21
- if (teeCache.size >= TEE_MAX_SIZE) {
21
+ // Content-derived id: the same tool result must compress to byte-identical
22
+ // output on every turn, or the tee marker breaks provider prompt-cache
23
+ // prefixes. (Was Date.now()+counter — new bytes in old messages each turn.)
24
+ const id = "tee_" + crypto.createHash("sha1").update(original).digest("hex").slice(0, 16);
25
+ if (!teeCache.has(id) && teeCache.size >= TEE_MAX_SIZE) {
22
26
  const oldest = teeCache.keys().next().value;
23
27
  teeCache.delete(oldest);
24
28
  }
25
- const id = `tee_${Date.now()}_${teeCounter++}`;
26
29
  teeCache.set(id, { content: original, createdAt: Date.now() });
27
30
  return id;
28
31
  }