From 5ff49ae03e088b445ca97eeeac41747e8d971b2d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 09:08:21 +0100 Subject: [PATCH] fix(gateway): skip local model pricing refreshes --- CHANGELOG.md | 1 + src/gateway/model-pricing-cache.test.ts | 35 ++++++++++++++ src/gateway/model-pricing-cache.ts | 61 ++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b02b963df3..cceb6e03bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers. Thanks @codex. - CLI/plugins: stop security-blocked plugin installs from retrying as hook packs, so normal plugin packages report the scanner failure without a misleading "not a valid hook pack" follow-up. Fixes #61175; supersedes #64102. Thanks @KonsultDigital and @ziyincody. - Control UI/Dreaming: require explicit confirmation before applying restart-impacting Dreaming mode changes, with restart warning copy and loading feedback. Fixes #63804. (#63807) Thanks @bbddbb1. - CLI/update: keep the automatic post-update completion refresh on the core-command tree so it no longer stages bundled plugin runtime deps before the Gateway restart path, avoiding `.24` update hangs and 1006 disconnect cascades. Fixes #72665. Thanks @sakalaboator and @He-Pin. diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 9f04e2fdbfe..03c1e043597 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -121,6 +121,41 @@ describe("model-pricing-cache", () => { expect(refs).toContain("tavily/search-preview"); }); + it("skips remote pricing catalogs for local-only model providers", async () => { + const config = { + agents: { + defaults: { + model: { primary: "ollama/llama3.2:latest" }, + }, + }, + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [{ id: "llama3.2:latest" }], + }, + vllm: { + baseUrl: "http://192.168.1.25:8000/v1", + api: "openai-completions", + models: [{ id: "qwen2.5-coder:7b" }], + }, + }, + }, + tools: { + subagents: { model: { primary: "vllm/qwen2.5-coder:7b" } }, + }, + } as unknown as OpenClawConfig; + const fetchImpl = vi.fn(); + + await refreshGatewayModelPricingCache({ config, fetchImpl }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect( + getCachedGatewayModelPricing({ provider: "ollama", model: "llama3.2:latest" }), + ).toBeUndefined(); + }); + it("loads openrouter pricing and maps provider aliases, wrappers, and anthropic dotted ids", async () => { const config = { agents: { diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index b75e6f1cdb2..f89804c4a8c 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -58,6 +58,8 @@ const WRAPPER_PROVIDERS = new Set([ "openrouter", "vercel-ai-gateway", ]); +const LOCAL_MODEL_PROVIDER_APIS = new Set(["ollama"]); +const LOCAL_MODEL_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]); const log = createSubsystemLogger("gateway").child("model-pricing"); let refreshTimer: ReturnType | null = null; @@ -422,6 +424,60 @@ function addConfiguredWebSearchPluginModels(params: { } } +function isPrivateOrLoopbackHost(hostname: string): boolean { + const host = hostname + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + if ( + host === "localhost" || + host === "localhost.localdomain" || + host.endsWith(".localhost") || + host.endsWith(".local") + ) { + return true; + } + if (host === "::1" || host === "0:0:0:0:0:0:0:1" || host.startsWith("fe80:")) { + return true; + } + if (host.startsWith("fc") || host.startsWith("fd")) { + return true; + } + if (host.startsWith("127.") || host.startsWith("10.") || host.startsWith("192.168.")) { + return true; + } + return /^172\.(1[6-9]|2\d|3[0-1])\./u.test(host) || host.startsWith("169.254."); +} + +function isPrivateOrLoopbackBaseUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) { + return false; + } + try { + return isPrivateOrLoopbackHost(new URL(baseUrl).hostname); + } catch { + return false; + } +} + +function shouldFetchExternalPricingForRef(config: OpenClawConfig, ref: ModelRef): boolean { + const providerConfig = config.models?.providers?.[ref.provider]; + if (providerConfig?.api && LOCAL_MODEL_PROVIDER_APIS.has(providerConfig.api)) { + return false; + } + if (LOCAL_MODEL_PROVIDER_IDS.has(ref.provider)) { + return false; + } + if (isPrivateOrLoopbackBaseUrl(providerConfig?.baseUrl)) { + return false; + } + return true; +} + +function filterExternalPricingRefs(config: OpenClawConfig, refs: ModelRef[]): ModelRef[] { + return refs.filter((ref) => shouldFetchExternalPricingForRef(config, ref)); +} + export function collectConfiguredModelPricingRefs(config: OpenClawConfig): ModelRef[] { const refs = new Map(); const aliasIndex = buildModelAliasIndex({ @@ -547,7 +603,10 @@ export async function refreshGatewayModelPricingCache(params: { } const fetchImpl = params.fetchImpl ?? fetch; inFlightRefresh = (async () => { - const refs = collectConfiguredModelPricingRefs(params.config); + const refs = filterExternalPricingRefs( + params.config, + collectConfiguredModelPricingRefs(params.config), + ); if (refs.length === 0) { replaceGatewayModelPricingCache(new Map()); clearRefreshTimer();