Move to threads based system.

This commit is contained in:
Gabriel Brown
2026-06-22 10:37:26 -04:00
parent 8ae6c4b533
commit 206b64176b
82 changed files with 6169 additions and 1930 deletions
@@ -0,0 +1,451 @@
'use client';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useMemo, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
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 {
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' | '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;
reasoningEffort: ReasoningEffort;
enabled: boolean;
},
Id<'aiProviderProfiles'>
>('aiProviderProfilesNode:save');
const setDefaultProfileRef = makeFunctionReference<
'mutation',
{ profileId: Id<'aiProviderProfiles'> },
{ success: true }
>('aiProviderProfiles:setDefault');
const providerOptions: {
value: Provider;
label: string;
authType: AuthType;
}[] = [
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
{
value: 'cloudflare_ai_gateway',
label: 'Cloudflare AI Gateway',
authType: 'api_key',
},
{
value: 'custom_openai_compatible',
label: 'Custom OpenAI-compatible',
authType: 'api_key',
},
{
value: 'opencode_openai_login',
label: 'OpenCode OpenAI login',
authType: 'opencode_auth_json',
},
];
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 [provider, setProvider] = useState<Provider>('openai');
const selectedProvider = useMemo(
() =>
providerOptions.find((option) => option.value === provider) ??
({
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
} satisfies (typeof providerOptions)[number]),
[provider],
);
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState('');
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
loadModelsDevOptions(provider)
.then((options) => {
if (cancelled) return;
setModelOptions(options);
setDefaultModelValue((current) =>
current && options.some((option) => option.id === current)
? current
: (options[0]?.id ?? ''),
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setModelOptions([]);
});
return () => {
cancelled = true;
};
}, [provider]);
const reset = () => {
setProfileId(undefined);
setProvider('openai');
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
setReasoningEffort('medium');
setEnabled(true);
setName('OpenAI');
};
const edit = (profile: (typeof profiles)[number]) => {
setProfileId(profile._id);
setName(profile.name);
setProvider(profile.provider as Provider);
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
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,
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 an OpenCode OpenAI
login profile for the next auth-file injection pass.
</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={provider}
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
?.label.replace(' API key', '') ?? 'AI provider',
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>
{selectedProvider.authType === 'opencode_auth_json'
? 'OpenCode auth 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>
. 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'>
Models are loaded from Models.dev, the catalog OpenCode uses
for provider/model metadata.
</p>
</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>
);
};