From f052609162ba2071d36ed0d5acb0db023ffc6915 Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Sat, 15 Aug 2026 19:24:55 +0800 Subject: [PATCH] feat(llm): recognize orcarouter/ gateway model refs alongside openrouter The LLM client already strips a core-style "openrouter//" prefix down to the bare "/" id a direct OpenRouter call needs. Mirror that for the OrcaRouter gateway: a namespaced "orcarouter//" ref also strips to "/", while the auto-router "orcarouter/auto" keeps its prefix because OrcaRouter rejects the bare "auto" id with 503 model_not_found. Apply the same mirror to normalizeAdmissionModelRef, add baseURL inference for api.orcarouter.ai, document the gateway config in the README, and cover the new behavior with unit + harness tests. Co-Authored-By: Claude Signed-off-by: XiaoHuo888 --- README.md | 16 +++++++ dist/index.js | 2 + dist/src/admission-control.js | 22 +++++++-- dist/src/llm-client.js | 22 ++++++--- index.ts | 1 + src/admission-control.ts | 21 +++++++-- src/llm-client.ts | 21 +++++++-- test/admission-lane-model-affinity.test.mjs | 38 ++++++++++++++++ test/admission-model-resolution.test.mjs | 50 +++++++++++++++++++++ test/infer-provider-from-baseurl.test.mjs | 5 +++ test/llm-api-key-client.test.mjs | 24 +++++++++- 11 files changed, 205 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index added46ea..6dc6ba5a7 100644 --- a/README.md +++ b/README.md @@ -683,6 +683,22 @@ Notes for `llm.auth: "oauth"`: - `auth login` snapshots the previous api-key `llm` config next to the OAuth file, and `auth logout` restores that snapshot when available. - Switching from `api-key` to `oauth` does not automatically carry over `llm.baseURL`. Set it manually in OAuth mode only when you intentionally want a custom ChatGPT/Codex-compatible backend. +**Gateway (OpenAI-compatible) `llm` config — e.g. [OrcaRouter](https://www.orcarouter.ai):** + +The plugin's LLM client accepts any OpenAI-compatible gateway via `llm.baseURL` + `llm.apiKey`. When pointing at a gateway, use its namespaced model id — for OrcaRouter that is `orcarouter/auto` (the adaptive auto-router) or a provider-prefixed id such as `anthropic/claude-sonnet-4.6`. A bare model name is rejected by OrcaRouter, so keep the `orcarouter/` or vendor prefix intact: + +```json +{ + "llm": { + "auth": "api-key", + "apiKey": "${ORCAROUTER_API_KEY}", + "baseURL": "https://api.orcarouter.ai/v1", + "model": "orcarouter/auto", + "timeoutMs": 30000 + } +} +``` +
diff --git a/dist/index.js b/dist/index.js index a81e9c19f..72aa87ab8 100644 --- a/dist/index.js +++ b/dist/index.js @@ -763,6 +763,8 @@ export function inferProviderFromBaseURL(baseURL) { return "openai"; if (hostname.endsWith(".anthropic.com")) return "anthropic"; + if (hostname.endsWith(".orcarouter.ai")) + return "orcarouter"; return undefined; } catch { diff --git a/dist/src/admission-control.js b/dist/src/admission-control.js index 0b414fc9b..bd1bcd0af 100644 --- a/dist/src/admission-control.js +++ b/dist/src/admission-control.js @@ -480,6 +480,11 @@ function parseBatchUtilityResponse(response, expectedCount) { * Strip that literal "openrouter/" prefix so both forms reach this plugin's * direct client correctly; a bare "/" or an "@preset/" * alias already work against OpenRouter unchanged, so they pass through. + * + * The same mirror applies to the "orcarouter/" gateway prefix: a namespaced + * "orcarouter//" id strips to "/", while the + * auto-router "orcarouter/auto" keeps its prefix (OrcaRouter rejects the + * bare "auto" id with 503 model_not_found). */ export function normalizeAdmissionModelRef(modelRef) { const trimmed = modelRef.trim(); @@ -487,10 +492,19 @@ export function normalizeAdmissionModelRef(modelRef) { if (idx <= 0) return trimmed; const provider = trimmed.slice(0, idx).trim().toLowerCase(); - if (provider !== "openrouter") - return trimmed; - const rest = trimmed.slice(idx + 1).trim(); - return rest || trimmed; + if (provider === "openrouter") { + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; + } + if (provider === "orcarouter") { + const rest = trimmed.slice(idx + 1).trim(); + // OrcaRouter requires a namespaced model id. A remainder that still + // carries a "/" prefix (e.g. orcarouter/anthropic/claude-...) + // can drop the gateway prefix; a bare remainder (e.g. "auto") must keep + // "orcarouter/" or OrcaRouter returns 503 model_not_found. + return rest.includes("/") ? rest : trimmed; + } + return trimmed; } /** * Resolves which LLM model an admission call should use, in order: diff --git a/dist/src/llm-client.js b/dist/src/llm-client.js index 3bea16d64..fa4cbe9ef 100644 --- a/dist/src/llm-client.js +++ b/dist/src/llm-client.js @@ -7,7 +7,10 @@ import { buildOauthEndpoint, extractOutputTextFromSse, loadOAuthSession, needsRe /** * Strips a core-style provider prefix (e.g. "openrouter/anthropic/claude-...") * down to the bare "/" form a direct OpenRouter-compatible API - * needs. Any other prefix, or a string with no "/", passes through unchanged. + * needs. Also recognizes the "orcarouter/" gateway prefix: a namespaced + * "orcarouter//" id strips to "/", while the + * auto-router "orcarouter/auto" keeps its prefix (OrcaRouter rejects the bare + * "auto" id). Any other prefix, or a string with no "/", passes through. */ export function normalizeDirectModelRef(modelRef) { const trimmed = modelRef.trim(); @@ -15,10 +18,19 @@ export function normalizeDirectModelRef(modelRef) { if (idx <= 0) return trimmed; const provider = trimmed.slice(0, idx).trim().toLowerCase(); - if (provider !== "openrouter") - return trimmed; - const rest = trimmed.slice(idx + 1).trim(); - return rest || trimmed; + if (provider === "openrouter") { + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; + } + if (provider === "orcarouter") { + const rest = trimmed.slice(idx + 1).trim(); + // OrcaRouter requires a namespaced model id. A remainder that still + // carries a "/" prefix (e.g. orcarouter/anthropic/claude-...) + // can drop the gateway prefix; a bare remainder (e.g. "auto") must keep + // "orcarouter/" or OrcaRouter returns 503 model_not_found. + return rest.includes("/") ? rest : trimmed; + } + return trimmed; } const DEFAULT_SYSTEM_PROMPT = "You are a memory extraction assistant. Always respond with valid JSON only."; /** diff --git a/index.ts b/index.ts index 0396503ed..bf759aa50 100644 --- a/index.ts +++ b/index.ts @@ -1206,6 +1206,7 @@ export function inferProviderFromBaseURL(baseURL: string | undefined): string | if (hostname.endsWith(".minimax.io")) return "minimax-portal"; if (hostname.endsWith(".openai.com")) return "openai"; if (hostname.endsWith(".anthropic.com")) return "anthropic"; + if (hostname.endsWith(".orcarouter.ai")) return "orcarouter"; return undefined; } catch { return undefined; diff --git a/src/admission-control.ts b/src/admission-control.ts index 94cf1ff11..49b81409e 100644 --- a/src/admission-control.ts +++ b/src/admission-control.ts @@ -688,15 +688,30 @@ export type AdmissionLane = "reflection" | "other"; * Strip that literal "openrouter/" prefix so both forms reach this plugin's * direct client correctly; a bare "/" or an "@preset/" * alias already work against OpenRouter unchanged, so they pass through. + * + * The same mirror applies to the "orcarouter/" gateway prefix: a namespaced + * "orcarouter//" id strips to "/", while the + * auto-router "orcarouter/auto" keeps its prefix (OrcaRouter rejects the + * bare "auto" id with 503 model_not_found). */ export function normalizeAdmissionModelRef(modelRef: string): string { const trimmed = modelRef.trim(); const idx = trimmed.indexOf("/"); if (idx <= 0) return trimmed; const provider = trimmed.slice(0, idx).trim().toLowerCase(); - if (provider !== "openrouter") return trimmed; - const rest = trimmed.slice(idx + 1).trim(); - return rest || trimmed; + if (provider === "openrouter") { + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; + } + if (provider === "orcarouter") { + const rest = trimmed.slice(idx + 1).trim(); + // OrcaRouter requires a namespaced model id. A remainder that still + // carries a "/" prefix (e.g. orcarouter/anthropic/claude-...) + // can drop the gateway prefix; a bare remainder (e.g. "auto") must keep + // "orcarouter/" or OrcaRouter returns 503 model_not_found. + return rest.includes("/") ? rest : trimmed; + } + return trimmed; } /** diff --git a/src/llm-client.ts b/src/llm-client.ts index 5228f4fa6..c40786517 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -17,16 +17,29 @@ import { /** * Strips a core-style provider prefix (e.g. "openrouter/anthropic/claude-...") * down to the bare "/" form a direct OpenRouter-compatible API - * needs. Any other prefix, or a string with no "/", passes through unchanged. + * needs. Also recognizes the "orcarouter/" gateway prefix: a namespaced + * "orcarouter//" id strips to "/", while the + * auto-router "orcarouter/auto" keeps its prefix (OrcaRouter rejects the bare + * "auto" id). Any other prefix, or a string with no "/", passes through. */ export function normalizeDirectModelRef(modelRef: string): string { const trimmed = modelRef.trim(); const idx = trimmed.indexOf("/"); if (idx <= 0) return trimmed; const provider = trimmed.slice(0, idx).trim().toLowerCase(); - if (provider !== "openrouter") return trimmed; - const rest = trimmed.slice(idx + 1).trim(); - return rest || trimmed; + if (provider === "openrouter") { + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; + } + if (provider === "orcarouter") { + const rest = trimmed.slice(idx + 1).trim(); + // OrcaRouter requires a namespaced model id. A remainder that still + // carries a "/" prefix (e.g. orcarouter/anthropic/claude-...) + // can drop the gateway prefix; a bare remainder (e.g. "auto") must keep + // "orcarouter/" or OrcaRouter returns 503 model_not_found. + return rest.includes("/") ? rest : trimmed; + } + return trimmed; } export interface LlmClientConfig { diff --git a/test/admission-lane-model-affinity.test.mjs b/test/admission-lane-model-affinity.test.mjs index 2874c11e3..d26518910 100644 --- a/test/admission-lane-model-affinity.test.mjs +++ b/test/admission-lane-model-affinity.test.mjs @@ -215,6 +215,44 @@ describe("admission lane model affinity", () => { ); }); + it("normalizes a namespaced orcarouter// reflection model and keeps orcarouter/auto for the direct client", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: baseConfig(workspaceDir, { + admissionControl: { enabled: true, modelAffinity: "lane" }, + memoryReflection: { model: "orcarouter/anthropic/claude-sonnet-4.6" }, + }), + }); + + memoryLanceDBProPlugin.register(harness.api); + + assert.ok( + requestedModels.includes("anthropic/claude-sonnet-4.6"), + "orcarouter// must strip to the bare / id a direct OrcaRouter call accepts", + ); + assert.ok( + !requestedModels.includes("orcarouter/anthropic/claude-sonnet-4.6"), + "the raw core-style orcarouter ref must never reach a direct client", + ); + }); + + it("keeps the orcarouter/auto router model prefixed for the direct client", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: baseConfig(workspaceDir, { + admissionControl: { enabled: true, modelAffinity: "lane" }, + memoryReflection: { model: "orcarouter/auto" }, + }), + }); + + memoryLanceDBProPlugin.register(harness.api); + + assert.ok( + requestedModels.includes("orcarouter/auto"), + "OrcaRouter rejects the bare auto id, so orcarouter/auto must reach the direct client intact", + ); + }); + it("lets an explicit admissionControl.model override beat lane affinity on every admission lane", () => { const harness = createPluginApiHarness({ resolveRoot: workspaceDir, diff --git a/test/admission-model-resolution.test.mjs b/test/admission-model-resolution.test.mjs index b23ee57f2..2500020f1 100644 --- a/test/admission-model-resolution.test.mjs +++ b/test/admission-model-resolution.test.mjs @@ -101,6 +101,56 @@ describe("resolveAdmissionModel", () => { assert.equal(reflection, "anthropic/claude-opus-4-8"); }); + it("normalizes a core-style orcarouter// reflection model to the bare / form the OrcaRouter-direct client needs", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); + + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "orcarouter/anthropic/claude-sonnet-4.6", + }); + + assert.equal(reflection, "anthropic/claude-sonnet-4.6"); + }); + + it("keeps the orcarouter/auto router model prefixed (OrcaRouter rejects the bare auto id)", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); + + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "orcarouter/auto", + }); + + assert.equal(reflection, "orcarouter/auto"); + }); + + it("normalizes an explicit orcarouter admissionControl.model override the same way as lane-resolved models", () => { + const admissionControl = normalizeAdmissionControlConfig({ + enabled: true, + modelAffinity: "lane", + model: "orcarouter/anthropic/claude-sonnet-4.6", + }); + + const other = resolveAdmissionModel({ + admissionControl, + lane: "other", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + + assert.equal(other, "anthropic/claude-sonnet-4.6"); + assert.equal(reflection, "anthropic/claude-sonnet-4.6"); + }); + it("passes a bare / reflection model through unchanged", () => { const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); diff --git a/test/infer-provider-from-baseurl.test.mjs b/test/infer-provider-from-baseurl.test.mjs index 72aec3c37..1fe9ad79c 100644 --- a/test/infer-provider-from-baseurl.test.mjs +++ b/test/infer-provider-from-baseurl.test.mjs @@ -33,6 +33,11 @@ describe("inferProviderFromBaseURL - PR #713 regression", () => { const result = inferProviderFromBaseURL("https://api.anthropic.com"); assert.strictEqual(result, "anthropic"); }); + + it("baseURL with orcarouter.ai returns orcarouter", () => { + const result = inferProviderFromBaseURL("https://api.orcarouter.ai/v1"); + assert.strictEqual(result, "orcarouter"); + }); }); describe("edge cases", () => { diff --git a/test/llm-api-key-client.test.mjs b/test/llm-api-key-client.test.mjs index 86e16967c..47a4331ef 100644 --- a/test/llm-api-key-client.test.mjs +++ b/test/llm-api-key-client.test.mjs @@ -4,7 +4,29 @@ import { afterEach, describe, it } from "node:test"; import jitiFactory from "jiti"; const jiti = jitiFactory(import.meta.url, { interopDefault: true }); -const { createLlmClient, shouldDisableReasoningForJson, stripReasoningTrace } = jiti("../src/llm-client.ts"); +const { createLlmClient, normalizeDirectModelRef, shouldDisableReasoningForJson, stripReasoningTrace } = jiti("../src/llm-client.ts"); + +describe("normalizeDirectModelRef", () => { + it("strips a core-style openrouter// ref to the bare / form", () => { + assert.equal(normalizeDirectModelRef("openrouter/anthropic/claude-opus-4-8"), "anthropic/claude-opus-4-8"); + }); + + it("strips a namespaced orcarouter// ref to the bare / form", () => { + assert.equal(normalizeDirectModelRef("orcarouter/anthropic/claude-sonnet-4.6"), "anthropic/claude-sonnet-4.6"); + }); + + it("keeps orcarouter/auto prefixed (OrcaRouter rejects the bare auto id)", () => { + assert.equal(normalizeDirectModelRef("orcarouter/auto"), "orcarouter/auto"); + }); + + it("passes a bare / ref through unchanged", () => { + assert.equal(normalizeDirectModelRef("anthropic/claude-sonnet-4.6"), "anthropic/claude-sonnet-4.6"); + }); + + it("passes an unrelated provider prefix through unchanged", () => { + assert.equal(normalizeDirectModelRef("openai/gpt-4o"), "openai/gpt-4o"); + }); +}); describe("LLM api-key client", () => { let server;