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,428 @@
1
+ /**
2
+ * OrcaRouter credential interface + dual adapters.
3
+ *
4
+ * Both ways of obtaining a credential produce the SAME result — a normal
5
+ * OrcaRouter API key (`sk-orca-…`) that belongs to the user — and downstream
6
+ * provider/model/catalog code treats them identically. This module is the one
7
+ * seam; nothing else copies auth logic.
8
+ *
9
+ * Adapter A — API key: the user pastes/stores an `sk-orca-…` key in their
10
+ * existing secret location (.env / process env, same as every other Lynkr
11
+ * provider). Supports update, clear, and a masked read for UI/status.
12
+ *
13
+ * Adapter B — OAuth 2.0 + PKCE (Flow B, out-of-band code): the user opens
14
+ * the consent page, copies a one-time code, and the client exchanges it at
15
+ * www.orcarouter.ai/api/v1/auth/keys for a durable API key. Chosen over
16
+ * Flow A because Lynkr is self-hosted software whose install address differs
17
+ * on every deployment — there is no predictable loopback port to register,
18
+ * and the out-of-band flow exists precisely for that case (no callback
19
+ * registration step). PKCE binds the code to this process: the verifier is
20
+ * freshly generated per attempt, hashed to an S256 challenge that rides on
21
+ * the authorize URL, and never leaves the process until the exchange.
22
+ *
23
+ * Security invariants enforced here:
24
+ * - challenge = base64url(sha256(verifier)), no padding, S256 always.
25
+ * - verifier/state from crypto RNG per attempt; verifier never in a URL,
26
+ * log, error, or telemetry.
27
+ * - state is constant-time compared before a code is used.
28
+ * - exchange response `scope` is what was GRANTED, not what was asked for —
29
+ * a narrower grant is surfaced, never assumed.
30
+ * - the exchanged key is durable (not a refresh token): reused until the
31
+ * provider revokes it, no proactive refresh, no invented refresh grant.
32
+ * - a 401 from the relay marks the exact account + credential generation
33
+ * `needsReauth`; a late failure from an old generation never pollutes a
34
+ * newly logged-in credential.
35
+ *
36
+ * The key is persisted to the project's existing secret location (.env), the
37
+ * same trust boundary Lynkr already uses for every provider key. It is never
38
+ * written to logs, errors, telemetry, or this module's own outputs.
39
+ *
40
+ * @module clients/orcarouter-credentials
41
+ */
42
+
43
+ const crypto = require("crypto");
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // OAuth origins (public defaults; explicit overrides win; shared self-hosted
47
+ // fallback). Never derive one origin from the other.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function resolveAuthBase(env = process.env) {
51
+ return String(
52
+ env.ORCA_AUTH_BASE_URL || env.ORCA_BASE_URL || "https://www.orcarouter.ai"
53
+ ).replace(/\/+$/, "");
54
+ }
55
+
56
+ function resolveApiBase(env = process.env) {
57
+ return String(
58
+ env.ORCA_API_BASE_URL || env.ORCA_BASE_URL || "https://api.orcarouter.ai"
59
+ ).replace(/\/+$/, "");
60
+ }
61
+
62
+ /** Require HTTPS for non-loopback remote origins. */
63
+ function assertAllowedOrigin(base, { allowHttpLoopback = false } = {}) {
64
+ let u;
65
+ try {
66
+ u = new URL(base);
67
+ } catch {
68
+ throw new Error(`OrcaRouter base URL is not a valid URL: ${base}`);
69
+ }
70
+ const host = u.hostname.toLowerCase();
71
+ const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
72
+ const protocol = u.protocol.toLowerCase();
73
+ if (protocol === "http:") {
74
+ if (!(allowHttpLoopback && isLoopback)) {
75
+ throw new Error(`OrcaRouter base URL must be HTTPS (got ${protocol}//${host})`);
76
+ }
77
+ } else if (protocol !== "https:") {
78
+ throw new Error(`OrcaRouter base URL must be HTTPS (got ${protocol}//${host})`);
79
+ }
80
+ return base;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // PKCE primitives (S256)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function b64url(bytes) {
88
+ return Buffer.from(bytes).toString("base64url");
89
+ }
90
+
91
+ /** Fresh 256-bit verifier from a crypto RNG. */
92
+ function generateVerifier() {
93
+ return b64url(crypto.randomBytes(32));
94
+ }
95
+
96
+ /** S256 challenge: base64url(sha256(verifier)), no padding. */
97
+ function generateChallenge(verifier) {
98
+ return b64url(crypto.createHash("sha256").update(verifier).digest());
99
+ }
100
+
101
+ /** Opaque CSRF state, fresh per attempt. */
102
+ function generateState() {
103
+ return b64url(crypto.randomBytes(16));
104
+ }
105
+
106
+ /** Constant-time string comparison. */
107
+ function timingSafeEqualStr(a, b) {
108
+ const ba = Buffer.from(String(a || ""));
109
+ const bb = Buffer.from(String(b || ""));
110
+ if (ba.length !== bb.length) return false;
111
+ return crypto.timingSafeEqual(ba, bb);
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Credential shape + store
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * The credential result both adapters produce and every consumer consumes.
120
+ * @typedef {object} OrcaCredential
121
+ * @property {string} key - the sk-orca-… API key
122
+ * @property {string} source - 'api_key' | 'pkce'
123
+ * @property {string} [scope] - granted scope (from exchange response)
124
+ * @property {number} [generation] - monotonic credential generation
125
+ */
126
+
127
+ let _generation = 0;
128
+
129
+ /**
130
+ * The one credential adapter seam. `read`, `save`, `clear` delegate to the
131
+ * repository's own secret store (here: env/.env via a provided persistence
132
+ * hook, defaulting to in-process state for headless/test use).
133
+ */
134
+ class OrcaCredentialStore {
135
+ /**
136
+ * @param {object} [opts]
137
+ * @param {object} [opts.env] - env object (defaults to process.env)
138
+ * @param {Function} [opts.loadSecret] - () => string|null current key
139
+ * @param {Function} [opts.saveSecret] - (key) => void persist key
140
+ * @param {Function} [opts.clearSecret] - () => void clear key
141
+ */
142
+ constructor(opts = {}) {
143
+ this.env = opts.env || process.env;
144
+ this._loadSecret = opts.loadSecret || (() => this.env.ORCAROUTER_API_KEY || null);
145
+ // Default persistence keeps the in-memory env in sync so the same
146
+ // process sees a just-saved key immediately (mirrors .env semantics).
147
+ this._saveSecret = opts.saveSecret || ((key) => { this.env.ORCAROUTER_API_KEY = key; });
148
+ this._clearSecret = opts.clearSecret || (() => { delete this.env.ORCAROUTER_API_KEY; });
149
+ /** accountId → { generation, needsReauth } */
150
+ this._reauthState = new Map();
151
+ }
152
+
153
+ /**
154
+ * Read the current key (masked by default for UI/status display).
155
+ * @returns {{ present:boolean, masked:string|null, generation:number, needsReauth:boolean }}
156
+ */
157
+ read() {
158
+ const raw = this._loadSecret();
159
+ const generation = _generation;
160
+ const account = this._accountFor(raw);
161
+ const st = this._reauthState.get(account);
162
+ return {
163
+ present: !!raw,
164
+ masked: raw ? maskKey(raw) : null,
165
+ generation,
166
+ needsReauth: !!st?.needsReauth,
167
+ account,
168
+ };
169
+ }
170
+
171
+ /** Persist a newly obtained key (API key or PKCE result) and bump generation. */
172
+ save(key, meta = {}) {
173
+ if (!key || typeof key !== "string" || !key.trim()) {
174
+ throw new Error("OrcaRouter credential save requires a key");
175
+ }
176
+ _generation += 1;
177
+ this._saveSecret(key.trim());
178
+ const account = this._accountFor(key);
179
+ // A successful new login clears the reauth flag for this account only.
180
+ if (this._reauthState.has(account)) this._reauthState.delete(account);
181
+ return {
182
+ key: key.trim(),
183
+ source: meta.source || "api_key",
184
+ scope: meta.scope || null,
185
+ generation: _generation,
186
+ };
187
+ }
188
+
189
+ /** Clear the stored key without touching reauth state (user-initiated). */
190
+ clear() {
191
+ _generation += 1;
192
+ this._clearSecret();
193
+ }
194
+
195
+ /**
196
+ * Mark a rejected credential generation `needsReauth`. Only the exact
197
+ * account that made the rejected request is marked; a late failure from an
198
+ * old generation must never pollute a newly reauthorized credential.
199
+ * @param {string} key - the key that produced the 401
200
+ * @param {number} generation - the generation that made the request
201
+ */
202
+ markRejected(key, generation) {
203
+ const account = this._accountFor(key);
204
+ // generation is assigned at save time; an absent generation (legacy
205
+ // request) is treated as current — only an explicitly older generation is
206
+ // skipped as stale.
207
+ if (generation != null && generation < _generation) {
208
+ // A stale request failed AFTER a newer login succeeded — don't mark
209
+ // the fresh credential broken.
210
+ return { skipped: true, reason: "stale_generation" };
211
+ }
212
+ this._reauthState.set(account, { needsReauth: true, at: Date.now() });
213
+ return { account, needsReauth: true };
214
+ }
215
+
216
+ _accountFor(key) {
217
+ if (!key) return "anonymous";
218
+ // Account identity = the credential's own stable fingerprint (never the
219
+ // key itself in logs); generation-safe reauth keys off this.
220
+ return "orca-" + crypto.createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
221
+ }
222
+ }
223
+
224
+ /** Mask an API key for display: keep first 7 + last 4 chars. */
225
+ function maskKey(key) {
226
+ const s = String(key || "");
227
+ if (s.length <= 11) return s ? `${s.slice(0, 2)}…` : "";
228
+ return `${s.slice(0, 7)}…${s.slice(-4)}`;
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Adapter A — API key
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Build the API-key adapter on the shared store seam.
237
+ * Supports save (update), read (masked), and clear.
238
+ */
239
+ function createApiKeyAdapter(store, opts = {}) {
240
+ const keyInput = () => opts.env?.ORCAROUTER_API_KEY || store.env.ORCAROUTER_API_KEY || null;
241
+ return {
242
+ kind: "api_key",
243
+ /** @returns {OrcaCredential|null} */
244
+ obtain() {
245
+ const raw = keyInput();
246
+ if (!raw) return null;
247
+ return { key: raw.trim(), source: "api_key", generation: _generation };
248
+ },
249
+ /** Persist/update an API key (user-entered) and return the credential. */
250
+ set(key) {
251
+ return store.save(key, { source: "api_key" });
252
+ },
253
+ /** Read masked state for UI. */
254
+ status() {
255
+ return store.read();
256
+ },
257
+ /** Clear the stored key. */
258
+ clear() {
259
+ return store.clear();
260
+ },
261
+ };
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Adapter B — OAuth 2.0 + PKCE (Flow B, out-of-band code)
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * Build an authorize URL for Flow B (`callback_url=oob`, S256 mandatory).
270
+ * @param {object} opts - { authBaseUrl, appName, verifier, state }
271
+ * @returns {string}
272
+ */
273
+ function buildAuthorizeUrl({ authBaseUrl, appName = "Lynkr", verifier, state }) {
274
+ assertAllowedOrigin(authBaseUrl, { allowHttpLoopback: true });
275
+ const u = new URL("/auth", authBaseUrl);
276
+ u.searchParams.set("callback_url", "oob");
277
+ u.searchParams.set("code_challenge", generateChallenge(verifier));
278
+ u.searchParams.set("code_challenge_method", "S256");
279
+ u.searchParams.set("state", state);
280
+ u.searchParams.set("app_name", appName);
281
+ u.searchParams.set("scope", "api");
282
+ return u.toString();
283
+ }
284
+
285
+ /**
286
+ * Exchange an out-of-band code for a durable API key.
287
+ * @param {object} opts - { authBaseUrl, code, verifier, fetchImpl }
288
+ * @returns {Promise<{ok:boolean, credential?:OrcaCredential, error?:{status?:number, code?:string, message:string}}>}
289
+ */
290
+ async function exchangeCode({ authBaseUrl, code, verifier, fetchImpl = fetch }) {
291
+ const url = `${String(authBaseUrl).replace(/\/+$/, "")}/api/v1/auth/keys`;
292
+ let res;
293
+ try {
294
+ res = await fetchImpl(url, {
295
+ method: "POST",
296
+ headers: { "Content-Type": "application/json" },
297
+ body: JSON.stringify({
298
+ code,
299
+ code_verifier: verifier,
300
+ code_challenge_method: "S256",
301
+ }),
302
+ });
303
+ } catch (err) {
304
+ return { ok: false, error: { code: "network", message: `OrcaRouter exchange failed: ${err.message}` } };
305
+ }
306
+
307
+ const text = await res.text().catch(() => "");
308
+ let body = null;
309
+ try { body = JSON.parse(text); } catch { /* non-JSON error body */ }
310
+
311
+ if (!res.ok) {
312
+ // 400 = challenge mismatch/downgrade; 403 = unknown/expired/used code or
313
+ // verifier mismatch; 429 = per-user per-24h PKCE key cap.
314
+ const message = body?.error_description
315
+ || (typeof body?.error === "string" ? body.error : null)
316
+ || body?.message
317
+ || `HTTP ${res.status}`;
318
+ return {
319
+ ok: false,
320
+ error: { status: res.status, code: body?.error || "http_error", message },
321
+ };
322
+ }
323
+
324
+ const key = body?.key;
325
+ if (!key || typeof key !== "string" || !key.trim()) {
326
+ return { ok: false, error: { status: res.status, code: "invalid_response", message: "OrcaRouter exchange returned no key" } };
327
+ }
328
+
329
+ // Read the GRANTED scope (not the requested scope). A narrower grant is
330
+ // surfaced, never assumed to be the requested one.
331
+ const scope = typeof body.scope === "string" ? body.scope : null;
332
+
333
+ return {
334
+ ok: true,
335
+ credential: {
336
+ key: key.trim(),
337
+ source: "pkce",
338
+ scope,
339
+ generation: _generation,
340
+ },
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Full PKCE connect (Flow B): generate verifier+state, build authorize URL,
346
+ * accept the pasted one-time code, exchange it, persist the durable key.
347
+ *
348
+ * Two-phase usage (same process — the verifier is the PKCE binding):
349
+ * const started = await connectWithPkce({ appName });
350
+ * // ... user authorizes started.authorizeUrl, pastes code ...
351
+ * const result = await connectWithPkce({ appName, code, verifier: started.verifier, state: started.state });
352
+ * @param {object} opts - { authBaseUrl, appName, code?, verifier?, state?, env, fetchImpl }
353
+ * @returns {Promise<{ok:boolean, authorizeUrl?:string, credential?:OrcaCredential, error?:object}>}
354
+ */
355
+ async function connectWithPkce(opts = {}) {
356
+ const authBaseUrl = resolveAuthBase(opts.env || process.env);
357
+ // Second phase: reuse the verifier that produced the challenge. Generating
358
+ // a fresh verifier here would never match the authorized challenge and the
359
+ // exchange would always fail — fail fast with an actionable error instead.
360
+ if (opts.code) {
361
+ const verifier = typeof opts.verifier === "string" ? opts.verifier.trim() : "";
362
+ if (!verifier) {
363
+ return {
364
+ ok: false,
365
+ error: {
366
+ code: "missing_verifier",
367
+ message: "PKCE verifier missing — restart `lynkr connect orcarouter` and exchange the code in the same process.",
368
+ },
369
+ };
370
+ }
371
+ const exchanged = await exchangeCode({ authBaseUrl, code: opts.code, verifier, fetchImpl: opts.fetchImpl });
372
+ if (!exchanged.ok) return exchanged;
373
+
374
+ // Persist the durable key. The PKCE flow only issues keys via this seam.
375
+ const credential = store.save(exchanged.credential.key, {
376
+ source: "pkce",
377
+ scope: exchanged.credential.scope,
378
+ });
379
+ return { ok: true, credential, authorizeUrl: undefined };
380
+ }
381
+
382
+ const verifier = generateVerifier();
383
+ const state = generateState();
384
+ const authorizeUrl = buildAuthorizeUrl({
385
+ authBaseUrl,
386
+ appName: opts.appName || "Lynkr",
387
+ verifier,
388
+ state,
389
+ });
390
+
391
+ // No code yet → hand back the authorize URL for the caller/UI to show and
392
+ // the user to paste the code. The verifier stays in-process only.
393
+ return { ok: true, authorizeUrl, state, verifier };
394
+ }
395
+
396
+ // Shared default store for the seam (tests may construct their own).
397
+ let store = new OrcaCredentialStore();
398
+
399
+ /** @returns {OrcaCredentialStore} */
400
+ function getCredentialStore() {
401
+ return store;
402
+ }
403
+
404
+ /** Test helper: reset the shared store to a clean instance. */
405
+ function _resetCredentialStore() {
406
+ store = new OrcaCredentialStore();
407
+ }
408
+
409
+ module.exports = {
410
+ // seam + adapters
411
+ OrcaCredentialStore,
412
+ createApiKeyAdapter,
413
+ connectWithPkce,
414
+ getCredentialStore,
415
+ _resetCredentialStore,
416
+ // primitives
417
+ generateVerifier,
418
+ generateChallenge,
419
+ generateState,
420
+ timingSafeEqualStr,
421
+ buildAuthorizeUrl,
422
+ exchangeCode,
423
+ maskKey,
424
+ // origins
425
+ resolveAuthBase,
426
+ resolveApiBase,
427
+ assertAllowedOrigin,
428
+ };
@@ -9,7 +9,7 @@ const NATIVE_THINKING_BEDROCK_MODELS = [
9
9
  "claude-haiku",
10
10
  ];
11
11
 
12
- const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai", "atlas"]);
12
+ const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai", "atlas", "orcarouter"]);
13
13
 
14
14
  function supportsNativeThinking(providerType, model) {
15
15
  if (NATIVE_THINKING_PROVIDERS.has(providerType)) return true;
@@ -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", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu", "fireworks"]);
65
+ const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu", "fireworks", "orcarouter"]);
66
66
  const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
67
67
 
68
68
  // Validate MODEL_PROVIDER early with a clear error message
@@ -158,6 +158,39 @@ const fireworksApiKey = process.env.FIREWORKS_API_KEY?.trim() || null;
158
158
  const fireworksEndpoint = process.env.FIREWORKS_ENDPOINT?.trim() || "https://api.fireworks.ai/inference/v1/chat/completions";
159
159
  const fireworksModel = process.env.FIREWORKS_MODEL?.trim() || "accounts/fireworks/models/kimi-k2-instruct-0905";
160
160
 
161
+ // OrcaRouter configuration — OpenAI-compatible gateway (adaptive routing,
162
+ // failover, zero-markup inference). The public relay is api.orcarouter.ai/v1;
163
+ // authentication (OAuth 2.0 + PKCE code exchange) lives on www.orcarouter.ai.
164
+ // A shared self-hosted ORCA_BASE_URL falls back for both origins; the explicit
165
+ // auth/API overrides win when set. Never derive one origin from the other.
166
+ const orcaRouterApiKey = process.env.ORCAROUTER_API_KEY?.trim() || null;
167
+ const orcaRouterAuthBaseUrl = trimTrailingSlash(
168
+ process.env.ORCA_AUTH_BASE_URL || process.env.ORCA_BASE_URL || "https://www.orcarouter.ai"
169
+ );
170
+ const orcaRouterApiBaseUrl = trimTrailingSlash(
171
+ process.env.ORCA_API_BASE_URL || process.env.ORCA_BASE_URL || "https://api.orcarouter.ai"
172
+ );
173
+ const orcaRouterEndpoint = process.env.ORCAROUTER_ENDPOINT?.trim()
174
+ || `${orcaRouterApiBaseUrl}/v1/chat/completions`;
175
+ const orcaRouterModel = process.env.ORCAROUTER_MODEL?.trim() || "orcarouter/auto";
176
+
177
+ // Fail closed on plaintext remote OrcaRouter origins — the Bearer key must
178
+ // never travel over HTTP. Same HTTPS-or-loopback policy as the auth origin.
179
+ // Validated lazily (not at require time) so unit tests and unrelated
180
+ // providers never break on an unset/invalid Orca env.
181
+ function assertOrcaOriginsAllowed() {
182
+ const { assertAllowedOrigin } = require("../clients/orcarouter-credentials");
183
+ assertAllowedOrigin(orcaRouterAuthBaseUrl, { allowHttpLoopback: true });
184
+ assertAllowedOrigin(orcaRouterApiBaseUrl, { allowHttpLoopback: true });
185
+ try {
186
+ const epOrigin = new URL(orcaRouterEndpoint).origin;
187
+ assertAllowedOrigin(epOrigin, { allowHttpLoopback: true });
188
+ } catch (err) {
189
+ if (/must be HTTPS|not a valid URL/.test(err.message)) throw err;
190
+ throw new Error(`OrcaRouter endpoint is not a valid URL: ${orcaRouterEndpoint}`);
191
+ }
192
+ }
193
+
161
194
  // Vertex AI (Google Gemini) configuration
162
195
  const vertexApiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
163
196
  const vertexModel = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
@@ -313,6 +346,12 @@ if (modelProvider === "atlas" && !atlasCloudApiKey) {
313
346
  );
314
347
  }
315
348
 
349
+ if (modelProvider === "orcarouter" && !orcaRouterApiKey) {
350
+ throw new Error(
351
+ "Set ORCAROUTER_API_KEY before starting the proxy.",
352
+ );
353
+ }
354
+
316
355
  if (modelProvider === "ollama") {
317
356
  try {
318
357
  new URL(ollamaEndpoint);
@@ -361,7 +400,7 @@ const tiersConfigured = !!(
361
400
  if (fallbackEnabled && tiersConfigured) {
362
401
  const localProviders = ["ollama", "llamacpp", "lmstudio"];
363
402
  if (localProviders.includes(fallbackProvider)) {
364
- 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`);
403
+ 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, orcarouter`);
365
404
  }
366
405
  let fallbackMisconfigured = false;
367
406
  if (fallbackProvider === "databricks" && (!rawBaseUrl || !apiKey)) {
@@ -379,6 +418,9 @@ if (fallbackEnabled && tiersConfigured) {
379
418
  if (fallbackProvider === "atlas" && !atlasCloudApiKey) {
380
419
  fallbackMisconfigured = true;
381
420
  }
421
+ if (fallbackProvider === "orcarouter" && !orcaRouterApiKey) {
422
+ fallbackMisconfigured = true;
423
+ }
382
424
  if (fallbackMisconfigured) {
383
425
  console.warn(`[WARN] FALLBACK_PROVIDER='${fallbackProvider}' is enabled but missing credentials. Fallback will not work until configured.`);
384
426
  }
@@ -672,6 +714,13 @@ var config = {
672
714
  endpoint: fireworksEndpoint,
673
715
  model: fireworksModel,
674
716
  },
717
+ orcarouter: {
718
+ apiKey: orcaRouterApiKey,
719
+ model: orcaRouterModel,
720
+ endpoint: orcaRouterEndpoint,
721
+ authBaseUrl: orcaRouterAuthBaseUrl,
722
+ apiBaseUrl: orcaRouterApiBaseUrl,
723
+ },
675
724
  codex: {
676
725
  enabled: process.env.CODEX_ENABLED !== "false",
677
726
  binaryPath: process.env.CODEX_BINARY_PATH?.trim() || null,
@@ -1137,6 +1186,17 @@ function reloadConfig() {
1137
1186
  config.baidu.model = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
1138
1187
  config.fireworks.apiKey = process.env.FIREWORKS_API_KEY?.trim() || null;
1139
1188
  config.fireworks.model = process.env.FIREWORKS_MODEL?.trim() || "accounts/fireworks/models/kimi-k2-instruct-0905";
1189
+ config.orcarouter.apiKey = process.env.ORCAROUTER_API_KEY?.trim() || null;
1190
+ config.orcarouter.model = process.env.ORCAROUTER_MODEL?.trim() || "orcarouter/auto";
1191
+ const orcaAuth = trimTrailingSlash(
1192
+ process.env.ORCA_AUTH_BASE_URL || process.env.ORCA_BASE_URL || "https://www.orcarouter.ai"
1193
+ );
1194
+ const orcaApi = trimTrailingSlash(
1195
+ process.env.ORCA_API_BASE_URL || process.env.ORCA_BASE_URL || "https://api.orcarouter.ai"
1196
+ );
1197
+ config.orcarouter.authBaseUrl = orcaAuth;
1198
+ config.orcarouter.apiBaseUrl = orcaApi;
1199
+ config.orcarouter.endpoint = process.env.ORCAROUTER_ENDPOINT?.trim() || `${orcaApi}/v1/chat/completions`;
1140
1200
 
1141
1201
  // Model provider settings
1142
1202
  const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
@@ -1225,3 +1285,4 @@ if (missingTiers.length > 0) {
1225
1285
  }
1226
1286
 
1227
1287
  module.exports = config;
1288
+ module.exports.assertOrcaOriginsAllowed = assertOrcaOriginsAllowed;
@@ -18,6 +18,7 @@ function providerMeta() {
18
18
  vertex: { type: 'cloud', configured: !!c.vertex?.projectId },
19
19
  moonshot: { type: 'cloud', configured: !!c.moonshot?.apiKey },
20
20
  fireworks: { type: 'cloud', configured: !!c.fireworks?.apiKey },
21
+ orcarouter: { type: 'cloud', configured: !!c.orcarouter?.apiKey },
21
22
  ollama: { type: 'local', configured: !!c.ollama?.endpoint },
22
23
  llamacpp: { type: 'local', configured: !!c.llamacpp?.endpoint },
23
24
  lmstudio: { type: 'local', configured: !!c.lmstudio?.endpoint },
@@ -127,6 +127,12 @@ function hashAndTruncate(content, maxLength, deduplicator) {
127
127
  return { hash: null, content: null, truncated: false, originalLength: null };
128
128
  }
129
129
 
130
+ // Redact image bytes before hashing/storing — never persist base64 to disk.
131
+ try {
132
+ const { redactVisionBytes } = require('../routing/vision');
133
+ content = redactVisionBytes(content);
134
+ } catch { /* redaction is best-effort */ }
135
+
130
136
  // Hash the ORIGINAL content before any truncation
131
137
  const hash = deduplicator ? deduplicator.hashContent(content) : null;
132
138
 
@@ -244,6 +244,11 @@ class ContentDeduplicator {
244
244
  return null;
245
245
  }
246
246
 
247
+ // Redact image bytes before anything touches disk.
248
+ try {
249
+ content = require('../routing/vision').redactVisionBytes(content);
250
+ } catch { /* best-effort */ }
251
+
247
252
  let stringContent = typeof content === "string" ? content : JSON.stringify(content);
248
253
 
249
254
  // Sanitize content before hashing (if enabled)
@@ -292,6 +297,11 @@ class ContentDeduplicator {
292
297
  return null;
293
298
  }
294
299
 
300
+ // Redact image bytes before anything touches disk.
301
+ try {
302
+ content = require('../routing/vision').redactVisionBytes(content);
303
+ } catch { /* best-effort */ }
304
+
295
305
  let stringContent = typeof content === "string" ? content : JSON.stringify(content);
296
306
 
297
307
  // Sanitize content (if enabled)
@@ -61,6 +61,8 @@ function getDestinationUrl(providerType) {
61
61
  return config.baidu?.endpoint ?? 'unknown';
62
62
  case 'fireworks':
63
63
  return config.fireworks?.endpoint ?? 'unknown';
64
+ case 'orcarouter':
65
+ return config.orcarouter?.endpoint ?? 'https://api.orcarouter.ai/v1/chat/completions';
64
66
  case 'codex':
65
67
  return 'codex://app-server (local process)';
66
68
  default:
@@ -948,7 +950,14 @@ function sanitizePayload(payload) {
948
950
  || config.modelProvider?.type
949
951
  || "databricks";
950
952
  const willFlatten = providerType !== "azure-anthropic";
951
- const clean = clonePayloadSmart(payload ?? {}, { willFlatten });
953
+ // Vision payloads must keep image bytes through the clone: flatten-aware
954
+ // cloning replaces them with {_skipped:true} placeholders that downstream
955
+ // converters can no longer recover. Full-clone when images are present.
956
+ let needsVisionForClone = false;
957
+ try {
958
+ needsVisionForClone = require("../routing/vision").payloadNeedsVision(payload);
959
+ } catch { needsVisionForClone = false; }
960
+ const clean = clonePayloadSmart(payload ?? {}, { willFlatten: willFlatten && !needsVisionForClone });
952
961
  const requestedModel =
953
962
  (typeof payload?.model === "string" && payload.model.trim().length > 0
954
963
  ? payload.model.trim()
@@ -1031,17 +1040,28 @@ function sanitizePayload(payload) {
1031
1040
  const { modelNameSupportsTools } = require("../clients/ollama-utils");
1032
1041
  const modelSupportsTools = modelNameSupportsTools(config.ollama?.model);
1033
1042
 
1043
+ // Preserve image blocks for vision-capable Ollama models (e.g. llava,
1044
+ // llama3.2-vision): the native /v1/messages path forwards them verbatim,
1045
+ // the legacy /api/chat path drops what it can't handle. Text-only
1046
+ // filtering below must not blind the native path.
1047
+ const msgHasVision = (msg) => Array.isArray(msg?.content) && msg.content.some((b) =>
1048
+ b?.type === 'image' || b?.type === 'image_url' || b?.image_url?.url || b?.source?.data
1049
+ );
1034
1050
  if (!modelSupportsTools) {
1035
1051
  // Filter out tool_result content blocks for models without tool support
1052
+ // (but keep text + images so vision still works).
1036
1053
  clean.messages = clean.messages
1037
1054
  .map((msg) => {
1038
1055
  if (Array.isArray(msg.content)) {
1039
- // Filter out tool_use and tool_result blocks
1040
- const textBlocks = msg.content.filter(
1041
- (block) => block.type === "text" && block.text
1056
+ const keepBlocks = msg.content.filter(
1057
+ (block) => (block.type === "text" && block.text) || msgHasVision({ content: [block] })
1042
1058
  );
1043
- if (textBlocks.length > 0) {
1044
- // Convert to simple string format for Ollama
1059
+ if (keepBlocks.length > 0) {
1060
+ const textBlocks = keepBlocks.filter((b) => b.type === "text" && b.text);
1061
+ // If only images remain, keep array form for the native path
1062
+ if (textBlocks.length === 0) return { role: msg.role, content: keepBlocks };
1063
+ // Mixed text+images: keep array form (native) — legacy drops images
1064
+ if (keepBlocks.some((b) => b.type !== "text")) return { role: msg.role, content: keepBlocks };
1045
1065
  return {
1046
1066
  role: msg.role,
1047
1067
  content: textBlocks.map((b) => b.text).join("\n"),
@@ -1054,9 +1074,10 @@ function sanitizePayload(payload) {
1054
1074
  .filter(Boolean);
1055
1075
  } else {
1056
1076
  // Keep tool blocks for tool-capable models
1057
- // But flatten content to simple string for better compatibility
1077
+ // Preserve image arrays; flatten pure-text arrays for compatibility.
1058
1078
  clean.messages = clean.messages.map((msg) => {
1059
1079
  if (Array.isArray(msg.content)) {
1080
+ if (msgHasVision(msg)) return msg;
1060
1081
  const textBlocks = msg.content.filter(
1061
1082
  (block) => block.type === "text" && block.text
1062
1083
  );