lynkr 9.14.10 → 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.
@@ -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
+ };