refactor(gateway): move model pricing policy to manifests

This commit is contained in:
Peter Steinberger
2026-04-27 09:26:37 +01:00
parent a494eea6d4
commit a60f15c611
22 changed files with 665 additions and 91 deletions

View File

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

View File

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

View File

@@ -6,6 +6,15 @@
"modelSupport": {
"modelPrefixes": ["claude-"]
},
"modelPricing": {
"providers": {
"anthropic": {
"openRouter": {
"modelIdTransforms": ["version-dots"]
}
}
}
},
"cliBackends": ["claude-cli"],
"syntheticAuthRefs": ["claude-cli"],
"providerAuthEnvVars": {

View File

@@ -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"]
},

View File

@@ -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"]

View File

@@ -2,6 +2,18 @@
"id": "kilocode",
"enabledByDefault": true,
"providers": ["kilocode"],
"modelPricing": {
"providers": {
"kilocode": {
"openRouter": {
"passthroughProviderModel": true
},
"liteLLM": {
"passthroughProviderModel": true
}
}
}
},
"providerAuthEnvVars": {
"kilocode": ["KILOCODE_API_KEY"]
},

View File

@@ -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"]

View File

@@ -2,6 +2,13 @@
"id": "lmstudio",
"enabledByDefault": true,
"providers": ["lmstudio"],
"modelPricing": {
"providers": {
"lmstudio": {
"external": false
}
}
},
"nonSecretAuthMarkers": ["lmstudio-local"],
"providerAuthEnvVars": {
"lmstudio": ["LM_API_TOKEN"]

View File

@@ -3,6 +3,18 @@
"enabledByDefault": true,
"providerDiscoveryEntry": "./provider-discovery.ts",
"providers": ["moonshot"],
"modelPricing": {
"providers": {
"moonshot": {
"openRouter": {
"provider": "moonshotai"
},
"liteLLM": {
"provider": "moonshot"
}
}
}
},
"modelCatalog": {
"providers": {
"moonshot": {

View File

@@ -3,6 +3,13 @@
"enabledByDefault": true,
"providers": ["ollama"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"modelPricing": {
"providers": {
"ollama": {
"external": false
}
}
},
"syntheticAuthRefs": ["ollama"],
"nonSecretAuthMarkers": ["ollama-local"],
"providerAuthEnvVars": {

View File

@@ -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"]

View File

@@ -2,6 +2,16 @@
"id": "openrouter",
"enabledByDefault": true,
"providers": ["openrouter"],
"modelPricing": {
"providers": {
"openrouter": {
"openRouter": {
"passthroughProviderModel": true
},
"liteLLM": false
}
}
},
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},

View File

@@ -2,6 +2,13 @@
"id": "sglang",
"enabledByDefault": true,
"providers": ["sglang"],
"modelPricing": {
"providers": {
"sglang": {
"external": false
}
}
},
"providerAuthEnvVars": {
"sglang": ["SGLANG_API_KEY"]
},

View File

@@ -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"]
},

View File

@@ -2,6 +2,13 @@
"id": "vllm",
"enabledByDefault": true,
"providers": ["vllm"],
"modelPricing": {
"providers": {
"vllm": {
"external": false
}
}
},
"providerAuthEnvVars": {
"vllm": ["VLLM_API_KEY"]
},

View File

@@ -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"]
},

View File

@@ -23,13 +23,6 @@ export type CachedModelPricing = {
let cachedPricing = new Map<string, CachedModelPricing>();
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<string, CachedModelPricing>,
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;
}

View File

@@ -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<typeof fetch>();
@@ -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<typeof fetch>();
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,

View File

@@ -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<string, string> = {
"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<typeof setTimeout> | 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<string, unknown>;
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<Lite
return catalog;
}
function resolveLiteLLMPricingForRef(params: {
ref: ModelRef;
catalog: LiteLLMPricingCatalog;
}): CachedModelPricing | undefined {
// Only use provider-qualified key to avoid cross-provider pricing collisions.
return params.catalog.get(`${params.ref.provider}/${params.ref.model}`);
function normalizeExternalPricingSource(
value: PluginManifestModelPricingSource | false | undefined,
): ExternalPricingSourcePolicy | undefined {
if (!value) {
return undefined;
}
return {
...(value.provider
? { provider: normalizeModelRef(value.provider, "placeholder").provider }
: {}),
...(value.passthroughProviderModel ? { passthroughProviderModel: true } : {}),
modelIdTransforms: value.modelIdTransforms ?? [],
};
}
function canonicalizeOpenRouterProvider(provider: string): string {
const normalized = normalizeModelRef(provider, "placeholder").provider;
return PROVIDER_ALIAS_TO_OPENROUTER[normalized] ?? normalized;
function normalizeExternalPricingPolicy(
value: PluginManifestModelPricingProvider | undefined,
): ExternalPricingPolicy | undefined {
if (!value) {
return undefined;
}
return {
external: value.external !== false,
...(normalizeExternalPricingSource(value.openRouter) !== undefined
? { openRouter: normalizeExternalPricingSource(value.openRouter) }
: {}),
...(normalizeExternalPricingSource(value.liteLLM) !== undefined
? { liteLLM: normalizeExternalPricingSource(value.liteLLM) }
: {}),
};
}
function loadManifestPricingContext(config: OpenClawConfig): {
policies: Map<string, ExternalPricingPolicy>;
catalogPricing: Map<string, CachedModelPricing>;
} {
const registry = loadPluginManifestRegistryForPluginRegistry({ config });
const policies = new Map<string, ExternalPricingPolicy>();
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<string, CachedModelPricing>();
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>()): string[] {
function buildExternalCatalogCandidates(params: {
ref: ModelRef;
source: "openRouter" | "liteLLM";
policies: ReadonlyMap<string, ExternalPricingPolicy>;
seen?: Set<string>;
}): string[] {
const { ref, source, policies } = params;
const refKey = modelKey(ref.provider, ref.model);
const seen = params.seen ?? new Set<string>();
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<string>();
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<string, ExternalPricingPolicy>;
seededPricing: ReadonlyMap<string, CachedModelPricing>;
}): 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<string, ExternalPricingPolicy>;
seededPricing: ReadonlyMap<string, CachedModelPricing>;
}): 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<string, ExternalPricingPolicy>;
catalogById: Map<string, OpenRouterPricingEntry>;
catalogByNormalizedId: Map<string, OpenRouterPricingEntry>;
}): 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<string, ExternalPricingPolicy>;
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<string, CachedModelPricing>;
}): Map<string, CachedModelPricing> {
const seeded = new Map<string, CachedModelPricing>();
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<string, CachedModelPricing>();
const nextPricing = new Map<string, CachedModelPricing>(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,
});

View File

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

View File

@@ -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 ?? [],

View File

@@ -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<string, PluginManifestModelPricingProvider>;
};
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<string> },
): 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<string, PluginManifestModelPricingProvider> = {};
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,