lynkr 9.11.0 → 9.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -9
- package/bin/lynkr-init.js +25 -2
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/package.json +8 -3
- package/public/dashboard.html +325 -1
- package/src/api/openai-router.js +163 -2
- package/src/api/providers-handler.js +28 -0
- package/src/clients/databricks.js +297 -30
- 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/dashboard/api.js +78 -2
- package/src/dashboard/recommendations/cache-weakspots.js +88 -0
- package/src/dashboard/recommendations/deadweight.js +82 -0
- package/src/dashboard/recommendations/downsize.js +109 -0
- package/src/dashboard/recommendations/index.js +106 -0
- package/src/dashboard/recommendations/side-requests.js +68 -0
- package/src/dashboard/recommendations/wilson.js +24 -0
- package/src/dashboard/router.js +5 -0
- package/src/orchestrator/index.js +28 -5
- package/src/orchestrator/sse-transformer.js +10 -3
- package/src/routing/complexity-analyzer.js +30 -9
- package/src/routing/index.js +2 -0
- package/src/routing/intent-score.js +27 -2
- package/src/routing/model-tiers.js +4 -0
- package/src/routing/telemetry.js +206 -2
- package/scripts/convert-routellm.py +0 -105
|
@@ -77,7 +77,7 @@ function _stripInternalFields(body) {
|
|
|
77
77
|
return cleaned || body;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride }, providerLabel) {
|
|
80
|
+
async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride }, providerLabel) {
|
|
81
81
|
const agent = url.startsWith('https:') ? httpsAgent : httpAgent;
|
|
82
82
|
body = _stripInternalFields(body);
|
|
83
83
|
const isStreaming = body.stream === true;
|
|
@@ -158,7 +158,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
|
|
|
158
158
|
|
|
159
159
|
return result;
|
|
160
160
|
}, {
|
|
161
|
-
maxRetries: config.apiRetry?.maxRetries || 3,
|
|
161
|
+
maxRetries: maxRetriesOverride ?? (config.apiRetry?.maxRetries || 3),
|
|
162
162
|
initialDelay: config.apiRetry?.initialDelay || 1000,
|
|
163
163
|
maxDelay: config.apiRetry?.maxDelay || 30000,
|
|
164
164
|
...(retryableStatusesOverride ? { retryableStatuses: retryableStatusesOverride } : {}),
|
|
@@ -310,6 +310,18 @@ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
|
|
|
310
310
|
incomingAuth = null;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
// Anthropic OAuth tokens are only meaningful at api.anthropic.com. When this
|
|
314
|
+
// provider points at any OTHER Anthropic-compatible host (Azure AI Foundry
|
|
315
|
+
// Claude, a corporate gateway), forwarding Claude Code's sk-ant-oat token
|
|
316
|
+
// earns a 401 there — and the configured x-api-key below is the right auth.
|
|
317
|
+
// This also means a subscription token never leaves the box unless the
|
|
318
|
+
// endpoint is Anthropic itself.
|
|
319
|
+
const isAnthropicDirect = String(config.azureAnthropic.endpoint || "").includes("api.anthropic.com");
|
|
320
|
+
if (incomingAuth && !isAnthropicDirect) {
|
|
321
|
+
logger.debug({ endpoint: config.azureAnthropic.endpoint }, "Azure Anthropic: non-Anthropic endpoint — using configured API key instead of client OAuth token");
|
|
322
|
+
incomingAuth = null;
|
|
323
|
+
}
|
|
324
|
+
|
|
313
325
|
// Headers Anthropic uses to verify client identity for subscription OAuth tokens.
|
|
314
326
|
// If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
|
|
315
327
|
// headers (its terse anti-proxy response). Forward every Anthropic-relevant
|
|
@@ -876,6 +888,23 @@ function detectAzureFormat(url) {
|
|
|
876
888
|
}
|
|
877
889
|
|
|
878
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Stable per-conversation cache key for GPT-5.6 declared prompt caching.
|
|
893
|
+
* Prefers the session id; falls back to hashing the first user message,
|
|
894
|
+
* which is identical across every turn of the same conversation.
|
|
895
|
+
*/
|
|
896
|
+
function derivePromptCacheKey(body) {
|
|
897
|
+
if (body._sessionId) return String(body._sessionId).slice(0, 64);
|
|
898
|
+
const first = (body.messages || []).find(m => m.role === "user");
|
|
899
|
+
let text = "";
|
|
900
|
+
if (first) {
|
|
901
|
+
text = typeof first.content === "string"
|
|
902
|
+
? first.content
|
|
903
|
+
: JSON.stringify(first.content);
|
|
904
|
+
}
|
|
905
|
+
return "conv-" + crypto.createHash("sha1").update(text.slice(0, 4000)).digest("hex").slice(0, 32);
|
|
906
|
+
}
|
|
907
|
+
|
|
879
908
|
async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
880
909
|
if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
|
|
881
910
|
throw new Error("Azure OpenAI endpoint or API key is not configured.");
|
|
@@ -992,6 +1021,16 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
992
1021
|
const responsesInput = [];
|
|
993
1022
|
// Track function call IDs for matching with outputs
|
|
994
1023
|
const pendingCallIds = [];
|
|
1024
|
+
// Fallback IDs must be DETERMINISTIC: the same history must render
|
|
1025
|
+
// byte-identically on every turn, or provider prompt caching never hits.
|
|
1026
|
+
// (Was Date.now()+Math.random(), which re-randomized history each turn.)
|
|
1027
|
+
let stableIdCounter = 0;
|
|
1028
|
+
const stableCallId = (name, args) => {
|
|
1029
|
+
const h = crypto.createHash("sha1")
|
|
1030
|
+
.update(`${name || ""}|${args || ""}|${stableIdCounter++}`)
|
|
1031
|
+
.digest("hex").slice(0, 16);
|
|
1032
|
+
return `call_${h}`;
|
|
1033
|
+
};
|
|
995
1034
|
|
|
996
1035
|
// Detect if this is a continuation request (has tool results)
|
|
997
1036
|
// Azure content filter triggers on full system prompt in continuations
|
|
@@ -1053,36 +1092,32 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1053
1092
|
|
|
1054
1093
|
for (const msg of azureBody.messages) {
|
|
1055
1094
|
if (msg.role === "system") {
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
type: "message",
|
|
1068
|
-
role: "developer",
|
|
1069
|
-
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1095
|
+
// The system prompt must be IDENTICAL on every turn of a conversation:
|
|
1096
|
+
// (a) swapping it per turn breaks provider prompt-cache prefixes, and
|
|
1097
|
+
// (b) replacing it on continuations silently dropped the client
|
|
1098
|
+
// agent's actual instructions mid-task. Strip system-reminder blocks
|
|
1099
|
+
// uniformly (that was the content-filter trigger, not the prompt).
|
|
1100
|
+
const sysText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
1101
|
+
responsesInput.push({
|
|
1102
|
+
type: "message",
|
|
1103
|
+
role: "developer",
|
|
1104
|
+
content: stripSystemReminders(sysText) || sysText
|
|
1105
|
+
});
|
|
1072
1106
|
} else if (msg.role === "user") {
|
|
1073
1107
|
// Check if content contains tool_result blocks (Anthropic format)
|
|
1074
1108
|
if (Array.isArray(msg.content)) {
|
|
1075
1109
|
for (const block of msg.content) {
|
|
1076
1110
|
if (block.type === "tool_result") {
|
|
1077
|
-
const callId = block.tool_use_id || pendingCallIds.shift() ||
|
|
1111
|
+
const callId = block.tool_use_id || pendingCallIds.shift() || stableCallId("result", block.content);
|
|
1078
1112
|
responsesInput.push({
|
|
1079
1113
|
type: "function_call_output",
|
|
1080
1114
|
call_id: callId,
|
|
1081
1115
|
output: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || "")
|
|
1082
1116
|
});
|
|
1083
1117
|
} else if (block.type === "text") {
|
|
1084
|
-
//
|
|
1085
|
-
|
|
1118
|
+
// Strip system-reminder tags on EVERY turn (uniformly), so the
|
|
1119
|
+
// same message renders identically across turns (cache prefix).
|
|
1120
|
+
const textContent = stripSystemReminders(block.text || "");
|
|
1086
1121
|
if (textContent) { // Only add if there's content after stripping
|
|
1087
1122
|
responsesInput.push({
|
|
1088
1123
|
type: "message",
|
|
@@ -1093,11 +1128,9 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1093
1128
|
}
|
|
1094
1129
|
}
|
|
1095
1130
|
} else {
|
|
1096
|
-
//
|
|
1131
|
+
// Strip system-reminder tags uniformly on every turn (cache prefix).
|
|
1097
1132
|
let userContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
1098
|
-
|
|
1099
|
-
userContent = stripSystemReminders(userContent);
|
|
1100
|
-
}
|
|
1133
|
+
userContent = stripSystemReminders(userContent);
|
|
1101
1134
|
if (userContent) { // Only add if there's content after stripping
|
|
1102
1135
|
responsesInput.push({
|
|
1103
1136
|
type: "message",
|
|
@@ -1111,7 +1144,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1111
1144
|
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
1112
1145
|
// OpenAI format: tool_calls array
|
|
1113
1146
|
for (const tc of msg.tool_calls) {
|
|
1114
|
-
const callId = tc.id ||
|
|
1147
|
+
const callId = tc.id || stableCallId(tc.function?.name || tc.name, tc.function?.arguments);
|
|
1115
1148
|
pendingCallIds.push(callId);
|
|
1116
1149
|
responsesInput.push({
|
|
1117
1150
|
type: "function_call",
|
|
@@ -1126,7 +1159,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1126
1159
|
// Anthropic format: content is array of blocks
|
|
1127
1160
|
for (const block of msg.content) {
|
|
1128
1161
|
if (block.type === "tool_use") {
|
|
1129
|
-
const callId = block.id ||
|
|
1162
|
+
const callId = block.id || stableCallId(block.name, block.input);
|
|
1130
1163
|
pendingCallIds.push(callId);
|
|
1131
1164
|
responsesInput.push({
|
|
1132
1165
|
type: "function_call",
|
|
@@ -1152,7 +1185,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1152
1185
|
}
|
|
1153
1186
|
} else if (msg.role === "tool") {
|
|
1154
1187
|
// Tool results become function_call_output
|
|
1155
|
-
const callId = msg.tool_call_id || pendingCallIds.shift() ||
|
|
1188
|
+
const callId = msg.tool_call_id || pendingCallIds.shift() || stableCallId("tool", msg.content);
|
|
1156
1189
|
responsesInput.push({
|
|
1157
1190
|
type: "function_call_output",
|
|
1158
1191
|
call_id: callId,
|
|
@@ -1161,12 +1194,26 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1161
1194
|
}
|
|
1162
1195
|
}
|
|
1163
1196
|
|
|
1197
|
+
// Reasoning effort: honor the client's request, else the env default.
|
|
1198
|
+
// Without this, gpt-5.x reasoning models run at their shallowest setting.
|
|
1199
|
+
const reasoningEffort = body.reasoning_effort
|
|
1200
|
+
?? body.reasoning?.effort
|
|
1201
|
+
?? process.env.AZURE_OPENAI_REASONING_EFFORT
|
|
1202
|
+
?? null;
|
|
1164
1203
|
const responsesBody = {
|
|
1165
1204
|
input: responsesInput,
|
|
1166
1205
|
model: azureBody.model,
|
|
1167
|
-
|
|
1206
|
+
// gpt-5.x deployments store the cap under max_completion_tokens.
|
|
1207
|
+
max_output_tokens: azureBody.max_completion_tokens ?? azureBody.max_tokens,
|
|
1168
1208
|
tools: responsesTools,
|
|
1169
1209
|
tool_choice: azureBody.tool_choice,
|
|
1210
|
+
...(isGpt5 && reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}),
|
|
1211
|
+
// GPT-5.6 caching is declaration-based: a stable per-conversation key
|
|
1212
|
+
// makes prefix cache matching reliable (~90% discount on agent loops).
|
|
1213
|
+
// _sessionId is empty on the agentic path, so fall back to a hash of
|
|
1214
|
+
// the conversation's first user message — stable across every turn of
|
|
1215
|
+
// the same task. Keep per-key traffic under ~15 req/min.
|
|
1216
|
+
...(isGpt5 ? { prompt_cache_key: derivePromptCacheKey(body) } : {}),
|
|
1170
1217
|
stream: false
|
|
1171
1218
|
};
|
|
1172
1219
|
logger.debug({
|
|
@@ -1265,6 +1312,11 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
|
|
|
1265
1312
|
completion_tokens: result.json.usage.completion_tokens ?? result.json.usage.output_tokens ?? 0,
|
|
1266
1313
|
total_tokens: result.json.usage.total_tokens
|
|
1267
1314
|
?? ((result.json.usage.input_tokens ?? 0) + (result.json.usage.output_tokens ?? 0)),
|
|
1315
|
+
// Provider-side prompt-cache hits (Responses API: input_tokens_details,
|
|
1316
|
+
// Chat Completions: prompt_tokens_details) — telemetry reads this name.
|
|
1317
|
+
cache_read_input_tokens: result.json.usage.input_tokens_details?.cached_tokens
|
|
1318
|
+
?? result.json.usage.prompt_tokens_details?.cached_tokens
|
|
1319
|
+
?? null,
|
|
1268
1320
|
} : undefined
|
|
1269
1321
|
};
|
|
1270
1322
|
|
|
@@ -1373,6 +1425,69 @@ async function invokeOpenAI(body, incomingHeaders = {}) {
|
|
|
1373
1425
|
return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
|
|
1374
1426
|
}
|
|
1375
1427
|
|
|
1428
|
+
async function invokeAtlas(body) {
|
|
1429
|
+
if (!config.atlas?.apiKey) {
|
|
1430
|
+
throw new Error("Atlas Cloud API key is not configured.");
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const {
|
|
1434
|
+
convertAnthropicToolsToOpenRouter,
|
|
1435
|
+
convertAnthropicMessagesToOpenRouter
|
|
1436
|
+
} = require("./openrouter-utils");
|
|
1437
|
+
|
|
1438
|
+
const endpoint = config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions";
|
|
1439
|
+
const headers = {
|
|
1440
|
+
"Authorization": `Bearer ${config.atlas.apiKey}`,
|
|
1441
|
+
"Content-Type": "application/json",
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
1445
|
+
if (body.system) {
|
|
1446
|
+
messages.unshift({ role: "system", content: body.system });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const atlasBody = {
|
|
1450
|
+
model: body._suggestionModeModel || body._tierModel || config.atlas.model || "qwen/qwen3.8-max",
|
|
1451
|
+
messages,
|
|
1452
|
+
temperature: body.temperature ?? 0.7,
|
|
1453
|
+
max_tokens: body.max_tokens ?? 16384,
|
|
1454
|
+
top_p: body.top_p ?? 1.0,
|
|
1455
|
+
stream: body.stream ?? false,
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
let toolsToSend = body.tools;
|
|
1459
|
+
let toolsInjected = false;
|
|
1460
|
+
if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
|
|
1461
|
+
toolsToSend = STANDARD_TOOLS;
|
|
1462
|
+
toolsInjected = true;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (toolsToSend.length > 0) {
|
|
1466
|
+
atlasBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
|
|
1467
|
+
atlasBody.parallel_tool_calls = false;
|
|
1468
|
+
atlasBody.tool_choice = "auto";
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
logger.debug({
|
|
1472
|
+
endpoint,
|
|
1473
|
+
model: atlasBody.model,
|
|
1474
|
+
hasTools: !!atlasBody.tools,
|
|
1475
|
+
toolCount: atlasBody.tools?.length || 0,
|
|
1476
|
+
toolsInjected,
|
|
1477
|
+
temperature: atlasBody.temperature,
|
|
1478
|
+
max_tokens: atlasBody.max_tokens,
|
|
1479
|
+
}, "=== ATLAS CLOUD REQUEST ===");
|
|
1480
|
+
|
|
1481
|
+
// Chat completions are billable POSTs, so Atlas requests are never replayed
|
|
1482
|
+
// automatically. Callers can retry explicitly with their own idempotency policy.
|
|
1483
|
+
return performJsonRequest(endpoint, {
|
|
1484
|
+
headers,
|
|
1485
|
+
body: atlasBody,
|
|
1486
|
+
maxRetriesOverride: 0,
|
|
1487
|
+
retryableStatusesOverride: [],
|
|
1488
|
+
}, "Atlas Cloud");
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1376
1491
|
async function invokeLlamaCpp(body, incomingHeaders = {}) {
|
|
1377
1492
|
if (!config.llamacpp?.endpoint) {
|
|
1378
1493
|
throw new Error("llama.cpp endpoint is not configured.");
|
|
@@ -1935,6 +2050,12 @@ async function invokeZai(body, incomingHeaders = {}) {
|
|
|
1935
2050
|
zaiBody = { ...body };
|
|
1936
2051
|
zaiBody.model = mappedModel;
|
|
1937
2052
|
|
|
2053
|
+
// Force buffered mode: with stream:true this endpoint returns ANTHROPIC
|
|
2054
|
+
// SSE, but the downstream transformer parses OPENAI SSE — every chunk is
|
|
2055
|
+
// unreadable and the client receives an empty completion. Buffered JSON
|
|
2056
|
+
// converts correctly; the router synthesizes client-side SSE as usual.
|
|
2057
|
+
zaiBody.stream = false;
|
|
2058
|
+
|
|
1938
2059
|
// Inject standard tools if client didn't send any (passthrough mode)
|
|
1939
2060
|
if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) {
|
|
1940
2061
|
zaiBody.tools = STANDARD_TOOLS;
|
|
@@ -2094,7 +2215,11 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2094
2215
|
max_tokens: body.max_tokens || 16384,
|
|
2095
2216
|
temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
|
|
2096
2217
|
top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
|
|
2097
|
-
|
|
2218
|
+
// Streaming honored since the Phase-2b sse-transformer landed: the raw
|
|
2219
|
+
// OpenAI SSE stream is returned below and reshaped in flight by the
|
|
2220
|
+
// orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered
|
|
2221
|
+
// requests keep the Anthropic conversion path unchanged.
|
|
2222
|
+
stream: body.stream ?? false,
|
|
2098
2223
|
};
|
|
2099
2224
|
|
|
2100
2225
|
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
@@ -2132,6 +2257,13 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2132
2257
|
throw err;
|
|
2133
2258
|
}
|
|
2134
2259
|
|
|
2260
|
+
// Streaming request: hand the raw OpenAI SSE stream to the orchestrator's
|
|
2261
|
+
// stream branch (sse-transformer reshapes it). The Anthropic conversion
|
|
2262
|
+
// below is buffered-only — it would consume the stream.
|
|
2263
|
+
if (response?.stream) {
|
|
2264
|
+
return response;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2135
2267
|
const rawMsg = response?.json?.choices?.[0]?.message;
|
|
2136
2268
|
logger.debug({
|
|
2137
2269
|
responseOk: response?.ok,
|
|
@@ -2168,6 +2300,129 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2168
2300
|
return response;
|
|
2169
2301
|
}
|
|
2170
2302
|
|
|
2303
|
+
/**
|
|
2304
|
+
* Baidu Qianfan (ERNIE) Provider
|
|
2305
|
+
*
|
|
2306
|
+
* Qianfan's v2 endpoint is an OpenAI-compatible Chat Completions API
|
|
2307
|
+
* (bearer-token auth, key format "bce-v3/ALTAK-..."). Modeled on
|
|
2308
|
+
* invokeMoonshot: request side reuses the shared openrouter-utils
|
|
2309
|
+
* converters, response is converted to Anthropic shape locally so the
|
|
2310
|
+
* orchestrator branch is a plain passthrough.
|
|
2311
|
+
*
|
|
2312
|
+
* NOTE: sampling-param quirks and the exact model roster below are best
|
|
2313
|
+
* effort from public docs, not yet probed against a live key. Revisit the
|
|
2314
|
+
* modelMap and pinned-params logic (see Moonshot's kimi-k* precedent) once
|
|
2315
|
+
* real traffic surfaces 400s.
|
|
2316
|
+
*/
|
|
2317
|
+
async function invokeBaidu(body, incomingHeaders = {}) {
|
|
2318
|
+
if (!config.baidu?.apiKey) {
|
|
2319
|
+
throw new Error("Baidu API key is not configured. Set BAIDU_API_KEY in your .env file.");
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const {
|
|
2323
|
+
convertAnthropicToolsToOpenRouter,
|
|
2324
|
+
convertAnthropicMessagesToOpenRouter
|
|
2325
|
+
} = require("./openrouter-utils");
|
|
2326
|
+
|
|
2327
|
+
const endpoint = config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions";
|
|
2328
|
+
|
|
2329
|
+
// Model mapping: Anthropic names → Baidu ERNIE names.
|
|
2330
|
+
// Starting point only — refine once TIER_* mappings and live-probe results
|
|
2331
|
+
// are known (see file-level NOTE above).
|
|
2332
|
+
const modelMap = {
|
|
2333
|
+
"claude-sonnet-4-5-20250929": "ernie-4.5-turbo-128k",
|
|
2334
|
+
"claude-sonnet-4-5": "ernie-4.5-turbo-128k",
|
|
2335
|
+
"claude-sonnet-4.5": "ernie-4.5-turbo-128k",
|
|
2336
|
+
"claude-3-5-sonnet": "ernie-4.5-turbo-128k",
|
|
2337
|
+
"claude-opus-4-5": "ernie-x1.1",
|
|
2338
|
+
"claude-haiku-4-5-20251001": "ernie-speed-8k",
|
|
2339
|
+
"claude-haiku-4-5": "ernie-speed-8k",
|
|
2340
|
+
"claude-3-haiku": "ernie-speed-8k",
|
|
2341
|
+
};
|
|
2342
|
+
|
|
2343
|
+
const requestedModel = body._tierModel || body.model || config.baidu.model;
|
|
2344
|
+
// Honor tier-selected ERNIE ids (e.g. TIER_REASONING=baidu:ernie-x1.1)
|
|
2345
|
+
// instead of silently swapping in the .env default model.
|
|
2346
|
+
const mappedModel = modelMap[requestedModel]
|
|
2347
|
+
|| (/^ernie-/i.test(requestedModel || "") ? requestedModel : null)
|
|
2348
|
+
|| config.baidu.model
|
|
2349
|
+
|| "ernie-4.5-turbo-128k";
|
|
2350
|
+
|
|
2351
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
2352
|
+
|
|
2353
|
+
// Qianfan's OpenAI-compatible endpoint supports the system role natively.
|
|
2354
|
+
if (body.system) {
|
|
2355
|
+
const systemContent = Array.isArray(body.system)
|
|
2356
|
+
? body.system.map(s => s.text || s).join("\n")
|
|
2357
|
+
: body.system;
|
|
2358
|
+
messages.unshift({ role: "system", content: systemContent });
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const baiduBody = {
|
|
2362
|
+
model: mappedModel,
|
|
2363
|
+
messages,
|
|
2364
|
+
max_tokens: body.max_tokens || 16384,
|
|
2365
|
+
temperature: body.temperature ?? 0.7,
|
|
2366
|
+
top_p: body.top_p ?? 1.0,
|
|
2367
|
+
// Streaming honored once "baidu" is added to DEFAULT_OPENAI_SSE_PROVIDERS
|
|
2368
|
+
// (sse-transformer.js) and confirmed to match OpenAI SSE shape. Buffered
|
|
2369
|
+
// requests use the Anthropic conversion path below regardless.
|
|
2370
|
+
stream: body.stream ?? false,
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
2374
|
+
baiduBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
|
|
2375
|
+
baiduBody.tool_choice = "auto";
|
|
2376
|
+
baiduBody.parallel_tool_calls = false;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const headers = {
|
|
2380
|
+
"Content-Type": "application/json",
|
|
2381
|
+
"Authorization": `Bearer ${config.baidu.apiKey}`,
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2384
|
+
logger.debug({
|
|
2385
|
+
endpoint,
|
|
2386
|
+
model: baiduBody.model,
|
|
2387
|
+
originalModel: requestedModel,
|
|
2388
|
+
messageCount: baiduBody.messages?.length || 0,
|
|
2389
|
+
hasTools: !!baiduBody.tools,
|
|
2390
|
+
toolCount: baiduBody.tools?.length || 0,
|
|
2391
|
+
}, "=== Baidu Qianfan REQUEST ===");
|
|
2392
|
+
|
|
2393
|
+
const response = await performJsonRequest(endpoint, {
|
|
2394
|
+
headers,
|
|
2395
|
+
body: baiduBody,
|
|
2396
|
+
retryableStatusesOverride: [500, 502, 503, 504],
|
|
2397
|
+
}, "Baidu");
|
|
2398
|
+
|
|
2399
|
+
if (!response.ok && response.status === 429) {
|
|
2400
|
+
const err = new Error(`Baidu Qianfan rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
|
|
2401
|
+
err.status = 429;
|
|
2402
|
+
throw err;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Streaming request: hand the raw stream to the orchestrator's stream
|
|
2406
|
+
// branch. The Anthropic conversion below is buffered-only.
|
|
2407
|
+
if (response?.stream) {
|
|
2408
|
+
return response;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (response?.ok && response?.json) {
|
|
2412
|
+
const anthropicJson = convertOpenAIToAnthropic(response.json);
|
|
2413
|
+
return {
|
|
2414
|
+
ok: response.ok,
|
|
2415
|
+
status: response.status,
|
|
2416
|
+
json: anthropicJson,
|
|
2417
|
+
text: JSON.stringify(anthropicJson),
|
|
2418
|
+
contentType: "application/json",
|
|
2419
|
+
headers: response.headers,
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
return response;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2171
2426
|
/**
|
|
2172
2427
|
* Convert OpenAI response to Anthropic format
|
|
2173
2428
|
*/
|
|
@@ -2244,6 +2499,10 @@ function convertOpenAIToAnthropic(response) {
|
|
|
2244
2499
|
usage: {
|
|
2245
2500
|
input_tokens: response.usage?.prompt_tokens || 0,
|
|
2246
2501
|
output_tokens: response.usage?.completion_tokens || 0,
|
|
2502
|
+
// Provider-side prompt-cache hits — telemetry reads this field name.
|
|
2503
|
+
cache_read_input_tokens: response.usage?.cache_read_input_tokens
|
|
2504
|
+
?? response.usage?.prompt_tokens_details?.cached_tokens
|
|
2505
|
+
?? null,
|
|
2247
2506
|
}
|
|
2248
2507
|
};
|
|
2249
2508
|
}
|
|
@@ -2728,6 +2987,7 @@ const PROVIDER_INVOKERS = {
|
|
|
2728
2987
|
openrouter: invokeOpenRouter,
|
|
2729
2988
|
edenai: invokeEdenAI,
|
|
2730
2989
|
openai: invokeOpenAI,
|
|
2990
|
+
atlas: invokeAtlas,
|
|
2731
2991
|
llamacpp: invokeLlamaCpp,
|
|
2732
2992
|
lmstudio: invokeLMStudio,
|
|
2733
2993
|
bedrock: invokeBedrock,
|
|
@@ -2735,6 +2995,7 @@ const PROVIDER_INVOKERS = {
|
|
|
2735
2995
|
vertex: invokeVertex,
|
|
2736
2996
|
moonshot: invokeMoonshot,
|
|
2737
2997
|
codex: invokeCodex,
|
|
2998
|
+
baidu: invokeBaidu,
|
|
2738
2999
|
};
|
|
2739
3000
|
|
|
2740
3001
|
function invokeProvider(provider, body, incomingHeaders) {
|
|
@@ -3000,6 +3261,8 @@ async function invokeModel(body, options = {}) {
|
|
|
3000
3261
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3001
3262
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3002
3263
|
cache_decision: routingResult._cacheDecision ?? null,
|
|
3264
|
+
cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
|
|
3265
|
+
cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
|
|
3003
3266
|
});
|
|
3004
3267
|
|
|
3005
3268
|
// WS5.4 — feedback loop (success path).
|
|
@@ -3351,6 +3614,8 @@ async function invokeModel(body, options = {}) {
|
|
|
3351
3614
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3352
3615
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3353
3616
|
cache_decision: routingResult._cacheDecision ?? null,
|
|
3617
|
+
cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
|
|
3618
|
+
cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
|
|
3354
3619
|
});
|
|
3355
3620
|
|
|
3356
3621
|
// WS5.4 — feedback loop (fallback success). The served provider
|
|
@@ -3513,6 +3778,8 @@ module.exports = {
|
|
|
3513
3778
|
invokeZai,
|
|
3514
3779
|
invokeOllama,
|
|
3515
3780
|
invokeMoonshot,
|
|
3781
|
+
invokeBaidu,
|
|
3782
|
+
invokeAtlas,
|
|
3516
3783
|
PROVIDER_INVOKERS,
|
|
3517
3784
|
stripLynkrBadges,
|
|
3518
3785
|
destroyHttpAgents,
|
|
@@ -11,7 +11,7 @@ const NATIVE_THINKING_BEDROCK_MODELS = [
|
|
|
11
11
|
"claude-haiku",
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
-
const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai"]);
|
|
14
|
+
const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai", "atlas"]);
|
|
15
15
|
|
|
16
16
|
function supportsNativeThinking(providerType, model) {
|
|
17
17
|
if (NATIVE_THINKING_PROVIDERS.has(providerType)) return true;
|
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
|
}
|