feat(ui): add Ollama provider presets to dashboard

- Add ollama (local) and ollama-cloud presets to UI provider-presets.ts
- Update profile-create-dialog to handle requiresApiKey: false
- Show "No API Key Required" info box for local Ollama
- Add --api-key ignored warning to CLI for local Ollama preset
- Add ollama-cloud to CLI help text preset list

Fixes edge cases identified in PR review:
- UI dashboard now has parity with CLI Ollama support
- Conditional API key validation in profile creation form
This commit is contained in:
kaitranntt
2026-01-22 14:09:59 -05:00
parent dc6977d32e
commit 2b7d18c4c6
3 changed files with 84 additions and 33 deletions
+4 -1
View File
@@ -186,6 +186,9 @@ async function handleCreate(args: string[]): Promise<void> {
if (preset?.noApiKey) {
// Preset doesn't require API key (e.g., local Ollama)
if (parsedArgs.apiKey) {
console.log(dim('Note: API key ignored for local Ollama (not required)'));
}
console.log(info('No API key required for local Ollama'));
apiKey = ''; // Empty string for local providers
} else if (!apiKey) {
@@ -431,7 +434,7 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, ollama, glm, glmt, kimi, foundry, mm, deepseek, qwen)`
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, kimi, foundry, mm, deepseek, qwen)`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
@@ -47,7 +47,7 @@ const schema = z.object({
.min(1, 'Name is required')
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'),
baseUrl: z.string().url('Invalid URL format'),
apiKey: z.string().min(1, 'API key is required'),
apiKey: z.string(), // Validation handled conditionally in onSubmit
model: z.string().optional(),
opusModel: z.string().optional(),
sonnetModel: z.string().optional(),
@@ -207,9 +207,16 @@ export function ProfileCreateDialog({
}, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => {
// Validate API key - required unless preset has requiresApiKey: false
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
toast.error('API key is required');
return;
}
// Use user-provided baseUrl (allows customization of preset URLs)
const finalData = {
...data,
// For presets that don't require API key, use empty string
apiKey: currentPreset?.requiresApiKey === false ? '' : data.apiKey,
};
try {
await createMutation.mutateAsync(finalData);
@@ -367,38 +374,51 @@ export function ProfileCreateDialog({
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder={currentPreset?.apiKeyPlaceholder ?? 'sk-...'}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
{/* API Key - hidden for presets that don't require it */}
{currentPreset?.requiresApiKey !== false ? (
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder={currentPreset?.apiKeyPlaceholder ?? 'sk-...'}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.apiKey ? (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : (
currentPreset?.apiKeyHint && (
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
)
)}
</div>
{errors.apiKey ? (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : (
currentPreset?.apiKeyHint && (
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
)
)}
</div>
) : (
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-950/20 text-green-800 dark:text-green-300 rounded-md text-sm border border-green-100 dark:border-green-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium">No API Key Required</p>
<p className="text-xs opacity-90">
{currentPreset?.apiKeyHint ||
'This provider runs locally and does not require authentication.'}
</p>
</div>
</div>
)}
</TabsContent>
<TabsContent value="models" className="p-6 mt-0 space-y-4">
+28
View File
@@ -44,6 +44,21 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
},
// Recommended - Ollama (Local)
{
id: 'ollama',
name: 'Ollama (Local)',
description: 'Local open-source models via Ollama (32K+ context)',
baseUrl: 'http://localhost:11434',
defaultProfileName: 'ollama',
badge: 'Local',
featured: true,
defaultModel: 'qwen3-coder',
requiresApiKey: false,
apiKeyPlaceholder: '',
apiKeyHint: 'No API key required for local Ollama',
category: 'recommended',
},
// Alternative providers - GLM/GLMT/Kimi
{
id: 'glm',
@@ -136,6 +151,19 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
category: 'alternative',
},
{
id: 'ollama-cloud',
name: 'Ollama Cloud',
description: 'Ollama.com cloud models (glm-4.7:cloud, qwen3-coder:480b)',
baseUrl: 'https://ollama.com',
defaultProfileName: 'ollama-cloud',
badge: 'Cloud',
defaultModel: 'glm-4.7:cloud',
requiresApiKey: true,
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
apiKeyHint: 'Get your API key at ollama.com',
category: 'alternative',
},
];
/** Get presets by category */