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.
@@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) {
62
62
  return path.resolve(normalised);
63
63
  }
64
64
 
65
- const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot"]);
65
+ const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu"]);
66
66
  const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
67
67
 
68
68
  // Validate MODEL_PROVIDER early with a clear error message
@@ -115,6 +115,11 @@ const openAIModel = process.env.OPENAI_MODEL?.trim() || "gpt-4o";
115
115
  const openAIEndpoint = process.env.OPENAI_ENDPOINT?.trim() || "https://api.openai.com/v1/chat/completions";
116
116
  const openAIOrganization = process.env.OPENAI_ORGANIZATION?.trim() || null;
117
117
 
118
+ // Atlas Cloud configuration (OpenAI-compatible Chat Completions)
119
+ const atlasCloudApiKey = process.env.ATLASCLOUD_API_KEY?.trim() || null;
120
+ const atlasCloudModel = process.env.ATLASCLOUD_MODEL?.trim() || "qwen/qwen3.8-max";
121
+ const atlasCloudEndpoint = process.env.ATLASCLOUD_ENDPOINT?.trim() || "https://api.atlascloud.ai/v1/chat/completions";
122
+
118
123
  // llama.cpp configuration
119
124
  const llamacppEndpoint = process.env.LLAMACPP_ENDPOINT?.trim() || "http://localhost:8080";
120
125
  const llamacppModel = process.env.LLAMACPP_MODEL?.trim() || "default";
@@ -143,6 +148,11 @@ const moonshotApiKey = process.env.MOONSHOT_API_KEY?.trim() || null;
143
148
  const moonshotEndpoint = process.env.MOONSHOT_ENDPOINT?.trim() || "https://api.moonshot.ai/v1/chat/completions";
144
149
  const moonshotModel = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
145
150
 
151
+ // Baidu Qianfan (ERNIE) configuration - OpenAI-compatible v2 Chat Completions API
152
+ const baiduApiKey = process.env.BAIDU_API_KEY?.trim() || null;
153
+ const baiduEndpoint = process.env.BAIDU_ENDPOINT?.trim() || "https://qianfan.baidubce.com/v2/chat/completions";
154
+ const baiduModel = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
155
+
146
156
  // Vertex AI (Google Gemini) configuration
147
157
  const vertexApiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
148
158
  const vertexModel = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
@@ -292,6 +302,12 @@ if (modelProvider === "openai" && !openAIApiKey) {
292
302
  );
293
303
  }
294
304
 
305
+ if (modelProvider === "atlas" && !atlasCloudApiKey) {
306
+ throw new Error(
307
+ "Set ATLASCLOUD_API_KEY before starting the proxy.",
308
+ );
309
+ }
310
+
295
311
  if (modelProvider === "ollama") {
296
312
  try {
297
313
  new URL(ollamaEndpoint);
@@ -340,7 +356,7 @@ const tiersConfigured = !!(
340
356
  if (fallbackEnabled && tiersConfigured) {
341
357
  const localProviders = ["ollama", "llamacpp", "lmstudio"];
342
358
  if (localProviders.includes(fallbackProvider)) {
343
- throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, edenai, openai, bedrock`);
359
+ throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, edenai, openai, atlas, bedrock`);
344
360
  }
345
361
  let fallbackMisconfigured = false;
346
362
  if (fallbackProvider === "databricks" && (!rawBaseUrl || !apiKey)) {
@@ -355,6 +371,9 @@ if (fallbackEnabled && tiersConfigured) {
355
371
  if (fallbackProvider === "bedrock" && !bedrockApiKey) {
356
372
  fallbackMisconfigured = true;
357
373
  }
374
+ if (fallbackProvider === "atlas" && !atlasCloudApiKey) {
375
+ fallbackMisconfigured = true;
376
+ }
358
377
  if (fallbackMisconfigured) {
359
378
  console.warn(`[WARN] FALLBACK_PROVIDER='${fallbackProvider}' is enabled but missing credentials. Fallback will not work until configured.`);
360
379
  }
@@ -602,6 +621,11 @@ var config = {
602
621
  endpoint: openAIEndpoint,
603
622
  organization: openAIOrganization,
604
623
  },
624
+ atlas: {
625
+ apiKey: atlasCloudApiKey,
626
+ model: atlasCloudModel,
627
+ endpoint: atlasCloudEndpoint,
628
+ },
605
629
  llamacpp: {
606
630
  endpoint: llamacppEndpoint,
607
631
  model: llamacppModel,
@@ -634,6 +658,11 @@ var config = {
634
658
  endpoint: moonshotEndpoint,
635
659
  model: moonshotModel,
636
660
  },
661
+ baidu: {
662
+ apiKey: baiduApiKey,
663
+ endpoint: baiduEndpoint,
664
+ model: baiduModel,
665
+ },
637
666
  codex: {
638
667
  enabled: process.env.CODEX_ENABLED !== "false",
639
668
  binaryPath: process.env.CODEX_BINARY_PATH?.trim() || null,
@@ -654,7 +683,7 @@ var config = {
654
683
  fallbackProvider,
655
684
  },
656
685
  toolResultCompression: {
657
- enabled: true,
686
+ enabled: process.env.TOOL_RESULT_COMPRESSION_ENABLED !== "false",
658
687
  },
659
688
  caveman: {
660
689
  enabled: cavemanEnabled,
@@ -1085,6 +1114,9 @@ function reloadConfig() {
1085
1114
  config.edenai.model = process.env.EDENAI_MODEL ?? "openai/gpt-4o-mini";
1086
1115
  config.azureOpenAI.apiKey = process.env.AZURE_OPENAI_API_KEY?.trim() || null;
1087
1116
  config.openai.apiKey = process.env.OPENAI_API_KEY?.trim() || null;
1117
+ config.atlas.apiKey = process.env.ATLASCLOUD_API_KEY?.trim() || null;
1118
+ config.atlas.model = process.env.ATLASCLOUD_MODEL?.trim() || "qwen/qwen3.8-max";
1119
+ config.atlas.endpoint = process.env.ATLASCLOUD_ENDPOINT?.trim() || "https://api.atlascloud.ai/v1/chat/completions";
1088
1120
  config.bedrock.apiKey = process.env.AWS_BEDROCK_API_KEY?.trim() || null;
1089
1121
  config.zai.apiKey = process.env.ZAI_API_KEY?.trim() || null;
1090
1122
  config.zai.model = process.env.ZAI_MODEL?.trim() || "GLM-4.7";
@@ -1092,6 +1124,8 @@ function reloadConfig() {
1092
1124
  config.vertex.model = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
1093
1125
  config.moonshot.apiKey = process.env.MOONSHOT_API_KEY?.trim() || null;
1094
1126
  config.moonshot.model = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
1127
+ config.baidu.apiKey = process.env.BAIDU_API_KEY?.trim() || null;
1128
+ config.baidu.model = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
1095
1129
 
1096
1130
  // Model provider settings
1097
1131
  const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
@@ -9,20 +9,23 @@
9
9
  */
10
10
 
11
11
  const logger = require("../logger");
12
+ const crypto = require("crypto");
12
13
 
13
14
  // ── Tee Recovery Cache ───────────────────────────────────────────────
14
15
 
15
16
  const teeCache = new Map();
16
17
  const TEE_MAX_SIZE = 200;
17
18
  const TEE_TTL_MS = 5 * 60 * 1000; // 5 minutes
18
- let teeCounter = 0;
19
19
 
20
20
  function teeStore(original) {
21
- if (teeCache.size >= TEE_MAX_SIZE) {
21
+ // Content-derived id: the same tool result must compress to byte-identical
22
+ // output on every turn, or the tee marker breaks provider prompt-cache
23
+ // prefixes. (Was Date.now()+counter — new bytes in old messages each turn.)
24
+ const id = "tee_" + crypto.createHash("sha1").update(original).digest("hex").slice(0, 16);
25
+ if (!teeCache.has(id) && teeCache.size >= TEE_MAX_SIZE) {
22
26
  const oldest = teeCache.keys().next().value;
23
27
  teeCache.delete(oldest);
24
28
  }
25
- const id = `tee_${Date.now()}_${teeCounter++}`;
26
29
  teeCache.set(id, { content: original, createdAt: Date.now() });
27
30
  return id;
28
31
  }
@@ -43,7 +43,9 @@ function getDestinationUrl(providerType) {
43
43
  case 'edenai':
44
44
  return config.edenai?.endpoint ?? 'unknown';
45
45
  case 'openai':
46
- return 'https://api.openai.com/v1/chat/completions';
46
+ return config.openai?.endpoint ?? 'https://api.openai.com/v1/chat/completions';
47
+ case 'atlas':
48
+ return config.atlas?.endpoint ?? 'https://api.atlascloud.ai/v1/chat/completions';
47
49
  case 'llamacpp':
48
50
  return config.llamacpp?.endpoint ?? 'unknown';
49
51
  case 'lmstudio':
@@ -56,6 +58,8 @@ function getDestinationUrl(providerType) {
56
58
  return config.vertex?.endpoint ?? 'unknown';
57
59
  case 'moonshot':
58
60
  return config.moonshot?.endpoint ?? 'unknown';
61
+ case 'baidu':
62
+ return config.baidu?.endpoint ?? 'unknown';
59
63
  case 'codex':
60
64
  return 'codex://app-server (local process)';
61
65
  default:
@@ -1145,8 +1149,16 @@ function sanitizePayload(payload) {
1145
1149
  } else {
1146
1150
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1147
1151
  }
1148
- } else if (providerType === "azure-openai" || providerType === "openai") {
1149
- // Azure OpenAI / OpenAI support tools — keep Anthropic format; the
1152
+ } else if (providerType === "baidu") {
1153
+ // Baidu Qianfan supports tools - keep them in Anthropic format
1154
+ // They will be converted to OpenAI format in invokeBaidu
1155
+ if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1156
+ delete clean.tools;
1157
+ } else {
1158
+ clean.tools = ensureAnthropicToolFormat(clean.tools);
1159
+ }
1160
+ } else if (providerType === "azure-openai" || providerType === "openai" || providerType === "atlas") {
1161
+ // Azure OpenAI / OpenAI-compatible providers support tools — keep Anthropic format; the
1150
1162
  // client converts to Chat Completions / Responses format. Without this
1151
1163
  // branch the unknown-provider catch-all below deletes the tools.
1152
1164
  if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
@@ -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
- const headroomCompatible = headroomSafeProviders.has(providerType);
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
- const tier = cleanPayload._routingTier || "MEDIUM";
1843
- compressToolResults(cleanPayload.messages, { tier });
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
- badgeText: config.routing?.visibleInteraction ? options?.streamBadgeText || null : null,
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 },
@@ -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':