From 07df423557445db078735aa5f179df53764c885f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 10 May 2026 06:54:53 +0100 Subject: [PATCH] fix(openrouter): honor model tool support metadata --- CHANGELOG.md | 1 + extensions/openrouter/index.ts | 3 ++ .../model.provider-runtime.test-support.ts | 3 ++ src/agents/pi-embedded-runner/model.test.ts | 4 +- .../openrouter-model-capabilities.test.ts | 45 ++++++++++++++++++- .../openrouter-model-capabilities.ts | 16 ++++++- src/plugins/provider-runtime-model.types.ts | 4 +- 7 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1148510bba4..10b7d100f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - Models/OpenRouter: treat `403 API key budget limit exceeded` as billing so model fallback advances instead of retrying the exhausted primary. Fixes #60191. Thanks @omgitsgela. - Models/OpenRouter: repair stale session overrides that lost the outer `openrouter/` provider wrapper, so sessions return to the configured OpenRouter model instead of failing as an unknown direct-provider model. Fixes #78161. Thanks @hjamal7-bit. - Telegram: show full provider/model labels for nested OpenRouter model ids in the model picker, so `openrouter/openai/gpt-5.4-mini` no longer displays as `openai/gpt-5.4-mini`. Fixes #67792. (#72752) Thanks @iot2edge. +- Models/OpenRouter: preserve live `supported_parameters` tool support metadata so non-tool Perplexity Sonar models no longer receive agent tool payloads and fall back unnecessarily. Fixes #64175. Thanks @Catfish-75. - Kimi Code: use Kimi's stable `kimi-for-coding` API model id in bundled catalog, onboarding, and docs while normalizing legacy `kimi-code` and `k2p5` refs. Fixes #79965. - Volcengine/Kimi: strip provider-unsupported tool schema length and item constraint keywords for direct and coding-plan models so hosted Kimi runs do not reject message tools with `minLength`. Fixes #38817. - DeepSeek: backfill V4 `reasoning_content` replay fields for unowned OpenAI-compatible proxy providers, preventing follow-up request failures outside the bundled DeepSeek and OpenRouter routes. Fixes #79608. diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index e1884f96ecf..626edef4319 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -77,6 +77,9 @@ export default definePluginEntry({ (capabilities?.reasoning ?? false) && !isOpenRouterProxyReasoningUnsupportedModel(ctx.modelId), input: capabilities?.input ?? ["text"], + ...(capabilities?.supportsTools !== undefined + ? { compat: { supportsTools: capabilities.supportsTools } } + : {}), cost: capabilities?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, maxTokens: capabilities?.maxTokens ?? OPENROUTER_DEFAULT_MAX_TOKENS, diff --git a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts index 6743a55f211..d694a4e921c 100644 --- a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts +++ b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts @@ -188,6 +188,9 @@ function buildDynamicModel( baseUrl: OPENROUTER_BASE_URL, reasoning: capabilities?.reasoning ?? false, input: capabilities?.input ?? (["text"] as const), + ...(capabilities?.supportsTools !== undefined + ? { compat: { supportsTools: capabilities.supportsTools } } + : {}), cost: capabilities?.cost ?? OPENROUTER_FALLBACK_COST, contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_WINDOW, maxTokens: capabilities?.maxTokens ?? DEFAULT_MAX_TOKENS, diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index ed8c700be9a..cf6ca93e9f2 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1315,6 +1315,7 @@ describe("resolveModel", () => { name: "Healer Alpha", input: ["text", "image"], reasoning: true, + supportsTools: false, contextWindow: 262144, maxTokens: 65536, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -1323,7 +1324,7 @@ describe("resolveModel", () => { const result = resolveModelForTest("openrouter", "openrouter/healer-alpha", "/tmp/agent"); expect(result.error).toBeUndefined(); - expectRecordFields(result.model, { + const resolvedModel = expectRecordFields(result.model, { provider: "openrouter", id: "openrouter/healer-alpha", name: "Healer Alpha", @@ -1332,6 +1333,7 @@ describe("resolveModel", () => { contextWindow: 262144, maxTokens: 65536, }); + expect(resolvedModel.compat).toMatchObject({ supportsTools: false }); }); it("falls back to text-only when OpenRouter API cache is empty", () => { diff --git a/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts b/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts index 40d790193f0..5adb74a3aa9 100644 --- a/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts +++ b/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts @@ -50,7 +50,7 @@ describe("openrouter-model-capabilities", () => { id: "acme/top-level-max-completion", name: "Top Level Max Completion", architecture: { modality: "text+image->text" }, - supported_parameters: ["reasoning"], + supported_parameters: ["reasoning", "tools"], context_length: 65432, max_completion_tokens: 12345, pricing: { prompt: "0.000001", completion: "0.000002" }, @@ -79,17 +79,60 @@ describe("openrouter-model-capabilities", () => { const maxCompletion = module.getOpenRouterModelCapabilities("acme/top-level-max-completion"); expect(maxCompletion?.input).toEqual(["text", "image"]); expect(maxCompletion?.reasoning).toBe(true); + expect(maxCompletion?.supportsTools).toBe(true); expect(maxCompletion?.contextWindow).toBe(65432); expect(maxCompletion?.maxTokens).toBe(12345); const maxOutput = module.getOpenRouterModelCapabilities("acme/top-level-max-output"); expect(maxOutput?.input).toEqual(["text", "image"]); expect(maxOutput?.reasoning).toBe(false); + expect(maxOutput?.supportsTools).toBeUndefined(); expect(maxOutput?.contextWindow).toBe(54321); expect(maxOutput?.maxTokens).toBe(23456); }); }); + it("preserves explicit OpenRouter tool support metadata", async () => { + await withOpenRouterStateDir(async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "perplexity/sonar-deep-research", + name: "Sonar Deep Research", + supported_parameters: ["reasoning", "web_search_options"], + }, + { + id: "google/gemini-2.5-pro", + name: "Gemini 2.5 Pro", + supported_parameters: ["reasoning", "tools"], + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), + ); + + const module = await importOpenRouterModelCapabilities("tool-support"); + await module.loadOpenRouterModelCapabilities("perplexity/sonar-deep-research"); + + expect( + module.getOpenRouterModelCapabilities("perplexity/sonar-deep-research")?.supportsTools, + ).toBe(false); + expect(module.getOpenRouterModelCapabilities("google/gemini-2.5-pro")?.supportsTools).toBe( + true, + ); + }); + }); + it("does not refetch immediately after an awaited miss for the same model id", async () => { await withOpenRouterStateDir(async () => { const fetchSpy = vi.fn( diff --git a/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts b/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts index 9ed04fd9118..74c66323474 100644 --- a/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts +++ b/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts @@ -31,6 +31,7 @@ const log = createSubsystemLogger("openrouter-model-capabilities"); const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const FETCH_TIMEOUT_MS = 10_000; const DISK_CACHE_FILENAME = "openrouter-models.json"; +const DISK_CACHE_VERSION = 2; // --------------------------------------------------------------------------- // Types @@ -62,6 +63,7 @@ export interface OpenRouterModelCapabilities { name: string; input: Array<"text" | "image">; reasoning: boolean; + supportsTools?: boolean; contextWindow: number; maxTokens: number; cost: { @@ -73,6 +75,7 @@ export interface OpenRouterModelCapabilities { } interface DiskCachePayload { + version?: number; models: Record; } @@ -92,6 +95,7 @@ function writeDiskCache(map: Map): void { try { const cachePath = resolveDiskCachePath(); const payload: DiskCachePayload = { + version: DISK_CACHE_VERSION, models: Object.fromEntries(map), }; privateFileStoreSync(dirname(cachePath)).writeJson(basename(cachePath), payload); @@ -126,7 +130,11 @@ function readDiskCache(): Map | undefined { if (!payload || typeof payload !== "object") { return undefined; } - const models = (payload as DiskCachePayload).models; + const cachePayload = payload as DiskCachePayload; + if (cachePayload.version !== DISK_CACHE_VERSION) { + return undefined; + } + const models = cachePayload.models; if (!models || typeof models !== "object") { return undefined; } @@ -157,11 +165,15 @@ function parseModel(model: OpenRouterApiModel): OpenRouterModelCapabilities { if (inputModalities.includes("image")) { input.push("image"); } + const supportedParameters = Array.isArray(model.supported_parameters) + ? model.supported_parameters + : undefined; return { name: model.name || model.id, input, - reasoning: model.supported_parameters?.includes("reasoning") ?? false, + reasoning: supportedParameters?.includes("reasoning") ?? false, + ...(supportedParameters ? { supportsTools: supportedParameters.includes("tools") } : {}), contextWindow: model.context_length || 128_000, maxTokens: model.top_provider?.max_completion_tokens ?? diff --git a/src/plugins/provider-runtime-model.types.ts b/src/plugins/provider-runtime-model.types.ts index c961ad34591..d5364097d34 100644 --- a/src/plugins/provider-runtime-model.types.ts +++ b/src/plugins/provider-runtime-model.types.ts @@ -1,10 +1,12 @@ import type { Api, Model } from "@mariozechner/pi-ai"; +import type { ModelCompatConfig } from "../config/types.models.js"; /** * Fully-resolved runtime model shape used after provider/plugin-owned * discovery, overrides, and compat normalization. */ -export type ProviderRuntimeModel = Model & { +export type ProviderRuntimeModel = Omit, "compat"> & { + compat?: ModelCompatConfig; contextTokens?: number; params?: Record; requestTimeoutMs?: number;