mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 23:55:12 +00:00
fix(models): default local custom providers to completions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() }],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,6 @@ function resolveModelRequestPolicy(model: Model<Api>) {
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
request,
|
||||
allowPrivateNetwork: request?.allowPrivateNetwork === true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user