fix: discover self-hosted provider wildcards

This commit is contained in:
rendrag-git
2026-05-13 00:18:12 +00:00
committed by Sally O'Malley
parent 0c5bbdaad0
commit 3b361cf51c
7 changed files with 361 additions and 137 deletions

View File

@@ -20,7 +20,7 @@ SGLang serves open-weight models via an OpenAI-compatible HTTP API. OpenClaw con
| Streaming usage | Yes (`supportsStreamingUsage: true`) |
| Pricing | Marked external-free (`modelPricing.external: false`) |
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY` and you do not define an explicit `models.providers.sglang` entry — see [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY`. Use `sglang/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom SGLang base URL. See [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
## Getting started
@@ -71,8 +71,10 @@ define `models.providers.sglang`, OpenClaw will query:
and convert the returned IDs into model entries.
<Note>
If you set `models.providers.sglang` explicitly, auto-discovery is skipped and
you must define models manually.
If you set `models.providers.sglang` explicitly, OpenClaw uses your declared
models by default. Add `"sglang/*": {}` to `agents.defaults.models` when you
want OpenClaw to query that configured provider's `/models` endpoint and include
all advertised SGLang models.
</Note>
## Explicit configuration (manual models)

View File

@@ -8,7 +8,7 @@ title: "vLLM"
vLLM can serve open-source (and some custom) models via an **OpenAI-compatible** HTTP API. OpenClaw connects to vLLM using the `openai-completions` API.
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth) and you do not define an explicit `models.providers.vllm` entry.
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth). Use `vllm/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom vLLM base URL.
OpenClaw treats `vllm` as a local OpenAI-compatible provider that supports
streamed usage accounting, so status/context token counts can update from
@@ -72,7 +72,7 @@ GET http://127.0.0.1:8000/v1/models
and converts the returned IDs into model entries.
<Note>
If you set `models.providers.vllm` explicitly, auto-discovery is skipped and you must define models manually.
If you set `models.providers.vllm` explicitly, OpenClaw uses your declared models by default. Add `"vllm/*": {}` to `agents.defaults.models` when you want OpenClaw to query that configured provider's `/models` endpoint and include all advertised vLLM models.
</Note>
## Explicit configuration (manual models)
@@ -111,6 +111,21 @@ Use explicit config when:
}
```
To keep this provider dynamic without manually listing every model, add a provider
wildcard to the visible model catalog:
```json5
{
agents: {
defaults: {
models: {
"vllm/*": {},
},
},
},
}
```
## Advanced configuration
<AccordionGroup>
@@ -331,7 +346,7 @@ Use explicit config when:
</Accordion>
<Accordion title="No models discovered">
Auto-discovery requires `VLLM_API_KEY` to be set **and** no explicit `models.providers.vllm` config entry. If you have defined the provider manually, OpenClaw skips discovery and uses only your declared models.
Auto-discovery requires `VLLM_API_KEY` to be set. If you have defined `models.providers.vllm`, OpenClaw uses only your declared models unless `agents.defaults.models` includes `"vllm/*": {}`.
</Accordion>
<Accordion title="Tools render as raw text">

View File

@@ -1,128 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fileURLToPath } from "node:url";
import { describeVllmProviderDiscoveryContract } from "openclaw/plugin-sdk/provider-test-contracts";
const buildVllmProviderMock = vi.hoisted(() => vi.fn());
type DiscoverOpenAICompatibleSelfHostedProviderParams = {
buildProvider: (args: { apiKey?: string }) => Promise<Record<string, unknown>>;
ctx: {
resolveProviderApiKey: () => {
apiKey?: string;
};
resolveProviderAuth: () => {
discoveryApiKey?: string;
};
};
providerId: string;
};
const discoverOpenAICompatibleSelfHostedProviderMock = vi.hoisted(() =>
vi.fn(async (params: DiscoverOpenAICompatibleSelfHostedProviderParams) => ({
provider: {
...(await params.buildProvider({
apiKey: params.ctx.resolveProviderAuth().discoveryApiKey,
})),
apiKey: params.ctx.resolveProviderApiKey().apiKey,
},
})),
);
vi.mock("./api.js", () => ({
VLLM_DEFAULT_API_KEY_ENV_VAR: "VLLM_API_KEY",
VLLM_DEFAULT_BASE_URL: "http://127.0.0.1:8000/v1",
VLLM_MODEL_PLACEHOLDER: "meta-llama/Meta-Llama-3-8B-Instruct",
VLLM_PROVIDER_LABEL: "vLLM",
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
}));
vi.mock("openclaw/plugin-sdk/provider-setup", () => ({
discoverOpenAICompatibleSelfHostedProvider: (
params: DiscoverOpenAICompatibleSelfHostedProviderParams,
) => discoverOpenAICompatibleSelfHostedProviderMock(params),
}));
type ProviderDiscoveryRun = (ctx: {
config: Record<string, unknown>;
env: NodeJS.ProcessEnv;
resolveProviderApiKey: () => {
apiKey: string | undefined;
discoveryApiKey?: string;
};
resolveProviderAuth: () => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
};
}) => Promise<unknown>;
type RegisteredVllmProvider = {
id: string;
catalog?: {
order?: string;
run: ProviderDiscoveryRun;
};
};
describe("vllm provider discovery contract", () => {
beforeEach(() => {
buildVllmProviderMock.mockReset();
discoverOpenAICompatibleSelfHostedProviderMock.mockClear();
});
it("keeps self-hosted discovery provider-owned", async () => {
const { default: plugin } = await import("./index.js");
let provider: RegisteredVllmProvider | undefined;
plugin.register({
registerProvider: (registeredProvider) => {
provider = registeredProvider as RegisteredVllmProvider;
},
} as OpenClawPluginApi);
expect(provider?.id).toBe("vllm");
expect(provider?.catalog?.order).toBe("late");
const catalog = provider?.catalog;
if (!catalog) {
throw new Error("expected vllm provider catalog hook");
}
buildVllmProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
});
await expect(
catalog.run({
config: {},
env: {
VLLM_API_KEY: "env-vllm-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
}),
resolveProviderAuth: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
apiKey: "VLLM_API_KEY",
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
},
});
expect(buildVllmProviderMock).toHaveBeenCalledWith({
apiKey: "env-vllm-key",
});
expect(discoverOpenAICompatibleSelfHostedProviderMock).toHaveBeenCalledTimes(1);
const [discoveryParams] = discoverOpenAICompatibleSelfHostedProviderMock.mock.calls.at(0) ?? [];
if (discoveryParams === undefined) {
throw new Error("expected discovery parameters");
}
expect(discoveryParams.providerId).toBe("vllm");
expect(discoveryParams.buildProvider).toBeTypeOf("function");
});
describeVllmProviderDiscoveryContract({
load: () => import("./index.js"),
apiModuleId: fileURLToPath(new URL("./api.js", import.meta.url)),
});

View File

@@ -55,6 +55,18 @@ function createProvider(id: string): ProviderPlugin {
};
}
function createTextModel(id: string, name: string) {
return {
id,
name,
reasoning: false,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
}
function firstMockArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown {
const call = mock.mock.calls[0];
if (!call) {
@@ -121,4 +133,87 @@ describe("resolveImplicitProviders startup discovery scope", () => {
) as { discoveryEntriesOnly?: boolean };
expect(discoveryOptions?.discoveryEntriesOnly).toBe(true);
});
it("keeps explicit provider models manual without provider wildcard visibility", async () => {
const explicitProvider = {
baseUrl: "http://vllm.example/v1",
api: "openai-completions" as const,
models: [createTextModel("manual-model", "Manual Model")],
};
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([createProvider("vllm")]);
mocks.runProviderCatalog.mockResolvedValue({
provider: {
baseUrl: "http://vllm.example/v1",
api: "openai-completions" as const,
models: [createTextModel("discovered-model", "Discovered Model")],
},
});
const providers = await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {
agents: {
defaults: {
models: {
"vllm/manual-model": {},
},
},
},
models: {
providers: {
vllm: explicitProvider,
},
},
},
env: {} as NodeJS.ProcessEnv,
explicitProviders: {
vllm: explicitProvider,
},
});
expect(providers?.vllm?.models.map((model) => model.id)).toEqual(["manual-model"]);
});
it("merges discovered self-hosted models into explicit provider models for wildcard visibility", async () => {
const explicitProvider = {
baseUrl: "http://vllm.example/v1",
api: "openai-completions" as const,
models: [createTextModel("manual-model", "Manual Model")],
};
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([createProvider("vllm")]);
mocks.runProviderCatalog.mockResolvedValue({
provider: {
baseUrl: "http://vllm.example/v1",
api: "openai-completions" as const,
models: [createTextModel("discovered-model", "Discovered Model")],
},
});
const providers = await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {
agents: {
defaults: {
models: {
"vllm/*": {},
},
},
},
models: {
providers: {
vllm: explicitProvider,
},
},
},
env: {} as NodeJS.ProcessEnv,
explicitProviders: {
vllm: explicitProvider,
},
});
expect(providers?.vllm?.models.map((model) => model.id)).toEqual([
"manual-model",
"discovered-model",
]);
});
});

View File

@@ -14,6 +14,8 @@ import {
isNonSecretApiKeyMarker,
resolveNonEnvSecretRefApiKeyMarker,
} from "./model-auth-markers.js";
import { parseConfiguredModelVisibilityEntries } from "./model-selection-shared.js";
import { mergeProviderModels } from "./models-config.merge.js";
import type {
ProviderApiKeyResolver,
ProviderAuthResolver,
@@ -257,6 +259,7 @@ function mergeImplicitProviderConfig(params: {
providerId: string;
existing: ProviderConfig | undefined;
implicit: ProviderConfig;
dynamicProviderModels?: boolean;
}): ProviderConfig {
const { providerId, existing, implicit } = params;
if (!existing) {
@@ -266,6 +269,9 @@ function mergeImplicitProviderConfig(params: {
if (merge) {
return merge({ existing, implicit });
}
if (params.dynamicProviderModels) {
return mergeProviderModels(implicit, existing);
}
return {
...implicit,
...existing,
@@ -308,6 +314,15 @@ function resolveExistingImplicitProviderFromContext(params: {
);
}
function hasProviderWildcardVisibility(params: {
config?: OpenClawConfig;
providerId: string;
}): boolean {
return parseConfiguredModelVisibilityEntries({ cfg: params.config }).providerWildcards.has(
normalizeProviderId(params.providerId),
);
}
async function resolvePluginImplicitProviders(
ctx: ImplicitProviderContext,
providers: import("../plugins/types.js").ProviderPlugin[],
@@ -388,6 +403,10 @@ async function resolvePluginImplicitProviders(
],
}),
implicit: implicitProvider,
dynamicProviderModels: hasProviderWildcardVisibility({
config: ctx.config,
providerId,
}),
});
}
}

View File

@@ -83,7 +83,7 @@ function runCatalog(
provider: ProviderHandle;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
resolveProviderApiKey?: () => { apiKey: string | undefined };
resolveProviderApiKey?: () => { apiKey: string | undefined; discoveryApiKey?: string };
resolveProviderAuth?: (
providerId?: string,
options?: { oauthMarker?: string },
@@ -376,6 +376,104 @@ export function describeVllmProviderDiscoveryContract(params: {
apiKey: "env-vllm-key",
});
});
it("uses configured transport only for provider wildcard discovery", async () => {
buildVllmProviderMock.mockResolvedValueOnce({
baseUrl: "http://vllm-router.example/v1",
api: "openai-completions",
models: [{ id: "router-model", name: "Router Model" }],
});
await expect(
runCatalog(state, {
provider: state.vllmProvider!,
config: {
agents: {
defaults: {
models: {
"vllm/*": {},
},
},
},
models: {
providers: {
vllm: {
baseUrl: "http://vllm-router.example/v1",
apiKey: "VLLM_API_KEY",
api: "openai-completions",
models: [],
},
},
},
} as OpenClawConfig,
env: {
VLLM_API_KEY: "env-vllm-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
}),
resolveProviderAuth: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
baseUrl: "http://vllm-router.example/v1",
api: "openai-completions",
apiKey: "VLLM_API_KEY",
models: [{ id: "router-model", name: "Router Model" }],
},
});
expect(buildVllmProviderMock).toHaveBeenCalledWith({
apiKey: "env-vllm-key",
baseUrl: "http://vllm-router.example/v1",
});
});
it("keeps explicit self-hosted provider config manual without wildcard visibility", async () => {
await expect(
runCatalog(state, {
provider: state.vllmProvider!,
config: {
agents: {
defaults: {
models: {
"vllm/manual-model": {},
},
},
},
models: {
providers: {
vllm: {
baseUrl: "http://vllm-router.example/v1",
apiKey: "VLLM_API_KEY",
api: "openai-completions",
models: [],
},
},
},
} as OpenClawConfig,
env: {
VLLM_API_KEY: "env-vllm-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
}),
resolveProviderAuth: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toBeNull();
expect(buildVllmProviderMock).not.toHaveBeenCalled();
});
});
}
@@ -429,6 +527,104 @@ export function describeSglangProviderDiscoveryContract(params: {
apiKey: "env-sglang-key",
});
});
it("uses configured transport only for provider wildcard discovery", async () => {
buildSglangProviderMock.mockResolvedValueOnce({
baseUrl: "http://sglang-router.example/v1",
api: "openai-completions",
models: [{ id: "Qwen/Qwen3-32B", name: "Qwen3-32B" }],
});
await expect(
runCatalog(state, {
provider: state.sglangProvider!,
config: {
agents: {
defaults: {
models: {
"sglang/*": {},
},
},
},
models: {
providers: {
sglang: {
baseUrl: "http://sglang-router.example/v1",
apiKey: "SGLANG_API_KEY",
api: "openai-completions",
models: [],
},
},
},
} as OpenClawConfig,
env: {
SGLANG_API_KEY: "env-sglang-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
}),
resolveProviderAuth: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
baseUrl: "http://sglang-router.example/v1",
api: "openai-completions",
apiKey: "SGLANG_API_KEY",
models: [{ id: "Qwen/Qwen3-32B", name: "Qwen3-32B" }],
},
});
expect(buildSglangProviderMock).toHaveBeenCalledWith({
apiKey: "env-sglang-key",
baseUrl: "http://sglang-router.example/v1",
});
});
it("keeps explicit self-hosted provider config manual without wildcard visibility", async () => {
await expect(
runCatalog(state, {
provider: state.sglangProvider!,
config: {
agents: {
defaults: {
models: {
"sglang/Qwen/Qwen3-32B": {},
},
},
},
models: {
providers: {
sglang: {
baseUrl: "http://sglang-router.example/v1",
apiKey: "SGLANG_API_KEY",
api: "openai-completions",
models: [],
},
},
},
} as OpenClawConfig,
env: {
SGLANG_API_KEY: "env-sglang-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
}),
resolveProviderAuth: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toBeNull();
expect(buildSglangProviderMock).not.toHaveBeenCalled();
});
});
}

View File

@@ -1,5 +1,7 @@
import type { ApiKeyCredential, AuthProfileCredential } from "../agents/auth-profiles/types.js";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js";
import { parseConfiguredModelVisibilityEntries } from "../agents/model-selection-shared.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../agents/provider-id.js";
import {
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
@@ -395,10 +397,23 @@ export async function discoverOpenAICompatibleSelfHostedProvider<
>(params: {
ctx: ProviderDiscoveryContext;
providerId: string;
buildProvider: (params: { apiKey?: string }) => Promise<T>;
buildProvider: (params: { apiKey?: string; baseUrl?: string }) => Promise<T>;
}): Promise<{ provider: T & { apiKey: string } } | null> {
if (params.ctx.config.models?.providers?.[params.providerId]) {
return null;
const configuredProvider = findNormalizedProviderValue(
params.ctx.config.models?.providers,
params.providerId,
);
const configuredBaseUrl = configuredProvider
? normalizeOptionalString(configuredProvider.baseUrl)
: undefined;
if (configuredProvider) {
const visibility = parseConfiguredModelVisibilityEntries({ cfg: params.ctx.config });
if (
!visibility.providerWildcards.has(normalizeProviderId(params.providerId)) ||
!configuredBaseUrl
) {
return null;
}
}
const { apiKey, discoveryApiKey } = params.ctx.resolveProviderApiKey(params.providerId);
if (!apiKey) {
@@ -406,7 +421,10 @@ export async function discoverOpenAICompatibleSelfHostedProvider<
}
return {
provider: {
...(await params.buildProvider({ apiKey: discoveryApiKey })),
...(await params.buildProvider({
apiKey: discoveryApiKey,
...(configuredBaseUrl ? { baseUrl: configuredBaseUrl } : {}),
})),
apiKey,
},
};