fix(openrouter): honor model tool support metadata

This commit is contained in:
Peter Steinberger
2026-05-10 06:54:53 +01:00
parent a31b75f543
commit 07df423557
7 changed files with 71 additions and 5 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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(

View File

@@ -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<string, OpenRouterModelCapabilities>;
}
@@ -92,6 +95,7 @@ function writeDiskCache(map: Map<string, OpenRouterModelCapabilities>): 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<string, OpenRouterModelCapabilities> | 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 ??

View File

@@ -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<Api> & {
export type ProviderRuntimeModel = Omit<Model<Api>, "compat"> & {
compat?: ModelCompatConfig;
contextTokens?: number;
params?: Record<string, unknown>;
requestTimeoutMs?: number;