Skip to content

Commit 6314c37

Browse files
authored
fix(openrouter): normalize provider-qualified model IDs (#92627)
Normalize provider-qualified OpenRouter model IDs before capability lookup and transport while preserving native OpenRouter namespace IDs. Fixes #92611. Co-authored-by: liuhao1024 <[email protected]>
1 parent d3e7e03 commit 6314c37

5 files changed

Lines changed: 266 additions & 14 deletions

File tree

extensions/openrouter/index.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ import {
1111
expectUnifiedModelCatalogProviderRegistration,
1212
} from "openclaw/plugin-sdk/provider-test-contracts";
1313
import { describe, expect, it, vi } from "vitest";
14+
15+
const { getOpenRouterModelCapabilitiesMock, loadOpenRouterModelCapabilitiesMock } = vi.hoisted(
16+
() => ({
17+
getOpenRouterModelCapabilitiesMock: vi.fn(),
18+
loadOpenRouterModelCapabilitiesMock: vi.fn(async () => {}),
19+
}),
20+
);
21+
22+
vi.mock("openclaw/plugin-sdk/provider-stream-family", async (importOriginal) => {
23+
const actual =
24+
await importOriginal<typeof import("openclaw/plugin-sdk/provider-stream-family")>();
25+
return {
26+
...actual,
27+
getOpenRouterModelCapabilities: getOpenRouterModelCapabilitiesMock,
28+
loadOpenRouterModelCapabilities: loadOpenRouterModelCapabilitiesMock,
29+
};
30+
});
31+
1432
import openrouterPlugin from "./index.js";
1533
import {
1634
buildOpenrouterProvider,
@@ -204,6 +222,59 @@ describe("openrouter provider hooks", () => {
204222
expect(buildOpenrouterProvider().models?.map((model) => model.id)).not.toContain("auto");
205223
});
206224

225+
it("normalizes OpenRouter API ids before capability loading and lookup", async () => {
226+
getOpenRouterModelCapabilitiesMock.mockReset();
227+
loadOpenRouterModelCapabilitiesMock.mockClear();
228+
getOpenRouterModelCapabilitiesMock.mockReturnValue({
229+
name: "Claude Sonnet 4.6",
230+
reasoning: true,
231+
input: ["text", "image"],
232+
supportsTools: true,
233+
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
234+
contextWindow: 200_000,
235+
maxTokens: 64_000,
236+
});
237+
const provider = await registerSingleProviderPlugin(openrouterPlugin);
238+
const modelId = "openrouter/anthropic/claude-sonnet-4.6";
239+
const context = {
240+
provider: "openrouter",
241+
modelId,
242+
modelRegistry: { find: vi.fn(() => null) },
243+
} as never;
244+
245+
await provider.prepareDynamicModel?.(context);
246+
const model = provider.resolveDynamicModel?.(context);
247+
248+
expect(loadOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("anthropic/claude-sonnet-4.6");
249+
expect(getOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("anthropic/claude-sonnet-4.6");
250+
expect(model).toMatchObject({
251+
id: modelId,
252+
name: "Claude Sonnet 4.6",
253+
reasoning: true,
254+
input: ["text", "image"],
255+
compat: { supportsTools: true },
256+
contextWindow: 200_000,
257+
maxTokens: 64_000,
258+
});
259+
});
260+
261+
it("keeps native OpenRouter namespace ids for capability lookup", async () => {
262+
getOpenRouterModelCapabilitiesMock.mockReset();
263+
loadOpenRouterModelCapabilitiesMock.mockClear();
264+
const provider = await registerSingleProviderPlugin(openrouterPlugin);
265+
const context = {
266+
provider: "openrouter",
267+
modelId: "openrouter/auto",
268+
modelRegistry: { find: vi.fn(() => null) },
269+
} as never;
270+
271+
await provider.prepareDynamicModel?.(context);
272+
provider.resolveDynamicModel?.(context);
273+
274+
expect(loadOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("openrouter/auto");
275+
expect(getOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("openrouter/auto");
276+
});
277+
207278
it("does not include retired stealth models in the bundled catalog", () => {
208279
const modelIds = buildOpenrouterProvider().models?.map((model) => model.id) ?? [];
209280
expect(modelIds).not.toContain("openrouter/hunter-alpha");
@@ -389,6 +460,61 @@ describe("openrouter provider hooks", () => {
389460
},
390461
} as never);
391462
expect(normalizedHunterModel?.reasoning).toBe(false);
463+
expect(normalizedHunterModel?.id).toBe("openrouter/hunter-alpha");
464+
465+
const normalizedAnthropicModel = provider.normalizeResolvedModel?.({
466+
provider: "openrouter",
467+
model: {
468+
provider: "openrouter",
469+
id: "openrouter/anthropic/claude-sonnet-4.6",
470+
name: "anthropic/claude-sonnet-4.6",
471+
api: "openai-completions",
472+
baseUrl: "https://openrouter.ai/api/v1",
473+
reasoning: true,
474+
input: ["text", "image"],
475+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
476+
contextWindow: 200_000,
477+
maxTokens: 8192,
478+
},
479+
} as never);
480+
expect(normalizedAnthropicModel?.id).toBe("anthropic/claude-sonnet-4.6");
481+
482+
expect(
483+
provider.normalizeResolvedModel?.({
484+
provider: "openrouter",
485+
modelId: "openrouter/auto",
486+
model: {
487+
provider: "openrouter",
488+
id: "openrouter/auto",
489+
name: "OpenRouter Auto",
490+
api: "openai-completions",
491+
baseUrl: "https://openrouter.ai/api/v1",
492+
reasoning: false,
493+
input: ["text", "image"],
494+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
495+
contextWindow: 200_000,
496+
maxTokens: 8192,
497+
},
498+
} as never),
499+
).toBeUndefined();
500+
501+
const normalizedDuplicatedAutoModel = provider.normalizeResolvedModel?.({
502+
provider: "openrouter",
503+
modelId: "openrouter/openrouter/auto",
504+
model: {
505+
provider: "openrouter",
506+
id: "openrouter/openrouter/auto",
507+
name: "OpenRouter Auto",
508+
api: "openai-completions",
509+
baseUrl: "https://openrouter.ai/api/v1",
510+
reasoning: false,
511+
input: ["text", "image"],
512+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
513+
contextWindow: 200_000,
514+
maxTokens: 8192,
515+
},
516+
} as never);
517+
expect(normalizedDuplicatedAutoModel?.id).toBe("openrouter/auto");
392518

393519
expect(
394520
provider.normalizeTransport?.({

extensions/openrouter/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from "openclaw/plugin-sdk/provider-stream-family";
1818
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
1919
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
20-
import { isOpenRouterMistralModelId } from "./models.js";
20+
import { isOpenRouterMistralModelId, normalizeOpenRouterApiModelId } from "./models.js";
2121
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
2222
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
2323
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
@@ -51,15 +51,18 @@ const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [
5151

5252
function normalizeOpenRouterResolvedModel<T extends ProviderRuntimeModel>(model: T): T | undefined {
5353
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(model.baseUrl);
54+
const normalizedId = normalizeOpenRouterApiModelId(model.id);
5455
const reasoning = isOpenRouterProxyReasoningUnsupportedModel(model.id) ? false : model.reasoning;
5556
if (
5657
(!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) &&
58+
(!normalizedId || normalizedId === model.id) &&
5759
reasoning === model.reasoning
5860
) {
5961
return undefined;
6062
}
6163
return {
6264
...model,
65+
...(normalizedId ? { id: normalizedId } : {}),
6366
...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}),
6467
reasoning,
6568
};
@@ -73,7 +76,8 @@ export default definePluginEntry({
7376
function buildDynamicOpenRouterModel(
7477
ctx: ProviderResolveDynamicModelContext,
7578
): ProviderRuntimeModel {
76-
const capabilities = getOpenRouterModelCapabilities(ctx.modelId);
79+
const apiModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
80+
const capabilities = getOpenRouterModelCapabilities(apiModelId);
7781
return {
7882
id: ctx.modelId,
7983
name: capabilities?.name ?? ctx.modelId,
@@ -166,7 +170,9 @@ export default definePluginEntry({
166170
},
167171
resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx),
168172
prepareDynamicModel: async (ctx) => {
169-
await loadOpenRouterModelCapabilities(ctx.modelId);
173+
await loadOpenRouterModelCapabilities(
174+
normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId,
175+
);
170176
},
171177
normalizeConfig: ({ providerConfig }) => {
172178
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl);

extensions/openrouter/models.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,30 @@ const OPENROUTER_MISTRAL_MODEL_PREFIXES = [
1212
"pixtral-",
1313
"voxtral-",
1414
] as const;
15+
const OPENROUTER_MODEL_PREFIX = "openrouter/";
1516

1617
export function normalizeOpenRouterModelId(modelId: unknown): string | undefined {
1718
if (typeof modelId !== "string") {
1819
return undefined;
1920
}
2021
const normalized = normalizeLowercaseStringOrEmpty(modelId);
21-
return normalized.startsWith("openrouter/") ? normalized.slice("openrouter/".length) : normalized;
22+
return normalized.startsWith(OPENROUTER_MODEL_PREFIX)
23+
? normalized.slice(OPENROUTER_MODEL_PREFIX.length)
24+
: normalized;
25+
}
26+
27+
export function normalizeOpenRouterApiModelId(modelId: unknown): string | undefined {
28+
if (typeof modelId !== "string") {
29+
return undefined;
30+
}
31+
const normalized = normalizeLowercaseStringOrEmpty(modelId);
32+
if (!normalized.startsWith(OPENROUTER_MODEL_PREFIX)) {
33+
return normalized;
34+
}
35+
const unprefixed = normalized.slice(OPENROUTER_MODEL_PREFIX.length);
36+
// `openrouter/` is both a provider qualifier and an upstream namespace.
37+
// Strip it only when the remainder is still a namespaced API model id.
38+
return unprefixed.includes("/") ? unprefixed : normalized;
2239
}
2340

2441
export function isOpenRouterMistralModelId(modelId: unknown): boolean {

extensions/openrouter/openrouter.live.test.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import {
77
} from "openclaw/plugin-sdk/plugin-test-runtime";
88
import { describe, expect, it } from "vitest";
99
import plugin from "./index.js";
10+
import { normalizeOpenRouterApiModelId } from "./models.js";
1011

1112
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
1213
const OPENROUTER_MISTRAL_PROVIDER_PREFIX = "mistralai/";
1314
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? "";
14-
const LIVE_MODEL_ID =
15-
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano";
15+
const LIVE_MODEL_REF =
16+
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() ||
17+
"openrouter/anthropic/claude-sonnet-4.6";
18+
const LIVE_MODEL_ID = LIVE_MODEL_REF.startsWith("openrouter/")
19+
? LIVE_MODEL_REF
20+
: `openrouter/${LIVE_MODEL_REF}`;
1621
const LIVE_CACHE_MODEL_ID =
1722
process.env.OPENCLAW_LIVE_OPENROUTER_CACHE_MODEL?.trim() || "deepseek/deepseek-v3.2";
1823
const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
@@ -57,6 +62,40 @@ async function completeOpenRouterChat(params: {
5762
});
5863
}
5964

65+
async function expectWeatherToolCall(client: OpenAI, model: string): Promise<void> {
66+
const response = await client.chat.completions.create({
67+
model,
68+
messages: [{ role: "user", content: "Call get_weather for Paris." }],
69+
tools: [
70+
{
71+
type: "function",
72+
function: {
73+
name: "get_weather",
74+
description: "Get the weather for a city.",
75+
parameters: {
76+
type: "object",
77+
properties: { city: { type: "string" } },
78+
required: ["city"],
79+
additionalProperties: false,
80+
},
81+
},
82+
},
83+
],
84+
tool_choice: {
85+
type: "function",
86+
function: { name: "get_weather" },
87+
},
88+
max_tokens: 64,
89+
});
90+
91+
const toolCall = response.choices[0]?.message?.tool_calls?.find(
92+
(call) => call.type === "function",
93+
);
94+
expect(toolCall?.type).toBe("function");
95+
expect(toolCall?.function.name).toBe("get_weather");
96+
expect(JSON.parse(toolCall?.function.arguments ?? "{}")).toMatchObject({ city: "Paris" });
97+
}
98+
6099
async function fetchOpenRouterModelIds(): Promise<string[]> {
61100
const response = await fetch(OPENROUTER_MODELS_URL, {
62101
headers: { "accept-encoding": "identity" },
@@ -69,7 +108,7 @@ async function fetchOpenRouterModelIds(): Promise<string[]> {
69108
}
70109

71110
describeLive("openrouter plugin live", () => {
72-
it("registers an OpenRouter provider that can complete a live request", async () => {
111+
it("normalizes a prefixed OpenRouter model and completes a live tool call", async () => {
73112
const { providers } = await registerOpenRouterPlugin();
74113
const provider = requireRegisteredProvider(providers, "openrouter");
75114

@@ -87,17 +126,35 @@ describeLive("openrouter plugin live", () => {
87126
expect(resolved.api).toBe("openai-completions");
88127
expect(resolved.baseUrl).toBe("https://openrouter.ai/api/v1");
89128

129+
const normalized =
130+
provider.normalizeResolvedModel?.({
131+
provider: "openrouter",
132+
modelId: resolved.id,
133+
model: resolved,
134+
}) ?? resolved;
135+
expect(normalized.id).toBe(normalizeOpenRouterApiModelId(LIVE_MODEL_ID));
136+
90137
const client = new OpenAI({
91138
apiKey: OPENROUTER_API_KEY,
92-
baseURL: resolved.baseUrl,
139+
baseURL: normalized.baseUrl,
93140
});
94-
const response = await client.chat.completions.create({
95-
model: resolved.id,
96-
messages: [{ role: "user", content: "Reply with exactly OK." }],
97-
max_tokens: 16,
141+
const autoResolved = provider.resolveDynamicModel?.({
142+
provider: "openrouter",
143+
modelId: "openrouter/auto",
144+
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
98145
});
99-
100-
expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
146+
if (!autoResolved) {
147+
throw new Error("openrouter provider did not resolve openrouter/auto");
148+
}
149+
const autoModel =
150+
provider.normalizeResolvedModel?.({
151+
provider: "openrouter",
152+
modelId: autoResolved.id,
153+
model: autoResolved,
154+
}) ?? autoResolved;
155+
expect(autoModel.id).toBe("openrouter/auto");
156+
await expectWeatherToolCall(client, autoModel.id);
157+
await expectWeatherToolCall(client, normalized.id);
101158
}, 30_000);
102159
});
103160

src/agents/embedded-agent-runner/model.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3016,6 +3016,52 @@ describe("resolveModel", () => {
30163016
});
30173017
});
30183018

3019+
it("uses provider-normalized model ids for OpenRouter transport", () => {
3020+
const modelId = "openrouter/anthropic/claude-sonnet-4.6";
3021+
mockDiscoveredModel(discoverModels, {
3022+
provider: "openrouter",
3023+
modelId,
3024+
templateModel: {
3025+
...makeModel(modelId),
3026+
provider: "openrouter",
3027+
api: "openai-completions",
3028+
baseUrl: "https://openrouter.ai/api/v1",
3029+
},
3030+
});
3031+
const baseRuntimeHooks = createRuntimeHooks();
3032+
const normalizeProviderResolvedModelWithPlugin = vi.fn(
3033+
(params: { context: { model: { id: string } } }) => ({
3034+
...params.context.model,
3035+
id: params.context.model.id.slice("openrouter/".length),
3036+
}),
3037+
);
3038+
3039+
const result = resolveModel("openrouter", modelId, "/tmp/agent", undefined, {
3040+
authStorage: { mocked: true } as never,
3041+
modelRegistry: discoverModels({ mocked: true } as never, "/tmp/agent"),
3042+
runtimeHooks: {
3043+
...baseRuntimeHooks,
3044+
normalizeProviderResolvedModelWithPlugin,
3045+
},
3046+
});
3047+
3048+
expect(normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledWith(
3049+
expect.objectContaining({
3050+
provider: "openrouter",
3051+
context: expect.objectContaining({
3052+
modelId,
3053+
model: expect.objectContaining({ id: modelId }),
3054+
}),
3055+
}),
3056+
);
3057+
expectRecordFields(result.model, {
3058+
provider: "openrouter",
3059+
id: "anthropic/claude-sonnet-4.6",
3060+
api: "openai-completions",
3061+
baseUrl: "https://openrouter.ai/api/v1",
3062+
});
3063+
});
3064+
30193065
it("matches prefixed Hugging Face ids against discovered registry models", () => {
30203066
mockDiscoveredModel(discoverModels, {
30213067
provider: "huggingface",

0 commit comments

Comments
 (0)