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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,7 +13,11 @@ const reasoningEffort = v.union(
|
||||
v.literal('xhigh'),
|
||||
);
|
||||
|
||||
const runtime = v.literal('opencode');
|
||||
const runtime = v.union(
|
||||
v.literal('codex'),
|
||||
v.literal('opencode'),
|
||||
v.literal('claude'),
|
||||
);
|
||||
|
||||
const envFilePath = v.union(
|
||||
v.literal('.env'),
|
||||
@@ -123,7 +127,7 @@ export const update = mutation({
|
||||
patch.testCommand = optionalText(args.testCommand);
|
||||
}
|
||||
if (args.runtime !== undefined) {
|
||||
patch.runtime = 'opencode';
|
||||
patch.runtime = args.runtime;
|
||||
}
|
||||
if (args.agentModel !== undefined) {
|
||||
patch.agentModel = optionalText(args.agentModel) ?? defaults.agentModel;
|
||||
|
||||
Reference in New Issue
Block a user