Files
spoon/apps/next/src/components/integrations/ai-provider-profiles-panel.tsx
T

569 lines
19 KiB
TypeScript

'use client';
import type { ProviderModelOption } from '@/lib/provider-model-options';
import { useMemo, useState } from 'react';
import {
modelOptionsFromIds,
suggestedModelOptions,
supportsCustomModelOptions,
} from '@/lib/provider-model-options';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { KeyRound, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@spoon/ui';
type Provider =
| 'openai'
| 'anthropic'
| 'google'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
type AuthType =
| 'api_key'
| 'opencode_auth_json'
| 'anthropic_oauth_json'
| 'none';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveProfileRef = makeFunctionReference<
'action',
{
profileId?: Id<'aiProviderProfiles'>;
name: string;
provider: Provider;
authType: AuthType;
secret?: string;
baseUrl?: string;
defaultModel: string;
modelOptions?: string[];
reasoningEffort: ReasoningEffort;
enabled: boolean;
},
Id<'aiProviderProfiles'>
>('aiProviderProfilesNode:save');
const setDefaultProfileRef = makeFunctionReference<
'mutation',
{ profileId: Id<'aiProviderProfiles'> },
{ success: true }
>('aiProviderProfiles:setDefault');
const providerOptions: {
key: string;
value: Provider;
label: string;
authType: AuthType;
}[] = [
{
key: 'openai',
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
},
{
key: 'anthropic',
value: 'anthropic',
label: 'Anthropic API key',
authType: 'api_key',
},
{
key: 'google',
value: 'google',
label: 'Google Gemini API key',
authType: 'api_key',
},
{
key: 'openrouter',
value: 'openrouter',
label: 'OpenRouter',
authType: 'api_key',
},
{
key: 'requesty',
value: 'requesty',
label: 'Requesty',
authType: 'api_key',
},
{ key: 'litellm', value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
{
key: 'cloudflare_ai_gateway',
value: 'cloudflare_ai_gateway',
label: 'Cloudflare AI Gateway',
authType: 'api_key',
},
{
key: 'custom_openai_compatible',
value: 'custom_openai_compatible',
label: 'Custom OpenAI-compatible',
authType: 'api_key',
},
{
key: 'opencode_openai_login',
value: 'opencode_openai_login',
label: 'Codex ChatGPT login',
authType: 'opencode_auth_json',
},
{
key: 'anthropic_oauth_json',
value: 'anthropic',
label: 'Anthropic OAuth (paste credentials)',
authType: 'anthropic_oauth_json',
},
];
const defaultProviderOption: (typeof providerOptions)[number] = {
key: 'openai',
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
};
const reasoningOptions: ReasoningEffort[] = [
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
];
export const AiProviderProfilesPanel = () => {
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const saveProfile = useAction(saveProfileRef);
const setDefaultProfile = useMutation(setDefaultProfileRef);
const removeProfile = useMutation(api.aiProviderProfiles.remove);
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
const [name, setName] = useState('OpenAI');
const [providerKey, setProviderKey] = useState('openai');
const selectedProvider = useMemo(
() =>
providerOptions.find((option) => option.key === providerKey) ??
defaultProviderOption,
[providerKey],
);
const provider = selectedProvider.value;
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState(
suggestedModelOptions('openai')[0]?.id ?? '',
);
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
suggestedModelOptions('openai'),
);
const [customModelId, setCustomModelId] = useState('');
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const resetModelOptions = (nextProvider: Provider) => {
const options = suggestedModelOptions(nextProvider);
setModelOptions(options);
setDefaultModelValue(options[0]?.id ?? '');
setCustomModelId('');
};
const reset = () => {
setProfileId(undefined);
setProviderKey('openai');
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
setModelOptions(suggestedModelOptions('openai'));
setCustomModelId('');
setReasoningEffort('medium');
setEnabled(true);
setName('OpenAI');
};
const edit = (profile: (typeof profiles)[number]) => {
setProfileId(profile._id);
setName(profile.name);
setProviderKey(
providerOptions.find(
(option) =>
option.value === profile.provider &&
option.authType === profile.authType,
)?.key ??
providerOptions.find((option) => option.value === profile.provider)
?.key ??
'openai',
);
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
setModelOptions(
modelOptionsFromIds(
profile.modelOptions?.length
? profile.modelOptions
: [profile.defaultModel],
),
);
setCustomModelId('');
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
setEnabled(profile.enabled);
};
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
try {
await saveProfile({
profileId,
name,
provider,
authType: selectedProvider.authType,
secret: secret.trim() ? secret : undefined,
baseUrl: baseUrl.trim() || undefined,
defaultModel: defaultModelValue,
modelOptions: modelOptions.map((model) => model.id),
reasoningEffort,
enabled,
});
toast.success('AI provider saved.');
reset();
} catch (error) {
console.error(error);
toast.error('Could not save AI provider.');
} finally {
setSaving(false);
}
};
const selectedProfile = profileId
? profiles.find((profile) => profile._id === profileId)
: undefined;
const hasCredential =
selectedProvider.authType === 'none' ||
Boolean(secret.trim()) ||
Boolean(selectedProfile?.configured);
const canSelectModel = hasCredential && modelOptions.length > 0;
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
const defaultProfile = configuredProfiles.find(
(profile) => profile.isDefault,
);
return (
<div className='grid gap-4 xl:grid-cols-[1fr_0.9fr]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<KeyRound className='size-4' />
Provider profiles
</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{configuredProfiles.length > 1 ? (
<div className='grid gap-2 rounded-md border p-3'>
<Label>Default provider</Label>
<Select
value={defaultProfile?._id ?? ''}
onValueChange={async (value) => {
await setDefaultProfile({
profileId: value as Id<'aiProviderProfiles'>,
});
toast.success('Default AI provider updated.');
}}
>
<SelectTrigger>
<SelectValue placeholder='Choose default provider' />
</SelectTrigger>
<SelectContent>
{configuredProfiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.defaultModel}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Spoons using account default will use this provider.
</p>
</div>
) : null}
{profiles.length ? (
profiles.map((profile) => (
<div
key={profile._id}
className='border-border flex items-center justify-between gap-3 rounded-md border p-3'
>
<button
type='button'
className='min-w-0 text-left'
onClick={() => edit(profile)}
>
<p className='truncate text-sm font-medium'>{profile.name}</p>
<p className='text-muted-foreground text-xs'>
{profile.provider.replaceAll('_', ' ')} ·{' '}
{profile.secretPreview ?? 'not configured'} ·{' '}
{profile.defaultModel}
{profile.isDefault ? ' · default' : ''}
</p>
</button>
<Button
type='button'
variant='ghost'
size='icon'
aria-label='Remove provider'
onClick={async () => {
await removeProfile({ profileId: profile._id });
toast.success('AI provider removed.');
}}
>
<Trash2 className='size-4' />
</Button>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>
Add API-key providers for OpenCode, or store a Codex ChatGPT login
profile for runs that should use your Codex plan.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>
{profileId ? 'Edit provider' : 'Add provider'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={save} className='space-y-4'>
<div className='grid gap-2'>
<Label>Name</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label>Provider</Label>
<Select
value={providerKey}
onValueChange={(value) => {
const nextOption =
providerOptions.find((option) => option.key === value) ??
defaultProviderOption;
setProviderKey(nextOption.key);
resetModelOptions(nextOption.value);
setName(nextOption.label.replace(' API key', ''));
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>
{selectedProvider.authType === 'opencode_auth_json'
? 'Codex auth JSON'
: selectedProvider.authType === 'anthropic_oauth_json'
? 'Anthropic credentials JSON'
: 'API key'}
</Label>
{selectedProvider.authType === 'opencode_auth_json' ? (
<>
<Textarea
value={secret}
placeholder='Paste the full auth.json contents.'
onChange={(event) => setSecret(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Copy your Codex auth file from{' '}
<code className='bg-muted rounded px-1 py-0.5'>
~/.codex/auth.json
</code>
. Spoon writes it into the isolated job workspace as
Codex&apos;s auth cache. It is stored encrypted and should
be treated like a password.
</p>
</>
) : selectedProvider.authType === 'anthropic_oauth_json' ? (
<>
<Textarea
value={secret}
placeholder='Paste the full .credentials.json contents.'
onChange={(event) => setSecret(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Copy your Claude credentials file from{' '}
<code className='bg-muted rounded px-1 py-0.5'>
~/.claude/.credentials.json
</code>
. Spoon writes it into the isolated job workspace as
Claude&apos;s auth cache. It is stored encrypted and should
be treated like a password.
</p>
</>
) : (
<Input
type='password'
value={secret}
placeholder={
profileId ? 'Leave blank to keep current secret' : 'sk-...'
}
onChange={(event) => setSecret(event.target.value)}
/>
)}
</div>
<div className='grid gap-2'>
<Label>Base URL</Label>
<Input
value={baseUrl}
placeholder='Optional for LiteLLM, Requesty, custom providers'
onChange={(event) => setBaseUrl(event.target.value)}
/>
</div>
<div className='grid gap-2 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Default model</Label>
<Select
value={defaultModelValue}
onValueChange={setDefaultModelValue}
disabled={!canSelectModel}
>
<SelectTrigger>
<SelectValue
placeholder={
hasCredential
? 'Choose a model'
: 'Add credentials first'
}
/>
</SelectTrigger>
<SelectContent>
{modelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Saved model options are used by Spoons. Add custom model IDs
for compatible provider gateways.
</p>
<div className='rounded-md border p-2'>
<p className='text-muted-foreground mb-2 text-xs'>
Available model options
</p>
<div className='flex flex-wrap gap-2'>
{modelOptions.map((model) => (
<Badge key={model.id} variant='outline'>
{model.id}
</Badge>
))}
</div>
</div>
{supportsCustomModelOptions(provider) ? (
<div className='flex gap-2'>
<Input
value={customModelId}
placeholder='provider/model-id'
onChange={(event) => setCustomModelId(event.target.value)}
/>
<Button
type='button'
variant='outline'
onClick={() => {
const id = customModelId.trim();
if (!id) return;
setModelOptions((current) =>
current.some((model) => model.id === id)
? current
: [...current, ...modelOptionsFromIds([id])],
);
setDefaultModelValue((current) => current || id);
setCustomModelId('');
}}
>
Add
</Button>
</div>
) : null}
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(value as ReasoningEffort)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Enabled</Label>
<p className='text-muted-foreground text-xs'>
Disabled profiles cannot be selected for new jobs.
</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='submit'
disabled={saving || !hasCredential || !defaultModelValue}
>
{saving ? 'Saving...' : 'Save provider'}
</Button>
{profileId ? (
<Button type='button' variant='outline' onClick={reset}>
New provider
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
</div>
);
};