lynkr 9.6.0 → 9.7.1
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 +32 -302
- package/bin/cli.js +6 -0
- package/bin/lynkr-init.js +679 -0
- package/bin/wrap.js +686 -0
- package/install.sh +26 -81
- package/package.json +4 -3
- package/scripts/build-knn-index.js +1 -1
- package/scripts/convert-routellm.py +105 -0
- package/src/api/router.js +694 -21
- package/src/auth-mode.js +116 -0
- package/src/clients/databricks.js +416 -67
- package/src/clients/prompt-cache-injection.js +15 -0
- package/src/orchestrator/index.js +120 -60
- package/src/routing/index.js +23 -4
- package/src/routing/knn-router.js +9 -2
- package/src/routing/model-tiers.js +34 -0
|
@@ -177,9 +177,24 @@ function injectPromptCaching(body, provider) {
|
|
|
177
177
|
// Gate on model capability: a provider may support cache_control in general
|
|
178
178
|
// while the specific routed model does not.
|
|
179
179
|
if (!modelSupportsCacheControl(body, provider)) return 0;
|
|
180
|
+
// If the client (e.g. Claude Code) already attached cache_control breakpoints,
|
|
181
|
+
// don't add more. Anthropic caps at 4 breakpoints per request and stacking ours
|
|
182
|
+
// on top has caused 400/429 errors on OAuth subscription requests.
|
|
183
|
+
if (hasExistingCacheControl(body)) return 0;
|
|
180
184
|
return injectAnthropicCacheBreakpoints(body);
|
|
181
185
|
}
|
|
182
186
|
|
|
187
|
+
function hasExistingCacheControl(body) {
|
|
188
|
+
if (!body) return false;
|
|
189
|
+
const scan = (obj) => {
|
|
190
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
191
|
+
if (Array.isArray(obj)) return obj.some(scan);
|
|
192
|
+
if (obj.cache_control) return true;
|
|
193
|
+
return Object.values(obj).some(scan);
|
|
194
|
+
};
|
|
195
|
+
return scan(body.system) || scan(body.messages) || scan(body.tools);
|
|
196
|
+
}
|
|
197
|
+
|
|
183
198
|
module.exports = {
|
|
184
199
|
injectPromptCaching,
|
|
185
200
|
injectAnthropicCacheBreakpoints,
|
|
@@ -965,22 +965,48 @@ function stripThinkingBlocks(text) {
|
|
|
965
965
|
/**
|
|
966
966
|
* Convert legacy Ollama /api/chat response to Anthropic Messages format.
|
|
967
967
|
* Used when Ollama < v0.14.0 (no native Anthropic endpoint).
|
|
968
|
+
*
|
|
969
|
+
* Critical for MiniMax M2/M2.5 (and other interleaved-thinking models):
|
|
970
|
+
* preserve <think>...</think> from message.content AND Ollama's native
|
|
971
|
+
* message.thinking field as Anthropic thinking blocks. Dropping them breaks
|
|
972
|
+
* the model's long-horizon agent loop — vendor-quantified at Tau^2 -35.9%,
|
|
973
|
+
* BrowseComp -40.1% (https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2).
|
|
968
974
|
*/
|
|
969
975
|
function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
|
|
970
976
|
const message = ollamaResponse?.message ?? {};
|
|
971
|
-
const rawContent = message.content
|
|
977
|
+
const rawContent = typeof message.content === "string" ? message.content : "";
|
|
978
|
+
const nativeThinking = typeof message.thinking === "string" ? message.thinking : "";
|
|
972
979
|
const toolCalls = message.tool_calls || [];
|
|
973
980
|
|
|
981
|
+
// Extract <think>...</think> blocks from content (concatenate if multiple).
|
|
982
|
+
// What remains becomes the text body.
|
|
983
|
+
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
|
|
984
|
+
const thinkMatches = [];
|
|
985
|
+
let textBody = rawContent;
|
|
986
|
+
let m;
|
|
987
|
+
while ((m = thinkRegex.exec(rawContent)) !== null) {
|
|
988
|
+
thinkMatches.push(m[1]);
|
|
989
|
+
}
|
|
990
|
+
textBody = textBody.replace(thinkRegex, "").trim();
|
|
991
|
+
|
|
992
|
+
const combinedThinking = [nativeThinking, ...thinkMatches]
|
|
993
|
+
.map(s => (s || "").trim())
|
|
994
|
+
.filter(Boolean)
|
|
995
|
+
.join("\n\n");
|
|
996
|
+
|
|
974
997
|
const contentItems = [];
|
|
975
998
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
contentItems.push({ type: "text", text: cleanedContent });
|
|
980
|
-
}
|
|
999
|
+
// 1. Thinking block FIRST (Mini-Agent reference order: thinking → text → tool_use)
|
|
1000
|
+
if (combinedThinking) {
|
|
1001
|
+
contentItems.push({ type: "thinking", thinking: combinedThinking });
|
|
981
1002
|
}
|
|
982
1003
|
|
|
983
|
-
//
|
|
1004
|
+
// 2. Text body (after <think> tags removed)
|
|
1005
|
+
if (textBody) {
|
|
1006
|
+
contentItems.push({ type: "text", text: textBody });
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// 3. Tool calls converted to Anthropic tool_use
|
|
984
1010
|
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
|
|
985
1011
|
for (const toolCall of toolCalls) {
|
|
986
1012
|
const func = toolCall.function || {};
|
|
@@ -1008,6 +1034,9 @@ function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
|
|
|
1008
1034
|
const inputTokens = ollamaResponse.prompt_eval_count ?? 0;
|
|
1009
1035
|
const outputTokens = ollamaResponse.eval_count ?? 0;
|
|
1010
1036
|
|
|
1037
|
+
// stop_reason derived from tool_calls presence, NOT done_reason.
|
|
1038
|
+
// Ollama emits done_reason="stop" even when tool_calls are present
|
|
1039
|
+
// (ollama/ollama#12557) — naive mapping would falsely halt Claude Code's loop.
|
|
1011
1040
|
return {
|
|
1012
1041
|
id: `msg_${Date.now()}`,
|
|
1013
1042
|
type: "message",
|
|
@@ -1104,7 +1133,16 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
|
|
|
1104
1133
|
|
|
1105
1134
|
function sanitizePayload(payload) {
|
|
1106
1135
|
const { clonePayloadSmart } = require("../utils/payload");
|
|
1107
|
-
|
|
1136
|
+
// Honor a forceProvider marker (set by the OAuth tier-routing path) so the
|
|
1137
|
+
// tool-format / system-flatten / strip-thinking branches downstream match
|
|
1138
|
+
// the actual destination provider, not the static MODEL_PROVIDER default.
|
|
1139
|
+
// Without this, a TIER_SIMPLE=ollama:... user gets the "databricks" branch
|
|
1140
|
+
// running normaliseTools — which wraps tools in OpenAI {type:"function",...}
|
|
1141
|
+
// shape, leaving Ollama with tools named "function" and a model that
|
|
1142
|
+
// (correctly) reports no real tools available.
|
|
1143
|
+
const providerType = payload?._forceProvider
|
|
1144
|
+
|| config.modelProvider?.type
|
|
1145
|
+
|| "databricks";
|
|
1108
1146
|
const willFlatten = providerType !== "azure-anthropic";
|
|
1109
1147
|
const clean = clonePayloadSmart(payload ?? {}, { willFlatten });
|
|
1110
1148
|
const requestedModel =
|
|
@@ -1260,55 +1298,16 @@ function sanitizePayload(payload) {
|
|
|
1260
1298
|
}));
|
|
1261
1299
|
delete clean.tool_choice;
|
|
1262
1300
|
} else if (providerType === "ollama") {
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
//
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
const lastMessage = clean.messages[clean.messages.length - 1];
|
|
1273
|
-
if (lastMessage?.role !== "user") {
|
|
1274
|
-
return false;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
const content = typeof lastMessage.content === "string"
|
|
1278
|
-
? lastMessage.content
|
|
1279
|
-
: "";
|
|
1280
|
-
|
|
1281
|
-
const trimmed = content.trim().toLowerCase();
|
|
1282
|
-
|
|
1283
|
-
// Simple greetings
|
|
1284
|
-
if (/^(hi|hello|hey|good morning|good afternoon|good evening|howdy|greetings)[\s\.\!\?]*$/.test(trimmed)) {
|
|
1285
|
-
return "greeting";
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
// Conversational phrases that don't need tools (thanks, farewells, acknowledgements)
|
|
1289
|
-
if (/^(thanks|thank you|thx|ty|bye|goodbye|see you|ok|okay|cool|nice|great|awesome|sure|got it|sounds good|no worries|np|cheers)[\s\.\!\?]*$/.test(trimmed)) {
|
|
1290
|
-
return "conversational";
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
return false;
|
|
1294
|
-
})();
|
|
1295
|
-
|
|
1296
|
-
if (isConversational) {
|
|
1297
|
-
// Strip all tools for simple conversational messages
|
|
1298
|
-
delete clean.tools;
|
|
1299
|
-
delete clean.tool_choice;
|
|
1300
|
-
logger.debug({
|
|
1301
|
-
model: config.ollama?.model,
|
|
1302
|
-
reason: isConversational,
|
|
1303
|
-
}, "Ollama conversational mode - tools removed");
|
|
1304
|
-
} else if (modelSupportsTools && Array.isArray(clean.tools) && clean.tools.length > 0) {
|
|
1305
|
-
// Keep all tools — Ollama receives them in Anthropic format (native API)
|
|
1306
|
-
// or they get converted to OpenAI format in invokeOllama (legacy API)
|
|
1301
|
+
// Always pass tools through to Ollama in Anthropic format when they exist.
|
|
1302
|
+
// Ollama (v0.14+ native /v1/messages) accepts the Anthropic tool shape; if
|
|
1303
|
+
// the underlying model doesn't actually emit tool_use blocks, the model
|
|
1304
|
+
// simply responds conversationally — which is the correct fallback. Don't
|
|
1305
|
+
// strip the tools array based on heuristics about user intent or a
|
|
1306
|
+
// hardcoded "model supports tools" check, both of which produce
|
|
1307
|
+
// tool-blind responses ("I don't have file system access") when the
|
|
1308
|
+
// client (Claude Code) is clearly in an agentic session.
|
|
1309
|
+
if (Array.isArray(clean.tools) && clean.tools.length > 0) {
|
|
1307
1310
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1308
|
-
} else {
|
|
1309
|
-
// Remove tools for models without tool support
|
|
1310
|
-
delete clean.tools;
|
|
1311
|
-
delete clean.tool_choice;
|
|
1312
1311
|
}
|
|
1313
1312
|
} else if (providerType === "openrouter") {
|
|
1314
1313
|
// OpenRouter supports tools - keep them as-is
|
|
@@ -1902,8 +1901,29 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1902
1901
|
}, 'Estimated token usage before model call');
|
|
1903
1902
|
}
|
|
1904
1903
|
|
|
1905
|
-
// Apply Headroom compression if enabled
|
|
1906
|
-
|
|
1904
|
+
// Apply Headroom compression if enabled.
|
|
1905
|
+
//
|
|
1906
|
+
// Headroom is configured for a single provider (HEADROOM_PROVIDER, default
|
|
1907
|
+
// 'anthropic'). Its Tool Crusher rewrites tool results compactly, Cache
|
|
1908
|
+
// Aligner restructures messages to maximize that provider's prompt-cache
|
|
1909
|
+
// hit pattern, and Smart Crusher does semantic compression — all tuned for
|
|
1910
|
+
// Anthropic. Sending the compressed output to a different model family
|
|
1911
|
+
// (Ollama, OpenAI, etc.) yields output the receiver reads as "garbled tool
|
|
1912
|
+
// result" and the agent loop stalls.
|
|
1913
|
+
//
|
|
1914
|
+
// Gate Headroom on providers matching HEADROOM_PROVIDER. By default that's
|
|
1915
|
+
// Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
|
|
1916
|
+
// analogous gate.
|
|
1917
|
+
const headroomProviderMap = {
|
|
1918
|
+
'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter']),
|
|
1919
|
+
'openai': new Set(['azure-openai', 'openai', 'openrouter']),
|
|
1920
|
+
'google': new Set(['vertex', 'openrouter']),
|
|
1921
|
+
};
|
|
1922
|
+
const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
|
|
1923
|
+
const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
|
|
1924
|
+
const headroomCompatible = headroomSafeProviders.has(providerType);
|
|
1925
|
+
|
|
1926
|
+
if (isHeadroomEnabled() && headroomCompatible && cleanPayload.messages && cleanPayload.messages.length > 0) {
|
|
1907
1927
|
try {
|
|
1908
1928
|
const compressionResult = await headroomCompress(
|
|
1909
1929
|
cleanPayload.messages,
|
|
@@ -1945,6 +1965,12 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1945
1965
|
} catch (headroomErr) {
|
|
1946
1966
|
logger.warn({ err: headroomErr, sessionId: session?.id ?? null }, 'Headroom compression failed, using original messages');
|
|
1947
1967
|
}
|
|
1968
|
+
} else if (isHeadroomEnabled() && !headroomCompatible) {
|
|
1969
|
+
logger.debug({
|
|
1970
|
+
providerType,
|
|
1971
|
+
headroomProvider,
|
|
1972
|
+
reason: 'provider_mismatch',
|
|
1973
|
+
}, 'Headroom skipped — provider does not match HEADROOM_PROVIDER family');
|
|
1948
1974
|
}
|
|
1949
1975
|
|
|
1950
1976
|
// Generate correlation ID for request/response pairing
|
|
@@ -2003,15 +2029,49 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2003
2029
|
|
|
2004
2030
|
// Caveman terse-output injection (opt-in): nudge the model toward shorter
|
|
2005
2031
|
// responses to reduce output tokens.
|
|
2006
|
-
|
|
2032
|
+
//
|
|
2033
|
+
// Default safe-set is the Claude-family + capable instruction-following
|
|
2034
|
+
// models. Operators can override via LYNKR_CAVEMAN_SAFE_PROVIDERS=a,b,c.
|
|
2035
|
+
// (Some smaller / older models read "respond like a terse caveman" too
|
|
2036
|
+
// literally and produce broken telegraphic English — keep them out of the
|
|
2037
|
+
// set if you see that degradation.)
|
|
2038
|
+
const DEFAULT_CAVEMAN_SAFE = [
|
|
2039
|
+
'azure-anthropic',
|
|
2040
|
+
'bedrock',
|
|
2041
|
+
'vertex',
|
|
2042
|
+
'openrouter',
|
|
2043
|
+
'ollama',
|
|
2044
|
+
'openai',
|
|
2045
|
+
'azure-openai',
|
|
2046
|
+
'moonshot',
|
|
2047
|
+
'zai',
|
|
2048
|
+
'databricks',
|
|
2049
|
+
];
|
|
2050
|
+
const cavemanSafeEnv = process.env.LYNKR_CAVEMAN_SAFE_PROVIDERS;
|
|
2051
|
+
const CAVEMAN_SAFE_PROVIDERS = new Set(
|
|
2052
|
+
cavemanSafeEnv
|
|
2053
|
+
? cavemanSafeEnv.split(',').map(s => s.trim()).filter(Boolean)
|
|
2054
|
+
: DEFAULT_CAVEMAN_SAFE
|
|
2055
|
+
);
|
|
2056
|
+
if (config.caveman?.enabled === true && CAVEMAN_SAFE_PROVIDERS.has(providerType)) {
|
|
2007
2057
|
const { injectCaveman } = require("../context/caveman");
|
|
2008
2058
|
cleanPayload.system = injectCaveman(cleanPayload.system);
|
|
2059
|
+
} else if (config.caveman?.enabled === true) {
|
|
2060
|
+
logger.debug({ providerType }, 'Caveman injection skipped (provider not in safe set)');
|
|
2009
2061
|
}
|
|
2010
2062
|
|
|
2011
2063
|
if (agentTimer) agentTimer.mark("preInvokeModel");
|
|
2012
2064
|
let databricksResponse;
|
|
2065
|
+
// Honor a body-level forceProvider marker (set by the OAuth tier-routing
|
|
2066
|
+
// path in the router) so the orchestrator's internal tier router can't
|
|
2067
|
+
// re-pick a different provider mid-flight.
|
|
2068
|
+
const invokeOpts = { headers };
|
|
2069
|
+
if (cleanPayload._forceProvider) {
|
|
2070
|
+
invokeOpts.forceProvider = cleanPayload._forceProvider;
|
|
2071
|
+
delete cleanPayload._forceProvider;
|
|
2072
|
+
}
|
|
2013
2073
|
try {
|
|
2014
|
-
databricksResponse = await invokeModel(cleanPayload);
|
|
2074
|
+
databricksResponse = await invokeModel(cleanPayload, invokeOpts);
|
|
2015
2075
|
if (agentTimer) agentTimer.mark("invokeModel");
|
|
2016
2076
|
} catch (modelError) {
|
|
2017
2077
|
const isConnectionError = modelError.cause?.code === 'ECONNREFUSED'
|
package/src/routing/index.js
CHANGED
|
@@ -486,7 +486,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
486
486
|
: null;
|
|
487
487
|
if (queryText) {
|
|
488
488
|
knnResult = await getKnnRouter().query(queryText);
|
|
489
|
-
|
|
489
|
+
// Confidence thresholds (env-configurable; defaults 0.7 high / 0.4 low):
|
|
490
|
+
const KNN_HIGH = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_HIGH) || 0.7;
|
|
491
|
+
const KNN_LOW = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_LOW) || 0.4;
|
|
492
|
+
if (knnResult && knnResult.confidence > KNN_HIGH && knnResult.model && knnResult.model !== selectedModel) {
|
|
490
493
|
// High confidence — trust kNN's model recommendation directly.
|
|
491
494
|
logger.debug({
|
|
492
495
|
from: `${provider}:${selectedModel}`,
|
|
@@ -496,7 +499,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
496
499
|
provider = knnResult.provider;
|
|
497
500
|
selectedModel = knnResult.model;
|
|
498
501
|
method = method + '+knn';
|
|
499
|
-
} else if (knnResult && knnResult.confidence >
|
|
502
|
+
} else if (knnResult && knnResult.confidence > KNN_LOW && knnResult.confidence <= KNN_HIGH) {
|
|
500
503
|
// Ambiguous signal — neighbors are split, we can't trust any single model
|
|
501
504
|
// recommendation. Err on quality: bump the current tier one step up so the
|
|
502
505
|
// request gets a more capable model rather than risking a bad answer from
|
|
@@ -532,10 +535,26 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
532
535
|
// one with the highest estimated UCB score for the current context.
|
|
533
536
|
if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
|
|
534
537
|
try {
|
|
535
|
-
// Build candidates: current selection and kNN alternative if different
|
|
538
|
+
// Build candidates: current selection and kNN alternative if different.
|
|
539
|
+
//
|
|
540
|
+
// Tier-aware filter: only treat the kNN suggestion as a real candidate
|
|
541
|
+
// if it matches a (provider, model) combo configured in ANY TIER_*
|
|
542
|
+
// entry. The bandit is allowed to explore freely across the user's
|
|
543
|
+
// configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier
|
|
544
|
+
// model), but is forbidden from picking a credentialed-but-untiered
|
|
545
|
+
// model (e.g. an Azure OpenAI deployment whose endpoint is set in .env
|
|
546
|
+
// for some other use, but not referenced by any TIER_*). This keeps
|
|
547
|
+
// tier routing as the source of truth for what's eligible while
|
|
548
|
+
// preserving cross-tier bandit exploration.
|
|
536
549
|
const allCandidates = [{ provider, model: selectedModel }];
|
|
537
550
|
if (knnResult.model !== selectedModel) {
|
|
538
|
-
|
|
551
|
+
const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
|
|
552
|
+
const inConfig = configured.some(
|
|
553
|
+
m => m.provider === knnResult.provider && m.model === knnResult.model
|
|
554
|
+
);
|
|
555
|
+
if (inConfig) {
|
|
556
|
+
allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
|
|
557
|
+
}
|
|
539
558
|
}
|
|
540
559
|
|
|
541
560
|
if (allCandidates.length > 1) {
|
|
@@ -29,7 +29,10 @@ const META_FILE = path.join(INDEX_DIR, 'meta.json');
|
|
|
29
29
|
const MAX_ELEMENTS = 50000;
|
|
30
30
|
const DIM = 768; // nomic-embed-text default
|
|
31
31
|
const K = 10;
|
|
32
|
-
|
|
32
|
+
// Default 1000 is a safety floor for quality; override via env when you
|
|
33
|
+
// want to activate kNN with less data (e.g. bootstrapping from your own
|
|
34
|
+
// telemetry before reaching 1k entries).
|
|
35
|
+
const MIN_INDEX_SIZE = Number.parseInt(process.env.LYNKR_KNN_MIN_INDEX_SIZE, 10) || 1000;
|
|
33
36
|
|
|
34
37
|
let _hnsw = null;
|
|
35
38
|
let _hnswLoaded = false;
|
|
@@ -72,7 +75,11 @@ class KnnRouter {
|
|
|
72
75
|
this.meta = metaData.entries || [];
|
|
73
76
|
this.size = this.meta.length;
|
|
74
77
|
this.index = new hnsw.HierarchicalNSW('cosine', this.dim);
|
|
75
|
-
|
|
78
|
+
// hnswlib-node v3 API: readIndexSync(filename, allowReplaceDeleted=false).
|
|
79
|
+
// (Earlier Lynkr code passed MAX_ELEMENTS here — wrong type, threw on load.)
|
|
80
|
+
this.index.readIndexSync(INDEX_FILE, false);
|
|
81
|
+
// resize if needed so we can keep adding up to MAX_ELEMENTS
|
|
82
|
+
try { this.index.resizeIndex(MAX_ELEMENTS); } catch (_) {}
|
|
76
83
|
this.ready = true;
|
|
77
84
|
logger.info({ size: this.size, dim: this.dim }, '[KnnRouter] Index loaded');
|
|
78
85
|
return true;
|
|
@@ -258,6 +258,40 @@ class ModelTierSelector {
|
|
|
258
258
|
};
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Return every {provider, model} combo configured for a tier.
|
|
263
|
+
* Today TIER_* parses to a single provider:model, so this returns at most
|
|
264
|
+
* one entry. Kept as an array so callers don't have to change when
|
|
265
|
+
* multi-model tier syntax is added (e.g. TIER_SIMPLE=ollama:m1,ollama:m2).
|
|
266
|
+
*/
|
|
267
|
+
getModelsForTier(tier) {
|
|
268
|
+
const tierConfig = config.modelTiers?.[tier];
|
|
269
|
+
if (!tierConfig) return [];
|
|
270
|
+
const parsed = this._parseTierConfig(tierConfig);
|
|
271
|
+
return parsed ? [{ provider: parsed.provider, model: parsed.model }] : [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Return the union of every {provider, model} configured across all tiers,
|
|
276
|
+
* deduped. Used by the bandit-candidate filter to constrain exploration to
|
|
277
|
+
* the user's stated tier preferences — the bandit may pick any combo the
|
|
278
|
+
* user has configured for any tier, but never a model that isn't in any
|
|
279
|
+
* TIER_* entry (even if its credentials happen to be set).
|
|
280
|
+
*/
|
|
281
|
+
getAllConfiguredModels() {
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const tier of ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']) {
|
|
285
|
+
for (const m of this.getModelsForTier(tier)) {
|
|
286
|
+
const key = `${m.provider}:${m.model}`;
|
|
287
|
+
if (seen.has(key)) continue;
|
|
288
|
+
seen.add(key);
|
|
289
|
+
out.push(m);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
261
295
|
/**
|
|
262
296
|
* Parse tier config string (format: provider:model)
|
|
263
297
|
* Examples: "ollama:llama3.2", "azure-openai:gpt-5.2-chat", "openai:gpt-4o"
|