diff --git a/CHANGELOG.md b/CHANGELOG.md index 057937dabc8..4cb683019de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe. - Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319. - Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou. - Agents/reasoning: recover fully wrapped unclosed `` replies that would otherwise sanitize to empty text while keeping strict stripping for closed reasoning blocks and unclosed tails after visible text. Fixes #37696; supersedes #51915. Thanks @druide67 and @okuyam2y. diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 096eee8881b..979863b03e6 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -432,7 +432,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi - Safe edits: use `openclaw config set models.providers. '' --strict-json --merge` or `openclaw config set models.providers..models '' --strict-json --merge` for additive updates. `config set` refuses destructive replacements unless you pass `--replace`. - - `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc). For self-hosted `/v1/chat/completions` backends such as MLX, vLLM, SGLang, and most OpenAI-compatible local servers, use `openai-completions`. Use `openai-responses` only when the backend supports `/v1/responses`. + - `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc). For self-hosted `/v1/chat/completions` backends such as MLX, vLLM, SGLang, and most OpenAI-compatible local servers, use `openai-completions`. A custom provider with `baseUrl` but no `api` defaults to `openai-completions`; set `openai-responses` only when the backend supports `/v1/responses`. - `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution). - `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`). - `models.providers.*.contextWindow`: default native context window for models under this provider when the model entry does not set `contextWindow`. @@ -451,7 +451,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi - `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`). - `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object. - `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`. - - `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`. + - `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). Loopback model-provider stream URLs such as `localhost`, `127.0.0.1`, and `[::1]` are allowed automatically unless this is explicitly set to `false`; LAN, tailnet, and private DNS hosts still require opt-in. WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`. diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index 5595ca5c5e5..8e36625ece5 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -151,6 +151,11 @@ endpoint and model ID: } ``` +If `api` is omitted on a custom provider with a `baseUrl`, OpenClaw defaults to +`openai-completions`. Loopback endpoints such as `127.0.0.1` are trusted +automatically; LAN, tailnet, and private DNS endpoints still need +`request.allowPrivateNetwork: true`. + The `models.providers..models[].id` value is provider-local. Do not include the provider prefix there. For example, an MLX server started with `mlx_lm.server --model mlx-community/Qwen3-30B-A3B-6bit` should use this diff --git a/docs/providers/lmstudio.md b/docs/providers/lmstudio.md index b71c279d2f8..d335fde69a6 100644 --- a/docs/providers/lmstudio.md +++ b/docs/providers/lmstudio.md @@ -189,7 +189,7 @@ Use the LM Studio host's reachable address, keep `/v1`, and make sure LM Studio } ``` -Unlike generic OpenAI-compatible providers, `lmstudio` automatically trusts its configured local/private endpoint for guarded model requests. If you use a custom provider id instead of `lmstudio`, set `models.providers..request.allowPrivateNetwork: true` explicitly. +Unlike generic OpenAI-compatible providers, `lmstudio` automatically trusts its configured local/private endpoint for guarded model requests. Custom loopback provider IDs such as `localhost` or `127.0.0.1` are also trusted automatically; for LAN, tailnet, or private DNS custom provider IDs, set `models.providers..request.allowPrivateNetwork: true` explicitly. ## Related diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 6f4a0cf22b0..dd143d0a905 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -500,9 +500,8 @@ describe("openai transport stream", () => { maxTokens: 256, requestTimeoutMs: 900_000, } satisfies Model<"openai-completions"> & { requestTimeoutMs: number }; - const model = attachModelProviderRequestTransport(baseModel, { allowPrivateNetwork: true }); const stream = createOpenAICompletionsTransportStreamFn()( - model, + baseModel, { systemPrompt: "system", messages: [{ role: "user", content: "Reply OK", timestamp: Date.now() }], diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index fad57243061..3f1ece55438 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -41,6 +41,7 @@ vi.mock("./openrouter-model-capabilities.js", () => ({ })); import type { OpenClawConfig } from "../../config/config.js"; +import { getModelProviderRequestTransport } from "../provider-request-config.js"; import { buildForwardCompatTemplate } from "./model.forward-compat.test-support.js"; import { buildInlineProviderModels, resolveModel, resolveModelAsync } from "./model.js"; import { @@ -162,6 +163,36 @@ describe("resolveModel", () => { expect(result.model?.baseUrl).toBe("http://localhost:9000"); expect(result.model?.provider).toBe("custom"); expect(result.model?.id).toBe("missing-model"); + expect(result.model?.api).toBe("openai-completions"); + }); + + it("defaults baseUrl-only local custom fallback models to chat completions", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "local-agent-proxy/gpt-5.2" }, + }, + }, + models: { + providers: { + "local-agent-proxy": { + baseUrl: "http://127.0.0.1:3000/v1", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModelForTest("local-agent-proxy", "gpt-5.2", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "local-agent-proxy", + id: "gpt-5.2", + api: "openai-completions", + baseUrl: "http://127.0.0.1:3000/v1", + }); + expect(getModelProviderRequestTransport(result.model ?? {})).toBeUndefined(); }); it("normalizes Google fallback baseUrls for custom providers", () => { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 9679813a7ff..1487fe92df3 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -260,6 +260,16 @@ function resolveProviderTransport(params: { }; } +function resolveConfiguredProviderDefaultApi( + providerConfig: InlineProviderConfig | undefined, +): Api | undefined { + const explicit = normalizeResolvedTransportApi(providerConfig?.api); + if (explicit) { + return explicit; + } + return providerConfig?.baseUrl ? "openai-completions" : undefined; +} + function resolveProviderRequestTimeoutMs(timeoutSeconds: unknown): number | undefined { if ( typeof timeoutSeconds !== "number" || @@ -516,7 +526,11 @@ function applyConfiguredProviderOverrides(params: { const resolvedTransport = resolveProviderTransport({ provider: params.provider, - api: metadataOverrideModel?.api ?? providerConfig.api ?? discoveredModel.api, + api: + metadataOverrideModel?.api ?? + providerConfig.api ?? + discoveredModel.api ?? + resolveConfiguredProviderDefaultApi(providerConfig), baseUrl: providerConfig.baseUrl ?? discoveredModel.baseUrl, cfg: params.cfg, runtimeHooks: params.runtimeHooks, @@ -530,6 +544,7 @@ function applyConfiguredProviderOverrides(params: { api: resolvedTransport.api ?? normalizeResolvedTransportApi(discoveredModel.api) ?? + resolveConfiguredProviderDefaultApi(providerConfig) ?? "openai-responses", baseUrl: resolvedTransport.baseUrl ?? discoveredModel.baseUrl, discoveredHeaders, @@ -750,7 +765,7 @@ function resolveConfiguredFallbackModel(params: { } const fallbackTransport = resolveProviderTransport({ provider, - api: providerConfig?.api ?? "openai-responses", + api: resolveConfiguredProviderDefaultApi(providerConfig) ?? "openai-responses", baseUrl: providerConfig?.baseUrl, cfg, runtimeHooks, diff --git a/src/agents/provider-request-config.test.ts b/src/agents/provider-request-config.test.ts index 3adae4b5ef4..d077a2a8c8d 100644 --- a/src/agents/provider-request-config.test.ts +++ b/src/agents/provider-request-config.test.ts @@ -533,4 +533,41 @@ describe("provider request config", () => { "X-Custom": "1", }); }); + + it("auto-allows loopback model-provider stream requests", () => { + const resolved = resolveProviderRequestPolicyConfig({ + provider: "local-agent-proxy", + api: "openai-completions", + baseUrl: "http://127.0.0.1:3000/v1", + capability: "llm", + transport: "stream", + }); + + expect(resolved.allowPrivateNetwork).toBe(true); + }); + + it("keeps explicit private-network denial for loopback model requests", () => { + const resolved = resolveProviderRequestPolicyConfig({ + provider: "local-agent-proxy", + api: "openai-completions", + baseUrl: "http://127.0.0.1:3000/v1", + capability: "llm", + transport: "stream", + request: { allowPrivateNetwork: false }, + }); + + expect(resolved.allowPrivateNetwork).toBe(false); + }); + + it("does not auto-allow non-loopback private model-provider hosts", () => { + const resolved = resolveProviderRequestPolicyConfig({ + provider: "local-agent-proxy", + api: "openai-completions", + baseUrl: "http://192.168.1.20:3000/v1", + capability: "llm", + transport: "stream", + }); + + expect(resolved.allowPrivateNetwork).toBe(false); + }); }); diff --git a/src/agents/provider-request-config.ts b/src/agents/provider-request-config.ts index 74dcde7767c..ac9997aa0cd 100644 --- a/src/agents/provider-request-config.ts +++ b/src/agents/provider-request-config.ts @@ -6,6 +6,7 @@ import type { } from "../config/types.provider-request.js"; import { assertSecretInputResolved } from "../config/types.secrets.js"; import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js"; +import { isLoopbackIpAddress } from "../shared/net/ip.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { ProviderRequestCapabilities, @@ -166,6 +167,30 @@ type ResolveProviderRequestPolicyConfigParams = { request?: ModelProviderRequestTransportOverrides; }; +function isLoopbackProviderBaseUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) { + return false; + } + try { + const host = new URL(baseUrl).hostname.trim().toLowerCase().replace(/\.+$/, ""); + return host === "localhost" || host.endsWith(".localhost") || isLoopbackIpAddress(host); + } catch { + return false; + } +} + +function shouldAutoAllowLoopbackModelRequest( + params: ResolveProviderRequestPolicyConfigParams, +): boolean { + return ( + params.capability === "llm" && + params.transport === "stream" && + params.allowPrivateNetwork === undefined && + params.request?.allowPrivateNetwork === undefined && + isLoopbackProviderBaseUrl(params.baseUrl) + ); +} + function sanitizeConfiguredRequestString(value: unknown, path: string): string | undefined { if (typeof value !== "string") { // Config transport overrides are sanitized after secrets runtime resolution. @@ -659,7 +684,10 @@ export function resolveProviderRequestPolicyConfig( tls: resolveTlsOverride(params.request?.tls), policy, capabilities, - allowPrivateNetwork: params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork ?? false, + allowPrivateNetwork: + params.allowPrivateNetwork ?? + params.request?.allowPrivateNetwork ?? + shouldAutoAllowLoopbackModelRequest(params), }; } diff --git a/src/agents/provider-transport-fetch.ts b/src/agents/provider-transport-fetch.ts index 8e31f8718d0..745f89e2c58 100644 --- a/src/agents/provider-transport-fetch.ts +++ b/src/agents/provider-transport-fetch.ts @@ -150,7 +150,6 @@ function resolveModelRequestPolicy(model: Model) { capability: "llm", transport: "stream", request, - allowPrivateNetwork: request?.allowPrivateNetwork === true, }); }