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.
- package/README.md +1 -0
- package/bin/cli.js +1 -0
- package/bin/lynkr-connect-orcarouter.js +246 -0
- package/bin/lynkr-init.js +15 -1
- package/config/model-capability-seeds.json +32 -4
- package/package.json +1 -1
- package/src/api/middleware/session.js +10 -1
- package/src/api/openai-router.js +22 -4
- package/src/api/providers-handler.js +33 -10
- package/src/cache/prompt.js +40 -1
- package/src/cache/semantic.js +7 -0
- package/src/clients/databricks.js +219 -2
- package/src/clients/openrouter-utils.js +84 -14
- 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/logger/audit-logger.js +6 -0
- package/src/logger/deduplicator.js +10 -0
- package/src/orchestrator/index.js +28 -7
- package/src/orchestrator/sse-transformer.js +3 -0
- package/src/routing/cache-economics.js +1 -0
- package/src/routing/client-profiles.js +19 -0
- package/src/routing/index.js +45 -19
- package/src/routing/model-tiers.js +12 -11
- package/src/routing/risk-classifier.js +13 -1
- package/src/routing/tokenizer.js +22 -5
- package/src/routing/vision.js +246 -0
- package/src/training/trajectory-compressor.js +8 -0
- package/src/utils/tokens.js +3 -3
|
@@ -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 },
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
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 (
|
|
1044
|
-
|
|
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
|
-
//
|
|
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
|
);
|