fix: add pricing bootstrap opt-out and sdk compat exports

This commit is contained in:
Peter Steinberger
2026-04-28 08:35:01 +01:00
parent f5a7632ffc
commit 1dd011984a
22 changed files with 195 additions and 4 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
- Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9.
- Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby.
- CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk.
@@ -26,6 +27,7 @@ Docs: https://docs.openclaw.ai
- ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia.
- CLI/onboarding: infer image input for common custom-provider vision model IDs, ask only for unknown models, and keep `--custom-image-input`/`--custom-text-input` overrides so vision-capable proxies do not get saved as text-only configs. Fixes #51869. Thanks @Antsoldier1974.
- Models/OpenAI Codex: stop listing or resolving unsupported `openai-codex/gpt-5.4-mini` rows through Codex OAuth, keep stale discovery rows suppressed with a clear API-key-route hint, and leave direct `openai/gpt-5.4-mini` available. Fixes #73242. Thanks @0xCyda.
- Plugin SDK: restore the root `stringEnum` and `optionalStringEnum` exports on both the published SDK entry and runtime root-alias bridge, so older external plugins can keep building and loading while migrating to focused SDK subpaths. Fixes #68279. Thanks @marzliak.
- Plugin SDK: restore the root-alias bridge for `registerContextEngine` and expose missing legacy compat helpers `normalizeAccountId` and `resolvePreferredOpenClawTmpDir` so older external plugins such as `openclaw-weixin` can keep loading while migrating to focused SDK subpaths. Fixes #53497. Thanks @alanxchen85.
- Auth profiles: make `openclaw doctor --fix` migrate legacy flat `auth-profiles.json` files such as `{ "ollama-windows": { "apiKey": "ollama-local" } }` to canonical provider default API-key profiles with a backup, so custom Ollama/OpenAI-compatible providers recover cleanly after upgrading. Fixes #59629; supersedes #59642. Thanks @Xsanders555 and @Linux2010.
- Memory/Dreaming: retry Dream Diary once with the session default when a configured dreaming model is unavailable, while leaving subagent trust and allowlist errors visible instead of silently masking configuration problems. Refs #67409 and #69209. Thanks @Ghiggins18 and @everySympathy.

View File

@@ -1,4 +1,4 @@
f888e19429506211e4b8b4113594641825d300c0c0a721121092cae2201b721f config-baseline.json
481eb68ecf9538d8f6d9808af1a7416b05a3b5d00080552b955a77dbd90819e3 config-baseline.core.json
b1d76b9451b21434325e64d5bb531b9b995ba3bbf8f7b1628c09cce18f24c8e2 config-baseline.json
58e98b59498060d301104b3772332de5600eb674687b06d0d32a202370709ee0 config-baseline.core.json
a9f058ee9616e189dab7fc223e1207a49ae52b8490b8028935c9d0a2b16f81b2 config-baseline.channel.json
1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
ffc0924db91ebb9b79c488879bc9938b199047a2577fc469e194af673c9e1303 plugin-sdk-api-baseline.json
f2445b07d3ead6c38ab2a37c2e0eccb6414ade36d3fb9eb3dd157e5104f88b0d plugin-sdk-api-baseline.jsonl
9a688c953f0108f85f58c173e79c28363d846a592130abec04cafbcabbb22dcc plugin-sdk-api-baseline.json
010252e56202abde0816787588239c41b4bfb710b930a5454848a5ae76ad6dae plugin-sdk-api-baseline.jsonl

View File

@@ -57,6 +57,28 @@ Tool policy, experimental toggles, provider-backed tool config, and custom
provider / base-URL setup moved to a dedicated page — see
[Configuration — tools and custom providers](/gateway/config-tools).
## Models
Provider definitions, model allowlists, and custom provider setup live in
[Configuration — tools and custom providers](/gateway/config-tools#custom-providers-and-base-urls).
The `models` root also owns global model-catalog behavior.
```json5
{
models: {
// Optional. Default: true. Requires a Gateway restart when changed.
pricing: { enabled: false },
},
}
```
- `models.mode`: provider catalog behavior (`merge` or `replace`).
- `models.providers`: custom provider map keyed by provider id.
- `models.pricing.enabled`: controls the background pricing bootstrap. When
`false`, Gateway startup skips OpenRouter and LiteLLM pricing-catalog fetches;
configured `models.providers.*.models[].cost` values still work for local cost
estimates.
## MCP
OpenClaw-managed MCP server definitions live under `mcp.servers` and are

View File

@@ -120,6 +120,13 @@ These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and
`cacheWrite`. If pricing is missing, OpenClaw shows tokens only. OAuth tokens
never show dollar cost.
Gateway startup also performs 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 startup catalog fetches on offline
or restricted networks; explicit `models.providers.*.models[].cost` entries
continue to drive local cost estimates.
## Cache TTL and pruning impact
Provider prompt caching only applies within the cache TTL window. OpenClaw can

View File

@@ -65,6 +65,28 @@ 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("crestodian.rescue", () => {
it("accepts documented rescue config", () => {
const result = OpenClawSchema.safeParse({

View File

@@ -3179,6 +3179,21 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.",
},
pricing: {
type: "object",
properties: {
enabled: {
type: "boolean",
title: "Model Pricing Enabled",
description:
"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.",
},
},
additionalProperties: false,
title: "Model Pricing",
description:
"Controls the optional background model-pricing bootstrap that fetches remote per-token cost catalogs.",
},
},
additionalProperties: false,
title: "Models",
@@ -26592,6 +26607,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: '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.',
tags: ["models"],
},
"models.pricing": {
label: "Model Pricing",
help: "Controls the optional background model-pricing bootstrap that fetches remote per-token cost catalogs.",
tags: ["models"],
},
"models.pricing.enabled": {
label: "Model Pricing Enabled",
help: "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.",
tags: ["models"],
},
"models.providers": {
label: "Model Providers",
help: "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.",

View File

@@ -818,6 +818,10 @@ export const 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. 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.providers.*.baseUrl":
"Base URL for the provider endpoint used to serve model requests for that provider entry. Use HTTPS endpoints and keep URLs environment-specific through config templating where needed.",
"models.providers.*.apiKey":

View File

@@ -515,6 +515,8 @@ 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.providers": "Model Providers",
"models.providers.*.baseUrl": "Model Provider Base URL",
"models.providers.*.apiKey": "Model Provider API Key", // pragma: allowlist secret

View File

@@ -143,9 +143,14 @@ export type DiscoveryToggleConfig = {
enabled?: boolean;
};
export type ModelPricingConfig = {
enabled?: boolean;
};
export type ModelsConfig = {
mode?: "merge" | "replace";
providers?: Record<string, ModelProviderConfig>;
pricing?: ModelPricingConfig;
// Deprecated legacy compat aliases. Kept in the runtime type surface so
// doctor/runtime fallbacks can read older configs until migration completes.
bedrockDiscovery?: BedrockDiscoveryConfig;

View File

@@ -381,10 +381,18 @@ export const BedrockDiscoverySchema = z
.strict()
.optional();
const ModelPricingConfigSchema = z
.object({
enabled: z.boolean().optional(),
})
.strict()
.optional();
export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: z.record(z.string(), ModelProviderSchema).optional(),
pricing: ModelPricingConfigSchema,
})
.strict()
.optional();

View File

@@ -80,6 +80,10 @@ const BASE_RELOAD_RULES: ReloadRule[] = [
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "models.pricing",
kind: "restart",
},
{
prefix: "models",
kind: "hot",

View File

@@ -245,6 +245,14 @@ describe("buildGatewayReloadPlan", () => {
);
});
it("requires restart when model pricing bootstrap changes", () => {
const plan = buildGatewayReloadPlan(["models.pricing.enabled"]);
expect(plan.restartGateway).toBe(true);
expect(plan.restartReasons).toContain("models.pricing.enabled");
expect(plan.restartHeartbeat).toBe(false);
expect(plan.hotReasons).toEqual([]);
});
it("restarts heartbeat when agents.defaults.models allowlist changes", () => {
const plan = buildGatewayReloadPlan(["agents.defaults.models"]);
expect(plan.restartGateway).toBe(false);

View File

@@ -761,6 +761,40 @@ describe("model-pricing-cache", () => {
stop();
});
it("does not bootstrap remote pricing when pricing is disabled", async () => {
const config = {
agents: {
defaults: {
model: { primary: "openrouter/moonshotai/kimi-k2.5" },
},
},
models: { pricing: { enabled: false } },
} as unknown as OpenClawConfig;
const fetchImpl = withFetchPreconnect(vi.fn());
const stop = startGatewayModelPricingRefresh({ config, fetchImpl });
await vi.dynamicImportSettled();
expect(fetchImpl).not.toHaveBeenCalled();
stop();
});
it("does not refresh remote pricing when pricing is disabled", async () => {
const config = {
agents: {
defaults: {
model: { primary: "openrouter/moonshotai/kimi-k2.5" },
},
},
models: { pricing: { enabled: false } },
} as unknown as OpenClawConfig;
const fetchImpl = withFetchPreconnect(vi.fn());
await refreshGatewayModelPricingCache({ config, fetchImpl });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("logs configured timeout seconds when pricing fetches time out", async () => {
const warnings: string[] = [];
loggingState.rawConsole = {

View File

@@ -34,6 +34,7 @@ import {
type CachedModelPricing,
type CachedPricingTier,
} from "./model-pricing-cache-state.js";
import { isGatewayModelPricingEnabled } from "./model-pricing-config.js";
type OpenRouterPricingEntry = {
id: string;
@@ -1105,6 +1106,10 @@ export async function refreshGatewayModelPricingCache(params: {
pluginLookUpTable?: Pick<PluginLookUpTable, "index" | "manifestRegistry">;
manifestRegistry?: PluginManifestRegistry;
}): Promise<void> {
if (!isGatewayModelPricingEnabled(params.config)) {
clearRefreshTimer();
return;
}
if (inFlightRefresh) {
return await inFlightRefresh;
}
@@ -1250,6 +1255,10 @@ export function startGatewayModelPricingRefresh(params: {
pluginLookUpTable?: Pick<PluginLookUpTable, "index" | "manifestRegistry">;
manifestRegistry?: PluginManifestRegistry;
}): () => void {
if (!isGatewayModelPricingEnabled(params.config)) {
clearRefreshTimer();
return () => {};
}
let stopped = false;
queueMicrotask(() => {
if (stopped) {

View File

@@ -0,0 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
export function isGatewayModelPricingEnabled(config: OpenClawConfig): boolean {
return config.models?.pricing?.enabled !== false;
}

View File

@@ -10,6 +10,7 @@ const hoisted = vi.hoisted(() => {
startHeartbeatRunner: vi.fn(() => heartbeatRunner),
startChannelHealthMonitor: vi.fn(() => ({ stop: vi.fn() })),
startGatewayModelPricingRefresh: vi.fn(() => vi.fn()),
loadModelPricingCacheModule: vi.fn(),
isVitestRuntimeEnv: vi.fn(() => false),
recoverPendingDeliveries: vi.fn(async () => undefined),
recoverPendingRestartContinuationDeliveries: vi.fn(async () => undefined),
@@ -42,6 +43,10 @@ vi.mock("./channel-health-monitor.js", () => ({
}));
vi.mock("./model-pricing-cache.js", () => ({
...(() => {
hoisted.loadModelPricingCacheModule();
return {};
})(),
startGatewayModelPricingRefresh: hoisted.startGatewayModelPricingRefresh,
}));
@@ -56,12 +61,31 @@ describe("server-runtime-services", () => {
hoisted.startHeartbeatRunner.mockClear();
hoisted.startChannelHealthMonitor.mockClear();
hoisted.startGatewayModelPricingRefresh.mockClear();
hoisted.loadModelPricingCacheModule.mockClear();
hoisted.isVitestRuntimeEnv.mockReset().mockReturnValue(false);
hoisted.recoverPendingDeliveries.mockClear();
hoisted.recoverPendingRestartContinuationDeliveries.mockClear();
hoisted.deliverOutboundPayloads.mockClear();
});
it("skips model pricing bootstrap import when pricing is disabled", async () => {
startGatewayRuntimeServices({
minimalTestGateway: false,
cfgAtStart: { models: { pricing: { enabled: false } } } as never,
channelManager: {
getRuntimeSnapshot: vi.fn(),
isHealthMonitorEnabled: vi.fn(),
isManuallyStopped: vi.fn(),
} as never,
log: createLog(),
});
await vi.dynamicImportSettled();
expect(hoisted.loadModelPricingCacheModule).not.toHaveBeenCalled();
expect(hoisted.startGatewayModelPricingRefresh).not.toHaveBeenCalled();
});
it("keeps scheduled services inert during initial runtime setup", async () => {
const services = startGatewayRuntimeServices({
minimalTestGateway: false,

View File

@@ -4,6 +4,7 @@ import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-r
import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
import type { ChannelHealthMonitor } from "./channel-health-monitor.js";
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
import { isGatewayModelPricingEnabled } from "./model-pricing-config.js";
type GatewayRuntimeServiceLogger = {
child: (name: string) => {
@@ -93,6 +94,9 @@ function startGatewayModelPricingRefreshOnDemand(params: {
pluginLookUpTable?: Pick<PluginLookUpTable, "index" | "manifestRegistry">;
log: GatewayRuntimeServiceLogger;
}): () => void {
if (!isGatewayModelPricingEnabled(params.config)) {
return () => {};
}
let stopped = false;
let stopRefresh: (() => void) | undefined;
void (async () => {

View File

@@ -31,6 +31,7 @@ export {
export { registerContextEngine } from "../context-engine/registry.js";
export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js";
export { onDiagnosticEvent } from "../infra/diagnostic-events.js";
export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js";
export {
applyAuthProfileConfig,
buildApiKeyCredential,

View File

@@ -122,3 +122,4 @@ export {
delegateCompactionToRuntime,
} from "../context-engine/delegate.js";
export { onDiagnosticEvent } from "../infra/diagnostic-events.js";
export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js";

View File

@@ -101,7 +101,9 @@ describe("plugin-sdk exports", () => {
"delegateCompactionToRuntime",
"emptyPluginConfigSchema",
"onDiagnosticEvent",
"optionalStringEnum",
"registerContextEngine",
"stringEnum",
]);
});

View File

@@ -464,6 +464,8 @@ describe("plugin-sdk root alias", () => {
expect(typeof rootSdk.delegateCompactionToRuntime).toBe("function");
expect(typeof rootSdk.resolveControlCommandGate).toBe("function");
expect(typeof rootSdk.onDiagnosticEvent).toBe("function");
expect(typeof rootSdk.optionalStringEnum).toBe("function");
expect(typeof rootSdk.stringEnum).toBe("function");
expect(typeof rootSdk.buildChannelConfigSchema).toBe("function");
expect(typeof rootSdk.normalizeAccountId).toBe("function");
expect(typeof rootSdk.resolvePreferredOpenClawTmpDir).toBe("function");