fix(onboard): preserve discovery for explicit proxy URLs (#149757)

## What Problem This Solves

Proxy setup seeded a list that hid discovered models. Omitting it also exposed first-request capability loss, now repaired by merged prerequisites #150003 and #150136.

## Why This Change Was Made

The onboarding preset alone decides whether to seed; this state has one writer. Explicit URL + merge preserves authored rows or leaves discovery an empty list. Replace mode disables discovery and keeps the default. Production growth: two lines for this distinction.

Growth accepted under the owner's standing acceptance (2026-09-15).

## User Impact

Fresh remote setup can list proxy models. Replace mode and setup without a URL keep a usable default.

## Evidence

After supported noninteractive onboarding to an isolated proxy, `agent --local --json` before discovery returns the exact fixture reply with 1M context, medium thinking, configured URL/auth and no fallback. Explicit off stays off. `models list --provider litellm --refresh --json` then makes one authenticated GET and lists the returned row; the original setup showed only its seed. Replace makes one inference POST and zero discovery GETs.

These fresh checks ran after integrating both prerequisites. The final commit only corrects test inputs; production is identical. This proves a synthetic HTTP boundary, not external inference, images or cache performance.

## Compatibility

Authored metadata, no-URL defaults and both auth methods remain. No configuration shape or SDK change.

## Consumers

Interactive and noninteractive setup use the shared preset. Agent test inputs now declare authored rows and replace mode before use.

## Invalidation

Existing config publication and explicit refresh remain responsible.

## Tests

Twelve registered setup/catalog cases, four sibling cases, selected preparation/refresh/auth tests and all eight first-request/authority/memory cases pass. Two integrated fixture failures were corrected without weakening assertions. Local preflight and both line-limit checks pass; new-head CI is pending.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi 2026-09-17 02:44:40 +05:30 • committed by GitHub
parent 36dd227193
commit f9584dc5cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 201 additions and 92 deletions

View file

@ -92,6 +92,12 @@ spend limits, and backend failover without changing OpenClaw config.
The default model onboarding writes is `litellm/claude-opus-4-6`.
In merge mode, onboarding with an explicit proxy URL preserves any authored provider models and
otherwise leaves the provider model list empty for discovery. Run
`openclaw models list --provider litellm --refresh --json` to list the proxy's models.
With `models.mode: "replace"`, discovery is disabled, so onboarding keeps the documented default
in the provider model list while preserving existing model definitions.
## Image generation
LiteLLM can back the `image_generate` tool through OpenAI-compatible `/images/generations` and

View file

@ -2,8 +2,10 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
capturePluginRegistration,
createTestWizardPrompter,
registerProviderPlugin,
requireRegisteredProvider,
runProviderCatalog,
@ -41,6 +43,89 @@ describe("litellm plugin", () => {
clearLiveCatalogCacheForTests();
});
it.each([
{ authMode: "non-interactive", modelsMode: "merge" },
{ authMode: "interactive", modelsMode: "merge" },
{ authMode: "non-interactive", modelsMode: "replace" },
{ authMode: "interactive", modelsMode: "replace" },
] as const)(
"preserves an explicit proxy's authored models through registered $authMode auth in $modelsMode mode",
async ({ authMode, modelsMode }) => {
const auth = registerProvider()?.auth?.[0];
const config = {
models: {
mode: modelsMode,
providers: {
litellm: {
baseUrl: "https://litellm.example/v1",
api: "anthropic-messages",
apiKey: " old-key ",
models: [
{
id: "custom-model",
name: "Custom",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
} satisfies OpenClawConfig;
let result: OpenClawConfig | null | undefined;
if (authMode === "non-interactive") {
result = await auth?.runNonInteractive?.({
authChoice: "litellm-api-key",
config,
baseConfig: config,
opts: { customBaseUrl: "https://litellm.example/v1/" },
runtime: createRuntimeSpies(),
resolveApiKey: async () => ({ key: "old-key", source: "profile" }),
toApiKeyCredential: () => null,
});
} else {
const interactive = await auth?.run({
config,
opts: { litellmApiKey: "old-key" },
env: {},
runtime: createRuntimeSpies(),
prompter: createTestWizardPrompter(),
secretInputMode: "plaintext",
isRemote: false,
openUrl: async () => {
throw new Error("Unexpected browser auth");
},
oauth: {
createVpsAwareHandlers: () => {
throw new Error("Unexpected OAuth");
},
},
});
expect(interactive?.profiles).toEqual([
{
profileId: "litellm:default",
credential: { type: "api_key", provider: "litellm", key: "old-key" },
},
]);
result = interactive?.configPatch;
}
expect(result?.models?.mode).toBe(modelsMode);
expect(result?.models?.providers?.litellm).toEqual({
baseUrl: "https://litellm.example/v1",
api: "openai-completions",
apiKey: "old-key",
models: [
...config.models.providers.litellm.models,
...(modelsMode === "replace" ? [LITELLM_DEFAULT_MODEL] : []),
],
});
},
);
it.each([
{
name: "default proxy base URL",
@ -96,76 +181,102 @@ describe("litellm plugin", () => {
});
});
it("honors --custom-base-url in non-interactive API-key setup", async () => {
const provider = registerProvider();
const auth = provider?.auth?.[0];
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-litellm-auth-"));
const resolveApiKey = vi.fn(async () => ({ key: "litellm-test-key", source: "flag" as const }));
const toApiKeyCredential = vi.fn(({ provider: providerId, resolved }) => ({
type: "api_key" as const,
provider: providerId,
key: resolved.key,
}));
it.each([
{
modelsMode: undefined,
baseUrl: "https://litellm.example/v1/",
expectedBaseUrl: "https://litellm.example/v1",
expectedModels: [],
},
{
modelsMode: undefined,
baseUrl: undefined,
expectedBaseUrl: "http://localhost:4000",
expectedModels: [LITELLM_DEFAULT_MODEL],
},
{
modelsMode: "replace" as const,
baseUrl: "https://litellm.example/v1/",
expectedBaseUrl: "https://litellm.example/v1",
expectedModels: [LITELLM_DEFAULT_MODEL],
},
])(
"configures proxy URL $baseUrl in $modelsMode mode",
async ({ modelsMode, baseUrl, expectedBaseUrl, expectedModels }) => {
const provider = registerProvider();
const auth = provider?.auth?.[0];
const config = (modelsMode ? { models: { mode: modelsMode } } : {}) satisfies OpenClawConfig;
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-litellm-auth-"));
const resolveApiKey = vi.fn(async () => ({
key: "litellm-test-key",
source: "flag" as const,
}));
const toApiKeyCredential = vi.fn(({ provider: providerId, resolved }) => ({
type: "api_key" as const,
provider: providerId,
key: resolved.key,
}));
try {
const result = await auth?.runNonInteractive?.({
authChoice: "litellm-api-key",
config: {},
baseConfig: {},
opts: {
litellmApiKey: "litellm-test-key",
customBaseUrl: "https://litellm.example/v1/",
},
runtime: createRuntimeSpies(),
agentDir,
resolveApiKey,
toApiKeyCredential,
});
expect(result).toStrictEqual({
auth: {
profiles: {
"litellm:default": {
provider: "litellm",
mode: "api_key",
},
try {
const result = await auth?.runNonInteractive?.({
authChoice: "litellm-api-key",
config,
baseConfig: config,
opts: {
litellmApiKey: "litellm-test-key",
customBaseUrl: baseUrl,
},
},
agents: {
defaults: {
models: {
"litellm/claude-opus-4-6": {
alias: "LiteLLM",
runtime: createRuntimeSpies(),
agentDir,
resolveApiKey,
toApiKeyCredential,
});
expect(result).toStrictEqual({
auth: {
profiles: {
"litellm:default": {
provider: "litellm",
mode: "api_key",
},
},
model: {
primary: "litellm/claude-opus-4-6",
},
agents: {
defaults: {
models: {
"litellm/claude-opus-4-6": {
alias: "LiteLLM",
},
},
model: {
primary: "litellm/claude-opus-4-6",
},
},
},
},
models: {
mode: "merge",
providers: {
litellm: {
baseUrl: "https://litellm.example/v1",
api: "openai-completions",
models: [LITELLM_DEFAULT_MODEL],
models: {
mode: modelsMode ?? "merge",
providers: {
litellm: {
baseUrl: expectedBaseUrl,
api: "openai-completions",
models: expectedModels,
},
},
},
},
});
expect(resolveApiKey).toHaveBeenCalledWith({
provider: "litellm",
flagValue: "litellm-test-key",
flagName: "--litellm-api-key",
envVar: "LITELLM_API_KEY",
});
expect(toApiKeyCredential).toHaveBeenCalledWith({
provider: "litellm",
resolved: { key: "litellm-test-key", source: "flag" },
});
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
});
expect(resolveApiKey).toHaveBeenCalledWith({
provider: "litellm",
flagValue: "litellm-test-key",
flagName: "--litellm-api-key",
envVar: "LITELLM_API_KEY",
});
expect(toApiKeyCredential).toHaveBeenCalledWith({
provider: "litellm",
resolved: { key: "litellm-test-key", source: "flag" },
});
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
},
);
});

View file

@ -1,22 +0,0 @@
// Litellm tests cover onboard plugin behavior.
import { expectProviderOnboardMergedLegacyConfig } from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it } from "vitest";
import { applyLitellmProviderConfig } from "./onboard.js";
describe("litellm onboard", () => {
it("preserves existing baseUrl and api key while adding the default model", () => {
const provider = expectProviderOnboardMergedLegacyConfig({
applyProviderConfig: applyLitellmProviderConfig,
providerId: "litellm",
providerApi: "openai-completions",
baseUrl: "https://litellm.example/v1",
legacyApi: "anthropic-messages",
legacyModelId: "custom-model",
legacyModelName: "Custom",
legacyBaseUrl: "https://litellm.example/v1",
legacyApiKey: " old-key ",
});
expect(provider?.models.map((m) => m.id)).toEqual(["custom-model", "claude-opus-4-6"]);
});
});

View file

@ -1,6 +1,6 @@
// Litellm setup module handles plugin onboarding behavior.
import {
createDefaultModelPresetAppliers,
createDefaultModelsPresetAppliers,
type ModelDefinitionConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
@ -30,7 +30,7 @@ export function buildLitellmModelDefinition(): ModelDefinitionConfig {
}
export const { applyConfig: applyLitellmConfig, applyProviderConfig: applyLitellmProviderConfig } =
createDefaultModelPresetAppliers<[]>({
createDefaultModelsPresetAppliers<[]>({
primaryModelRef: LITELLM_DEFAULT_MODEL_REF,
resolveParams: (cfg: OpenClawConfig) => {
const existingProvider = cfg.models?.providers?.litellm as { baseUrl?: unknown } | undefined;
@ -41,7 +41,9 @@ export const { applyConfig: applyLitellmConfig, applyProviderConfig: applyLitell
providerId: "litellm",
api: "openai-completions" as const,
baseUrl: resolvedBaseUrl || LITELLM_BASE_URL,
defaultModel: buildLitellmModelDefinition(),
// Replace mode disables discovery, so it still needs the configured default.
defaultModels:
resolvedBaseUrl && cfg.models?.mode !== "replace" ? [] : [buildLitellmModelDefinition()],
defaultModelId: LITELLM_DEFAULT_MODEL_ID,
aliases: [{ modelRef: LITELLM_DEFAULT_MODEL_REF, alias: "LiteLLM" }],
};

View file

@ -189,6 +189,9 @@ describe("agent command static capabilities", () => {
}
const baseUrl = `http://127.0.0.1:${address.port}/proxy/v1`;
const env = { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath };
if (testCase.inventory === "replace") {
await fs.writeFile(configPath, JSON.stringify({ models: { mode: "replace" } }));
}
const onboard = runBuiltCli(
home,
[
@ -215,10 +218,19 @@ describe("agent command static capabilities", () => {
expect(onboard.status, onboard.stderr).toBe(0);
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
if (testCase.inventory === "authored") {
config.models.providers.litellm.models[0].contextWindow = 640_000;
config.models.providers.litellm.models[0].reasoning = false;
config.models.providers.litellm.models = [
{
id: config.agents.defaults.model.primary.slice("litellm/".length),
name: "Authored fixture",
reasoning: false,
input: ["text", "image"],
contextWindow: 640_000,
maxTokens: 128_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
];
} else if (testCase.inventory === "replace") {
config.models.mode = "replace";
expect(config.models.providers.litellm.models).toHaveLength(1);
} else if (testCase.inventory === "generic") {
config.models.providers = {
"proxy-fixture": { baseUrl, api: "openai-completions", apiKey: key, models: [] },