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.
- 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/difficulty-anchors.json +12 -2
- package/config/model-capability-seeds.json +5 -0
- package/package.json +3 -3
- package/scripts/validate-intent-anchors.js +2 -2
- package/src/api/openai-router.js +22 -4
- package/src/api/providers-handler.js +33 -10
- package/src/api/router.js +370 -16
- package/src/clients/databricks.js +196 -22
- package/src/clients/openai-format.js +37 -2
- package/src/clients/openrouter-utils.js +20 -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 +49 -10
- package/src/orchestrator/sse-transformer.js +3 -0
- package/src/prompts/system.js +41 -49
- package/src/routing/affinity-store.js +47 -21
- package/src/routing/cache-economics.js +1 -0
- package/src/routing/cache-switch-cost.js +46 -2
- package/src/routing/complexity-analyzer.js +21 -4
- package/src/routing/index.js +123 -1
- package/src/routing/intent-score.js +48 -13
- package/src/routing/jev-router.js +272 -0
- package/src/routing/model-tiers.js +15 -0
- package/src/routing/passthrough-route.js +286 -0
- package/src/routing/quota-ledger.js +110 -0
- package/src/routing/risk-analyzer.js +17 -1
- package/src/routing/telemetry.js +47 -2
|
@@ -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;
|
package/src/config/index.js
CHANGED
|
@@ -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;
|
package/src/dashboard/api.js
CHANGED
|
@@ -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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|