Files
nanobot/webui/src/tests/settings-models.test.tsx
T

1526 lines
53 KiB
TypeScript

import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, openPopover, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
function settingsPayloadWithBackup(): {
payload: SettingsPayload;
backupPreset: SettingsPayload["model_presets"][number];
} {
const base = settingsPayload();
const backupPreset = {
...base.model_presets[0],
name: "backup",
label: "Backup",
active: false,
model: "anthropic/claude-sonnet-4",
provider: "anthropic",
resolved_provider: "anthropic",
};
return {
backupPreset,
payload: {
...base,
model_presets: [base.model_presets[0], backupPreset],
model_call_order: ["primary", "backup"],
providers: [
{
name: "openai",
label: "OpenAI",
configured: true,
},
{
name: "anthropic",
label: "Anthropic",
configured: true,
},
],
},
};
}
function autoDynamicProviderPayload(
options: {
configured: boolean;
hasApiKey: boolean;
apiBase: string | null;
apiKeyHint: string | null;
},
): SettingsPayload {
const base = settingsPayload();
return {
...base,
agent: {
...base.agent,
model: "companyProxy/gpt-4o",
provider: "companyProxy",
resolved_provider: "companyProxy",
has_api_key: options.hasApiKey,
},
model_presets: [
{
...base.model_presets[0],
model: "companyProxy/gpt-4o",
provider: "auto",
resolved_provider: "companyProxy",
},
],
providers: [
{
name: "companyProxy",
label: "Company Proxy",
configured: options.configured,
auth_type: "api_key",
api_key_required: false,
api_key_hint: options.apiKeyHint,
api_base: options.apiBase,
default_api_base: null,
},
],
};
}
async function togglePresetEditor(name = "primary") {
const row = await screen.findByTestId(`model-call-order-row-${name}`);
fireEvent.click(within(row).getAllByRole("button")[0]);
}
describe("Settings models", () => {
installSettingsViewTestHooks();
it("uses the preset name as the canonical identity", async () => {
const payload = settingsPayload();
payload.model_presets[0] = {
...payload.model_presets[0],
name: "openai",
label: "minimax",
};
payload.model_call_order = ["openai"];
payload.agent.model_preset = "openai";
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor("openai");
const editor = screen.getByTestId("model-preset-editor");
expect(within(editor).getByText("Preset name")).toBeInTheDocument();
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
"openai",
);
expect(within(editor).queryByText("minimax")).not.toBeInTheDocument();
});
it("edits a legacy case-conflicting preset without treating its own name as a rename", async () => {
const payload = settingsPayload();
const primary = {
...payload.model_presets[0],
name: "Fast",
label: "Fast",
active: true,
};
const legacyConflict = {
...primary,
name: "fast",
label: "fast",
active: false,
};
const legacyPayload: SettingsPayload = {
...payload,
agent: { ...payload.agent, model_preset: "Fast" },
model_presets: [primary, legacyConflict],
model_call_order: ["Fast", "fast"],
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
requestMutationMock.mockResolvedValueOnce(legacyPayload);
renderSettingsView({ initialSection: "models", initialSettings: legacyPayload });
await togglePresetEditor("Fast");
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
fireEvent.change(screen.getByLabelText("Temperature"), {
target: { value: "0.4" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.model_configuration.update",
{ name: "Fast", temperature: 0.4 },
20_000,
);
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("renames an existing preset without losing the editor selection", async () => {
const payload = settingsPayload();
const renamedPayload: SettingsPayload = {
...payload,
agent: { ...payload.agent, model_preset: "Codex" },
model_presets: payload.model_presets.map((preset) => ({
...preset,
name: "Codex",
label: "Codex",
})),
model_call_order: ["Codex"],
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
requestMutationMock.mockResolvedValueOnce(renamedPayload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.model_configuration.update",
{ name: "primary", new_name: "Codex" },
20_000,
);
});
expect(await screen.findByTestId("model-call-order-row-Codex")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Preset name" })).toHaveValue("Codex");
});
it("maps a server-side name conflict back to the preset name field", async () => {
const payload = settingsPayload();
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
requestMutationMock.mockRejectedValueOnce({ status: 409 });
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"A preset with this name already exists.",
);
expect(nameInput).toHaveAttribute("aria-invalid", "true");
expect(nameInput).toHaveFocus();
});
it("keeps generation parameters collapsed until advanced options are opened", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
expect(screen.queryByText("Edit preset")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Advanced options/ })).not.toBeInTheDocument();
await togglePresetEditor();
const advanced = await screen.findByRole("button", { name: /Advanced options/ });
expect(screen.queryByText("Context window")).not.toBeInTheDocument();
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
fireEvent.click(advanced);
expect(await screen.findByText("Context window")).toBeInTheDocument();
expect(screen.getByText("Temperature")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "200K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "500K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "1M" })).toBeInTheDocument();
const reasoningEffort = screen.getByLabelText("Reasoning effort");
expect(reasoningEffort).toHaveProperty("type", "text");
fireEvent.change(reasoningEffort, { target: { value: "provider-native-mode" } });
expect(reasoningEffort).toHaveValue("provider-native-mode");
});
it("expands the model preset editor directly below the selected row", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const row = await screen.findByTestId("model-call-order-row-primary");
const trigger = within(row).getAllByRole("button")[0];
expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument();
expect(trigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(trigger);
const editor = screen.getByTestId("model-preset-editor");
expect(trigger).toHaveAttribute("aria-pressed", "true");
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(trigger).toHaveAttribute("aria-controls", "model-preset-editor");
expect(row.parentElement).toHaveAttribute("role", "listitem");
expect(row.parentElement?.parentElement).toHaveAttribute("role", "list");
expect(row.nextElementSibling).toBe(editor);
expect(editor).toHaveClass(
"slide-in-from-top-1",
"lg:max-w-6xl",
"rounded-floating",
);
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
"primary",
);
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");
expect(
within(editor).getByText("Remove this preset from the call order before deleting it."),
).toBeInTheDocument();
fireEvent.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument();
});
it("drags model presets to reorder and saves the model call order immediately", async () => {
const { payload, backupPreset } = settingsPayloadWithBackup();
const updatedPayload: SettingsPayload = {
...payload,
agent: {
...payload.agent,
model: backupPreset.model,
provider: backupPreset.provider,
resolved_provider: backupPreset.resolved_provider,
model_preset: backupPreset.name,
},
model_presets: payload.model_presets.map((preset) => ({
...preset,
active: preset.name === backupPreset.name,
})),
model_call_order: ["backup", "primary"],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(updatedPayload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
await togglePresetEditor();
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
fireEvent.change(screen.getByLabelText("Temperature"), {
target: { value: "0.4" },
});
const primaryRow = screen.getByTestId("model-call-order-row-primary");
const backupRow = screen.getByTestId("model-call-order-row-backup");
expect(backupRow).toHaveAttribute("draggable", "true");
expect(screen.queryByRole("button", { name: "Move up" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Move down" })).not.toBeInTheDocument();
const dataTransfer = {
dropEffect: "move",
effectAllowed: "move",
setData: vi.fn(),
};
fireEvent.dragStart(backupRow, { dataTransfer });
fireEvent.dragEnter(primaryRow, { dataTransfer });
fireEvent.drop(primaryRow, { dataTransfer });
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.model_call_order.update",
{ order: ["backup", "primary"] },
20_000,
);
});
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Temperature")).toHaveValue(0.4);
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("keeps repeated fallback preset rows stable when changing the primary preset", async () => {
const { payload, backupPreset } = settingsPayloadWithBackup();
const initialPayload: SettingsPayload = {
...payload,
agent: {
...payload.agent,
model: backupPreset.model,
provider: backupPreset.provider,
resolved_provider: backupPreset.resolved_provider,
model_preset: backupPreset.name,
},
model_presets: payload.model_presets.map((preset) => ({
...preset,
active: preset.name === backupPreset.name,
})),
model_call_order: ["backup", "primary", "backup"],
};
const updatedPayload: SettingsPayload = {
...initialPayload,
agent: {
...payload.agent,
model_preset: "primary",
},
model_presets: payload.model_presets.map((preset) => ({
...preset,
active: preset.name === "primary",
})),
model_call_order: ["primary", "backup", "backup"],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(initialPayload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(updatedPayload);
renderSettingsView({ initialSection: "models", initialSettings: initialPayload });
const primaryRow = screen.getByTestId("model-call-order-row-primary");
const backupRows = screen.getAllByTestId("model-call-order-row-backup");
const firstBackupRow = backupRows[0];
const secondBackupRow = backupRows[1];
const secondBackupTrigger = within(secondBackupRow).getAllByRole("button")[0];
fireEvent.click(secondBackupTrigger);
expect(screen.getAllByTestId("model-preset-editor")).toHaveLength(1);
expect(secondBackupRow.nextElementSibling).toBe(
screen.getByTestId("model-preset-editor"),
);
fireEvent.click(secondBackupTrigger);
const dataTransfer = {
dropEffect: "move",
effectAllowed: "move",
setData: vi.fn(),
};
fireEvent.dragStart(primaryRow, { dataTransfer });
fireEvent.dragEnter(firstBackupRow, { dataTransfer });
fireEvent.drop(firstBackupRow, { dataTransfer });
await waitFor(() =>
expect(
screen
.getAllByTestId(/^model-call-order-row-/)
.map((row) => row.getAttribute("data-testid")),
).toEqual([
"model-call-order-row-primary",
"model-call-order-row-backup",
"model-call-order-row-backup",
]),
);
});
it("restores the model call order when immediate persistence fails", async () => {
const { payload } = settingsPayloadWithBackup();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockRejectedValueOnce(new Error("Order update failed"));
renderSettingsView({ initialSection: "models", initialSettings: payload });
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
fireEvent.keyDown(screen.getByTestId("model-call-order-row-backup"), {
key: "ArrowUp",
});
expect(await screen.findByText("Order update failed")).toBeInTheDocument();
expect(
screen
.getAllByTestId(/^model-call-order-row-/)
.map((row) => row.getAttribute("data-testid")),
).toEqual([
"model-call-order-row-primary",
"model-call-order-row-backup",
]);
});
it("shows presets outside the call order in the unified list and adds them directly", async () => {
const { payload } = settingsPayloadWithBackup();
const codexPreset = {
...payload.model_presets[0],
name: "codex",
label: "Codex",
active: false,
model: "openai-codex/gpt-5.5",
provider: "openai",
resolved_provider: "openai",
};
const payloadWithCodex: SettingsPayload = {
...payload,
model_presets: [...payload.model_presets, codexPreset],
};
const orderedPayload: SettingsPayload = {
...payloadWithCodex,
model_call_order: ["primary", "backup", "codex"],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payloadWithCodex);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(orderedPayload);
renderSettingsView({ initialSection: "models", initialSettings: payloadWithCodex });
const codexRow = await screen.findByTestId("model-call-order-row-codex");
expect(codexRow).toHaveTextContent("codex");
expect(codexRow).not.toHaveTextContent("Codex");
expect(codexRow).toHaveTextContent("openai-codex/gpt-5.5");
expect(codexRow).toHaveTextContent("Disabled");
expect(codexRow).toHaveAttribute("draggable", "false");
expect(screen.queryByRole("button", { name: "Add preset" })).not.toBeInTheDocument();
const enableSwitch = within(codexRow).getByRole("switch", { name: "Enable preset" });
expect(enableSwitch).not.toBeChecked();
fireEvent.click(enableSwitch);
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.model_call_order.update",
{ order: ["primary", "backup", "codex"] },
20_000,
);
});
const enabledCodexRow = await screen.findByTestId("model-call-order-row-codex");
expect(enabledCodexRow).not.toHaveTextContent("Disabled");
expect(enabledCodexRow).not.toHaveTextContent(/Fallback/);
expect(enabledCodexRow).toHaveAttribute("draggable", "true");
expect(
within(enabledCodexRow).getByRole("switch", { name: "Disable preset" }),
).toBeChecked();
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
});
it("appends a new model preset to the call order immediately", async () => {
const { payload } = settingsPayloadWithBackup();
const writerPreset = {
...payload.model_presets[0],
name: "Writer",
label: "Writer",
active: false,
model: "openai/gpt-4o-mini",
provider: "openai",
resolved_provider: "openai",
};
const createdPayload: SettingsPayload = {
...payload,
model_presets: [...payload.model_presets, writerPreset],
created_model_preset: writerPreset.name,
};
const orderedPayload: SettingsPayload = {
...createdPayload,
model_call_order: ["primary", "backup", "Writer"],
created_model_preset: undefined,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock
.mockResolvedValueOnce(createdPayload)
.mockResolvedValueOnce(orderedPayload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
screen.queryByText("Complete the preset before saving."),
).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
target: { value: "Writer" },
});
await openPopover(screen.getByRole("button", { name: "Select model" }));
const modelSearch = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(modelSearch, {
target: { value: "openai/gpt-4o-mini" },
});
fireEvent.keyDown(modelSearch, { key: "Enter" });
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
fireEvent.click(saveButton);
await waitFor(() => {
expect(requestMutationMock).toHaveBeenLastCalledWith(
"settings.model_call_order.update",
{ order: ["primary", "backup", "Writer"] },
20_000,
);
});
const writerRow = await screen.findByTestId("model-call-order-row-Writer");
expect(writerRow).not.toHaveTextContent("Disabled");
expect(writerRow).not.toHaveTextContent(/Fallback/);
expect(within(writerRow).getByRole("switch", { name: "Disable preset" })).toBeChecked();
expect(screen.getAllByText("Writer").length).toBeGreaterThan(0);
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
});
it("shows an inline error when a new preset name already exists", async () => {
const { payload } = settingsPayloadWithBackup();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response),
);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "PRIMARY" } });
await openPopover(screen.getByRole("button", { name: "Select model" }));
const modelSearch = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } });
fireEvent.keyDown(modelSearch, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(requestMutationMock).not.toHaveBeenCalled();
expect(nameInput).toHaveAttribute("aria-invalid", "true");
expect(nameInput).toHaveAttribute("aria-describedby", "model-preset-name-error");
expect(screen.getByRole("alert")).toHaveTextContent(
"A preset with this name already exists.",
);
expect(nameInput.parentElement).toHaveClass(
"animate-[preset-name-shake_180ms_ease-in-out]",
);
fireEvent.change(nameInput, { target: { value: "Writer" } });
expect(nameInput).toHaveAttribute("aria-invalid", "false");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("converts legacy model settings into presets before editing call order", async () => {
const migratedPayload = settingsPayload();
const defaultPreset = {
...migratedPayload.model_presets[0],
name: "default",
label: "Default",
is_default: true,
};
const legacyPayload: SettingsPayload = {
...migratedPayload,
agent: {
...migratedPayload.agent,
model_preset: "default",
},
model_presets: [defaultPreset],
model_call_order: [],
model_call_order_editable: false,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(legacyPayload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(migratedPayload);
renderSettingsView({ initialSection: "models", initialSettings: legacyPayload });
fireEvent.click(
await screen.findByRole("button", { name: "Convert to presets" }),
);
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.model_configuration.migrate",
{},
20_000,
),
);
expect(
screen.queryByRole("button", { name: "Convert to presets" }),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
expect(screen.queryByText("Default")).not.toBeInTheDocument();
});
it("does not expose the synthetic default configuration as a WebUI preset", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
agent: {
...base.agent,
model: "MiniMax-M3",
provider: "minimax_anthropic",
resolved_provider: "minimax_anthropic",
model_preset: "fast",
},
model_presets: [
{
...base.model_presets[0],
name: "default",
label: "Default",
active: false,
is_default: true,
model: "openai-codex/gpt-5.5",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
{
...base.model_presets[0],
name: "fast",
label: "fast",
active: true,
is_default: false,
model: "MiniMax-M3",
provider: "minimax_anthropic",
resolved_provider: "minimax_anthropic",
},
],
model_call_order: ["fast"],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "minimax_anthropic",
label: "MiniMax (Anthropic)",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.minimax.io/anthropic",
default_api_base: "https://api.minimax.io/anthropic",
},
],
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({ initialSection: "models", initialSettings: payload });
expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0);
expect(screen.getAllByText("fast").length).toBeGreaterThan(0);
expect(screen.queryByText("Default")).not.toBeInTheDocument();
expect(screen.queryByText("openai-codex/gpt-5.5")).not.toBeInTheDocument();
});
it("does not expose the synthetic default preset in the overview summary", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
agent: {
...base.agent,
model_preset: "default",
},
model_presets: [
{
...base.model_presets[0],
name: "default",
label: "Default",
is_default: true,
},
],
model_call_order: [],
model_call_order_editable: false,
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({ initialSection: "overview", initialSettings: payload });
expect(await screen.findByText("openai/gpt-4o")).toBeInTheDocument();
expect(screen.queryByText("openai · default")).not.toBeInTheDocument();
expect(screen.getByText("openai")).toBeInTheDocument();
});
it("uses the resolved provider row for auto dynamic providers without api keys", async () => {
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({
initialSection: "models",
initialSettings: autoDynamicProviderPayload({
configured: true,
hasApiKey: false,
apiBase: "https://proxy.example.test/v1",
apiKeyHint: null,
}),
});
expect((await screen.findAllByText("companyProxy/gpt-4o")).length).toBeGreaterThan(0);
expect(screen.getAllByText("Company Proxy").length).toBeGreaterThan(0);
expect(screen.queryByText("Provider setup required")).not.toBeInTheDocument();
});
it("does not treat auto dynamic provider api keys as configured without apiBase", async () => {
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({
initialSection: "models",
initialSettings: autoDynamicProviderPayload({
configured: false,
hasApiKey: true,
apiBase: null,
apiKeyHint: "sk-...",
}),
});
expect((await screen.findAllByText("companyProxy/gpt-4o")).length).toBeGreaterThan(0);
expect(screen.getByText("Provider setup required")).toBeInTheDocument();
expect(
screen.queryByText("Configure this provider before saving the preset."),
).not.toBeInTheDocument();
});
it("marks the current model as unconfigured when its provider needs setup", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
resolved_provider: "openai_codex",
has_api_key: false,
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
expect(await screen.findByText("Provider setup required")).toBeInTheDocument();
await togglePresetEditor();
expect(screen.getAllByText(/Sign in before saving/).length).toBeGreaterThan(0);
expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument();
});
it("keeps unsigned OAuth providers out of the active provider picker", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "deepseek-chat",
provider: "deepseek",
resolved_provider: "deepseek",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "deepseek-chat",
provider: "deepseek",
},
],
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.deepseek.com",
default_api_base: "https://api.deepseek.com",
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
await togglePresetEditor();
const deepseekButtons = await screen.findAllByRole("button", { name: /DeepSeek/ });
const providerPicker = deepseekButtons.find(
(button) => button.getAttribute("aria-haspopup") === "menu",
);
if (!providerPicker) throw new Error("provider picker was not found");
fireEvent.pointerDown(providerPicker);
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
});
it("does not fetch model lists for unsigned OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
await togglePresetEditor();
await openPopover(await screen.findByRole("button", { name: /Select model/i }));
expect(
await screen.findByText("Configure this provider before loading models."),
).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("prefills manual model ids for configured OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "open-codex/gpt-5.5",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "open-codex/gpt-5.5",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
await openPopover(modelButtons[modelButtons.length - 1]);
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
expect(input.value).toBe("open-codex/gpt-5.5");
fireEvent.change(input, { target: { value: "openai-codex/gpt-5.5" } });
expect(await screen.findByText("“openai-codex/gpt-5.5”")).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("defers the OrcaRouter catalog until the user searches", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
agent: {
...base.agent,
model: "orcarouter/auto",
provider: "orcarouter",
resolved_provider: "orcarouter",
},
model_presets: [
{
...base.model_presets[0],
model: "orcarouter/auto",
provider: "orcarouter",
resolved_provider: "orcarouter",
},
],
providers: [
{
name: "orcarouter",
label: "OrcaRouter",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-o••••test",
api_base: null,
default_api_base: "https://api.orcarouter.ai/v1",
model_catalog: "catalog",
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/provider-models?provider=orcarouter") {
return jsonResponse({
provider: "orcarouter",
label: "OrcaRouter",
status: "available",
catalog_kind: "catalog",
models: [
{ id: "orcarouter/auto", owned_by: "orcarouter" },
{ id: "anthropic/claude-sonnet-4.6", owned_by: "anthropic" },
],
model_count: 2,
fetched_at: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /orcarouter\/auto/i });
await openPopover(modelButtons[modelButtons.length - 1]);
expect(await screen.findByText("Search provider catalog to choose a model.")).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
fireEvent.change(screen.getByPlaceholderText("Search or type model ID"), {
target: { value: "cl" },
});
await screen.findByText("anthropic/claude-sonnet-4.6");
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=orcarouter",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("loads curated models for configured OAuth providers", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
agent: {
...base.agent,
model: "openai-codex/gpt-5.5",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...base.model_presets[0],
model: "openai-codex/gpt-5.5",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings/provider-models?provider=openai_codex") {
return jsonResponse({
provider: "openai_codex",
label: "OpenAI Codex",
status: "available",
catalog_kind: "builtin",
models: [
{
id: "openai-codex/gpt-5.6-sol",
label: "GPT-5.6-Sol",
description: "Latest frontier agentic coding model.",
owned_by: "OpenAI Codex",
context_window: 372000,
},
],
model_count: 1,
fetched_at: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", {
name: /openai-codex\/gpt-5\.5/i,
});
await openPopover(modelButtons[modelButtons.length - 1]);
expect(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument();
expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=openai_codex",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("loads hybrid online models for configured OAuth providers", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
agent: {
...base.agent,
model: "xai-grok/grok-4.5",
provider: "xai_grok",
resolved_provider: "xai_grok",
},
model_presets: [
{
...base.model_presets[0],
model: "xai-grok/grok-4.5",
provider: "xai_grok",
},
],
providers: [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "hybrid",
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings/provider-models?provider=xai_grok") {
return jsonResponse({
provider: "xai_grok",
label: "xAI Grok",
status: "available",
catalog_kind: "hybrid",
source: "remote",
models: [
{
id: "xai-grok/grok-4.6",
label: "Grok 4.6",
description: "Latest frontier model",
owned_by: "xAI",
context_window: 500_000,
},
{
id: "xai-grok/grok-4.5",
label: "Grok 4.5",
owned_by: "xAI",
context_window: 500_000,
},
],
model_count: 2,
fetched_at: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", {
name: /xai-grok\/grok-4\.5/i,
});
await openPopover(modelButtons[modelButtons.length - 1]);
expect(await screen.findByText("Grok 4.6")).toBeInTheDocument();
expect(screen.getByText(/Latest frontier model/)).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=xai_grok",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("creates presets in the inline editor and can cancel without opening a dialog", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
fireEvent.click(await screen.findByRole("button", { name: "New model preset" }));
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("Fast writing")).toHaveValue("");
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
expect(screen.getByText("Temperature")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByDisplayValue("Primary")).not.toBeInTheDocument();
expect(screen.queryByText("Edit preset")).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
expect(await screen.findByPlaceholderText("Fast writing")).toHaveValue("");
});
it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "deepseek-chat",
provider: "deepseek",
resolved_provider: "deepseek",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "deepseek-chat",
provider: "deepseek",
},
],
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.deepseek.com",
default_api_base: "https://api.deepseek.com",
},
],
};
const updatedPayload: SettingsPayload = {
...payload,
agent: {
...payload.agent,
model: "deepseek-reasoner",
temperature: 0.4,
},
model_presets: [
{
...payload.model_presets[0],
model: "deepseek-reasoner",
temperature: 0.4,
reasoning_effort: "provider-native-mode",
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/provider-models?provider=deepseek") {
return jsonResponse({
provider: "deepseek",
label: "DeepSeek",
status: "available",
catalog_kind: "official",
models: [
{ id: "deepseek-chat", owned_by: "deepseek", context_window: 65536 },
{ id: "deepseek-reasoner", owned_by: "deepseek", context_window: 65536 },
],
model_count: 2,
fetched_at: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(updatedPayload);
renderSettingsView({ initialSection: "models" });
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
await openPopover(modelButtons[modelButtons.length - 1]);
await screen.findByText("deepseek-reasoner");
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
fireEvent.change(screen.getByLabelText("Temperature"), {
target: { value: "0.4" },
});
fireEvent.change(screen.getByLabelText("Reasoning effort"), {
target: { value: "provider-native-mode" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=deepseek",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
await waitFor(() => {
const saveCall = requestMutationMock.mock.calls.find(([action]) =>
action === "settings.model_configuration.update",
);
expect(saveCall).toBeDefined();
expect(saveCall?.[1]).toEqual({
name: "primary",
model: "deepseek-reasoner",
reasoning_effort: "provider-native-mode",
temperature: 0.4,
});
});
});
});