Merge pull request #426 from stefandevo/opencode-dynamic-providers

feat: add dynamic model discovery and routing for OpenCode provider
This commit is contained in:
Dhanush Santosh
2026-01-12 23:51:06 +05:30
committed by GitHub
21 changed files with 2231 additions and 542 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);
@@ -110,7 +111,11 @@ async function executeWithCursor(prompt: string, model: string): Promise<string>
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
readOnly: true, // Prompt enhancement only generates text, doesn't write files
})) {
if (msg.type === 'assistant' && msg.message?.content) {
if (msg.type === 'error') {
// Throw error with the message from the provider
const errorMessage = msg.error || 'Provider returned an error';
throw new Error(errorMessage);
} else if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
@@ -212,7 +217,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,189 @@
/**
* 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) {
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) {
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

@@ -3,7 +3,7 @@ import {
OpencodeProvider,
resetToolUseIdCounter,
} from '../../../src/providers/opencode-provider.js';
import type { ProviderMessage } from '@automaker/types';
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
@@ -51,63 +51,38 @@ describe('opencode-provider.ts', () => {
});
describe('getAvailableModels', () => {
it('should return 10 models', () => {
it('should return 5 models', () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(10);
expect(models).toHaveLength(5);
});
it('should include Claude Sonnet 4.5 (Bedrock) as default', () => {
const models = provider.getAvailableModels();
const sonnet = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
);
expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)');
expect(sonnet?.provider).toBe('opencode');
expect(sonnet?.default).toBe(true);
expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
});
it('should include Claude Opus 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const opus = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
);
expect(opus).toBeDefined();
expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)');
expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0');
});
it('should include Claude Haiku 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const haiku = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
);
expect(haiku).toBeDefined();
expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)');
expect(haiku?.tier).toBe('standard');
});
it('should include free tier Big Pickle model', () => {
it('should include Big Pickle as default', () => {
const models = provider.getAvailableModels();
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');
expect(bigPickle).toBeDefined();
expect(bigPickle?.name).toBe('Big Pickle (Free)');
expect(bigPickle?.provider).toBe('opencode');
expect(bigPickle?.default).toBe(true);
expect(bigPickle?.modelString).toBe('opencode/big-pickle');
expect(bigPickle?.tier).toBe('basic');
});
it('should include DeepSeek R1 (Bedrock)', () => {
it('should include free tier GLM model', () => {
const models = provider.getAvailableModels();
const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0');
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
expect(deepseek).toBeDefined();
expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)');
expect(deepseek?.tier).toBe('premium');
expect(glm).toBeDefined();
expect(glm?.name).toBe('GLM 4.7 Free');
expect(glm?.tier).toBe('basic');
});
it('should include free tier MiniMax model', () => {
const models = provider.getAvailableModels();
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');
expect(minimax).toBeDefined();
expect(minimax?.name).toBe('MiniMax M2.1 Free');
expect(minimax?.tier).toBe('basic');
});
it('should have all models support tools', () => {
@@ -128,6 +103,24 @@ describe('opencode-provider.ts', () => {
});
});
describe('parseModelsOutput', () => {
it('should parse nested provider model IDs', () => {
const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n');
const parseModelsOutput = (
provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] }
).parseModelsOutput.bind(provider);
const models = parseModelsOutput(output);
expect(models).toHaveLength(2);
const openrouterModel = models.find((model) => model.id.startsWith('openrouter/'));
expect(openrouterModel).toBeDefined();
expect(openrouterModel?.provider).toBe('openrouter');
expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet');
});
});
describe('supportsFeature', () => {
it("should support 'tools' feature", () => {
expect(provider.supportsFeature('tools')).toBe(true);
@@ -1243,7 +1236,7 @@ describe('opencode-provider.ts', () => {
const defaultModels = models.filter((m) => m.default === true);
expect(defaultModels).toHaveLength(1);
expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
expect(defaultModels[0].id).toBe('opencode/big-pickle');
});
it('should have valid tier values for all models', () => {

View File

@@ -6,6 +6,7 @@ import { getProviderFromModel } from '@/lib/utils';
const PROVIDER_ICON_KEYS = {
anthropic: 'anthropic',
openai: 'openai',
openrouter: 'openrouter',
cursor: 'cursor',
gemini: 'gemini',
grok: 'grok',
@@ -41,6 +42,12 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z',
fill: '#74aa9c',
},
openrouter: {
viewBox: '0 0 24 24',
// OpenRouter logo from Simple Icons
path: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z',
fill: '#94A3B8',
},
cursor: {
viewBox: '0 0 512 512',
// Official Cursor logo - hexagonal shape with triangular wedge
@@ -151,6 +158,10 @@ export function OpenAIIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openai} {...props} />;
}
export function OpenRouterIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openrouter} {...props} />;
}
export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
}
@@ -395,6 +406,11 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('openrouter/')) {
return 'openrouter';
}
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('amazon-bedrock/')) {
// Bedrock-hosted models - detect the specific provider
@@ -442,6 +458,71 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
return 'opencode';
}
// Check for dynamic OpenCode provider models (provider/model format)
// e.g., zai-coding-plan/glm-4.5, github-copilot/gpt-4o, google/gemini-2.5-pro
// Only handle strings with exactly one slash (not URLs or paths)
if (!modelStr.includes('://')) {
const slashIndex = modelStr.indexOf('/');
if (slashIndex !== -1 && slashIndex === modelStr.lastIndexOf('/')) {
const providerName = modelStr.slice(0, slashIndex);
const modelName = modelStr.slice(slashIndex + 1);
// Skip if either part is empty
if (providerName && modelName) {
// Check model name for known patterns
if (modelName.includes('glm')) {
return 'glm';
}
if (
modelName.includes('claude') ||
modelName.includes('sonnet') ||
modelName.includes('opus')
) {
return 'anthropic';
}
if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) {
return 'openai';
}
if (modelName.includes('gemini')) {
return 'gemini';
}
if (modelName.includes('grok')) {
return 'grok';
}
if (modelName.includes('deepseek')) {
return 'deepseek';
}
if (modelName.includes('llama')) {
return 'meta';
}
if (modelName.includes('qwen')) {
return 'qwen';
}
if (modelName.includes('mistral')) {
return 'mistral';
}
// Check provider name for hints
if (providerName.includes('google')) {
return 'gemini';
}
if (providerName.includes('anthropic')) {
return 'anthropic';
}
if (providerName.includes('openai')) {
return 'openai';
}
if (providerName.includes('openrouter')) {
return 'openrouter';
}
if (providerName.includes('xai')) {
return 'grok';
}
// Default for unknown dynamic models
return 'opencode';
}
}
}
// Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic';
@@ -475,6 +556,7 @@ export function getProviderIconForModel(
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
anthropic: AnthropicIcon,
openai: OpenAIIcon,
openrouter: OpenRouterIcon,
cursor: CursorIcon,
gemini: GeminiIcon,
grok: GrokIcon,

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

@@ -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,19 +1,18 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } 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');
const OPENCODE_PROVIDER_ID = 'opencode';
const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|';
const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]);
export function OpencodeSettingsTab() {
const {
@@ -21,15 +20,22 @@ 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);
const providerRefreshSignatureRef = useRef<string>('');
// Load OpenCode CLI status on mount
// Phase 1: Load CLI status quickly on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
@@ -46,7 +52,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 +62,6 @@ export function OpencodeSettingsTab() {
});
}
} else {
// Fallback for web mode or when API is not available
setCliStatus({
success: false,
status: 'not_installed',
@@ -73,14 +77,111 @@ 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
useEffect(() => {
const refreshModelsForNewProviders = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup?.refreshOpencodeModels) return;
if (isLoadingDynamicModels) return;
const authenticatedProviders = cachedOpencodeProviders
.filter((provider) => provider.authenticated)
.map((provider) => provider.id)
.filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId));
if (authenticatedProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const dynamicProviderIds = new Set(
dynamicOpencodeModels.map((model) => model.provider).filter(Boolean)
);
const missingProviders = authenticatedProviders.filter(
(providerId) => !dynamicProviderIds.has(providerId)
);
if (missingProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR);
if (providerRefreshSignatureRef.current === signature) return;
providerRefreshSignatureRef.current = signature;
setIsLoadingDynamicModels(true);
try {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
} catch (error) {
logger.error('Failed to refresh OpenCode models for new providers:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
refreshModelsForNewProviders();
}, [
cachedOpencodeProviders,
dynamicOpencodeModels,
cliStatus?.success,
cliStatus?.status,
isLoadingDynamicModels,
setDynamicOpencodeModels,
]);
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
setIsLoadingDynamicModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
@@ -94,7 +195,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 +204,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 +263,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 +293,7 @@ export function OpencodeSettingsTab() {
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
@@ -171,6 +306,11 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
dynamicModels={dynamicOpencodeModels}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}
/>
)}
</div>

View File

@@ -182,7 +182,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
<div>
<p className="font-medium text-foreground">This step is optional</p>
<p className="text-sm text-muted-foreground mt-1">
Configure OpenCode CLI for access to free tier models and AWS Bedrock models. You
Configure OpenCode CLI for access to free tier models and connected providers. You
can skip this and use other providers, or configure it later in Settings.
</p>
</div>
@@ -241,7 +241,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI to use free tier and AWS Bedrock models.
Install the OpenCode CLI to use free tier models and connected providers.
</p>
</div>
</div>

View File

@@ -1119,7 +1119,7 @@ function OpencodeContent() {
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI for free tier and AWS Bedrock models.
Install the OpenCode CLI for free tier models and connected providers.
</p>
</div>
</div>

View File

@@ -28,7 +28,11 @@ import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
import { getItem, setItem } from '@/lib/storage';
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import type { GlobalSettings } from '@automaker/types';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
type GlobalSettings,
} from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -152,6 +156,10 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
enabledCursorModels: state.enabledCursorModels as GlobalSettings['enabledCursorModels'],
cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'],
enabledOpencodeModels: state.enabledOpencodeModels as GlobalSettings['enabledOpencodeModels'],
opencodeDefaultModel: state.opencodeDefaultModel as GlobalSettings['opencodeDefaultModel'],
enabledDynamicModelIds:
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
@@ -495,6 +503,27 @@ export function useSettingsMigration(): MigrationState {
*/
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
const current = useAppStore.getState();
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
settings.enabledOpencodeModels ?? current.enabledOpencodeModels;
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
settings.opencodeDefaultModel ?? current.opencodeDefaultModel
)
? (settings.opencodeDefaultModel ?? current.opencodeDefaultModel)
: DEFAULT_OPENCODE_MODEL;
const sanitizedEnabledOpencodeModels = Array.from(
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
);
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
const persistedDynamicModelIds =
settings.enabledDynamicModelIds ?? current.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
const projects = (settings.projects ?? []).map((ref) => ({
@@ -539,6 +568,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
phaseModels: settings.phaseModels ?? current.phaseModels,
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
skipSandboxWarning: settings.skipSandboxWarning ?? false,
keyboardShortcuts: {
@@ -592,6 +624,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
phaseModels: state.phaseModels,
enabledDynamicModelIds: state.enabledDynamicModelIds,
autoLoadClaudeMd: state.autoLoadClaudeMd,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,

View File

@@ -19,7 +19,11 @@ import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-stor
import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import type { GlobalSettings } from '@automaker/types';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
type GlobalSettings,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -44,6 +48,9 @@ const SETTINGS_FIELDS_TO_SYNC = [
'phaseModels',
'enabledCursorModels',
'cursorDefaultModel',
'enabledOpencodeModels',
'opencodeDefaultModel',
'enabledDynamicModelIds',
'autoLoadClaudeMd',
'keyboardShortcuts',
'mcpServers',
@@ -421,6 +428,27 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
const serverSettings = result.settings as unknown as GlobalSettings;
const currentAppState = useAppStore.getState();
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels;
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel
)
? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel)
: DEFAULT_OPENCODE_MODEL;
const sanitizedEnabledOpencodeModels = Array.from(
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
);
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
const persistedDynamicModelIds =
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
// Save theme to localStorage for fallback when server settings aren't available
if (serverSettings.theme) {
@@ -444,6 +472,9 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
phaseModels: serverSettings.phaseModels,
enabledCursorModels: serverSettings.enabledCursorModels,
cursorDefaultModel: serverSettings.cursorDefaultModel,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
keyboardShortcuts: {
...currentAppState.keyboardShortcuts,

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,
@@ -33,6 +34,8 @@ import {
} from '@automaker/types';
const logger = createLogger('AppStore');
const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock';
const OPENCODE_BEDROCK_MODEL_PREFIX = `${OPENCODE_BEDROCK_PROVIDER_ID}/`;
// Re-export types for convenience
export type {
@@ -583,8 +586,19 @@ export interface AppState {
codexEnableImages: boolean; // Enable image processing
// OpenCode CLI Settings (global)
enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal
// Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC
enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
// Dynamic models are session-only (not persisted) because they're discovered at runtime
// from `opencode models` CLI and depend on current provider authentication state
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
enabledDynamicModelIds: string[]; // Which dynamic models are enabled
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 +1002,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>;
@@ -1219,7 +1239,10 @@ const initialState: AppState = {
codexEnableWebSearch: false, // Default to disabled
codexEnableImages: false, // Default to disabled
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to OpenCode free tier
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
enabledDynamicModelIds: [], // Empty until user enables dynamic models
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 +2040,34 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
? [...state.enabledOpencodeModels, model]
: state.enabledOpencodeModels.filter((m) => m !== model),
})),
setDynamicOpencodeModels: (models) => {
// Dynamic models depend on CLI authentication state and are re-discovered each session.
// Persist enabled model IDs, but do not auto-enable new models.
const filteredModels = models.filter(
(model) =>
model.provider !== OPENCODE_BEDROCK_PROVIDER_ID &&
!model.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
);
const currentEnabled = get().enabledDynamicModelIds;
const newModelIds = filteredModels.map((m) => m.id);
const filteredEnabled = currentEnabled.filter((modelId) => newModelIds.includes(modelId));
const nextEnabled = currentEnabled.length === 0 ? [] : filteredEnabled;
set({ dynamicOpencodeModels: filteredModels, enabledDynamicModelIds: nextEnabled });
},
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.filter(
(provider) => provider.id !== OPENCODE_BEDROCK_PROVIDER_ID
),
}),
// 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

@@ -399,7 +399,7 @@ describe('enhancement.ts', () => {
const modes = getAvailableEnhancementModes();
expect(Array.isArray(modes)).toBe(true);
expect(modes.length).toBe(4);
expect(modes.length).toBe(5);
});
it('should include all valid modes', () => {
@@ -409,6 +409,7 @@ describe('enhancement.ts', () => {
expect(modes).toContain('technical');
expect(modes).toContain('simplify');
expect(modes).toContain('acceptance');
expect(modes).toContain('ux-reviewer');
});
it('should return modes in consistent order', () => {

View File

@@ -8,43 +8,12 @@ export type OpencodeModelId =
| 'opencode/glm-4.7-free'
| 'opencode/gpt-5-nano'
| 'opencode/grok-code'
| 'opencode/minimax-m2.1-free'
// Amazon Bedrock - Claude Models
| 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
| 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
| 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
| 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0'
| 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0'
| 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0'
| 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0'
| 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0'
// Amazon Bedrock - DeepSeek Models
| 'amazon-bedrock/deepseek.r1-v1:0'
| 'amazon-bedrock/deepseek.v3-v1:0'
// Amazon Bedrock - Amazon Nova Models
| 'amazon-bedrock/amazon.nova-premier-v1:0'
| 'amazon-bedrock/amazon.nova-pro-v1:0'
| 'amazon-bedrock/amazon.nova-lite-v1:0'
// Amazon Bedrock - Meta Llama Models
| 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0'
| 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0'
// Amazon Bedrock - Mistral Models
| 'amazon-bedrock/mistral.mistral-large-2402-v1:0'
// Amazon Bedrock - Qwen Models
| 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0'
| 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0';
| 'opencode/minimax-m2.1-free';
/**
* Provider type for OpenCode models
*/
export type OpencodeProvider =
| 'opencode'
| 'amazon-bedrock-anthropic'
| 'amazon-bedrock-deepseek'
| 'amazon-bedrock-amazon'
| 'amazon-bedrock-meta'
| 'amazon-bedrock-mistral'
| 'amazon-bedrock-qwen';
export type OpencodeProvider = 'opencode';
/**
* Friendly aliases mapped to full model IDs
@@ -59,36 +28,6 @@ export const OPENCODE_MODEL_MAP: Record<string, OpencodeModelId> = {
'grok-code': 'opencode/grok-code',
grok: 'opencode/grok-code',
minimax: 'opencode/minimax-m2.1-free',
// Claude aliases (via Bedrock)
'claude-sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
'sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
sonnet: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
'claude-opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
'opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
opus: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
'claude-haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
'haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
haiku: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
// DeepSeek aliases
'deepseek-r1': 'amazon-bedrock/deepseek.r1-v1:0',
r1: 'amazon-bedrock/deepseek.r1-v1:0',
'deepseek-v3': 'amazon-bedrock/deepseek.v3-v1:0',
// Nova aliases
'nova-premier': 'amazon-bedrock/amazon.nova-premier-v1:0',
'nova-pro': 'amazon-bedrock/amazon.nova-pro-v1:0',
nova: 'amazon-bedrock/amazon.nova-pro-v1:0',
// Llama aliases
llama4: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
'llama-4': 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
llama3: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0',
// Qwen aliases
qwen: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
'qwen-coder': 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
} as const;
/**
@@ -148,162 +87,6 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
provider: 'opencode',
tier: 'free',
},
// Amazon Bedrock - Claude Models
{
id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
label: 'Claude Sonnet 4.5 (Bedrock)',
description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent (default)',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'premium',
},
{
id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
label: 'Claude Opus 4.5 (Bedrock)',
description: 'Most capable Claude model via AWS Bedrock',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'premium',
},
{
id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
label: 'Claude Haiku 4.5 (Bedrock)',
description: 'Fastest Claude model via AWS Bedrock',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'standard',
},
{
id: 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0',
label: 'Claude Sonnet 4 (Bedrock)',
description: 'Claude Sonnet 4 via AWS Bedrock',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'premium',
},
{
id: 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0',
label: 'Claude Opus 4 (Bedrock)',
description: 'Claude Opus 4 via AWS Bedrock',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'premium',
},
{
id: 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0',
label: 'Claude 3.7 Sonnet (Bedrock)',
description: 'Claude 3.7 Sonnet via AWS Bedrock',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'standard',
},
{
id: 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0',
label: 'Claude 3.5 Sonnet (Bedrock)',
description: 'Claude 3.5 Sonnet v2 via AWS Bedrock',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'standard',
},
{
id: 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0',
label: 'Claude 3 Opus (Bedrock)',
description: 'Claude 3 Opus via AWS Bedrock',
supportsVision: true,
provider: 'amazon-bedrock-anthropic',
tier: 'premium',
},
// Amazon Bedrock - DeepSeek Models
{
id: 'amazon-bedrock/deepseek.r1-v1:0',
label: 'DeepSeek R1 (Bedrock)',
description: 'DeepSeek R1 reasoning model via AWS Bedrock - excellent for coding',
supportsVision: false,
provider: 'amazon-bedrock-deepseek',
tier: 'premium',
},
{
id: 'amazon-bedrock/deepseek.v3-v1:0',
label: 'DeepSeek V3 (Bedrock)',
description: 'DeepSeek V3 via AWS Bedrock',
supportsVision: false,
provider: 'amazon-bedrock-deepseek',
tier: 'standard',
},
// Amazon Bedrock - Amazon Nova Models
{
id: 'amazon-bedrock/amazon.nova-premier-v1:0',
label: 'Amazon Nova Premier (Bedrock)',
description: 'Amazon Nova Premier - most capable Nova model',
supportsVision: true,
provider: 'amazon-bedrock-amazon',
tier: 'premium',
},
{
id: 'amazon-bedrock/amazon.nova-pro-v1:0',
label: 'Amazon Nova Pro (Bedrock)',
description: 'Amazon Nova Pro - balanced performance',
supportsVision: true,
provider: 'amazon-bedrock-amazon',
tier: 'standard',
},
{
id: 'amazon-bedrock/amazon.nova-lite-v1:0',
label: 'Amazon Nova Lite (Bedrock)',
description: 'Amazon Nova Lite - fast and efficient',
supportsVision: true,
provider: 'amazon-bedrock-amazon',
tier: 'standard',
},
// Amazon Bedrock - Meta Llama Models
{
id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
label: 'Llama 4 Maverick 17B (Bedrock)',
description: 'Meta Llama 4 Maverick via AWS Bedrock',
supportsVision: false,
provider: 'amazon-bedrock-meta',
tier: 'standard',
},
{
id: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0',
label: 'Llama 3.3 70B (Bedrock)',
description: 'Meta Llama 3.3 70B via AWS Bedrock',
supportsVision: false,
provider: 'amazon-bedrock-meta',
tier: 'standard',
},
// Amazon Bedrock - Mistral Models
{
id: 'amazon-bedrock/mistral.mistral-large-2402-v1:0',
label: 'Mistral Large (Bedrock)',
description: 'Mistral Large via AWS Bedrock',
supportsVision: false,
provider: 'amazon-bedrock-mistral',
tier: 'standard',
},
// Amazon Bedrock - Qwen Models
{
id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
label: 'Qwen3 Coder 480B (Bedrock)',
description: 'Qwen3 Coder 480B via AWS Bedrock - excellent for coding',
supportsVision: false,
provider: 'amazon-bedrock-qwen',
tier: 'premium',
},
{
id: 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0',
label: 'Qwen3 235B (Bedrock)',
description: 'Qwen3 235B via AWS Bedrock',
supportsVision: false,
provider: 'amazon-bedrock-qwen',
tier: 'premium',
},
];
/**
@@ -319,10 +102,9 @@ export const OPENCODE_MODEL_CONFIG_MAP: Record<OpencodeModelId, OpencodeModelCon
);
/**
* Default OpenCode model - Claude Sonnet 4.5 via Bedrock
* Default OpenCode model - OpenCode free tier
*/
export const DEFAULT_OPENCODE_MODEL: OpencodeModelId =
'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0';
export const DEFAULT_OPENCODE_MODEL: OpencodeModelId = 'opencode/big-pickle';
/**
* Helper: Get display name for model

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;
}

View File

@@ -401,6 +401,8 @@ export interface GlobalSettings {
enabledOpencodeModels?: OpencodeModelId[];
/** Default OpenCode model selection when switching to OpenCode CLI */
opencodeDefaultModel?: OpencodeModelId;
/** Which dynamic OpenCode models are enabled (empty = all discovered) */
enabledDynamicModelIds?: string[];
// Input Configuration
/** User's keyboard shortcut bindings */
@@ -704,6 +706,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
cursorDefaultModel: 'auto',
enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
enabledDynamicModelIds: [],
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
projects: [],
trashedProjects: [],