fix(providers): guard self-hosted model discovery

This commit is contained in:
Peter Steinberger
2026-04-27 10:37:52 +01:00
parent f9b78fb08e
commit e862e0acb5
3 changed files with 136 additions and 30 deletions

View File

@@ -38,6 +38,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: 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.
- Local models: pass configured provider request timeouts through OpenAI SDK transports and the model idle watchdog so long-running local or custom OpenAI-compatible streams use one timeout knob instead of hitting the SDK's 10-minute default or the 120s idle default. Fixes #63663. Thanks @aidiffuser.
- LM Studio: trust configured LM Studio loopback, LAN, and tailnet endpoints for guarded model requests by default, preserving explicit private-network opt-outs. Refs #60994. Thanks @tnowakow.

View File

@@ -1,8 +1,19 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureOpenAICompatibleSelfHostedProviderNonInteractive } from "./provider-self-hosted-setup.js";
import {
configureOpenAICompatibleSelfHostedProviderNonInteractive,
discoverOpenAICompatibleLocalModels,
} from "./provider-self-hosted-setup.js";
import type { ProviderAuthMethodNonInteractiveContext } from "./types.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => null));
const { fetchWithSsrFGuardMock, upsertAuthProfileWithLock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
upsertAuthProfileWithLock: vi.fn(async () => null),
}));
vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
vi.mock("../agents/auth-profiles/upsert-with-lock.js", () => ({
upsertAuthProfileWithLock,
}));
@@ -74,6 +85,70 @@ async function configureSelfHostedTestProvider(params: {
});
}
describe("discoverOpenAICompatibleLocalModels", () => {
it("uses guarded fetch pinned to the configured self-hosted provider", async () => {
const release = vi.fn(async () => undefined);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(JSON.stringify({ data: [{ id: "Qwen/Qwen3-32B" }] }), {
status: 200,
}),
finalUrl: "http://127.0.0.1:8000/v1/models",
release,
});
const models = await discoverOpenAICompatibleLocalModels({
baseUrl: "http://127.0.0.1:8000/v1/",
apiKey: "self-hosted-test-key",
label: "vLLM",
env: {},
});
expect(models).toEqual([
expect.objectContaining({
id: "Qwen/Qwen3-32B",
name: "Qwen/Qwen3-32B",
}),
]);
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://127.0.0.1:8000/v1/models",
init: { headers: { Authorization: "Bearer self-hosted-test-key" } },
policy: {
hostnameAllowlist: ["127.0.0.1"],
allowPrivateNetwork: true,
},
timeoutMs: 5000,
}),
);
expect(release).toHaveBeenCalledOnce();
});
it("does not allowlist always-blocked metadata hostnames", async () => {
const release = vi.fn(async () => undefined);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(JSON.stringify({ data: [{ id: "metadata-probe" }] }), {
status: 200,
}),
finalUrl: "http://metadata.google.internal/v1/models",
release,
});
await discoverOpenAICompatibleLocalModels({
baseUrl: "http://metadata.google.internal/v1",
label: "vLLM",
env: {},
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://metadata.google.internal/v1/models",
policy: undefined,
}),
);
expect(release).toHaveBeenCalledOnce();
});
});
describe("configureOpenAICompatibleSelfHostedProviderNonInteractive", () => {
it.each([
{

View File

@@ -7,6 +7,8 @@ import {
} from "../agents/self-hosted-provider-defaults.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
normalizeOptionalString,
@@ -40,6 +42,26 @@ function isReasoningModelHeuristic(modelId: string): boolean {
return /r1|reasoning|think|reason/i.test(modelId);
}
const SELF_HOSTED_ALWAYS_BLOCKED_HOSTNAMES = new Set(["metadata.google.internal"]);
function buildSelfHostedBaseUrlSsrFPolicy(baseUrl: string): SsrFPolicy | undefined {
try {
const parsed = new URL(baseUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
if (SELF_HOSTED_ALWAYS_BLOCKED_HOSTNAMES.has(parsed.hostname.toLowerCase())) {
return undefined;
}
return {
hostnameAllowlist: [parsed.hostname],
allowPrivateNetwork: true,
};
} catch {
return undefined;
}
}
export async function discoverOpenAICompatibleLocalModels(params: {
baseUrl: string;
apiKey?: string;
@@ -58,36 +80,44 @@ export async function discoverOpenAICompatibleLocalModels(params: {
try {
const trimmedApiKey = normalizeOptionalString(params.apiKey);
const response = await fetch(url, {
headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined,
signal: AbortSignal.timeout(5000),
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined,
},
policy: buildSelfHostedBaseUrlSsrFPolicy(trimmedBaseUrl),
timeoutMs: 5000,
});
if (!response.ok) {
log.warn(`Failed to discover ${params.label} models: ${response.status}`);
return [];
}
const data = (await response.json()) as OpenAICompatModelsResponse;
const models = data.data ?? [];
if (models.length === 0) {
log.warn(`No ${params.label} models found on local instance`);
return [];
}
try {
if (!response.ok) {
log.warn(`Failed to discover ${params.label} models: ${response.status}`);
return [];
}
const data = (await response.json()) as OpenAICompatModelsResponse;
const models = data.data ?? [];
if (models.length === 0) {
log.warn(`No ${params.label} models found on local instance`);
return [];
}
return models
.map((model) => ({ id: normalizeOptionalString(model.id) ?? "" }))
.filter((model) => Boolean(model.id))
.map((model) => {
const modelId = model.id;
return {
id: modelId,
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
cost: SELF_HOSTED_DEFAULT_COST,
contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
});
return models
.map((model) => ({ id: normalizeOptionalString(model.id) ?? "" }))
.filter((model) => Boolean(model.id))
.map((model) => {
const modelId = model.id;
return {
id: modelId,
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
cost: SELF_HOSTED_DEFAULT_COST,
contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
});
} finally {
await release();
}
} catch (error) {
log.warn(`Failed to discover ${params.label} models: ${String(error)}`);
return [];