Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 31 additions & 12 deletions app/src/components/settings/panels/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,23 @@ const BUILTIN_PROVIDER_META: Record<string, { tone: string; label: string }> = {
label: 'OpenRouter',
tone: 'bg-slate-100 dark:bg-slate-500/15 ring-slate-300 text-slate-900 dark:text-slate-100',
},
novita: {
label: 'Novita AI',
tone: 'bg-cyan-50 dark:bg-cyan-500/10 ring-cyan-200 text-cyan-900 dark:text-cyan-100',
},
custom: {
label: 'Custom',
tone: 'bg-stone-100 dark:bg-neutral-800 ring-stone-300 text-stone-900 dark:text-neutral-100',
},
};

const BUILTIN_CLOUD_SLUGS = ['openai', 'anthropic', 'openrouter', 'novita', 'custom'] as const;
const CLOUD_EDITOR_SLUGS = ['openai', 'anthropic', 'openrouter', 'custom'] as const;

const BUILTIN_PROVIDER_DEFAULT_MODELS: Record<string, string> = {
novita: 'deepseek/deepseek-v4-pro',
};

const WORKLOADS: Workload[] = [
{
id: 'reasoning',
Expand Down Expand Up @@ -451,7 +462,9 @@ const ProviderKeyDialog = ({
? 'sk-ant-...'
: slug === 'openrouter'
? 'sk-or-...'
: 'your-api-key';
: slug === 'novita'
? 'Novita API key'
: 'your-api-key';

const handleSave = async () => {
const trimmed = apiKey.trim();
Expand Down Expand Up @@ -1515,7 +1528,7 @@ const CustomRoutingDialog = ({
if (initial.kind === 'cloud' || initial.kind === 'local') return initial.model;
if (initialSource?.kind === 'cloud') {
const p = customCloud.find(c => c.slug === initialSource.providerSlug);
return p ? '' : '';
return p ? (BUILTIN_PROVIDER_DEFAULT_MODELS[p.slug] ?? '') : '';
}
return localModels[0]?.id ?? '';
});
Expand Down Expand Up @@ -1591,7 +1604,7 @@ const CustomRoutingDialog = ({
setModel(localModels[0]?.id ?? '');
} else if (kind === 'cloud') {
setSource({ kind: 'cloud', providerSlug: slug });
setModel('');
setModel(BUILTIN_PROVIDER_DEFAULT_MODELS[slug] ?? '');
}
}}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
Expand Down Expand Up @@ -1624,7 +1637,12 @@ const CustomRoutingDialog = ({
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder={selectedCloud ? `${selectedCloud.slug} model id` : 'model-id'}
placeholder={
selectedCloud
? (BUILTIN_PROVIDER_DEFAULT_MODELS[selectedCloud.slug] ??
`${selectedCloud.slug} model id`)
: 'model-id'
}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
)}
Expand Down Expand Up @@ -1797,8 +1815,8 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
)}

<div className="flex flex-wrap gap-2">
{/* Built-in cloud providers — openai/anthropic/openrouter/custom */}
{(['openai', 'anthropic', 'openrouter', 'custom'] as const).map(slug => {
{/* Built-in cloud providers — openai/anthropic/openrouter/novita/custom */}
{BUILTIN_CLOUD_SLUGS.map(slug => {
const meta = BUILTIN_PROVIDER_META[slug];
const label = meta?.label ?? slug;
const existing = draft.cloudProviders.find(cp => cp.slug === slug);
Expand Down Expand Up @@ -2111,11 +2129,7 @@ const CloudProviderEditor = ({
}) => {
const { t } = useT();
const defaultSlug: string =
initial?.slug ??
(['openai', 'anthropic', 'openrouter', 'custom'] as const).find(
s => !existingSlugs.includes(s)
) ??
'custom';
initial?.slug ?? CLOUD_EDITOR_SLUGS.find(s => !existingSlugs.includes(s)) ?? 'custom';
const [slug, setSlug] = useState<string>(defaultSlug);
const [label, setLabel] = useState<string>(
initial?.label ?? BUILTIN_PROVIDER_META[defaultSlug]?.label ?? defaultSlug
Expand All @@ -2125,6 +2139,9 @@ const CloudProviderEditor = ({
const [saving, setSaving] = useState(false);
const isOpenHuman = slug === 'openhuman';
const hasExistingKey = (initial?.maskedKey ?? '').startsWith('••••');
const editorSlugOptions = CLOUD_EDITOR_SLUGS.includes(slug as (typeof CLOUD_EDITOR_SLUGS)[number])
? CLOUD_EDITOR_SLUGS
: ([slug, ...CLOUD_EDITOR_SLUGS] as const);

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/30 p-4">
Expand Down Expand Up @@ -2157,7 +2174,7 @@ const CloudProviderEditor = ({
}}
disabled={!!initial}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200">
{(['openai', 'anthropic', 'openrouter', 'custom'] as const)
{editorSlugOptions
.filter(s => s === slug || !existingSlugs.includes(s))
.map(s => (
<option key={s} value={s}>
Expand Down Expand Up @@ -2261,6 +2278,8 @@ function defaultEndpointFor(slug: string): string {
return 'https://api.anthropic.com/v1';
case 'openrouter':
return 'https://openrouter.ai/api/v1';
case 'novita':
return 'https://api.novita.ai/openai';
default:
return '';
}
Expand Down
125 changes: 125 additions & 0 deletions app/src/components/settings/panels/__tests__/AIPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,88 @@
expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
});

it('connects Novita AI with the built-in OpenAI-compatible endpoint', async () => {
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
vi.mocked(setCloudProviderKey).mockResolvedValue(undefined);
vi.mocked(saveAISettings).mockResolvedValue(undefined);

renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect Novita AI/i })).toBeInTheDocument()
);

fireEvent.click(screen.getByRole('switch', { name: /Connect Novita AI/i }));
await waitFor(() =>
expect(screen.getByRole('dialog', { name: /Connect Novita AI/i })).toBeInTheDocument()
);

fireEvent.change(screen.getByLabelText(/API key/i), {
target: { value: 'novita-test-key' },
});
fireEvent.click(screen.getByRole('button', { name: /^Save$/i }));

await waitFor(() =>
expect(screen.getByRole('switch', { name: /Disconnect Novita AI/i })).toBeInTheDocument()
);
expect(vi.mocked(setCloudProviderKey)).toHaveBeenCalledWith('novita', 'novita-test-key');

fireEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => expect(vi.mocked(saveAISettings)).toHaveBeenCalled());

const [, nextSettings] = vi.mocked(saveAISettings).mock.calls[0];
const novitaProvider = nextSettings.cloudProviders.find(
(p: { slug: string }) => p.slug === 'novita'
);
expect(novitaProvider).toMatchObject({
slug: 'novita',
label: 'Novita AI',
endpoint: 'https://api.novita.ai/openai',
auth_style: 'bearer',
has_api_key: true,
});
});

it('prefills the Novita default model when routing a workload to Novita AI', async () => {
const settingsWithNovita = {
cloudProviders: [
{
id: 'p_novita_1',
slug: 'novita',
label: 'Novita AI',
endpoint: 'https://api.novita.ai/openai',
auth_style: 'bearer' as const,
has_api_key: true,
},
],
routing: baseSettings.routing,
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithNovita);
vi.mocked(saveAISettings).mockResolvedValue(undefined);

renderWithProviders(<AIPanel />);
await waitFor(() => expect(screen.getAllByText(/Novita AI/i).length).toBeGreaterThan(0));

const reasoningRow = screen.getByText('Reasoning').parentElement!.parentElement!;
fireEvent.click(within(reasoningRow).getAllByRole('button')[1]);

await waitFor(() =>
expect(screen.getByDisplayValue('deepseek/deepseek-v4-pro')).toBeInTheDocument()
);
const dialog = screen.getByRole('dialog', { name: /Custom routing for Reasoning/i });
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/i }));

await waitFor(() => expect(screen.getByText(/unsaved change/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => expect(vi.mocked(saveAISettings)).toHaveBeenCalled());

const [, nextSettings] = vi.mocked(saveAISettings).mock.calls[0];
expect(nextSettings.routing.reasoning).toEqual({
kind: 'cloud',
providerSlug: 'novita',
model: 'deepseek/deepseek-v4-pro',
});
});

it('clicking the Custom chip (when disabled) opens the CloudProviderEditor, not the key dialog', async () => {
// Load with no custom provider → chip is off.
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
Expand All @@ -315,6 +397,49 @@
expect(screen.queryByRole('dialog', { name: /Connect Custom/i })).not.toBeInTheDocument();
});

it('keeps the Custom editor default on custom when Novita AI is the only unconfigured built-in provider', async () => {
vi.mocked(loadAISettings).mockResolvedValue({
...baseSettings,
cloudProviders: [
{
id: 'p_openai_1',
slug: 'openai',
label: 'OpenAI',
endpoint: 'https://api.openai.com/v1',
auth_style: 'bearer' as const,
has_api_key: true,
},
{
id: 'p_anthropic_1',
slug: 'anthropic',
label: 'Anthropic',
endpoint: 'https://api.anthropic.com/v1',
auth_style: 'anthropic' as const,
has_api_key: true,
},
{
id: 'p_openrouter_1',
slug: 'openrouter',
label: 'OpenRouter',
endpoint: 'https://openrouter.ai/api/v1',
auth_style: 'bearer' as const,
has_api_key: true,
},
],
});

renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect Custom/i })).toBeInTheDocument()
);

fireEvent.click(screen.getByRole('switch', { name: /Connect Custom/i }));

await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
expect(screen.getByDisplayValue('Custom')).toBeInTheDocument();

Check failure on line 439 in app/src/components/settings/panels/__tests__/AIPanel.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend Coverage (Vitest)

src/components/settings/panels/__tests__/AIPanel.test.tsx > AIPanel > keeps the Custom editor default on custom when Novita AI is the only unconfigured built-in provider

TestingLibraryElementError: Found multiple elements with the display value: Custom. Here are the matching elements: Ignored nodes: comments, script, style <select class="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" > <option value="custom" > Custom </option> </select> Ignored nodes: comments, script, style <input class="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" placeholder="My Provider" value="Custom" /> (If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)). Ignored nodes: comments, script, style <body> <div> <div class="relative" > <div class="px-5 pt-5 pb-3 " > <div class="flex items-center" > <button aria-label="Back" class="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2" > <svg class="w-4 h-4 text-stone-500 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" /> </svg> </button> <h2 class="text-sm font-semibold text-stone-900 dark:text-neutral-100" > LLM </h2> </div> </div> <div class="space-y-6 p-4" > <div class="space-y-4" > <div class="border-b border-stone-200 dark:border-neutral-800 pb-2" > <h2 class="text-base font-semibold text-stone-900 dark:text-neutral-100" > LLM Providers </h2> <p class="text-xs text-stone-500 dark:text-neutral-400 mt-0.5" > Llm providers desc </p> </div> <section class="space-y-3" > <div class="flex flex-wrap gap-2" > <div class="inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors dark:ring-neutral-700 bg-emerald-50 dark:bg-emerald-500/10 ring-emerald-200 text-emerald-900 dark:text-emerald-100" > <span> OpenAI </span> <button aria-checked="true" aria-label="Disconnect OpenAI" class="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 bg-primary-500" role="switch" type="button" > <span aria-hidden="true" class="inline-block h-3 w-3 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform translate-x-3.5" /> </button> </div> <div class="inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors dark:ring-neutral-700 bg-orange-50 dark:bg-orange-500/10 ring-orange-200 text-orange-900 dark:text-orange-100" > <span> Anthropic

Check failure on line 439 in app/src/components/settings/panels/__tests__/AIPanel.test.tsx

View workflow job for this annotation

GitHub Actions / test / Frontend Unit Tests

src/components/settings/panels/__tests__/AIPanel.test.tsx > AIPanel > keeps the Custom editor default on custom when Novita AI is the only unconfigured built-in provider

TestingLibraryElementError: Found multiple elements with the display value: Custom. Here are the matching elements: Ignored nodes: comments, script, style <select class="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" > <option value="custom" > Custom </option> </select> Ignored nodes: comments, script, style <input class="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" placeholder="My Provider" value="Custom" /> (If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)). Ignored nodes: comments, script, style <body> <div> <div class="relative" > <div class="px-5 pt-5 pb-3 " > <div class="flex items-center" > <button aria-label="Back" class="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2" > <svg class="w-4 h-4 text-stone-500 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" /> </svg> </button> <h2 class="text-sm font-semibold text-stone-900 dark:text-neutral-100" > LLM </h2> </div> </div> <div class="space-y-6 p-4" > <div class="space-y-4" > <div class="border-b border-stone-200 dark:border-neutral-800 pb-2" > <h2 class="text-base font-semibold text-stone-900 dark:text-neutral-100" > LLM Providers </h2> <p class="text-xs text-stone-500 dark:text-neutral-400 mt-0.5" > Llm providers desc </p> </div> <section class="space-y-3" > <div class="flex flex-wrap gap-2" > <div class="inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors dark:ring-neutral-700 bg-emerald-50 dark:bg-emerald-500/10 ring-emerald-200 text-emerald-900 dark:text-emerald-100" > <span> OpenAI </span> <button aria-checked="true" aria-label="Disconnect OpenAI" class="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 bg-primary-500" role="switch" type="button" > <span aria-hidden="true" class="inline-block h-3 w-3 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform translate-x-3.5" /> </button> </div> <div class="inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors dark:ring-neutral-700 bg-orange-50 dark:bg-orange-500/10 ring-orange-200 text-orange-900 dark:text-orange-100" > <span> Anthropic
expect(screen.getByPlaceholderText('https://api.example.com/v1')).toHaveValue('');
});

// ─── chip toggle: toggle OFF scrubs routing entries ──────────────────────────

it('toggling OFF an enabled provider scrubs routing entries that reference it', async () => {
Expand Down
Loading