569 lines
19 KiB
TypeScript
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'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'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>
|
|
);
|
|
};
|