feat(web): per-thread/per-spoon runtime picker and Anthropic OAuth provider profile

This commit is contained in:
Gabriel Brown
2026-07-11 11:20:33 -04:00
parent 6ec2e51312
commit 702e53a4ba
4 changed files with 190 additions and 36 deletions
@@ -42,7 +42,11 @@ type Provider =
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
type AuthType =
| 'api_key'
| 'opencode_auth_json'
| 'anthropic_oauth_json'
| 'none';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveProfileRef = makeFunctionReference<
@@ -69,33 +73,75 @@ const setDefaultProfileRef = makeFunctionReference<
>('aiProviderProfiles:setDefault');
const providerOptions: {
key: string;
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' },
{
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',
@@ -112,17 +158,14 @@ export const AiProviderProfilesPanel = () => {
const removeProfile = useMutation(api.aiProviderProfiles.remove);
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
const [name, setName] = useState('OpenAI');
const [provider, setProvider] = useState<Provider>('openai');
const [providerKey, setProviderKey] = useState('openai');
const selectedProvider = useMemo(
() =>
providerOptions.find((option) => option.value === provider) ??
({
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
} satisfies (typeof providerOptions)[number]),
[provider],
providerOptions.find((option) => option.key === providerKey) ??
defaultProviderOption,
[providerKey],
);
const provider = selectedProvider.value;
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState(
@@ -146,7 +189,7 @@ export const AiProviderProfilesPanel = () => {
const reset = () => {
setProfileId(undefined);
setProvider('openai');
setProviderKey('openai');
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
@@ -160,7 +203,16 @@ export const AiProviderProfilesPanel = () => {
const edit = (profile: (typeof profiles)[number]) => {
setProfileId(profile._id);
setName(profile.name);
setProvider(profile.provider as Provider);
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);
@@ -314,16 +366,14 @@ export const AiProviderProfilesPanel = () => {
<div className='grid gap-2'>
<Label>Provider</Label>
<Select
value={provider}
value={providerKey}
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
resetModelOptions(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
?.label.replace(' API key', '') ?? 'AI provider',
);
const nextOption =
providerOptions.find((option) => option.key === value) ??
defaultProviderOption;
setProviderKey(nextOption.key);
resetModelOptions(nextOption.value);
setName(nextOption.label.replace(' API key', ''));
}}
>
<SelectTrigger>
@@ -331,7 +381,7 @@ export const AiProviderProfilesPanel = () => {
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.key} value={option.key}>
{option.label}
</SelectItem>
))}
@@ -342,7 +392,9 @@ export const AiProviderProfilesPanel = () => {
<Label>
{selectedProvider.authType === 'opencode_auth_json'
? 'Codex auth JSON'
: 'API key'}
: selectedProvider.authType === 'anthropic_oauth_json'
? 'Anthropic credentials JSON'
: 'API key'}
</Label>
{selectedProvider.authType === 'opencode_auth_json' ? (
<>
@@ -361,6 +413,23 @@ export const AiProviderProfilesPanel = () => {
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'