lynkr 9.14.11 → 9.14.12
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 +1 -0
- package/bin/cli.js +1 -0
- package/bin/lynkr-connect-orcarouter.js +246 -0
- package/bin/lynkr-init.js +15 -1
- package/config/model-capability-seeds.json +1 -0
- package/package.json +1 -1
- package/src/api/openai-router.js +22 -4
- package/src/api/providers-handler.js +33 -10
- package/src/clients/databricks.js +133 -0
- package/src/clients/orcarouter-catalog.js +330 -0
- package/src/clients/orcarouter-credentials.js +428 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/config/index.js +63 -2
- package/src/dashboard/api.js +1 -0
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/sse-transformer.js +3 -0
- package/src/routing/cache-economics.js +1 -0
- package/src/routing/index.js +2 -0
- package/src/routing/model-tiers.js +2 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrcaRouter model catalog discovery and capability filtering.
|
|
3
|
+
*
|
|
4
|
+
* OrcaRouter serves a single OpenAI-compatible endpoint
|
|
5
|
+
* (https://api.orcarouter.ai/v1) whose `/models` listing is the authoritative
|
|
6
|
+
* source for the models an account can actually call. Live discovery uses the
|
|
7
|
+
* account's own API key so the list reflects the workspace's real serving
|
|
8
|
+
* set, and every model carries metadata (supported_endpoint_types,
|
|
9
|
+
* architecture.input_modalities, context_length, max_completion_tokens,
|
|
10
|
+
* pricing) that the repository's static provider lists cannot provide.
|
|
11
|
+
*
|
|
12
|
+
* Capability filters map an AI entry point to a compatible subset:
|
|
13
|
+
* - chat : ?capability=chat + supported_endpoint_types includes
|
|
14
|
+
* at least one of openai / anthropic / gemini /
|
|
15
|
+
* openai-response; image-generation / openai-video /
|
|
16
|
+
* jina-rerank / embeddings-only models are excluded.
|
|
17
|
+
* - multimodal chat : chat ∩ architecture.input_modalities includes the
|
|
18
|
+
* actual non-text modality (image / audio / video).
|
|
19
|
+
* Models that do not declare capabilities fail closed
|
|
20
|
+
* (never mixed into a multimodal list).
|
|
21
|
+
* - embedding : ?capability=embedding / strict `embeddings` endpoint.
|
|
22
|
+
* - image generation: ?capability=image / strict `image-generation`.
|
|
23
|
+
* - video generation: strict `openai-video`.
|
|
24
|
+
* - rerank : strict `jina-rerank`.
|
|
25
|
+
*
|
|
26
|
+
* Bounds: request timeout, max items, accepted item shape and whitelisted
|
|
27
|
+
* endpoint types are all capped so a catalog response cannot consume
|
|
28
|
+
* unbounded memory or advertise routes this client cannot speak.
|
|
29
|
+
*
|
|
30
|
+
* Failure handling: when live discovery fails, callers fall back to a small
|
|
31
|
+
* verified seed (see VERIFIED_SEED below) that preserves metadata (context,
|
|
32
|
+
* input modalities, reasoning-effort ladders). Live results are authoritative
|
|
33
|
+
* and are never mixed with the seed.
|
|
34
|
+
*
|
|
35
|
+
* @module clients/orcarouter-catalog
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const logger = require("../logger");
|
|
39
|
+
|
|
40
|
+
// Live discovery bounds. The catalog is exposed through the repository's
|
|
41
|
+
// own /v1/models and /v1/providers handlers, so keep it memory-bounded.
|
|
42
|
+
const CATALOG_TIMEOUT_MS = 15000;
|
|
43
|
+
const CATALOG_MAX_ITEMS = 5000;
|
|
44
|
+
|
|
45
|
+
// Endpoint types this client can actually speak (OpenAI chat-completions wire
|
|
46
|
+
// format through the shared openrouter-utils converters). Any model that only
|
|
47
|
+
// advertises other endpoint types is filtered out — a model that the client
|
|
48
|
+
// cannot call must never appear in a chat dropdown.
|
|
49
|
+
const CHAT_ENDPOINT_TYPES = new Set(["openai", "openai-response", "anthropic", "gemini"]);
|
|
50
|
+
|
|
51
|
+
// Capability→endpoint-type matches for non-chat entry points.
|
|
52
|
+
const CAPABILITY_ENDPOINTS = {
|
|
53
|
+
embedding: new Set(["embeddings"]),
|
|
54
|
+
image: new Set(["image-generation"]),
|
|
55
|
+
video: new Set(["openai-video"]),
|
|
56
|
+
rerank: new Set(["jina-rerank"]),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Small verified fallback seed, used ONLY when live discovery is unavailable
|
|
61
|
+
* (cold start, catalog outage). Each entry preserves the metadata this
|
|
62
|
+
* repository already consumes elsewhere: context length, input modalities,
|
|
63
|
+
* and the verified reasoning-effort ladder for openai/gpt-5.5 (low/medium/
|
|
64
|
+
* high/xhigh — see the routing/model-slots notes on Codex effort enums).
|
|
65
|
+
* Live discovery success is authoritative and never merged with this list.
|
|
66
|
+
*/
|
|
67
|
+
const VERIFIED_SEED = [
|
|
68
|
+
{
|
|
69
|
+
id: "openai/gpt-5.5",
|
|
70
|
+
name: "OpenAI: GPT-5.5",
|
|
71
|
+
context_length: 1000000,
|
|
72
|
+
max_completion_tokens: 128000,
|
|
73
|
+
supported_endpoint_types: ["openai", "openai-response"],
|
|
74
|
+
architecture: { input_modalities: ["text", "image", "file"], output_modalities: ["text"] },
|
|
75
|
+
reasoning_efforts: ["low", "medium", "high", "xhigh"],
|
|
76
|
+
seed: true,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "anthropic/claude-opus-4.8",
|
|
80
|
+
name: "Anthropic: Claude Opus 4.8",
|
|
81
|
+
context_length: 200000,
|
|
82
|
+
supported_endpoint_types: ["anthropic", "openai"],
|
|
83
|
+
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
|
|
84
|
+
reasoning_efforts: ["low", "medium", "high", "xhigh"],
|
|
85
|
+
seed: true,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "google/gemini-3.5-flash",
|
|
89
|
+
name: "Google: Gemini 3.5 Flash",
|
|
90
|
+
context_length: 1000000,
|
|
91
|
+
supported_endpoint_types: ["gemini", "openai", "openai-response"],
|
|
92
|
+
architecture: { input_modalities: ["text", "image", "audio", "video"], output_modalities: ["text"] },
|
|
93
|
+
reasoning_efforts: ["low", "medium", "high"],
|
|
94
|
+
seed: true,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "deepseek/deepseek-v4-pro",
|
|
98
|
+
name: "DeepSeek: DeepSeek V4 Pro",
|
|
99
|
+
context_length: 128000,
|
|
100
|
+
supported_endpoint_types: ["openai"],
|
|
101
|
+
architecture: { input_modalities: ["text"], output_modalities: ["text"] },
|
|
102
|
+
reasoning_efforts: ["low", "medium", "high"],
|
|
103
|
+
seed: true,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "orcarouter/auto",
|
|
107
|
+
name: "OrcaRouter: Auto (adaptive routing)",
|
|
108
|
+
context_length: 1000000,
|
|
109
|
+
supported_endpoint_types: ["openai", "openai-response", "anthropic", "gemini"],
|
|
110
|
+
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
|
|
111
|
+
seed: true,
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// Cache last-known-good live catalog so a momentary outage doesn't blank the
|
|
116
|
+
// dropdown. Keyed by API base + credential fingerprint + capability: model
|
|
117
|
+
// visibility depends on the account, so a failed fetch after rotation must
|
|
118
|
+
// never serve the previous account's models.
|
|
119
|
+
let lastKnownGood = new Map(); // cacheKey -> { models, fetchedAt }
|
|
120
|
+
|
|
121
|
+
function fingerprintKey(apiKey) {
|
|
122
|
+
try {
|
|
123
|
+
return require("crypto").createHash("sha256").update(String(apiKey || "")).digest("hex").slice(0, 16);
|
|
124
|
+
} catch {
|
|
125
|
+
return "nokey";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function catalogCacheKey(apiBase, apiKey, capability) {
|
|
130
|
+
const base = String(apiBase || "https://api.orcarouter.ai").replace(/\/+$/, "");
|
|
131
|
+
return `${base}::${fingerprintKey(apiKey)}::${capability || "chat"}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build the chat-completions endpoint URL for the configured origin.
|
|
136
|
+
* Rejects remote plaintext HTTP so a misconfigured base can never carry the
|
|
137
|
+
* Bearer key in clear text (same HTTPS-or-loopback policy as auth origin).
|
|
138
|
+
* @param {string} apiBase - e.g. https://api.orcarouter.ai
|
|
139
|
+
* @returns {string} e.g. https://api.orcarouter.ai/v1/chat/completions
|
|
140
|
+
*/
|
|
141
|
+
function chatCompletionsUrl(apiBase) {
|
|
142
|
+
const base = String(apiBase || "https://api.orcarouter.ai").replace(/\/+$/, "");
|
|
143
|
+
try {
|
|
144
|
+
require("./orcarouter-credentials").assertAllowedOrigin(base, { allowHttpLoopback: true });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
throw new Error(`OrcaRouter API base rejected: ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
return `${base}/v1/chat/completions`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Fetch the live OrcaRouter model catalog for a given API base + key.
|
|
153
|
+
* @param {string} apiBase
|
|
154
|
+
* @param {string} apiKey
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {string} [opts.capability] - chat | embedding | image | video | rerank
|
|
157
|
+
* @returns {Promise<{ok:boolean, models?:Array, degraded?:boolean, reason?:string, cached?:boolean}>}
|
|
158
|
+
*/
|
|
159
|
+
async function fetchOrcaCatalog({ apiBase, apiKey, capability = "chat" }) {
|
|
160
|
+
const base = String(apiBase || "https://api.orcarouter.ai").replace(/\/+$/, "");
|
|
161
|
+
// Fail closed on plaintext remote bases — never send the Bearer key over HTTP.
|
|
162
|
+
try {
|
|
163
|
+
require("./orcarouter-credentials").assertAllowedOrigin(base, { allowHttpLoopback: true });
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { ok: false, reason: err.message, degraded: true };
|
|
166
|
+
}
|
|
167
|
+
const cacheKey = catalogCacheKey(base, apiKey, capability);
|
|
168
|
+
const url = `${base}/v1/models${capability ? `?capability=${encodeURIComponent(capability)}` : ""}`;
|
|
169
|
+
try {
|
|
170
|
+
const res = await fetch(url, {
|
|
171
|
+
headers: {
|
|
172
|
+
Authorization: `Bearer ${apiKey}`,
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
},
|
|
175
|
+
signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
|
|
176
|
+
});
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
logger.warn({ status: res.status, base }, "OrcaRouter catalog fetch failed");
|
|
179
|
+
return { ok: false, reason: `catalog HTTP ${res.status}`, degraded: true };
|
|
180
|
+
}
|
|
181
|
+
const payload = await res.json();
|
|
182
|
+
const raw = Array.isArray(payload) ? payload : (payload.data || payload.models || []);
|
|
183
|
+
if (!Array.isArray(raw)) {
|
|
184
|
+
logger.warn({ base }, "OrcaRouter catalog response had no model array");
|
|
185
|
+
return { ok: false, reason: "catalog response shape invalid", degraded: true };
|
|
186
|
+
}
|
|
187
|
+
const bounded = raw.slice(0, CATALOG_MAX_ITEMS);
|
|
188
|
+
const models = bounded
|
|
189
|
+
.map(sanitizeModel)
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
if (models.length === 0) {
|
|
192
|
+
return { ok: false, reason: "catalog empty after sanitize", degraded: true };
|
|
193
|
+
}
|
|
194
|
+
lastKnownGood.set(cacheKey, { models, fetchedAt: Date.now() });
|
|
195
|
+
return { ok: true, models, capability };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.debug({ err: err.message, base }, "OrcaRouter catalog fetch threw");
|
|
198
|
+
const cached = lastKnownGood.get(cacheKey);
|
|
199
|
+
if (cached && cached.models) {
|
|
200
|
+
return { ok: true, models: cached.models, degraded: true, cached: true, reason: err.message };
|
|
201
|
+
}
|
|
202
|
+
return { ok: false, reason: err.message, degraded: true };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Accept only well-formed catalog items, preserving vendor/model namespaces
|
|
208
|
+
* verbatim. Unknown fields are dropped; known fields are passed through.
|
|
209
|
+
* @param {object} raw
|
|
210
|
+
* @returns {object|null}
|
|
211
|
+
*/
|
|
212
|
+
function sanitizeModel(raw) {
|
|
213
|
+
if (!raw || typeof raw !== "object") return null;
|
|
214
|
+
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
|
215
|
+
if (!id || id.length > 256) return null;
|
|
216
|
+
const out = { id };
|
|
217
|
+
if (typeof raw.name === "string") out.name = raw.name;
|
|
218
|
+
if (typeof raw.context_length === "number") out.context_length = raw.context_length;
|
|
219
|
+
if (typeof raw.max_completion_tokens === "number") out.max_completion_tokens = raw.max_completion_tokens;
|
|
220
|
+
if (Array.isArray(raw.supported_endpoint_types)) {
|
|
221
|
+
out.supported_endpoint_types = raw.supported_endpoint_types.filter((t) => typeof t === "string");
|
|
222
|
+
}
|
|
223
|
+
if (raw.architecture && typeof raw.architecture === "object") {
|
|
224
|
+
const arch = {};
|
|
225
|
+
if (Array.isArray(raw.architecture.input_modalities)) {
|
|
226
|
+
arch.input_modalities = raw.architecture.input_modalities.filter((m) => typeof m === "string");
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(raw.architecture.output_modalities)) {
|
|
229
|
+
arch.output_modalities = raw.architecture.output_modalities.filter((m) => typeof m === "string");
|
|
230
|
+
}
|
|
231
|
+
if (Object.keys(arch).length) out.architecture = arch;
|
|
232
|
+
}
|
|
233
|
+
if (Array.isArray(raw.reasoning_efforts)) {
|
|
234
|
+
out.reasoning_efforts = raw.reasoning_efforts.filter((m) => typeof m === "string");
|
|
235
|
+
}
|
|
236
|
+
if (raw.pricing && typeof raw.pricing === "object") {
|
|
237
|
+
out.pricing = raw.pricing;
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Chat-compatible? Whitelists endpoint types this client can speak.
|
|
244
|
+
* Mixed-capability models (e.g. ["openai", "embeddings"]) are chat-capable;
|
|
245
|
+
* only models with NO chat endpoint (image-generation / openai-video /
|
|
246
|
+
* jina-rerank / embeddings-only) are excluded.
|
|
247
|
+
* @param {object} model
|
|
248
|
+
* @returns {boolean}
|
|
249
|
+
*/
|
|
250
|
+
function isChatCapable(model) {
|
|
251
|
+
const ep = model?.supported_endpoint_types;
|
|
252
|
+
if (!Array.isArray(ep) || ep.length === 0) return false;
|
|
253
|
+
// Chat-capable = speaks at least one chat wire format. Mixed-capability
|
|
254
|
+
// models (e.g. ["openai", "embeddings"]) handle chat fine — do NOT exclude
|
|
255
|
+
// them for also advertising image/video/rerank/embeddings endpoints.
|
|
256
|
+
// (Only chat vs non-chat matters; embeddings-only etc. fail hasChat above.)
|
|
257
|
+
return ep.some((t) => CHAT_ENDPOINT_TYPES.has(t));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Capability filter by entry-point type. Only fields the catalog metadata can
|
|
262
|
+
* actually prove are used — no guessing by model name.
|
|
263
|
+
* @param {Array<object>} models
|
|
264
|
+
* @param {object} req - { capability?: string, modality?: string }
|
|
265
|
+
* @returns {Array<object>}
|
|
266
|
+
*/
|
|
267
|
+
function filterModelsByCapability(models, req = {}) {
|
|
268
|
+
const capability = req.capability || "chat";
|
|
269
|
+
const modality = req.modality || null;
|
|
270
|
+
const list = Array.isArray(models) ? models : [];
|
|
271
|
+
if (capability === "chat") {
|
|
272
|
+
const chat = list.filter(isChatCapable);
|
|
273
|
+
if (!modality || modality === "text") return chat;
|
|
274
|
+
// Multimodal: require the actual non-text modality to be declared.
|
|
275
|
+
return chat.filter((m) => {
|
|
276
|
+
const mods = m?.architecture?.input_modalities;
|
|
277
|
+
if (!Array.isArray(mods)) return false; // fail closed
|
|
278
|
+
return mods.includes(modality);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const endpointSet = CAPABILITY_ENDPOINTS[capability];
|
|
282
|
+
if (!endpointSet) return [];
|
|
283
|
+
return list.filter((m) => {
|
|
284
|
+
const ep = m?.supported_endpoint_types;
|
|
285
|
+
return Array.isArray(ep) && ep.some((t) => endpointSet.has(t));
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get a chat model list for the configured provider — live when possible,
|
|
291
|
+
* otherwise verified seed / last-known-good. Used by the repository's model
|
|
292
|
+
* catalog handlers so the dropdown is real catalog, never free text.
|
|
293
|
+
* @param {object} cfg - config.orcarouter
|
|
294
|
+
* @param {object} [opts] - { capability, modality }
|
|
295
|
+
* @returns {Promise<{models:Array, source:'live'|'seed'|'cached', degraded:boolean}>}
|
|
296
|
+
*/
|
|
297
|
+
async function getOrcaChatModels(cfg, opts = {}) {
|
|
298
|
+
const capability = opts.capability || "chat";
|
|
299
|
+
if (cfg?.apiKey) {
|
|
300
|
+
const live = await fetchOrcaCatalog({
|
|
301
|
+
apiBase: cfg.apiBaseUrl,
|
|
302
|
+
apiKey: cfg.apiKey,
|
|
303
|
+
capability,
|
|
304
|
+
});
|
|
305
|
+
if (live.ok) {
|
|
306
|
+
const models = filterModelsByCapability(live.models, { capability, modality: opts.modality });
|
|
307
|
+
return {
|
|
308
|
+
models,
|
|
309
|
+
source: live.cached ? "cached" : "live",
|
|
310
|
+
degraded: !!live.degraded,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Verified fallback — never the free-text/example list, always metadata-rich.
|
|
315
|
+
const models = filterModelsByCapability(VERIFIED_SEED, { capability, modality: opts.modality });
|
|
316
|
+
return { models, source: "seed", degraded: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
module.exports = {
|
|
320
|
+
chatCompletionsUrl,
|
|
321
|
+
fetchOrcaCatalog,
|
|
322
|
+
filterModelsByCapability,
|
|
323
|
+
isChatCapable,
|
|
324
|
+
getOrcaChatModels,
|
|
325
|
+
sanitizeModel,
|
|
326
|
+
VERIFIED_SEED,
|
|
327
|
+
CHAT_ENDPOINT_TYPES,
|
|
328
|
+
catalogCacheKey,
|
|
329
|
+
_lastKnownGood: lastKnownGood,
|
|
330
|
+
};
|