fix(models): default local custom providers to completions

This commit is contained in:
Peter Steinberger
2026-04-27 13:09:54 +01:00
parent b6c8e51dcb
commit 63eaf8ea51
10 changed files with 124 additions and 9 deletions

View File

@@ -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 `<think>` 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.

View File

@@ -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.<id> '<json>' --strict-json --merge` or `openclaw config set models.providers.<id>.models '<json-array>' --strict-json --merge` for additive updates. `config set` refuses destructive replacements unless you pass `--replace`.
</Accordion>
<Accordion title="Provider connection and auth">
- `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`.
</Accordion>
<Accordion title="Model catalog entries">

View File

@@ -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.<id>.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

View File

@@ -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.<id>.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.<id>.request.allowPrivateNetwork: true` explicitly.
## Related

View File

@@ -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() }],

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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),
};
}

View File

@@ -150,7 +150,6 @@ function resolveModelRequestPolicy(model: Model<Api>) {
capability: "llm",
transport: "stream",
request,
allowPrivateNetwork: request?.allowPrivateNetwork === true,
});
}