mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
6b625357fc
* refactor(cli): consolidate security-sensitive regression fixtures * refactor(cli): refresh fixture cleanup against main
2658 lines
86 KiB
TypeScript
2658 lines
86 KiB
TypeScript
// Model picker tests cover catalog rows, provider metadata, backend defaults, and prompt choices.
|
|
import path from "node:path";
|
|
import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js";
|
|
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { stampConfigWriteMetadata } from "../config/io.meta.js";
|
|
import type { WizardMultiSelectParams, WizardPrompter } from "../wizard/prompts.js";
|
|
import {
|
|
applyModelAllowlist,
|
|
applyModelFallbacksFromSelection,
|
|
promptDefaultModel,
|
|
promptModelAllowlist,
|
|
} from "./model-picker.js";
|
|
import { makePrompter } from "./setup/__tests__/test-utils.js";
|
|
|
|
const loadModelCatalog = vi.hoisted(() => vi.fn());
|
|
const modelCatalogRouteVariants = vi.hoisted(() => ({
|
|
value: undefined as readonly ModelCatalogEntry[] | undefined,
|
|
}));
|
|
vi.mock("../agents/prepared-model-catalog.js", () => ({
|
|
loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => {
|
|
const entries = await loadModelCatalog(...args);
|
|
return { entries, routeVariants: modelCatalogRouteVariants.value ?? entries };
|
|
},
|
|
}));
|
|
|
|
const loadStaticManifestCatalogRowsForList = vi.hoisted(() =>
|
|
vi.fn<() => readonly NormalizedModelCatalogRow[]>(() => []),
|
|
);
|
|
vi.mock("./models/list.manifest-catalog.js", () => ({
|
|
loadStaticManifestCatalogRowsForList,
|
|
}));
|
|
|
|
const loadPreferredProviderPickerCatalog = vi.hoisted(() =>
|
|
vi.fn<
|
|
(_params: {
|
|
cfg: OpenClawConfig;
|
|
preferredProvider: string;
|
|
agentDir?: string;
|
|
workspaceDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}) => Promise<ModelCatalogEntry[]>
|
|
>(async () => []),
|
|
);
|
|
vi.mock("../flows/model-picker.provider-catalog.js", () => ({
|
|
loadPreferredProviderPickerCatalog,
|
|
}));
|
|
|
|
const ensureAuthProfileStore = vi.hoisted(() =>
|
|
vi.fn(() => ({
|
|
version: 1,
|
|
profiles: {},
|
|
})),
|
|
);
|
|
const listProfilesForProvider = vi.hoisted(() => vi.fn(() => []));
|
|
const upsertAuthProfile = vi.hoisted(() => vi.fn());
|
|
vi.mock("../agents/auth-profiles.js", () => ({
|
|
externalCliDiscoveryForProviderAuth: () => ({
|
|
mode: "scoped",
|
|
allowKeychainPrompt: false,
|
|
}),
|
|
ensureAuthProfileStore,
|
|
listProfilesForProvider,
|
|
upsertAuthProfile,
|
|
}));
|
|
|
|
const resolveEnvApiKey = vi.hoisted(() =>
|
|
vi.fn<(_provider: string, _env?: NodeJS.ProcessEnv) => { apiKey: string; source: string } | null>(
|
|
(_provider: string) => ({
|
|
apiKey: "test-key",
|
|
source: "test",
|
|
}),
|
|
),
|
|
);
|
|
const hasUsableCustomProviderApiKey = vi.hoisted(() =>
|
|
vi.fn<(_cfg?: OpenClawConfig, _provider?: string, _env?: NodeJS.ProcessEnv) => boolean>(
|
|
() => false,
|
|
),
|
|
);
|
|
const hasRuntimeAvailableProviderAuth = vi.hoisted(() =>
|
|
vi.fn(
|
|
({
|
|
provider,
|
|
cfg,
|
|
env,
|
|
}: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
workspaceDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}) => {
|
|
if (provider === "amazon-bedrock") {
|
|
const auth = cfg?.models?.providers?.["amazon-bedrock"]?.auth;
|
|
return auth === undefined || auth === "aws-sdk";
|
|
}
|
|
if (resolveEnvApiKey(provider, env)?.apiKey) {
|
|
return true;
|
|
}
|
|
if (hasUsableCustomProviderApiKey(cfg, provider, env)) {
|
|
return true;
|
|
}
|
|
const providerConfig = cfg?.models?.providers?.[provider];
|
|
return Boolean(
|
|
providerConfig?.baseUrl?.startsWith("http://127.0.0.1") &&
|
|
providerConfig.api &&
|
|
providerConfig.models?.length &&
|
|
!providerConfig.apiKey,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
const createRuntimeProviderAuthLookup = vi.hoisted(() =>
|
|
vi.fn(() => ({
|
|
envApiKey: {
|
|
aliasMap: {},
|
|
candidateMap: {},
|
|
authEvidenceMap: {},
|
|
},
|
|
syntheticAuthProviderRefs: [],
|
|
})),
|
|
);
|
|
vi.mock("../agents/model-auth.js", () => ({
|
|
createRuntimeProviderAuthLookup,
|
|
resolveEnvApiKey,
|
|
hasUsableCustomProviderApiKey,
|
|
hasRuntimeAvailableProviderAuth,
|
|
}));
|
|
|
|
const providerAuthRoute = vi.hoisted(() => ({
|
|
value: undefined as
|
|
| {
|
|
api: "openai-responses" | "openai-chatgpt-responses";
|
|
baseUrl: string;
|
|
authRequirement: "api-key" | "subscription";
|
|
requestTransportOverrides: "none" | "present";
|
|
}
|
|
| undefined,
|
|
}));
|
|
const providerAuthEvaluations = vi.hoisted(
|
|
() =>
|
|
new Map<
|
|
string,
|
|
{
|
|
availability: boolean | undefined;
|
|
routeResolution: null;
|
|
selectedAuthMode?: string;
|
|
evidence?: "aws-sdk" | "provider-config";
|
|
}
|
|
>(),
|
|
);
|
|
const createProviderAuthChecker = vi.hoisted(() =>
|
|
vi.fn((params: { cfg?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv }) => {
|
|
const checker = vi.fn(
|
|
async (provider: string, ref?: { api?: string | null; baseUrl?: unknown }) => {
|
|
const prepared = providerAuthEvaluations.get(provider);
|
|
if (prepared) {
|
|
return prepared.availability === true;
|
|
}
|
|
return (
|
|
hasRuntimeAvailableProviderAuth({
|
|
provider,
|
|
cfg: params.cfg,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
}) &&
|
|
!(ref?.api === "openai-chatgpt-responses" && ref.baseUrl === "https://api.openai.com/v1")
|
|
);
|
|
},
|
|
);
|
|
const evaluateModelAuth = vi.fn(
|
|
async (provider: string, ref?: { api?: string | null; baseUrl?: unknown }) => {
|
|
const prepared = providerAuthEvaluations.get(provider);
|
|
if (prepared) {
|
|
return prepared;
|
|
}
|
|
const availability = await checker(provider, ref);
|
|
const selectedRoute = providerAuthRoute.value;
|
|
return {
|
|
availability,
|
|
routeResolution: selectedRoute
|
|
? { kind: "routes" as const, routes: [selectedRoute] as const }
|
|
: null,
|
|
...(selectedRoute ? { selectedRoute } : {}),
|
|
};
|
|
},
|
|
);
|
|
return Object.assign(checker, { evaluateModelAuth });
|
|
}),
|
|
);
|
|
vi.mock("../agents/model-provider-auth.js", () => ({
|
|
createProviderAuthChecker,
|
|
}));
|
|
|
|
const resolveOwningPluginIdsForProvider = vi.hoisted(() =>
|
|
vi.fn(({ provider }: { provider: string }) => {
|
|
if (provider === "byteplus" || provider === "byteplus-plan") {
|
|
return ["byteplus"];
|
|
}
|
|
if (provider === "volcengine" || provider === "volcengine-plan") {
|
|
return ["volcengine"];
|
|
}
|
|
return undefined;
|
|
}),
|
|
);
|
|
vi.mock("../plugins/providers.js", () => ({
|
|
resolveOwningPluginIdsForProviderRef: resolveOwningPluginIdsForProvider,
|
|
}));
|
|
|
|
const providerModelPickerContributionRuntime = vi.hoisted(() => ({
|
|
enabled: false,
|
|
resolve: vi.fn(() => []),
|
|
}));
|
|
const resolveProviderModelPickerEntries = vi.hoisted(() => vi.fn(() => []));
|
|
const resolveProviderPluginChoice = vi.hoisted(() => vi.fn());
|
|
const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {}));
|
|
const resolvePluginProviders = vi.hoisted(() => vi.fn(() => []));
|
|
const runProviderPluginAuthMethod = vi.hoisted(() => vi.fn());
|
|
vi.mock("../commands/model-picker.runtime.js", () => ({
|
|
modelPickerRuntime: {
|
|
get resolveProviderModelPickerContributions() {
|
|
return providerModelPickerContributionRuntime.enabled
|
|
? providerModelPickerContributionRuntime.resolve
|
|
: undefined;
|
|
},
|
|
resolveProviderModelPickerEntries,
|
|
resolveProviderPluginChoice,
|
|
runProviderModelSelectedHook,
|
|
resolvePluginProviders,
|
|
runProviderPluginAuthMethod,
|
|
},
|
|
}));
|
|
|
|
const OPENROUTER_CATALOG = [
|
|
catalogModel("openrouter", "auto", "OpenRouter Auto"),
|
|
catalogModel("openrouter", "meta-llama/llama-3.3-70b:free", "Llama 3.3 70B"),
|
|
] as const;
|
|
|
|
function expectRouterModelFiltering(options: Array<{ value: string }>) {
|
|
const routerValues = options
|
|
.map((option) => option.value)
|
|
.filter((value) => value.startsWith("openrouter/"));
|
|
expect(routerValues).toEqual(["openrouter/meta-llama/llama-3.3-70b:free"]);
|
|
}
|
|
|
|
function createSelectAllMultiselect() {
|
|
return vi.fn(async (params) => params.options.map((option: { value: string }) => option.value));
|
|
}
|
|
|
|
function promptDefaultPicker(params: Parameters<typeof promptDefaultModel>[0]) {
|
|
return promptDefaultModel({
|
|
allowKeep: false,
|
|
includeManual: false,
|
|
ignoreAllowlist: true,
|
|
...params,
|
|
});
|
|
}
|
|
|
|
function catalogModel(provider: string, id: string, name: string): ModelCatalogEntry {
|
|
return { provider, id, name };
|
|
}
|
|
|
|
function configuredTextModel(id: string, name: string) {
|
|
return {
|
|
id,
|
|
name,
|
|
reasoning: false,
|
|
input: ["text" as const],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128_000,
|
|
maxTokens: 8192,
|
|
};
|
|
}
|
|
|
|
function manifestTextRow(
|
|
provider: string,
|
|
id: string,
|
|
name: string,
|
|
status: NormalizedModelCatalogRow["status"] = "available",
|
|
): NormalizedModelCatalogRow {
|
|
return {
|
|
provider,
|
|
id,
|
|
name,
|
|
ref: `${provider}/${id}`,
|
|
mergeKey: `${provider}:${id}`,
|
|
source: "manifest",
|
|
input: ["text"],
|
|
reasoning: false,
|
|
status,
|
|
};
|
|
}
|
|
|
|
type MockCallSource = {
|
|
mock: {
|
|
calls: ReadonlyArray<ReadonlyArray<unknown>>;
|
|
};
|
|
};
|
|
|
|
type PickerOption = Record<string, unknown> & {
|
|
value: string;
|
|
};
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error(`expected ${label}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function mockArg(source: MockCallSource, callIndex: number, argIndex: number, label: string) {
|
|
const call = source.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`expected mock call: ${label}`);
|
|
}
|
|
return call[argIndex];
|
|
}
|
|
|
|
function pickerParams(source: MockCallSource, callIndex = 0) {
|
|
return requireRecord(mockArg(source, callIndex, 0, `picker call ${callIndex}`), "picker params");
|
|
}
|
|
|
|
function pickerOptions(source: MockCallSource, callIndex = 0) {
|
|
const options = pickerParams(source, callIndex).options;
|
|
expect(options, "picker options").toBeInstanceOf(Array);
|
|
return options as PickerOption[];
|
|
}
|
|
|
|
function optionValues(options: PickerOption[]) {
|
|
return options.map((option) => option.value);
|
|
}
|
|
|
|
function requireOption(options: PickerOption[], value: string) {
|
|
const option = options.find((candidate) => candidate.value === value);
|
|
if (!option) {
|
|
throw new Error(`expected picker option: ${value}`);
|
|
}
|
|
return option;
|
|
}
|
|
|
|
function providerCallProviders() {
|
|
return resolveOwningPluginIdsForProvider.mock.calls.map(
|
|
([params]) => requireRecord(params, "provider ownership params").provider,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
delete process.env.OPENCLAW_LOCALE;
|
|
// Route hints exercise source policy even when a prior local build left stale dist artifacts.
|
|
vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions"));
|
|
vi.clearAllMocks();
|
|
modelCatalogRouteVariants.value = undefined;
|
|
providerAuthRoute.value = undefined;
|
|
providerAuthEvaluations.clear();
|
|
cliBackendsTesting.setDepsForTest({
|
|
resolveRuntimeCliBackends: () => [
|
|
{
|
|
id: "claude-cli",
|
|
modelProvider: "anthropic",
|
|
pluginId: "anthropic",
|
|
config: { command: "claude" },
|
|
},
|
|
{
|
|
id: "google-gemini-cli",
|
|
modelProvider: "google",
|
|
pluginId: "google",
|
|
config: { command: "gemini" },
|
|
},
|
|
],
|
|
resolvePluginSetupRegistry: () => ({
|
|
providers: [],
|
|
cliBackends: [
|
|
{
|
|
pluginId: "anthropic",
|
|
backend: {
|
|
id: "claude-cli",
|
|
modelProvider: "anthropic",
|
|
config: { command: "claude" },
|
|
},
|
|
},
|
|
{
|
|
pluginId: "google",
|
|
backend: {
|
|
id: "google-gemini-cli",
|
|
modelProvider: "google",
|
|
config: { command: "gemini" },
|
|
},
|
|
},
|
|
],
|
|
configMigrations: [],
|
|
autoEnableProbes: [],
|
|
diagnostics: [],
|
|
}),
|
|
});
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([]);
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([]);
|
|
listProfilesForProvider.mockReturnValue([]);
|
|
resolveEnvApiKey.mockImplementation((_provider: string) => ({
|
|
apiKey: "test-key",
|
|
source: "test",
|
|
}));
|
|
hasUsableCustomProviderApiKey.mockReturnValue(false);
|
|
providerModelPickerContributionRuntime.enabled = false;
|
|
resolveOwningPluginIdsForProvider.mockImplementation(({ provider }: { provider: string }) => {
|
|
if (provider === "byteplus" || provider === "byteplus-plan") {
|
|
return ["byteplus"];
|
|
}
|
|
if (provider === "volcengine" || provider === "volcengine-plan") {
|
|
return ["volcengine"];
|
|
}
|
|
return undefined;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cliBackendsTesting.resetDepsForTest();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe("promptDefaultModel", () => {
|
|
it("adds runtime-route hints for canonical OpenAI models", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
},
|
|
]);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter,
|
|
});
|
|
|
|
const options = pickerOptions(select as MockCallSource);
|
|
const canonical = requireOption(options, "openai/gpt-5.5");
|
|
expect(canonical.hint).toContain("Codex runtime route");
|
|
expect(canonical.hint).not.toContain("OpenClaw runtime route");
|
|
});
|
|
|
|
it.each([
|
|
["default request params", { params: { temperature: 0.2 } }],
|
|
[
|
|
"model request params",
|
|
{ models: { "openai/gpt-5.5": { params: { text_verbosity: "low" } } } },
|
|
],
|
|
] as const)(
|
|
"labels official OpenAI with %s as an OpenClaw runtime route",
|
|
async (_label, defaults) => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
},
|
|
]);
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults } } as OpenClawConfig,
|
|
prompter: makePrompter({ select }),
|
|
});
|
|
|
|
const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5");
|
|
expect(option.hint).toContain("OpenClaw runtime route");
|
|
expect(option.hint).not.toContain("Codex runtime route");
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["custom endpoint", "openai-responses", "https://example.test/v1"],
|
|
["authored Completions", "openai-completions", "https://api.openai.com/v1"],
|
|
] as const)("labels an OpenAI %s as an OpenClaw runtime route", async (_label, api, baseUrl) => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5", api, baseUrl },
|
|
]);
|
|
const config = {
|
|
agents: { defaults: {} },
|
|
models: {
|
|
providers: {
|
|
openai: { api, baseUrl, models: [configuredTextModel("gpt-5.5", "GPT-5.5")] },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
|
|
await promptDefaultPicker({
|
|
config,
|
|
prompter: makePrompter({ select }),
|
|
});
|
|
|
|
const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5");
|
|
expect(option.hint).toContain("OpenClaw runtime route");
|
|
expect(option.hint).not.toContain("Codex runtime route");
|
|
});
|
|
|
|
it("uses selected ChatGPT capabilities regardless of physical row order", async () => {
|
|
providerAuthRoute.value = {
|
|
api: "openai-chatgpt-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
authRequirement: "subscription",
|
|
requestTransportOverrides: "none",
|
|
};
|
|
const platform: ModelCatalogEntry = {
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "Platform GPT-5.5",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
contextWindow: 1_000_000,
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
};
|
|
const chatGPT: ModelCatalogEntry = {
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "ChatGPT GPT-5.5",
|
|
api: "openai-chatgpt-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
contextWindow: 400_000,
|
|
reasoning: false,
|
|
input: ["text"],
|
|
};
|
|
loadModelCatalog.mockResolvedValue([platform]);
|
|
modelCatalogRouteVariants.value = [platform, chatGPT];
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter: makePrompter({ select }),
|
|
});
|
|
|
|
const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5");
|
|
expect(option.hint).toContain("ChatGPT GPT-5.5");
|
|
expect(option.hint).toContain("ctx 400k");
|
|
expect(option.hint).not.toContain("reasoning");
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual(["openai/gpt-5.5"]);
|
|
});
|
|
|
|
it("hides unauthenticated catalog entries from default model choices", async () => {
|
|
resolveEnvApiKey.mockReturnValue(null);
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
|
|
]);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } } },
|
|
prompter,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(select as MockCallSource));
|
|
expect(values).toEqual(["anthropic/claude-sonnet-4-6"]);
|
|
});
|
|
|
|
it("does not offer an OpenAI row with a conflicting API and endpoint", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
api: "openai-chatgpt-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
},
|
|
]);
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
|
|
await promptDefaultPicker({
|
|
config: {
|
|
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } },
|
|
},
|
|
prompter: makePrompter({ select }),
|
|
});
|
|
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"anthropic/claude-sonnet-4-6",
|
|
]);
|
|
const checker = createProviderAuthChecker.mock.results.at(-1)?.value;
|
|
expect(checker).toHaveBeenCalledWith("openai", {
|
|
modelId: "gpt-5.5",
|
|
api: "openai-chatgpt-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
});
|
|
});
|
|
|
|
it("keeps implicit Bedrock AWS SDK models visible without API-key auth", async () => {
|
|
resolveEnvApiKey.mockReturnValue(null);
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "amazon-bedrock", id: "us.anthropic.claude-sonnet-4-5", name: "Claude Sonnet" },
|
|
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
|
|
]);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(select as MockCallSource));
|
|
expect(values).toEqual(["amazon-bedrock/us.anthropic.claude-sonnet-4-5"]);
|
|
});
|
|
|
|
it("shows AWS SDK models but hides unresolved non-OpenAI SecretRefs", async () => {
|
|
providerAuthEvaluations.set("amazon-bedrock", {
|
|
availability: true,
|
|
routeResolution: null,
|
|
selectedAuthMode: "aws-sdk",
|
|
evidence: "aws-sdk",
|
|
});
|
|
providerAuthEvaluations.set("anthropic", {
|
|
availability: undefined,
|
|
routeResolution: null,
|
|
selectedAuthMode: "api-key",
|
|
evidence: "provider-config",
|
|
});
|
|
loadModelCatalog.mockResolvedValue([
|
|
{
|
|
provider: "amazon-bedrock",
|
|
id: "us.anthropic.claude-sonnet-4-5",
|
|
name: "Bedrock Claude",
|
|
api: "bedrock-converse-stream",
|
|
},
|
|
{
|
|
provider: "anthropic",
|
|
id: "claude-sonnet-4-6",
|
|
name: "Anthropic Claude",
|
|
api: "anthropic-messages",
|
|
},
|
|
]);
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter: makePrompter({ select }),
|
|
});
|
|
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"amazon-bedrock/us.anthropic.claude-sonnet-4-5",
|
|
]);
|
|
const authChecker = createProviderAuthChecker.mock.results.at(-1)?.value;
|
|
if (!authChecker) {
|
|
throw new Error("expected provider auth checker");
|
|
}
|
|
expect(authChecker.evaluateModelAuth).toHaveBeenCalledWith("amazon-bedrock", {
|
|
modelId: "us.anthropic.claude-sonnet-4-5",
|
|
observedRoutes: [{ api: "bedrock-converse-stream", baseUrl: undefined }],
|
|
});
|
|
expect(authChecker.evaluateModelAuth).toHaveBeenCalledWith("anthropic", {
|
|
modelId: "claude-sonnet-4-6",
|
|
observedRoutes: [{ api: "anthropic-messages", baseUrl: undefined }],
|
|
});
|
|
});
|
|
|
|
it("hides legacy runtime providers from default model choices", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "codex", id: "gpt-5.5", name: "GPT-5.5" },
|
|
{ provider: "codex-cli", id: "gpt-5.5", name: "GPT-5.5" },
|
|
{ provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "google-gemini-cli", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
|
|
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "google", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
|
|
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
|
|
]);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(select as MockCallSource));
|
|
expect(values).toEqual([
|
|
"openai/gpt-5.5",
|
|
"anthropic/claude-sonnet-4-6",
|
|
"google/gemini-3.1-pro-preview",
|
|
]);
|
|
});
|
|
|
|
it("normalizes retired Google Gemini catalog rows before saving config", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "google", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
|
|
]);
|
|
|
|
const select = vi.fn(async (params) => params.options[0]?.value as never);
|
|
const prompter = makePrompter({ select });
|
|
|
|
const result = await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter,
|
|
});
|
|
|
|
expect(result.model).toBe("google/gemini-3.1-pro-preview");
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"google/gemini-3.1-pro-preview",
|
|
]);
|
|
expect(
|
|
requireRecord(
|
|
mockArg(runProviderModelSelectedHook as MockCallSource, 0, 0, "provider selected hook"),
|
|
"provider selected hook params",
|
|
).model,
|
|
).toBe("google/gemini-3.1-pro-preview");
|
|
});
|
|
|
|
it("uses configured provider models for default picker without loading the full catalog in replace mode", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
]);
|
|
|
|
const select = vi.fn(async (params) => params.options[0]?.value as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
models: {
|
|
mode: "replace",
|
|
providers: {
|
|
minimax: {
|
|
baseUrl: "https://api.minimax.test/v1",
|
|
models: [configuredTextModel("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed")],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
});
|
|
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
const minimaxOption = requireOption(
|
|
pickerOptions(select as MockCallSource),
|
|
"minimax/MiniMax-M2.7-highspeed",
|
|
);
|
|
expect(minimaxOption.hint).toContain("MiniMax M2.7 Highspeed");
|
|
expect(result.model).toBe("minimax/MiniMax-M2.7-highspeed");
|
|
});
|
|
|
|
it("treats byteplus plan models as preferred-provider matches", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
catalogModel("byteplus-plan", "ark-code-latest", "Ark Coding Plan"),
|
|
]);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
preferredProvider: "byteplus",
|
|
});
|
|
|
|
const options = pickerOptions(select as MockCallSource);
|
|
const values = optionValues(options);
|
|
expect(values).toContain("byteplus-plan/ark-code-latest");
|
|
expect(values[1]).toBe("byteplus-plan/ark-code-latest");
|
|
expect(pickerParams(select as MockCallSource).initialValue).toBe(
|
|
"byteplus-plan/ark-code-latest",
|
|
);
|
|
expect(result.model).toBe("byteplus-plan/ark-code-latest");
|
|
expect(providerCallProviders()).toContain("byteplus");
|
|
expect(providerCallProviders()).toContain("byteplus-plan");
|
|
});
|
|
|
|
it("shows literal double-prefix labels for providers that preserve literal prefixes", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron"),
|
|
]);
|
|
resolvePluginProviders.mockReturnValue([
|
|
{
|
|
id: "nvidia",
|
|
preserveLiteralProviderPrefix: true,
|
|
},
|
|
] as never);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "nvidia/nemotron-3-super-120b-a12b",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
});
|
|
|
|
const options = pickerOptions(select as MockCallSource);
|
|
expect(requireOption(options, "__keep__").label).toBe(
|
|
"Keep current (nvidia/nvidia/nemotron-3-super-120b-a12b)",
|
|
);
|
|
expect(requireOption(options, "nvidia/nemotron-3-super-120b-a12b").label).toBe(
|
|
"nvidia/nvidia/nemotron-3-super-120b-a12b",
|
|
);
|
|
});
|
|
|
|
it("does not double-prefix non-literal NVIDIA vendor model labels", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron"),
|
|
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
|
|
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
|
|
]);
|
|
resolvePluginProviders.mockReturnValue([
|
|
{
|
|
id: "nvidia",
|
|
preserveLiteralProviderPrefix: true,
|
|
},
|
|
] as never);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
});
|
|
|
|
const options = pickerOptions(select as MockCallSource);
|
|
expect(requireOption(options, "nvidia/nemotron-3-super-120b-a12b").label).toBe(
|
|
"nvidia/nvidia/nemotron-3-super-120b-a12b",
|
|
);
|
|
expect(requireOption(options, "nvidia/minimaxai/minimax-m2.7").label).toBe(
|
|
"nvidia/minimaxai/minimax-m2.7",
|
|
);
|
|
expect(requireOption(options, "nvidia/z-ai/glm-5.1").label).toBe("nvidia/z-ai/glm-5.1");
|
|
});
|
|
|
|
it("shows literal double-prefix keep label before browsing provider catalogs", async () => {
|
|
resolvePluginProviders.mockReturnValue([
|
|
{
|
|
id: "nvidia",
|
|
preserveLiteralProviderPrefix: true,
|
|
},
|
|
] as never);
|
|
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "nvidia/nemotron-3-super-120b-a12b",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
preferredProvider: "nvidia",
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
expect(result).toStrictEqual({});
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
const params = pickerParams(select as MockCallSource);
|
|
expect(params.searchable).toBe(false);
|
|
expect(params.initialValue).toBe("__keep__");
|
|
const options = pickerOptions(select as MockCallSource);
|
|
expect(optionValues(options)).toEqual(["__keep__", "__manual__", "__browse__"]);
|
|
expect(requireOption(options, "__keep__").label).toBe(
|
|
"Keep current (nvidia/nvidia/nemotron-3-super-120b-a12b)",
|
|
);
|
|
});
|
|
|
|
it("keeps current preferred-provider models cold until browsing is requested", async () => {
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
preferredProvider: "openai",
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
expect(result).toStrictEqual({});
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
const params = pickerParams(select as MockCallSource);
|
|
expect(params.searchable).toBe(false);
|
|
expect(params.initialValue).toBe("__keep__");
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"__keep__",
|
|
"__manual__",
|
|
"__browse__",
|
|
]);
|
|
});
|
|
|
|
it("keeps the full catalog cold until browsing when no provider is preferred", async () => {
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "fleet-router/qwen3.6:latest",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
browseCatalogOnDemand: true,
|
|
loadCatalog: true,
|
|
});
|
|
|
|
expect(result).toStrictEqual({});
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
const params = pickerParams(select as MockCallSource);
|
|
expect(params.searchable).toBe(false);
|
|
expect(params.initialValue).toBe("__keep__");
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"__keep__",
|
|
"__manual__",
|
|
"__browse__",
|
|
]);
|
|
});
|
|
|
|
it("loads the full model catalog when browsing without a preferred provider", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
catalogModel("openai", "gpt-5.5-pro", "GPT-5.5 Pro"),
|
|
]);
|
|
const select = vi
|
|
.fn()
|
|
.mockResolvedValueOnce("__browse__")
|
|
.mockImplementationOnce(async (params) => {
|
|
const option = params.options.find(
|
|
(entry: { value: string }) => entry.value === "openai/gpt-5.5-pro",
|
|
);
|
|
return option?.value ?? params.initialValue;
|
|
});
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
expect(result.model).toBe("openai/gpt-5.5-pro");
|
|
expect(loadModelCatalog).toHaveBeenCalledOnce();
|
|
expect(loadPreferredProviderPickerCatalog).not.toHaveBeenCalled();
|
|
expect(select).toHaveBeenCalledTimes(2);
|
|
expect(select.mock.calls[1]?.[0]?.searchable).toBe(true);
|
|
});
|
|
|
|
it("loads the preferred provider catalog when the user chooses to browse", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
catalogModel("openai", "gpt-5.5-pro", "GPT-5.5 Pro"),
|
|
]);
|
|
const select = vi
|
|
.fn()
|
|
.mockResolvedValueOnce("__browse__")
|
|
.mockImplementationOnce(async (params) => {
|
|
const option = params.options.find(
|
|
(entry: { value: string }) => entry.value === "openai/gpt-5.5-pro",
|
|
);
|
|
return option?.value ?? params.initialValue;
|
|
});
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
preferredProvider: "openai",
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
expect(result.model).toBe("openai/gpt-5.5-pro");
|
|
expect(loadPreferredProviderPickerCatalog).toHaveBeenCalledWith({
|
|
cfg: config,
|
|
preferredProvider: "openai",
|
|
agentDir: expect.stringContaining("agents/main/agent"),
|
|
});
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
expect(select).toHaveBeenCalledTimes(2);
|
|
expect(select.mock.calls[1]?.[0]?.searchable).toBe(true);
|
|
});
|
|
|
|
it("scopes on-demand preferred-provider loads before the first model prompt", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
|
|
catalogModel("nvidia", "moonshotai/kimi-k2.5", "Kimi K2.5"),
|
|
]);
|
|
const select = vi.fn(async (params) => params.options[0]?.value as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "nvidia/nemotron-3-super-120b-a12b",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
expect(result.model).toBe("nvidia/nemotron-3-super-120b-a12b");
|
|
expect(loadPreferredProviderPickerCatalog).toHaveBeenCalledWith({
|
|
cfg: config,
|
|
preferredProvider: "nvidia",
|
|
agentDir: expect.stringContaining("agents/main/agent"),
|
|
});
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"nvidia/nemotron-3-super-120b-a12b",
|
|
"nvidia/moonshotai/kimi-k2.5",
|
|
]);
|
|
});
|
|
|
|
it("preselects the first live provider row when keep-current is disabled", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
|
|
]);
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "nvidia/nemotron-3-ultra-550b-a55b",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
expect(result.model).toBe("nvidia/z-ai/glm-5.1");
|
|
expect(pickerParams(select as MockCallSource).initialValue).toBe("nvidia/z-ai/glm-5.1");
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"nvidia/z-ai/glm-5.1",
|
|
"nvidia/nemotron-3-super-120b-a12b",
|
|
"nvidia/nemotron-3-ultra-550b-a55b",
|
|
]);
|
|
expect(
|
|
requireOption(pickerOptions(select as MockCallSource), "nvidia/nemotron-3-ultra-550b-a55b")
|
|
.hint,
|
|
).toBe("current (not in catalog)");
|
|
});
|
|
|
|
it("keeps on-demand NVIDIA vendor labels single-prefixed after browsing", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
|
|
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
|
|
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
|
|
]);
|
|
resolvePluginProviders.mockReturnValue([
|
|
{
|
|
id: "nvidia",
|
|
preserveLiteralProviderPrefix: true,
|
|
},
|
|
] as never);
|
|
const select = vi
|
|
.fn()
|
|
.mockResolvedValueOnce("__browse__")
|
|
.mockResolvedValueOnce("nvidia/nemotron-3-super-120b-a12b");
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: {
|
|
agents: {
|
|
defaults: {
|
|
model: "nvidia/nemotron-3-super-120b-a12b",
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
preferredProvider: "nvidia",
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
const options = pickerOptions(select as MockCallSource, 1);
|
|
expect(requireOption(options, "nvidia/nemotron-3-super-120b-a12b").label).toBe(
|
|
"nvidia/nvidia/nemotron-3-super-120b-a12b",
|
|
);
|
|
expect(requireOption(options, "nvidia/minimaxai/minimax-m2.7").label).toBe(
|
|
"nvidia/minimaxai/minimax-m2.7",
|
|
);
|
|
expect(requireOption(options, "nvidia/z-ai/glm-5.1").label).toBe("nvidia/z-ai/glm-5.1");
|
|
});
|
|
|
|
it("omits local NVIDIA static fallback rows when browsing live provider rows", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
|
|
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
|
|
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
|
|
]);
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([
|
|
manifestTextRow("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5", "deprecated"),
|
|
manifestTextRow("nvidia", "z-ai/glm5", "GLM5", "deprecated"),
|
|
]);
|
|
resolvePluginProviders.mockReturnValue([
|
|
{
|
|
id: "nvidia",
|
|
preserveLiteralProviderPrefix: true,
|
|
},
|
|
] as never);
|
|
const select = vi
|
|
.fn()
|
|
.mockResolvedValueOnce("__browse__")
|
|
.mockResolvedValueOnce("nvidia/nemotron-3-super-120b-a12b");
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: {
|
|
agents: {
|
|
defaults: {
|
|
model: "nvidia/nemotron-3-super-120b-a12b",
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
preferredProvider: "nvidia",
|
|
browseCatalogOnDemand: true,
|
|
});
|
|
|
|
expect(optionValues(pickerOptions(select as MockCallSource, 1))).toEqual([
|
|
"__keep__",
|
|
"__manual__",
|
|
"nvidia/nemotron-3-super-120b-a12b",
|
|
"nvidia/minimaxai/minimax-m2.7",
|
|
"nvidia/z-ai/glm-5.1",
|
|
]);
|
|
});
|
|
|
|
it("uses the configured default agent dir for provider-scoped catalog auth", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
|
|
]);
|
|
const select = vi.fn(async (params) => params.options[0]?.value as never);
|
|
const prompter = makePrompter({ select });
|
|
const env = {
|
|
...process.env,
|
|
OPENCLAW_STATE_DIR: "/tmp/openclaw-picker-state",
|
|
};
|
|
const config = {
|
|
agents: {
|
|
list: [{ id: "worker", default: true }],
|
|
defaults: {
|
|
model: "nvidia/nemotron-3-super-120b-a12b",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
browseCatalogOnDemand: true,
|
|
env,
|
|
});
|
|
|
|
expect(loadPreferredProviderPickerCatalog).toHaveBeenCalledWith({
|
|
cfg: config,
|
|
preferredProvider: "nvidia",
|
|
agentDir: "/tmp/openclaw-picker-state/agents/worker/agent",
|
|
env,
|
|
});
|
|
});
|
|
|
|
it("supports configuring vLLM during setup", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
|
|
]);
|
|
resolveProviderModelPickerEntries.mockReturnValue([
|
|
{ value: "vllm", label: "vLLM (custom)", hint: "Enter vLLM URL + API key + model" },
|
|
] as never);
|
|
resolvePluginProviders.mockReturnValue([{ id: "vllm" }] as never);
|
|
resolveProviderPluginChoice.mockReturnValue({
|
|
provider: { id: "vllm", label: "vLLM", auth: [] },
|
|
method: { id: "custom", label: "vLLM", kind: "custom" },
|
|
});
|
|
runProviderPluginAuthMethod.mockResolvedValue({
|
|
config: {
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
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/Meta-Llama-3-8B-Instruct",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
defaultModel: "vllm/meta-llama/Meta-Llama-3-8B-Instruct",
|
|
});
|
|
|
|
const select = vi.fn(async (params) => {
|
|
const vllm = params.options.find((opt: { value: string }) => opt.value === "vllm");
|
|
return (vllm?.value ?? "") as never;
|
|
});
|
|
const prompter = makePrompter({ select });
|
|
const config = { agents: { defaults: {} } } as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
includeProviderPluginSetups: true,
|
|
agentDir: "/tmp/openclaw-agent",
|
|
runtime: {} as never,
|
|
});
|
|
|
|
expect(runProviderPluginAuthMethod).toHaveBeenCalledOnce();
|
|
expect(resolvePluginProviders).toHaveBeenCalledWith({
|
|
config,
|
|
workspaceDir: undefined,
|
|
env: undefined,
|
|
mode: "setup",
|
|
});
|
|
expect(result.model).toBe("vllm/meta-llama/Meta-Llama-3-8B-Instruct");
|
|
expect(result.config?.models?.providers?.vllm).toEqual({
|
|
baseUrl: "http://127.0.0.1:8000/v1",
|
|
api: "openai-completions",
|
|
apiKey: "VLLM_API_KEY", // pragma: allowlist secret
|
|
models: [
|
|
{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "meta-llama/Meta-Llama-3-8B-Instruct" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("prefers provider model-picker contributions when the runtime exposes them", async () => {
|
|
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
|
|
providerModelPickerContributionRuntime.enabled = true;
|
|
providerModelPickerContributionRuntime.resolve.mockReturnValue([
|
|
{
|
|
id: "provider:model-picker:ollama",
|
|
kind: "provider",
|
|
surface: "model-picker",
|
|
option: {
|
|
value: "ollama",
|
|
label: "Ollama",
|
|
hint: "Local/self-hosted setup",
|
|
},
|
|
},
|
|
] as never);
|
|
resolveProviderModelPickerEntries.mockReturnValue([
|
|
{
|
|
value: "legacy-entry",
|
|
label: "Legacy entry",
|
|
hint: "Should not be used when contributions exist",
|
|
},
|
|
] as never);
|
|
|
|
const select = vi.fn(async (params) => {
|
|
const ollama = params.options.find((opt: { value: string }) => opt.value === "ollama");
|
|
return (ollama?.value ?? "") as never;
|
|
});
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter,
|
|
includeProviderPluginSetups: true,
|
|
agentDir: "/tmp/openclaw-agent",
|
|
runtime: {} as never,
|
|
});
|
|
|
|
expect(providerModelPickerContributionRuntime.resolve).toHaveBeenCalledOnce();
|
|
const options = pickerOptions(select as MockCallSource);
|
|
expect(requireOption(options, "ollama").label).toBe("Ollama");
|
|
expect(optionValues(options)).not.toContain("legacy-entry");
|
|
});
|
|
|
|
it("keeps skip-auth model selection cold when catalog loading is disabled", async () => {
|
|
const select = vi.fn(async (params) => params.initialValue as never);
|
|
const prompter = makePrompter({ select });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptDefaultPicker({
|
|
config,
|
|
prompter,
|
|
allowKeep: true,
|
|
includeManual: true,
|
|
includeProviderPluginSetups: true,
|
|
loadCatalog: false,
|
|
agentDir: "/tmp/openclaw-agent",
|
|
runtime: {} as never,
|
|
});
|
|
|
|
expect(result).toStrictEqual({});
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
expect(resolveProviderModelPickerEntries).not.toHaveBeenCalled();
|
|
expect(providerModelPickerContributionRuntime.resolve).not.toHaveBeenCalled();
|
|
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
|
|
"__keep__",
|
|
"__manual__",
|
|
"openai/gpt-5.5",
|
|
]);
|
|
});
|
|
|
|
it("surfaces NVIDIA provider model-picker contributions", async () => {
|
|
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.4", "GPT-5.4")]);
|
|
providerModelPickerContributionRuntime.enabled = true;
|
|
providerModelPickerContributionRuntime.resolve.mockReturnValue([
|
|
{
|
|
id: "provider:model-picker:provider-plugin:nvidia:api-key",
|
|
kind: "provider",
|
|
surface: "model-picker",
|
|
option: {
|
|
value: "provider-plugin:nvidia:api-key",
|
|
label: "NVIDIA (custom)",
|
|
hint: "Use NVIDIA-hosted open models",
|
|
},
|
|
},
|
|
] as never);
|
|
|
|
const select = vi.fn(async (params) => {
|
|
const nvidia = params.options.find(
|
|
(opt: { value: string }) => opt.value === "provider-plugin:nvidia:api-key",
|
|
);
|
|
return (nvidia?.value ?? "") as never;
|
|
});
|
|
const prompter = makePrompter({ select });
|
|
|
|
await promptDefaultPicker({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter,
|
|
includeProviderPluginSetups: true,
|
|
agentDir: "/tmp/openclaw-agent",
|
|
runtime: {} as never,
|
|
});
|
|
|
|
expect(
|
|
requireOption(pickerOptions(select as MockCallSource), "provider-plugin:nvidia:api-key")
|
|
.label,
|
|
).toBe("NVIDIA (custom)");
|
|
});
|
|
});
|
|
|
|
describe("promptModelAllowlist", () => {
|
|
it("filters to allowed keys when provided", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("anthropic", "claude-opus-4-6", "Claude Opus 4.5"),
|
|
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = { agents: { defaults: {} } } as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
allowedKeys: ["anthropic/claude-opus-4-6"],
|
|
});
|
|
|
|
const options = pickerOptions(multiselect as MockCallSource);
|
|
expect(optionValues(options)).toEqual(["anthropic/claude-opus-4-6"]);
|
|
expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]);
|
|
});
|
|
|
|
it("localizes the model allowlist picker", async () => {
|
|
process.env.OPENCLAW_LOCALE = "zh-CN";
|
|
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = { agents: { defaults: {} } } as OpenClawConfig;
|
|
|
|
await promptModelAllowlist({ config, prompter });
|
|
|
|
expect(multiselect.mock.calls[0]?.[0]?.message).toBe("/model 选择器中的模型(多选)");
|
|
});
|
|
|
|
it("uses static manifest catalog rows for a preferred provider without loading runtime catalog", async () => {
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([
|
|
{
|
|
provider: "github-copilot",
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4",
|
|
ref: "github-copilot/gpt-5.4",
|
|
mergeKey: "github-copilot:gpt-5.4",
|
|
source: "manifest",
|
|
input: ["text"],
|
|
reasoning: true,
|
|
status: "available",
|
|
},
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = { agents: { defaults: {} } } as OpenClawConfig;
|
|
|
|
await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "github-copilot",
|
|
});
|
|
|
|
expect(loadStaticManifestCatalogRowsForList).toHaveBeenCalledWith({
|
|
cfg: config,
|
|
providerFilter: "github-copilot",
|
|
});
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
|
|
"github-copilot/gpt-5.4",
|
|
]);
|
|
});
|
|
|
|
it("preserves static OpenAI route facts for future model auth checks", async () => {
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([
|
|
{
|
|
provider: "openai",
|
|
id: "gpt-future",
|
|
name: "GPT Future",
|
|
ref: "openai/gpt-future",
|
|
mergeKey: "openai:gpt-future",
|
|
source: "manifest",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
input: ["text"],
|
|
reasoning: true,
|
|
status: "available",
|
|
},
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
await promptModelAllowlist({
|
|
config: { agents: { defaults: {} } },
|
|
prompter: makePrompter({ multiselect }),
|
|
preferredProvider: "openai",
|
|
});
|
|
|
|
const checker = createProviderAuthChecker.mock.results.at(-1)?.value;
|
|
expect(checker).toHaveBeenCalledWith("openai", {
|
|
modelId: "gpt-future",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
});
|
|
expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
|
|
"openai/gpt-future",
|
|
]);
|
|
});
|
|
|
|
it("uses the selected route for allowlist capability hints", async () => {
|
|
providerAuthRoute.value = {
|
|
api: "openai-chatgpt-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
authRequirement: "subscription",
|
|
requestTransportOverrides: "none",
|
|
};
|
|
loadModelCatalog.mockResolvedValue([
|
|
{
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "Platform GPT-5.5",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
contextWindow: 1_000_000,
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
},
|
|
{
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "ChatGPT GPT-5.5",
|
|
api: "openai-chatgpt-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
contextWindow: 400_000,
|
|
reasoning: false,
|
|
input: ["text"],
|
|
},
|
|
]);
|
|
const multiselect = createSelectAllMultiselect();
|
|
|
|
await promptModelAllowlist({
|
|
config: { agents: { defaults: {} } } as OpenClawConfig,
|
|
prompter: makePrompter({ multiselect }),
|
|
});
|
|
|
|
const option = requireOption(pickerOptions(multiselect as MockCallSource), "openai/gpt-5.5");
|
|
expect(option.hint).toContain("ChatGPT GPT-5.5");
|
|
expect(option.hint).toContain("ctx 400k");
|
|
expect(option.hint).not.toContain("reasoning");
|
|
});
|
|
|
|
it("uses configured provider models for allowlist picker without loading the full catalog in replace mode", async () => {
|
|
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
models: {
|
|
mode: "replace",
|
|
providers: {
|
|
minimax: {
|
|
baseUrl: "https://api.minimax.test/v1",
|
|
models: [configuredTextModel("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed")],
|
|
},
|
|
zhipu: {
|
|
baseUrl: "https://api.zhipu.test/v1",
|
|
models: [configuredTextModel("glm-4.5-air", "GLM 4.5 Air")],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({ config, prompter });
|
|
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
|
|
"minimax/MiniMax-M2.7-highspeed",
|
|
"zhipu/glm-4.5-air",
|
|
]);
|
|
expect(result.models).toEqual(["minimax/MiniMax-M2.7-highspeed", "zhipu/glm-4.5-air"]);
|
|
});
|
|
|
|
it("scopes the initial allowlist picker to the preferred provider", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
catalogModel("openai", "gpt-5.4-mini", "GPT-5.4 Mini"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = { agents: { defaults: {} } } as OpenClawConfig;
|
|
|
|
await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "openai",
|
|
});
|
|
|
|
const options = pickerOptions(multiselect as MockCallSource);
|
|
expect(optionValues(options)).toEqual(["openai/gpt-5.5", "openai/gpt-5.4-mini"]);
|
|
});
|
|
|
|
it("includes stale configured preferred provider models in the scoped cleanup", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("openrouter", "meta-llama/llama-3.3-70b:free", "Llama 3.3 70B"),
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
]);
|
|
|
|
const activeModel = "openrouter/meta-llama/llama-3.3-70b:free";
|
|
const staleModel = "openrouter/elephant-alpha";
|
|
const multiselect = vi.fn(async (params: WizardMultiSelectParams) =>
|
|
params.options.map((option) => option.value).filter((value) => value === activeModel),
|
|
);
|
|
const prompter = makePrompter({
|
|
multiselect: multiselect as unknown as WizardPrompter["multiselect"],
|
|
});
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
[activeModel]: { alias: "llama" },
|
|
[staleModel]: { alias: "elephant" },
|
|
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "openrouter",
|
|
});
|
|
|
|
const options = pickerOptions(multiselect as MockCallSource);
|
|
expect(optionValues(options)).toEqual([activeModel, staleModel]);
|
|
expect(requireOption(options, staleModel).hint).toBe("configured (not in catalog)");
|
|
expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual([activeModel, staleModel]);
|
|
expect(result).toEqual({
|
|
models: [activeModel],
|
|
scopeKeys: [activeModel, staleModel],
|
|
});
|
|
|
|
const next = applyModelAllowlist(config, result.models ?? [], {
|
|
scopeKeys: result.scopeKeys,
|
|
});
|
|
expect(next.agents?.defaults?.models).toEqual({
|
|
[activeModel]: { alias: "llama" },
|
|
[staleModel]: { alias: "elephant" },
|
|
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
|
});
|
|
expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([
|
|
"anthropic/claude-sonnet-4-6",
|
|
activeModel,
|
|
]);
|
|
});
|
|
|
|
it("shows configured preferred provider models when the catalog has no entries", async () => {
|
|
loadModelCatalog.mockResolvedValue([]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const text = vi.fn(async () => "");
|
|
const prompter = makePrompter({ multiselect, text });
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
ollama: {
|
|
api: "ollama",
|
|
baseUrl: "https://ollama.com/v1",
|
|
models: [
|
|
configuredTextModel("kimi-k2.5:cloud", "Kimi K2.5"),
|
|
configuredTextModel("gpt-oss:20b-cloud", "GPT OSS 20B"),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "ollama",
|
|
loadCatalog: true,
|
|
});
|
|
|
|
expect(text).not.toHaveBeenCalled();
|
|
expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
|
|
"ollama/kimi-k2.5:cloud",
|
|
"ollama/gpt-oss:20b-cloud",
|
|
]);
|
|
expect(result).toEqual({
|
|
models: ["ollama/kimi-k2.5:cloud", "ollama/gpt-oss:20b-cloud"],
|
|
scopeKeys: ["ollama/kimi-k2.5:cloud", "ollama/gpt-oss:20b-cloud"],
|
|
});
|
|
});
|
|
|
|
it("keeps live preferred-provider rows before configured fallback supplements", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
api: "openai-completions",
|
|
baseUrl: "https://integrate.api.nvidia.com/v1",
|
|
models: [
|
|
configuredTextModel("nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"),
|
|
configuredTextModel("moonshotai/kimi-k2.5", "Bundled Kimi K2.5"),
|
|
configuredTextModel("minimaxai/minimax-m2.7", "Bundled MiniMax M2.7"),
|
|
configuredTextModel("z-ai/glm5", "Bundled GLM5"),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
loadCatalog: true,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(multiselect as MockCallSource));
|
|
expect(values).toEqual([
|
|
"nvidia/minimaxai/minimax-m2.7",
|
|
"nvidia/nemotron-3-super-120b-a12b",
|
|
"nvidia/moonshotai/kimi-k2.5",
|
|
"nvidia/z-ai/glm5",
|
|
]);
|
|
expect(result.scopeKeys).toEqual(values);
|
|
});
|
|
|
|
it("keeps provider-scoped live rows authoritative over configured provider supplements", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"),
|
|
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
|
|
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
|
|
catalogModel("nvidia", "moonshotai/kimi-k2.5", "Kimi K2.5"),
|
|
catalogModel("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5"),
|
|
catalogModel("nvidia", "z-ai/glm5", "GLM5"),
|
|
]);
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([
|
|
manifestTextRow("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"),
|
|
manifestTextRow("nvidia", "moonshotai/kimi-k2.5", "Bundled Kimi K2.5"),
|
|
manifestTextRow("nvidia", "minimaxai/minimax-m2.7", "Bundled MiniMax M2.7"),
|
|
manifestTextRow("nvidia", "z-ai/glm-5.1", "Bundled GLM 5.1"),
|
|
manifestTextRow("nvidia", "minimaxai/minimax-m2.5", "Bundled MiniMax M2.5", "deprecated"),
|
|
manifestTextRow("nvidia", "z-ai/glm5", "Bundled GLM5", "deprecated"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
api: "openai-completions",
|
|
baseUrl: "https://integrate.api.nvidia.com/v1",
|
|
models: [
|
|
configuredTextModel("nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"),
|
|
configuredTextModel("moonshotai/kimi-k2.5", "Bundled Kimi K2.5"),
|
|
configuredTextModel("minimaxai/minimax-m2.7", "Bundled MiniMax M2.7"),
|
|
configuredTextModel("z-ai/glm-5.1", "Bundled GLM 5.1"),
|
|
configuredTextModel("minimaxai/minimax-m2.5", "Bundled MiniMax M2.5"),
|
|
configuredTextModel("z-ai/glm5", "Bundled GLM5"),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
loadCatalog: true,
|
|
providerScopedCatalog: true,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(multiselect as MockCallSource));
|
|
expect(values).toEqual([
|
|
"nvidia/nemotron-3-super-120b-a12b",
|
|
"nvidia/z-ai/glm-5.1",
|
|
"nvidia/minimaxai/minimax-m2.7",
|
|
"nvidia/moonshotai/kimi-k2.5",
|
|
]);
|
|
expect(result.scopeKeys).toEqual(values);
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps custom configured rows after provider-scoped live rows", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"),
|
|
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
|
|
]);
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([
|
|
manifestTextRow("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"),
|
|
manifestTextRow("nvidia", "z-ai/glm-5.1", "Bundled GLM 5.1"),
|
|
manifestTextRow("nvidia", "z-ai/glm5", "Bundled GLM5", "deprecated"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
api: "openai-completions",
|
|
baseUrl: "https://integrate.api.nvidia.com/v1",
|
|
models: [
|
|
configuredTextModel("nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"),
|
|
configuredTextModel("z-ai/glm5", "Configured GLM5 fallback"),
|
|
configuredTextModel("private/custom-nvidia", "Private NVIDIA model"),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
loadCatalog: true,
|
|
providerScopedCatalog: true,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(multiselect as MockCallSource));
|
|
expect(values).toEqual([
|
|
"nvidia/nemotron-3-super-120b-a12b",
|
|
"nvidia/z-ai/glm-5.1",
|
|
"nvidia/private/custom-nvidia",
|
|
]);
|
|
expect(result.scopeKeys).toEqual(values);
|
|
});
|
|
|
|
it("does not re-add configured static rows after filtering deprecated live rows", async () => {
|
|
loadPreferredProviderPickerCatalog.mockResolvedValue([
|
|
catalogModel("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5"),
|
|
catalogModel("nvidia", "z-ai/glm5", "GLM5"),
|
|
]);
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([
|
|
manifestTextRow("nvidia", "minimaxai/minimax-m2.5", "Bundled MiniMax M2.5", "deprecated"),
|
|
manifestTextRow("nvidia", "z-ai/glm5", "Bundled GLM5", "deprecated"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
api: "openai-completions",
|
|
baseUrl: "https://integrate.api.nvidia.com/v1",
|
|
models: [
|
|
configuredTextModel("minimaxai/minimax-m2.5", "Configured MiniMax M2.5"),
|
|
configuredTextModel("z-ai/glm5", "Configured GLM5"),
|
|
configuredTextModel("private/custom-nvidia", "Private NVIDIA model"),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
loadCatalog: true,
|
|
providerScopedCatalog: true,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(multiselect as MockCallSource));
|
|
expect(values).toEqual(["nvidia/private/custom-nvidia"]);
|
|
expect(result.scopeKeys).toEqual(values);
|
|
});
|
|
|
|
it("uses configured provider rows when a provider-scoped live catalog is unavailable", async () => {
|
|
loadStaticManifestCatalogRowsForList.mockReturnValue([
|
|
manifestTextRow("nvidia", "z-ai/glm5", "Bundled GLM5", "deprecated"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
api: "openai-completions",
|
|
baseUrl: "https://integrate.api.nvidia.com/v1",
|
|
models: [
|
|
configuredTextModel("custom-nvidia-model", "Custom NVIDIA model"),
|
|
configuredTextModel("z-ai/glm5", "Configured GLM5 fallback"),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "nvidia",
|
|
loadCatalog: true,
|
|
providerScopedCatalog: true,
|
|
});
|
|
|
|
const values = optionValues(pickerOptions(multiselect as MockCallSource));
|
|
expect(values).toEqual(["nvidia/custom-nvidia-model", "nvidia/z-ai/glm5"]);
|
|
expect(result.scopeKeys).toEqual(values);
|
|
expect(loadStaticManifestCatalogRowsForList).not.toHaveBeenCalled();
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps local no-key provider models visible in allowlist choices", async () => {
|
|
resolveEnvApiKey.mockReturnValue(null);
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("vllm", "meta-llama/Meta-Llama-3-8B-Instruct", "Meta Llama"),
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
api: "openai-completions",
|
|
baseUrl: "http://127.0.0.1:8000/v1",
|
|
models: [configuredTextModel("meta-llama/Meta-Llama-3-8B-Instruct", "Meta Llama")],
|
|
},
|
|
},
|
|
},
|
|
agents: { defaults: {} },
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({ config, prompter });
|
|
|
|
expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
|
|
"vllm/meta-llama/Meta-Llama-3-8B-Instruct",
|
|
]);
|
|
expect(result.models).toEqual(["vllm/meta-llama/Meta-Llama-3-8B-Instruct"]);
|
|
});
|
|
|
|
it("seeds existing model fallbacks into unscoped allowlist selections", async () => {
|
|
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
|
|
|
|
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
},
|
|
models: {
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({ config, prompter });
|
|
const call = pickerParams(multiselect as MockCallSource);
|
|
expect(optionValues(call.options as PickerOption[])).toEqual([
|
|
"openai/gpt-5.5",
|
|
"anthropic/claude-sonnet-4-6",
|
|
]);
|
|
expect(call.initialValues).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
|
|
expect(result.models).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
|
|
});
|
|
|
|
it("resolves bare fallback seeds against the primary model provider", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("anthropic", "claude-opus-4-6", "Claude Opus 4.5"),
|
|
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
|
|
catalogModel("openai", "claude-sonnet-4-6", "Wrong provider"),
|
|
]);
|
|
|
|
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["claude-sonnet-4-6"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({ config, prompter });
|
|
const call = pickerParams(multiselect as MockCallSource);
|
|
|
|
expect(call.initialValues).toEqual([
|
|
"anthropic/claude-opus-4-6",
|
|
"anthropic/claude-sonnet-4-6",
|
|
]);
|
|
expect(result.models).toEqual(["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"]);
|
|
});
|
|
|
|
it("keeps the no-catalog allowlist prompt blank when no allowlist exists", async () => {
|
|
loadModelCatalog.mockResolvedValue([]);
|
|
|
|
const text = vi.fn(async (params) => params.initialValue ?? "");
|
|
const prompter = makePrompter({ text });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({ config, prompter });
|
|
|
|
expect(pickerParams(text as MockCallSource).initialValue).toBe("");
|
|
expect(result).toStrictEqual({});
|
|
});
|
|
|
|
it("shows existing fallbacks in the no-catalog allowlist prompt when an allowlist exists", async () => {
|
|
loadModelCatalog.mockResolvedValue([]);
|
|
|
|
const text = vi.fn(async (params) => params.initialValue ?? "");
|
|
const prompter = makePrompter({ text });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
},
|
|
models: {
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({ config, prompter });
|
|
|
|
expect(pickerParams(text as MockCallSource).initialValue).toBe(
|
|
"openai/gpt-5.5, anthropic/claude-sonnet-4-6",
|
|
);
|
|
expect(result.models).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
|
|
});
|
|
|
|
it("keeps provider-scoped fallback supplements within scope", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
|
|
catalogModel("openai", "gpt-5.4", "GPT-5.4"),
|
|
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
|
|
]);
|
|
|
|
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "openai",
|
|
});
|
|
|
|
const call = pickerParams(multiselect as MockCallSource);
|
|
expect(optionValues(call.options as PickerOption[])).toEqual([
|
|
"openai/gpt-5.5",
|
|
"openai/gpt-5.4",
|
|
]);
|
|
expect(call.initialValues).toEqual(["openai/gpt-5.5"]);
|
|
expect(result).toEqual({
|
|
models: ["openai/gpt-5.5"],
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
|
|
});
|
|
});
|
|
|
|
it("uses configured provider-scoped seeds without loading the full catalog", async () => {
|
|
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
preferredProvider: "openai",
|
|
loadCatalog: false,
|
|
});
|
|
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual(["openai/gpt-5.5"]);
|
|
expect(pickerParams(multiselect as MockCallSource).initialValues).toEqual(["openai/gpt-5.5"]);
|
|
expect(result).toEqual({
|
|
models: ["openai/gpt-5.5"],
|
|
scopeKeys: ["openai/gpt-5.5"],
|
|
});
|
|
});
|
|
|
|
it("uses explicit allowed model keys without loading the full catalog", async () => {
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = await promptModelAllowlist({
|
|
config,
|
|
prompter,
|
|
allowedKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
|
|
preferredProvider: "openai",
|
|
});
|
|
|
|
expect(loadModelCatalog).not.toHaveBeenCalled();
|
|
expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
|
|
"openai/gpt-5.5",
|
|
"openai/gpt-5.4",
|
|
]);
|
|
expect(pickerParams(multiselect as MockCallSource).initialValues).toEqual(["openai/gpt-5.5"]);
|
|
expect(result).toEqual({
|
|
models: ["openai/gpt-5.5", "openai/gpt-5.4"],
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("runtime model picker visibility", () => {
|
|
it("hides legacy runtime refs from allowlist choices and configured supplements", async () => {
|
|
loadModelCatalog.mockResolvedValue([
|
|
{ provider: "codex", id: "gpt-5.5", name: "GPT-5.5" },
|
|
{ provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "google-gemini-cli", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
|
|
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "google", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
|
|
]);
|
|
|
|
const multiselect = createSelectAllMultiselect();
|
|
const prompter = makePrompter({ multiselect });
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"codex/gpt-5.5": { alias: "legacy-codex" },
|
|
"claude-cli/claude-sonnet-4-6": { alias: "CLI Claude" },
|
|
"google-gemini-cli/gemini-3-pro-preview": { alias: "CLI Gemini" },
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
await promptModelAllowlist({ config, prompter });
|
|
|
|
const call = pickerParams(multiselect as MockCallSource);
|
|
const values = optionValues(call.options as PickerOption[]);
|
|
expect(values).toEqual([
|
|
"openai/gpt-5.5",
|
|
"anthropic/claude-sonnet-4-6",
|
|
"google/gemini-3.1-pro-preview",
|
|
"openai/gpt-5.6-sol",
|
|
]);
|
|
expect(call.initialValues).toEqual(["openai/gpt-5.5", "openai/gpt-5.6-sol"]);
|
|
});
|
|
});
|
|
|
|
describe("router model filtering", () => {
|
|
it("filters internal router models in both default and allowlist prompts", async () => {
|
|
loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);
|
|
|
|
const select = vi.fn(async (params) => {
|
|
const first = params.options[0];
|
|
return first?.value ?? "";
|
|
});
|
|
const multiselect = createSelectAllMultiselect();
|
|
const defaultPrompter = makePrompter({ select });
|
|
const allowlistPrompter = makePrompter({ multiselect });
|
|
const config = { agents: { defaults: {} } } as OpenClawConfig;
|
|
|
|
await promptDefaultPicker({
|
|
config,
|
|
prompter: defaultPrompter,
|
|
});
|
|
await promptModelAllowlist({ config, prompter: allowlistPrompter });
|
|
|
|
const defaultOptions = pickerOptions(select as MockCallSource);
|
|
expectRouterModelFiltering(defaultOptions);
|
|
|
|
const allowlistCall = pickerParams(multiselect as MockCallSource);
|
|
expectRouterModelFiltering(allowlistCall.options as Array<{ value: string }>);
|
|
expect(allowlistCall.searchable).toBe(true);
|
|
expect(runProviderPluginAuthMethod).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("applyModelAllowlist", () => {
|
|
it("preserves existing entries for selected models", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
"anthropic/claude-opus-4-6": { alias: "opus" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelAllowlist(config, ["openai/gpt-5.5"]);
|
|
expect(next.agents?.defaults?.models).toEqual({
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
"anthropic/claude-opus-4-6": { alias: "opus" },
|
|
});
|
|
expect(next.agents?.defaults?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]);
|
|
});
|
|
|
|
it("normalizes retired Google Gemini refs before writing selected models", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"google/gemini-3.1-pro-preview": { alias: "gemini" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelAllowlist(config, [
|
|
"google/gemini-3-pro-preview",
|
|
"google-gemini-cli/gemini-3-pro-preview",
|
|
"openrouter/google/gemini-3-pro-preview",
|
|
]);
|
|
expect(next.agents?.defaults?.models).toEqual({
|
|
"google/gemini-3.1-pro-preview": { alias: "gemini" },
|
|
"google-gemini-cli/gemini-3.1-pro-preview": {},
|
|
"openrouter/google/gemini-3.1-pro-preview": {},
|
|
});
|
|
expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([
|
|
"google/gemini-3.1-pro-preview",
|
|
"google-gemini-cli/gemini-3.1-pro-preview",
|
|
"openrouter/google/gemini-3.1-pro-preview",
|
|
]);
|
|
});
|
|
|
|
it("keeps non-Google provider Gemini-looking refs unchanged while writing selected models", () => {
|
|
const config = {} as OpenClawConfig;
|
|
|
|
const next = applyModelAllowlist(config, ["litellm/gemini-3-flash", "litellm/gemini-3.1-pro"]);
|
|
expect(next.agents?.defaults?.models).toEqual({
|
|
"litellm/gemini-3-flash": {},
|
|
"litellm/gemini-3.1-pro": {},
|
|
});
|
|
expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([
|
|
"litellm/gemini-3-flash",
|
|
"litellm/gemini-3.1-pro",
|
|
]);
|
|
});
|
|
|
|
it("preserves entries outside scoped allowlist updates", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
"anthropic/claude-opus-4-6": { alias: "opus" },
|
|
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
|
},
|
|
modelPolicy: { allow: ["openai/*", "anthropic/*", "sonnet"] },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelAllowlist(config, ["anthropic/claude-sonnet-4-6"], {
|
|
scopeKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"],
|
|
});
|
|
expect(next.agents?.defaults?.models).toEqual({
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
"anthropic/claude-opus-4-6": { alias: "opus" },
|
|
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
|
});
|
|
expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([
|
|
"openai/*",
|
|
"anthropic/claude-sonnet-4-6",
|
|
]);
|
|
});
|
|
|
|
it("seeds provider-scoped configure edits from the effective legacy allowlist", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
"anthropic/claude-opus-4-6": { alias: "opus" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const applied = applyModelAllowlist(config, ["openai/gpt-5.6-sol"], {
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.6-sol"],
|
|
});
|
|
const next = stampConfigWriteMetadata(applied, undefined, undefined, config);
|
|
|
|
expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([
|
|
"anthropic/claude-opus-4-6",
|
|
"openai/gpt-5.6-sol",
|
|
]);
|
|
expect(next.meta?.migrations?.modelPolicyAllowlist).toBe(true);
|
|
});
|
|
|
|
it("clears an effective legacy restriction and preserves model metadata", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const applied = applyModelAllowlist(config, []);
|
|
const next = stampConfigWriteMetadata(applied, undefined, undefined, config);
|
|
expect(next.agents?.defaults?.models).toEqual({
|
|
"openai/gpt-5.5": { alias: "gpt" },
|
|
});
|
|
expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([]);
|
|
expect(next.meta?.migrations?.modelPolicyAllowlist).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("applyModelFallbacksFromSelection", () => {
|
|
it("sets fallbacks from selection when the primary is included", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-opus-4-6" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, [
|
|
"anthropic/claude-opus-4-6",
|
|
"anthropic/claude-sonnet-4-6",
|
|
]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
});
|
|
});
|
|
|
|
it("does not inject a phantom primary when none was configured", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, [
|
|
"openai/gpt-5.6-sol",
|
|
"anthropic/claude-sonnet-4-6",
|
|
]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
});
|
|
expect(next.agents?.defaults?.model).not.toHaveProperty("primary");
|
|
});
|
|
|
|
it("does not write an empty model object for singleton default selections", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
|
|
expect(next).toBe(config);
|
|
});
|
|
|
|
it("clears existing fallbacks when only the primary remains selected", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["anthropic/claude-opus-4-6"]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "anthropic/claude-opus-4-6",
|
|
});
|
|
});
|
|
|
|
it("normalizes retired Google Gemini refs in selected fallbacks before writing config", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["google/gemini-3-pro-preview"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, [
|
|
"openai/gpt-5.5",
|
|
"google/gemini-3-pro-preview",
|
|
"openrouter/google/gemini-3-pro-preview",
|
|
]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["google/gemini-3.1-pro-preview", "openrouter/google/gemini-3.1-pro-preview"],
|
|
});
|
|
});
|
|
|
|
it("normalizes a retired Google Gemini primary while writing selected fallbacks", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "google/gemini-3-pro-preview",
|
|
fallbacks: ["openai/gpt-5.5"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, [
|
|
"google/gemini-3.1-pro-preview",
|
|
"openai/gpt-5.5",
|
|
]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "google/gemini-3.1-pro-preview",
|
|
fallbacks: ["openai/gpt-5.5"],
|
|
});
|
|
});
|
|
|
|
it("drops malformed fallback refs instead of preserving raw strings", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["openai/"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "openai/gpt-5.5",
|
|
});
|
|
});
|
|
|
|
it("preserves hidden fallbacks during unscoped selections", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["claude-cli/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["claude-cli/claude-sonnet-4-6"],
|
|
});
|
|
});
|
|
|
|
it("preserves out-of-scope fallbacks during scoped selections", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"], {
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
|
|
});
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
});
|
|
});
|
|
|
|
it("removes scoped fallbacks for empty scoped selections", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["openai/gpt-5.5", "google/gemini-3-pro-preview"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, [], {
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
|
|
});
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["google/gemini-3.1-pro-preview"],
|
|
});
|
|
});
|
|
|
|
it("does not add new scoped fallbacks when the primary is outside scope", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["openai/gpt-5.5"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5", "openai/gpt-5.4"], {
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
|
|
});
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["openai/gpt-5.5"],
|
|
});
|
|
});
|
|
|
|
it("removes existing scoped fallback aliases when deselected", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["mini"],
|
|
},
|
|
models: {
|
|
"openai/gpt-5.4-mini": { alias: "mini" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"], {
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4-mini"],
|
|
});
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "openai/gpt-5.5",
|
|
});
|
|
});
|
|
|
|
it("canonicalizes existing scoped fallback aliases when kept selected", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["mini"],
|
|
},
|
|
models: {
|
|
"openai/gpt-5.4-mini": { alias: "mini" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(
|
|
config,
|
|
["openai/gpt-5.5", "openai/gpt-5.4-mini"],
|
|
{
|
|
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4-mini"],
|
|
},
|
|
);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "openai/gpt-5.5",
|
|
fallbacks: ["openai/gpt-5.4-mini"],
|
|
});
|
|
});
|
|
|
|
it("keeps existing fallbacks when the primary is not selected", () => {
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-opus-4-6", fallbacks: ["openai/gpt-5.5"] },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
|
|
expect(next.agents?.defaults?.model).toEqual({
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["openai/gpt-5.5"],
|
|
});
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|