mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
36dd227193
commit
f9584dc5cc
5 changed files with 201 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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" }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue