improve(models): source pricing from hosted catalog (#114060)

* feat(model-catalog): serve hosted fallback pricing

* refactor(config): retire client pricing bootstrap settings

* refactor(gateway): delete client pricing refresh runtime

* docs(models): explain hosted catalog pricing

* fix(model-catalog): preserve pricing privacy and aliases

* fix(model-catalog): fingerprint pricing eligibility

* fix(model-catalog): harden pricing endpoint checks

* fix(model-catalog): materialize source-safe pricing aliases

* fix(model-catalog): keep unknown pricing fallbacks safe

* fix(model-catalog): reject zero-only hosted prices

* fix(model-catalog): fail closed without pricing policy metadata

* refactor(utils): extract usage pricing normalization

* fix(model-catalog): rebuild policy-owned pricing namespaces

* test(model-catalog): type publisher cost fixtures

* chore(config): regenerate schema baselines

* fix(utils): keep raw pricing tiers private
This commit is contained in:
Peter Steinberger
2026-07-26 03:48:25 -04:00
committed by GitHub
parent 0951d0dd30
commit 5347285d6b
55 changed files with 1114 additions and 3782 deletions
-2
View File
@@ -730,8 +730,6 @@ src/gateway/gateway-models.profiles.live.test.ts
src/gateway/managed-image-attachments.test.ts
src/gateway/managed-image-attachments.ts
src/gateway/mcp-http.test.ts
src/gateway/model-pricing-cache.test.ts
src/gateway/model-pricing-cache.ts
src/gateway/node-registry.test.ts
src/gateway/node-registry.ts
src/gateway/openai-http.test.ts
+1 -1
View File
@@ -1,5 +1,5 @@
{
"core": 2306,
"core": 2304,
"channel": 3630,
"plugin": 3556
}
+2 -2
View File
@@ -1,4 +1,4 @@
b5a60d7a0cfb860835eb4f38acb6421db736c76121a891311f37dede4c26e5a4 config-baseline.json
e51de41a04b71b26f3ed5edd8e663982f2dee84026b7ecfab6ef75ecb2e7eb4d config-baseline.core.json
8a8f87d6d3b350de300c9ce32d628fcd2f6e90b3e1dfb8f2f6ff915f56e83642 config-baseline.json
2079c0b778fafecfcb2bea1a2eb6d54fbbe00f44959a0a3a0393c5c60e1f9089 config-baseline.core.json
af6ca0e70007113462270d46fa14ef0551e577e2fa157d2b9f6d0a93d36f96f1 config-baseline.channel.json
d47f7eb4268480e99ab85b6c1b5ee07f6c570ce0555e3db682ec26037014b47e config-baseline.plugin.json
+5 -4
View File
@@ -231,10 +231,11 @@ The hosted file is published from the public
Its scheduled workflow refreshes from OpenClaw's shipped plugin manifests and
pricing sources; every catalog content change is preserved as a public commit.
Run `openclaw models refresh` for an immediate check, or disable every hosted
catalog request with `models.catalogRefresh.enabled: false`. A self-hosted mirror
can be selected with an HTTPS `models.catalogRefresh.url` (or localhost HTTP
for testing); see
Run `openclaw models refresh` for an immediate metadata and pricing check, or
disable every hosted catalog request with `models.catalogRefresh.enabled:
false`. When disabled, pricing stays at bundled and explicitly configured
values. A self-hosted mirror can be selected with an HTTPS
`models.catalogRefresh.url` (or localhost HTTP for testing); see
[configuration reference](/gateway/configuration-reference#models).
Custom providers configured under `models.providers` are written into `models.json` under the agent directory (default `~/.openclaw/agents/<agentId>/agent/models.json`). Provider-plugin catalogs are stored separately as generated plugin-owned catalog shards and load automatically. This file is merged with config by default; set `models.mode: "replace"` to use only your configured providers.
+7 -7
View File
@@ -64,8 +64,6 @@ The `models` root also owns global model-catalog behavior.
```json5
{
models: {
// Optional. Default: true. Requires a Gateway restart when changed.
pricing: { enabled: false },
// Optional. Hosted catalog updates default on.
catalogRefresh: {
enabled: true,
@@ -81,19 +79,21 @@ The `models` root also owns global model-catalog behavior.
local model servers. OpenClaw probes the configured health endpoint, starts
the absolute `command` when needed, waits for readiness, then sends the model
request. See [Local model services](/gateway/local-model-services).
- `models.pricing.enabled`: controls the background pricing bootstrap that
starts after sidecars and channels reach the Gateway ready path. When `false`,
the Gateway skips OpenRouter and LiteLLM pricing-catalog fetches; configured
`models.providers.*.models[].cost` values still work for local cost estimates.
- `models.catalogRefresh.enabled`: controls the hosted model catalog refresh
(default: `true`). Set it to `false` to prevent all remote catalog requests;
only catalog data shipped in the installed release is then used.
model metadata and pricing then stay at the values shipped in the installed
release or declared under `models.providers.*.models[].cost`.
- `models.catalogRefresh.url`: optional HTTPS mirror override (plain HTTP is
accepted only for explicit localhost testing). The Gateway
checks in the background at startup and every six hours. A downloaded catalog
applies on the next Gateway restart; a release whose bundled catalog is newer
always wins.
Pricing updates ship in the same hosted catalog file as model metadata. The
retired `models.pricing` toggle is removed automatically by `openclaw doctor
--fix`; use `models.catalogRefresh.enabled: false` when OpenClaw must avoid all
hosted catalog traffic.
## MCP
OpenClaw-managed MCP server definitions live under `mcp.servers` and are
+7 -7
View File
@@ -147,7 +147,7 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m
| `providerCatalogEntry` | No | `string` | Lightweight provider-catalog 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. |
| `modelPricing` | No | `object` | Provider-owned hosted-pricing publication policy. Use it to opt local/self-hosted providers out of published pricing or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. |
| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. |
| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. |
| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. |
@@ -1133,7 +1133,7 @@ OpenClaw derives `trustedDirs` for manifest presets from the plugin root and, fo
## 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.
Use `modelPricing` when the hosted catalog publisher needs provider-specific pricing-key behavior. The publisher reads this metadata without importing provider runtime code.
```json
{
@@ -1156,11 +1156,11 @@ Use `modelPricing` when a provider needs control-plane pricing behavior before r
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. |
| Field | Type | What it means |
| ------------ | ----------------- | --------------------------------------------------------------------------------------------- |
| `external` | `boolean` | Set `false` for local/self-hosted providers that should never use published external pricing. |
| `openRouter` | `false \| object` | OpenRouter publication-key mapping. `false` disables OpenRouter matching for this provider. |
| `liteLLM` | `false \| object` | LiteLLM publication-key mapping. `false` disables LiteLLM matching for this provider. |
Source fields:
+4 -5
View File
@@ -183,11 +183,10 @@ auth: non-API-key providers such as `aws-sdk` can show estimated cost when
their configured model entry includes local pricing and the provider
returns usage metadata.
After sidecars and channels reach the Gateway ready path, OpenClaw starts an
optional background pricing bootstrap for configured model refs that do not
already have local pricing. That bootstrap fetches remote OpenRouter and
LiteLLM pricing catalogs. Set `models.pricing.enabled: false` to skip those
catalog fetches on offline or restricted networks; explicit
Pricing updates ship in the hosted model catalog alongside model metadata.
OpenClaw does not fetch OpenRouter or LiteLLM directly. Set
`models.catalogRefresh.enabled: false` to disable hosted catalog traffic on
offline or restricted networks; bundled pricing and explicit
`models.providers.*.models[].cost` entries still drive local cost estimates.
## Cache TTL and pruning impact
@@ -25,6 +25,9 @@ const validBundle = {
],
},
},
pricing: {
"openai/gpt-external": { input: 2.5, output: 10, cacheRead: 1.25 },
},
} as const;
describe("remote model catalog bundle", () => {
@@ -44,6 +47,11 @@ describe("remote model catalog bundle", () => {
expect(anthropic.models[0]).not.toHaveProperty("baseUrl");
expect(anthropic.models[0]).not.toHaveProperty("headers");
expect(anthropic.models[0]?.compat).toEqual({ nested: {} });
expect(parsed.pricing?.["openai/gpt-external"]).toEqual({
input: 2.5,
output: 10,
cacheRead: 1.25,
});
});
it("rejects unsupported versions, invalid timestamps, and malformed providers", () => {
@@ -64,5 +72,17 @@ describe("remote model catalog bundle", () => {
providers: { anthropic: { models: [{ id: " duplicate " }, { id: "duplicate" }] } },
}),
).toThrow("duplicate model id: duplicate");
expect(() =>
parseRemoteModelCatalogBundle({
...validBundle,
pricing: { "openai/bad": { input: -1, output: 2 } },
}),
).toThrow();
expect(() =>
parseRemoteModelCatalogBundle({
...validBundle,
pricing: { "openai/bad": { input: 1, output: 2, baseUrl: "https://bad.test" } },
}),
).toThrow();
});
});
@@ -5,29 +5,41 @@ import type { ModelCatalogProvider } from "./model-catalog-types.js";
export const REMOTE_CATALOG_MAX_FUTURE_SKEW_MS = 24 * 60 * 60_000;
const stringMapSchema = z.record(z.string(), z.string());
const pricingTierSchema = z
.object({
input: z.number().finite().nonnegative(),
output: z.number().finite().nonnegative(),
cacheRead: z.number().finite().nonnegative(),
cacheWrite: z.number().finite().nonnegative(),
range: z.union([
z.tuple([z.number().finite().nonnegative()]),
z.tuple([z.number().finite().nonnegative(), z.number().finite().nonnegative()]),
]),
})
.strict();
const costSchema = z
.object({
input: z.number().finite().nonnegative().optional(),
output: z.number().finite().nonnegative().optional(),
cacheRead: z.number().finite().nonnegative().optional(),
cacheWrite: z.number().finite().nonnegative().optional(),
tieredPricing: z
.array(
z.object({
input: z.number().finite().nonnegative(),
output: z.number().finite().nonnegative(),
cacheRead: z.number().finite().nonnegative(),
cacheWrite: z.number().finite().nonnegative(),
range: z.union([
z.tuple([z.number().finite().nonnegative()]),
z.tuple([z.number().finite().nonnegative(), z.number().finite().nonnegative()]),
]),
}),
)
.optional(),
tieredPricing: z.array(pricingTierSchema).optional(),
})
.strict();
const hostedPricingSchema = z
.object({
input: z.number().finite().nonnegative(),
output: z.number().finite().nonnegative(),
cacheRead: z.number().finite().nonnegative().optional(),
cacheWrite: z.number().finite().nonnegative().optional(),
tieredPricing: z.array(pricingTierSchema).optional(),
})
.strict();
export type RemoteModelCatalogPricing = z.infer<typeof hostedPricingSchema>;
const modelSchema = z.object({
id: z.string().trim().min(1),
name: z.string().optional(),
@@ -89,6 +101,7 @@ export const remoteModelCatalogBundleSchema = z
minVersion: z.string().trim().min(1).optional(),
sourceCommit: z.string().trim().min(1),
providers: z.record(z.string().trim().min(1), remoteModelCatalogProviderSchema),
pricing: z.record(z.string().trim().min(1), hostedPricingSchema).optional(),
})
.strict();
+18 -1
View File
@@ -25,6 +25,21 @@ export type ModelCatalogBundleSummary = {
providers: number;
models: number;
costModels: number;
pricingEntries: number;
};
export type PublishedModelPricing = {
input: number;
output: number;
cacheRead?: number;
cacheWrite?: number;
tieredPricing?: Array<{
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
range: [number] | [number, number];
}>;
};
export type PublishedModelCatalogBundle = {
@@ -39,6 +54,7 @@ export type PublishedModelCatalogBundle = {
[key: string]: unknown;
}
>;
pricing?: Record<string, PublishedModelPricing>;
};
export function parsePublishModelCatalogArgs(args: string[]): PublishModelCatalogArgs;
@@ -59,5 +75,6 @@ export function enrichModelCatalogPricing(options: {
bundle: PublishedModelCatalogBundle;
manifests: ModelCatalogManifestInput[];
fetchImpl?: typeof fetch;
}): Promise<number>;
validateBundle?: (bundle: unknown) => PublishedModelCatalogBundle;
}): Promise<{ modelsEnriched: number; pricingEntries: number }>;
export function serializeModelCatalogBundle(bundle: PublishedModelCatalogBundle): string;
+217 -22
View File
@@ -12,6 +12,8 @@ export const LITELLM_PRICING_URL =
const SCRIPT_LABEL = "publish-model-catalog";
const PRICING_FETCH_TIMEOUT_MS = 60_000;
const MAX_PRICING_CATALOG_BYTES = 5 * 1024 * 1024;
const BUNDLE_SIZE_WARNING_BYTES = 2 * 1024 * 1024;
const CLIENT_BUNDLE_LIMIT_BYTES = 4 * 1024 * 1024;
const defaultRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function requireOptionValue(args, index, flag) {
@@ -129,6 +131,7 @@ export function summarizeModelCatalogBundle(bundle) {
(total, provider) => total + provider.models.filter((model) => model.cost).length,
0,
),
pricingEntries: Object.keys(bundle.pricing ?? {}).length,
};
}
@@ -212,6 +215,43 @@ function parseLiteLLMPricing(value) {
};
}
function compactPricing(pricing) {
return {
input: pricing.input,
output: pricing.output,
...(pricing.cacheRead > 0 ? { cacheRead: pricing.cacheRead } : {}),
...(pricing.cacheWrite > 0 ? { cacheWrite: pricing.cacheWrite } : {}),
...(pricing.tieredPricing ? { tieredPricing: pricing.tieredPricing } : {}),
};
}
function mergePricing(primary, secondary) {
if (!primary || !hasKnownPricing(primary)) {
return secondary;
}
if (!secondary || !hasKnownPricing(secondary)) {
return primary;
}
if (!secondary?.tieredPricing) {
return primary;
}
return { ...primary, tieredPricing: secondary.tieredPricing };
}
function hasKnownPricing(pricing) {
return (
(pricing.input ?? 0) > 0 ||
(pricing.output ?? 0) > 0 ||
(pricing.cacheRead ?? 0) > 0 ||
(pricing.cacheWrite ?? 0) > 0 ||
Boolean(
pricing.tieredPricing?.some(
(tier) => tier.input > 0 || tier.output > 0 || tier.cacheRead > 0 || tier.cacheWrite > 0,
),
)
);
}
function applyModelIdTransforms(modelId, transforms) {
const variants = new Set([modelId]);
for (const transform of transforms ?? []) {
@@ -264,6 +304,106 @@ function buildPricingCandidates({ providerId, modelId, source, policies, seen =
return [...candidates];
}
function reverseModelIdTransforms(modelId, transforms) {
const variants = new Set([modelId]);
for (const transform of transforms ?? []) {
if (transform !== "version-dots") {
continue;
}
for (const variant of Array.from(variants)) {
variants.add(
variant
.replace(/^claude-(\d+)\.(\d+)-/u, "claude-$1-$2-")
.replace(/^claude-([a-z]+)-(\d+)\.(\d+)$/u, "claude-$1-$2-$3"),
);
}
}
return [...variants];
}
function setRuntimePricingSource(entries, key, source, pricing) {
const entry = entries.get(key) ?? {};
entry[source] = pricing;
entries.set(key, entry);
}
function collectPolicyRuntimePricingAliases({ providerId, policy, source, catalog, entries }) {
const rawSourcePolicy = policy?.[source];
const sourceEnabled = Boolean(rawSourcePolicy);
const sourcePolicy = sourceEnabled ? rawSourcePolicy : {};
const sourceProvider = sourcePolicy.provider ?? providerId;
for (const [sourceKey, pricing] of catalog) {
const slash = sourceKey.indexOf("/");
if (slash <= 0 || slash === sourceKey.length - 1) {
continue;
}
const keyProvider = sourceKey.slice(0, slash);
const sourceModel = sourceKey.slice(slash + 1);
if (keyProvider === sourceProvider) {
for (const runtimeModel of reverseModelIdTransforms(
sourceModel,
sourcePolicy.modelIdTransforms,
)) {
setRuntimePricingSource(
entries,
`${providerId}/${runtimeModel}`,
source,
sourceEnabled ? pricing : undefined,
);
}
}
if (sourceEnabled && sourcePolicy.passthroughProviderModel) {
setRuntimePricingSource(entries, `${providerId}/${sourceKey}`, source, pricing);
}
}
}
function materializePolicyRuntimePricing({
hostedPricing,
policies,
openRouterCatalog,
liteLlmCatalog,
pricedProviderModelKeys,
}) {
for (const [providerId, policy] of policies) {
for (const key of hostedPricing.keys()) {
if (key.startsWith(`${providerId}/`)) {
hostedPricing.delete(key);
}
}
if (policy?.external === false) {
continue;
}
const entries = new Map();
collectPolicyRuntimePricingAliases({
providerId,
policy,
source: "openRouter",
catalog: openRouterCatalog,
entries,
});
collectPolicyRuntimePricingAliases({
providerId,
policy,
source: "liteLLM",
catalog: liteLlmCatalog,
entries,
});
for (const [key, entry] of entries) {
if (pricedProviderModelKeys.has(key)) {
hostedPricing.delete(key);
continue;
}
const pricing = mergePricing(entry.openRouter, entry.liteLLM);
if (pricing) {
hostedPricing.set(key, pricing);
} else {
hostedPricing.delete(key);
}
}
}
}
function readPricingPolicies(manifests) {
const policies = new Map();
for (const entry of manifests) {
@@ -360,50 +500,94 @@ export async function enrichModelCatalogPricing(options) {
}
}
const liteLlmCatalog = new Map();
const liteLlmAliasGroups = [];
for (const [id, row] of Object.entries(sources.liteLlm)) {
const pricing = parseLiteLLMPricing(row);
if (pricing) {
liteLlmCatalog.set(id, pricing);
const aliases = [id];
if (typeof row?.litellm_provider === "string" && !id.includes("/")) {
liteLlmCatalog.set(`${row.litellm_provider}/${id}`, pricing);
aliases.push(`${row.litellm_provider}/${id}`);
}
for (const alias of aliases) {
liteLlmCatalog.set(alias, pricing);
}
liteLlmAliasGroups.push(aliases);
}
}
let enriched = 0;
const coveredPricingKeys = new Set();
const pricedProviderModelKeys = new Set();
for (const [providerId, provider] of Object.entries(options.bundle.providers)) {
for (const model of provider.models) {
const openRouterPricing = buildPricingCandidates({
const openRouterCandidates = buildPricingCandidates({
providerId,
modelId: model.id,
source: "openRouter",
policies,
})
});
const openRouterPricing = openRouterCandidates
.map((candidate) => openRouterCatalog.get(candidate))
.find(Boolean);
const liteLlmPricing = buildPricingCandidates({
const liteLlmCandidates = buildPricingCandidates({
providerId,
modelId: model.id,
source: "liteLLM",
policies,
})
});
const liteLlmPricing = liteLlmCandidates
.map((candidate) => liteLlmCatalog.get(candidate))
.find(Boolean);
const cost = openRouterPricing
? {
...openRouterPricing,
...(liteLlmPricing?.tieredPricing
? { tieredPricing: liteLlmPricing.tieredPricing }
: {}),
}
: liteLlmPricing;
if (cost) {
const cost = mergePricing(openRouterPricing, liteLlmPricing);
if (cost && hasKnownPricing(cost)) {
model.cost = cost;
enriched += 1;
}
if (model.cost && hasKnownPricing(model.cost)) {
const providerModelKey = `${providerId}/${model.id}`;
coveredPricingKeys.add(providerModelKey);
pricedProviderModelKeys.add(providerModelKey);
for (const candidate of [...openRouterCandidates, ...liteLlmCandidates]) {
coveredPricingKeys.add(candidate);
}
}
}
}
return enriched;
const hostedPricing = new Map();
for (const [key, pricing] of openRouterCatalog) {
hostedPricing.set(key, pricing);
}
for (const [key, pricing] of liteLlmCatalog) {
hostedPricing.set(key, mergePricing(hostedPricing.get(key), pricing));
}
for (const aliases of liteLlmAliasGroups) {
if (aliases.some((alias) => coveredPricingKeys.has(alias))) {
for (const alias of aliases) {
coveredPricingKeys.add(alias);
}
}
}
for (const key of coveredPricingKeys) {
hostedPricing.delete(key);
}
materializePolicyRuntimePricing({
hostedPricing,
policies,
openRouterCatalog,
liteLlmCatalog,
pricedProviderModelKeys,
});
options.bundle.pricing = Object.fromEntries(
[...hostedPricing.entries()]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, pricing]) => [key, compactPricing(pricing)]),
);
const validateBundle = options.validateBundle ?? (await loadClientBundleValidator());
const validated = validateBundle(options.bundle);
options.bundle.providers = validated.providers;
options.bundle.pricing = validated.pricing;
return { modelsEnriched: enriched, pricingEntries: hostedPricing.size };
}
function sortCatalogValue(value) {
@@ -454,24 +638,35 @@ async function runPublishModelCatalog(options = {}) {
generatedAt,
sourceCommit,
});
const enriched = args.pricing
const pricingResult = args.pricing
? await enrichModelCatalogPricing({ bundle, manifests, fetchImpl: options.fetchImpl })
: 0;
: { modelsEnriched: 0, pricingEntries: 0 };
const summary = summarizeModelCatalogBundle(bundle);
const stats = `schemaVersion=1 providers=${summary.providers} models=${summary.models} costModels=${summary.costModels} pricingEnriched=${enriched} generatedAt=${bundle.generatedAt} minVersion=${bundle.minVersion} sourceCommit=${bundle.sourceCommit}`;
const serialized = serializeModelCatalogBundle(bundle);
const bundleBytes = Buffer.byteLength(serialized);
if (bundleBytes > BUNDLE_SIZE_WARNING_BYTES) {
process.stderr.write(
`[${SCRIPT_LABEL}] warning: bundle size ${bundleBytes} bytes exceeds ${BUNDLE_SIZE_WARNING_BYTES} bytes\n`,
);
}
if (bundleBytes > CLIENT_BUNDLE_LIMIT_BYTES) {
throw new Error(
`catalog bundle ${bundleBytes} bytes exceeds client limit ${CLIENT_BUNDLE_LIMIT_BYTES} bytes`,
);
}
const stats = `schemaVersion=1 providers=${summary.providers} models=${summary.models} costModels=${summary.costModels} pricingEnriched=${pricingResult.modelsEnriched} pricingEntries=${pricingResult.pricingEntries} bundleBytes=${bundleBytes} generatedAt=${bundle.generatedAt} minVersion=${bundle.minVersion} sourceCommit=${bundle.sourceCommit}`;
if (args.dryRun) {
process.stdout.write(`[${SCRIPT_LABEL}] dry-run ${stats}\n`);
return { bundle, summary, pricingEnriched: enriched, wrote: false };
return { bundle, summary, pricingEnriched: pricingResult.modelsEnriched, wrote: false };
}
const serialized = serializeModelCatalogBundle(bundle);
const outputFile = path.resolve(rootDir, args.out);
if (args.out) {
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
fs.writeFileSync(outputFile, serialized);
}
process.stdout.write(`[${SCRIPT_LABEL}] published ${stats} out=${args.out}\n`);
return { bundle, summary, pricingEnriched: enriched, wrote: true };
return { bundle, summary, pricingEnriched: pricingResult.modelsEnriched, wrote: true };
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+2 -52
View File
@@ -69,9 +69,8 @@ vi.mock("./config-model-validation.js", () => ({
vi.mock("../gateway/config-reload-plan.js", () => ({
buildGatewayReloadPlan: (changedPaths: string[]) => {
const restartReasons = changedPaths.filter(
(changedPath) =>
changedPath.startsWith("models.pricing.") || changedPath.startsWith("plugins.load."),
const restartReasons = changedPaths.filter((changedPath) =>
changedPath.startsWith("plugins.load."),
);
const hotReasons = changedPaths.filter(
(changedPath) =>
@@ -3985,34 +3984,6 @@ describe("config cli", () => {
expectLogExcludes("Restart the gateway to apply.");
});
it("keeps the restart hint for broad models writes that change pricing bootstrap", async () => {
const resolved: OpenClawConfig = {
models: {
pricing: {
enabled: false,
},
providers: {
openai: {
agentRuntime: { id: "node" },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"models",
'{"pricing":{"enabled":true},"providers":{"openai":{"agentRuntime":{"id":"node"}}}}',
"--strict-json",
"--replace",
]);
expectLogIncludes("Updated models. Restart the gateway to apply.");
expectLogExcludes("Change will apply without restarting the gateway.");
});
it("keeps the restart hint for broad plugins writes that change load paths", async () => {
const resolved: OpenClawConfig = {
plugins: {
@@ -4039,27 +4010,6 @@ describe("config cli", () => {
expectLogExcludes("Change will apply without restarting the gateway.");
});
it("keeps the restart hint for broad models unsets that remove pricing bootstrap", async () => {
const resolved: OpenClawConfig = {
models: {
pricing: {
enabled: false,
},
providers: {
openai: {
agentRuntime: { id: "node" },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "unset", "models"]);
expectLogIncludes("Removed models. Restart the gateway to apply.");
expectLogExcludes("Change will apply without restarting the gateway.");
});
it("keeps the restart hint for broad plugins unsets that remove load paths", async () => {
const resolved: OpenClawConfig = {
plugins: {
@@ -10,6 +10,30 @@ import {
LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS,
} from "./legacy-config-migrations.runtime.models.js";
describe("retired model pricing config migration", () => {
const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS.find(
(entry) => entry.id === "models.pricing-retired",
);
it("drops models.pricing while preserving hosted catalog config", () => {
const raw = {
models: {
pricing: { enabled: false },
catalogRefresh: { enabled: true },
},
};
const changes: string[] = [];
expect(migration?.legacyRules?.[0]?.path).toEqual(["models", "pricing"]);
migration?.apply(raw, changes);
expect(raw.models).toEqual({ catalogRefresh: { enabled: true } });
expect(changes).toEqual([
"Removed models.pricing (pricing now ships with the hosted model catalog).",
]);
});
});
describe("model compat catalog ownership migration", () => {
const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS.find(
(entry) => entry.id === "models.providers.*.models.*.compat->provider-catalog",
@@ -41,6 +41,25 @@ const LEGACY_DEFAULT_MODEL_MIGRATION = defineLegacyConfigMigration({
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS = [
LEGACY_DEFAULT_MODEL_MIGRATION,
defineLegacyConfigMigration({
id: "models.pricing-retired",
describe: "Remove the retired client-side model pricing bootstrap toggle",
legacyRules: [
{
path: ["models", "pricing"],
message:
'models.pricing is retired because pricing ships with the hosted catalog; run "openclaw doctor --fix" to remove it.',
},
],
apply: (raw, changes) => {
const models = getRecord(raw.models);
if (!models || !Object.hasOwn(models, "pricing")) {
return;
}
delete models.pricing;
changes.push("Removed models.pricing (pricing now ships with the hosted model catalog).");
},
}),
defineLegacyConfigMigration({
id: "models.providers.*.models.*.compat->provider-catalog",
describe: "Move known-model compatibility capability ownership into provider catalogs",
-54
View File
@@ -458,60 +458,6 @@ describe("gateway-status command", () => {
);
});
it("surfaces degraded model-pricing health as a warning", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
const defaultProbeGateway = probeGateway.getMockImplementation();
try {
probeGateway.mockImplementation(async (opts: { url: string }) => {
const result = defaultProbeGateway
? await defaultProbeGateway(opts)
: await mocks.probeGateway(opts);
return {
...result,
health: {
ok: true,
modelPricing: {
state: "degraded",
detail: "OpenRouter pricing fetch failed: TypeError: fetch failed",
sources: [
{
source: "openrouter",
state: "degraded",
detail: "OpenRouter pricing fetch failed: TypeError: fetch failed",
},
],
},
},
};
});
await runGatewayStatus(runtime, { timeout: "1000", json: true });
} finally {
probeGateway.mockReset();
if (defaultProbeGateway) {
probeGateway.mockImplementation(defaultProbeGateway);
}
}
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
degraded?: boolean;
warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>;
};
expect(parsed.degraded).toBe(false);
const pricingWarnings =
parsed.warnings?.filter((warning) => warning.code === "model_pricing_degraded") ?? [];
expect(pricingWarnings).toHaveLength(2);
expect(pricingWarnings.map((warning) => warning.message)).toEqual([
"Model pricing warning: optional pricing refresh degraded: OpenRouter pricing fetch failed: TypeError: fetch failed",
"Model pricing warning: optional pricing refresh degraded: OpenRouter pricing fetch failed: TypeError: fetch failed",
]);
expect(pricingWarnings.map((warning) => warning.targetIds)).toEqual([
["sshTunnel"],
["configRemote"],
]);
});
it("includes diagnostic next steps when no gateway is reachable or discoverable", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
const defaultProbeGateway = probeGateway.getMockImplementation();
-28
View File
@@ -53,23 +53,6 @@ function hasMultipleReachableGatewayIdentities(reachable: GatewayStatusProbedTar
return new Set(identityKeys).size > 1;
}
function readModelPricingDegradedDetail(health: unknown): string | null {
if (!health || typeof health !== "object") {
return null;
}
const modelPricing = (health as { modelPricing?: unknown }).modelPricing;
if (!modelPricing || typeof modelPricing !== "object") {
return null;
}
const record = modelPricing as { state?: unknown; detail?: unknown };
if (record.state !== "degraded") {
return null;
}
return typeof record.detail === "string" && record.detail.trim()
? record.detail.trim()
: "pricing bootstrap or refresh failed";
}
/** Chooses the reachable target that best represents the user's requested gateway. */
export function pickPrimaryProbedTarget(probed: GatewayStatusProbedTarget[]) {
const reachable = probed.filter((entry) => isProbeReachable(entry.probe));
@@ -159,17 +142,6 @@ export function buildGatewayStatusWarnings(params: {
targetIds: [result.target.id],
});
}
for (const result of reachable) {
const detail = readModelPricingDegradedDetail(result.probe.health);
if (!detail) {
continue;
}
warnings.push({
code: "model_pricing_degraded",
message: `Model pricing warning: optional pricing refresh degraded: ${detail}`,
targetIds: [result.target.id],
});
}
return warnings;
}
-26
View File
@@ -13,7 +13,6 @@ import {
formatContextEngineHealthLine,
formatDeliveryQueueHealthLine,
formatHealthChannelLines,
formatModelPricingHealthLine,
healthCommand,
} from "./health.js";
@@ -426,31 +425,6 @@ describe("healthCommand", () => {
expect(runtime.error).not.toHaveBeenCalled();
});
it("formats degraded model-pricing health as a warning", () => {
const snapshot = createHealthSummary({
channels: {},
channelOrder: [],
channelLabels: {},
});
snapshot.modelPricing = {
state: "degraded",
sources: [
{
source: "openrouter",
state: "degraded",
lastFailureAt: Date.now(),
detail: "OpenRouter pricing fetch failed: TypeError: fetch failed",
},
],
detail: "OpenRouter pricing fetch failed: TypeError: fetch failed",
lastFailureAt: Date.now(),
};
expect(formatModelPricingHealthLine(snapshot)).toBe(
"Model pricing: warning (optional pricing refresh degraded) (OpenRouter pricing fetch failed: TypeError: fetch failed)",
);
});
it("formats per-account probe timings", () => {
const summary = createHealthSummary({
channels: {
-20
View File
@@ -36,8 +36,6 @@ import {
} from "../gateway/channel-health-policy.js";
import type { GatewayHotReloadStatus } from "../gateway/config-reload-status.types.js";
import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { getGatewayModelPricingHealth } from "../gateway/model-pricing-cache-state.js";
import { isGatewayModelPricingEnabled } from "../gateway/model-pricing-config.js";
import type { ChannelRuntimeSnapshot } from "../gateway/server-channel-runtime.types.js";
import { info } from "../globals.js";
import { countFailedDeliveryQueueEntries } from "../infra/delivery-queue-sqlite.js";
@@ -214,19 +212,6 @@ function formatEventLoopHealthLine(summary: HealthSummary): string | null {
}`;
}
/** Formats optional model-pricing cache degradation for text health output. */
export function formatModelPricingHealthLine(summary: HealthSummary): string | null {
const modelPricing = summary.modelPricing;
if (!modelPricing || modelPricing.state === "disabled") {
return null;
}
if (modelPricing.state === "ok") {
return null;
}
const detail = modelPricing.detail ? ` (${modelPricing.detail})` : "";
return `Model pricing: warning (optional pricing refresh degraded)${detail}`;
}
function buildContextEngineHealthSummary(): ContextEngineHealthSummary | undefined {
const quarantined: ContextEngineHealthSummary["quarantined"] = [];
for (const entry of listContextEngineQuarantines()) {
@@ -788,7 +773,6 @@ export async function getHealthSnapshot(params?: {
...(params?.configReloadHotReloadStatus
? { configReload: { hotReloadStatus: params.configReloadHotReloadStatus } }
: {}),
modelPricing: getGatewayModelPricingHealth({ enabled: isGatewayModelPricingEnabled(cfg) }),
channels,
channelOrder,
channelLabels,
@@ -1015,10 +999,6 @@ export async function healthCommand(
if (eventLoopLine) {
runtime.log(styleHealthChannelLine(eventLoopLine, rich));
}
const modelPricingLine = formatModelPricingHealthLine(summary);
if (modelPricingLine) {
runtime.log(styleHealthChannelLine(modelPricingLine, rich));
}
const contextEngineLine = formatContextEngineHealthLine(summary);
if (contextEngineLine) {
runtime.log(styleHealthChannelLine(contextEngineLine, rich));
-5
View File
@@ -1,5 +1,4 @@
// Shared summary types returned by gateway health and rendered by the CLI.
import type { GatewayModelPricingHealth } from "../gateway/model-pricing-cache.types.js";
/** Health snapshot for one configured channel account. */
export type ChannelAccountHealthSummary = {
accountId: string;
@@ -80,9 +79,6 @@ export type DeliveryQueueHealthSummary = {
}>;
};
/** Optional model pricing cache health reported by the gateway. */
type ModelPricingHealthSummary = GatewayModelPricingHealth;
/** Config hot-reload watcher status, present only when a reloader is running. */
type ConfigReloadHealthSummary = {
hotReloadStatus: import("../gateway/config-reload-status.types.js").GatewayHotReloadStatus;
@@ -97,7 +93,6 @@ export type HealthSummary = {
plugins?: PluginHealthSummary;
contextEngines?: ContextEngineHealthSummary;
deliveryQueues?: DeliveryQueueHealthSummary;
modelPricing?: ModelPricingHealthSummary;
configReload?: ConfigReloadHealthSummary;
channels: Record<string, ChannelHealthSummary>;
channelOrder: string[];
-8
View File
@@ -512,14 +512,6 @@ export function buildGatewayStatusJsonPayload(params: {
self: params.gatewaySelf ?? null,
error: params.gatewayProbe?.error ?? null,
authWarning: params.gatewayProbeAuthWarning ?? null,
...(params.gatewayProbe?.health &&
typeof params.gatewayProbe.health === "object" &&
"modelPricing" in params.gatewayProbe.health
? {
// Preserve model pricing when the gateway already returned it; do not synthesize pricing locally.
modelPricing: (params.gatewayProbe.health as { modelPricing?: unknown }).modelPricing,
}
: {}),
};
}
-47
View File
@@ -169,51 +169,4 @@ describe("status-json-payload", () => {
}),
).not.toHaveProperty("securityAudit");
});
it("includes model-pricing health from the gateway probe", () => {
const payload = buildStatusJsonPayload({
summary: { ok: true },
surface: {
cfg: { gateway: {} },
update: {
root: "/tmp/openclaw",
installKind: "package",
packageManager: "npm",
} as never,
tailscaleMode: "off",
gatewayMode: "local",
remoteUrlMissing: false,
gatewayConnection: { url: "ws://127.0.0.1:18789" },
gatewayReachable: true,
gatewayProbe: {
connectLatencyMs: 42,
error: null,
health: {
ok: true,
modelPricing: {
state: "degraded",
detail: "OpenRouter pricing fetch failed: TypeError: fetch failed",
sources: [{ source: "openrouter", state: "degraded" }],
},
},
},
gatewayProbeAuth: null,
gatewaySelf: null,
gatewayProbeAuthWarning: null,
gatewayService: { label: "LaunchAgent", installed: false, loadedText: "not installed" },
nodeService: { label: "node", installed: false, loadedText: "not installed" },
},
osSummary: { platform: "linux" },
memory: null,
memoryPlugin: null,
agents: [],
secretDiagnostics: [],
});
const modelPricing = payload.gateway.modelPricing as
| { state?: string; detail?: string }
| undefined;
expect(modelPricing?.state).toBe("degraded");
expect(modelPricing?.detail).toBe("OpenRouter pricing fetch failed: TypeError: fetch failed");
});
});
-51
View File
@@ -30,46 +30,6 @@ import {
import type { MemoryPluginStatus, MemoryStatusSnapshot } from "./status.scan.shared.js";
import type { StatusSummary } from "./status.types.js";
function readModelPricingHealth(params: {
health?: HealthSummary;
surface: StatusOverviewSurface;
}): HealthSummary["modelPricing"] | undefined {
if (params.health?.modelPricing) {
return params.health.modelPricing;
}
// Fast status can receive model pricing through the gateway probe before deep health is requested.
const probeHealth = params.surface.gatewayProbe?.health;
if (!probeHealth || typeof probeHealth !== "object") {
return undefined;
}
const modelPricing = (probeHealth as { modelPricing?: unknown }).modelPricing;
if (!modelPricing || typeof modelPricing !== "object") {
return undefined;
}
const state = (modelPricing as { state?: unknown }).state;
if (state !== "ok" && state !== "degraded" && state !== "disabled") {
return undefined;
}
return modelPricing as HealthSummary["modelPricing"];
}
function buildModelPricingOverviewValue(params: {
health?: HealthSummary["modelPricing"];
ok: (value: string) => string;
warn: (value: string) => string;
muted: (value: string) => string;
}): string | null {
const health = params.health;
if (!health) {
return null;
}
if (health.state !== "degraded") {
return null;
}
const detail = health.detail ? ` · ${health.detail}` : "";
return params.warn(`warning · optional pricing refresh degraded${detail}`);
}
/** Builds the default `openclaw status` overview rows from scan, health, memory, and session inputs. */
export function buildStatusCommandOverviewRows(
params: {
@@ -159,16 +119,6 @@ export function buildStatusCommandOverviewRows(
ok: params.ok,
warn: params.warn,
});
const modelPricingValue = buildModelPricingOverviewValue({
health: readModelPricingHealth({
health: params.health,
surface: params.surface,
}),
ok: params.ok,
warn: params.warn,
muted: params.muted,
});
return buildStatusOverviewRowsFromSurface({
surface: params.surface,
decorateOk: params.ok,
@@ -179,7 +129,6 @@ export function buildStatusCommandOverviewRows(
updateValue: params.updateValue,
agentsValue,
suffixRows: [
...(modelPricingValue ? [{ Item: "Model pricing", Value: modelPricingValue }] : []),
...(params.updateRestartValue
? [{ Item: "Update restart", Value: params.updateRestartValue }]
: []),
@@ -109,39 +109,6 @@ describe("buildStatusCommandReportData", () => {
expect(result.retainedLostTaskLine).toBe("muted(2 lost tasks retained until cleanupAfter)");
});
it("adds model-pricing degradation from gateway probe health to overview rows", async () => {
const baseParams = createStatusCommandReportDataParams();
const result = await buildStatusCommandReportData(
createStatusCommandReportDataParams({
surface: {
...baseParams.surface,
gatewayProbe: {
connectLatencyMs: 123,
error: null,
health: {
ok: true,
modelPricing: {
state: "degraded",
detail: "OpenRouter pricing fetch failed: TypeError: fetch failed",
sources: [{ source: "openrouter", state: "degraded" }],
},
},
},
},
health: undefined,
}),
);
const modelPricingIndex = result.overviewRows.findIndex((row) => row.Item === "Model pricing");
expect(modelPricingIndex).toBeGreaterThanOrEqual(0);
expect(result.overviewRows[modelPricingIndex]).toStrictEqual({
Item: "Model pricing",
Value:
"warn(warning · optional pricing refresh degraded · OpenRouter pricing fetch failed: TypeError: fetch failed)",
});
expect(result.overviewRows[modelPricingIndex + 1]?.Item).toBe("Memory");
});
it("adds pinned-session model selection lines", async () => {
const baseParams = createStatusCommandReportDataParams();
const result = await buildStatusCommandReportData(
-9
View File
@@ -286,15 +286,6 @@ export function buildStatusHealthRows(params: {
Detail: formatEventLoopHealthDetail(params.health.eventLoop),
});
}
if (params.health.modelPricing?.state === "degraded") {
rows.push({
Item: "Model pricing",
Status: params.warn("WARN"),
Detail: `optional pricing refresh degraded${
params.health.modelPricing.detail ? `: ${params.health.modelPricing.detail}` : ""
}`,
});
}
for (const line of params.formatHealthChannelLines(params.health, { accountMode: "all" })) {
const colon = line.indexOf(":");
if (colon === -1) {
-22
View File
@@ -411,28 +411,6 @@ describe("plugins.slots.contextEngine", () => {
});
});
describe("models.pricing", () => {
it("accepts the model pricing bootstrap toggle", () => {
for (const enabled of [true, false]) {
const result = OpenClawSchema.safeParse({
models: {
pricing: { enabled },
},
});
expect(result.success).toBe(true);
}
});
it("rejects non-boolean model pricing bootstrap values", () => {
const result = OpenClawSchema.safeParse({
models: {
pricing: { enabled: "false" },
},
});
expect(result.success).toBe(false);
});
});
describe("models.catalogRefresh", () => {
it("accepts the refresh toggle and an http(s) override", () => {
expect(
-4
View File
@@ -6,10 +6,6 @@ export const MODEL_FIELD_HELP: Record<string, string> = {
'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.',
"models.providers":
"Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Built-in providers may be tuned with provider-level overlays; custom providers must include baseUrl and models. Use stable provider keys so references from agents and tooling remain portable across environments.",
"models.pricing":
"Controls the optional background model-pricing bootstrap that fetches remote per-token cost catalogs.",
"models.pricing.enabled":
"Enable the background model-pricing bootstrap. Set to false to skip OpenRouter and LiteLLM catalog fetches during Gateway startup; changing this value requires a Gateway restart.",
"models.catalogRefresh":
"Controls background updates to the bundled model catalog. Remote rows can update model metadata but cannot change provider endpoints or headers.",
"models.catalogRefresh.enabled":
-2
View File
@@ -498,8 +498,6 @@ export const FIELD_LABELS: Record<string, string> = {
"acp.runtime.installCommand": "ACP Runtime Install Command",
models: "Models",
"models.mode": "Model Catalog Mode",
"models.pricing": "Model Pricing",
"models.pricing.enabled": "Model Pricing Enabled",
"models.catalogRefresh": "Model Catalog Refresh",
"models.catalogRefresh.enabled": "Model Catalog Refresh Enabled",
"models.catalogRefresh.url": "Model Catalog Refresh URL",
-7
View File
@@ -269,11 +269,6 @@ export type DiscoveryToggleConfig = {
enabled?: boolean;
};
export type ModelPricingConfig = {
/** Enable external or generated pricing enrichment. */
enabled?: boolean;
};
export type ModelCatalogRefreshConfig = {
/** Fetch model catalog updates from the hosted OpenClaw catalog. Default: true. */
enabled?: boolean;
@@ -286,8 +281,6 @@ export type ModelsConfig = {
mode?: "merge" | "replace";
/** Configured provider catalog keyed by provider id. */
providers?: Record<string, ModelProviderConfig>;
/** Pricing enrichment settings. */
pricing?: ModelPricingConfig;
/** Hosted model catalog refresh settings. */
catalogRefresh?: ModelCatalogRefreshConfig;
};
-8
View File
@@ -548,13 +548,6 @@ const ModelProvidersSchema = z
}
});
const ModelPricingConfigSchema = z
.object({
enabled: z.boolean().optional(),
})
.strict()
.optional();
const ModelCatalogRefreshConfigSchema = z
.object({
enabled: z.boolean().optional(),
@@ -586,7 +579,6 @@ export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: ModelProvidersSchema.optional(),
pricing: ModelPricingConfigSchema,
catalogRefresh: ModelCatalogRefreshConfigSchema,
})
.strict()
-4
View File
@@ -111,10 +111,6 @@ const BASE_RELOAD_RULES: ReloadRule[] = [
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "models.pricing",
kind: "restart",
},
{
prefix: "models",
kind: "hot",
-5
View File
@@ -282,11 +282,6 @@ describe("buildGatewayReloadPlan", () => {
restart: true,
reason: "gateway.auth.token",
},
{
path: "models.pricing.enabled",
restart: true,
reason: "models.pricing.enabled",
},
{
path: "agents.defaults.model",
restart: false,
-165
View File
@@ -1,165 +0,0 @@
// Gateway model-pricing cache state.
// Stores normalized pricing rows and source-health failures for runtime reads.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeModelRef } from "../agents/model-selection.js";
import type { GatewayModelPricingHealth } from "./model-pricing-cache.types.js";
export type CachedPricingTier = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** [startTokens, endTokens) — half-open interval on the input token axis. */
range: [number, number];
};
export type CachedModelPricing = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** Optional tiered pricing tiers sourced from LiteLLM or local config. */
tieredPricing?: CachedPricingTier[];
};
type GatewayModelPricingHealthSource = GatewayModelPricingHealth["sources"][number]["source"];
let cachedPricing = new Map<string, CachedModelPricing>();
let cachedAt = 0;
const sourceFailures = new Map<
GatewayModelPricingHealthSource,
{ lastFailureAt: number; detail: string }
>();
function modelPricingCacheKey(provider: string, model: string): string {
// Keys accept both provider/model and provider-prefixed model ids so external
// catalogs can be queried without double-prefixing.
const providerId = normalizeProviderId(provider);
const modelId = model.trim();
if (!providerId || !modelId) {
return "";
}
return normalizeLowercaseStringOrEmpty(modelId).startsWith(
`${normalizeLowercaseStringOrEmpty(providerId)}/`,
)
? modelId
: `${providerId}/${modelId}`;
}
export function replaceGatewayModelPricingCache(
nextPricing: Map<string, CachedModelPricing>,
nextCachedAt = Date.now(),
): void {
cachedPricing = nextPricing;
cachedAt = nextCachedAt;
}
export function recordGatewayModelPricingSourceFailure(
source: GatewayModelPricingHealthSource,
detail: string,
failedAt = Date.now(),
): void {
sourceFailures.set(source, {
lastFailureAt: failedAt,
detail,
});
}
export function clearGatewayModelPricingSourceFailure(
source: GatewayModelPricingHealthSource,
): void {
sourceFailures.delete(source);
}
export function clearGatewayModelPricingFailures(): void {
sourceFailures.clear();
}
export function getGatewayModelPricingHealth(params?: {
enabled?: boolean;
}): GatewayModelPricingHealth {
if (params?.enabled === false) {
return {
state: "disabled",
sources: [],
};
}
const sources: GatewayModelPricingHealth["sources"] = Array.from(sourceFailures.entries())
.map(([source, failure]) => ({
source,
state: "degraded" as const,
lastFailureAt: failure.lastFailureAt,
detail: failure.detail,
}))
.toSorted((left, right) => left.source.localeCompare(right.source));
const latest = sources.reduce<(typeof sources)[number] | undefined>((current, source) => {
if (!current || (source.lastFailureAt ?? 0) > (current.lastFailureAt ?? 0)) {
return source;
}
return current;
}, undefined);
return {
state: sources.length > 0 ? "degraded" : "ok",
sources,
...(latest?.lastFailureAt ? { lastFailureAt: latest.lastFailureAt } : {}),
...(latest?.detail ? { detail: latest.detail } : {}),
};
}
export function getCachedGatewayModelPricing(params: {
provider?: string;
model?: string;
}): CachedModelPricing | undefined {
const provider = params.provider?.trim();
const model = params.model?.trim();
if (!provider || !model) {
return undefined;
}
const key = modelPricingCacheKey(provider, model);
const direct = key ? cachedPricing.get(key) : undefined;
if (direct) {
return direct;
}
const normalized = normalizeModelRef(provider, model);
const normalizedKey = modelPricingCacheKey(normalized.provider, normalized.model);
if (normalizedKey === key) {
return undefined;
}
return normalizedKey ? cachedPricing.get(normalizedKey) : undefined;
}
export function getGatewayModelPricingCacheMeta(): {
cachedAt: number;
ttlMs: number;
size: number;
} {
return {
cachedAt,
ttlMs: 0,
size: cachedPricing.size,
};
}
function stablePricingValue(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? JSON.stringify(value) : JSON.stringify(String(value));
}
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stablePricingValue(entry)).join(",")}]`;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.filter((key) => record[key] !== undefined)
.toSorted()
.map((key) => `${JSON.stringify(key)}:${stablePricingValue(record[key])}`)
.join(",")}}`;
}
export function getGatewayModelPricingCacheFingerprint(): string {
const entries = Array.from(cachedPricing.entries()).toSorted(([a], [b]) => a.localeCompare(b));
return stablePricingValue(entries);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
/** Health of the gateway model-pricing sources exposed through health summaries. */
export type GatewayModelPricingHealth = {
state: "ok" | "degraded" | "disabled";
sources: Array<{
source: "openrouter" | "litellm" | "bootstrap" | "refresh";
state: "ok" | "degraded";
lastFailureAt?: number;
detail?: string;
}>;
lastFailureAt?: number;
detail?: string;
};
-8
View File
@@ -1,8 +0,0 @@
// Gateway model-pricing config helper.
// Resolves whether cost/pricing metadata should be available to Gateway surfaces.
import type { OpenClawConfig } from "../config/types.openclaw.js";
/** Returns whether gateway model pricing/cost metadata should be shown. */
export function isGatewayModelPricingEnabled(config: OpenClawConfig): boolean {
return config.models?.pricing?.enabled !== false;
}
-2
View File
@@ -617,7 +617,6 @@ export async function runGatewayClosePrelude(params: {
skillsChangeUnsub?: () => void;
disposeAuthRateLimiter?: () => void;
disposeBrowserAuthRateLimiter: () => void;
stopModelPricingRefresh?: () => void;
stopChannelHealthMonitor?: () => Promise<void>;
stopReadinessEventLoopHealth?: () => void;
clearSecretsRuntimeSnapshot?: () => void;
@@ -628,7 +627,6 @@ export async function runGatewayClosePrelude(params: {
params.skillsChangeUnsub?.();
params.disposeAuthRateLimiter?.();
params.disposeBrowserAuthRateLimiter();
params.stopModelPricingRefresh?.();
await params.stopChannelHealthMonitor?.();
params.stopReadinessEventLoopHealth?.();
params.clearSecretsRuntimeSnapshot?.();
-1
View File
@@ -364,7 +364,6 @@ export async function prepareGatewayLifecycle(params: {
nodeReapprovalCoordinator.dispose();
},
disposeBrowserAuthRateLimiter: () => browserAuthRateLimiter.dispose(),
stopModelPricingRefresh: runtimeState.stopModelPricingRefresh,
stopChannelHealthMonitor: async () => {
const monitor = runtimeState?.channelHealthMonitor;
monitor?.shutdown();
-4
View File
@@ -7,7 +7,6 @@ import type { ChannelHealthSummary, HealthSummary } from "../../commands/health.
import { getStatusSummary } from "../../commands/status.js";
import { listContextEngineQuarantines } from "../../context-engine/registry.js";
import type { GatewayHotReloadStatus } from "../config-reload-status.types.js";
import { getGatewayModelPricingHealth } from "../model-pricing-cache-state.js";
import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js";
import { HEALTH_REFRESH_INTERVAL_MS } from "../server-constants.js";
import { formatError } from "../server-utils.js";
@@ -127,9 +126,6 @@ function mergeCachedHealthRuntimeState(params: {
...(params.configReloadHotReloadStatus
? { configReload: { hotReloadStatus: params.configReloadHotReloadStatus } }
: {}),
modelPricing: getGatewayModelPricingHealth({
enabled: params.cached.modelPricing?.state !== "disabled",
}),
};
}
@@ -5028,25 +5028,19 @@ describe("gateway healthHandlers.status scope handling", () => {
describe("gateway healthHandlers.health cache freshness", () => {
let healthHandlers: typeof import("./health.js").healthHandlers;
let pricingState: typeof import("../model-pricing-cache-state.js");
const contextEngineTestOwner = "plugin:health-test";
beforeAll(async () => {
({ healthHandlers } = await import("./health.js"));
pricingState = await import("../model-pricing-cache-state.js");
});
beforeEach(() => {
pricingState.replaceGatewayModelPricingCache(new Map(), 0);
pricingState.clearGatewayModelPricingFailures();
registerLegacyContextEngine();
clearContextEnginesForOwner(contextEngineTestOwner);
resetContextEngineRuntimeQuarantineForTests();
});
afterEach(() => {
pricingState.replaceGatewayModelPricingCache(new Map(), 0);
pricingState.clearGatewayModelPricingFailures();
clearContextEnginesForOwner(contextEngineTestOwner);
resetContextEngineRuntimeQuarantineForTests();
});
@@ -5198,69 +5192,6 @@ describe("gateway healthHandlers.health cache freshness", () => {
expect(mockCallArg(respond, 0, 2)).toBeUndefined();
});
it("merges live model-pricing state into cached health responses", async () => {
const cached = {
ok: true,
ts: Date.now(),
durationMs: 1,
channels: {},
channelOrder: [],
channelLabels: {},
heartbeatSeconds: 0,
defaultAgentId: "main",
agents: [],
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
modelPricing: { state: "ok", sources: [] },
};
pricingState.recordGatewayModelPricingSourceFailure(
"openrouter",
"OpenRouter pricing fetch failed: TypeError: fetch failed",
123,
);
const respond = vi.fn();
const refreshHealthSnapshot = vi.fn().mockResolvedValue(cached);
await expectDefined(healthHandlers.health, "healthHandlers.health test invariant").call(
healthHandlers,
{
req: {} as never,
params: {} as never,
respond: respond as never,
context: {
getHealthCache: () => cached,
refreshHealthSnapshot,
getRuntimeSnapshot: () => ({ channels: {}, channelAccounts: {} }),
logHealth: { error: vi.fn() },
} as never,
client: { connect: { role: "operator", scopes: ["operator.read"] } } as never,
isWebchatConnect: () => false,
},
);
const payload = mockCallArg(respond, 0, 1) as
| {
modelPricing?: {
state?: string;
detail?: string;
sources?: Array<{ source?: string; state?: string; lastFailureAt?: number }>;
};
}
| undefined;
expect(payload?.modelPricing?.state).toBe("degraded");
expect(payload?.modelPricing?.detail).toBe(
"OpenRouter pricing fetch failed: TypeError: fetch failed",
);
expect(payload?.modelPricing?.sources).toHaveLength(1);
expect(payload?.modelPricing?.sources?.[0]?.source).toBe("openrouter");
expect(payload?.modelPricing?.sources?.[0]?.state).toBe("degraded");
expect(payload?.modelPricing?.sources?.[0]?.lastFailureAt).toBe(123);
expect(mockCallArg(respond, 0, 3)).toEqual({ cached: true });
expect(refreshHealthSnapshot).toHaveBeenCalledWith({
probe: false,
includeSensitive: false,
});
});
it("merges live context-engine quarantine state into cached health responses", async () => {
const engineId = `health-context-engine-${Date.now()}`;
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
-2
View File
@@ -35,7 +35,6 @@ export type GatewayServerMutableState = {
skillsRefreshDelayMs: number;
skillsChangeUnsub: () => void;
channelHealthMonitor: ChannelHealthMonitor | null;
stopModelPricingRefresh: () => void;
mcpServer: { port: number; close: () => Promise<void> } | undefined;
configReloader: GatewayConfigReloaderHandle;
agentUnsub: (() => Promise<void> | void) | null;
@@ -74,7 +73,6 @@ export function createGatewayServerMutableState(): GatewayServerMutableState {
skillsRefreshDelayMs: 30_000,
skillsChangeUnsub: () => {},
channelHealthMonitor: null as ChannelHealthMonitor | null,
stopModelPricingRefresh: () => {},
mcpServer: undefined as { port: number; close: () => Promise<void> } | undefined,
configReloader: {
stop: async () => {},
+1 -73
View File
@@ -23,7 +23,6 @@ const hoisted = vi.hoisted(() => {
stop: vi.fn(),
updateConfig: vi.fn(),
};
const stopModelPricingRefresh = vi.fn();
const stopSessionUpstreamMonitor = vi.fn();
const stopSessionDeliveryRuntime = vi.fn();
return {
@@ -34,7 +33,6 @@ const hoisted = vi.hoisted(() => {
shutdown: vi.fn(),
waitForIdle: vi.fn(async () => {}),
})),
stopModelPricingRefresh,
stopSessionUpstreamMonitor,
stopSessionDeliveryRuntime,
startSessionDeliveryRuntime: vi.fn<StartSessionDeliveryRuntime>(
@@ -42,9 +40,6 @@ const hoisted = vi.hoisted(() => {
),
schedulePendingSessionDeliveries: vi.fn(async () => undefined),
startSessionUpstreamMonitor: vi.fn(() => ({ stop: stopSessionUpstreamMonitor })),
startGatewayModelPricingRefresh: vi.fn(() => stopModelPricingRefresh),
loadModelPricingCacheModule: vi.fn(),
isVitestRuntimeEnv: vi.fn(() => false),
recoverPendingDeliveries: vi.fn(async () => undefined),
recoverPendingRestartContinuationDeliveries: vi.fn(async () => undefined),
deliverQueuedSessionDelivery: vi.fn(async () => undefined),
@@ -67,7 +62,6 @@ vi.mock("../sessions/session-upstream-monitor.js", () => ({
vi.mock("../infra/env.js", () => ({
isTruthyEnvValue: (value?: string) =>
["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""),
isVitestRuntimeEnv: hoisted.isVitestRuntimeEnv,
}));
vi.mock("../infra/outbound/deliver.js", () => ({
@@ -97,14 +91,6 @@ vi.mock("./channel-health-monitor.js", () => ({
startChannelHealthMonitor: hoisted.startChannelHealthMonitor,
}));
vi.mock("./model-pricing-cache.js", () => ({
...(() => {
hoisted.loadModelPricingCacheModule();
return {};
})(),
startGatewayModelPricingRefresh: hoisted.startGatewayModelPricingRefresh,
}));
const {
activateGatewayScheduledServices,
runGatewayPostReadyMaintenance,
@@ -127,15 +113,11 @@ describe("server-runtime-services", () => {
hoisted.heartbeatRunner.updateConfig.mockClear();
hoisted.startHeartbeatRunner.mockClear();
hoisted.startChannelHealthMonitor.mockClear();
hoisted.startGatewayModelPricingRefresh.mockClear();
hoisted.stopModelPricingRefresh.mockClear();
hoisted.startSessionUpstreamMonitor.mockClear();
hoisted.stopSessionUpstreamMonitor.mockClear();
hoisted.stopSessionDeliveryRuntime.mockClear();
hoisted.startSessionDeliveryRuntime.mockClear();
hoisted.schedulePendingSessionDeliveries.mockClear();
hoisted.loadModelPricingCacheModule.mockClear();
hoisted.isVitestRuntimeEnv.mockReset().mockReturnValue(false);
hoisted.recoverPendingDeliveries.mockClear();
hoisted.recoverPendingRestartContinuationDeliveries.mockClear();
hoisted.deliverQueuedSessionDelivery.mockClear();
@@ -149,25 +131,7 @@ describe("server-runtime-services", () => {
resetGatewayWorkAdmission();
});
it("skips model pricing bootstrap import when pricing is disabled", async () => {
activateGatewayScheduledServices({
minimalTestGateway: false,
cfgAtStart: { models: { pricing: { enabled: false } } } as never,
deps: {} as never,
sessionDeliveryRecoveryMaxEnqueuedAt: 123,
cronState: createTestCronState(),
cronReconciliation: createTestCronReconciliation(),
logCron: { error: vi.fn() },
log: createLog(),
});
await vi.dynamicImportSettled();
expect(hoisted.loadModelPricingCacheModule).not.toHaveBeenCalled();
expect(hoisted.startGatewayModelPricingRefresh).not.toHaveBeenCalled();
});
it("keeps scheduled services and pricing refresh inert during initial runtime setup", async () => {
it("keeps scheduled services inert during initial runtime setup", () => {
const services = startGatewayRuntimeServices({
minimalTestGateway: false,
cfgAtStart: {} as never,
@@ -180,8 +144,6 @@ describe("server-runtime-services", () => {
});
expect(hoisted.startChannelHealthMonitor).toHaveBeenCalledTimes(1);
expect(hoisted.loadModelPricingCacheModule).not.toHaveBeenCalled();
expect(hoisted.startGatewayModelPricingRefresh).not.toHaveBeenCalled();
expect(hoisted.startHeartbeatRunner).not.toHaveBeenCalled();
expect(hoisted.startSessionUpstreamMonitor).not.toHaveBeenCalled();
expect(hoisted.recoverPendingDeliveries).not.toHaveBeenCalled();
@@ -204,30 +166,6 @@ describe("server-runtime-services", () => {
},
);
it("starts model pricing refresh after scheduled services activate", async () => {
const pluginLookUpTable = {
index: { plugins: [] },
manifestRegistry: { plugins: [], diagnostics: [] },
};
const { cronStart, services } = activateScheduledServicesForTest({
pluginLookUpTable: pluginLookUpTable as never,
});
expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1);
expect(hoisted.startSessionUpstreamMonitor).toHaveBeenCalledTimes(1);
expect(cronStart).toHaveBeenCalledTimes(1);
await vi.dynamicImportSettled();
expect(hoisted.startGatewayModelPricingRefresh).toHaveBeenCalledWith({
config: {},
pluginLookUpTable,
});
services.stopModelPricingRefresh();
expect(hoisted.stopModelPricingRefresh).toHaveBeenCalledTimes(1);
services.heartbeatRunner.stop();
expect(hoisted.stopSessionUpstreamMonitor).toHaveBeenCalledTimes(1);
expect(hoisted.heartbeatRunner.stop).toHaveBeenCalledTimes(1);
});
it("warns when cron is disabled but scheduled heartbeats remain enabled", () => {
const warn = vi.fn();
const log = {
@@ -382,16 +320,6 @@ describe("server-runtime-services", () => {
await waitForFast(() => expect(getActiveGatewayRootWorkCount()).toBe(0));
});
it("does not start model pricing refresh after scheduled services stop before import settles", async () => {
const { services } = activateScheduledServicesForTest();
services.stopModelPricingRefresh();
await vi.dynamicImportSettled();
expect(hoisted.startGatewayModelPricingRefresh).not.toHaveBeenCalled();
expect(hoisted.stopModelPricingRefresh).not.toHaveBeenCalled();
});
it("activates heartbeat, cron, and delivery recovery after sidecars are ready", async () => {
vi.useFakeTimers();
const log = createLog();
+1 -49
View File
@@ -2,7 +2,6 @@
// Starts delayed maintenance, cron, heartbeat, recovery, and pricing refresh work.
import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isVitestRuntimeEnv } from "../infra/env.js";
import {
resolveHeartbeatAgents,
startHeartbeatRunner,
@@ -13,11 +12,9 @@ import {
schedulePendingSessionDeliveries,
startSessionDeliveryRuntime,
} from "../infra/session-delivery-queue-runtime.js";
import type { PluginMetadataRegistryView } from "../plugins/plugin-metadata-snapshot.types.js";
import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js";
import { startSessionUpstreamMonitor } from "../sessions/session-upstream-monitor.js";
import { removeCronRunContinuationSessionIfIdle } from "../tasks/cron-run-continuation-cleanup.js";
import { isGatewayModelPricingEnabled } from "./model-pricing-config.js";
import type { GatewayCronReconciliation } from "./server-cron-reconciled.js";
import type { GatewayCronState } from "./server-cron.js";
import type { startGatewayMaintenanceTimers } from "./server-maintenance.js";
@@ -305,41 +302,6 @@ function startPendingSessionDeliveryRuntime(params: {
};
}
function startGatewayModelPricingRefreshOnDemand(params: {
config: OpenClawConfig;
pluginLookUpTable?: PluginMetadataRegistryView;
log: GatewayRuntimeServiceLogger;
}): () => void {
if (!isGatewayModelPricingEnabled(params.config)) {
return () => {};
}
let stopped = false;
let stopRefresh: (() => void) | undefined;
// Import pricing refresh lazily; many gateway starts never use model-pricing metadata.
// The stopped flag closes the race where shutdown happens before the import resolves.
void runWithGatewayIndependentRootWorkAdmission(async () => {
const { startGatewayModelPricingRefresh } = await import("./model-pricing-cache.js");
if (stopped) {
return;
}
stopRefresh = startGatewayModelPricingRefresh({
config: params.config,
...(params.pluginLookUpTable ? { pluginLookUpTable: params.pluginLookUpTable } : {}),
});
if (stopped) {
stopRefresh();
stopRefresh = undefined;
}
}).catch((err: unknown) =>
params.log.error(`Model pricing refresh failed to start: ${String(err)}`),
);
return () => {
stopped = true;
stopRefresh?.();
stopRefresh = undefined;
};
}
/** Activates background gateway services after core runtime startup is ready. */
export function activateGatewayScheduledServices(params: {
minimalTestGateway: boolean;
@@ -351,14 +313,12 @@ export function activateGatewayScheduledServices(params: {
startCron?: boolean;
logCron: { error: (message: string) => void };
log: GatewayRuntimeServiceLogger;
pluginLookUpTable?: PluginMetadataRegistryView;
}): { heartbeatRunner: HeartbeatRunner; stopModelPricingRefresh: () => void } {
}): { heartbeatRunner: HeartbeatRunner } {
if (params.minimalTestGateway) {
// Minimal gateways keep handles callable but inert so tests can share shutdown paths with
// production starts without launching background loops.
return {
heartbeatRunner: createNoopHeartbeatRunner(),
stopModelPricingRefresh: () => {},
};
}
if (
@@ -404,15 +364,7 @@ export function activateGatewayScheduledServices(params: {
cfg: params.cfgAtStart,
log: params.log,
});
const stopModelPricingRefresh = !isVitestRuntimeEnv()
? startGatewayModelPricingRefreshOnDemand({
config: params.cfgAtStart,
...(params.pluginLookUpTable ? { pluginLookUpTable: params.pluginLookUpTable } : {}),
log: params.log,
})
: () => {};
return {
heartbeatRunner: heartbeatRunnerWithUpstreamMonitor,
stopModelPricingRefresh,
};
}
@@ -45,7 +45,6 @@ export function startGatewayRuntimeServices(params: {
}): {
heartbeatRunner: ReturnType<typeof createNoopHeartbeatRunner>;
channelHealthMonitor: ChannelHealthMonitor | null;
stopModelPricingRefresh: () => void;
} {
const channelHealthMonitor = startGatewayChannelHealthMonitor({
cfg: params.cfgAtStart,
@@ -55,6 +54,5 @@ export function startGatewayRuntimeServices(params: {
return {
heartbeatRunner: createNoopHeartbeatRunner(),
channelHealthMonitor,
stopModelPricingRefresh: () => {},
};
}
-2
View File
@@ -411,10 +411,8 @@ export async function finishGatewayStartup(params: {
startCron: false,
logCron,
log,
pluginLookUpTable,
});
runtimeState.heartbeatRunner = activated.heartbeatRunner;
runtimeState.stopModelPricingRefresh = activated.stopModelPricingRefresh;
});
};
({
+17 -31
View File
@@ -10,10 +10,6 @@ import {
persistSessionTranscriptTurn,
upsertSessionEntry,
} from "../config/sessions/session-accessor.js";
import {
clearGatewayModelPricingFailures,
replaceGatewayModelPricingCache,
} from "../gateway/model-pricing-cache-state.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { withEnvAsync } from "../test-utils/env.js";
import * as usageFormat from "../utils/usage-format.js";
@@ -85,11 +81,6 @@ async function refreshSessionCostUsageForTest(sessionFile: string): Promise<void
});
}
function clearGatewayModelPricingState(): void {
replaceGatewayModelPricingCache(new Map(), 0);
clearGatewayModelPricingFailures();
}
describe("session cost usage", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-session-cost-" });
const withStateDir = async <T>(stateDir: string, fn: () => Promise<T>): Promise<T> =>
@@ -552,16 +543,14 @@ describe("session cost usage", () => {
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// A real assistant turn that burned tokens. The transport recorded cost.total: 0,
// derived from an all-zero catalog price — exactly what codex/gpt-5.x models produce,
// since the Codex backend exposes no per-token price and the operator never set one.
// A real assistant turn that burned tokens for a model absent from every pricing source.
const entry = {
type: "message",
timestamp: new Date().toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.5",
provider: "custom",
model: "unpriced-model",
usage: {
input: 881,
output: 6,
@@ -581,7 +570,6 @@ describe("session cost usage", () => {
// No operator-configured pricing for this model, so its all-zero cost is unknown,
// not an intentional "free" price.
clearGatewayModelPricingState();
await withStateDir(root, async () => {
const summary = await loadCostUsageSummary();
expect(summary.totals.totalTokens).toBe(23287);
@@ -605,8 +593,8 @@ describe("session cost usage", () => {
timestamp: new Date().toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.5",
provider: "custom",
model: "unpriced-model",
usage: {
input: 881,
output: 6,
@@ -629,10 +617,10 @@ describe("session cost usage", () => {
const config = {
models: {
providers: {
openai: {
custom: {
models: [
{
id: "gpt-5.5",
id: "unpriced-model",
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
@@ -641,7 +629,6 @@ describe("session cost usage", () => {
},
} as unknown as OpenClawConfig;
clearGatewayModelPricingState();
await withStateDir(root, async () => {
const summary = await loadCostUsageSummary({ config });
expect(summary.totals.totalTokens).toBe(23287);
@@ -657,9 +644,9 @@ describe("session cost usage", () => {
const sessionFile = path.join(sessionsDir, "sess-missing-by-model.jsonl");
const timestamp = Date.now() - 1_000;
const entries = [
["openai", "gpt-5.6-sol"],
["openai", "gpt-5.6-sol"],
["openai-codex", "gpt-5.5"],
["custom", "unpriced-a"],
["custom", "unpriced-a"],
["other", "unpriced-b"],
].map(([provider, model], index) => ({
type: "message",
timestamp: new Date(timestamp + index).toISOString(),
@@ -681,13 +668,12 @@ describe("session cost usage", () => {
"utf-8",
);
clearGatewayModelPricingState();
await withStateDir(root, async () => {
const summary = await loadCostUsageSummary();
expect(summary.totals.missingCostEntries).toBe(3);
expect(summary.totals.missingCostByModel).toEqual({
"openai/gpt-5.6-sol": 2,
"openai-codex/gpt-5.5": 1,
"custom/unpriced-a": 2,
"other/unpriced-b": 1,
});
const sessionSummary = await loadSessionCostSummary({ sessionFile });
@@ -1631,8 +1617,8 @@ describe("session cost usage", () => {
timestamp: `2026-02-05T12:0${index}:00.000Z`,
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.5",
provider: "custom",
model: "unpriced-batch",
usage: { input: index + 1, output: 0, totalTokens: index + 1 },
},
}),
@@ -1649,7 +1635,7 @@ describe("session cost usage", () => {
refreshMode: "sync-when-empty",
});
expect(warmed.cacheStatus?.status).toBe("fresh");
expect(warmed.totals.missingCostByModel).toEqual({ "openai/gpt-5.5": 2 });
expect(warmed.totals.missingCostByModel).toEqual({ "custom/unpriced-batch": 2 });
await loadSessionCostSummariesFromCache({
sessions,
@@ -1664,8 +1650,8 @@ describe("session cost usage", () => {
});
expect(cached.cacheStatus.status).toBe("fresh");
expect(cached.summaries.map((summary) => summary?.missingCostByModel)).toEqual([
{ "openai/gpt-5.5": 1 },
{ "openai/gpt-5.5": 1 },
{ "custom/unpriced-batch": 1 },
{ "custom/unpriced-batch": 1 },
]);
},
{ interval: 10, timeout: 2_000 },
+234
View File
@@ -0,0 +1,234 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveModelCostConfig,
resolveModelCostConfigFingerprint,
} from "../utils/usage-format.js";
import {
resetRemoteModelCatalogOverlayForTest,
setRemoteModelCatalogOverlaySourcesForTest,
} from "./remote-overlay.test-support.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const readStoredCatalog = vi.fn();
beforeEach(() => {
resetRemoteModelCatalogOverlayForTest();
readStoredCatalog.mockReset().mockReturnValue({
source_url: "https://catalog.openclaw.ai/models/v1/catalog.json",
bundle_json: JSON.stringify({
schemaVersion: 1,
generatedAt: 200,
minVersion: "2026.7.0",
sourceCommit: "pricing-test",
providers: {
openai: {
models: [
{ id: "gpt-catalog", cost: { input: 1, output: 2 } },
{
id: "gpt-zero-tier",
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
tieredPricing: [{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, range: [0] }],
},
},
],
},
},
pricing: {
"openai/gpt-external": { input: 2.5, output: 10, cacheRead: 1.25 },
"openai/gpt-zero-hosted": {
input: 0,
output: 0,
tieredPricing: [{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, range: [0] }],
},
"openai/gpt-zero-tier": { input: 4, output: 8 },
"openrouter/openai/gpt-catalog": { input: 1, output: 2 },
"z-ai/forbidden": { input: 9, output: 18 },
},
}),
});
setRemoteModelCatalogOverlaySourcesForTest({
bundledGeneratedAt: () => 100,
readStoredCatalog,
});
});
afterEach(() => {
setRemoteModelCatalogOverlaySourcesForTest();
resetRemoteModelCatalogOverlayForTest();
});
function configFor(baseUrl: string): OpenClawConfig {
return {
models: {
providers: {
openai: {
baseUrl,
models: [{ id: "gpt-external", name: "External GPT" }],
},
},
},
} as unknown as OpenClawConfig;
}
describe("hosted model pricing", () => {
it("resolves a non-catalog model from the stored hosted pricing map", () => {
const agentDir = tempDirs.make("openclaw-hosted-pricing-");
expect(
resolveModelCostConfig({
config: configFor("https://api.openai.com/v1"),
agentDir,
provider: "openai",
model: "gpt-external",
}),
).toEqual({ input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 });
});
it("prefers merged catalog pricing over configured pricing", () => {
const agentDir = tempDirs.make("openclaw-catalog-pricing-");
const config = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [
{
id: "gpt-catalog",
name: "Catalog GPT",
cost: { input: 99, output: 99, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
},
} as unknown as OpenClawConfig;
expect(
resolveModelCostConfig({ config, agentDir, provider: "openai", model: "gpt-catalog" }),
).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0 });
});
it("does not apply hosted pricing to private endpoints or unknown models", () => {
const agentDir = tempDirs.make("openclaw-private-pricing-");
expect(
resolveModelCostConfig({
config: configFor("http://127.0.0.1:8080/v1"),
agentDir,
provider: "openai",
model: "gpt-external",
}),
).toBeUndefined();
expect(resolveModelCostConfigFingerprint(configFor("https://api.openai.com/v1"))).not.toBe(
resolveModelCostConfigFingerprint(configFor("http://127.0.0.1:8080/v1")),
);
expect(
resolveModelCostConfig({
config: configFor("https://fc-proxy.example.com/v1"),
agentDir,
provider: "openai",
model: "gpt-external",
}),
).toEqual({ input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 });
expect(
resolveModelCostConfig({
config: configFor("http://127.0.0.1:8080/v1"),
agentDir,
provider: "openai",
model: "gpt-catalog",
}),
).toBeUndefined();
expect(
resolveModelCostConfig({
config: configFor("https://api.openai.com/v1"),
agentDir,
provider: "openai",
model: "unknown-model",
}),
).toBeUndefined();
expect(
resolveModelCostConfig({
config: configFor("https://api.openai.com/v1"),
agentDir,
provider: "openai",
model: "gpt-zero-hosted",
}),
).toBeUndefined();
const disabled = configFor("https://api.openai.com/v1");
disabled.models = {
...disabled.models,
catalogRefresh: { enabled: false },
};
expect(
resolveModelCostConfig({
config: disabled,
agentDir,
provider: "openai",
model: "gpt-external",
}),
).toBeUndefined();
});
it("resolves passthrough provider aliases through a priced catalog row", () => {
const agentDir = tempDirs.make("openclaw-passthrough-pricing-");
const config = {
models: {
providers: {
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
models: [{ id: "openai/gpt-catalog", name: "Catalog GPT through OpenRouter" }],
},
},
},
} as unknown as OpenClawConfig;
expect(
resolveModelCostConfig({
config,
agentDir,
provider: "openrouter",
model: "openai/gpt-catalog",
}),
).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0 });
});
it("falls through zero-only catalog tiers without reviving disabled source aliases", () => {
const agentDir = tempDirs.make("openclaw-zero-tier-pricing-");
expect(
resolveModelCostConfig({
config: configFor("https://api.openai.com/v1"),
agentDir,
provider: "openai",
model: "gpt-zero-tier",
}),
).toEqual({ input: 4, output: 8, cacheRead: 0, cacheWrite: 0 });
const zaiConfig = {
models: {
providers: {
zai: {
baseUrl: "https://api.z.ai/api/paas/v4",
models: [{ id: "forbidden", name: "Forbidden source alias" }],
},
},
},
} as unknown as OpenClawConfig;
expect(
resolveModelCostConfig({
config: zaiConfig,
agentDir,
provider: "zai",
model: "forbidden",
}),
).toBeUndefined();
});
it("fingerprints provider overlays without explicit model rows", () => {
const config = {
models: { providers: { openai: { baseUrl: "https://api.openai.com/v1" } } },
} as unknown as OpenClawConfig;
expect(() => resolveModelCostConfigFingerprint(config)).not.toThrow();
});
});
+267
View File
@@ -0,0 +1,267 @@
import { isIP } from "node:net";
import type { RemoteModelCatalogPricing } from "@openclaw/model-catalog-core";
import type { ModelCatalogCost } from "@openclaw/model-catalog-core/model-catalog-types";
import { modelKey, normalizeModelRef } from "../agents/model-selection.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isInstalledPluginEnabled } from "../plugins/installed-plugin-index.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import {
resolvePluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "../plugins/plugin-metadata-snapshot.js";
import { planEffectiveModelCatalogRows } from "./index.js";
import { getRemoteModelCatalogPricing } from "./remote-overlay.js";
type PricingValue = RemoteModelCatalogPricing | ModelCatalogCost;
type ManifestPlugins = readonly PluginManifestRegistry["plugins"][number][];
type ExternalPricingPolicy = {
external: boolean;
};
type PricingContext = {
snapshot?: PluginMetadataSnapshot;
catalog: ReadonlyMap<string, PricingValue>;
hosted: Readonly<Record<string, RemoteModelCatalogPricing>>;
normalizedHosted: ReadonlyMap<string, RemoteModelCatalogPricing>;
policies: ReadonlyMap<string, ExternalPricingPolicy>;
fingerprint: string;
};
const EMPTY_CONFIG: OpenClawConfig = {};
const pricingContextByConfig = new WeakMap<OpenClawConfig, PricingContext>();
function normalizePolicy(
policy: { external?: boolean } | undefined,
): ExternalPricingPolicy | undefined {
if (!policy) {
return undefined;
}
return { external: policy.external !== false };
}
function activeManifestRegistry(
snapshot: PluginMetadataSnapshot,
config: OpenClawConfig,
): PluginManifestRegistry {
if (config.plugins?.enabled === false) {
return { plugins: [], diagnostics: [] };
}
return {
diagnostics: snapshot.manifestRegistry.diagnostics,
plugins: snapshot.manifestRegistry.plugins.filter((plugin) =>
isInstalledPluginEnabled(snapshot.index, plugin.id, config),
),
};
}
function normalizedHostedKey(key: string, manifestPlugins?: ManifestPlugins): string | undefined {
const slash = key.indexOf("/");
if (slash <= 0 || slash === key.length - 1) {
return undefined;
}
const normalized = normalizeModelRef(key.slice(0, slash), key.slice(slash + 1), {
manifestPlugins,
});
return modelKey(normalized.provider, normalized.model);
}
function buildPricingContext(config: OpenClawConfig): PricingContext {
let snapshot: PluginMetadataSnapshot | undefined;
try {
snapshot = resolvePluginMetadataSnapshot({
config,
env: process.env,
allowWorkspaceScopedCurrent: true,
});
} catch {
snapshot = undefined;
}
const registry = snapshot
? activeManifestRegistry(snapshot, config)
: ({ plugins: [], diagnostics: [] } satisfies PluginManifestRegistry);
const catalog = new Map<string, PricingValue>();
for (const row of planEffectiveModelCatalogRows({ registry, config }).rows) {
if (row.cost) {
catalog.set(modelKey(row.provider, row.id), row.cost);
}
}
const policies = new Map<string, ExternalPricingPolicy>();
for (const plugin of registry.plugins) {
for (const [provider, rawPolicy] of Object.entries(plugin.modelPricing?.providers ?? {})) {
const policy = normalizePolicy(rawPolicy);
if (policy) {
policies.set(provider, policy);
}
}
}
// Hosted aliases are policy-resolved against installed manifests. If that metadata is
// unavailable, fail closed instead of treating every provider as policy-free.
const hosted = snapshot ? (getRemoteModelCatalogPricing(config) ?? {}) : {};
const normalizedHosted = new Map<string, RemoteModelCatalogPricing>();
for (const [key, pricing] of Object.entries(hosted).toSorted(([a], [b]) => a.localeCompare(b))) {
const normalized = normalizedHostedKey(key, snapshot?.plugins);
if (normalized && !normalizedHosted.has(normalized)) {
normalizedHosted.set(normalized, pricing);
}
}
const fingerprint = JSON.stringify({
catalog: [...catalog.entries()].toSorted(([a], [b]) => a.localeCompare(b)),
hosted: Object.entries(hosted).toSorted(([a], [b]) => a.localeCompare(b)),
normalizedHosted: [...normalizedHosted.entries()].toSorted(([a], [b]) => a.localeCompare(b)),
policies: [...policies.entries()].toSorted(([a], [b]) => a.localeCompare(b)),
});
return { snapshot, catalog, hosted, normalizedHosted, policies, fingerprint };
}
function getPricingContext(config: OpenClawConfig): PricingContext {
const existing = pricingContextByConfig.get(config);
if (existing) {
return existing;
}
const context = buildPricingContext(config);
pricingContextByConfig.set(config, context);
return context;
}
function hasKnownPricing(pricing: PricingValue): boolean {
return (
Boolean(
pricing.tieredPricing?.some(
(tier) => tier.input > 0 || tier.output > 0 || tier.cacheRead > 0 || tier.cacheWrite > 0,
),
) ||
(pricing.input ?? 0) > 0 ||
(pricing.output ?? 0) > 0 ||
(pricing.cacheRead ?? 0) > 0 ||
(pricing.cacheWrite ?? 0) > 0
);
}
function isPrivateOrLoopbackHost(hostname: string): boolean {
const host = hostname
.trim()
.toLowerCase()
.replace(/^\[|\]$/gu, "");
if (
host === "localhost" ||
host === "localhost.localdomain" ||
host.endsWith(".localhost") ||
host.endsWith(".local")
) {
return true;
}
const addressFamily = isIP(host);
if (addressFamily === 6) {
return (
host === "::1" ||
host === "0:0:0:0:0:0:0:1" ||
host.startsWith("fe80:") ||
host.startsWith("fc") ||
host.startsWith("fd")
);
}
if (addressFamily === 4) {
return (
host.startsWith("127.") ||
host.startsWith("10.") ||
host.startsWith("192.168.") ||
host.startsWith("169.254.") ||
/^172\.(1[6-9]|2\d|3[0-1])\./u.test(host)
);
}
return false;
}
function isPrivateOrLoopbackUrl(value: string | undefined): boolean {
if (!value) {
return false;
}
try {
return isPrivateOrLoopbackHost(new URL(value).hostname);
} catch {
return false;
}
}
function findConfiguredModel(
config: OpenClawConfig,
provider: string,
model: string,
manifestPlugins?: ManifestPlugins,
): ModelDefinitionConfig | undefined {
return config.models?.providers?.[provider]?.models?.find((entry) => {
const normalized = normalizeModelRef(provider, entry.id, { manifestPlugins });
return modelKey(normalized.provider, normalized.model) === modelKey(provider, model);
});
}
function allowsHostedPricing(
config: OpenClawConfig,
provider: string,
model: string,
manifestPlugins?: ManifestPlugins,
): boolean {
const providerConfig = config.models?.providers?.[provider];
const configuredModel = findConfiguredModel(config, provider, model, manifestPlugins);
return !(
isPrivateOrLoopbackUrl(configuredModel?.baseUrl) ||
isPrivateOrLoopbackUrl(providerConfig?.baseUrl)
);
}
export function resolveCatalogModelPricing(params: {
config?: OpenClawConfig;
provider: string;
model: string;
}): PricingValue | undefined {
const config = params.config ?? EMPTY_CONFIG;
const context = getPricingContext(config);
const normalized = normalizeModelRef(params.provider, params.model, {
manifestPlugins: context.snapshot?.plugins,
});
if (
!allowsHostedPricing(config, normalized.provider, normalized.model, context.snapshot?.plugins)
) {
return undefined;
}
const pricing = context.catalog.get(modelKey(normalized.provider, normalized.model));
return pricing && hasKnownPricing(pricing) ? pricing : undefined;
}
export function resolveHostedModelPricing(params: {
config?: OpenClawConfig;
provider: string;
model: string;
}): PricingValue | undefined {
const config = params.config ?? EMPTY_CONFIG;
const context = getPricingContext(config);
const normalized = normalizeModelRef(params.provider, params.model, {
manifestPlugins: context.snapshot?.plugins,
});
if (
context.policies.get(normalized.provider)?.external === false ||
!allowsHostedPricing(config, normalized.provider, normalized.model, context.snapshot?.plugins)
) {
return undefined;
}
const key = modelKey(normalized.provider, normalized.model);
const pricing =
context.hosted[key] ??
(context.policies.has(normalized.provider) ? undefined : context.normalizedHosted.get(key));
return pricing && hasKnownPricing(pricing) ? pricing : undefined;
}
export function modelCatalogPricingFingerprint(config?: OpenClawConfig): string {
const resolvedConfig = config ?? EMPTY_CONFIG;
const context = getPricingContext(resolvedConfig);
const configuredEndpoints = Object.entries(resolvedConfig.models?.providers ?? {})
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([provider, providerConfig]) => ({
provider,
baseUrl: providerConfig.baseUrl,
models: (providerConfig.models ?? [])
.map((model) => ({ id: model.id, baseUrl: model.baseUrl }))
.toSorted((a, b) => a.id.localeCompare(b.id)),
}));
return JSON.stringify({ pricing: context.fingerprint, configuredEndpoints });
}
+6 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getRemoteModelCatalogOverlay } from "./remote-overlay.js";
import { getRemoteModelCatalogOverlay, getRemoteModelCatalogPricing } from "./remote-overlay.js";
import {
resetRemoteModelCatalogOverlayForTest,
setRemoteModelCatalogOverlaySourcesForTest,
@@ -16,6 +16,7 @@ const bundle = {
minVersion: "2026.7.0",
sourceCommit: "abc",
providers: { anthropic: { models: [{ id: "new" }] } },
pricing: { "openai/gpt-external": { input: 2.5, output: 10 } },
};
beforeEach(() => {
@@ -40,6 +41,10 @@ describe("remote model catalog overlay", () => {
it("loads a newer compatible bundle once", () => {
expect(getRemoteModelCatalogOverlay({})).toHaveProperty("anthropic");
expect(getRemoteModelCatalogOverlay({})).toHaveProperty("anthropic");
expect(getRemoteModelCatalogPricing({})?.["openai/gpt-external"]).toEqual({
input: 2.5,
output: 10,
});
expect(mocks.read).toHaveBeenCalledOnce();
});
+25 -6
View File
@@ -1,6 +1,7 @@
import {
validateAndSanitizeRemoteModelCatalogBundle,
type RemoteModelCatalogBundle,
type RemoteModelCatalogPricing,
} from "@openclaw/model-catalog-core";
import type { ModelCatalogProvider } from "@openclaw/model-catalog-core/model-catalog-types";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -11,8 +12,12 @@ import { isRemoteModelCatalogRefreshEnabled, resolveRemoteCatalogUrl } from "./r
import { readRemoteModelCatalog } from "./remote-store.js";
type RemoteModelCatalogOverlay = Readonly<Record<string, ModelCatalogProvider>>;
type ActiveRemoteModelCatalog = {
providers: RemoteModelCatalogOverlay;
pricing?: Readonly<Record<string, RemoteModelCatalogPricing>>;
};
let cachedOverlay: { sourceUrl: string; value: RemoteModelCatalogOverlay | null } | undefined;
let cachedOverlay: { sourceUrl: string; value: ActiveRemoteModelCatalog | null } | undefined;
let readBundledGeneratedAt = bundledCatalogGeneratedAt;
let readStoredCatalog = readRemoteModelCatalog;
@@ -24,9 +29,7 @@ function isCompatible(bundle: RemoteModelCatalogBundle): boolean {
return comparison !== null && comparison >= 0;
}
export function getRemoteModelCatalogOverlay(
config: OpenClawConfig,
): RemoteModelCatalogOverlay | undefined {
function getActiveRemoteModelCatalog(config: OpenClawConfig): ActiveRemoteModelCatalog | undefined {
if (!isRemoteModelCatalogRefreshEnabled(config)) {
return undefined;
}
@@ -50,14 +53,30 @@ export function getRemoteModelCatalogOverlay(
cachedOverlay = { sourceUrl, value: null };
return undefined;
}
cachedOverlay = { sourceUrl, value: bundle.providers };
return bundle.providers;
const value = {
providers: bundle.providers,
...(bundle.pricing ? { pricing: bundle.pricing } : {}),
};
cachedOverlay = { sourceUrl, value };
return value;
} catch {
cachedOverlay = undefined;
return undefined;
}
}
export function getRemoteModelCatalogOverlay(
config: OpenClawConfig,
): RemoteModelCatalogOverlay | undefined {
return getActiveRemoteModelCatalog(config)?.providers;
}
export function getRemoteModelCatalogPricing(
config: OpenClawConfig,
): Readonly<Record<string, RemoteModelCatalogPricing>> | undefined {
return getActiveRemoteModelCatalog(config)?.pricing;
}
function resetRemoteModelCatalogOverlayForTest(): void {
cachedOverlay = undefined;
}
@@ -222,7 +222,7 @@ describe("secrets runtime provider and media surfaces", () => {
},
models: {
...initial.config.models,
pricing: { enabled: true },
catalogRefresh: { enabled: false },
},
},
runtimeSourceConfig,
@@ -237,7 +237,7 @@ describe("secrets runtime provider and media surfaces", () => {
"https://runtime-only.example",
]);
expect(active?.config.auth?.order?.openai).toEqual(["runtime-only-profile"]);
expect(active?.config.models?.pricing?.enabled).toBe(true);
expect(active?.config.models?.catalogRefresh?.enabled).toBe(false);
expect(active?.config.models?.providers?.openai?.apiKey).toBe("model-new");
expect(getRuntimeConfigSnapshot()).toEqual(active?.config);
expect(getRuntimeConfigSourceSnapshot()).toEqual(runtimeSourceConfig);
+92
View File
@@ -0,0 +1,92 @@
/** One normalized tier in a per-million-token pricing schedule. */
export type PricingTier = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** Half-open input-token interval `[start, end)`. */
range: [number, number];
};
type RawPricingTier = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
range: [number, number] | [number];
};
/** Per-million-token pricing used by usage summaries and cost estimates. */
export type ModelCostConfig = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
tieredPricing?: PricingTier[];
};
export type RawModelCostConfig = Omit<ModelCostConfig, "tieredPricing"> & {
tieredPricing?: RawPricingTier[];
};
function normalizeTieredPricing(raw: RawPricingTier[] | undefined): PricingTier[] | undefined {
if (!raw || raw.length === 0) {
return undefined;
}
const result: PricingTier[] = [];
for (const tier of raw) {
const range = tier.range;
const start = Array.isArray(range) && typeof range[0] === "number" ? range[0] : Number.NaN;
if (!Number.isFinite(start)) {
continue;
}
const rawEnd = range.length >= 2 ? range[1] : null;
const end =
typeof rawEnd === "number" && Number.isFinite(rawEnd) && rawEnd > start ? rawEnd : Infinity;
if (
!Number.isFinite(tier.input) ||
!Number.isFinite(tier.output) ||
!Number.isFinite(tier.cacheRead) ||
!Number.isFinite(tier.cacheWrite)
) {
continue;
}
result.push({
input: tier.input,
output: tier.output,
cacheRead: tier.cacheRead,
cacheWrite: tier.cacheWrite,
range: [start, end],
});
}
return result.length > 0 ? result.toSorted((a, b) => a.range[0] - b.range[0]) : undefined;
}
export function normalizeModelCostConfig(cost: RawModelCostConfig): ModelCostConfig {
const normalizedTiers = normalizeTieredPricing(cost.tieredPricing);
return {
input: cost.input,
output: cost.output,
cacheRead: cost.cacheRead,
cacheWrite: cost.cacheWrite,
...(normalizedTiers ? { tieredPricing: normalizedTiers } : {}),
};
}
export function normalizeResolvedPricing(cost: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
tieredPricing?: RawPricingTier[];
}): ModelCostConfig {
const finiteOrZero = (value: number | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
return normalizeModelCostConfig({
input: finiteOrZero(cost.input),
output: finiteOrZero(cost.output),
cacheRead: finiteOrZero(cost.cacheRead),
cacheWrite: finiteOrZero(cost.cacheWrite),
...(cost.tieredPricing ? { tieredPricing: cost.tieredPricing } : {}),
});
}
-101
View File
@@ -6,11 +6,6 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
clearGatewayModelPricingFailures,
replaceGatewayModelPricingCache,
type CachedModelPricing,
} from "../gateway/model-pricing-cache-state.js";
import * as manifestModelIdNormalization from "../plugins/manifest-model-id-normalization.js";
import { captureEnv } from "../test-utils/env.js";
import {
@@ -25,23 +20,6 @@ import {
type ModelCostConfig = NonNullable<ReturnType<typeof resolveModelCostConfig>>;
type PricingTier = NonNullable<ModelCostConfig["tieredPricing"]>[number];
function setGatewayModelPricing(
entries: Array<{
provider: string;
model: string;
pricing: CachedModelPricing;
}>,
): void {
replaceGatewayModelPricingCache(
new Map(entries.map((entry) => [`${entry.provider}/${entry.model}`, entry.pricing])),
);
}
function clearGatewayModelPricingState(): void {
replaceGatewayModelPricingCache(new Map(), 0);
clearGatewayModelPricingFailures();
}
function requireCostConfig(
cost: ReturnType<typeof resolveModelCostConfig>,
label: string,
@@ -75,14 +53,12 @@ describe("usage-format", () => {
delete process.env.OPENCLAW_AGENT_DIR;
await fs.mkdir(agentDir, { recursive: true });
resetUsageFormatCachesForTest();
clearGatewayModelPricingState();
});
afterEach(async () => {
envSnapshot?.restore();
envSnapshot = undefined;
resetUsageFormatCachesForTest();
clearGatewayModelPricingState();
await fs.rm(stateDir, { recursive: true, force: true });
});
@@ -216,14 +192,6 @@ describe("usage-format", () => {
"utf8",
);
setGatewayModelPricing([
{
provider: "demo-preferred",
model: "demo-model",
pricing: { input: 30, output: 31, cacheRead: 32, cacheWrite: 33 },
},
]);
expect(
resolveModelCostConfig({
provider: "demo-preferred",
@@ -353,14 +321,6 @@ describe("usage-format", () => {
},
} as unknown as OpenClawConfig;
setGatewayModelPricing([
{
provider: "demo-config-provider",
model: "demo-model",
pricing: { input: 3, output: 4, cacheRead: 0.3, cacheWrite: 0.4 },
},
]);
expect(
resolveModelCostConfig({
provider: "demo-config-provider",
@@ -375,28 +335,6 @@ describe("usage-format", () => {
});
});
it("falls back to cached gateway pricing when no configured cost exists", () => {
setGatewayModelPricing([
{
provider: "demo-cached-provider",
model: "demo-model",
pricing: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
},
]);
expect(
resolveModelCostConfig({
provider: "demo-cached-provider",
model: "demo-model",
}),
).toEqual({
input: 2.5,
output: 15,
cacheRead: 0.25,
cacheWrite: 0,
});
});
it("can skip plugin-backed model normalization for display-only cost lookup", () => {
const config = {
models: {
@@ -1088,43 +1026,4 @@ describe("usage-format", () => {
expect(expectDefined(tiers[0], "tiers[0] test invariant").range).toEqual([0, 32000]);
expect(expectDefined(tiers[1], "tiers[1] test invariant").input).toBe(0.7);
});
it("resolves tiered pricing from cached gateway (LiteLLM)", () => {
setGatewayModelPricing([
{
provider: "volcengine",
model: "doubao-seed",
pricing: {
input: 0.46,
output: 2.3,
cacheRead: 0,
cacheWrite: 0,
tieredPricing: [
{
input: 0.46,
output: 2.3,
cacheRead: 0,
cacheWrite: 0,
range: [0, 32000] as [number, number],
},
{
input: 0.7,
output: 3.5,
cacheRead: 0,
cacheWrite: 0,
range: [32000, 128000] as [number, number],
},
],
},
},
]);
const cost = resolveModelCostConfig({
provider: "volcengine",
model: "doubao-seed",
});
const tiers = requireTieredPricing(requireCostConfig(cost, "cached gateway"), "cached gateway");
expect(tiers).toHaveLength(2);
});
});
+30 -98
View File
@@ -15,48 +15,21 @@ import type { NormalizedUsage } from "../agents/usage.js";
import { resolveStateDir } from "../config/paths.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getGatewayModelPricingCacheFingerprint } from "../gateway/model-pricing-cache-state.js";
import { getCachedGatewayModelPricing } from "../gateway/model-pricing-cache.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import {
modelCatalogPricingFingerprint,
resolveCatalogModelPricing,
resolveHostedModelPricing,
} from "../model-catalog/pricing.js";
import {
normalizeModelCostConfig,
normalizeResolvedPricing,
type ModelCostConfig,
type PricingTier,
type RawModelCostConfig,
} from "./usage-format-pricing.js";
export { formatTokenCount } from "./token-format.js";
/**
* A single tier in a tiered-pricing schedule. Prices are expressed as
* USD per-million tokens, just like the flat `ModelCostConfig` fields.
*
* `range` is a half-open interval `[start, end)` expressed in *input*
* token counts. The tiers MUST be sorted in ascending `range[0]` order
* with no gaps.
*/
type PricingTier = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** [startTokens, endTokens) — half-open interval on the input token axis. */
range: [number, number];
};
type RawPricingTier = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
range: [number, number] | [number];
};
/** Per-million-token model pricing used by usage summaries and cost estimates. */
export type ModelCostConfig = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** Optional tiered pricing tiers. When present, `estimateUsageCost`
* uses them instead of the flat rates above. The flat rates still
* serve as the "default / first-tier" fallback for callers that are
* unaware of tiered pricing. */
tieredPricing?: PricingTier[];
};
export type { ModelCostConfig } from "./usage-format-pricing.js";
type UsageTotals = {
input?: number;
@@ -91,10 +64,6 @@ type ProviderCostIndex = {
structureFingerprint: string;
};
type RawModelCostConfig = Omit<ModelCostConfig, "tieredPricing"> & {
tieredPricing?: RawPricingTier[];
};
const EMPTY_PROVIDER_COST_INDEX = new Map<string, ModelCostConfig>();
const MODELS_JSON_COST_CACHE_LIMIT = 128;
const MODEL_KEY_CACHE_LIMIT = 4096;
@@ -179,58 +148,6 @@ function shouldUseNormalizedCostLookup(params: { provider?: string; model?: stri
return provider === "anthropic" || provider === "openrouter" || provider === "vercel-ai-gateway";
}
/**
* Normalize a raw tieredPricing array from models.json / config.
* Supports open-ended ranges such as `[128000]` or `[128000, -1]`,
* which are converted to `[128000, Infinity]`.
*/
function normalizeTieredPricing(raw: RawPricingTier[] | undefined): PricingTier[] | undefined {
if (!raw || raw.length === 0) {
return undefined;
}
const result: PricingTier[] = [];
for (const tier of raw) {
const range = tier.range;
if (!Array.isArray(range) || range.length < 1) {
continue;
}
const start = typeof range[0] === "number" ? range[0] : Number.NaN;
if (!Number.isFinite(start)) {
continue;
}
const rawEnd = range.length >= 2 ? range[1] : null;
const end =
typeof rawEnd === "number" && Number.isFinite(rawEnd) && rawEnd > start ? rawEnd : Infinity;
if (
!Number.isFinite(tier.input) ||
!Number.isFinite(tier.output) ||
!Number.isFinite(tier.cacheRead) ||
!Number.isFinite(tier.cacheWrite)
) {
continue;
}
result.push({
input: tier.input,
output: tier.output,
cacheRead: tier.cacheRead,
cacheWrite: tier.cacheWrite,
range: [start, end],
});
}
return result.length > 0 ? result.toSorted((a, b) => a.range[0] - b.range[0]) : undefined;
}
function normalizeModelCostConfig(cost: RawModelCostConfig): ModelCostConfig {
const normalizedTiers = normalizeTieredPricing(cost.tieredPricing);
return {
input: cost.input,
output: cost.output,
cacheRead: cost.cacheRead,
cacheWrite: cost.cacheWrite,
...(normalizedTiers ? { tieredPricing: normalizedTiers } : {}),
};
}
function isRawModelCostConfig(value: unknown): value is RawModelCostConfig {
return value !== null && typeof value === "object";
}
@@ -626,7 +543,7 @@ export function resolveModelCostConfigFingerprint(
modelsJsonNormalized: serializeCostIndex(
loadModelsJsonCostIndex({ agentDir: resolvedAgentDir }),
),
gatewayPricing: getGatewayModelPricingCacheFingerprint(),
catalogPricing: modelCatalogPricingFingerprint(config),
});
}
@@ -646,6 +563,16 @@ export function resolveModelCostConfig(params: {
return undefined;
}
const agentDir = resolveCostAgentDir(params.config, params.agentDir);
if (params.allowPluginNormalization !== false) {
const catalogPricing = resolveCatalogModelPricing({
config: params.config,
provider: params.provider ?? "",
model: params.model ?? "",
});
if (catalogPricing) {
return normalizeResolvedPricing(catalogPricing);
}
}
// Favor direct configured keys first so local pricing/status lookups stay
// synchronous and do not drag plugin/provider discovery into the hot path.
@@ -684,7 +611,12 @@ export function resolveModelCostConfig(params: {
}
}
return getCachedGatewayModelPricing(params);
const hostedPricing = resolveHostedModelPricing({
config: params.config,
provider: params.provider ?? "",
model: params.model ?? "",
});
return hostedPricing ? normalizeResolvedPricing(hostedPricing) : undefined;
}
const toNumber = (value: number | undefined): number =>
+86 -5
View File
@@ -23,7 +23,12 @@ afterEach(() => {
}
});
function fixtureProvider(prefix: string, count: number) {
function fixtureProvider(
prefix: string,
count: number,
): {
models: Array<{ id: string; cost?: { input: number; output: number } }>;
} {
return { models: Array.from({ length: count }, (_, index) => ({ id: `${prefix}-${index}` })) };
}
@@ -56,6 +61,7 @@ describe("publish model catalog", () => {
providers: 2,
models: 200,
costModels: 0,
pricingEntries: 0,
});
expect(MODEL_CATALOG_MIN_MODELS).toBe(200);
});
@@ -123,11 +129,12 @@ describe("publish model catalog", () => {
expect(fs.existsSync(out)).toBe(false);
});
it("enriches only existing models with OpenRouter flat and LiteLLM tier pricing", async () => {
it("enriches catalog models and emits unmatched hosted pricing keys", async () => {
const anthropic = fixtureProvider("claude", 100);
anthropic.models[0] = { id: "claude-3-5-sonnet" };
const openai = fixtureProvider("gpt", 100);
openai.models[0] = { id: "gpt-special" };
openai.models[1] = { id: "zero-upstream", cost: { input: 5, output: 6 } };
const manifests = [
{
pluginId: "anthropic",
@@ -144,6 +151,31 @@ describe("publish model catalog", () => {
manifestPath: "openai.json",
manifest: { modelCatalog: { providers: { openai } } },
},
{
pluginId: "openrouter",
manifestPath: "openrouter.json",
manifest: {
modelPricing: {
providers: {
openrouter: {
openRouter: { passthroughProviderModel: true },
liteLLM: false,
},
},
},
},
},
{
pluginId: "mapped",
manifestPath: "mapped.json",
manifest: {
modelPricing: {
providers: {
mapped: { openRouter: { provider: "approved-source" }, liteLLM: false },
},
},
},
},
];
const bundle = await assembleModelCatalogBundle({
manifests,
@@ -165,6 +197,8 @@ describe("publish model catalog", () => {
},
{ id: "openai/gpt-2", pricing: { prompt: "-1", completion: "0.000004" } },
{ id: "unknown/new-model", pricing: { prompt: "1", completion: "1" } },
{ id: "custom/secondary-wins", pricing: { prompt: "0", completion: "0" } },
{ id: "mapped/wrong-source", pricing: { prompt: "0.000013", completion: "0.000014" } },
],
});
}
@@ -184,18 +218,63 @@ describe("publish model catalog", () => {
output_cost_per_token: 0.000004,
},
"unknown/new-model": { input_cost_per_token: 1, output_cost_per_token: 1 },
"external-model": {
litellm_provider: "custom",
input_cost_per_token: 0.000007,
output_cost_per_token: 0.000008,
},
"forbidden-model": {
litellm_provider: "openrouter",
input_cost_per_token: 0.000009,
output_cost_per_token: 0.00001,
},
"secondary-wins": {
litellm_provider: "custom",
input_cost_per_token: 0.000011,
output_cost_per_token: 0.000012,
},
"zero-upstream": {
litellm_provider: "openai",
input_cost_per_token: 0,
output_cost_per_token: 0,
},
});
};
await expect(enrichModelCatalogPricing({ bundle, manifests, fetchImpl })).resolves.toBe(2);
await expect(enrichModelCatalogPricing({ bundle, manifests, fetchImpl })).resolves.toEqual({
modelsEnriched: 2,
pricingEntries: 11,
});
expect(bundle.providers.anthropic?.models[0]?.cost).toMatchObject({ input: 1, output: 2 });
expect(bundle.providers.openai?.models[0]?.cost).toMatchObject({
input: 3,
output: 4,
tieredPricing: [{ input: 5, output: 6, range: [1000] }],
});
expect(bundle.providers.openai?.models[1]?.cost).toEqual({ input: 5, output: 6 });
expect(bundle.providers.openai?.models[2]?.cost).toBeUndefined();
expect(summarizeModelCatalogBundle(bundle)).toMatchObject({ models: 200, costModels: 2 });
expect(bundle.pricing).toEqual({
"anthropic/claude-3.5-sonnet": { input: 1, output: 2 },
"custom/external-model": { input: 7, output: 8 },
"custom/secondary-wins": { input: 11, output: 12 },
"external-model": { input: 7, output: 8 },
"forbidden-model": { input: 9, output: 10 },
"openrouter/anthropic/claude-3.5-sonnet": { input: 1, output: 2 },
"openrouter/mapped/wrong-source": { input: 13, output: 14 },
"openrouter/openai/gpt-special": { input: 3, output: 4 },
"openrouter/unknown/new-model": { input: 1_000_000, output: 1_000_000 },
"secondary-wins": { input: 11, output: 12 },
"unknown/new-model": { input: 1_000_000, output: 1_000_000 },
});
expect(bundle.pricing).not.toHaveProperty("openrouter/forbidden-model");
expect(bundle.pricing).not.toHaveProperty("mapped/wrong-source");
expect(bundle.pricing).not.toHaveProperty("gpt-special");
expect(bundle.pricing).not.toHaveProperty("openai/gpt-special");
expect(summarizeModelCatalogBundle(bundle)).toMatchObject({
models: 200,
costModels: 3,
pricingEntries: 11,
});
expect(Object.hasOwn(bundle.providers, "unknown")).toBe(false);
});
@@ -236,7 +315,7 @@ describe("publish model catalog", () => {
return new Response("not-json", { status: 200 });
},
}),
).resolves.toBe(0);
).resolves.toEqual({ modelsEnriched: 0, pricingEntries: 0 });
} finally {
stderr.mockRestore();
}
@@ -255,10 +334,12 @@ describe("publish model catalog", () => {
const left = {
...base,
providers: { zeta: { models: [{ id: "b" }, { id: "a" }] }, alpha: { models: [{ id: "c" }] } },
pricing: { "z/model": { input: 2, output: 3 }, "a/model": { input: 1, output: 2 } },
};
const right = {
...base,
providers: { alpha: { models: [{ id: "c" }] }, zeta: { models: [{ id: "a" }, { id: "b" }] } },
pricing: { "a/model": { output: 2, input: 1 }, "z/model": { output: 3, input: 2 } },
};
expect(serializeModelCatalogBundle(left)).toBe(serializeModelCatalogBundle(right));
});