diff --git a/CHANGELOG.md b/CHANGELOG.md index 24934816fda..c8803ba51d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Agents/Bedrock: stop heartbeat runs from persisting blank user transcript turns and repair existing blank user text messages before replay, preventing AWS Bedrock `ContentBlock` blank-text validation failures. Fixes #72640 and #72622. Thanks @goldzulu. - Agents/LM Studio: promote standalone bracketed local-model tool requests into registered tool calls and hide unsupported bracket blocks from visible replies, so MemPalace MCP lookups do not print raw `[tool]` JSON scaffolding in chat. Fixes #66178. Thanks @detroit357. - Local models: warn when an assistant reply looks like a tool call but the provider emitted plain text instead of a structured tool invocation, making fake/non-executed tool calls visible in logs. Fixes #51332. Thanks @emilclaw. +- Local models: accept persisted non-secret local auth markers for private-LAN custom OpenAI-compatible providers, so LAN Ollama configs no longer fail with missing auth when `ollama-local` is saved as the key. Fixes #49736. Thanks @charles-zh. - TUI/local models: treat visible gateway client labels such as `openclaw-tui` as the current requester session for session-aware tools, so Ollama tool calls no longer fail by resolving the UI label as a session id. Fixes #66391. Thanks @kickingzebra. - Local models: route self-hosted OpenAI-compatible model discovery through the guarded fetch path pinned to the configured host, covering vLLM and SGLang setup without reopening local/LAN SSRF probes. Supersedes #46359. Thanks @cdxiaodong. - Local models: classify terminated, reset, closed, timeout, and aborted model-call failures and attach a process memory snapshot to the diagnostic event, making LM Studio/Ollama RAM-pressure failures easier to prove from stability bundles. Refs #65551. Thanks @BigWiLLi111. diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 2f9506f1e51..5dae069702f 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -823,6 +823,17 @@ describe("resolveApiKeyForProvider – synthetic local auth for custom providers expect(auth.source).toContain("synthetic local key"); }); + it("synthesizes a local auth marker for private LAN custom providers with no apiKey", async () => { + const auth = await resolveCustomProviderAuth( + "custom-192-168-0-222-11434", + "http://192.168.0.222:11434/v1", + "qwen3.5:9b", + "Qwen 3.5 9B", + ); + expect(auth.apiKey).toBe(CUSTOM_LOCAL_AUTH_MARKER); + expect(auth.source).toContain("synthetic local key"); + }); + it("synthesizes a local auth marker for localhost custom providers", async () => { const auth = await resolveCustomProviderAuth("my-local", "http://localhost:11434/v1"); expect(auth.apiKey).toBe(CUSTOM_LOCAL_AUTH_MARKER); @@ -912,6 +923,72 @@ describe("resolveApiKeyForProvider – synthetic local auth for custom providers }); }); + it("accepts non-secret local markers for private LAN custom OpenAI-compatible providers", async () => { + const auth = await resolveApiKeyForProvider({ + provider: "custom-192-168-0-222-11434", + cfg: { + models: { + providers: { + "custom-192-168-0-222-11434": { + baseUrl: "http://192.168.0.222:11434/v1", + api: "openai-completions", + apiKey: "ollama-local", + models: [ + { + id: "qwen3.5:9b", + name: "Qwen 3.5 9B", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ], + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }); + + expect(auth).toMatchObject({ + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + source: "models.json (local marker)", + mode: "api-key", + }); + }); + + it("does not accept non-secret local markers for remote custom providers", async () => { + await expect( + resolveApiKeyForProvider({ + provider: "custom-remote", + cfg: { + models: { + providers: { + "custom-remote": { + baseUrl: "https://api.example.com/v1", + api: "openai-completions", + apiKey: "ollama-local", + models: [ + { + id: "qwen3.5:9b", + name: "Qwen 3.5 9B", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ], + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }), + ).rejects.toThrow('No API key found for provider "custom-remote"'); + }); + it("does not synthesize local auth when apiKey is explicitly configured but unresolved", async () => { const previous = process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY; diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 934ff099fdf..f7bb54fcc4a 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -157,22 +157,30 @@ export function resolveUsableCustomProviderApiKey(params: { if (!isNonSecretApiKeyMarker(customKey)) { return { apiKey: customKey, source: "models.json" }; } - if (!isKnownEnvApiKeyMarker(customKey)) { - return null; + if (isKnownEnvApiKeyMarker(customKey)) { + const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[customKey]); + if (!envValue) { + return null; + } + const applied = new Set(getShellEnvAppliedKeys()); + return { + apiKey: envValue, + source: resolveEnvSourceLabel({ + applied, + envVars: [customKey], + label: `${customKey} (models.json marker)`, + }), + }; } - const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[customKey]); - if (!envValue) { - return null; + if ( + customProviderConfig && + isCustomLocalProviderConfig(customProviderConfig) && + customProviderConfig.baseUrl && + isLocalBaseUrl(customProviderConfig.baseUrl) + ) { + return { apiKey: CUSTOM_LOCAL_AUTH_MARKER, source: "models.json (local marker)" }; } - const applied = new Set(getShellEnvAppliedKeys()); - return { - apiKey: envValue, - source: resolveEnvSourceLabel({ - applied, - envVars: [customKey], - label: `${customKey} (models.json marker)`, - }), - }; + return null; } export function hasUsableCustomProviderApiKey( @@ -209,20 +217,37 @@ function resolveProviderAuthOverride( function isLocalBaseUrl(baseUrl: string): boolean { try { - const host = normalizeLowercaseStringOrEmpty(new URL(baseUrl).hostname); + let host = normalizeLowercaseStringOrEmpty(new URL(baseUrl).hostname); + if (host.startsWith("[") && host.endsWith("]")) { + host = host.slice(1, -1); + } return ( host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || - host === "[::1]" || - host === "[::ffff:7f00:1]" || - host === "[::ffff:127.0.0.1]" + host === "::1" || + host === "::ffff:7f00:1" || + host === "::ffff:127.0.0.1" || + host.endsWith(".local") || + isPrivateIpv4Host(host) ); } catch { return false; } } +function isPrivateIpv4Host(host: string): boolean { + if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + return false; + } + const octets = host.split(".").map((part) => Number.parseInt(part, 10)); + if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + const [a, b] = octets; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + function hasExplicitProviderApiKeyConfig(providerConfig: ModelProviderConfig): boolean { return ( normalizeOptionalSecretInput(providerConfig.apiKey) !== undefined ||