'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>(); 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( suggestedModelOptions('openai'), ); const [customModelId, setCustomModelId] = useState(''); const [reasoningEffort, setReasoningEffort] = useState('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) => { 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 (
Provider profiles {configuredProfiles.length > 1 ? (

Spoons using account default will use this provider.

) : null} {profiles.length ? ( profiles.map((profile) => (
)) ) : (

Add API-key providers for OpenCode, or store a Codex ChatGPT login profile for runs that should use your Codex plan.

)}
{profileId ? 'Edit provider' : 'Add provider'}
setName(event.target.value)} />
{selectedProvider.authType === 'opencode_auth_json' ? ( <>