mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: add dynamic model discovery and routing for OpenCode provider
- Update isOpencodeModel() to detect dynamic models with provider/model format (e.g., github-copilot/gpt-4o, google/gemini-2.5-pro, zai-coding-plan/glm-4.7) - Update resolveModelString() to recognize and pass through OpenCode models - Update enhance route to route OpenCode models to OpenCode provider - Fix OpenCode CLI command format: use --format json (not stream-json) - Remove unsupported -q and - flags from CLI arguments - Update normalizeEvent() to handle actual OpenCode JSON event format - Add dynamic model configuration UI with provider grouping - Cache providers and models in app store for snappier navigation - Show authenticated providers in OpenCode CLI status card Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
DhanushSantosh
parent
ed65f70315
commit
6c5206daf4
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);
|
||||
@@ -212,7 +213,14 @@ export function createEnhanceHandler(
|
||||
|
||||
// Cursor doesn't have a separate system prompt concept, so combine them
|
||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
||||
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
|
||||
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
|
||||
} else if (isOpencodeModel(resolvedModel)) {
|
||||
// Use OpenCode provider for OpenCode models (static and dynamic)
|
||||
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);
|
||||
|
||||
// OpenCode CLI handles the system prompt, so combine them
|
||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
||||
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
||||
|
||||
@@ -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());
|
||||
|
||||
191
apps/server/src/routes/setup/routes/opencode-models.ts
Normal file
191
apps/server/src/routes/setup/routes/opencode-models.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* OpenCode Dynamic Models API Routes
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - GET /api/setup/opencode/models - Get available models (cached or refreshed)
|
||||
* - POST /api/setup/opencode/models/refresh - Force refresh models from CLI
|
||||
* - GET /api/setup/opencode/providers - Get authenticated providers
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
OpencodeProvider,
|
||||
type OpenCodeProviderInfo,
|
||||
} from '../../../providers/opencode-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import type { ModelDefinition } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('OpenCodeModelsRoute');
|
||||
|
||||
// Singleton provider instance for caching
|
||||
let providerInstance: OpencodeProvider | null = null;
|
||||
|
||||
function getProvider(): OpencodeProvider {
|
||||
if (!providerInstance) {
|
||||
providerInstance = new OpencodeProvider();
|
||||
}
|
||||
return providerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for models endpoint
|
||||
*/
|
||||
interface ModelsResponse {
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
count?: number;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for providers endpoint
|
||||
*/
|
||||
interface ProvidersResponse {
|
||||
success: boolean;
|
||||
providers?: OpenCodeProviderInfo[];
|
||||
authenticated?: OpenCodeProviderInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/opencode/models
|
||||
*
|
||||
* Returns currently available models (from cache if available).
|
||||
* Query params:
|
||||
* - refresh=true: Force refresh from CLI before returning
|
||||
*
|
||||
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
|
||||
*/
|
||||
export function createGetOpencodeModelsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
let models: ModelDefinition[];
|
||||
let cached = true;
|
||||
|
||||
if (forceRefresh) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
// Check if we have cached models
|
||||
const cachedModels = provider.getAvailableModels();
|
||||
|
||||
// If cache only has default models (provider.hasCachedModels() would be false),
|
||||
// trigger a refresh to get dynamic models
|
||||
if (!provider.hasCachedModels()) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
models = cachedModels;
|
||||
}
|
||||
}
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Get OpenCode models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/opencode/models/refresh
|
||||
*
|
||||
* Forces a refresh of models from the OpenCode CLI.
|
||||
*/
|
||||
export function createRefreshOpencodeModelsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const models = await provider.refreshModels();
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logger.error('Refresh OpenCode models failed:', error);
|
||||
logError(error, 'Refresh OpenCode models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/opencode/providers
|
||||
*
|
||||
* Returns authenticated providers from OpenCode CLI.
|
||||
* This calls `opencode auth list` to get provider status.
|
||||
*/
|
||||
export function createGetOpencodeProvidersHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const providers = await provider.fetchAuthenticatedProviders();
|
||||
|
||||
// Filter to only authenticated providers
|
||||
const authenticated = providers.filter((p) => p.authenticated);
|
||||
|
||||
const response: ProvidersResponse = {
|
||||
success: true,
|
||||
providers,
|
||||
authenticated,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logger.error('Get OpenCode providers failed:', error);
|
||||
logError(error, 'Get OpenCode providers failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ProvidersResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/opencode/cache/clear
|
||||
*
|
||||
* Clears the model cache, forcing a fresh fetch on next access.
|
||||
*/
|
||||
export function createClearOpencodeCacheHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
provider.clearModelCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OpenCode model cache cleared',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear OpenCode cache failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* OpenCode Dynamic Providers Component
|
||||
*
|
||||
* Shows authenticated providers from OpenCode CLI and allows
|
||||
* refreshing the model list from the CLI.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, CheckCircle2, XCircle, Cloud, Terminal, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
const logger = createLogger('OpenCodeDynamicProviders');
|
||||
|
||||
interface OpenCodeProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
authMethod?: 'oauth' | 'api_key';
|
||||
}
|
||||
|
||||
interface OpenCodeDynamicProvidersProps {
|
||||
isCliInstalled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider display configuration
|
||||
*/
|
||||
const PROVIDER_CONFIG: Record<string, { icon: string; displayName: string }> = {
|
||||
copilot: { icon: '', displayName: 'GitHub Copilot' },
|
||||
anthropic: { icon: '', displayName: 'Anthropic' },
|
||||
openai: { icon: '', displayName: 'OpenAI' },
|
||||
google: { icon: '', displayName: 'Google' },
|
||||
'amazon-bedrock': { icon: '', displayName: 'AWS Bedrock' },
|
||||
azure: { icon: '', displayName: 'Azure OpenAI' },
|
||||
ollama: { icon: '', displayName: 'Ollama' },
|
||||
lmstudio: { icon: '', displayName: 'LM Studio' },
|
||||
opencode: { icon: '', displayName: 'OpenCode' },
|
||||
};
|
||||
|
||||
function getProviderDisplay(provider: OpenCodeProviderInfo) {
|
||||
const config = PROVIDER_CONFIG[provider.id] || {
|
||||
displayName: provider.name || provider.id,
|
||||
};
|
||||
return config.displayName;
|
||||
}
|
||||
|
||||
export function OpenCodeDynamicProviders({ isCliInstalled }: OpenCodeDynamicProvidersProps) {
|
||||
const [providers, setProviders] = useState<OpenCodeProviderInfo[]>([]);
|
||||
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
|
||||
const [isRefreshingModels, setIsRefreshingModels] = useState(false);
|
||||
const { dynamicOpencodeModels, setDynamicOpencodeModels } = useAppStore();
|
||||
|
||||
// Model count derived from store
|
||||
const modelCount = dynamicOpencodeModels.length;
|
||||
|
||||
// Fetch models from API and store them (only if not already loaded)
|
||||
const fetchModels = useCallback(
|
||||
async (force = false) => {
|
||||
// Skip if already have models and not forcing refresh
|
||||
if (!force && dynamicOpencodeModels.length > 0) {
|
||||
logger.debug('Dynamic models already loaded, skipping fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.setup?.getOpencodeModels) {
|
||||
const data = await api.setup.getOpencodeModels();
|
||||
if (data.success && data.models) {
|
||||
setDynamicOpencodeModels(data.models);
|
||||
logger.info(`Loaded ${data.models.length} dynamic OpenCode models`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch OpenCode models:', error);
|
||||
}
|
||||
},
|
||||
[dynamicOpencodeModels.length, setDynamicOpencodeModels]
|
||||
);
|
||||
|
||||
// Fetch providers on mount, but only fetch models if not already loaded
|
||||
useEffect(() => {
|
||||
if (isCliInstalled) {
|
||||
fetchProviders();
|
||||
// Only fetch models if store is empty
|
||||
if (dynamicOpencodeModels.length === 0) {
|
||||
fetchModels(false);
|
||||
}
|
||||
}
|
||||
}, [isCliInstalled]); // Intentionally not including fetchModels to avoid re-fetching
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
setIsLoadingProviders(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.setup?.getOpencodeProviders) {
|
||||
const data = await api.setup.getOpencodeProviders();
|
||||
if (data.success && data.providers) {
|
||||
setProviders(data.providers);
|
||||
}
|
||||
} else {
|
||||
logger.warn('OpenCode providers API not available');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch OpenCode providers:', error);
|
||||
} finally {
|
||||
setIsLoadingProviders(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefreshModels = useCallback(async () => {
|
||||
setIsRefreshingModels(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.setup?.refreshOpencodeModels) {
|
||||
const data = await api.setup.refreshOpencodeModels();
|
||||
if (data.success) {
|
||||
// Store the refreshed models in the app store
|
||||
if (data.models) {
|
||||
setDynamicOpencodeModels(data.models);
|
||||
toast.success(`Refreshed ${data.models.length} models from OpenCode CLI`);
|
||||
}
|
||||
// Also refresh providers
|
||||
await fetchProviders();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to refresh models');
|
||||
}
|
||||
} else {
|
||||
logger.warn('OpenCode refresh models API not available');
|
||||
toast.error('OpenCode API not available');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh OpenCode models:', error);
|
||||
toast.error('Failed to refresh models from OpenCode CLI');
|
||||
} finally {
|
||||
setIsRefreshingModels(false);
|
||||
}
|
||||
}, [fetchProviders, setDynamicOpencodeModels]);
|
||||
|
||||
if (!isCliInstalled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authenticatedProviders = providers.filter((p) => p.authenticated);
|
||||
const unauthenticatedProviders = providers.filter((p) => !p.authenticated);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Cloud className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Dynamic Providers
|
||||
</h2>
|
||||
{modelCount !== null && (
|
||||
<p className="text-xs text-muted-foreground">{modelCount} models available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefreshModels}
|
||||
disabled={isRefreshingModels}
|
||||
className="gap-2"
|
||||
>
|
||||
{isRefreshingModels ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Refresh Models
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
OpenCode discovers models from your authenticated providers (GitHub Copilot, Google, etc.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{isLoadingProviders ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-2">No providers detected yet</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Run <code className="font-mono bg-accent/50 px-1 rounded">opencode</code> and use{' '}
|
||||
<code className="font-mono bg-accent/50 px-1 rounded">/connect</code> to authenticate
|
||||
with providers
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Authenticated Providers */}
|
||||
{authenticatedProviders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
Authenticated
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{authenticatedProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center">
|
||||
<Cloud className="w-4 h-4 text-emerald-500" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-emerald-400">
|
||||
{getProviderDisplay(provider)}
|
||||
</span>
|
||||
</div>
|
||||
{provider.authMethod && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
|
||||
>
|
||||
{provider.authMethod === 'oauth' ? 'OAuth' : 'API Key'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available but Not Authenticated */}
|
||||
{unauthenticatedProviders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-muted-foreground" />
|
||||
Available
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{unauthenticatedProviders.slice(0, 5).map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
|
||||
<Cloud className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{getProviderDisplay(provider)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground/70">Not authenticated</span>
|
||||
</div>
|
||||
))}
|
||||
{unauthenticatedProviders.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground/70 text-center py-1">
|
||||
+{unauthenticatedProviders.length - 5} more providers available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<div className="pt-2 border-t border-border/30">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Use <code className="font-mono bg-accent/50 px-1 rounded">opencode /connect</code>{' '}
|
||||
to add new providers like GitHub Copilot, Google AI, or local models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +1,13 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
OpencodeCliStatus,
|
||||
OpencodeCliStatusSkeleton,
|
||||
OpencodeModelConfigSkeleton,
|
||||
} from '../cli-status/opencode-cli-status';
|
||||
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
||||
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||
import type { OpencodeModelId } from '@automaker/types';
|
||||
import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status';
|
||||
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
|
||||
|
||||
const logger = createLogger('OpencodeSettings');
|
||||
|
||||
@@ -21,15 +17,21 @@ export function OpencodeSettingsTab() {
|
||||
opencodeDefaultModel,
|
||||
setOpencodeDefaultModel,
|
||||
toggleOpencodeModel,
|
||||
setDynamicOpencodeModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledDynamicModelIds,
|
||||
toggleDynamicModel,
|
||||
cachedOpencodeProviders,
|
||||
setCachedOpencodeProviders,
|
||||
} = useAppStore();
|
||||
|
||||
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
|
||||
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
||||
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Load OpenCode CLI status on mount
|
||||
// Phase 1: Load CLI status quickly on mount
|
||||
useEffect(() => {
|
||||
const checkOpencodeStatus = async () => {
|
||||
setIsCheckingOpencodeCli(true);
|
||||
@@ -46,7 +48,6 @@ export function OpencodeSettingsTab() {
|
||||
recommendation: result.recommendation,
|
||||
installCommands: result.installCommands,
|
||||
});
|
||||
// Set auth status if available
|
||||
if (result.auth) {
|
||||
setAuthStatus({
|
||||
authenticated: result.auth.authenticated,
|
||||
@@ -57,7 +58,6 @@ export function OpencodeSettingsTab() {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback for web mode or when API is not available
|
||||
setCliStatus({
|
||||
success: false,
|
||||
status: 'not_installed',
|
||||
@@ -73,14 +73,54 @@ export function OpencodeSettingsTab() {
|
||||
});
|
||||
} finally {
|
||||
setIsCheckingOpencodeCli(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
checkOpencodeStatus();
|
||||
}, []);
|
||||
|
||||
// Phase 2: Load dynamic models and providers in background (only if not cached)
|
||||
useEffect(() => {
|
||||
const loadDynamicContent = async () => {
|
||||
const api = getElectronAPI();
|
||||
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
||||
|
||||
if (!isInstalled || !api?.setup) return;
|
||||
|
||||
// Skip if already have cached data
|
||||
const needsProviders = cachedOpencodeProviders.length === 0;
|
||||
const needsModels = dynamicOpencodeModels.length === 0;
|
||||
|
||||
if (!needsProviders && !needsModels) return;
|
||||
|
||||
setIsLoadingDynamicModels(true);
|
||||
try {
|
||||
// Load providers if needed
|
||||
if (needsProviders && api.setup.getOpencodeProviders) {
|
||||
const providersResult = await api.setup.getOpencodeProviders();
|
||||
if (providersResult.success && providersResult.providers) {
|
||||
setCachedOpencodeProviders(providersResult.providers);
|
||||
}
|
||||
}
|
||||
|
||||
// Load models if needed
|
||||
if (needsModels && api.setup.getOpencodeModels) {
|
||||
const modelsResult = await api.setup.getOpencodeModels();
|
||||
if (modelsResult.success && modelsResult.models) {
|
||||
setDynamicOpencodeModels(modelsResult.models);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load dynamic content:', error);
|
||||
} finally {
|
||||
setIsLoadingDynamicModels(false);
|
||||
}
|
||||
};
|
||||
loadDynamicContent();
|
||||
}, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleRefreshOpencodeCli = useCallback(async () => {
|
||||
setIsCheckingOpencodeCli(true);
|
||||
setIsLoadingDynamicModels(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.setup?.getOpencodeStatus) {
|
||||
@@ -94,7 +134,6 @@ export function OpencodeSettingsTab() {
|
||||
recommendation: result.recommendation,
|
||||
installCommands: result.installCommands,
|
||||
});
|
||||
// Update auth status if available
|
||||
if (result.auth) {
|
||||
setAuthStatus({
|
||||
authenticated: result.auth.authenticated,
|
||||
@@ -104,14 +143,35 @@ export function OpencodeSettingsTab() {
|
||||
hasOAuthToken: result.auth.hasOAuthToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.installed) {
|
||||
// Refresh providers
|
||||
if (api?.setup?.getOpencodeProviders) {
|
||||
const providersResult = await api.setup.getOpencodeProviders();
|
||||
if (providersResult.success && providersResult.providers) {
|
||||
setCachedOpencodeProviders(providersResult.providers);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh dynamic models
|
||||
if (api?.setup?.refreshOpencodeModels) {
|
||||
const modelsResult = await api.setup.refreshOpencodeModels();
|
||||
if (modelsResult.success && modelsResult.models) {
|
||||
setDynamicOpencodeModels(modelsResult.models);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('OpenCode CLI refreshed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh OpenCode CLI status:', error);
|
||||
toast.error('Failed to refresh OpenCode CLI status');
|
||||
} finally {
|
||||
setIsCheckingOpencodeCli(false);
|
||||
setIsLoadingDynamicModels(false);
|
||||
}
|
||||
}, []);
|
||||
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
|
||||
|
||||
const handleDefaultModelChange = useCallback(
|
||||
(model: OpencodeModelId) => {
|
||||
@@ -142,12 +202,25 @@ export function OpencodeSettingsTab() {
|
||||
[toggleOpencodeModel]
|
||||
);
|
||||
|
||||
// Show loading skeleton during initial load
|
||||
if (isInitialLoading) {
|
||||
const handleDynamicModelToggle = useCallback(
|
||||
(modelId: string, enabled: boolean) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
toggleDynamicModel(modelId, enabled);
|
||||
} catch (error) {
|
||||
toast.error('Failed to update dynamic model');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[toggleDynamicModel]
|
||||
);
|
||||
|
||||
// Show skeleton only while checking CLI status initially
|
||||
if (!cliStatus && isCheckingOpencodeCli) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<OpencodeCliStatusSkeleton />
|
||||
<OpencodeModelConfigSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +232,7 @@ export function OpencodeSettingsTab() {
|
||||
<OpencodeCliStatus
|
||||
status={cliStatus}
|
||||
authStatus={authStatus}
|
||||
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
||||
isChecking={isCheckingOpencodeCli}
|
||||
onRefresh={handleRefreshOpencodeCli}
|
||||
/>
|
||||
@@ -171,6 +245,10 @@ export function OpencodeSettingsTab() {
|
||||
isSaving={isSaving}
|
||||
onDefaultModelChange={handleDefaultModelChange}
|
||||
onModelToggle={handleModelToggle}
|
||||
dynamicModels={dynamicOpencodeModels}
|
||||
enabledDynamicModelIds={enabledDynamicModelIds}
|
||||
onDynamicModelToggle={handleDynamicModelToggle}
|
||||
isLoadingDynamicModels={isLoadingDynamicModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
@@ -583,8 +584,16 @@ export interface AppState {
|
||||
codexEnableImages: boolean; // Enable image processing
|
||||
|
||||
// OpenCode CLI Settings (global)
|
||||
enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal
|
||||
enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available
|
||||
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
|
||||
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
|
||||
enabledDynamicModelIds: string[]; // Which dynamic models are enabled (model IDs)
|
||||
cachedOpencodeProviders: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
authMethod?: string;
|
||||
}>; // Cached providers
|
||||
|
||||
// Claude Agent SDK Settings
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
@@ -988,6 +997,12 @@ export interface AppActions {
|
||||
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
|
||||
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
|
||||
setEnabledDynamicModelIds: (ids: string[]) => void;
|
||||
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
|
||||
setCachedOpencodeProviders: (
|
||||
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
||||
) => void;
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
@@ -1220,6 +1235,9 @@ const initialState: AppState = {
|
||||
codexEnableImages: false, // Default to disabled
|
||||
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
|
||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5
|
||||
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
|
||||
enabledDynamicModelIds: [], // All dynamic models enabled by default (populated when models are fetched)
|
||||
cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
@@ -2017,6 +2035,27 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
? [...state.enabledOpencodeModels, model]
|
||||
: state.enabledOpencodeModels.filter((m) => m !== model),
|
||||
})),
|
||||
setDynamicOpencodeModels: (models) => {
|
||||
// When setting dynamic models, auto-enable all of them if enabledDynamicModelIds is empty
|
||||
const currentEnabled = get().enabledDynamicModelIds;
|
||||
const newModelIds = models.map((m) => m.id);
|
||||
|
||||
// If no models were previously enabled, enable all new ones
|
||||
if (currentEnabled.length === 0) {
|
||||
set({ dynamicOpencodeModels: models, enabledDynamicModelIds: newModelIds });
|
||||
} else {
|
||||
// Keep existing enabled state, just update the models list
|
||||
set({ dynamicOpencodeModels: models });
|
||||
}
|
||||
},
|
||||
setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }),
|
||||
toggleDynamicModel: (modelId, enabled) =>
|
||||
set((state) => ({
|
||||
enabledDynamicModelIds: enabled
|
||||
? [...state.enabledDynamicModelIds, modelId]
|
||||
: state.enabledDynamicModelIds.filter((id) => id !== modelId),
|
||||
})),
|
||||
setCachedOpencodeProviders: (providers) => set({ cachedOpencodeProviders: providers }),
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: async (enabled) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user