lynkr 9.12.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.
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <td align="center"><strong>84%</strong><br/>JSON Compression</td>
16
16
  <td align="center"><strong>53%</strong><br/>Tool Token Reduction</td>
17
17
  <td align="center"><strong>&lt;300ms</strong><br/>Semantic Cache Hits</td>
18
- <td align="center"><strong>13+</strong><br/>LLM Providers</td>
18
+ <td align="center"><strong>14+</strong><br/>LLM Providers</td>
19
19
  <td align="center"><strong>0</strong><br/>Code Changes Required</td>
20
20
  </tr>
21
21
  </table>
@@ -28,26 +28,25 @@
28
28
 
29
29
  ## πŸš€ New: Wrap Mode for AI Coding Tools
30
30
 
31
- **Use Lynkr's routing with your AI coding assistant β€” BYO API keys, route by task complexity:**
31
+ **Use Lynkr's routing with your AI coding assistant β€” maximize your subscription value:**
32
32
 
33
33
  ```bash
34
34
  npm install -g lynkr
35
35
 
36
36
 
37
- # Claude Code (with your own ANTHROPIC_API_KEY)
37
+ # Claude Code Pro/Max
38
38
  lynkr wrap claude
39
39
 
40
40
 
41
41
  ```
42
42
 
43
43
  **Wrapping gives you:**
44
- - βœ… Tier routing (send simple tasks to free Ollama, complex to your configured API provider)
44
+ - βœ… Tier routing (send simple tasks to free Ollama, complex to your subscription/API)
45
45
  - βœ… Sticky sessions: one routing decision per conversation via content fingerprinting, with automatic escalation when the task outgrows the model
46
46
  - βœ… TOON/RTK compression (84% token reduction on large JSON tool outputs)
47
47
  - βœ… Semantic caching (sub-300ms cache hits, 0 tokens billed)
48
- - βœ… Works with API keys (Claude, Aider, Codex, Copilot, Cursor)
49
-
50
- > **Note on subscription (OAuth) logins:** Anthropic's Feb 2026 usage policy prohibits third-party tools from using OAuth tokens issued to Free/Pro/Max accounts. Wrap mode's tier routing, compression, and caching require an API key β€” they do not apply to and are not compatible with Claude Pro/Max subscription auth. See [ToS Compliance](docs/wrap-guide.md#tos-compliance) for details.
48
+ - βœ… **3-5x more usage from the same subscription limits**
49
+ - βœ… Works with OAuth (Claude, Copilot, Cursor) or API keys (Aider, Codex)
51
50
 
52
51
  [Full wrap guide β†’](docs/wrap-guide.md)
53
52
 
@@ -69,7 +68,7 @@ The fastest path is the interactive wizard:
69
68
  lynkr init
70
69
  ```
71
70
 
72
- It asks four questions β€” usage mode (BYO API keys, direct), tier picks for SIMPLE/MEDIUM/COMPLEX/REASONING across the 12 supported providers, credentials for what you chose, and a few routing-intelligence knobs β€” then writes a fully-populated `.env` with sensible production defaults for everything else (caching, compression, policy budgets, MCP sandbox, rate limiting).
71
+ It asks four questions β€” usage mode (Claude Pro/Max via wrap, or direct API keys), tier picks for SIMPLE/MEDIUM/COMPLEX/REASONING across the 14 supported providers, credentials for what you chose, and a few routing-intelligence knobs β€” then writes a fully-populated `.env` with sensible production defaults for everything else (caching, compression, policy budgets, MCP sandbox, rate limiting).
73
72
 
74
73
  Useful flags:
75
74
 
@@ -217,9 +216,24 @@ Claude Code / Cursor / Codex / Cline / Continue
217
216
  | **Azure OpenAI** | Cloud | GPT-4o, o1, o3 | $$$ |
218
217
  | **Azure Anthropic** | Cloud | Claude Sonnet, Opus | $$$ |
219
218
  | **OpenAI** | Cloud | GPT-4o, o3-mini | $$$ |
219
+ | **Atlas Cloud** | Cloud | Qwen, DeepSeek, and other OpenAI-compatible models | $-$$$ |
220
220
  | **DeepSeek** | Cloud | DeepSeek R1, Reasoner | $ |
221
-
222
- **4 local providers** for 100% offline, free usage. **10+ cloud providers** for scale.
221
+ | **Z.ai** | Cloud | GLM-4.7, GLM-4.5-Air | $ |
222
+ | **Moonshot AI** | Cloud | Kimi K2.6, Kimi K3 | $ |
223
+ | **Baidu Qianfan** | Cloud | ERNIE 4.5 Turbo, ERNIE X1.1 | $ (unverified β€” not yet probed against a live key) |
224
+
225
+ **4 local providers** for 100% offline, free usage. **14+ cloud providers** for scale.
226
+
227
+ <div align="center">
228
+ <a href="https://www.atlascloud.ai/?utm_source=github&amp;utm_medium=readme&amp;utm_campaign=lynkr">
229
+ <picture>
230
+ <source media="(prefers-color-scheme: dark)" srcset="https://www.atlascloud.ai/logo-white.svg">
231
+ <img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="196">
232
+ </picture>
233
+ </a>
234
+ <br>
235
+ <sub>Use Lynkr with Atlas Cloud's OpenAI-compatible API and route across its live model catalog with one optional provider configuration. <a href="https://www.atlascloud.ai/?utm_source=github&amp;utm_medium=readme&amp;utm_campaign=lynkr">Explore Atlas Cloud</a>.</sub>
236
+ </div>
223
237
 
224
238
  ---
225
239
 
package/bin/lynkr-init.js CHANGED
@@ -89,6 +89,17 @@ const PROVIDERS = {
89
89
  extras: [],
90
90
  defaultModel: 'gpt-4o',
91
91
  },
92
+ atlas: {
93
+ label: 'Atlas Cloud (OpenAI-compatible)',
94
+ local: false,
95
+ creds: [
96
+ { key: 'ATLASCLOUD_API_KEY', label: 'Atlas Cloud API key', secret: true },
97
+ ],
98
+ extras: [
99
+ { key: 'ATLASCLOUD_ENDPOINT', label: 'Chat Completions endpoint', default: 'https://api.atlascloud.ai/v1/chat/completions' },
100
+ ],
101
+ defaultModel: 'qwen/qwen3.8-max',
102
+ },
92
103
  openrouter: {
93
104
  label: 'OpenRouter (100+ models, one key)',
94
105
  local: false,
@@ -159,12 +170,21 @@ const PROVIDERS = {
159
170
  extras: [],
160
171
  defaultModel: 'kimi-k2-turbo-preview',
161
172
  },
173
+ baidu: {
174
+ label: 'Baidu Qianfan (ERNIE)',
175
+ local: false,
176
+ creds: [
177
+ { key: 'BAIDU_API_KEY', label: 'Baidu Qianfan API key (bce-v3/ALTAK-...)', secret: true },
178
+ ],
179
+ extras: [],
180
+ defaultModel: 'ernie-4.5-turbo-128k',
181
+ },
162
182
  };
163
183
 
164
184
  const PROVIDER_ORDER = [
165
185
  'ollama', 'llamacpp', 'lmstudio',
166
- 'azure-anthropic', 'azure-openai', 'openai', 'openrouter', 'edenai',
167
- 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot',
186
+ 'azure-anthropic', 'azure-openai', 'openai', 'atlas', 'openrouter', 'edenai',
187
+ 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu',
168
188
  ];
169
189
  const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
170
190
 
@@ -319,6 +339,9 @@ const BASELINE_ENV = {
319
339
  MOONSHOT_API_KEY: '',
320
340
  MOONSHOT_ENDPOINT: 'https://api.moonshot.ai/v1/chat/completions',
321
341
  MOONSHOT_MODEL: 'kimi-k2.6',
342
+ BAIDU_API_KEY: '',
343
+ BAIDU_ENDPOINT: 'https://qianfan.baidubce.com/v2/chat/completions',
344
+ BAIDU_MODEL: 'ernie-4.5-turbo-128k',
322
345
  LLAMACPP_ENDPOINT: 'http://localhost:8080',
323
346
  LLAMACPP_MODEL: 'default',
324
347
  LLAMACPP_TIMEOUT_MS: '120000',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.12.0",
3
+ "version": "9.13.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "install.sh",
@@ -37,7 +37,7 @@
37
37
  "dev": "nodemon index.js",
38
38
  "lint": "eslint src index.js",
39
39
  "test": "npm run test:unit && npm run test:performance",
40
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js",
40
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js",
41
41
  "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
42
42
  "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
43
43
  "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -588,6 +588,22 @@ router.post("/chat/completions", async (req, res) => {
588
588
  const content = lynkrBadge(result.body) + (openaiResponse.choices[0].message.content || "");
589
589
  let toolCalls = openaiResponse.choices[0].message.tool_calls;
590
590
 
591
+ // Guard: never serve a clean empty completion. Upstream failures
592
+ // (e.g. exhausted 429 retries) can surface here as a contentless
593
+ // message with finish_reason "stop"; agent clients read that as
594
+ // "task complete" and terminate mid-task. Killing the stream without
595
+ // a finish chunk makes the client retry instead.
596
+ if (!content && (!toolCalls || toolCalls.length === 0)) {
597
+ logger.error({
598
+ finishReason: openaiResponse.choices[0]?.finish_reason,
599
+ terminationReason: result?.terminationReason,
600
+ status: result?.status,
601
+ }, "Empty completion reached serving path β€” aborting stream to force client retry");
602
+ res.write(`data: ${JSON.stringify({ error: { message: "Upstream returned an empty completion; retry.", type: "server_error", code: "empty_completion" } })}\n\n`);
603
+ res.destroy();
604
+ return;
605
+ }
606
+
591
607
  if (clientType !== "unknown" && toolCalls && toolCalls.length > 0) {
592
608
  toolCalls = toolCalls.map(tc => {
593
609
  const mapped = mapToolForClient(tc.function?.name || "", tc.function?.arguments || "{}", clientType);
@@ -764,6 +780,21 @@ router.post("/chat/completions", async (req, res) => {
764
780
  }, "Tool names mapped for non-streaming chat/completions");
765
781
  }
766
782
 
783
+ // Guard (mirrors the streaming path): never serve a clean empty
784
+ // completion β€” upstream failures can surface as contentless messages
785
+ // with finish_reason "stop", which agent clients read as "done" and
786
+ // terminate mid-task. A 502 is retryable; an empty 200 is a silent kill.
787
+ const _msg = openaiResponse.choices?.[0]?.message;
788
+ if (!_msg?.content && !(_msg?.tool_calls?.length > 0)) {
789
+ logger.error({
790
+ finishReason: openaiResponse.choices?.[0]?.finish_reason,
791
+ usage: openaiResponse.usage,
792
+ }, "Empty completion reached non-streaming serving path β€” returning 502");
793
+ return res.status(502).json({
794
+ error: { message: "Upstream returned an empty completion; retry.", type: "server_error", code: "empty_completion" }
795
+ });
796
+ }
797
+
767
798
  logger.info({
768
799
  duration: Date.now() - startTime,
769
800
  mode: "non-streaming",
@@ -939,6 +970,18 @@ function getConfiguredProviders() {
939
970
  });
940
971
  }
941
972
 
973
+ if (config.baidu?.apiKey) {
974
+ providers.push({
975
+ name: "baidu",
976
+ type: "baidu-qianfan",
977
+ models: [
978
+ config.baidu.model || "ernie-4.5-turbo-128k",
979
+ "ernie-4.5-turbo-128k",
980
+ "ernie-x1.1"
981
+ ]
982
+ });
983
+ }
984
+
942
985
  if (config.vertex?.projectId) {
943
986
  providers.push({
944
987
  name: "vertex",
@@ -107,6 +107,19 @@ function getConfiguredProviders() {
107
107
  });
108
108
  }
109
109
 
110
+ // Check Atlas Cloud (OpenAI-compatible Chat Completions)
111
+ if (config.atlas?.apiKey) {
112
+ providers.push({
113
+ name: "atlas",
114
+ type: "atlas-cloud",
115
+ baseUrl: config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions",
116
+ enabled: true,
117
+ models: [
118
+ { id: config.atlas.model || "qwen/qwen3.8-max", name: "Configured Model" },
119
+ ]
120
+ });
121
+ }
122
+
110
123
  // Check OpenRouter
111
124
  if (config.openrouter?.apiKey) {
112
125
  providers.push({
@@ -210,6 +223,21 @@ function getConfiguredProviders() {
210
223
  });
211
224
  }
212
225
 
226
+ // Check Baidu Qianfan (ERNIE)
227
+ if (config.baidu?.apiKey) {
228
+ providers.push({
229
+ name: "baidu",
230
+ type: "baidu-qianfan",
231
+ baseUrl: config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions",
232
+ enabled: true,
233
+ models: [
234
+ { id: config.baidu.model || "ernie-4.5-turbo-128k", name: "Configured Model" },
235
+ { id: "ernie-4.5-turbo-128k", name: "ERNIE 4.5 Turbo 128K" },
236
+ { id: "ernie-x1.1", name: "ERNIE X1.1 (reasoning)" },
237
+ ]
238
+ });
239
+ }
240
+
213
241
  // Check Vertex AI (Google Cloud)
214
242
  if (config.vertex?.projectId) {
215
243
  const region = config.vertex.region || "us-east5";
@@ -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) {
@@ -3540,6 +3778,8 @@ module.exports = {
3540
3778
  invokeZai,
3541
3779
  invokeOllama,
3542
3780
  invokeMoonshot,
3781
+ invokeBaidu,
3782
+ invokeAtlas,
3543
3783
  PROVIDER_INVOKERS,
3544
3784
  stripLynkrBadges,
3545
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
  }
@@ -43,7 +43,9 @@ function getDestinationUrl(providerType) {
43
43
  case 'edenai':
44
44
  return config.edenai?.endpoint ?? 'unknown';
45
45
  case 'openai':
46
- return 'https://api.openai.com/v1/chat/completions';
46
+ return config.openai?.endpoint ?? 'https://api.openai.com/v1/chat/completions';
47
+ case 'atlas':
48
+ return config.atlas?.endpoint ?? 'https://api.atlascloud.ai/v1/chat/completions';
47
49
  case 'llamacpp':
48
50
  return config.llamacpp?.endpoint ?? 'unknown';
49
51
  case 'lmstudio':
@@ -56,6 +58,8 @@ function getDestinationUrl(providerType) {
56
58
  return config.vertex?.endpoint ?? 'unknown';
57
59
  case 'moonshot':
58
60
  return config.moonshot?.endpoint ?? 'unknown';
61
+ case 'baidu':
62
+ return config.baidu?.endpoint ?? 'unknown';
59
63
  case 'codex':
60
64
  return 'codex://app-server (local process)';
61
65
  default:
@@ -1145,8 +1149,16 @@ function sanitizePayload(payload) {
1145
1149
  } else {
1146
1150
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1147
1151
  }
1148
- } else if (providerType === "azure-openai" || providerType === "openai") {
1149
- // Azure OpenAI / OpenAI support tools β€” keep Anthropic format; the
1152
+ } else if (providerType === "baidu") {
1153
+ // Baidu Qianfan supports tools - keep them in Anthropic format
1154
+ // They will be converted to OpenAI format in invokeBaidu
1155
+ if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1156
+ delete clean.tools;
1157
+ } else {
1158
+ clean.tools = ensureAnthropicToolFormat(clean.tools);
1159
+ }
1160
+ } else if (providerType === "azure-openai" || providerType === "openai" || providerType === "atlas") {
1161
+ // Azure OpenAI / OpenAI-compatible providers support tools β€” keep Anthropic format; the
1150
1162
  // client converts to Chat Completions / Responses format. Without this
1151
1163
  // branch the unknown-provider catch-all below deletes the tools.
1152
1164
  if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
@@ -1839,8 +1851,13 @@ IMPORTANT TOOL USAGE RULES:
1839
1851
  // before they reach the model (saves 60-90% on test/git/lint output)
1840
1852
  if (config.toolResultCompression?.enabled !== false) {
1841
1853
  const { compressToolResults } = require("../context/tool-result-compressor");
1842
- const tier = cleanPayload._routingTier || "MEDIUM";
1843
- compressToolResults(cleanPayload.messages, { tier });
1854
+ // Fixed threshold: compression must be deterministic per message β€” the
1855
+ // routed tier flaps between turns, and re-compressing history differently
1856
+ // breaks provider prompt-cache prefixes. COMPLEX (>2000 chars) compresses
1857
+ // only bulky outputs: with prompt caching live, resending history is
1858
+ // cheap, so lighter lossiness beats aggressive compression; still bounds
1859
+ // context growth enough to stay clear of the token-budget compressor.
1860
+ compressToolResults(cleanPayload.messages, { tier: "COMPLEX" });
1844
1861
  }
1845
1862
 
1846
1863
  // MCP-aware tool dedup: drop built-in tools superseded by present MCP tools
@@ -2606,6 +2623,12 @@ IMPORTANT TOOL USAGE RULES:
2606
2623
  if (Array.isArray(anthropicPayload?.content)) {
2607
2624
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
2608
2625
  }
2626
+ } else if (actualProvider === "baidu") {
2627
+ // Baidu Qianfan responses are already converted to Anthropic format in invokeBaidu
2628
+ anthropicPayload = databricksResponse.json;
2629
+ if (Array.isArray(anthropicPayload?.content)) {
2630
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
2631
+ }
2609
2632
  } else if (actualProvider === "codex") {
2610
2633
  // Codex responses are already in Anthropic format from invokeCodex
2611
2634
  anthropicPayload = databricksResponse.json;
@@ -30,6 +30,7 @@ const logger = require("../logger");
30
30
  // responses; the buffered path still lifts it into thinking blocks.
31
31
  const DEFAULT_OPENAI_SSE_PROVIDERS = [
32
32
  "openai",
33
+ "atlas",
33
34
  "azure-openai",
34
35
  "openrouter",
35
36
  "databricks",
@@ -84,6 +84,7 @@ function _enabledProviders() {
84
84
  if (config.openrouter?.apiKey) out.push('openrouter');
85
85
  if (config.edenai?.apiKey) out.push('edenai');
86
86
  if (config.openai?.apiKey) out.push('openai');
87
+ if (config.atlas?.apiKey) out.push('atlas');
87
88
  if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai');
88
89
  if (config.ollama?.endpoint) out.push('ollama');
89
90
  if (config.llamacpp?.endpoint) out.push('llamacpp');
@@ -126,6 +127,7 @@ function getBestCloudProvider() {
126
127
  if (config.openrouter?.apiKey) return 'openrouter';
127
128
  if (config.edenai?.apiKey) return 'edenai';
128
129
  if (config.openai?.apiKey) return 'openai';
130
+ if (config.atlas?.apiKey) return 'atlas';
129
131
  if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai';
130
132
 
131
133
  return getFallbackProvider();
@@ -366,6 +366,14 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
366
366
  return { score: anchorScore, reconciled: 'up_gated' };
367
367
  }
368
368
 
369
+ // Memoize the final reconciled score per cleaned text. The classifier leg is
370
+ // live and can time out under load, sending identical text down different
371
+ // fallback paths β€” which made the same turn score differently between the
372
+ // router and the pin-drift checker (observed: 63 vs 77 for one prompt under
373
+ // suite-wide Ollama contention). First resolution wins for the process life.
374
+ const _scoreMemo = new Map();
375
+ const _SCORE_MEMO_MAX = 500;
376
+
369
377
  async function scoreIntent(payload, opts = {}) {
370
378
  const mode = opts.mode ?? intentScoreMode();
371
379
  if (mode === 'legacy') return null;
@@ -373,6 +381,21 @@ async function scoreIntent(payload, opts = {}) {
373
381
  const text = extractCleanUserText(payload);
374
382
  if (!text) return null;
375
383
 
384
+ // Only memoize plain calls β€” opts that alter scoring (custom centroids,
385
+ // embedFn, risk/context inputs) must not share cache entries.
386
+ const memoizable = !opts.centroids && !opts.embedFn && !opts.forceMatched
387
+ && !opts.riskLevel && !opts.skipClassifier && !opts.priorTurns;
388
+ if (memoizable && _scoreMemo.has(text)) return _scoreMemo.get(text);
389
+ const _memoSet = (result) => {
390
+ if (memoizable && result) {
391
+ if (_scoreMemo.size >= _SCORE_MEMO_MAX) {
392
+ _scoreMemo.delete(_scoreMemo.keys().next().value);
393
+ }
394
+ _scoreMemo.set(text, result);
395
+ }
396
+ return result;
397
+ };
398
+
376
399
  try {
377
400
  const centroids = opts.centroids !== undefined ? opts.centroids : await getDefaultCentroids();
378
401
  if (centroids) {
@@ -421,7 +444,7 @@ async function scoreIntent(payload, opts = {}) {
421
444
  finalScore: score,
422
445
  }, '[IntentScore] classifier reconciled anchor score');
423
446
  }
424
- return {
447
+ return _memoSet({
425
448
  score,
426
449
  mode: reconciled ? 'anchor+classifier' : 'anchor',
427
450
  class: cls,
@@ -431,13 +454,15 @@ async function scoreIntent(payload, opts = {}) {
431
454
  classifierTier: classifierResult?.tier ?? null,
432
455
  classifierConfidence: classifierResult?.confidence ?? null,
433
456
  reconciled,
434
- };
457
+ });
435
458
  }
436
459
  }
437
460
  } catch (err) {
438
461
  logger.debug({ err: err.message }, '[IntentScore] anchor scoring failed β€” lexical fallback');
439
462
  }
440
463
 
464
+ // Lexical fallback is NOT memoized: it usually means the embedding service
465
+ // was unavailable; pinning it would lock in degraded scores after recovery.
441
466
  return { score: _lexicalCleanScore(text), mode: 'lexical', text };
442
467
  }
443
468
 
@@ -333,6 +333,8 @@ class ModelTierSelector {
333
333
  return config.azureOpenAI?.deployment || null;
334
334
  case 'openai':
335
335
  return config.openai?.model || null;
336
+ case 'atlas':
337
+ return config.atlas?.model || null;
336
338
  case 'ollama':
337
339
  return config.ollama?.model || null;
338
340
  case 'openrouter':
@@ -349,6 +351,8 @@ class ModelTierSelector {
349
351
  return config.zai?.model || null;
350
352
  case 'moonshot':
351
353
  return config.moonshot?.model || null;
354
+ case 'baidu':
355
+ return config.baidu?.model || null;
352
356
  case 'codex':
353
357
  return config.codex?.model || null;
354
358
  case 'vertex':
@@ -1,105 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Convert routellm/gpt4_dataset (train.jsonl) β†’ Lynkr kNN bootstrap JSONL.
4
-
5
- Stream-downloads from HuggingFace and maps mixtral_score (1-5) to your
6
- TIER_* model config so the kNN index learns to recommend YOUR real models,
7
- not RouteLLM's pool (mixtral / gpt-4).
8
-
9
- Output format matches scripts/build-knn-index.js bootstrap input:
10
- {"query": "...", "provider": "ollama", "model": "minimax-m2.5:cloud",
11
- "quality": 95, "cost": 0.001, "latency": 200, "tier": "SIMPLE"}
12
-
13
- Usage:
14
- .venv/bin/python scripts/convert-routellm.py [--limit 30000] -o routellm.jsonl
15
- """
16
- import argparse
17
- import json
18
- import sys
19
- import urllib.request
20
- import ssl
21
-
22
- # Map mixtral_score β†’ user's tier config.
23
- # Spread is intentionally aggressive: only score=5 (Mixtral truly nailed it)
24
- # stays SIMPLE; score=4 escalates to MEDIUM so the kNN index has enough MEDIUM
25
- # examples to actually recommend non-SIMPLE tiers (vs the previous mapping
26
- # where 86% of records were SIMPLE and kNN never disagreed with the heuristic).
27
- SCORE_TO_ROUTE = {
28
- 5: {"tier": "SIMPLE", "provider": "ollama", "model": "minimax-m2.5:cloud", "quality": 95, "cost": 0.001, "latency": 200},
29
- 4: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 80, "cost": 0.003, "latency": 1500},
30
- 3: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 70, "cost": 0.003, "latency": 1500},
31
- 2: {"tier": "COMPLEX", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 55, "cost": 0.003, "latency": 1500},
32
- 1: {"tier": "REASONING", "provider": "azure-anthropic", "model": "claude-opus-4-8", "quality": 40, "cost": 0.015, "latency": 3500},
33
- }
34
-
35
- URL = "https://huggingface.co/datasets/routellm/gpt4_dataset/resolve/main/train.jsonl"
36
-
37
-
38
- def main():
39
- p = argparse.ArgumentParser()
40
- p.add_argument("--output", "-o", default="data/routellm-bootstrap.jsonl")
41
- p.add_argument("--limit", type=int, default=None,
42
- help="Only convert first N rows")
43
- args = p.parse_args()
44
-
45
- print(f"Streaming {URL} …", file=sys.stderr)
46
- # Some macOS Python builds have SSL issues with huggingface.co β€” fall back
47
- # to unverified context if needed. The data is public, no auth.
48
- ctx = ssl.create_default_context()
49
- try:
50
- with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
51
- _stream(resp, args.output, args.limit)
52
- except (ssl.SSLError, OSError) as e:
53
- print(f"SSL strict failed ({e}); retrying with unverified context", file=sys.stderr)
54
- ctx = ssl._create_unverified_context()
55
- with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
56
- _stream(resp, args.output, args.limit)
57
-
58
-
59
- def _stream(resp, out_path, limit):
60
- written = 0
61
- by_score = {s: 0 for s in SCORE_TO_ROUTE}
62
- skipped = 0
63
- with open(out_path, "w") as out:
64
- for line in resp:
65
- if limit and written >= limit:
66
- break
67
- try:
68
- row = json.loads(line)
69
- except Exception:
70
- skipped += 1
71
- continue
72
- score = row.get("mixtral_score")
73
- if score not in SCORE_TO_ROUTE:
74
- skipped += 1
75
- continue
76
- route = SCORE_TO_ROUTE[score]
77
- rec = {
78
- "query": row.get("prompt", ""),
79
- "provider": route["provider"],
80
- "model": route["model"],
81
- "quality": route["quality"],
82
- "cost": route["cost"],
83
- "latency": route["latency"],
84
- "tier": route["tier"],
85
- }
86
- if not rec["query"]:
87
- skipped += 1
88
- continue
89
- out.write(json.dumps(rec) + "\n")
90
- written += 1
91
- by_score[score] += 1
92
- if written % 5000 == 0:
93
- print(f" {written} written…", file=sys.stderr)
94
-
95
- print(f"\nWrote {written} records to {out_path} ({skipped} skipped)", file=sys.stderr)
96
- print(f"By mixtral_score: {by_score}", file=sys.stderr)
97
- print(f"\nTier distribution:", file=sys.stderr)
98
- for score, route in SCORE_TO_ROUTE.items():
99
- n = by_score[score]
100
- if n:
101
- print(f" {route['tier']:>10} {route['provider']}:{route['model']:30s} n={n}", file=sys.stderr)
102
-
103
-
104
- if __name__ == "__main__":
105
- main()