feat(web): per-thread/per-spoon runtime picker and Anthropic OAuth provider profile
This commit is contained in:
@@ -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's auth cache. It is stored encrypted and should
|
||||
be treated like a password.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
type='password'
|
||||
|
||||
Reference in New Issue
Block a user