lynkr 9.14.11 → 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 CHANGED
@@ -211,6 +211,7 @@ Claude Code / Cursor / Codex / Cline / Continue
211
211
  | **llama.cpp** | Local | Any GGUF model | **Free** |
212
212
  | **LM Studio** | Local | Local models with GUI | **Free** |
213
213
  | **OpenRouter** | Cloud | GPT-4o, Claude 3.5, Llama 3, Gemini | $ |
214
+ | **OrcaRouter** | Cloud | Chat, reasoning, image, video (one endpoint, live catalog) | $ (zero-markup) |
214
215
  | **AWS Bedrock** | Cloud | Claude, Llama, Mistral, Titan | $$ |
215
216
  | **Databricks** | Cloud | Claude Sonnet 4.5, Opus 4.6 | $$$ |
216
217
  | **Azure OpenAI** | Cloud | GPT-4o, o1, o3 | $$$ |
package/bin/cli.js CHANGED
@@ -15,6 +15,7 @@ const SUBCOMMANDS = {
15
15
  "desktop-token": path.join(__dirname, "lynkr-desktop-token.js"),
16
16
  restart: path.join(__dirname, "lynkr-restart.js"),
17
17
  run: path.join(__dirname, "run.js"),
18
+ connect: path.join(__dirname, "lynkr-connect-orcarouter.js"),
18
19
  };
19
20
 
20
21
  const sub = process.argv[2];
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `lynkr connect orcarouter` — OAuth 2.0 + PKCE (Flow B, out-of-band) login.
4
+ *
5
+ * Lynkr is self-hosted software whose install address differs on every
6
+ * deployment (LAN box, NAS, moved port), so there is no predictable loopback
7
+ * port to register — Flow B (out-of-band code) exists precisely for that
8
+ * case. The command:
9
+ *
10
+ * 1. Generates a fresh PKCE verifier + CSRF state (crypto RNG, per attempt).
11
+ * 2. Prints an authorize URL (callback_url=oob, S256 challenge) and opens
12
+ * it in the default browser when possible.
13
+ * 3. Asks for the one-time code shown on the consent screen.
14
+ * 4. Exchanges it at www.orcarouter.ai/api/v1/auth/keys for a durable
15
+ * OrcaRouter API key that belongs to the user.
16
+ * 5. Persists the key to the project's .env (same secret location as every
17
+ * other Lynkr provider key) and prints the masked status.
18
+ *
19
+ * The verifier never leaves the process until the exchange; it never appears
20
+ * in a URL, a log, or an error. Auth requests hit the auth origin
21
+ * (www.orcarouter.ai) only; inference stays on api.orcarouter.ai/v1.
22
+ *
23
+ * Usage:
24
+ * lynkr connect orcarouter # interactive OOB code login
25
+ * lynkr connect orcarouter --code <code> # non-interactive (CI / GUI)
26
+ * lynkr connect orcarouter --status # masked status of stored key
27
+ * lynkr connect orcarouter --clear # remove the stored key
28
+ *
29
+ * @module bin/lynkr-connect-orcarouter
30
+ */
31
+
32
+ const path = require("path");
33
+ const fs = require("fs");
34
+ const readline = require("readline");
35
+ const { exec } = require("child_process");
36
+
37
+ const ROOT = path.join(__dirname, "..");
38
+ const ENV_PATH = path.join(ROOT, ".env");
39
+ const {
40
+ connectWithPkce,
41
+ getCredentialStore,
42
+ createApiKeyAdapter,
43
+ maskKey,
44
+ resolveAuthBase,
45
+ resolveApiBase,
46
+ } = require("../src/clients/orcarouter-credentials");
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // .env persistence — the project's existing secret location. No new secret
50
+ // store is introduced. Keys are written as `ORCAROUTER_API_KEY=...` alongside
51
+ // the other provider keys; existing keys are updated in place.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ function readEnvLines() {
55
+ try {
56
+ return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split(/\r?\n/) : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function writeEnvLines(lines) {
63
+ fs.writeFileSync(ENV_PATH, lines.join("\n") + "\n", { mode: 0o600 });
64
+ // mode only applies on creation — tighten existing files explicitly so a
65
+ // previously 0644 .env doesn't leave the new OAuth key world-readable.
66
+ try {
67
+ fs.chmodSync(ENV_PATH, 0o600);
68
+ } catch {
69
+ /* best-effort: e.g. read-only FS in CI */
70
+ }
71
+ }
72
+
73
+ function readEnvKey(key) {
74
+ try {
75
+ const lines = readEnvLines();
76
+ for (const line of lines) {
77
+ const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
78
+ if (m && m[1] === key) {
79
+ const v = (m[2] || "").trim();
80
+ return v || null;
81
+ }
82
+ }
83
+ } catch {
84
+ /* missing/unreadable .env → no file-backed value */
85
+ }
86
+ return null;
87
+ }
88
+
89
+ // File-backed env for status: process.env wins, .env file is the fallback so
90
+ // a key saved by a previous `connect` process is visible without re-export.
91
+ function statusEnv() {
92
+ const env = { ...process.env };
93
+ for (const k of ["ORCAROUTER_API_KEY", "ORCA_API_BASE_URL", "ORCA_AUTH_BASE_URL", "ORCA_BASE_URL"]) {
94
+ if (!env[k]) {
95
+ const v = readEnvKey(k);
96
+ if (v) env[k] = v;
97
+ }
98
+ }
99
+ return env;
100
+ }
101
+
102
+ function upsertEnvKey(key, value) {
103
+ const lines = readEnvLines();
104
+ let replaced = false;
105
+ const out = lines.map((line) => {
106
+ const m = line.match(/^([A-Z0-9_]+)\s*=/);
107
+ if (m && m[1] === key) {
108
+ replaced = true;
109
+ return `${key}=${value}`;
110
+ }
111
+ return line;
112
+ });
113
+ if (!replaced) out.push(`${key}=${value}`);
114
+ writeEnvLines(out);
115
+ }
116
+
117
+ function clearEnvKey(_key) {
118
+ const lines = readEnvLines().filter((line) => !line.match(/^ORCAROUTER_API_KEY\s*=/));
119
+ writeEnvLines(lines);
120
+ }
121
+
122
+ // (readEnvKey is intentionally not exported — the store's read() covers status.)
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // CLI
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function openBrowser(url) {
129
+ const platform = process.platform;
130
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
131
+ exec(`"${cmd}" "${url}"`, () => {});
132
+ }
133
+
134
+ function ask(question) {
135
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
136
+ return new Promise((resolve) =>
137
+ rl.question(question, (answer) => {
138
+ rl.close();
139
+ resolve(answer.trim());
140
+ })
141
+ );
142
+ }
143
+
144
+ function fail(msg) {
145
+ console.error(`✗ ${msg}`);
146
+ process.exit(1);
147
+ }
148
+
149
+ function status() {
150
+ // Read through a file-backed store: a key saved to .env by either
151
+ // connection path must show up in a later process without requiring export.
152
+ const { OrcaCredentialStore } = require("../src/clients/orcarouter-credentials");
153
+ const env = statusEnv();
154
+ const store = new OrcaCredentialStore({
155
+ env,
156
+ loadSecret: () => env.ORCAROUTER_API_KEY || null,
157
+ });
158
+ const st = store.read();
159
+ console.log(st.present
160
+ ? `OrcaRouter credential: ${st.masked} (generation ${st.generation})${st.needsReauth ? " — needs reauthentication" : ""}`
161
+ : "No OrcaRouter credential configured.");
162
+ console.log(` API base: ${resolveApiBase(env)}`);
163
+ console.log(` Auth base: ${resolveAuthBase(env)}`);
164
+ }
165
+
166
+ async function runConnect(opts) {
167
+ const store = getCredentialStore();
168
+
169
+ // Adapter A: update/clear an API key directly through the seam too.
170
+ if (opts.apiKey) {
171
+ const adapter = createApiKeyAdapter(store, { env: process.env });
172
+ const cred = adapter.set(opts.apiKey);
173
+ upsertEnvKey("ORCAROUTER_API_KEY", cred.key);
174
+ console.log(`✓ OrcaRouter API key saved (${maskKey(cred.key)}).`);
175
+ return;
176
+ }
177
+ if (opts.clear) {
178
+ store.clear();
179
+ clearEnvKey("ORCAROUTER_API_KEY");
180
+ console.log("✓ OrcaRouter credential cleared.");
181
+ return;
182
+ }
183
+ if (opts.status) {
184
+ status();
185
+ return;
186
+ }
187
+
188
+ // PKCE connect. Generate verifier+state fresh; hand back the authorize URL.
189
+ const started = await connectWithPkce({ appName: "Lynkr" });
190
+ if (!started.ok) fail(started.error?.message || "Could not start OrcaRouter login");
191
+
192
+ console.log("\n1. Open this URL in your browser (opening automatically…):");
193
+ console.log(` ${started.authorizeUrl}\n`);
194
+ openBrowser(started.authorizeUrl);
195
+
196
+ const code = opts.code || (await ask("2. Paste the one-time code from the consent screen: ")).trim();
197
+ if (!code) fail("No code provided.");
198
+
199
+ // Exchange the code for the durable key using the SAME verifier that
200
+ // produced the challenge (PKCE binding — a fresh verifier would mismatch).
201
+ const result = await connectWithPkce({ appName: "Lynkr", code, verifier: started.verifier, state: started.state });
202
+ if (!result.ok) {
203
+ fail(result.error?.message || `OrcaRouter exchange failed (HTTP ${result.error?.status || "?"})`);
204
+ }
205
+ upsertEnvKey("ORCAROUTER_API_KEY", result.credential.key);
206
+ const scopeNote = result.credential.scope && result.credential.scope !== "api"
207
+ ? ` (granted scope: ${result.credential.scope})`
208
+ : "";
209
+ console.log(`\n✓ Connected to OrcaRouter. Key saved to .env${scopeNote}.`);
210
+ console.log(` Masked: ${maskKey(result.credential.key)}`);
211
+ console.log(" Revoke anytime at https://www.orcarouter.ai/console/authorized-apps");
212
+ }
213
+
214
+ function parseArgs(argv) {
215
+ const opts = { apiKey: null, code: null, status: false, clear: false };
216
+ for (let i = 0; i < argv.length; i++) {
217
+ const a = argv[i];
218
+ if (a === "--status") opts.status = true;
219
+ else if (a === "--clear") opts.clear = true;
220
+ else if (a === "--api-key") opts.apiKey = argv[++i];
221
+ else if (a === "--code") opts.code = argv[++i];
222
+ }
223
+ return opts;
224
+ }
225
+
226
+ async function main() {
227
+ const args = process.argv.slice(2);
228
+ if (args.includes("--help") || args.includes("-h")) {
229
+ console.log(`lynkr connect orcarouter — OAuth 2.0 + PKCE login for the OrcaRouter provider
230
+
231
+ Usage:
232
+ lynkr connect orcarouter Interactive out-of-band code login
233
+ lynkr connect orcarouter --code <code> Non-interactive (paste the consent code)
234
+ lynkr connect orcarouter --api-key <key> Save an existing sk-orca-… API key
235
+ lynkr connect orcarouter --status Show masked credential status
236
+ lynkr connect orcarouter --clear Remove the stored credential`);
237
+ return;
238
+ }
239
+ return runConnect(parseArgs(args));
240
+ }
241
+
242
+ if (require.main === module || process.env._LYNKR_SUBCMD === "connect") {
243
+ main().catch((err) => fail(err.message));
244
+ }
245
+
246
+ module.exports = { runConnect, parseArgs, upsertEnvKey, clearEnvKey, readEnvKey, statusEnv, status };
package/bin/lynkr-init.js CHANGED
@@ -188,12 +188,23 @@ const PROVIDERS = {
188
188
  extras: [],
189
189
  defaultModel: 'accounts/fireworks/models/kimi-k2-instruct-0905',
190
190
  },
191
+ orcarouter: {
192
+ label: 'OrcaRouter (OpenAI-compatible gateway — adaptive routing, failover, zero-markup inference)',
193
+ local: false,
194
+ creds: [
195
+ { key: 'ORCAROUTER_API_KEY', label: 'OrcaRouter API key (sk-orca-...; or run `lynkr connect orcarouter` for OAuth + PKCE login)', secret: true },
196
+ ],
197
+ extras: [
198
+ { key: 'ORCAROUTER_MODEL', label: 'default model', default: 'orcarouter/auto' },
199
+ ],
200
+ defaultModel: 'orcarouter/auto',
201
+ },
191
202
  };
192
203
 
193
204
  const PROVIDER_ORDER = [
194
205
  'ollama', 'llamacpp', 'lmstudio',
195
206
  'azure-anthropic', 'azure-openai', 'openai', 'atlas', 'openrouter', 'edenai',
196
- 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu', 'fireworks',
207
+ 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu', 'fireworks', 'orcarouter',
197
208
  ];
198
209
  const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
199
210
 
@@ -354,6 +365,9 @@ const BASELINE_ENV = {
354
365
  FIREWORKS_API_KEY: '',
355
366
  FIREWORKS_ENDPOINT: 'https://api.fireworks.ai/inference/v1/chat/completions',
356
367
  FIREWORKS_MODEL: 'accounts/fireworks/models/kimi-k2-instruct-0905',
368
+ ORCAROUTER_API_KEY: '',
369
+ ORCAROUTER_MODEL: 'orcarouter/auto',
370
+ ORCAROUTER_ENDPOINT: 'https://api.orcarouter.ai/v1/chat/completions',
357
371
  LLAMACPP_ENDPOINT: 'http://localhost:8080',
358
372
  LLAMACPP_MODEL: 'default',
359
373
  LLAMACPP_TIMEOUT_MS: '120000',
@@ -7,6 +7,7 @@
7
7
  "claude-opus-3*": { "caps": { "reasoning": 0.8, "codegen": 0.82, "debugging": 0.8, "tool_use": 0.78 }, "sources": ["swe-bench-verified"], "note": "curated-estimate" },
8
8
  "claude-sonnet-4*": { "caps": { "reasoning": 0.78, "codegen": 0.8, "debugging": 0.78, "tool_use": 0.78 }, "sources": ["swe-bench-verified", "artificial-analysis"], "note": "curated-estimate" },
9
9
  "claude-sonnet-3.5": { "caps": { "reasoning": 0.72, "codegen": 0.75, "debugging": 0.72, "tool_use": 0.7 }, "sources": ["swe-bench-verified"], "note": "curated-estimate" },
10
+ "orcarouter-auto": { "caps": { "reasoning": 0.8, "codegen": 0.82, "debugging": 0.8, "tool_use": 0.82 }, "sources": ["provider-routing:adaptive auto"], "note": "curated-estimate: orcarouter/auto routes across frontier models per request" },
10
11
  "claude-3.5-sonnet*": { "caps": { "reasoning": 0.72, "codegen": 0.75, "debugging": 0.72, "tool_use": 0.7 }, "sources": ["swe-bench-verified"], "note": "alias: dated/provider form (claude-3-5-sonnet-20241022) shares claude-sonnet-3.5 caps" },
11
12
  "claude-haiku*": { "caps": { "reasoning": 0.42, "codegen": 0.48, "debugging": 0.42, "tool_use": 0.45 }, "sources": ["swe-bench-verified"], "note": "curated-estimate" },
12
13
  "claude-3.5-haiku*": { "caps": { "reasoning": 0.42, "codegen": 0.48, "debugging": 0.42, "tool_use": 0.45 }, "sources": ["swe-bench-verified"], "note": "alias: dated/provider form (claude-3-5-haiku-20241022) shares claude-haiku caps" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.14.11",
3
+ "version": "9.14.12",
4
4
  "files": [
5
5
  "index.js",
6
6
  "install.sh",
@@ -986,9 +986,10 @@ router.post("/chat/completions", async (req, res) => {
986
986
 
987
987
  /**
988
988
  * Get all configured providers with their models (cc-relay style)
989
- * Reads from config (which comes from .env) to discover what's available
989
+ * Reads from config (which comes from .env) to discover what's available.
990
+ * OrcaRouter contributes its live capability-filtered chat catalog.
990
991
  */
991
- function getConfiguredProviders() {
992
+ async function getConfiguredProviders() {
992
993
  const providers = [];
993
994
 
994
995
  if (config.databricks?.url && config.databricks?.apiKey) {
@@ -1156,6 +1157,23 @@ function getConfiguredProviders() {
1156
1157
  });
1157
1158
  }
1158
1159
 
1160
+ // OrcaRouter (OpenAI-compatible gateway). The model list is the live
1161
+ // capability-filtered catalog fetch (authoritative when the account key
1162
+ // works), with a verified seed fallback. Never a hand-written example list.
1163
+ if (config.orcarouter?.apiKey) {
1164
+ try {
1165
+ const { getOrcaChatModels } = require("../clients/orcarouter-catalog");
1166
+ const { models } = await getOrcaChatModels(config.orcarouter, { capability: "chat" });
1167
+ providers.push({
1168
+ name: "orcarouter",
1169
+ type: "orcarouter",
1170
+ models: models.map(m => m.id),
1171
+ });
1172
+ } catch (err) {
1173
+ logger.debug({ err: err.message }, "OrcaRouter discovery failed — omitting from OpenAI /v1/models");
1174
+ }
1175
+ }
1176
+
1159
1177
  if (config.vertex?.projectId) {
1160
1178
  providers.push({
1161
1179
  name: "vertex",
@@ -1178,9 +1196,9 @@ function getConfiguredProviders() {
1178
1196
  * List available models from ALL configured providers (cc-relay style).
1179
1197
  * Returns OpenAI-compatible model list with provider field.
1180
1198
  */
1181
- router.get("/models", (req, res) => {
1199
+ router.get("/models", async (req, res) => {
1182
1200
  try {
1183
- const providers = getConfiguredProviders();
1201
+ const providers = await getConfiguredProviders();
1184
1202
  const timestamp = Math.floor(Date.now() / 1000);
1185
1203
  const models = [];
1186
1204
  const seenModelIds = new Set();
@@ -17,9 +17,11 @@ const router = express.Router();
17
17
 
18
18
  /**
19
19
  * Get all configured providers with their models
20
- * Reads from config (which comes from .env) to discover what's available
20
+ * Reads from config (which comes from .env) to discover what's available.
21
+ * OrcaRouter's model list is a live capability-filtered catalog fetch
22
+ * (authoritative when the account key works), with a verified seed fallback.
21
23
  */
22
- function getConfiguredProviders() {
24
+ async function getConfiguredProviders() {
23
25
  const providers = [];
24
26
 
25
27
  // Check Databricks
@@ -253,6 +255,27 @@ function getConfiguredProviders() {
253
255
  });
254
256
  }
255
257
 
258
+ // Check OrcaRouter (OpenAI-compatible gateway). The model list is the live
259
+ // capability-filtered catalog (text chat) — authoritative when discovery
260
+ // succeeds, verified seed otherwise. Never a hand-written example list.
261
+ if (config.orcarouter?.apiKey) {
262
+ try {
263
+ const { getOrcaChatModels } = require("../clients/orcarouter-catalog");
264
+ const { models, source, degraded } = await getOrcaChatModels(config.orcarouter, { capability: "chat" });
265
+ providers.push({
266
+ name: "orcarouter",
267
+ type: "orcarouter",
268
+ baseUrl: `${config.orcarouter.apiBaseUrl || "https://api.orcarouter.ai"}/v1`,
269
+ enabled: true,
270
+ catalog_source: source,
271
+ catalog_degraded: degraded,
272
+ models: models.map(m => ({ id: m.id, name: m.name || m.id })),
273
+ });
274
+ } catch (err) {
275
+ logger.debug({ err: err.message }, "OrcaRouter provider discovery failed — omitting from /v1/models");
276
+ }
277
+ }
278
+
256
279
  // Check Vertex AI (Google Cloud)
257
280
  if (config.vertex?.projectId) {
258
281
  const region = config.vertex.region || "us-east5";
@@ -287,9 +310,9 @@ function getPrimaryProvider() {
287
310
  * Anthropic-compatible model listing endpoint (cc-relay style).
288
311
  * Lists all available models from all configured providers.
289
312
  */
290
- router.get("/models", (req, res) => {
313
+ router.get("/models", async (req, res) => {
291
314
  try {
292
- const providers = getConfiguredProviders();
315
+ const providers = await getConfiguredProviders();
293
316
  const primaryProvider = getPrimaryProvider();
294
317
  const timestamp = Math.floor(Date.now() / 1000);
295
318
 
@@ -344,9 +367,9 @@ router.get("/models", (req, res) => {
344
367
  * Provider listing endpoint (cc-relay style).
345
368
  * Lists all configured providers with their metadata and models.
346
369
  */
347
- router.get("/providers", (req, res) => {
370
+ router.get("/providers", async (req, res) => {
348
371
  try {
349
- const providers = getConfiguredProviders();
372
+ const providers = await getConfiguredProviders();
350
373
  const primaryProvider = getPrimaryProvider();
351
374
  const fallbackProvider = config.modelProvider?.fallbackProvider;
352
375
 
@@ -391,10 +414,10 @@ router.get("/providers", (req, res) => {
391
414
  *
392
415
  * Get details for a specific provider.
393
416
  */
394
- router.get("/providers/:name", (req, res) => {
417
+ router.get("/providers/:name", async (req, res) => {
395
418
  try {
396
419
  const providerName = req.params.name.toLowerCase();
397
- const providers = getConfiguredProviders();
420
+ const providers = await getConfiguredProviders();
398
421
  const provider = providers.find(p => p.name === providerName);
399
422
 
400
423
  if (!provider) {
@@ -435,9 +458,9 @@ router.get("/providers/:name", (req, res) => {
435
458
  *
436
459
  * Get current configuration summary (without sensitive data).
437
460
  */
438
- router.get("/config", (req, res) => {
461
+ router.get("/config", async (req, res) => {
439
462
  try {
440
- const providers = getConfiguredProviders();
463
+ const providers = await getConfiguredProviders();
441
464
 
442
465
  res.json({
443
466
  model_provider: config.modelProvider?.type || "databricks",
@@ -2893,6 +2893,137 @@ function sanitizeSchemaForGemini(schema) {
2893
2893
  return sanitized;
2894
2894
  }
2895
2895
 
2896
+ /**
2897
+ * OrcaRouter Provider (OpenAI-compatible gateway)
2898
+ *
2899
+ * OrcaRouter exposes an OpenAI-compatible Chat Completions API at
2900
+ * https://api.orcarouter.ai/v1/chat/completions with Bearer-token auth.
2901
+ * Modeled on invokeFireworks/invokeAtlas: request side reuses the shared
2902
+ * openrouter-utils converters, streaming returns the raw OpenAI SSE stream
2903
+ * (orcarouter is in DEFAULT_OPENAI_SSE_PROVIDERS), buffered responses are
2904
+ * converted to Anthropic shape locally so the orchestrator branch is a plain
2905
+ * passthrough.
2906
+ *
2907
+ * The key comes from the credential seam (src/clients/orcarouter-credentials):
2908
+ * either a user-pasted sk-orca-… key or a key issued via OAuth 2.0 + PKCE.
2909
+ * Downstream code never knows which source produced it.
2910
+ */
2911
+ async function invokeOrcaRouter(body, _incomingHeaders = {}) {
2912
+ const {
2913
+ convertAnthropicToolsToOpenRouter,
2914
+ convertAnthropicMessagesToOpenRouter
2915
+ } = require("./openrouter-utils");
2916
+ const { chatCompletionsUrl } = require("./orcarouter-catalog");
2917
+
2918
+ const apiKey = config.orcarouter?.apiKey;
2919
+ if (!apiKey) {
2920
+ throw new Error("OrcaRouter API key is not configured. Run `lynkr connect orcarouter` or set ORCAROUTER_API_KEY in your .env file.");
2921
+ }
2922
+
2923
+ // Defense in depth: never send the Bearer key over remote plaintext HTTP.
2924
+ // chatCompletionsUrl() already validates the base; validate an explicit
2925
+ // ORCAROUTER_ENDPOINT override here too (config is lazy by design).
2926
+ try {
2927
+ const { assertAllowedOrigin } = require("./orcarouter-credentials");
2928
+ const epOrigin = new URL(config.orcarouter?.endpoint || chatCompletionsUrl(config.orcarouter?.apiBaseUrl)).origin;
2929
+ assertAllowedOrigin(epOrigin, { allowHttpLoopback: true });
2930
+ } catch (err) {
2931
+ throw new Error(`OrcaRouter endpoint rejected: ${err.message}`);
2932
+ }
2933
+
2934
+ const endpoint = config.orcarouter?.endpoint || chatCompletionsUrl(config.orcarouter?.apiBaseUrl || "https://api.orcarouter.ai");
2935
+
2936
+ const requestedModel = body._tierModel || body.model || config.orcarouter?.model || "orcarouter/auto";
2937
+ const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
2938
+
2939
+ if (body.system) {
2940
+ const systemContent = Array.isArray(body.system)
2941
+ ? body.system.map(s => s.text || s).join("\n")
2942
+ : body.system;
2943
+ messages.unshift({ role: "system", content: systemContent });
2944
+ }
2945
+
2946
+ const { resolveThinkingParam } = require("./provider-capabilities");
2947
+ const orcaBody = {
2948
+ model: requestedModel,
2949
+ messages,
2950
+ max_tokens: body.max_tokens || 16384,
2951
+ temperature: body.temperature ?? 0.7,
2952
+ top_p: body.top_p ?? 1.0,
2953
+ thinking: resolveThinkingParam(body),
2954
+ stream: body.stream ?? false,
2955
+ };
2956
+
2957
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
2958
+ orcaBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2959
+ orcaBody.tool_choice = "auto";
2960
+ orcaBody.parallel_tool_calls = false;
2961
+ }
2962
+
2963
+ const headers = {
2964
+ "Content-Type": "application/json",
2965
+ "Authorization": `Bearer ${apiKey}`,
2966
+ };
2967
+
2968
+ logger.debug({
2969
+ endpoint,
2970
+ model: orcaBody.model,
2971
+ originalModel: requestedModel,
2972
+ messageCount: orcaBody.messages?.length || 0,
2973
+ hasTools: !!orcaBody.tools,
2974
+ toolCount: orcaBody.tools?.length || 0,
2975
+ }, "=== OrcaRouter REQUEST ===");
2976
+
2977
+ // Same pre-return 429 check as the other OpenAI-compatible providers:
2978
+ // performJsonRequest's streaming branch never throws on a bad status, so a
2979
+ // 429 would otherwise be silently handed back as a "successful" response
2980
+ // and tier-fallback would never climb. Like Atlas Cloud, chat completions
2981
+ // are billable POSTs and are never replayed automatically — callers can
2982
+ // retry explicitly with their own idempotency policy; only 429 escalates
2983
+ // to tier-fallback.
2984
+ const response = await performJsonRequest(endpoint, {
2985
+ headers,
2986
+ body: orcaBody,
2987
+ maxRetriesOverride: 0,
2988
+ retryableStatusesOverride: [],
2989
+ }, "OrcaRouter");
2990
+
2991
+ // A 401 means the durable key was revoked (there is no refresh grant).
2992
+ // Mark the exact credential generation needsReauth — the UI/status command
2993
+ // surfaces it and the user re-runs `lynkr connect orcarouter`. No retry
2994
+ // loop, no fake refresh.
2995
+ if (response && !response.ok && response.status === 401) {
2996
+ try {
2997
+ const { getCredentialStore } = require("./orcarouter-credentials");
2998
+ getCredentialStore().markRejected(apiKey, null);
2999
+ } catch { /* reauth marking is best-effort — never fail the request path */ }
3000
+ }
3001
+
3002
+ if (!response.ok && response.status === 429) {
3003
+ const err = new Error(`OrcaRouter rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
3004
+ err.status = 429;
3005
+ throw err;
3006
+ }
3007
+
3008
+ if (response?.stream) {
3009
+ return response;
3010
+ }
3011
+
3012
+ if (response?.ok && response?.json) {
3013
+ const anthropicJson = convertOpenAIToAnthropic(response.json);
3014
+ return {
3015
+ ok: response.ok,
3016
+ status: response.status,
3017
+ json: anthropicJson,
3018
+ text: JSON.stringify(anthropicJson),
3019
+ contentType: "application/json",
3020
+ headers: response.headers,
3021
+ };
3022
+ }
3023
+
3024
+ return response;
3025
+ }
3026
+
2896
3027
  /**
2897
3028
  * Vertex AI Provider - Gemini Models
2898
3029
  *
@@ -3370,6 +3501,7 @@ const PROVIDER_INVOKERS = {
3370
3501
  codex: invokeCodex,
3371
3502
  baidu: invokeBaidu,
3372
3503
  fireworks: invokeFireworks,
3504
+ orcarouter: invokeOrcaRouter,
3373
3505
  };
3374
3506
 
3375
3507
  function invokeProvider(provider, body, incomingHeaders) {
@@ -4241,6 +4373,7 @@ module.exports = {
4241
4373
  invokeBaidu,
4242
4374
  invokeFireworks,
4243
4375
  invokeAtlas,
4376
+ invokeOrcaRouter,
4244
4377
  PROVIDER_INVOKERS,
4245
4378
  stripLynkrBadges,
4246
4379
  destroyHttpAgents,