diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b49e0de7f6..724ef66eb3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex. - Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work. Thanks @codex. - Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers. Thanks @codex. - CLI/plugins: stop security-blocked plugin installs from retrying as hook packs, so normal plugin packages report the scanner failure without a misleading "not a valid hook pack" follow-up. Fixes #61175; supersedes #64102. Thanks @KonsultDigital and @ziyincody. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index afac720bd5e..b727ed1cb5a 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -144,6 +144,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`. | `providerDiscoveryEntry` | No | `string` | Lightweight provider-discovery module path, relative to the plugin root, for manifest-scoped provider catalog metadata that can be loaded without activating the full plugin runtime. | | `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. | | `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. | +| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. | | `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. | | `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | | `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. | @@ -744,6 +745,47 @@ Do not put runtime-only data in `modelCatalog`. If a provider needs account state, an API request, or local process discovery to know the complete model set, declare that provider as `refreshable` or `runtime` in `discovery`. +## modelPricing reference + +Use `modelPricing` when a provider needs control-plane pricing behavior before +runtime loads. The Gateway pricing cache reads this metadata without importing +provider runtime code. + +```json +{ + "providers": ["ollama", "openrouter"], + "modelPricing": { + "providers": { + "ollama": { + "external": false + }, + "openrouter": { + "openRouter": { + "passthroughProviderModel": true + }, + "liteLLM": false + } + } + } +} +``` + +Provider fields: + +| Field | Type | What it means | +| ------------ | ----------------- | -------------------------------------------------------------------------------------------------- | +| `external` | `boolean` | Set `false` for local/self-hosted providers that should never fetch OpenRouter or LiteLLM pricing. | +| `openRouter` | `false \| object` | OpenRouter pricing lookup mapping. `false` disables OpenRouter lookup for this provider. | +| `liteLLM` | `false \| object` | LiteLLM pricing lookup mapping. `false` disables LiteLLM lookup for this provider. | + +Source fields: + +| Field | Type | What it means | +| -------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------- | +| `provider` | `string` | External catalog provider id when it differs from the OpenClaw provider id, for example `z-ai` for a `zai` provider. | +| `passthroughProviderModel` | `boolean` | Treat slash-containing model ids as nested provider/model refs, useful for proxy providers such as OpenRouter. | +| `modelIdTransforms` | `"version-dots"[]` | Extra external catalog model-id variants. `version-dots` tries dotted version ids like `claude-opus-4.6`. | + ### OpenClaw Provider Index The OpenClaw Provider Index is OpenClaw-owned preview metadata for providers diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 4bf8a288b05..41c03a23d12 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -6,6 +6,15 @@ "modelSupport": { "modelPrefixes": ["claude-"] }, + "modelPricing": { + "providers": { + "anthropic": { + "openRouter": { + "modelIdTransforms": ["version-dots"] + } + } + } + }, "cliBackends": ["claude-cli"], "syntheticAuthRefs": ["claude-cli"], "providerAuthEnvVars": { diff --git a/extensions/cloudflare-ai-gateway/openclaw.plugin.json b/extensions/cloudflare-ai-gateway/openclaw.plugin.json index 0fa44404e56..8a19e86b6aa 100644 --- a/extensions/cloudflare-ai-gateway/openclaw.plugin.json +++ b/extensions/cloudflare-ai-gateway/openclaw.plugin.json @@ -2,6 +2,18 @@ "id": "cloudflare-ai-gateway", "enabledByDefault": true, "providers": ["cloudflare-ai-gateway"], + "modelPricing": { + "providers": { + "cloudflare-ai-gateway": { + "openRouter": { + "passthroughProviderModel": true + }, + "liteLLM": { + "passthroughProviderModel": true + } + } + } + }, "providerAuthEnvVars": { "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"] }, diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index a1641758254..36d0118b637 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -3,6 +3,18 @@ "enabledByDefault": true, "providers": ["google", "google-gemini-cli"], "autoEnableWhenConfiguredProviders": ["google-gemini-cli"], + "modelPricing": { + "providers": { + "google-gemini-cli": { + "openRouter": { + "provider": "google" + }, + "liteLLM": { + "provider": "google" + } + } + } + }, "cliBackends": ["google-gemini-cli"], "providerAuthEnvVars": { "google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"] diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json index ef3d29a20df..4b54a2ec4e9 100644 --- a/extensions/kilocode/openclaw.plugin.json +++ b/extensions/kilocode/openclaw.plugin.json @@ -2,6 +2,18 @@ "id": "kilocode", "enabledByDefault": true, "providers": ["kilocode"], + "modelPricing": { + "providers": { + "kilocode": { + "openRouter": { + "passthroughProviderModel": true + }, + "liteLLM": { + "passthroughProviderModel": true + } + } + } + }, "providerAuthEnvVars": { "kilocode": ["KILOCODE_API_KEY"] }, diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index d696c264275..9ab0ae42d20 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -2,6 +2,26 @@ "id": "kimi", "enabledByDefault": true, "providers": ["kimi", "kimi-coding"], + "modelPricing": { + "providers": { + "kimi": { + "openRouter": { + "provider": "moonshotai" + }, + "liteLLM": { + "provider": "moonshot" + } + }, + "kimi-coding": { + "openRouter": { + "provider": "moonshotai" + }, + "liteLLM": { + "provider": "moonshot" + } + } + } + }, "providerAuthEnvVars": { "kimi": ["KIMI_API_KEY", "KIMICODE_API_KEY"], "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json index bfb896f30dc..bb6cc94753b 100644 --- a/extensions/lmstudio/openclaw.plugin.json +++ b/extensions/lmstudio/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "lmstudio", "enabledByDefault": true, "providers": ["lmstudio"], + "modelPricing": { + "providers": { + "lmstudio": { + "external": false + } + } + }, "nonSecretAuthMarkers": ["lmstudio-local"], "providerAuthEnvVars": { "lmstudio": ["LM_API_TOKEN"] diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index 40a17c6eff0..255084c9ccf 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -3,6 +3,18 @@ "enabledByDefault": true, "providerDiscoveryEntry": "./provider-discovery.ts", "providers": ["moonshot"], + "modelPricing": { + "providers": { + "moonshot": { + "openRouter": { + "provider": "moonshotai" + }, + "liteLLM": { + "provider": "moonshot" + } + } + } + }, "modelCatalog": { "providers": { "moonshot": { diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index f2f3d86390c..0b56c6a82b5 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -3,6 +3,13 @@ "enabledByDefault": true, "providers": ["ollama"], "providerDiscoveryEntry": "./provider-discovery.ts", + "modelPricing": { + "providers": { + "ollama": { + "external": false + } + } + }, "syntheticAuthRefs": ["ollama"], "nonSecretAuthMarkers": ["ollama-local"], "providerAuthEnvVars": { diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 5ca93949ae4..01445b3ee44 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -5,6 +5,18 @@ "modelSupport": { "modelPrefixes": ["gpt-", "o1", "o3", "o4"] }, + "modelPricing": { + "providers": { + "openai-codex": { + "openRouter": { + "provider": "openai" + }, + "liteLLM": { + "provider": "openai" + } + } + } + }, "cliBackends": ["codex-cli"], "providerAuthEnvVars": { "openai": ["OPENAI_API_KEY"] diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index b3651a177e7..db4e0bde383 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -2,6 +2,16 @@ "id": "openrouter", "enabledByDefault": true, "providers": ["openrouter"], + "modelPricing": { + "providers": { + "openrouter": { + "openRouter": { + "passthroughProviderModel": true + }, + "liteLLM": false + } + } + }, "providerAuthEnvVars": { "openrouter": ["OPENROUTER_API_KEY"] }, diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index 39d25f66cfa..eba2571dc95 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "sglang", "enabledByDefault": true, "providers": ["sglang"], + "modelPricing": { + "providers": { + "sglang": { + "external": false + } + } + }, "providerAuthEnvVars": { "sglang": ["SGLANG_API_KEY"] }, diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 865c7d9765c..b7135d13e2c 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -2,6 +2,18 @@ "id": "vercel-ai-gateway", "enabledByDefault": true, "providers": ["vercel-ai-gateway"], + "modelPricing": { + "providers": { + "vercel-ai-gateway": { + "openRouter": { + "passthroughProviderModel": true + }, + "liteLLM": { + "passthroughProviderModel": true + } + } + } + }, "providerAuthEnvVars": { "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"] }, diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index cf9ebdb5087..30b8094b10d 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "vllm", "enabledByDefault": true, "providers": ["vllm"], + "modelPricing": { + "providers": { + "vllm": { + "external": false + } + } + }, "providerAuthEnvVars": { "vllm": ["VLLM_API_KEY"] }, diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index 5a8a097dd70..3cf56ebab51 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -2,6 +2,18 @@ "id": "zai", "enabledByDefault": true, "providers": ["zai"], + "modelPricing": { + "providers": { + "zai": { + "openRouter": { + "provider": "z-ai" + }, + "liteLLM": { + "provider": "zai" + } + } + } + }, "providerAuthEnvVars": { "zai": ["ZAI_API_KEY", "Z_AI_API_KEY"] }, diff --git a/src/gateway/model-pricing-cache-state.ts b/src/gateway/model-pricing-cache-state.ts index 521f8a61081..af00d3c38d4 100644 --- a/src/gateway/model-pricing-cache-state.ts +++ b/src/gateway/model-pricing-cache-state.ts @@ -23,13 +23,6 @@ export type CachedModelPricing = { let cachedPricing = new Map(); let cachedAt = 0; -const WRAPPER_PROVIDERS = new Set([ - "cloudflare-ai-gateway", - "kilocode", - "openrouter", - "vercel-ai-gateway", -]); - function modelPricingCacheKey(provider: string, model: string): string { const providerId = normalizeProviderId(provider); const modelId = model.trim(); @@ -43,16 +36,6 @@ function modelPricingCacheKey(provider: string, model: string): string { : `${providerId}/${modelId}`; } -function shouldNormalizeCachedPricingLookup(provider: string): boolean { - const normalized = normalizeProviderId(provider); - return ( - normalized === "anthropic" || - normalized === "openrouter" || - normalized === "xai" || - WRAPPER_PROVIDERS.has(normalized) - ); -} - export function replaceGatewayModelPricingCache( nextPricing: Map, nextCachedAt = Date.now(), @@ -80,11 +63,11 @@ export function getCachedGatewayModelPricing(params: { if (direct) { return direct; } - if (!shouldNormalizeCachedPricingLookup(provider)) { - return undefined; - } const normalized = normalizeModelRef(provider, model); const normalizedKey = modelPricingCacheKey(normalized.provider, normalized.model); + if (normalizedKey === key) { + return undefined; + } return normalizedKey ? cachedPricing.get(normalizedKey) : undefined; } diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 03c1e043597..b8f096650c2 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -135,7 +135,7 @@ describe("model-pricing-cache", () => { api: "ollama", models: [{ id: "llama3.2:latest" }], }, - vllm: { + "my-local-gpu": { baseUrl: "http://192.168.1.25:8000/v1", api: "openai-completions", models: [{ id: "qwen2.5-coder:7b" }], @@ -143,7 +143,7 @@ describe("model-pricing-cache", () => { }, }, tools: { - subagents: { model: { primary: "vllm/qwen2.5-coder:7b" } }, + subagents: { model: { primary: "my-local-gpu/qwen2.5-coder:7b" } }, }, } as unknown as OpenClawConfig; const fetchImpl = vi.fn(); @@ -156,6 +156,46 @@ describe("model-pricing-cache", () => { ).toBeUndefined(); }); + it("seeds pricing from explicit configured model cost without external catalog fetches", async () => { + const config = { + agents: { + defaults: { + model: { primary: "custom/gpt-local" }, + }, + }, + models: { + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-completions", + models: [ + { + id: "gpt-local", + name: "GPT Local", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 8192, + cost: { input: 0.12, output: 0.48, cacheRead: 0.01, cacheWrite: 0.02 }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + const fetchImpl = vi.fn(); + + await refreshGatewayModelPricingCache({ config, fetchImpl }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(getCachedGatewayModelPricing({ provider: "custom", model: "gpt-local" })).toEqual({ + input: 0.12, + output: 0.48, + cacheRead: 0.01, + cacheWrite: 0.02, + }); + }); + it("loads openrouter pricing and maps provider aliases, wrappers, and anthropic dotted ids", async () => { const config = { agents: { @@ -631,7 +671,7 @@ describe("model-pricing-cache", () => { const config = { agents: { defaults: { - model: { primary: "moonshot/kimi-k2.6" }, + model: { primary: "kimi/kimi-k2.6" }, }, }, } as unknown as OpenClawConfig; @@ -669,7 +709,7 @@ describe("model-pricing-cache", () => { await refreshGatewayModelPricingCache({ config, fetchImpl }); - expect(getCachedGatewayModelPricing({ provider: "moonshot", model: "kimi-k2.6" })).toEqual({ + expect(getCachedGatewayModelPricing({ provider: "kimi", model: "kimi-k2.6" })).toEqual({ input: 0.95, output: 4, cacheRead: 0.16, diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index f89804c4a8c..d4b4667d69d 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -8,9 +8,19 @@ import { type ModelRef, } from "../agents/model-selection.js"; import { resolvePluginWebSearchConfig } from "../config/plugin-web-search-config.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveManifestContractPluginIds } from "../plugins/plugin-registry.js"; +import { planManifestModelCatalogRows, type ModelCatalogCost } from "../model-catalog/index.js"; +import type { + PluginManifestModelPricingModelIdTransform, + PluginManifestModelPricingProvider, + PluginManifestModelPricingSource, +} from "../plugins/manifest.js"; +import { + loadPluginManifestRegistryForPluginRegistry, + resolveManifestContractPluginIds, +} from "../plugins/plugin-registry.js"; import { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalString, resolvePrimaryStringValue } from "../shared/string-coerce.js"; import { @@ -34,6 +44,18 @@ type OpenRouterModelPayload = { pricing?: unknown; }; +type ExternalPricingPolicy = { + external: boolean; + openRouter?: ExternalPricingSourcePolicy; + liteLLM?: ExternalPricingSourcePolicy; +}; + +type ExternalPricingSourcePolicy = { + provider?: string; + passthroughProviderModel?: boolean; + modelIdTransforms: readonly PluginManifestModelPricingModelIdTransform[]; +}; + export { getCachedGatewayModelPricing }; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; @@ -42,24 +64,6 @@ const LITELLM_PRICING_URL = const CACHE_TTL_MS = 24 * 60 * 60_000; const FETCH_TIMEOUT_MS = 60_000; const MAX_PRICING_CATALOG_BYTES = 5 * 1024 * 1024; -const PROVIDER_ALIAS_TO_OPENROUTER: Record = { - "google-gemini-cli": "google", - kimi: "moonshotai", - "kimi-coding": "moonshotai", - moonshot: "moonshotai", - moonshotai: "moonshotai", - "openai-codex": "openai", - xai: "x-ai", - zai: "z-ai", -}; -const WRAPPER_PROVIDERS = new Set([ - "cloudflare-ai-gateway", - "kilocode", - "openrouter", - "vercel-ai-gateway", -]); -const LOCAL_MODEL_PROVIDER_APIS = new Set(["ollama"]); -const LOCAL_MODEL_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]); const log = createSubsystemLogger("gateway").child("model-pricing"); let refreshTimer: ReturnType | null = null; @@ -151,6 +155,57 @@ function parseOpenRouterPricing(value: unknown): CachedModelPricing | null { }; } +function toCachedPricingTier(value: unknown): CachedPricingTier | null { + if (!value || typeof value !== "object") { + return null; + } + const tier = value as Record; + const input = parseNumberString(tier.input); + const output = parseNumberString(tier.output); + const range = tier.range; + if (input === null || output === null || !Array.isArray(range) || range.length < 1) { + return null; + } + const start = parseNumberString(range[0]); + if (start === null) { + return null; + } + const rawEnd = range.length >= 2 ? parseNumberString(range[1]) : null; + const end = rawEnd === null || rawEnd <= start ? Infinity : rawEnd; + return { + input, + output, + cacheRead: parseNumberString(tier.cacheRead) ?? 0, + cacheWrite: parseNumberString(tier.cacheWrite) ?? 0, + range: [start, end], + }; +} + +function toCachedModelPricing( + value: ModelCatalogCost | ModelDefinitionConfig["cost"] | undefined, +): CachedModelPricing | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const input = parseNumberString(value.input) ?? 0; + const output = parseNumberString(value.output) ?? 0; + const cacheRead = parseNumberString(value.cacheRead) ?? 0; + const cacheWrite = parseNumberString(value.cacheWrite) ?? 0; + const tieredPricing = Array.isArray(value.tieredPricing) + ? value.tieredPricing + .map((tier) => toCachedPricingTier(tier)) + .filter((tier): tier is CachedPricingTier => Boolean(tier)) + .toSorted((left, right) => left.range[0] - right.range[0]) + : []; + return { + input, + output, + cacheRead, + cacheWrite, + ...(tieredPricing.length > 0 ? { tieredPricing } : {}), + }; +} + async function readPricingJsonObject( response: Response, source: string, @@ -274,17 +329,89 @@ async function fetchLiteLLMPricingCatalog(fetchImpl: typeof fetch): Promise; + catalogPricing: Map; +} { + const registry = loadPluginManifestRegistryForPluginRegistry({ config }); + const policies = new Map(); + for (const plugin of registry.plugins) { + for (const [provider, rawPolicy] of Object.entries(plugin.modelPricing?.providers ?? {})) { + const policy = normalizeExternalPricingPolicy(rawPolicy); + if (policy) { + policies.set(provider, policy); + } + } + } + + const catalogPricing = new Map(); + for (const row of planManifestModelCatalogRows({ registry }).rows) { + const pricing = toCachedModelPricing(row.cost); + if (pricing) { + catalogPricing.set(modelKey(row.provider, row.id), pricing); + } + } + + return { policies, catalogPricing }; +} + +function applyModelIdTransform( + model: string, + transform: PluginManifestModelPricingModelIdTransform, +): string { + switch (transform) { + case "version-dots": + return model + .replace(/^claude-(\d+)-(\d+)-/u, "claude-$1.$2-") + .replace(/^claude-([a-z]+)-(\d+)-(\d+)$/u, "claude-$1-$2.$3"); + } + return model; +} + +function applyModelIdTransforms( + model: string, + transforms: readonly PluginManifestModelPricingModelIdTransform[], +): string[] { + const variants = new Set([model]); + for (const transform of transforms) { + const snapshot = Array.from(variants); + for (const variant of snapshot) { + variants.add(applyModelIdTransform(variant, transform)); + } + } + return [...variants]; } function canonicalizeOpenRouterLookupId(id: string): string { @@ -296,17 +423,12 @@ function canonicalizeOpenRouterLookupId(id: string): string { if (slash === -1) { return trimmed; } - const provider = canonicalizeOpenRouterProvider(trimmed.slice(0, slash)); - let model = trimmed.slice(slash + 1).trim(); + const provider = normalizeModelRef(trimmed.slice(0, slash), "placeholder").provider; + const model = trimmed.slice(slash + 1).trim(); if (!model) { return provider; } - if (provider === "anthropic") { - model = model - .replace(/^claude-(\d+)\.(\d+)-/u, "claude-$1-$2-") - .replace(/^claude-([a-z]+)-(\d+)\.(\d+)$/u, "claude-$1-$2-$3"); - } - model = + const normalizedModel = normalizeProviderModelIdWithPlugin({ provider, context: { @@ -314,37 +436,53 @@ function canonicalizeOpenRouterLookupId(id: string): string { modelId: model, }, }) ?? model; - return `${provider}/${model}`; + return modelKey(provider, normalizedModel); } -function buildOpenRouterExactCandidates(ref: ModelRef, seen = new Set()): string[] { +function buildExternalCatalogCandidates(params: { + ref: ModelRef; + source: "openRouter" | "liteLLM"; + policies: ReadonlyMap; + seen?: Set; +}): string[] { + const { ref, source, policies } = params; const refKey = modelKey(ref.provider, ref.model); + const seen = params.seen ?? new Set(); if (seen.has(refKey)) { return []; } const nextSeen = new Set(seen); nextSeen.add(refKey); + const policy = policies.get(ref.provider); + if (policy?.external === false) { + return []; + } + const sourcePolicy = policy?.[source]; + if (sourcePolicy === undefined && policy && source === "openRouter") { + return []; + } + if (sourcePolicy === undefined && policy && source === "liteLLM") { + return []; + } + const provider = sourcePolicy?.provider ?? ref.provider; + const transforms = sourcePolicy?.modelIdTransforms ?? []; const candidates = new Set(); - const canonicalProvider = canonicalizeOpenRouterProvider(ref.provider); - const canonicalFullId = canonicalizeOpenRouterLookupId(modelKey(canonicalProvider, ref.model)); - if (canonicalFullId) { - candidates.add(canonicalFullId); + + for (const model of applyModelIdTransforms(ref.model, transforms)) { + const candidate = modelKey(provider, model); + candidates.add(source === "openRouter" ? canonicalizeOpenRouterLookupId(candidate) : candidate); } - if (canonicalProvider === "anthropic") { - const slash = canonicalFullId.indexOf("/"); - const model = slash === -1 ? canonicalFullId : canonicalFullId.slice(slash + 1); - const dotted = model - .replace(/^claude-(\d+)-(\d+)-/u, "claude-$1.$2-") - .replace(/^claude-([a-z]+)-(\d+)-(\d+)$/u, "claude-$1-$2.$3"); - candidates.add(`${canonicalProvider}/${dotted}`); - } - - if (WRAPPER_PROVIDERS.has(ref.provider) && ref.model.includes("/")) { + if (sourcePolicy?.passthroughProviderModel && ref.model.includes("/")) { const nestedRef = parseModelRef(ref.model, DEFAULT_PROVIDER); if (nestedRef) { - for (const candidate of buildOpenRouterExactCandidates(nestedRef, nextSeen)) { + for (const candidate of buildExternalCatalogCandidates({ + ref: nestedRef, + source, + policies, + seen: nextSeen, + })) { candidates.add(candidate); } } @@ -460,22 +598,65 @@ function isPrivateOrLoopbackBaseUrl(baseUrl: string | undefined): boolean { } } -function shouldFetchExternalPricingForRef(config: OpenClawConfig, ref: ModelRef): boolean { +function findConfiguredProviderModel( + config: OpenClawConfig, + ref: ModelRef, +): ModelDefinitionConfig | undefined { const providerConfig = config.models?.providers?.[ref.provider]; - if (providerConfig?.api && LOCAL_MODEL_PROVIDER_APIS.has(providerConfig.api)) { + return providerConfig?.models?.find((model) => { + const normalized = normalizeModelRef(ref.provider, model.id); + return modelKey(normalized.provider, normalized.model) === modelKey(ref.provider, ref.model); + }); +} + +function getConfiguredModelPricing( + config: OpenClawConfig, + ref: ModelRef, +): CachedModelPricing | undefined { + return toCachedModelPricing(findConfiguredProviderModel(config, ref)?.cost); +} + +function hasPrivateOrLoopbackConfiguredEndpoint(config: OpenClawConfig, ref: ModelRef): boolean { + const providerConfig = config.models?.providers?.[ref.provider]; + const model = findConfiguredProviderModel(config, ref); + return ( + isPrivateOrLoopbackBaseUrl(model?.baseUrl) || + isPrivateOrLoopbackBaseUrl(providerConfig?.baseUrl) + ); +} + +function shouldFetchExternalPricingForRef(params: { + config: OpenClawConfig; + ref: ModelRef; + policies: ReadonlyMap; + seededPricing: ReadonlyMap; +}): boolean { + if (params.seededPricing.has(modelKey(params.ref.provider, params.ref.model))) { return false; } - if (LOCAL_MODEL_PROVIDER_IDS.has(ref.provider)) { + if (hasPrivateOrLoopbackConfiguredEndpoint(params.config, params.ref)) { return false; } - if (isPrivateOrLoopbackBaseUrl(providerConfig?.baseUrl)) { + if (params.policies.get(params.ref.provider)?.external === false) { return false; } return true; } -function filterExternalPricingRefs(config: OpenClawConfig, refs: ModelRef[]): ModelRef[] { - return refs.filter((ref) => shouldFetchExternalPricingForRef(config, ref)); +function filterExternalPricingRefs(params: { + config: OpenClawConfig; + refs: ModelRef[]; + policies: ReadonlyMap; + seededPricing: ReadonlyMap; +}): ModelRef[] { + return params.refs.filter((ref) => + shouldFetchExternalPricingForRef({ + config: params.config, + ref, + policies: params.policies, + seededPricing: params.seededPricing, + }), + ); } export function collectConfiguredModelPricingRefs(config: OpenClawConfig): ModelRef[] { @@ -562,16 +743,22 @@ async function fetchOpenRouterPricingCatalog( function resolveCatalogPricingForRef(params: { ref: ModelRef; + policies: ReadonlyMap; catalogById: Map; catalogByNormalizedId: Map; }): CachedModelPricing | undefined { - for (const candidate of buildOpenRouterExactCandidates(params.ref)) { + const candidates = buildExternalCatalogCandidates({ + ref: params.ref, + source: "openRouter", + policies: params.policies, + }); + for (const candidate of candidates) { const exact = params.catalogById.get(candidate); if (exact) { return exact.pricing; } } - for (const candidate of buildOpenRouterExactCandidates(params.ref)) { + for (const candidate of candidates) { const normalized = canonicalizeOpenRouterLookupId(candidate); if (!normalized) { continue; @@ -584,6 +771,24 @@ function resolveCatalogPricingForRef(params: { return undefined; } +function resolveLiteLLMPricingForRef(params: { + ref: ModelRef; + policies: ReadonlyMap; + catalog: LiteLLMPricingCatalog; +}): CachedModelPricing | undefined { + for (const candidate of buildExternalCatalogCandidates({ + ref: params.ref, + source: "liteLLM", + policies: params.policies, + })) { + const pricing = params.catalog.get(candidate); + if (pricing) { + return pricing; + } + } + return undefined; +} + function scheduleRefresh(params: { config: OpenClawConfig; fetchImpl: typeof fetch }): void { clearRefreshTimer(); refreshTimer = setTimeout(() => { @@ -594,6 +799,27 @@ function scheduleRefresh(params: { config: OpenClawConfig; fetchImpl: typeof fet }, CACHE_TTL_MS); } +function collectSeededPricing(params: { + config: OpenClawConfig; + refs: readonly ModelRef[]; + catalogPricing: ReadonlyMap; +}): Map { + const seeded = new Map(); + for (const ref of params.refs) { + const key = modelKey(ref.provider, ref.model); + const configuredPricing = getConfiguredModelPricing(params.config, ref); + if (configuredPricing) { + seeded.set(key, configuredPricing); + continue; + } + const catalogPricing = params.catalogPricing.get(key); + if (catalogPricing) { + seeded.set(key, catalogPricing); + } + } + return seeded; +} + export async function refreshGatewayModelPricingCache(params: { config: OpenClawConfig; fetchImpl?: typeof fetch; @@ -603,12 +829,21 @@ export async function refreshGatewayModelPricingCache(params: { } const fetchImpl = params.fetchImpl ?? fetch; inFlightRefresh = (async () => { - const refs = filterExternalPricingRefs( - params.config, - collectConfiguredModelPricingRefs(params.config), - ); + const pricingContext = loadManifestPricingContext(params.config); + const allRefs = collectConfiguredModelPricingRefs(params.config); + const seededPricing = collectSeededPricing({ + config: params.config, + refs: allRefs, + catalogPricing: pricingContext.catalogPricing, + }); + const refs = filterExternalPricingRefs({ + config: params.config, + refs: allRefs, + policies: pricingContext.policies, + seededPricing, + }); if (refs.length === 0) { - replaceGatewayModelPricingCache(new Map()); + replaceGatewayModelPricingCache(seededPricing); clearRefreshTimer(); return; } @@ -639,11 +874,12 @@ export async function refreshGatewayModelPricingCache(params: { catalogByNormalizedId.set(normalizedId, entry); } - const nextPricing = new Map(); + const nextPricing = new Map(seededPricing); for (const ref of refs) { // 1. Try OpenRouter first (existing behavior — flat pricing) const openRouterPricing = resolveCatalogPricingForRef({ ref, + policies: pricingContext.policies, catalogById, catalogByNormalizedId, }); @@ -651,6 +887,7 @@ export async function refreshGatewayModelPricingCache(params: { // 2. Try LiteLLM (may contain tiered pricing) const litellmPricing = resolveLiteLLMPricingForRef({ ref, + policies: pricingContext.policies, catalog: litellmCatalog, }); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index a2279c8dca3..eba92e67797 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -563,6 +563,22 @@ describe("loadPluginManifestRegistry", () => { ignored: "unknown", }, }, + modelPricing: { + providers: { + moonshot: { + openRouter: { + provider: "moonshotai", + modelIdTransforms: ["version-dots", "unknown"], + }, + liteLLM: { + provider: "moonshot", + }, + }, + openai: { + external: false, + }, + }, + }, configSchema: { type: "object" }, }); @@ -630,6 +646,19 @@ describe("loadPluginManifestRegistry", () => { moonshot: "static", }, }); + expect(registry.plugins[0]?.modelPricing).toEqual({ + providers: { + moonshot: { + openRouter: { + provider: "moonshotai", + modelIdTransforms: ["version-dots"], + }, + liteLLM: { + provider: "moonshot", + }, + }, + }, + }); }); it("hydrates bundled channel config metadata from plugin-local config surfaces", () => { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 633267b44be..a01ea263196 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -37,6 +37,7 @@ import { type PluginManifestContracts, type PluginManifestMediaUnderstandingProviderMetadata, type PluginManifestModelCatalog, + type PluginManifestModelPricing, type PluginManifestModelSupport, type PluginManifestProviderEndpoint, type PluginManifestQaRunner, @@ -113,6 +114,7 @@ export type PluginManifestRecord = { providerDiscoverySource?: string; modelSupport?: PluginManifestModelSupport; modelCatalog?: PluginManifestModelCatalog; + modelPricing?: PluginManifestModelPricing; providerEndpoints?: PluginManifestProviderEndpoint[]; cliBackends: string[]; syntheticAuthRefs?: string[]; @@ -338,6 +340,7 @@ function buildRecord(params: { : undefined, modelSupport: params.manifest.modelSupport, modelCatalog: params.manifest.modelCatalog, + modelPricing: params.manifest.modelPricing, providerEndpoints: params.manifest.providerEndpoints, cliBackends: params.manifest.cliBackends ?? [], syntheticAuthRefs: params.manifest.syntheticAuthRefs ?? [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index bfb13022957..2b722ed265d 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -7,6 +7,7 @@ import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/bou import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { normalizeModelCatalog, + normalizeModelCatalogProviderId, type ModelCatalog, type ModelCatalogAlias, type ModelCatalogCost, @@ -72,6 +73,24 @@ export type PluginManifestModelCatalogAlias = ModelCatalogAlias; export type PluginManifestModelCatalogSuppression = ModelCatalogSuppression; export type PluginManifestModelCatalog = ModelCatalog; +export type PluginManifestModelPricingModelIdTransform = "version-dots"; + +export type PluginManifestModelPricingSource = { + provider?: string; + passthroughProviderModel?: boolean; + modelIdTransforms?: PluginManifestModelPricingModelIdTransform[]; +}; + +export type PluginManifestModelPricingProvider = { + external?: boolean; + openRouter?: PluginManifestModelPricingSource | false; + liteLLM?: PluginManifestModelPricingSource | false; +}; + +export type PluginManifestModelPricing = { + providers?: Record; +}; + export type PluginManifestProviderEndpoint = { /** * Core endpoint class this plugin-owned endpoint should map to. Core must @@ -211,6 +230,8 @@ export type PluginManifest = { * onboarding, and model picker surfaces before provider runtime loads. */ modelCatalog?: PluginManifestModelCatalog; + /** Manifest-owned external pricing lookup policy for provider refs. */ + modelPricing?: PluginManifestModelPricing; /** Cheap provider endpoint metadata used before provider runtime loads. */ providerEndpoints?: PluginManifestProviderEndpoint[]; /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ @@ -615,6 +636,69 @@ function normalizeManifestModelSupport(value: unknown): PluginManifestModelSuppo return Object.keys(modelSupport).length > 0 ? modelSupport : undefined; } +function normalizeManifestModelPricingSource( + value: unknown, +): PluginManifestModelPricingSource | false | undefined { + if (value === false) { + return false; + } + if (!isRecord(value)) { + return undefined; + } + const provider = normalizeModelCatalogProviderId(normalizeOptionalString(value.provider) ?? ""); + const modelIdTransforms = normalizeTrimmedStringList(value.modelIdTransforms).filter( + (entry): entry is PluginManifestModelPricingModelIdTransform => entry === "version-dots", + ); + const source = { + ...(provider ? { provider } : {}), + ...(value.passthroughProviderModel === true ? { passthroughProviderModel: true } : {}), + ...(modelIdTransforms.length > 0 ? { modelIdTransforms } : {}), + } satisfies PluginManifestModelPricingSource; + return Object.keys(source).length > 0 ? source : undefined; +} + +function normalizeManifestModelPricingProvider( + value: unknown, +): PluginManifestModelPricingProvider | undefined { + if (!isRecord(value)) { + return undefined; + } + const openRouter = normalizeManifestModelPricingSource(value.openRouter); + const liteLLM = normalizeManifestModelPricingSource(value.liteLLM); + const policy = { + ...(typeof value.external === "boolean" ? { external: value.external } : {}), + ...(openRouter !== undefined ? { openRouter } : {}), + ...(liteLLM !== undefined ? { liteLLM } : {}), + } satisfies PluginManifestModelPricingProvider; + return Object.keys(policy).length > 0 ? policy : undefined; +} + +function normalizeManifestModelPricing( + value: unknown, + params: { ownedProviders: ReadonlySet }, +): PluginManifestModelPricing | undefined { + if (!isRecord(value) || !isRecord(value.providers)) { + return undefined; + } + const ownedProviders = new Set( + [...params.ownedProviders] + .map((provider) => normalizeModelCatalogProviderId(provider)) + .filter(Boolean), + ); + const providers: Record = {}; + for (const [rawProviderId, rawPolicy] of Object.entries(value.providers)) { + const providerId = normalizeModelCatalogProviderId(rawProviderId); + if (!providerId || !ownedProviders.has(providerId)) { + continue; + } + const policy = normalizeManifestModelPricingProvider(rawPolicy); + if (policy) { + providers[providerId] = policy; + } + } + return Object.keys(providers).length > 0 ? { providers } : undefined; +} + function normalizeManifestProviderEndpoints( value: unknown, ): PluginManifestProviderEndpoint[] | undefined { @@ -949,6 +1033,9 @@ export function loadPluginManifest( const modelCatalog = normalizeModelCatalog(raw.modelCatalog, { ownedProviders: new Set(providers), }); + const modelPricing = normalizeManifestModelPricing(raw.modelPricing, { + ownedProviders: new Set(providers), + }); const providerEndpoints = normalizeManifestProviderEndpoints(raw.providerEndpoints); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs); @@ -990,6 +1077,7 @@ export function loadPluginManifest( providerDiscoveryEntry, modelSupport, modelCatalog, + modelPricing, providerEndpoints, cliBackends, syntheticAuthRefs,