lynkr 9.14.11 → 9.14.13

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 },
@@ -1,5 +1,5 @@
1
1
  const config = require("../config");
2
- const { invokeModel } = require("../clients/databricks");
2
+ const { invokeModel, toolsDeclined } = require("../clients/databricks");
3
3
  const { appendTurnToSession } = require("../sessions/record");
4
4
  const { upsertSession } = require("../sessions/store");
5
5
  const policy = require("../policy");
@@ -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:
@@ -1111,15 +1113,50 @@ function sanitizePayload(payload) {
1111
1113
  if (tools) clean.tools = tools;
1112
1114
  else delete clean.tools;
1113
1115
  } else if (providerType === "azure-anthropic") {
1114
- const tools = sanitiseAzureTools(clean.tools);
1115
- clean.tools =
1116
- tools && tools.length > 0
1117
- ? tools
1118
- : DEFAULT_AZURE_TOOLS.map((tool) => ({
1119
- name: tool.name,
1120
- input_schema: JSON.parse(JSON.stringify(tool.input_schema)),
1121
- }));
1122
- delete clean.tool_choice;
1116
+ // toolsDeclined is imported top-level from ../clients/databricks
1117
+ // alongside invokeModel — same module instance, no inline require
1118
+ // needed (a guarded re-require would imply a failure mode that cannot
1119
+ // occur without breaking everything else first).
1120
+ // A caller that declined tools gets none — never substitute defaults
1121
+ // (issue #114: substitution here turned tool_choice "none" into an
1122
+ // unrelated tool set with the choice deleted).
1123
+ if (toolsDeclined(clean)) {
1124
+ delete clean.tools;
1125
+ clean.tool_choice = "none";
1126
+ } else {
1127
+ const tools = sanitiseAzureTools(clean.tools);
1128
+ if (Array.isArray(clean.tools) && clean.tools.length > 0 && (!tools || tools.length === 0)) {
1129
+ // The caller declared tools and every one was rejected: substituting
1130
+ // unrelated defaults would silently change the contract, so fail
1131
+ // loudly naming the tools instead (issue #114).
1132
+ const names = clean.tools
1133
+ .map((t) => t?.name || t?.function?.name || "unnamed")
1134
+ .filter(Boolean);
1135
+ const err = new Error(
1136
+ `azure-anthropic supports only built-in tools (WebSearch, WebFetch, Bash family); ` +
1137
+ `rejected tool(s): ${names.join(", ")}. Remove them or route to a provider that serves them.`
1138
+ );
1139
+ err.statusCode = 400;
1140
+ err.code = "unsupported_tools";
1141
+ err.isOperational = true;
1142
+ throw err;
1143
+ }
1144
+ const declaredNames = Array.isArray(clean.tools)
1145
+ ? clean.tools.map((t) => t?.name || t?.function?.name).filter(Boolean)
1146
+ : [];
1147
+ clean.tools =
1148
+ tools && tools.length > 0
1149
+ ? tools
1150
+ : DEFAULT_AZURE_TOOLS.map((tool) => ({
1151
+ name: tool.name,
1152
+ input_schema: JSON.parse(JSON.stringify(tool.input_schema)),
1153
+ }));
1154
+ const dropped = declaredNames.filter((n) => !(tools || []).some((k) => k.name === n));
1155
+ if (dropped.length > 0) {
1156
+ logger.debug({ dropped }, '[azure-anthropic] Partial tool substitution: unlisted tools dropped');
1157
+ }
1158
+ delete clean.tool_choice;
1159
+ }
1123
1160
  } else if (providerType === "ollama") {
1124
1161
  // Always pass tools through to Ollama in Anthropic format when they exist.
1125
1162
  // Ollama (v0.14+ native /v1/messages) accepts the Anthropic tool shape; if
@@ -3247,6 +3284,8 @@ function trimLoopMessages(messages, max) {
3247
3284
 
3248
3285
  module.exports = {
3249
3286
  processMessage,
3287
+ // Exported for unit testing of sanitizePayload tool handling.
3288
+ sanitizePayload,
3250
3289
  // Exported for unit testing of response-metadata conversion.
3251
3290
  toAnthropicResponse,
3252
3291
  // Exported for unit testing of loop trimming (task-preservation contract).
@@ -52,6 +52,9 @@ const DEFAULT_OPENAI_SSE_PROVIDERS = [
52
52
  // match choices[0].delta exactly, remove it from this list rather than
53
53
  // patching the shared transformer for one provider's quirk.
54
54
  "fireworks",
55
+ // orcarouter (api.orcarouter.ai/v1/chat/completions) is OpenAI-compatible
56
+ // SSE — E2E-verified live 2026-09-12 through invokeOrcaRouter.
57
+ "orcarouter",
55
58
  ];
56
59
 
57
60
  // llama.cpp specific: reasoning-capable local builds (live-confirmed on this