mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +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 {
|
import {
|
||||||
CLAUDE_MODEL_MAP,
|
CLAUDE_MODEL_MAP,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
|
isOpencodeModel,
|
||||||
stripProviderPrefix,
|
stripProviderPrefix,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
getThinkingTokenBudget,
|
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 prompt - The enhancement prompt
|
||||||
* @param model - The Cursor model to use
|
* @param model - The model to use
|
||||||
* @returns The enhanced text
|
* @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);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
// Strip provider prefix - providers expect bare model IDs
|
||||||
const bareModel = stripProviderPrefix(model);
|
const bareModel = stripProviderPrefix(model);
|
||||||
@@ -212,7 +213,14 @@ export function createEnhanceHandler(
|
|||||||
|
|
||||||
// Cursor doesn't have a separate system prompt concept, so combine them
|
// Cursor doesn't have a separate system prompt concept, so combine them
|
||||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
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 {
|
} else {
|
||||||
// Use Claude SDK for Claude models
|
// Use Claude SDK for Claude models
|
||||||
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
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 { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||||
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||||
|
import {
|
||||||
|
createGetOpencodeModelsHandler,
|
||||||
|
createRefreshOpencodeModelsHandler,
|
||||||
|
createGetOpencodeProvidersHandler,
|
||||||
|
createClearOpencodeCacheHandler,
|
||||||
|
} from './routes/opencode-models.js';
|
||||||
import {
|
import {
|
||||||
createGetCursorConfigHandler,
|
createGetCursorConfigHandler,
|
||||||
createSetCursorDefaultModelHandler,
|
createSetCursorDefaultModelHandler,
|
||||||
@@ -65,6 +71,12 @@ export function createSetupRoutes(): Router {
|
|||||||
router.get('/opencode-status', createOpencodeStatusHandler());
|
router.get('/opencode-status', createOpencodeStatusHandler());
|
||||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
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.get('/cursor-config', createGetCursorConfigHandler());
|
||||||
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
||||||
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
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 { 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 { cn } from '@/lib/utils';
|
||||||
import type { CliStatus } from '../shared/types';
|
import type { CliStatus } from '../shared/types';
|
||||||
import { OpenCodeIcon } from '@/components/ui/provider-icon';
|
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 =
|
export type OpencodeAuthMethod =
|
||||||
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
|
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
|
||||||
| 'api_key' // Manually stored API key
|
| 'api_key' // Manually stored API key
|
||||||
@@ -38,6 +69,7 @@ function getAuthMethodLabel(method: OpencodeAuthMethod): string {
|
|||||||
interface OpencodeCliStatusProps {
|
interface OpencodeCliStatusProps {
|
||||||
status: CliStatus | null;
|
status: CliStatus | null;
|
||||||
authStatus?: OpencodeAuthStatus | null;
|
authStatus?: OpencodeAuthStatus | null;
|
||||||
|
providers?: OpenCodeProviderInfo[];
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -152,9 +184,11 @@ export function OpencodeModelConfigSkeleton() {
|
|||||||
export function OpencodeCliStatus({
|
export function OpencodeCliStatus({
|
||||||
status,
|
status,
|
||||||
authStatus,
|
authStatus,
|
||||||
|
providers = [],
|
||||||
isChecking,
|
isChecking,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: OpencodeCliStatusProps) {
|
}: OpencodeCliStatusProps) {
|
||||||
|
const authenticatedProviders = providers.filter((p) => p.authenticated);
|
||||||
if (!status) return <OpencodeCliStatusSkeleton />;
|
if (!status) return <OpencodeCliStatusSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -223,8 +257,8 @@ export function OpencodeCliStatus({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Authentication Status */}
|
{/* Authentication Status - consider both direct auth and provider auth */}
|
||||||
{authStatus?.authenticated ? (
|
{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="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">
|
<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" />
|
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||||
@@ -232,24 +266,71 @@ export function OpencodeCliStatus({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
||||||
<div className="text-xs text-emerald-400/70 mt-1.5">
|
<div className="text-xs text-emerald-400/70 mt-1.5">
|
||||||
<p>
|
{authStatus?.authenticated && authStatus.method !== 'none' ? (
|
||||||
Method:{' '}
|
<p>
|
||||||
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
|
Method:{' '}
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
<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-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
<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">
|
||||||
<XCircle className="w-5 h-5 text-amber-500" />
|
<Bot className="w-5 h-5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
|
<p className="text-sm font-medium text-blue-400">OpenCode Free Tier Ready</p>
|
||||||
<p className="text-xs text-amber-400/70 mt-1">
|
<p className="text-xs text-blue-400/70 mt-1">
|
||||||
Run{' '}
|
Free OpenCode models work without login. Run{' '}
|
||||||
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
|
<code className="font-mono bg-blue-500/10 px-1 rounded">
|
||||||
set an API key to authenticate.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,18 +25,13 @@ import {
|
|||||||
THINKING_LEVEL_LABELS,
|
THINKING_LEVEL_LABELS,
|
||||||
REASONING_EFFORT_LEVELS,
|
REASONING_EFFORT_LEVELS,
|
||||||
REASONING_EFFORT_LABELS,
|
REASONING_EFFORT_LABELS,
|
||||||
|
type ModelOption,
|
||||||
} from '@/components/views/board-view/shared/model-constants';
|
} from '@/components/views/board-view/shared/model-constants';
|
||||||
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AnthropicIcon,
|
AnthropicIcon,
|
||||||
CursorIcon,
|
CursorIcon,
|
||||||
OpenAIIcon,
|
OpenAIIcon,
|
||||||
OpenCodeIcon,
|
|
||||||
DeepSeekIcon,
|
|
||||||
NovaIcon,
|
|
||||||
QwenIcon,
|
|
||||||
MistralIcon,
|
|
||||||
MetaIcon,
|
|
||||||
getProviderIconForModel,
|
getProviderIconForModel,
|
||||||
} from '@/components/ui/provider-icon';
|
} from '@/components/ui/provider-icon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -51,6 +46,80 @@ import {
|
|||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
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 {
|
interface PhaseModelSelectorProps {
|
||||||
/** Label shown in full mode */
|
/** Label shown in full mode */
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -95,6 +164,7 @@ export function PhaseModelSelector({
|
|||||||
codexModels,
|
codexModels,
|
||||||
codexModelsLoading,
|
codexModelsLoading,
|
||||||
fetchCodexModels,
|
fetchCodexModels,
|
||||||
|
dynamicOpencodeModels,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Extract model and thinking/reasoning levels from value
|
// Extract model and thinking/reasoning levels from value
|
||||||
@@ -235,12 +305,30 @@ export function PhaseModelSelector({
|
|||||||
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
||||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
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);
|
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;
|
return null;
|
||||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
|
}, [
|
||||||
|
selectedModel,
|
||||||
|
selectedThinkingLevel,
|
||||||
|
availableCursorModels,
|
||||||
|
transformedCodexModels,
|
||||||
|
dynamicOpencodeModels,
|
||||||
|
]);
|
||||||
|
|
||||||
// Compute grouped vs standalone Cursor models
|
// Compute grouped vs standalone Cursor models
|
||||||
const { groupedModels, standaloneCursorModels } = useMemo(() => {
|
const { groupedModels, standaloneCursorModels } = useMemo(() => {
|
||||||
@@ -275,13 +363,35 @@ export function PhaseModelSelector({
|
|||||||
return { groupedModels: grouped, standaloneCursorModels: standalone };
|
return { groupedModels: grouped, standaloneCursorModels: standalone };
|
||||||
}, [availableCursorModels, enabledCursorModels]);
|
}, [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
|
// Group models
|
||||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||||
const favs: typeof CLAUDE_MODELS = [];
|
const favs: typeof CLAUDE_MODELS = [];
|
||||||
const cModels: typeof CLAUDE_MODELS = [];
|
const cModels: typeof CLAUDE_MODELS = [];
|
||||||
const curModels: typeof CURSOR_MODELS = [];
|
const curModels: typeof CURSOR_MODELS = [];
|
||||||
const codModels: typeof transformedCodexModels = [];
|
const codModels: typeof transformedCodexModels = [];
|
||||||
const ocModels: typeof OPENCODE_MODELS = [];
|
const ocModels: ModelOption[] = [];
|
||||||
|
|
||||||
// Process Claude Models
|
// Process Claude Models
|
||||||
CLAUDE_MODELS.forEach((model) => {
|
CLAUDE_MODELS.forEach((model) => {
|
||||||
@@ -310,8 +420,8 @@ export function PhaseModelSelector({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process OpenCode Models
|
// Process OpenCode Models (including dynamic)
|
||||||
OPENCODE_MODELS.forEach((model) => {
|
allOpencodeModels.forEach((model) => {
|
||||||
if (favoriteModels.includes(model.id)) {
|
if (favoriteModels.includes(model.id)) {
|
||||||
favs.push(model);
|
favs.push(model);
|
||||||
} else {
|
} else {
|
||||||
@@ -326,7 +436,95 @@ export function PhaseModelSelector({
|
|||||||
codex: codModels,
|
codex: codModels,
|
||||||
opencode: ocModels,
|
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)
|
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
|
||||||
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
|
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
|
||||||
@@ -992,9 +1190,32 @@ export function PhaseModelSelector({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{opencode.length > 0 && (
|
{opencodeSections.length > 0 && (
|
||||||
<CommandGroup heading="OpenCode Models">
|
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
|
||||||
{opencode.map((model) => renderOpencodeModelItem(model))}
|
{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>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
</CommandList>
|
</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 { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -8,20 +9,32 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
|
||||||
|
import type { OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
|
||||||
import {
|
import {
|
||||||
OpenCodeIcon,
|
OpenCodeIcon,
|
||||||
DeepSeekIcon,
|
DeepSeekIcon,
|
||||||
QwenIcon,
|
QwenIcon,
|
||||||
NovaIcon,
|
NovaIcon,
|
||||||
AnthropicIcon,
|
AnthropicIcon,
|
||||||
|
OpenRouterIcon,
|
||||||
MistralIcon,
|
MistralIcon,
|
||||||
MetaIcon,
|
MetaIcon,
|
||||||
|
GeminiIcon,
|
||||||
|
OpenAIIcon,
|
||||||
|
GrokIcon,
|
||||||
getProviderIconForModel,
|
getProviderIconForModel,
|
||||||
} from '@/components/ui/provider-icon';
|
} from '@/components/ui/provider-icon';
|
||||||
import type { ComponentType } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||||
|
|
||||||
interface OpencodeModelConfigurationProps {
|
interface OpencodeModelConfigurationProps {
|
||||||
enabledOpencodeModels: OpencodeModelId[];
|
enabledOpencodeModels: OpencodeModelId[];
|
||||||
@@ -29,6 +42,12 @@ interface OpencodeModelConfigurationProps {
|
|||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onDefaultModelChange: (model: OpencodeModelId) => void;
|
onDefaultModelChange: (model: OpencodeModelId) => void;
|
||||||
onModelToggle: (model: OpencodeModelId, enabled: boolean) => 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) {
|
switch (provider) {
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
return 'OpenCode (Free)';
|
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:
|
default:
|
||||||
return provider;
|
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({
|
export function OpencodeModelConfiguration({
|
||||||
enabledOpencodeModels,
|
enabledOpencodeModels,
|
||||||
opencodeDefaultModel,
|
opencodeDefaultModel,
|
||||||
isSaving,
|
isSaving,
|
||||||
onDefaultModelChange,
|
onDefaultModelChange,
|
||||||
onModelToggle,
|
onModelToggle,
|
||||||
|
providers,
|
||||||
|
dynamicModels,
|
||||||
|
enabledDynamicModelIds,
|
||||||
|
onDynamicModelToggle,
|
||||||
|
isLoadingDynamicModels = false,
|
||||||
}: OpencodeModelConfigurationProps) {
|
}: OpencodeModelConfigurationProps) {
|
||||||
// Group models by provider for organized display
|
// Group static models by provider for organized display
|
||||||
const modelsByProvider = OPENCODE_MODELS.reduce(
|
const modelsByProvider = OPENCODE_MODELS.reduce(
|
||||||
(acc, model) => {
|
(acc, model) => {
|
||||||
if (!acc[model.provider]) {
|
if (!acc[model.provider]) {
|
||||||
@@ -81,17 +213,141 @@ export function OpencodeModelConfiguration({
|
|||||||
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
|
{} 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
|
// Order: Free tier first, then Claude, then others
|
||||||
const providerOrder: OpencodeProvider[] = [
|
const providerOrder: OpencodeProvider[] = ['opencode'];
|
||||||
'opencode',
|
|
||||||
'amazon-bedrock-anthropic',
|
// Dynamic provider order (prioritize commonly used ones)
|
||||||
'amazon-bedrock-deepseek',
|
const dynamicProviderOrder = [
|
||||||
'amazon-bedrock-amazon',
|
'github-copilot',
|
||||||
'amazon-bedrock-meta',
|
'google',
|
||||||
'amazon-bedrock-mistral',
|
'openai',
|
||||||
'amazon-bedrock-qwen',
|
'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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -146,7 +402,21 @@ export function OpencodeModelConfiguration({
|
|||||||
|
|
||||||
{/* Available Models grouped by provider */}
|
{/* Available Models grouped by provider */}
|
||||||
<div className="space-y-4">
|
<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) => {
|
{providerOrder.map((provider) => {
|
||||||
const models = modelsByProvider[provider];
|
const models = modelsByProvider[provider];
|
||||||
if (!models || models.length === 0) return null;
|
if (!models || models.length === 0) return null;
|
||||||
@@ -217,6 +487,196 @@ export function OpencodeModelConfiguration({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import {
|
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
||||||
OpencodeCliStatus,
|
|
||||||
OpencodeCliStatusSkeleton,
|
|
||||||
OpencodeModelConfigSkeleton,
|
|
||||||
} from '../cli-status/opencode-cli-status';
|
|
||||||
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||||
import type { OpencodeModelId } from '@automaker/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 logger = createLogger('OpencodeSettings');
|
||||||
|
|
||||||
@@ -21,15 +17,21 @@ export function OpencodeSettingsTab() {
|
|||||||
opencodeDefaultModel,
|
opencodeDefaultModel,
|
||||||
setOpencodeDefaultModel,
|
setOpencodeDefaultModel,
|
||||||
toggleOpencodeModel,
|
toggleOpencodeModel,
|
||||||
|
setDynamicOpencodeModels,
|
||||||
|
dynamicOpencodeModels,
|
||||||
|
enabledDynamicModelIds,
|
||||||
|
toggleDynamicModel,
|
||||||
|
cachedOpencodeProviders,
|
||||||
|
setCachedOpencodeProviders,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
|
||||||
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
||||||
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
// Load OpenCode CLI status on mount
|
// Phase 1: Load CLI status quickly on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkOpencodeStatus = async () => {
|
const checkOpencodeStatus = async () => {
|
||||||
setIsCheckingOpencodeCli(true);
|
setIsCheckingOpencodeCli(true);
|
||||||
@@ -46,7 +48,6 @@ export function OpencodeSettingsTab() {
|
|||||||
recommendation: result.recommendation,
|
recommendation: result.recommendation,
|
||||||
installCommands: result.installCommands,
|
installCommands: result.installCommands,
|
||||||
});
|
});
|
||||||
// Set auth status if available
|
|
||||||
if (result.auth) {
|
if (result.auth) {
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
authenticated: result.auth.authenticated,
|
authenticated: result.auth.authenticated,
|
||||||
@@ -57,7 +58,6 @@ export function OpencodeSettingsTab() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback for web mode or when API is not available
|
|
||||||
setCliStatus({
|
setCliStatus({
|
||||||
success: false,
|
success: false,
|
||||||
status: 'not_installed',
|
status: 'not_installed',
|
||||||
@@ -73,14 +73,54 @@ export function OpencodeSettingsTab() {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingOpencodeCli(false);
|
setIsCheckingOpencodeCli(false);
|
||||||
setIsInitialLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkOpencodeStatus();
|
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 () => {
|
const handleRefreshOpencodeCli = useCallback(async () => {
|
||||||
setIsCheckingOpencodeCli(true);
|
setIsCheckingOpencodeCli(true);
|
||||||
|
setIsLoadingDynamicModels(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api?.setup?.getOpencodeStatus) {
|
if (api?.setup?.getOpencodeStatus) {
|
||||||
@@ -94,7 +134,6 @@ export function OpencodeSettingsTab() {
|
|||||||
recommendation: result.recommendation,
|
recommendation: result.recommendation,
|
||||||
installCommands: result.installCommands,
|
installCommands: result.installCommands,
|
||||||
});
|
});
|
||||||
// Update auth status if available
|
|
||||||
if (result.auth) {
|
if (result.auth) {
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
authenticated: result.auth.authenticated,
|
authenticated: result.auth.authenticated,
|
||||||
@@ -104,14 +143,35 @@ export function OpencodeSettingsTab() {
|
|||||||
hasOAuthToken: result.auth.hasOAuthToken,
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to refresh OpenCode CLI status:', error);
|
logger.error('Failed to refresh OpenCode CLI status:', error);
|
||||||
toast.error('Failed to refresh OpenCode CLI status');
|
toast.error('Failed to refresh OpenCode CLI status');
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingOpencodeCli(false);
|
setIsCheckingOpencodeCli(false);
|
||||||
|
setIsLoadingDynamicModels(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
|
||||||
|
|
||||||
const handleDefaultModelChange = useCallback(
|
const handleDefaultModelChange = useCallback(
|
||||||
(model: OpencodeModelId) => {
|
(model: OpencodeModelId) => {
|
||||||
@@ -142,12 +202,25 @@ export function OpencodeSettingsTab() {
|
|||||||
[toggleOpencodeModel]
|
[toggleOpencodeModel]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show loading skeleton during initial load
|
const handleDynamicModelToggle = useCallback(
|
||||||
if (isInitialLoading) {
|
(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<OpencodeCliStatusSkeleton />
|
<OpencodeCliStatusSkeleton />
|
||||||
<OpencodeModelConfigSkeleton />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,6 +232,7 @@ export function OpencodeSettingsTab() {
|
|||||||
<OpencodeCliStatus
|
<OpencodeCliStatus
|
||||||
status={cliStatus}
|
status={cliStatus}
|
||||||
authStatus={authStatus}
|
authStatus={authStatus}
|
||||||
|
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
||||||
isChecking={isCheckingOpencodeCli}
|
isChecking={isCheckingOpencodeCli}
|
||||||
onRefresh={handleRefreshOpencodeCli}
|
onRefresh={handleRefreshOpencodeCli}
|
||||||
/>
|
/>
|
||||||
@@ -171,6 +245,10 @@ export function OpencodeSettingsTab() {
|
|||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onDefaultModelChange={handleDefaultModelChange}
|
onDefaultModelChange={handleDefaultModelChange}
|
||||||
onModelToggle={handleModelToggle}
|
onModelToggle={handleModelToggle}
|
||||||
|
dynamicModels={dynamicOpencodeModels}
|
||||||
|
enabledDynamicModelIds={enabledDynamicModelIds}
|
||||||
|
onDynamicModelToggle={handleDynamicModelToggle}
|
||||||
|
isLoadingDynamicModels={isLoadingDynamicModels}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1440,6 +1440,67 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get('/api/setup/opencode-status'),
|
}> => 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) => {
|
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||||
return this.subscribeToEvent('agent:stream', callback);
|
return this.subscribeToEvent('agent:stream', callback);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
PipelineConfig,
|
PipelineConfig,
|
||||||
PipelineStep,
|
PipelineStep,
|
||||||
PromptCustomization,
|
PromptCustomization,
|
||||||
|
ModelDefinition,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
@@ -583,8 +584,16 @@ export interface AppState {
|
|||||||
codexEnableImages: boolean; // Enable image processing
|
codexEnableImages: boolean; // Enable image processing
|
||||||
|
|
||||||
// OpenCode CLI Settings (global)
|
// 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
|
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
|
// Claude Agent SDK Settings
|
||||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||||
@@ -988,6 +997,12 @@ export interface AppActions {
|
|||||||
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||||
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
||||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => 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
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||||
@@ -1220,6 +1235,9 @@ const initialState: AppState = {
|
|||||||
codexEnableImages: false, // Default to disabled
|
codexEnableImages: false, // Default to disabled
|
||||||
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
|
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
|
||||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5
|
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)
|
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||||
mcpServers: [], // No MCP servers configured by default
|
mcpServers: [], // No MCP servers configured by default
|
||||||
@@ -2017,6 +2035,27 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
? [...state.enabledOpencodeModels, model]
|
? [...state.enabledOpencodeModels, model]
|
||||||
: state.enabledOpencodeModels.filter((m) => m !== 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
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: async (enabled) => {
|
setAutoLoadClaudeMd: async (enabled) => {
|
||||||
|
|||||||
@@ -69,8 +69,12 @@ export function resolveModelString(
|
|||||||
return modelKey;
|
return modelKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode model - pass through unchanged
|
// OpenCode model (static or dynamic) - pass through unchanged
|
||||||
// Supports: opencode/big-pickle, opencode-sonnet, amazon-bedrock/anthropic.claude-*
|
// 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)) {
|
if (isOpencodeModel(modelKey)) {
|
||||||
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
|
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
|
||||||
return modelKey;
|
return modelKey;
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ export function isCodexModel(model: string | undefined | null): boolean {
|
|||||||
* - 'opencode/' prefix (OpenCode free tier models)
|
* - 'opencode/' prefix (OpenCode free tier models)
|
||||||
* - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode)
|
* - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode)
|
||||||
* - Full model ID from OPENCODE_MODEL_CONFIG_MAP
|
* - 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
|
* @returns true if the model is an OpenCode model
|
||||||
*/
|
*/
|
||||||
export function isOpencodeModel(model: string | undefined | null): boolean {
|
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
|
// Check for OpenCode native model prefixes
|
||||||
// - opencode/ = OpenCode free tier models (e.g., opencode/big-pickle)
|
// - opencode/ = OpenCode free tier models
|
||||||
// - amazon-bedrock/ = AWS Bedrock models (e.g., amazon-bedrock/anthropic.claude-*)
|
// - amazon-bedrock/ = AWS Bedrock models
|
||||||
if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) {
|
if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) {
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user