mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
1526 lines
53 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|
|
});
|