mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
fix(openrouter): honor model tool support metadata
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user