mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
refactor(gateway): move model pricing policy to manifests
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
"modelSupport": {
|
||||
"modelPrefixes": ["claude-"]
|
||||
},
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"openRouter": {
|
||||
"modelIdTransforms": ["version-dots"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cliBackends": ["claude-cli"],
|
||||
"syntheticAuthRefs": ["claude-cli"],
|
||||
"providerAuthEnvVars": {
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
"id": "kilocode",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["kilocode"],
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"kilocode": {
|
||||
"openRouter": {
|
||||
"passthroughProviderModel": true
|
||||
},
|
||||
"liteLLM": {
|
||||
"passthroughProviderModel": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"kilocode": ["KILOCODE_API_KEY"]
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
"id": "lmstudio",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["lmstudio"],
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"lmstudio": {
|
||||
"external": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"nonSecretAuthMarkers": ["lmstudio-local"],
|
||||
"providerAuthEnvVars": {
|
||||
"lmstudio": ["LM_API_TOKEN"]
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
"enabledByDefault": true,
|
||||
"providerDiscoveryEntry": "./provider-discovery.ts",
|
||||
"providers": ["moonshot"],
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"moonshot": {
|
||||
"openRouter": {
|
||||
"provider": "moonshotai"
|
||||
},
|
||||
"liteLLM": {
|
||||
"provider": "moonshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelCatalog": {
|
||||
"providers": {
|
||||
"moonshot": {
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
"enabledByDefault": true,
|
||||
"providers": ["ollama"],
|
||||
"providerDiscoveryEntry": "./provider-discovery.ts",
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"external": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"syntheticAuthRefs": ["ollama"],
|
||||
"nonSecretAuthMarkers": ["ollama-local"],
|
||||
"providerAuthEnvVars": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"id": "openrouter",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["openrouter"],
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"openRouter": {
|
||||
"passthroughProviderModel": true
|
||||
},
|
||||
"liteLLM": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"openrouter": ["OPENROUTER_API_KEY"]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
"id": "sglang",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["sglang"],
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"sglang": {
|
||||
"external": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"sglang": ["SGLANG_API_KEY"]
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
"id": "vllm",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["vllm"],
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"external": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"vllm": ["VLLM_API_KEY"]
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user