Skip to content

Commit 0e4be84

Browse files
committed
fix(openrouter): normalize capability lookup ids
1 parent e2b4000 commit 0e4be84

3 files changed

Lines changed: 112 additions & 40 deletions

File tree

extensions/openrouter/index.test.ts

Lines changed: 71 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");

extensions/openrouter/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export default definePluginEntry({
7676
function buildDynamicOpenRouterModel(
7777
ctx: ProviderResolveDynamicModelContext,
7878
): ProviderRuntimeModel {
79-
const capabilities = getOpenRouterModelCapabilities(ctx.modelId);
79+
const apiModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
80+
const capabilities = getOpenRouterModelCapabilities(apiModelId);
8081
return {
8182
id: ctx.modelId,
8283
name: capabilities?.name ?? ctx.modelId,
@@ -169,7 +170,9 @@ export default definePluginEntry({
169170
},
170171
resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx),
171172
prepareDynamicModel: async (ctx) => {
172-
await loadOpenRouterModelCapabilities(ctx.modelId);
173+
await loadOpenRouterModelCapabilities(
174+
normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId,
175+
);
173176
},
174177
normalizeConfig: ({ providerConfig }) => {
175178
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl);

extensions/openrouter/openrouter.live.test.ts

Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,40 @@ async function completeOpenRouterChat(params: {
6262
});
6363
}
6464

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+
6599
async function fetchOpenRouterModelIds(): Promise<string[]> {
66100
const response = await fetch(OPENROUTER_MODELS_URL, {
67101
headers: { "accept-encoding": "identity" },
@@ -119,44 +153,8 @@ describeLive("openrouter plugin live", () => {
119153
model: autoResolved,
120154
}) ?? autoResolved;
121155
expect(autoModel.id).toBe("openrouter/auto");
122-
const autoResponse = await client.chat.completions.create({
123-
model: autoModel.id,
124-
messages: [{ role: "user", content: "Reply with exactly OK." }],
125-
max_tokens: 16,
126-
});
127-
expect(autoResponse.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
128-
129-
const response = await client.chat.completions.create({
130-
model: normalized.id,
131-
messages: [{ role: "user", content: "Call get_weather for Paris." }],
132-
tools: [
133-
{
134-
type: "function",
135-
function: {
136-
name: "get_weather",
137-
description: "Get the weather for a city.",
138-
parameters: {
139-
type: "object",
140-
properties: { city: { type: "string" } },
141-
required: ["city"],
142-
additionalProperties: false,
143-
},
144-
},
145-
},
146-
],
147-
tool_choice: {
148-
type: "function",
149-
function: { name: "get_weather" },
150-
},
151-
max_tokens: 64,
152-
});
153-
154-
const toolCall = response.choices[0]?.message?.tool_calls?.find(
155-
(call) => call.type === "function",
156-
);
157-
expect(toolCall?.type).toBe("function");
158-
expect(toolCall?.function.name).toBe("get_weather");
159-
expect(JSON.parse(toolCall?.function.arguments ?? "{}")).toMatchObject({ city: "Paris" });
156+
await expectWeatherToolCall(client, autoModel.id);
157+
await expectWeatherToolCall(client, normalized.id);
160158
}, 30_000);
161159
});
162160

0 commit comments

Comments
 (0)