mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge pull request #426 from stefandevo/opencode-dynamic-providers
feat: add dynamic model discovery and routing for OpenCode provider
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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}`);
|
||||
|
||||
@@ -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());
|
||||
|
||||
189
apps/server/src/routes/setup/routes/opencode-models.ts
Normal file
189
apps/server/src/routes/setup/routes/opencode-models.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user