mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 01:34:31 +00:00
onboard: support custom provider in non-interactive flow (#14223)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 5b98d6514e
Co-authored-by: ENCHIGO <38551565+ENCHIGO@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -20,6 +20,7 @@ type EnvSnapshot = {
|
||||
skipCanvas: string | undefined;
|
||||
token: string | undefined;
|
||||
password: string | undefined;
|
||||
customApiKey: string | undefined;
|
||||
disableConfigCache: string | undefined;
|
||||
};
|
||||
|
||||
@@ -39,6 +40,7 @@ function captureEnv(): EnvSnapshot {
|
||||
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
|
||||
customApiKey: process.env.CUSTOM_API_KEY,
|
||||
disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE,
|
||||
};
|
||||
}
|
||||
@@ -61,6 +63,7 @@ function restoreEnv(prev: EnvSnapshot): void {
|
||||
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas);
|
||||
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token);
|
||||
restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password);
|
||||
restoreEnvVar("CUSTOM_API_KEY", prev.customApiKey);
|
||||
restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache);
|
||||
}
|
||||
|
||||
@@ -77,6 +80,7 @@ async function withOnboardEnv(
|
||||
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1";
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete process.env.CUSTOM_API_KEY;
|
||||
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
const configPath = path.join(tempHome, "openclaw.json");
|
||||
@@ -324,4 +328,240 @@ describe("onboard (non-interactive): provider auth", () => {
|
||||
});
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("configures a custom provider from non-interactive flags", async () => {
|
||||
await withOnboardEnv("openclaw-onboard-custom-provider-", async ({ configPath, runtime }) => {
|
||||
await runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://llm.example.com/v1",
|
||||
customApiKey: "custom-test-key",
|
||||
customModelId: "foo-large",
|
||||
customCompatibility: "anthropic",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const cfg = await readJsonFile<{
|
||||
models?: {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
apiKey?: string;
|
||||
models?: Array<{ id?: string }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
agents?: { defaults?: { model?: { primary?: string } } };
|
||||
}>(configPath);
|
||||
|
||||
const provider = cfg.models?.providers?.["custom-llm-example-com"];
|
||||
expect(provider?.baseUrl).toBe("https://llm.example.com/v1");
|
||||
expect(provider?.api).toBe("anthropic-messages");
|
||||
expect(provider?.apiKey).toBe("custom-test-key");
|
||||
expect(provider?.models?.some((model) => model.id === "foo-large")).toBe(true);
|
||||
expect(cfg.agents?.defaults?.model?.primary).toBe("custom-llm-example-com/foo-large");
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("infers custom provider auth choice from custom flags", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-infer-",
|
||||
async ({ configPath, runtime }) => {
|
||||
await runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
customApiKey: "custom-test-key",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const cfg = await readJsonFile<{
|
||||
models?: {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
agents?: { defaults?: { model?: { primary?: string } } };
|
||||
}>(configPath);
|
||||
|
||||
expect(cfg.models?.providers?.["custom-models-custom-local"]?.baseUrl).toBe(
|
||||
"https://models.custom.local/v1",
|
||||
);
|
||||
expect(cfg.models?.providers?.["custom-models-custom-local"]?.api).toBe(
|
||||
"openai-completions",
|
||||
);
|
||||
expect(cfg.agents?.defaults?.model?.primary).toBe("custom-models-custom-local/local-large");
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("uses CUSTOM_API_KEY env fallback for non-interactive custom provider auth", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-env-fallback-",
|
||||
async ({ configPath, runtime }) => {
|
||||
process.env.CUSTOM_API_KEY = "custom-env-key";
|
||||
|
||||
await runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const cfg = await readJsonFile<{
|
||||
models?: {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
apiKey?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}>(configPath);
|
||||
|
||||
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
|
||||
"custom-env-key",
|
||||
);
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("uses matching profile fallback for non-interactive custom provider auth", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-profile-fallback-",
|
||||
async ({ configPath, runtime }) => {
|
||||
const { upsertAuthProfile } = await import("../agents/auth-profiles.js");
|
||||
upsertAuthProfile({
|
||||
profileId: "custom-models-custom-local:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "custom-models-custom-local",
|
||||
key: "custom-profile-key",
|
||||
},
|
||||
});
|
||||
|
||||
await runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const cfg = await readJsonFile<{
|
||||
models?: {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
apiKey?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}>(configPath);
|
||||
|
||||
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
|
||||
"custom-profile-key",
|
||||
);
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("fails custom provider auth when compatibility is invalid", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-invalid-compat-",
|
||||
async ({ runtime }) => {
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
customCompatibility: "xmlrpc",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow('Invalid --custom-compatibility (use "openai" or "anthropic").');
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("fails custom provider auth when explicit provider id is invalid", async () => {
|
||||
await withOnboardEnv("openclaw-onboard-custom-provider-invalid-id-", async ({ runtime }) => {
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
customProviderId: "!!!",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Invalid custom provider config: Custom provider ID must include letters, numbers, or hyphens.",
|
||||
);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("fails inferred custom auth when required flags are incomplete", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-missing-required-",
|
||||
async ({ runtime }) => {
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
customApiKey: "custom-test-key",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow('Auth choice "custom-api-key" requires a base URL and model ID.');
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user