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 +24 -10
- package/bin/lynkr-init.js +25 -2
- package/package.json +2 -2
- package/src/api/openai-router.js +43 -0
- package/src/api/providers-handler.js +28 -0
- package/src/clients/databricks.js +269 -29
- package/src/clients/provider-capabilities.js +1 -1
- package/src/config/index.js +37 -3
- package/src/context/tool-result-compressor.js +6 -3
- package/src/orchestrator/index.js +28 -5
- package/src/orchestrator/sse-transformer.js +1 -0
- package/src/routing/index.js +2 -0
- package/src/routing/intent-score.js +27 -2
- package/src/routing/model-tiers.js +4 -0
- package/scripts/convert-routellm.py +0 -105
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><300ms</strong><br/>Semantic Cache Hits</td>
|
|
18
|
-
<td align="center"><strong>
|
|
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 β
|
|
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
|
|
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
|
|
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
|
-
- β
|
|
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 (
|
|
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
|
-
**
|
|
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&utm_medium=readme&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&utm_medium=readme&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.
|
|
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",
|
package/src/api/openai-router.js
CHANGED
|
@@ -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
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
type: "message",
|
|
1080
|
-
role: "developer",
|
|
1081
|
-
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
|
1082
|
-
});
|
|
1083
|
-
}
|
|
1095
|
+
// The system prompt must be IDENTICAL on every turn of a conversation:
|
|
1096
|
+
// (a) swapping it per turn breaks provider prompt-cache prefixes, and
|
|
1097
|
+
// (b) replacing it on continuations silently dropped the client
|
|
1098
|
+
// agent's actual instructions mid-task. Strip system-reminder blocks
|
|
1099
|
+
// uniformly (that was the content-filter trigger, not the prompt).
|
|
1100
|
+
const sysText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
1101
|
+
responsesInput.push({
|
|
1102
|
+
type: "message",
|
|
1103
|
+
role: "developer",
|
|
1104
|
+
content: stripSystemReminders(sysText) || sysText
|
|
1105
|
+
});
|
|
1084
1106
|
} else if (msg.role === "user") {
|
|
1085
1107
|
// Check if content contains tool_result blocks (Anthropic format)
|
|
1086
1108
|
if (Array.isArray(msg.content)) {
|
|
1087
1109
|
for (const block of msg.content) {
|
|
1088
1110
|
if (block.type === "tool_result") {
|
|
1089
|
-
const callId = block.tool_use_id || pendingCallIds.shift() ||
|
|
1111
|
+
const callId = block.tool_use_id || pendingCallIds.shift() || stableCallId("result", block.content);
|
|
1090
1112
|
responsesInput.push({
|
|
1091
1113
|
type: "function_call_output",
|
|
1092
1114
|
call_id: callId,
|
|
1093
1115
|
output: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || "")
|
|
1094
1116
|
});
|
|
1095
1117
|
} else if (block.type === "text") {
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1118
|
+
// Strip system-reminder tags on EVERY turn (uniformly), so the
|
|
1119
|
+
// same message renders identically across turns (cache prefix).
|
|
1120
|
+
const textContent = stripSystemReminders(block.text || "");
|
|
1098
1121
|
if (textContent) { // Only add if there's content after stripping
|
|
1099
1122
|
responsesInput.push({
|
|
1100
1123
|
type: "message",
|
|
@@ -1105,11 +1128,9 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1105
1128
|
}
|
|
1106
1129
|
}
|
|
1107
1130
|
} else {
|
|
1108
|
-
//
|
|
1131
|
+
// Strip system-reminder tags uniformly on every turn (cache prefix).
|
|
1109
1132
|
let userContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
1110
|
-
|
|
1111
|
-
userContent = stripSystemReminders(userContent);
|
|
1112
|
-
}
|
|
1133
|
+
userContent = stripSystemReminders(userContent);
|
|
1113
1134
|
if (userContent) { // Only add if there's content after stripping
|
|
1114
1135
|
responsesInput.push({
|
|
1115
1136
|
type: "message",
|
|
@@ -1123,7 +1144,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1123
1144
|
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
1124
1145
|
// OpenAI format: tool_calls array
|
|
1125
1146
|
for (const tc of msg.tool_calls) {
|
|
1126
|
-
const callId = tc.id ||
|
|
1147
|
+
const callId = tc.id || stableCallId(tc.function?.name || tc.name, tc.function?.arguments);
|
|
1127
1148
|
pendingCallIds.push(callId);
|
|
1128
1149
|
responsesInput.push({
|
|
1129
1150
|
type: "function_call",
|
|
@@ -1138,7 +1159,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1138
1159
|
// Anthropic format: content is array of blocks
|
|
1139
1160
|
for (const block of msg.content) {
|
|
1140
1161
|
if (block.type === "tool_use") {
|
|
1141
|
-
const callId = block.id ||
|
|
1162
|
+
const callId = block.id || stableCallId(block.name, block.input);
|
|
1142
1163
|
pendingCallIds.push(callId);
|
|
1143
1164
|
responsesInput.push({
|
|
1144
1165
|
type: "function_call",
|
|
@@ -1164,7 +1185,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1164
1185
|
}
|
|
1165
1186
|
} else if (msg.role === "tool") {
|
|
1166
1187
|
// Tool results become function_call_output
|
|
1167
|
-
const callId = msg.tool_call_id || pendingCallIds.shift() ||
|
|
1188
|
+
const callId = msg.tool_call_id || pendingCallIds.shift() || stableCallId("tool", msg.content);
|
|
1168
1189
|
responsesInput.push({
|
|
1169
1190
|
type: "function_call_output",
|
|
1170
1191
|
call_id: callId,
|
|
@@ -1173,12 +1194,26 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1173
1194
|
}
|
|
1174
1195
|
}
|
|
1175
1196
|
|
|
1197
|
+
// Reasoning effort: honor the client's request, else the env default.
|
|
1198
|
+
// Without this, gpt-5.x reasoning models run at their shallowest setting.
|
|
1199
|
+
const reasoningEffort = body.reasoning_effort
|
|
1200
|
+
?? body.reasoning?.effort
|
|
1201
|
+
?? process.env.AZURE_OPENAI_REASONING_EFFORT
|
|
1202
|
+
?? null;
|
|
1176
1203
|
const responsesBody = {
|
|
1177
1204
|
input: responsesInput,
|
|
1178
1205
|
model: azureBody.model,
|
|
1179
|
-
|
|
1206
|
+
// gpt-5.x deployments store the cap under max_completion_tokens.
|
|
1207
|
+
max_output_tokens: azureBody.max_completion_tokens ?? azureBody.max_tokens,
|
|
1180
1208
|
tools: responsesTools,
|
|
1181
1209
|
tool_choice: azureBody.tool_choice,
|
|
1210
|
+
...(isGpt5 && reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}),
|
|
1211
|
+
// GPT-5.6 caching is declaration-based: a stable per-conversation key
|
|
1212
|
+
// makes prefix cache matching reliable (~90% discount on agent loops).
|
|
1213
|
+
// _sessionId is empty on the agentic path, so fall back to a hash of
|
|
1214
|
+
// the conversation's first user message β stable across every turn of
|
|
1215
|
+
// the same task. Keep per-key traffic under ~15 req/min.
|
|
1216
|
+
...(isGpt5 ? { prompt_cache_key: derivePromptCacheKey(body) } : {}),
|
|
1182
1217
|
stream: false
|
|
1183
1218
|
};
|
|
1184
1219
|
logger.debug({
|
|
@@ -1277,6 +1312,11 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1277
1312
|
completion_tokens: result.json.usage.completion_tokens ?? result.json.usage.output_tokens ?? 0,
|
|
1278
1313
|
total_tokens: result.json.usage.total_tokens
|
|
1279
1314
|
?? ((result.json.usage.input_tokens ?? 0) + (result.json.usage.output_tokens ?? 0)),
|
|
1315
|
+
// Provider-side prompt-cache hits (Responses API: input_tokens_details,
|
|
1316
|
+
// Chat Completions: prompt_tokens_details) β telemetry reads this name.
|
|
1317
|
+
cache_read_input_tokens: result.json.usage.input_tokens_details?.cached_tokens
|
|
1318
|
+
?? result.json.usage.prompt_tokens_details?.cached_tokens
|
|
1319
|
+
?? null,
|
|
1280
1320
|
} : undefined
|
|
1281
1321
|
};
|
|
1282
1322
|
|
|
@@ -1385,6 +1425,69 @@ async function invokeOpenAI(body, incomingHeaders = {}) {
|
|
|
1385
1425
|
return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
|
|
1386
1426
|
}
|
|
1387
1427
|
|
|
1428
|
+
async function invokeAtlas(body) {
|
|
1429
|
+
if (!config.atlas?.apiKey) {
|
|
1430
|
+
throw new Error("Atlas Cloud API key is not configured.");
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const {
|
|
1434
|
+
convertAnthropicToolsToOpenRouter,
|
|
1435
|
+
convertAnthropicMessagesToOpenRouter
|
|
1436
|
+
} = require("./openrouter-utils");
|
|
1437
|
+
|
|
1438
|
+
const endpoint = config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions";
|
|
1439
|
+
const headers = {
|
|
1440
|
+
"Authorization": `Bearer ${config.atlas.apiKey}`,
|
|
1441
|
+
"Content-Type": "application/json",
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
1445
|
+
if (body.system) {
|
|
1446
|
+
messages.unshift({ role: "system", content: body.system });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const atlasBody = {
|
|
1450
|
+
model: body._suggestionModeModel || body._tierModel || config.atlas.model || "qwen/qwen3.8-max",
|
|
1451
|
+
messages,
|
|
1452
|
+
temperature: body.temperature ?? 0.7,
|
|
1453
|
+
max_tokens: body.max_tokens ?? 16384,
|
|
1454
|
+
top_p: body.top_p ?? 1.0,
|
|
1455
|
+
stream: body.stream ?? false,
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
let toolsToSend = body.tools;
|
|
1459
|
+
let toolsInjected = false;
|
|
1460
|
+
if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
|
|
1461
|
+
toolsToSend = STANDARD_TOOLS;
|
|
1462
|
+
toolsInjected = true;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (toolsToSend.length > 0) {
|
|
1466
|
+
atlasBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
|
|
1467
|
+
atlasBody.parallel_tool_calls = false;
|
|
1468
|
+
atlasBody.tool_choice = "auto";
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
logger.debug({
|
|
1472
|
+
endpoint,
|
|
1473
|
+
model: atlasBody.model,
|
|
1474
|
+
hasTools: !!atlasBody.tools,
|
|
1475
|
+
toolCount: atlasBody.tools?.length || 0,
|
|
1476
|
+
toolsInjected,
|
|
1477
|
+
temperature: atlasBody.temperature,
|
|
1478
|
+
max_tokens: atlasBody.max_tokens,
|
|
1479
|
+
}, "=== ATLAS CLOUD REQUEST ===");
|
|
1480
|
+
|
|
1481
|
+
// Chat completions are billable POSTs, so Atlas requests are never replayed
|
|
1482
|
+
// automatically. Callers can retry explicitly with their own idempotency policy.
|
|
1483
|
+
return performJsonRequest(endpoint, {
|
|
1484
|
+
headers,
|
|
1485
|
+
body: atlasBody,
|
|
1486
|
+
maxRetriesOverride: 0,
|
|
1487
|
+
retryableStatusesOverride: [],
|
|
1488
|
+
}, "Atlas Cloud");
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1388
1491
|
async function invokeLlamaCpp(body, incomingHeaders = {}) {
|
|
1389
1492
|
if (!config.llamacpp?.endpoint) {
|
|
1390
1493
|
throw new Error("llama.cpp endpoint is not configured.");
|
|
@@ -1947,6 +2050,12 @@ async function invokeZai(body, incomingHeaders = {}) {
|
|
|
1947
2050
|
zaiBody = { ...body };
|
|
1948
2051
|
zaiBody.model = mappedModel;
|
|
1949
2052
|
|
|
2053
|
+
// Force buffered mode: with stream:true this endpoint returns ANTHROPIC
|
|
2054
|
+
// SSE, but the downstream transformer parses OPENAI SSE β every chunk is
|
|
2055
|
+
// unreadable and the client receives an empty completion. Buffered JSON
|
|
2056
|
+
// converts correctly; the router synthesizes client-side SSE as usual.
|
|
2057
|
+
zaiBody.stream = false;
|
|
2058
|
+
|
|
1950
2059
|
// Inject standard tools if client didn't send any (passthrough mode)
|
|
1951
2060
|
if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) {
|
|
1952
2061
|
zaiBody.tools = STANDARD_TOOLS;
|
|
@@ -2191,6 +2300,129 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2191
2300
|
return response;
|
|
2192
2301
|
}
|
|
2193
2302
|
|
|
2303
|
+
/**
|
|
2304
|
+
* Baidu Qianfan (ERNIE) Provider
|
|
2305
|
+
*
|
|
2306
|
+
* Qianfan's v2 endpoint is an OpenAI-compatible Chat Completions API
|
|
2307
|
+
* (bearer-token auth, key format "bce-v3/ALTAK-..."). Modeled on
|
|
2308
|
+
* invokeMoonshot: request side reuses the shared openrouter-utils
|
|
2309
|
+
* converters, response is converted to Anthropic shape locally so the
|
|
2310
|
+
* orchestrator branch is a plain passthrough.
|
|
2311
|
+
*
|
|
2312
|
+
* NOTE: sampling-param quirks and the exact model roster below are best
|
|
2313
|
+
* effort from public docs, not yet probed against a live key. Revisit the
|
|
2314
|
+
* modelMap and pinned-params logic (see Moonshot's kimi-k* precedent) once
|
|
2315
|
+
* real traffic surfaces 400s.
|
|
2316
|
+
*/
|
|
2317
|
+
async function invokeBaidu(body, incomingHeaders = {}) {
|
|
2318
|
+
if (!config.baidu?.apiKey) {
|
|
2319
|
+
throw new Error("Baidu API key is not configured. Set BAIDU_API_KEY in your .env file.");
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const {
|
|
2323
|
+
convertAnthropicToolsToOpenRouter,
|
|
2324
|
+
convertAnthropicMessagesToOpenRouter
|
|
2325
|
+
} = require("./openrouter-utils");
|
|
2326
|
+
|
|
2327
|
+
const endpoint = config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions";
|
|
2328
|
+
|
|
2329
|
+
// Model mapping: Anthropic names β Baidu ERNIE names.
|
|
2330
|
+
// Starting point only β refine once TIER_* mappings and live-probe results
|
|
2331
|
+
// are known (see file-level NOTE above).
|
|
2332
|
+
const modelMap = {
|
|
2333
|
+
"claude-sonnet-4-5-20250929": "ernie-4.5-turbo-128k",
|
|
2334
|
+
"claude-sonnet-4-5": "ernie-4.5-turbo-128k",
|
|
2335
|
+
"claude-sonnet-4.5": "ernie-4.5-turbo-128k",
|
|
2336
|
+
"claude-3-5-sonnet": "ernie-4.5-turbo-128k",
|
|
2337
|
+
"claude-opus-4-5": "ernie-x1.1",
|
|
2338
|
+
"claude-haiku-4-5-20251001": "ernie-speed-8k",
|
|
2339
|
+
"claude-haiku-4-5": "ernie-speed-8k",
|
|
2340
|
+
"claude-3-haiku": "ernie-speed-8k",
|
|
2341
|
+
};
|
|
2342
|
+
|
|
2343
|
+
const requestedModel = body._tierModel || body.model || config.baidu.model;
|
|
2344
|
+
// Honor tier-selected ERNIE ids (e.g. TIER_REASONING=baidu:ernie-x1.1)
|
|
2345
|
+
// instead of silently swapping in the .env default model.
|
|
2346
|
+
const mappedModel = modelMap[requestedModel]
|
|
2347
|
+
|| (/^ernie-/i.test(requestedModel || "") ? requestedModel : null)
|
|
2348
|
+
|| config.baidu.model
|
|
2349
|
+
|| "ernie-4.5-turbo-128k";
|
|
2350
|
+
|
|
2351
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
2352
|
+
|
|
2353
|
+
// Qianfan's OpenAI-compatible endpoint supports the system role natively.
|
|
2354
|
+
if (body.system) {
|
|
2355
|
+
const systemContent = Array.isArray(body.system)
|
|
2356
|
+
? body.system.map(s => s.text || s).join("\n")
|
|
2357
|
+
: body.system;
|
|
2358
|
+
messages.unshift({ role: "system", content: systemContent });
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const baiduBody = {
|
|
2362
|
+
model: mappedModel,
|
|
2363
|
+
messages,
|
|
2364
|
+
max_tokens: body.max_tokens || 16384,
|
|
2365
|
+
temperature: body.temperature ?? 0.7,
|
|
2366
|
+
top_p: body.top_p ?? 1.0,
|
|
2367
|
+
// Streaming honored once "baidu" is added to DEFAULT_OPENAI_SSE_PROVIDERS
|
|
2368
|
+
// (sse-transformer.js) and confirmed to match OpenAI SSE shape. Buffered
|
|
2369
|
+
// requests use the Anthropic conversion path below regardless.
|
|
2370
|
+
stream: body.stream ?? false,
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
2374
|
+
baiduBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
|
|
2375
|
+
baiduBody.tool_choice = "auto";
|
|
2376
|
+
baiduBody.parallel_tool_calls = false;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const headers = {
|
|
2380
|
+
"Content-Type": "application/json",
|
|
2381
|
+
"Authorization": `Bearer ${config.baidu.apiKey}`,
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2384
|
+
logger.debug({
|
|
2385
|
+
endpoint,
|
|
2386
|
+
model: baiduBody.model,
|
|
2387
|
+
originalModel: requestedModel,
|
|
2388
|
+
messageCount: baiduBody.messages?.length || 0,
|
|
2389
|
+
hasTools: !!baiduBody.tools,
|
|
2390
|
+
toolCount: baiduBody.tools?.length || 0,
|
|
2391
|
+
}, "=== Baidu Qianfan REQUEST ===");
|
|
2392
|
+
|
|
2393
|
+
const response = await performJsonRequest(endpoint, {
|
|
2394
|
+
headers,
|
|
2395
|
+
body: baiduBody,
|
|
2396
|
+
retryableStatusesOverride: [500, 502, 503, 504],
|
|
2397
|
+
}, "Baidu");
|
|
2398
|
+
|
|
2399
|
+
if (!response.ok && response.status === 429) {
|
|
2400
|
+
const err = new Error(`Baidu Qianfan rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
|
|
2401
|
+
err.status = 429;
|
|
2402
|
+
throw err;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Streaming request: hand the raw stream to the orchestrator's stream
|
|
2406
|
+
// branch. The Anthropic conversion below is buffered-only.
|
|
2407
|
+
if (response?.stream) {
|
|
2408
|
+
return response;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (response?.ok && response?.json) {
|
|
2412
|
+
const anthropicJson = convertOpenAIToAnthropic(response.json);
|
|
2413
|
+
return {
|
|
2414
|
+
ok: response.ok,
|
|
2415
|
+
status: response.status,
|
|
2416
|
+
json: anthropicJson,
|
|
2417
|
+
text: JSON.stringify(anthropicJson),
|
|
2418
|
+
contentType: "application/json",
|
|
2419
|
+
headers: response.headers,
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
return response;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2194
2426
|
/**
|
|
2195
2427
|
* Convert OpenAI response to Anthropic format
|
|
2196
2428
|
*/
|
|
@@ -2267,6 +2499,10 @@ function convertOpenAIToAnthropic(response) {
|
|
|
2267
2499
|
usage: {
|
|
2268
2500
|
input_tokens: response.usage?.prompt_tokens || 0,
|
|
2269
2501
|
output_tokens: response.usage?.completion_tokens || 0,
|
|
2502
|
+
// Provider-side prompt-cache hits β telemetry reads this field name.
|
|
2503
|
+
cache_read_input_tokens: response.usage?.cache_read_input_tokens
|
|
2504
|
+
?? response.usage?.prompt_tokens_details?.cached_tokens
|
|
2505
|
+
?? null,
|
|
2270
2506
|
}
|
|
2271
2507
|
};
|
|
2272
2508
|
}
|
|
@@ -2751,6 +2987,7 @@ const PROVIDER_INVOKERS = {
|
|
|
2751
2987
|
openrouter: invokeOpenRouter,
|
|
2752
2988
|
edenai: invokeEdenAI,
|
|
2753
2989
|
openai: invokeOpenAI,
|
|
2990
|
+
atlas: invokeAtlas,
|
|
2754
2991
|
llamacpp: invokeLlamaCpp,
|
|
2755
2992
|
lmstudio: invokeLMStudio,
|
|
2756
2993
|
bedrock: invokeBedrock,
|
|
@@ -2758,6 +2995,7 @@ const PROVIDER_INVOKERS = {
|
|
|
2758
2995
|
vertex: invokeVertex,
|
|
2759
2996
|
moonshot: invokeMoonshot,
|
|
2760
2997
|
codex: invokeCodex,
|
|
2998
|
+
baidu: invokeBaidu,
|
|
2761
2999
|
};
|
|
2762
3000
|
|
|
2763
3001
|
function invokeProvider(provider, body, incomingHeaders) {
|
|
@@ -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;
|
package/src/config/index.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 === "
|
|
1149
|
-
//
|
|
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
|
-
|
|
1843
|
-
|
|
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;
|
package/src/routing/index.js
CHANGED
|
@@ -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()
|