lynkr 9.12.0 → 9.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -10
- package/bin/lynkr-init.js +25 -2
- package/package.json +3 -2
- package/src/api/middleware/load-shedding.js +76 -13
- package/src/api/openai-router.js +79 -4
- package/src/api/providers-handler.js +28 -0
- package/src/clients/databricks.js +359 -40
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/resilience.js +9 -0
- package/src/config/index.js +37 -3
- package/src/context/tool-result-compressor.js +6 -3
- package/src/orchestrator/index.js +71 -7
- package/src/orchestrator/sse-transformer.js +28 -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/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) {
|
|
@@ -1344,6 +1356,18 @@ function buildNonJsonResponse(databricksResponse) {
|
|
|
1344
1356
|
};
|
|
1345
1357
|
}
|
|
1346
1358
|
|
|
1359
|
+
/**
|
|
1360
|
+
* Visible routing badge for live-streamed responses, built from the actual
|
|
1361
|
+
* post-routing decision (tier fallbacks included). Matches the format of
|
|
1362
|
+
* openai-router's lynkrBadge and the Anthropic router's intent badge so
|
|
1363
|
+
* LYNKR_BADGE_PREFIX_RE strips all three identically from replayed history.
|
|
1364
|
+
*/
|
|
1365
|
+
function buildRoutingBadge(rd) {
|
|
1366
|
+
if (!rd || !rd.tier) return null;
|
|
1367
|
+
const score = typeof rd.score === "number" ? ` · score ${rd.score}` : "";
|
|
1368
|
+
return `*[Lynkr] ${rd.tier} → ${rd.model || "—"} (${rd.provider || "—"})${score}*\n\n`;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1347
1371
|
function buildStreamingResponse(databricksResponse) {
|
|
1348
1372
|
return {
|
|
1349
1373
|
status: databricksResponse.status,
|
|
@@ -1743,7 +1767,24 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1743
1767
|
};
|
|
1744
1768
|
const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
|
|
1745
1769
|
const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
|
|
1746
|
-
|
|
1770
|
+
// Under tier routing the static providerType is just the MODEL_PROVIDER
|
|
1771
|
+
// default (usually "databricks") — the real provider is chosen per-request
|
|
1772
|
+
// later in invokeModel, AFTER this compression point, so gating on it
|
|
1773
|
+
// silently disabled Headroom for every tier-routed setup. Gate on the
|
|
1774
|
+
// configured tier fleet instead: compress only when every routable tier
|
|
1775
|
+
// provider is format-safe, since any request may land on any tier.
|
|
1776
|
+
let headroomCompatible = headroomSafeProviders.has(providerType);
|
|
1777
|
+
if (!headroomCompatible) {
|
|
1778
|
+
try {
|
|
1779
|
+
const { getModelTierSelector } = require('../routing/model-tiers');
|
|
1780
|
+
const sel = getModelTierSelector();
|
|
1781
|
+
const tierProviders = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']
|
|
1782
|
+
.map((t) => { try { return sel.selectModel(t)?.provider; } catch { return null; } })
|
|
1783
|
+
.filter(Boolean)
|
|
1784
|
+
.map((p) => (p === 'z-ai' ? 'zai' : p));
|
|
1785
|
+
headroomCompatible = tierProviders.length > 0 && tierProviders.every((p) => headroomSafeProviders.has(p));
|
|
1786
|
+
} catch { /* tier routing not configured — keep the static gate result */ }
|
|
1787
|
+
}
|
|
1747
1788
|
|
|
1748
1789
|
if (isHeadroomEnabled() && headroomCompatible && cleanPayload.messages && cleanPayload.messages.length > 0) {
|
|
1749
1790
|
try {
|
|
@@ -1839,8 +1880,13 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1839
1880
|
// before they reach the model (saves 60-90% on test/git/lint output)
|
|
1840
1881
|
if (config.toolResultCompression?.enabled !== false) {
|
|
1841
1882
|
const { compressToolResults } = require("../context/tool-result-compressor");
|
|
1842
|
-
|
|
1843
|
-
|
|
1883
|
+
// Fixed threshold: compression must be deterministic per message — the
|
|
1884
|
+
// routed tier flaps between turns, and re-compressing history differently
|
|
1885
|
+
// breaks provider prompt-cache prefixes. COMPLEX (>2000 chars) compresses
|
|
1886
|
+
// only bulky outputs: with prompt caching live, resending history is
|
|
1887
|
+
// cheap, so lighter lossiness beats aggressive compression; still bounds
|
|
1888
|
+
// context growth enough to stay clear of the token-budget compressor.
|
|
1889
|
+
compressToolResults(cleanPayload.messages, { tier: "COMPLEX" });
|
|
1844
1890
|
}
|
|
1845
1891
|
|
|
1846
1892
|
// MCP-aware tool dedup: drop built-in tools superseded by present MCP tools
|
|
@@ -2036,7 +2082,16 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2036
2082
|
const routingDecision = databricksResponse.routingDecision || {};
|
|
2037
2083
|
const transformed = sseTransform.openaiToAnthropicSSE(databricksResponse.stream, {
|
|
2038
2084
|
model: requestedModel,
|
|
2039
|
-
|
|
2085
|
+
// Badge precedence: an explicit streamBadgeText (Anthropic router's
|
|
2086
|
+
// pre-computed intent badge) wins; otherwise build one from the
|
|
2087
|
+
// ACTUAL post-routing decision so OpenAI-surface live streams
|
|
2088
|
+
// (opencode et al.) show which model served the response —
|
|
2089
|
+
// previously this path silently dropped the badge entirely.
|
|
2090
|
+
// History pollution is covered: stripLynkrBadges in invokeModel
|
|
2091
|
+
// removes resubmitted badges before anything reaches a provider.
|
|
2092
|
+
badgeText: config.routing?.visibleInteraction
|
|
2093
|
+
? (options?.streamBadgeText || buildRoutingBadge(routingDecision))
|
|
2094
|
+
: null,
|
|
2040
2095
|
onClose: (stats) => {
|
|
2041
2096
|
try {
|
|
2042
2097
|
const telemetry = require("../routing/telemetry");
|
|
@@ -2063,6 +2118,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2063
2118
|
logger.info({
|
|
2064
2119
|
provider: routingDecision.provider || _streamProvider,
|
|
2065
2120
|
stopReason: stats.stopReason,
|
|
2121
|
+
finishReason: stats.finishReason ?? null,
|
|
2066
2122
|
toolCalls: stats.toolCalls.map((t) => t.name),
|
|
2067
2123
|
outputTokens: stats.usage.output_tokens,
|
|
2068
2124
|
}, "[SSETransform] Stream closed");
|
|
@@ -2606,6 +2662,12 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2606
2662
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
2607
2663
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
2608
2664
|
}
|
|
2665
|
+
} else if (actualProvider === "baidu") {
|
|
2666
|
+
// Baidu Qianfan responses are already converted to Anthropic format in invokeBaidu
|
|
2667
|
+
anthropicPayload = databricksResponse.json;
|
|
2668
|
+
if (Array.isArray(anthropicPayload?.content)) {
|
|
2669
|
+
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
2670
|
+
}
|
|
2609
2671
|
} else if (actualProvider === "codex") {
|
|
2610
2672
|
// Codex responses are already in Anthropic format from invokeCodex
|
|
2611
2673
|
anthropicPayload = databricksResponse.json;
|
|
@@ -3168,4 +3230,6 @@ module.exports = {
|
|
|
3168
3230
|
toAnthropicResponse,
|
|
3169
3231
|
// Exported for unit testing of loop trimming (task-preservation contract).
|
|
3170
3232
|
trimLoopMessages,
|
|
3233
|
+
// Exported for unit testing of the live-stream routing badge.
|
|
3234
|
+
buildRoutingBadge,
|
|
3171
3235
|
};
|
|
@@ -30,6 +30,7 @@ const logger = require("../logger");
|
|
|
30
30
|
// responses; the buffered path still lifts it into thinking blocks.
|
|
31
31
|
const DEFAULT_OPENAI_SSE_PROVIDERS = [
|
|
32
32
|
"openai",
|
|
33
|
+
"atlas",
|
|
33
34
|
"azure-openai",
|
|
34
35
|
"openrouter",
|
|
35
36
|
"databricks",
|
|
@@ -255,6 +256,7 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
|
|
|
255
256
|
error: { type: "overloaded_error", message: `Lynkr: upstream stream failed — retry (${err.message})` },
|
|
256
257
|
});
|
|
257
258
|
stats.stopReason = "stream_error";
|
|
259
|
+
stats.finishReason = finishReason ?? null;
|
|
258
260
|
if (opts.onClose) { try { opts.onClose(stats); } catch { /* non-fatal */ } }
|
|
259
261
|
return;
|
|
260
262
|
}
|
|
@@ -262,10 +264,36 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
|
|
|
262
264
|
// Normal end of stream ([DONE] or upstream EOF).
|
|
263
265
|
yield* startMessage();
|
|
264
266
|
yield* closeTextBlock();
|
|
267
|
+
|
|
268
|
+
// An EOF with no finish_reason is a dropped upstream, not a completed
|
|
269
|
+
// turn. Mapping it to end_turn hands the client a success-shaped stop —
|
|
270
|
+
// agent loops halt mid-task believing the model chose to stop (live
|
|
271
|
+
// incident 2026-08-19: opencode stalled between tool calls; the stream
|
|
272
|
+
// closed as end_turn with null output tokens). Spec-compliant upstreams
|
|
273
|
+
// always send finish_reason on the last choice chunk, so signal an error
|
|
274
|
+
// and let the client retry, mirroring the mid-flight failure path above.
|
|
275
|
+
// Accumulated tool fragments are NOT flushed: without finish_reason their
|
|
276
|
+
// argument JSON may be truncated mid-string.
|
|
277
|
+
if (!finishReason) {
|
|
278
|
+
logger.warn(
|
|
279
|
+
{ droppedToolFragments: toolAcc.size, outputTokens: stats.usage.output_tokens },
|
|
280
|
+
"[SSETransform] Upstream ended without finish_reason — signaling stream error"
|
|
281
|
+
);
|
|
282
|
+
yield _sse("error", {
|
|
283
|
+
type: "error",
|
|
284
|
+
error: { type: "overloaded_error", message: "Lynkr: upstream ended without finish_reason — retry" },
|
|
285
|
+
});
|
|
286
|
+
stats.stopReason = "stream_error";
|
|
287
|
+
stats.finishReason = null;
|
|
288
|
+
if (opts.onClose) { try { opts.onClose(stats); } catch { /* non-fatal */ } }
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
265
292
|
yield* flushToolBlocks();
|
|
266
293
|
|
|
267
294
|
const stopReason = _mapFinishReason(finishReason);
|
|
268
295
|
stats.stopReason = stopReason;
|
|
296
|
+
stats.finishReason = finishReason;
|
|
269
297
|
yield _sse("message_delta", {
|
|
270
298
|
type: "message_delta",
|
|
271
299
|
delta: { stop_reason: stopReason, stop_sequence: null },
|
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':
|