Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
```

</details>

<details>
Expand Down
2 changes: 2 additions & 0 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 18 additions & 4 deletions dist/src/admission-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -480,17 +480,31 @@ function parseBatchUtilityResponse(response, expectedCount) {
* Strip that literal "openrouter/" prefix so both forms reach this plugin's
* direct client correctly; a bare "<vendor>/<model>" or an "@preset/<name>"
* alias already work against OpenRouter unchanged, so they pass through.
*
* The same mirror applies to the "orcarouter/" gateway prefix: a namespaced
* "orcarouter/<vendor>/<model>" id strips to "<vendor>/<model>", 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();
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 "<vendor>/" 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:
Expand Down
22 changes: 17 additions & 5 deletions dist/src/llm-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,30 @@ import { buildOauthEndpoint, extractOutputTextFromSse, loadOAuthSession, needsRe
/**
* Strips a core-style provider prefix (e.g. "openrouter/anthropic/claude-...")
* down to the bare "<vendor>/<model>" 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/<vendor>/<model>" id strips to "<vendor>/<model>", 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();
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 "<vendor>/" 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.";
/**
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 18 additions & 3 deletions src/admission-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<vendor>/<model>" or an "@preset/<name>"
* alias already work against OpenRouter unchanged, so they pass through.
*
* The same mirror applies to the "orcarouter/" gateway prefix: a namespaced
* "orcarouter/<vendor>/<model>" id strips to "<vendor>/<model>", 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 "<vendor>/" 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;
}

/**
Expand Down
21 changes: 17 additions & 4 deletions src/llm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,29 @@ import {
/**
* Strips a core-style provider prefix (e.g. "openrouter/anthropic/claude-...")
* down to the bare "<vendor>/<model>" 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/<vendor>/<model>" id strips to "<vendor>/<model>", 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 "<vendor>/" 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 {
Expand Down
38 changes: 38 additions & 0 deletions test/admission-lane-model-affinity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,44 @@ describe("admission lane model affinity", () => {
);
});

it("normalizes a namespaced orcarouter/<vendor>/<model> 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/<vendor>/<model> must strip to the bare <vendor>/<model> 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,
Expand Down
50 changes: 50 additions & 0 deletions test/admission-model-resolution.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,56 @@ describe("resolveAdmissionModel", () => {
assert.equal(reflection, "anthropic/claude-opus-4-8");
});

it("normalizes a core-style orcarouter/<vendor>/<model> reflection model to the bare <vendor>/<model> 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 <vendor>/<model> reflection model through unchanged", () => {
const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" });

Expand Down
5 changes: 5 additions & 0 deletions test/infer-provider-from-baseurl.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
24 changes: 23 additions & 1 deletion test/llm-api-key-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<vendor>/<model> ref to the bare <vendor>/<model> form", () => {
assert.equal(normalizeDirectModelRef("openrouter/anthropic/claude-opus-4-8"), "anthropic/claude-opus-4-8");
});

it("strips a namespaced orcarouter/<vendor>/<model> ref to the bare <vendor>/<model> 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 <vendor>/<model> 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;
Expand Down