feat: add dynamic model discovery and routing for OpenCode provider

- Update isOpencodeModel() to detect dynamic models with provider/model format
  (e.g., github-copilot/gpt-4o, google/gemini-2.5-pro, zai-coding-plan/glm-4.7)
- Update resolveModelString() to recognize and pass through OpenCode models
- Update enhance route to route OpenCode models to OpenCode provider
- Fix OpenCode CLI command format: use --format json (not stream-json)
- Remove unsupported -q and - flags from CLI arguments
- Update normalizeEvent() to handle actual OpenCode JSON event format
- Add dynamic model configuration UI with provider grouping
- Cache providers and models in app store for snappier navigation
- Show authenticated providers in OpenCode CLI status card

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Stefan de Vogelaere
2026-01-11 20:08:25 +01:00
committed by DhanushSantosh
parent ed65f70315
commit 6c5206daf4
13 changed files with 2247 additions and 265 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
import {
CLAUDE_MODEL_MAP,
isCursorModel,
isOpencodeModel,
stripProviderPrefix,
ThinkingLevel,
getThinkingTokenBudget,
@@ -91,13 +92,13 @@ async function extractTextFromStream(
}
/**
* Execute enhancement using Cursor provider
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
*
* @param prompt - The enhancement prompt
* @param model - The Cursor model to use
* @param model - The model to use
* @returns The enhanced text
*/
async function executeWithCursor(prompt: string, model: string): Promise<string> {
async function executeWithProvider(prompt: string, model: string): Promise<string> {
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
@@ -212,7 +213,14 @@ export function createEnhanceHandler(
// Cursor doesn't have a separate system prompt concept, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else if (isOpencodeModel(resolvedModel)) {
// Use OpenCode provider for OpenCode models (static and dynamic)
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);
// OpenCode CLI handles the system prompt, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for model: ${resolvedModel}`);

View File

@@ -24,6 +24,12 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
createGetOpencodeProvidersHandler,
createClearOpencodeCacheHandler,
} from './routes/opencode-models.js';
import {
createGetCursorConfigHandler,
createSetCursorDefaultModelHandler,
@@ -65,6 +71,12 @@ export function createSetupRoutes(): Router {
router.get('/opencode-status', createOpencodeStatusHandler());
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
router.get('/opencode/providers', createGetOpencodeProvidersHandler());
router.post('/opencode/cache/clear', createClearOpencodeCacheHandler());
router.get('/cursor-config', createGetCursorConfigHandler());
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
router.post('/cursor-config/models', createSetCursorModelsHandler());

View File

@@ -0,0 +1,191 @@
/**
* OpenCode Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/opencode/models - Get available models (cached or refreshed)
* - POST /api/setup/opencode/models/refresh - Force refresh models from CLI
* - GET /api/setup/opencode/providers - Get authenticated providers
*/
import type { Request, Response } from 'express';
import {
OpencodeProvider,
type OpenCodeProviderInfo,
} from '../../../providers/opencode-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OpenCodeModelsRoute');
// Singleton provider instance for caching
let providerInstance: OpencodeProvider | null = null;
function getProvider(): OpencodeProvider {
if (!providerInstance) {
providerInstance = new OpencodeProvider();
}
return providerInstance;
}
/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}
/**
* Response type for providers endpoint
*/
interface ProvidersResponse {
success: boolean;
providers?: OpenCodeProviderInfo[];
authenticated?: OpenCodeProviderInfo[];
error?: string;
}
/**
* Creates handler for GET /api/setup/opencode/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetOpencodeModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';
let models: ModelDefinition[];
let cached = true;
if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
const cachedModels = provider.getAvailableModels();
// If cache only has default models (provider.hasCachedModels() would be false),
// trigger a refresh to get dynamic models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = cachedModels;
}
}
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};
res.json(response);
} catch (error) {
logError(error, 'Get OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/models/refresh
*
* Forces a refresh of models from the OpenCode CLI.
*/
export function createRefreshOpencodeModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};
res.json(response);
} catch (error) {
logger.error('Refresh OpenCode models failed:', error);
logError(error, 'Refresh OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for GET /api/setup/opencode/providers
*
* Returns authenticated providers from OpenCode CLI.
* This calls `opencode auth list` to get provider status.
*/
export function createGetOpencodeProvidersHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const providers = await provider.fetchAuthenticatedProviders();
// Filter to only authenticated providers
const authenticated = providers.filter((p) => p.authenticated);
const response: ProvidersResponse = {
success: true,
providers,
authenticated,
};
res.json(response);
} catch (error) {
logger.error('Get OpenCode providers failed:', error);
logError(error, 'Get OpenCode providers failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ProvidersResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/cache/clear
*
* Clears the model cache, forcing a fresh fetch on next access.
*/
export function createClearOpencodeCacheHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
provider.clearModelCache();
res.json({
success: true,
message: 'OpenCode model cache cleared',
});
} catch (error) {
logError(error, 'Clear OpenCode cache failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,9 +1,40 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { OpenCodeIcon } from '@/components/ui/provider-icon';
export interface OpenCodeProviderInfo {
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}
/**
* Provider display configuration
*/
const PROVIDER_DISPLAY: Record<string, string> = {
copilot: 'GitHub Copilot',
'github-copilot': 'GitHub Copilot',
anthropic: 'Anthropic',
openai: 'OpenAI',
openrouter: 'OpenRouter',
google: 'Google AI',
'amazon-bedrock': 'AWS Bedrock',
azure: 'Azure OpenAI',
ollama: 'Ollama',
lmstudio: 'LM Studio',
opencode: 'OpenCode',
xai: 'xAI',
deepseek: 'DeepSeek',
};
function getProviderDisplayName(provider: OpenCodeProviderInfo): string {
return PROVIDER_DISPLAY[provider.id] || provider.name || provider.id;
}
export type OpencodeAuthMethod =
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
| 'api_key' // Manually stored API key
@@ -38,6 +69,7 @@ function getAuthMethodLabel(method: OpencodeAuthMethod): string {
interface OpencodeCliStatusProps {
status: CliStatus | null;
authStatus?: OpencodeAuthStatus | null;
providers?: OpenCodeProviderInfo[];
isChecking: boolean;
onRefresh: () => void;
}
@@ -152,9 +184,11 @@ export function OpencodeModelConfigSkeleton() {
export function OpencodeCliStatus({
status,
authStatus,
providers = [],
isChecking,
onRefresh,
}: OpencodeCliStatusProps) {
const authenticatedProviders = providers.filter((p) => p.authenticated);
if (!status) return <OpencodeCliStatusSkeleton />;
return (
@@ -223,8 +257,8 @@ export function OpencodeCliStatus({
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
{/* Authentication Status - consider both direct auth and provider auth */}
{authStatus?.authenticated || authenticatedProviders.length > 0 ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
@@ -232,24 +266,71 @@ export function OpencodeCliStatus({
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
{authStatus?.authenticated && authStatus.method !== 'none' ? (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
) : authenticatedProviders.length > 0 ? (
<p>
Via {authenticatedProviders.length} connected provider
{authenticatedProviders.length !== 1 ? 's' : ''}
</p>
) : null}
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<div className="w-10 h-10 rounded-xl bg-blue-500/15 flex items-center justify-center border border-blue-500/20 shrink-0 mt-0.5">
<Bot className="w-5 h-5 text-blue-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run{' '}
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
set an API key to authenticate.
<p className="text-sm font-medium text-blue-400">OpenCode Free Tier Ready</p>
<p className="text-xs text-blue-400/70 mt-1">
Free OpenCode models work without login. Run{' '}
<code className="font-mono bg-blue-500/10 px-1 rounded">
opencode auth login
</code>{' '}
to connect providers like GitHub Copilot, Google AI, etc.
</p>
</div>
</div>
)}
{/* Dynamic Providers Connected */}
{authenticatedProviders.length > 0 && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<Cloud className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">
{authenticatedProviders.length} Dynamic Provider
{authenticatedProviders.length !== 1 ? 's' : ''} Connected
</p>
<div className="flex flex-wrap gap-1.5 mt-2">
{authenticatedProviders.map((provider) => (
<Badge
key={provider.id}
variant="outline"
className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30"
>
{getProviderDisplayName(provider)}
{provider.authMethod && (
<span className="ml-1 text-emerald-400/60">
({provider.authMethod === 'oauth' ? 'OAuth' : 'Key'})
</span>
)}
</Badge>
))}
</div>
<p className="text-xs text-emerald-400/60 mt-2">
Use{' '}
<code className="font-mono bg-emerald-500/10 px-1 rounded">
opencode auth login
</code>{' '}
to add more providers.
</p>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type {
@@ -25,18 +25,13 @@ import {
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
REASONING_EFFORT_LABELS,
type ModelOption,
} from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
DeepSeekIcon,
NovaIcon,
QwenIcon,
MistralIcon,
MetaIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
@@ -51,6 +46,80 @@ import {
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI';
const OPENCODE_PROVIDER_FALLBACK = 'opencode';
const OPENCODE_PROVIDER_WORD_SEPARATOR = '-';
const OPENCODE_MODEL_ID_SEPARATOR = '/';
const OPENCODE_SECTION_GROUP_PADDING = 'pt-2';
const OPENCODE_STATIC_PROVIDER_LABELS: Record<string, string> = {
[OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
};
const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record<string, string> = {
'github-copilot': 'GitHub Copilot',
'zai-coding-plan': 'Z.AI Coding Plan',
google: 'Google AI',
openai: 'OpenAI',
openrouter: 'OpenRouter',
anthropic: 'Anthropic',
xai: 'xAI',
deepseek: 'DeepSeek',
ollama: 'Ollama (Local)',
lmstudio: 'LM Studio (Local)',
azure: 'Azure OpenAI',
[OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
};
const OPENCODE_DYNAMIC_PROVIDER_ORDER = [
'github-copilot',
'google',
'openai',
'openrouter',
'anthropic',
'xai',
'deepseek',
'ollama',
'lmstudio',
'azure',
'zai-coding-plan',
];
const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const;
const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = {
free: 'Free Tier',
dynamic: 'Connected Providers',
};
const OPENCODE_STATIC_PROVIDER_BY_ID = new Map(
OPENCODE_MODELS.map((model) => [model.id, model.provider])
);
function formatProviderLabel(providerKey: string): string {
return providerKey
.split(OPENCODE_PROVIDER_WORD_SEPARATOR)
.map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
.join(' ');
}
function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] {
if (providerKey === OPENCODE_PROVIDER_FALLBACK) {
return 'free';
}
return 'dynamic';
}
function getOpencodeGroupLabel(
providerKey: string,
sectionKey: (typeof OPENCODE_SECTION_ORDER)[number]
): string {
if (sectionKey === 'free') {
return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier';
}
return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey);
}
interface PhaseModelSelectorProps {
/** Label shown in full mode */
label?: string;
@@ -95,6 +164,7 @@ export function PhaseModelSelector({
codexModels,
codexModelsLoading,
fetchCodexModels,
dynamicOpencodeModels,
} = useAppStore();
// Extract model and thinking/reasoning levels from value
@@ -235,12 +305,30 @@ export function PhaseModelSelector({
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check OpenCode models
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
// Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons
const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel);
if (dynamicModel) {
return {
id: dynamicModel.id,
label: dynamicModel.name,
description: dynamicModel.description,
provider: 'opencode' as const,
icon: getProviderIconForModel(dynamicModel.id),
};
}
return null;
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
}, [
selectedModel,
selectedThinkingLevel,
availableCursorModels,
transformedCodexModels,
dynamicOpencodeModels,
]);
// Compute grouped vs standalone Cursor models
const { groupedModels, standaloneCursorModels } = useMemo(() => {
@@ -275,13 +363,35 @@ export function PhaseModelSelector({
return { groupedModels: grouped, standaloneCursorModels: standalone };
}, [availableCursorModels, enabledCursorModels]);
// Combine static and dynamic OpenCode models
const allOpencodeModels: ModelOption[] = useMemo(() => {
// Start with static models
const staticModels = [...OPENCODE_MODELS];
// Add dynamic models (convert ModelDefinition to ModelOption)
const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels.map((model) => ({
id: model.id,
label: model.name,
description: model.description,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
provider: 'opencode' as const,
}));
// Merge, avoiding duplicates (static models take precedence for same ID)
// In practice, static and dynamic IDs don't overlap
const staticIds = new Set(staticModels.map((m) => m.id));
const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
return [...staticModels, ...uniqueDynamic];
}, [dynamicOpencodeModels]);
// Group models
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof transformedCodexModels = [];
const ocModels: typeof OPENCODE_MODELS = [];
const ocModels: ModelOption[] = [];
// Process Claude Models
CLAUDE_MODELS.forEach((model) => {
@@ -310,8 +420,8 @@ export function PhaseModelSelector({
}
});
// Process OpenCode Models
OPENCODE_MODELS.forEach((model) => {
// Process OpenCode Models (including dynamic)
allOpencodeModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
@@ -326,7 +436,95 @@ export function PhaseModelSelector({
codex: codModels,
opencode: ocModels,
};
}, [favoriteModels, availableCursorModels, transformedCodexModels]);
}, [favoriteModels, availableCursorModels, transformedCodexModels, allOpencodeModels]);
// Group OpenCode models by model type for better organization
const opencodeSections = useMemo(() => {
type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number];
type OpencodeGroup = { key: string; label: string; models: ModelOption[] };
type OpencodeSection = {
key: OpencodeSectionKey;
label: string;
showGroupLabels: boolean;
groups: OpencodeGroup[];
};
const sections: Record<OpencodeSectionKey, Record<string, OpencodeGroup>> = {
free: {},
dynamic: {},
};
const dynamicProviderById = new Map(
dynamicOpencodeModels.map((model) => [model.id, model.provider])
);
const resolveProviderKey = (modelId: string): string => {
const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId);
if (staticProvider) return staticProvider;
const dynamicProvider = dynamicProviderById.get(modelId);
if (dynamicProvider) return dynamicProvider;
return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR)
? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0]
: OPENCODE_PROVIDER_FALLBACK;
};
const addModelToGroup = (
sectionKey: OpencodeSectionKey,
providerKey: string,
model: ModelOption
) => {
if (!sections[sectionKey][providerKey]) {
sections[sectionKey][providerKey] = {
key: providerKey,
label: getOpencodeGroupLabel(providerKey, sectionKey),
models: [],
};
}
sections[sectionKey][providerKey].models.push(model);
};
opencode.forEach((model) => {
const providerKey = resolveProviderKey(model.id);
const sectionKey = getOpencodeSectionKey(providerKey);
addModelToGroup(sectionKey, providerKey, model);
});
const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => {
const groupMap = sections[sectionKey];
const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : [];
const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index]));
return Object.keys(groupMap)
.sort((a, b) => {
const aPriority = priorityMap.get(a);
const bPriority = priorityMap.get(b);
if (aPriority !== undefined && bPriority !== undefined) {
return aPriority - bPriority;
}
if (aPriority !== undefined) return -1;
if (bPriority !== undefined) return 1;
return groupMap[a].label.localeCompare(groupMap[b].label);
})
.map((key) => groupMap[key]);
};
const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => {
const groups = buildGroupList(sectionKey);
if (groups.length === 0) return null;
return {
key: sectionKey,
label: OPENCODE_SECTION_LABELS[sectionKey],
showGroupLabels: sectionKey !== 'free',
groups,
};
}).filter(Boolean) as OpencodeSection[];
return builtSections;
}, [opencode, dynamicOpencodeModels]);
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
@@ -992,9 +1190,32 @@ export function PhaseModelSelector({
</CommandGroup>
)}
{opencode.length > 0 && (
<CommandGroup heading="OpenCode Models">
{opencode.map((model) => renderOpencodeModelItem(model))}
{opencodeSections.length > 0 && (
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
{opencodeSections.map((section, sectionIndex) => (
<Fragment key={section.key}>
<div className="px-2 pt-2 text-xs font-medium text-muted-foreground">
{section.label}
</div>
<div
className={cn(
'space-y-2',
section.key === 'dynamic' && OPENCODE_SECTION_GROUP_PADDING
)}
>
{section.groups.map((group) => (
<div key={group.key} className="space-y-1">
{section.showGroupLabels && (
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group.label}
</div>
)}
{group.models.map((model) => renderOpencodeModelItem(model))}
</div>
))}
</div>
</Fragment>
))}
</CommandGroup>
)}
</CommandList>

View File

@@ -0,0 +1,294 @@
/**
* OpenCode Dynamic Providers Component
*
* Shows authenticated providers from OpenCode CLI and allows
* refreshing the model list from the CLI.
*/
import { useState, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, CheckCircle2, XCircle, Cloud, Terminal, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('OpenCodeDynamicProviders');
interface OpenCodeProviderInfo {
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}
interface OpenCodeDynamicProvidersProps {
isCliInstalled: boolean;
}
/**
* Provider display configuration
*/
const PROVIDER_CONFIG: Record<string, { icon: string; displayName: string }> = {
copilot: { icon: '', displayName: 'GitHub Copilot' },
anthropic: { icon: '', displayName: 'Anthropic' },
openai: { icon: '', displayName: 'OpenAI' },
google: { icon: '', displayName: 'Google' },
'amazon-bedrock': { icon: '', displayName: 'AWS Bedrock' },
azure: { icon: '', displayName: 'Azure OpenAI' },
ollama: { icon: '', displayName: 'Ollama' },
lmstudio: { icon: '', displayName: 'LM Studio' },
opencode: { icon: '', displayName: 'OpenCode' },
};
function getProviderDisplay(provider: OpenCodeProviderInfo) {
const config = PROVIDER_CONFIG[provider.id] || {
displayName: provider.name || provider.id,
};
return config.displayName;
}
export function OpenCodeDynamicProviders({ isCliInstalled }: OpenCodeDynamicProvidersProps) {
const [providers, setProviders] = useState<OpenCodeProviderInfo[]>([]);
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
const [isRefreshingModels, setIsRefreshingModels] = useState(false);
const { dynamicOpencodeModels, setDynamicOpencodeModels } = useAppStore();
// Model count derived from store
const modelCount = dynamicOpencodeModels.length;
// Fetch models from API and store them (only if not already loaded)
const fetchModels = useCallback(
async (force = false) => {
// Skip if already have models and not forcing refresh
if (!force && dynamicOpencodeModels.length > 0) {
logger.debug('Dynamic models already loaded, skipping fetch');
return;
}
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeModels) {
const data = await api.setup.getOpencodeModels();
if (data.success && data.models) {
setDynamicOpencodeModels(data.models);
logger.info(`Loaded ${data.models.length} dynamic OpenCode models`);
}
}
} catch (error) {
logger.error('Failed to fetch OpenCode models:', error);
}
},
[dynamicOpencodeModels.length, setDynamicOpencodeModels]
);
// Fetch providers on mount, but only fetch models if not already loaded
useEffect(() => {
if (isCliInstalled) {
fetchProviders();
// Only fetch models if store is empty
if (dynamicOpencodeModels.length === 0) {
fetchModels(false);
}
}
}, [isCliInstalled]); // Intentionally not including fetchModels to avoid re-fetching
const fetchProviders = useCallback(async () => {
setIsLoadingProviders(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeProviders) {
const data = await api.setup.getOpencodeProviders();
if (data.success && data.providers) {
setProviders(data.providers);
}
} else {
logger.warn('OpenCode providers API not available');
}
} catch (error) {
logger.error('Failed to fetch OpenCode providers:', error);
} finally {
setIsLoadingProviders(false);
}
}, []);
const handleRefreshModels = useCallback(async () => {
setIsRefreshingModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.refreshOpencodeModels) {
const data = await api.setup.refreshOpencodeModels();
if (data.success) {
// Store the refreshed models in the app store
if (data.models) {
setDynamicOpencodeModels(data.models);
toast.success(`Refreshed ${data.models.length} models from OpenCode CLI`);
}
// Also refresh providers
await fetchProviders();
} else {
toast.error(data.error || 'Failed to refresh models');
}
} else {
logger.warn('OpenCode refresh models API not available');
toast.error('OpenCode API not available');
}
} catch (error) {
logger.error('Failed to refresh OpenCode models:', error);
toast.error('Failed to refresh models from OpenCode CLI');
} finally {
setIsRefreshingModels(false);
}
}, [fetchProviders, setDynamicOpencodeModels]);
if (!isCliInstalled) {
return null;
}
const authenticatedProviders = providers.filter((p) => p.authenticated);
const unauthenticatedProviders = providers.filter((p) => !p.authenticated);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Cloud className="w-5 h-5 text-brand-500" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Dynamic Providers
</h2>
{modelCount !== null && (
<p className="text-xs text-muted-foreground">{modelCount} models available</p>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefreshModels}
disabled={isRefreshingModels}
className="gap-2"
>
{isRefreshingModels ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Refresh Models
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
OpenCode discovers models from your authenticated providers (GitHub Copilot, Google, etc.)
</p>
</div>
<div className="p-6 space-y-4">
{isLoadingProviders ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : providers.length === 0 ? (
<div className="text-center py-6">
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-2">No providers detected yet</p>
<p className="text-xs text-muted-foreground/70">
Run <code className="font-mono bg-accent/50 px-1 rounded">opencode</code> and use{' '}
<code className="font-mono bg-accent/50 px-1 rounded">/connect</code> to authenticate
with providers
</p>
</div>
) : (
<div className="space-y-4">
{/* Authenticated Providers */}
{authenticatedProviders.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
Authenticated
</h3>
<div className="grid gap-2">
{authenticatedProviders.map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center">
<Cloud className="w-4 h-4 text-emerald-500" />
</div>
<span className="text-sm font-medium text-emerald-400">
{getProviderDisplay(provider)}
</span>
</div>
{provider.authMethod && (
<Badge
variant="outline"
className="text-xs bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
>
{provider.authMethod === 'oauth' ? 'OAuth' : 'API Key'}
</Badge>
)}
</div>
))}
</div>
</div>
)}
{/* Available but Not Authenticated */}
{unauthenticatedProviders.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
<XCircle className="w-4 h-4 text-muted-foreground" />
Available
</h3>
<div className="grid gap-2">
{unauthenticatedProviders.slice(0, 5).map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/30"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<Cloud className="w-4 h-4 text-muted-foreground" />
</div>
<span className="text-sm font-medium text-muted-foreground">
{getProviderDisplay(provider)}
</span>
</div>
<span className="text-xs text-muted-foreground/70">Not authenticated</span>
</div>
))}
{unauthenticatedProviders.length > 5 && (
<p className="text-xs text-muted-foreground/70 text-center py-1">
+{unauthenticatedProviders.length - 5} more providers available
</p>
)}
</div>
</div>
)}
{/* Help text */}
<div className="pt-2 border-t border-border/30">
<p className="text-xs text-muted-foreground/70">
Use <code className="font-mono bg-accent/50 px-1 rounded">opencode /connect</code>{' '}
to add new providers like GitHub Copilot, Google AI, or local models.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -8,20 +9,32 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
import { Input } from '@/components/ui/input';
import type {
OpencodeModelId,
OpencodeProvider,
OpencodeModelConfig,
ModelDefinition,
} from '@automaker/types';
import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
import type { OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
import {
OpenCodeIcon,
DeepSeekIcon,
QwenIcon,
NovaIcon,
AnthropicIcon,
OpenRouterIcon,
MistralIcon,
MetaIcon,
GeminiIcon,
OpenAIIcon,
GrokIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import type { ComponentType } from 'react';
import { useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
interface OpencodeModelConfigurationProps {
enabledOpencodeModels: OpencodeModelId[];
@@ -29,6 +42,12 @@ interface OpencodeModelConfigurationProps {
isSaving: boolean;
onDefaultModelChange: (model: OpencodeModelId) => void;
onModelToggle: (model: OpencodeModelId, enabled: boolean) => void;
providers?: OpenCodeProviderInfo[];
// Dynamic models
dynamicModels: ModelDefinition[];
enabledDynamicModelIds: string[];
onDynamicModelToggle: (modelId: string, enabled: boolean) => void;
isLoadingDynamicModels?: boolean;
}
/**
@@ -45,31 +64,144 @@ function getProviderLabel(provider: OpencodeProvider): string {
switch (provider) {
case 'opencode':
return 'OpenCode (Free)';
case 'amazon-bedrock-anthropic':
return 'Claude (Bedrock)';
case 'amazon-bedrock-deepseek':
return 'DeepSeek (Bedrock)';
case 'amazon-bedrock-amazon':
return 'Amazon Nova (Bedrock)';
case 'amazon-bedrock-meta':
return 'Meta Llama (Bedrock)';
case 'amazon-bedrock-mistral':
return 'Mistral (Bedrock)';
case 'amazon-bedrock-qwen':
return 'Qwen (Bedrock)';
default:
return provider;
}
}
/**
* Configuration for dynamic provider display
*/
const DYNAMIC_PROVIDER_CONFIG: Record<
string,
{ label: string; icon: ComponentType<{ className?: string }> }
> = {
'github-copilot': { label: 'GitHub Copilot', icon: Github },
google: { label: 'Google AI', icon: GeminiIcon },
openai: { label: 'OpenAI', icon: OpenAIIcon },
openrouter: { label: 'OpenRouter', icon: OpenRouterIcon },
anthropic: { label: 'Anthropic', icon: AnthropicIcon },
opencode: { label: 'OpenCode (Free)', icon: Terminal },
ollama: { label: 'Ollama (Local)', icon: Cpu },
lmstudio: { label: 'LM Studio (Local)', icon: Cpu },
azure: { label: 'Azure OpenAI', icon: Cloud },
'amazon-bedrock': { label: 'AWS Bedrock', icon: Cloud },
xai: { label: 'xAI', icon: GrokIcon },
deepseek: { label: 'DeepSeek', icon: Brain },
};
function getDynamicProviderConfig(providerId: string) {
return (
DYNAMIC_PROVIDER_CONFIG[providerId] || {
label: providerId.charAt(0).toUpperCase() + providerId.slice(1).replace(/-/g, ' '),
icon: Cloud,
}
);
}
const OPENCODE_AUTH_METHOD_LABELS: Record<string, string> = {
oauth: 'OAuth',
api_key: 'Key',
api: 'Key',
key: 'Key',
};
const OPENCODE_AUTH_METHOD_ICONS: Record<string, ComponentType<{ className?: string }>> = {
oauth: ShieldCheck,
api_key: KeyRound,
api: KeyRound,
key: KeyRound,
};
const OPENCODE_PROVIDER_FILTER_CLEAR_LABEL = 'Clear';
const OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER = 'Search models...';
const OPENCODE_PROVIDER_FILTER_EMPTY_LABEL = 'No models match your filters.';
const OPENCODE_PROVIDER_FILTER_EMPTY_HINT = 'Try a different search or provider.';
const OPENCODE_PROVIDER_MODELS_EMPTY_LABEL = 'No models available yet.';
const OPENCODE_PROVIDER_MODELS_EMPTY_HINT = 'Connect or refresh OpenCode CLI to load models.';
const OPENCODE_DYNAMIC_MODELS_SECTION_LABEL = 'Dynamic Models (from OpenCode providers)';
const OPENCODE_SELECT_DYNAMIC_LABEL = 'Select all';
const OPENCODE_SELECT_STATIC_LABEL = 'Select all';
const OPENCODE_SELECT_ALL_CONTAINER_CLASS =
'flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-2.5 py-1 text-xs text-muted-foreground';
function formatProviderAuthLabel(provider?: OpenCodeProviderInfo): string | null {
if (!provider?.authMethod) return null;
return OPENCODE_AUTH_METHOD_LABELS[provider.authMethod] || provider.authMethod;
}
function getProviderAuthIcon(
provider?: OpenCodeProviderInfo
): ComponentType<{ className?: string }> | null {
if (!provider?.authMethod) return null;
return OPENCODE_AUTH_METHOD_ICONS[provider.authMethod] || null;
}
function getDynamicProviderBaseLabel(
providerId: string,
providerInfo: OpenCodeProviderInfo | undefined
): string {
const providerConfig = getDynamicProviderConfig(providerId);
return providerInfo?.name || providerConfig.label;
}
function getDynamicProviderLabel(
providerId: string,
providerInfo: OpenCodeProviderInfo | undefined
): string {
const providerConfig = getDynamicProviderConfig(providerId);
const baseLabel = providerInfo?.name || providerConfig.label;
const authLabel = formatProviderAuthLabel(providerInfo);
return authLabel ? `${baseLabel} (${authLabel})` : baseLabel;
}
function getSelectionState(
candidateIds: string[],
selectedIds: string[]
): boolean | 'indeterminate' {
if (candidateIds.length === 0) return false;
const allSelected = candidateIds.every((modelId) => selectedIds.includes(modelId));
if (allSelected) return true;
const anySelected = candidateIds.some((modelId) => selectedIds.includes(modelId));
return anySelected ? 'indeterminate' : false;
}
/**
* Group dynamic models by their provider
*/
function groupDynamicModelsByProvider(
models: ModelDefinition[]
): Record<string, ModelDefinition[]> {
return models.reduce(
(acc, model) => {
const provider = model.provider || 'unknown';
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
},
{} as Record<string, ModelDefinition[]>
);
}
function matchesDynamicModelQuery(model: ModelDefinition, query: string): boolean {
if (!query) return true;
const haystack = `${model.name} ${model.description} ${model.id}`.toLowerCase();
return haystack.includes(query);
}
export function OpencodeModelConfiguration({
enabledOpencodeModels,
opencodeDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
providers,
dynamicModels,
enabledDynamicModelIds,
onDynamicModelToggle,
isLoadingDynamicModels = false,
}: OpencodeModelConfigurationProps) {
// Group models by provider for organized display
// Group static models by provider for organized display
const modelsByProvider = OPENCODE_MODELS.reduce(
(acc, model) => {
if (!acc[model.provider]) {
@@ -81,17 +213,141 @@ export function OpencodeModelConfiguration({
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
);
// Group dynamic models by provider
const dynamicModelsByProvider = groupDynamicModelsByProvider(dynamicModels);
const authenticatedProviders = (providers || []).filter((provider) => provider.authenticated);
const [dynamicProviderFilter, setDynamicProviderFilter] = useState<string | null>(null);
const hasInitializedDynamicProviderFilter = useRef(false);
const [dynamicProviderSearch, setDynamicProviderSearch] = useState('');
const normalizedDynamicSearch = dynamicProviderSearch.trim().toLowerCase();
const hasDynamicSearch = normalizedDynamicSearch.length > 0;
const allStaticModelIds = OPENCODE_MODELS.map((model) => model.id);
const selectableStaticModelIds = allStaticModelIds.filter(
(modelId) => modelId !== opencodeDefaultModel
);
const allDynamicModelIds = dynamicModels.map((model) => model.id);
const hasDynamicModels = allDynamicModelIds.length > 0;
const staticSelectState = getSelectionState(selectableStaticModelIds, enabledOpencodeModels);
// Order: Free tier first, then Claude, then others
const providerOrder: OpencodeProvider[] = [
'opencode',
'amazon-bedrock-anthropic',
'amazon-bedrock-deepseek',
'amazon-bedrock-amazon',
'amazon-bedrock-meta',
'amazon-bedrock-mistral',
'amazon-bedrock-qwen',
const providerOrder: OpencodeProvider[] = ['opencode'];
// Dynamic provider order (prioritize commonly used ones)
const dynamicProviderOrder = [
'github-copilot',
'google',
'openai',
'openrouter',
'anthropic',
'xai',
'deepseek',
'ollama',
'lmstudio',
'azure',
'amazon-bedrock',
'opencode', // Skip opencode in dynamic since it's in static
];
const sortedDynamicProviders = useMemo(() => {
const providerIndex = (providerId: string) => dynamicProviderOrder.indexOf(providerId);
const providerIds = new Set([
...Object.keys(dynamicModelsByProvider),
...(providers || []).map((provider) => provider.id),
]);
providerIds.delete('opencode'); // Don't show opencode twice
return Array.from(providerIds).sort((a, b) => {
const aIndex = providerIndex(a);
const bIndex = providerIndex(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
}, [dynamicModelsByProvider, providers]);
useEffect(() => {
if (
dynamicProviderFilter &&
sortedDynamicProviders.length > 0 &&
!sortedDynamicProviders.includes(dynamicProviderFilter)
) {
setDynamicProviderFilter(sortedDynamicProviders[0]);
return;
}
if (
!hasInitializedDynamicProviderFilter.current &&
!dynamicProviderFilter &&
sortedDynamicProviders.length > 0
) {
hasInitializedDynamicProviderFilter.current = true;
setDynamicProviderFilter(sortedDynamicProviders[0]);
}
}, [dynamicProviderFilter, sortedDynamicProviders]);
const filteredDynamicProviders = useMemo(() => {
const baseProviders = dynamicProviderFilter ? [dynamicProviderFilter] : sortedDynamicProviders;
if (!hasDynamicSearch) {
return baseProviders;
}
return baseProviders.filter((providerId) => {
const models = dynamicModelsByProvider[providerId] || [];
return models.some((model) => matchesDynamicModelQuery(model, normalizedDynamicSearch));
});
}, [
dynamicModelsByProvider,
dynamicProviderFilter,
hasDynamicSearch,
normalizedDynamicSearch,
sortedDynamicProviders,
]);
const hasDynamicProviders = sortedDynamicProviders.length > 0;
const showDynamicProviderFilters = sortedDynamicProviders.length > 1;
const hasFilteredDynamicProviders = filteredDynamicProviders.length > 0;
const toggleDynamicProviderFilter = (providerId: string) => {
setDynamicProviderFilter((current) => (current === providerId ? current : providerId));
};
const toggleAllStaticModels = (checked: boolean) => {
if (checked) {
selectableStaticModelIds.forEach((modelId) => {
if (!enabledOpencodeModels.includes(modelId)) {
onModelToggle(modelId, true);
}
});
return;
}
selectableStaticModelIds.forEach((modelId) => {
if (enabledOpencodeModels.includes(modelId)) {
onModelToggle(modelId, false);
}
});
};
const toggleProviderDynamicModels = (modelIds: string[], checked: boolean) => {
if (checked) {
modelIds.forEach((modelId) => {
if (!enabledDynamicModelIds.includes(modelId)) {
onDynamicModelToggle(modelId, true);
}
});
return;
}
modelIds.forEach((modelId) => {
if (enabledDynamicModelIds.includes(modelId)) {
onDynamicModelToggle(modelId, false);
}
});
};
return (
<div
className={cn(
@@ -146,7 +402,21 @@ export function OpencodeModelConfiguration({
{/* Available Models grouped by provider */}
<div className="space-y-4">
<Label>Available Models</Label>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>Available Models</Label>
{selectableStaticModelIds.length > 0 && (
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
<Checkbox
checked={staticSelectState}
onCheckedChange={toggleAllStaticModels}
disabled={isSaving}
/>
<span>{OPENCODE_SELECT_STATIC_LABEL}</span>
</div>
)}
</div>
{/* Static models grouped by provider (Built-in) */}
{providerOrder.map((provider) => {
const models = modelsByProvider[provider];
if (!models || models.length === 0) return null;
@@ -217,6 +487,196 @@ export function OpencodeModelConfiguration({
</div>
);
})}
{/* Dynamic models from OpenCode providers */}
{(hasDynamicProviders || isLoadingDynamicModels) && (
<>
{/* Separator between static and dynamic models */}
<div className="border-t border-border/50 my-4" />
<div className="flex flex-wrap items-center justify-between gap-2 -mt-2 mb-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground">
{OPENCODE_DYNAMIC_MODELS_SECTION_LABEL}
</p>
{isLoadingDynamicModels && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Discovering...</span>
</div>
)}
</div>
</div>
{showDynamicProviderFilters && (
<div className="space-y-2">
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/40 p-2">
{sortedDynamicProviders.map((providerId) => {
const providerInfo = authenticatedProviders.find(
(provider) => provider.id === providerId
);
const providerLabel = getDynamicProviderBaseLabel(providerId, providerInfo);
const providerConfig = getDynamicProviderConfig(providerId);
const ProviderIcon = providerConfig.icon;
const AuthIcon = getProviderAuthIcon(providerInfo);
const authLabel = formatProviderAuthLabel(providerInfo);
const isActive = dynamicProviderFilter === providerId;
const authBadgeClass = cn(
'inline-flex h-5 w-5 items-center justify-center rounded-full border border-transparent bg-transparent text-muted-foreground/80 transition-colors',
isActive && 'text-accent-foreground'
);
return (
<Button
key={providerId}
type="button"
size="sm"
variant="outline"
onClick={() => toggleDynamicProviderFilter(providerId)}
className={cn('text-xs', isActive && 'bg-accent text-accent-foreground')}
>
<span className="flex items-center gap-1.5">
<ProviderIcon className="w-3.5 h-3.5" />
<span>{providerLabel}</span>
{AuthIcon && authLabel && (
<span className={authBadgeClass}>
<AuthIcon className="w-2.5 h-2.5" />
<span className="sr-only">{authLabel}</span>
</span>
)}
</span>
</Button>
);
})}
</div>
</div>
)}
{hasDynamicProviders && (
<div className="flex flex-wrap items-center gap-2">
<Input
value={dynamicProviderSearch}
onChange={(event) => setDynamicProviderSearch(event.target.value)}
placeholder={OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER}
className="h-8 text-xs"
/>
{dynamicProviderSearch && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setDynamicProviderSearch('')}
className="text-xs"
>
{OPENCODE_PROVIDER_FILTER_CLEAR_LABEL}
</Button>
)}
</div>
)}
{hasDynamicSearch && !hasFilteredDynamicProviders && (
<div className="rounded-xl border border-dashed border-border/60 bg-card/40 px-3 py-2 text-xs text-muted-foreground">
<p className="font-medium">{OPENCODE_PROVIDER_FILTER_EMPTY_LABEL}</p>
<p className="mt-1">{OPENCODE_PROVIDER_FILTER_EMPTY_HINT}</p>
</div>
)}
{filteredDynamicProviders.map((providerId) => {
const models = dynamicModelsByProvider[providerId] || [];
const providerConfig = getDynamicProviderConfig(providerId);
const providerInfo = authenticatedProviders.find(
(provider) => provider.id === providerId
);
const providerLabel = getDynamicProviderLabel(providerId, providerInfo);
const DynamicProviderIcon = providerConfig.icon;
const filteredModels = hasDynamicSearch
? models.filter((model) =>
matchesDynamicModelQuery(model, normalizedDynamicSearch)
)
: models;
if (hasDynamicSearch && filteredModels.length === 0) {
return null;
}
return (
<div key={`dynamic-${providerId}`} className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<DynamicProviderIcon className="w-4 h-4" />
<span className="font-medium">{providerLabel}</span>
<Badge
variant="outline"
className="text-xs bg-blue-500/10 text-blue-500 border-blue-500/30"
>
Dynamic
</Badge>
</div>
{models.length > 0 && (
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
<Checkbox
checked={getSelectionState(
models.map((model) => model.id),
enabledDynamicModelIds
)}
onCheckedChange={(checked) =>
toggleProviderDynamicModels(
models.map((model) => model.id),
checked
)
}
disabled={isSaving}
/>
<span>{OPENCODE_SELECT_DYNAMIC_LABEL}</span>
</div>
)}
</div>
<div className="grid gap-2">
{filteredModels.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 bg-card/40 px-3 py-2 text-xs text-muted-foreground">
<p className="font-medium">{OPENCODE_PROVIDER_MODELS_EMPTY_LABEL}</p>
<p className="mt-1">{OPENCODE_PROVIDER_MODELS_EMPTY_HINT}</p>
</div>
) : (
filteredModels.map((model) => {
const isEnabled = enabledDynamicModelIds.includes(model.id);
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) =>
onDynamicModelToggle(model.id, !!checked)
}
disabled={isSaving}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.name}</span>
{model.supportsVision && (
<Badge variant="outline" className="text-xs">
Vision
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{model.description}
</p>
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
})}
</>
)}
</div>
</div>
</div>

View File

@@ -1,17 +1,13 @@
import { useState, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import {
OpencodeCliStatus,
OpencodeCliStatusSkeleton,
OpencodeModelConfigSkeleton,
} from '../cli-status/opencode-cli-status';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status';
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
const logger = createLogger('OpencodeSettings');
@@ -21,15 +17,21 @@ export function OpencodeSettingsTab() {
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
setDynamicOpencodeModels,
dynamicOpencodeModels,
enabledDynamicModelIds,
toggleDynamicModel,
cachedOpencodeProviders,
setCachedOpencodeProviders,
} = useAppStore();
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Load OpenCode CLI status on mount
// Phase 1: Load CLI status quickly on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
@@ -46,7 +48,6 @@ export function OpencodeSettingsTab() {
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Set auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
@@ -57,7 +58,6 @@ export function OpencodeSettingsTab() {
});
}
} else {
// Fallback for web mode or when API is not available
setCliStatus({
success: false,
status: 'not_installed',
@@ -73,14 +73,54 @@ export function OpencodeSettingsTab() {
});
} finally {
setIsCheckingOpencodeCli(false);
setIsInitialLoading(false);
}
};
checkOpencodeStatus();
}, []);
// Phase 2: Load dynamic models and providers in background (only if not cached)
useEffect(() => {
const loadDynamicContent = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup) return;
// Skip if already have cached data
const needsProviders = cachedOpencodeProviders.length === 0;
const needsModels = dynamicOpencodeModels.length === 0;
if (!needsProviders && !needsModels) return;
setIsLoadingDynamicModels(true);
try {
// Load providers if needed
if (needsProviders && api.setup.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Load models if needed
if (needsModels && api.setup.getOpencodeModels) {
const modelsResult = await api.setup.getOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
} catch (error) {
logger.error('Failed to load dynamic content:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
loadDynamicContent();
}, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
setIsLoadingDynamicModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
@@ -94,7 +134,6 @@ export function OpencodeSettingsTab() {
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Update auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
@@ -104,14 +143,35 @@ export function OpencodeSettingsTab() {
hasOAuthToken: result.auth.hasOAuthToken,
});
}
if (result.installed) {
// Refresh providers
if (api?.setup?.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Refresh dynamic models
if (api?.setup?.refreshOpencodeModels) {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
toast.success('OpenCode CLI refreshed');
}
}
} catch (error) {
logger.error('Failed to refresh OpenCode CLI status:', error);
toast.error('Failed to refresh OpenCode CLI status');
} finally {
setIsCheckingOpencodeCli(false);
setIsLoadingDynamicModels(false);
}
}, []);
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
@@ -142,12 +202,25 @@ export function OpencodeSettingsTab() {
[toggleOpencodeModel]
);
// Show loading skeleton during initial load
if (isInitialLoading) {
const handleDynamicModelToggle = useCallback(
(modelId: string, enabled: boolean) => {
setIsSaving(true);
try {
toggleDynamicModel(modelId, enabled);
} catch (error) {
toast.error('Failed to update dynamic model');
} finally {
setIsSaving(false);
}
},
[toggleDynamicModel]
);
// Show skeleton only while checking CLI status initially
if (!cliStatus && isCheckingOpencodeCli) {
return (
<div className="space-y-6">
<OpencodeCliStatusSkeleton />
<OpencodeModelConfigSkeleton />
</div>
);
}
@@ -159,6 +232,7 @@ export function OpencodeSettingsTab() {
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
@@ -171,6 +245,10 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
dynamicModels={dynamicOpencodeModels}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}
/>
)}
</div>

View File

@@ -1440,6 +1440,67 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.get('/api/setup/opencode-status'),
// OpenCode Dynamic Model Discovery
getOpencodeModels: (
refresh?: boolean
): Promise<{
success: boolean;
models?: Array<{
id: string;
name: string;
modelString: string;
provider: string;
description: string;
supportsTools: boolean;
supportsVision: boolean;
tier: string;
default?: boolean;
}>;
count?: number;
cached?: boolean;
error?: string;
}> => this.get(`/api/setup/opencode/models${refresh ? '?refresh=true' : ''}`),
refreshOpencodeModels: (): Promise<{
success: boolean;
models?: Array<{
id: string;
name: string;
modelString: string;
provider: string;
description: string;
supportsTools: boolean;
supportsVision: boolean;
tier: string;
default?: boolean;
}>;
count?: number;
error?: string;
}> => this.post('/api/setup/opencode/models/refresh'),
getOpencodeProviders: (): Promise<{
success: boolean;
providers?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}>;
authenticated?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}>;
error?: string;
}> => this.get('/api/setup/opencode/providers'),
clearOpencodeCache: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post('/api/setup/opencode/cache/clear'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);
},

View File

@@ -23,6 +23,7 @@ import type {
PipelineConfig,
PipelineStep,
PromptCustomization,
ModelDefinition,
} from '@automaker/types';
import {
getAllCursorModelIds,
@@ -583,8 +584,16 @@ export interface AppState {
codexEnableImages: boolean; // Enable image processing
// OpenCode CLI Settings (global)
enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal
enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
enabledDynamicModelIds: string[]; // Which dynamic models are enabled (model IDs)
cachedOpencodeProviders: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: string;
}>; // Cached providers
// Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
@@ -988,6 +997,12 @@ export interface AppActions {
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
setEnabledDynamicModelIds: (ids: string[]) => void;
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
setCachedOpencodeProviders: (
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
) => void;
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
@@ -1220,6 +1235,9 @@ const initialState: AppState = {
codexEnableImages: false, // Default to disabled
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
enabledDynamicModelIds: [], // All dynamic models enabled by default (populated when models are fetched)
cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
mcpServers: [], // No MCP servers configured by default
@@ -2017,6 +2035,27 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
? [...state.enabledOpencodeModels, model]
: state.enabledOpencodeModels.filter((m) => m !== model),
})),
setDynamicOpencodeModels: (models) => {
// When setting dynamic models, auto-enable all of them if enabledDynamicModelIds is empty
const currentEnabled = get().enabledDynamicModelIds;
const newModelIds = models.map((m) => m.id);
// If no models were previously enabled, enable all new ones
if (currentEnabled.length === 0) {
set({ dynamicOpencodeModels: models, enabledDynamicModelIds: newModelIds });
} else {
// Keep existing enabled state, just update the models list
set({ dynamicOpencodeModels: models });
}
},
setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }),
toggleDynamicModel: (modelId, enabled) =>
set((state) => ({
enabledDynamicModelIds: enabled
? [...state.enabledDynamicModelIds, modelId]
: state.enabledDynamicModelIds.filter((id) => id !== modelId),
})),
setCachedOpencodeProviders: (providers) => set({ cachedOpencodeProviders: providers }),
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: async (enabled) => {

View File

@@ -69,8 +69,12 @@ export function resolveModelString(
return modelKey;
}
// OpenCode model - pass through unchanged
// Supports: opencode/big-pickle, opencode-sonnet, amazon-bedrock/anthropic.claude-*
// OpenCode model (static or dynamic) - pass through unchanged
// This handles models like:
// - opencode-* (Automaker routing prefix)
// - opencode/* (free tier models)
// - amazon-bedrock/* (AWS Bedrock models)
// - provider/model-name (dynamic models like github-copilot/gpt-4o, google/gemini-2.5-pro)
if (isOpencodeModel(modelKey)) {
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
return modelKey;

View File

@@ -95,8 +95,9 @@ export function isCodexModel(model: string | undefined | null): boolean {
* - 'opencode/' prefix (OpenCode free tier models)
* - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode)
* - Full model ID from OPENCODE_MODEL_CONFIG_MAP
* - Dynamic models from OpenCode CLI with provider/model format (e.g., "github-copilot/gpt-4o", "google/gemini-2.5-pro")
*
* @param model - Model string to check (e.g., "opencode-sonnet", "opencode/big-pickle", "amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0")
* @param model - Model string to check
* @returns true if the model is an OpenCode model
*/
export function isOpencodeModel(model: string | undefined | null): boolean {
@@ -113,12 +114,26 @@ export function isOpencodeModel(model: string | undefined | null): boolean {
}
// Check for OpenCode native model prefixes
// - opencode/ = OpenCode free tier models (e.g., opencode/big-pickle)
// - amazon-bedrock/ = AWS Bedrock models (e.g., amazon-bedrock/anthropic.claude-*)
// - opencode/ = OpenCode free tier models
// - amazon-bedrock/ = AWS Bedrock models
if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) {
return true;
}
// Check for dynamic models from OpenCode CLI with provider/model format
// These are models discovered dynamically from authenticated providers like:
// - github-copilot/gpt-4o
// - google/gemini-2.5-pro
// - xai/grok-3
// Pattern: provider-id/model-name (must have exactly one / and not be a URL)
if (model.includes('/') && !model.includes('://')) {
const parts = model.split('/');
// Valid dynamic model format: provider/model-name (exactly 2 parts)
if (parts.length === 2 && parts[0].length > 0 && parts[1].length > 0) {
return true;
}
}
return false;
}