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'
@@ -24,9 +24,17 @@ import {
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
type RuntimeName = 'codex' | 'opencode' | 'claude';
const runtimeLabels: Record<RuntimeName, string> = {
codex: 'Codex',
opencode: 'OpenCode',
claude: 'Claude',
};
type AgentSettings = {
enabled: boolean;
runtime?: 'opencode' | 'openai_direct';
runtime?: RuntimeName | 'openai_direct';
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
@@ -91,6 +99,7 @@ export const SpoonAgentSettingsForm = ({
const [aiProviderProfileId, setAiProviderProfileId] = useState(
settings?.aiProviderProfileId ?? '__default',
);
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
const selectedProfile = profiles.find(
(profile) =>
profile._id ===
@@ -118,6 +127,19 @@ export const SpoonAgentSettingsForm = ({
: settings.reasoningEffort,
);
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
[]) as RuntimeName[];
const settingsRuntime = settings?.runtime as RuntimeName | undefined;
const effectiveRuntime =
runtime && supportedRuntimes.includes(runtime)
? runtime
: settingsRuntime && supportedRuntimes.includes(settingsRuntime)
? settingsRuntime
: supportedRuntimes[0];
const runtimeValid = Boolean(
effectiveRuntime && supportedRuntimes.includes(effectiveRuntime),
);
const selectableModels = selectedModelProfile?.configured
? selectedModelProfile.models
: [];
@@ -135,7 +157,7 @@ export const SpoonAgentSettingsForm = ({
await update({
spoonId: spoon._id,
enabled,
runtime: 'opencode',
runtime: effectiveRuntime,
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
@@ -186,7 +208,25 @@ export const SpoonAgentSettingsForm = ({
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Runtime</Label>
<Input value='OpenCode workspace' disabled />
<Select
value={effectiveRuntime ?? ''}
onValueChange={(value) => setRuntime(value as RuntimeName)}
disabled={!supportedRuntimes.length}
>
<SelectTrigger>
<SelectValue placeholder='Select a provider profile first' />
</SelectTrigger>
<SelectContent>
{supportedRuntimes.map((name) => (
<SelectItem key={name} value={name}>
{runtimeLabels[name]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Runtimes available for the selected provider profile.
</p>
</div>
<div className='grid gap-2'>
<Label>AI provider profile</Label>
@@ -400,6 +440,7 @@ export const SpoonAgentSettingsForm = ({
onClick={save}
disabled={
!selectedProfile?.configured ||
!runtimeValid ||
!selectableModels.some((model) => model.id === selectedAgentModel)
}
>
@@ -25,9 +25,17 @@ import {
Textarea,
} from '@spoon/ui';
type RuntimeName = 'codex' | 'opencode' | 'claude';
const runtimeLabels: Record<RuntimeName, string> = {
codex: 'Codex',
opencode: 'OpenCode',
claude: 'Claude',
};
type AgentSettings = {
defaultBaseBranch?: string;
runtime?: 'opencode' | 'openai_direct';
runtime?: RuntimeName | 'openai_direct';
agentModel: string;
reasoningEffort: string;
envFilePath?: string;
@@ -72,6 +80,7 @@ export const ThreadWorkspaceForm = ({
const [aiProviderProfileId, setAiProviderProfileId] = useState(
agentSettings?.aiProviderProfileId ?? '__settings',
);
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
const [submitting, setSubmitting] = useState(false);
const effectiveProviderProfileId =
aiProviderProfileId === '__settings'
@@ -87,6 +96,18 @@ export const ThreadWorkspaceForm = ({
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: profile._id === aiProviderProfileId,
);
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
[]) as RuntimeName[];
const settingsRuntime = agentSettings?.runtime as RuntimeName | undefined;
const effectiveRuntime =
runtime && supportedRuntimes.includes(runtime)
? runtime
: settingsRuntime && supportedRuntimes.includes(settingsRuntime)
? settingsRuntime
: supportedRuntimes[0];
const runtimeValid = Boolean(
effectiveRuntime && supportedRuntimes.includes(effectiveRuntime),
);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -97,6 +118,7 @@ export const ThreadWorkspaceForm = ({
prompt,
baseBranch,
requestedBranchName: requestedBranchName || undefined,
runtime: effectiveRuntime,
materializeEnvFile,
envFilePath: materializeEnvFile ? envFilePath : undefined,
aiProviderProfileId:
@@ -140,7 +162,22 @@ export const ThreadWorkspaceForm = ({
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Workspace runtime</Label>
<Input value='OpenCode workspace' disabled />
<Select
value={effectiveRuntime ?? ''}
onValueChange={(value) => setRuntime(value as RuntimeName)}
disabled={!supportedRuntimes.length}
>
<SelectTrigger>
<SelectValue placeholder='Select a provider first' />
</SelectTrigger>
<SelectContent>
{supportedRuntimes.map((name) => (
<SelectItem key={name} value={name}>
{runtimeLabels[name]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>AI provider</Label>
@@ -219,7 +256,10 @@ export const ThreadWorkspaceForm = ({
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
</span>
</div>
<Button type='submit' disabled={submitting || !hasProvider}>
<Button
type='submit'
disabled={submitting || !hasProvider || !runtimeValid}
>
{submitting ? 'Creating...' : 'Create thread'}
</Button>
</form>