Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
872 changes: 695 additions & 177 deletions apps/server/src/providers/opencode-provider.ts

Large diffs are not rendered by default.

22 changes: 17 additions & 5 deletions apps/server/src/routes/enhance-prompt/routes/enhance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
import {
CLAUDE_MODEL_MAP,
isCursorModel,
isOpencodeModel,
stripProviderPrefix,
ThinkingLevel,
getThinkingTokenBudget,
Expand Down Expand Up @@ -91,13 +92,13 @@ async function extractTextFromStream(
}

/**
* Execute enhancement using Cursor provider
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
*
* @param prompt - The enhancement prompt
* @param model - The Cursor model to use
* @param model - The model to use
* @returns The enhanced text
*/
async function executeWithCursor(prompt: string, model: string): Promise<string> {
async function executeWithProvider(prompt: string, model: string): Promise<string> {
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
Expand All @@ -110,7 +111,11 @@ async function executeWithCursor(prompt: string, model: string): Promise<string>
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
readOnly: true, // Prompt enhancement only generates text, doesn't write files
})) {
if (msg.type === 'assistant' && msg.message?.content) {
if (msg.type === 'error') {
// Throw error with the message from the provider
const errorMessage = msg.error || 'Provider returned an error';
throw new Error(errorMessage);
} else if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
Expand Down Expand Up @@ -212,7 +217,14 @@ export function createEnhanceHandler(

// Cursor doesn't have a separate system prompt concept, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else if (isOpencodeModel(resolvedModel)) {
// Use OpenCode provider for OpenCode models (static and dynamic)
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);

// OpenCode CLI handles the system prompt, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for model: ${resolvedModel}`);
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/routes/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
createGetOpencodeProvidersHandler,
createClearOpencodeCacheHandler,
} from './routes/opencode-models.js';
import {
createGetCursorConfigHandler,
createSetCursorDefaultModelHandler,
Expand Down Expand Up @@ -65,6 +71,12 @@ export function createSetupRoutes(): Router {
router.get('/opencode-status', createOpencodeStatusHandler());
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());

// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
router.get('/opencode/providers', createGetOpencodeProvidersHandler());
router.post('/opencode/cache/clear', createClearOpencodeCacheHandler());
router.get('/cursor-config', createGetCursorConfigHandler());
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
router.post('/cursor-config/models', createSetCursorModelsHandler());
Expand Down
189 changes: 189 additions & 0 deletions apps/server/src/routes/setup/routes/opencode-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* OpenCode Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/opencode/models - Get available models (cached or refreshed)
* - POST /api/setup/opencode/models/refresh - Force refresh models from CLI
* - GET /api/setup/opencode/providers - Get authenticated providers
*/

import type { Request, Response } from 'express';
import {
OpencodeProvider,
type OpenCodeProviderInfo,
} from '../../../providers/opencode-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';

const logger = createLogger('OpenCodeModelsRoute');

// Singleton provider instance for caching
let providerInstance: OpencodeProvider | null = null;

function getProvider(): OpencodeProvider {
if (!providerInstance) {
providerInstance = new OpencodeProvider();
}
return providerInstance;
}

/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}

/**
* Response type for providers endpoint
*/
interface ProvidersResponse {
success: boolean;
providers?: OpenCodeProviderInfo[];
authenticated?: OpenCodeProviderInfo[];
error?: string;
}

/**
* Creates handler for GET /api/setup/opencode/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetOpencodeModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';

let models: ModelDefinition[];
let cached = true;

if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
const cachedModels = provider.getAvailableModels();

// If cache only has default models (provider.hasCachedModels() would be false),
// trigger a refresh to get dynamic models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = cachedModels;
}
}

const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};

res.json(response);
} catch (error) {
logError(error, 'Get OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}

/**
* Creates handler for POST /api/setup/opencode/models/refresh
*
* Forces a refresh of models from the OpenCode CLI.
*/
export function createRefreshOpencodeModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();

const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};

res.json(response);
} catch (error) {
logError(error, 'Refresh OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}

/**
* Creates handler for GET /api/setup/opencode/providers
*
* Returns authenticated providers from OpenCode CLI.
* This calls `opencode auth list` to get provider status.
*/
export function createGetOpencodeProvidersHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const providers = await provider.fetchAuthenticatedProviders();

// Filter to only authenticated providers
const authenticated = providers.filter((p) => p.authenticated);

const response: ProvidersResponse = {
success: true,
providers,
authenticated,
};

res.json(response);
} catch (error) {
logError(error, 'Get OpenCode providers failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ProvidersResponse);
}
};
}
Comment on lines +140 to +164
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find UI client/consumers of /api/setup/opencode/providers and confirm expected response shape.
rg -n "/api/setup/opencode/providers|getOpencodeProviders" -S apps/ui libs || true

Repository: AutoMaker-Org/automaker

Length of output: 833


🏁 Script executed:

#!/bin/bash
# Check the expected response type in the API client
sed -n '1470,1500p' apps/ui/src/lib/http-api-client.ts

Repository: AutoMaker-Org/automaker

Length of output: 910


🏁 Script executed:

#!/bin/bash
# Check how the providersResult is used in the component
sed -n '90,160p' apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx

Repository: AutoMaker-Org/automaker

Length of output: 2856


Remove unused authenticated field from response.
The UI only consumes the providers field (lines 99, 150 of opencode-settings-tab.tsx) and never accesses authenticated. This redundant field should be removed from the response to avoid sending unnecessary data.

🤖 Prompt for AI Agents
In @apps/server/src/routes/setup/routes/opencode-models.ts around lines 140 -
164, The handler createGetOpencodeProvidersHandler builds and returns an
unnecessary authenticated field; remove the authenticated variable and the
authenticated property from the response object so the JSON only contains
success and providers, and delete the filtering call providers.filter((p) =>
p.authenticated); also update the ProvidersResponse type/interface (if defined)
to remove the authenticated field so the response shape matches what the UI
expects.


/**
* 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),
});
}
};
}
85 changes: 39 additions & 46 deletions apps/server/tests/unit/providers/opencode-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
OpencodeProvider,
resetToolUseIdCounter,
} from '../../../src/providers/opencode-provider.js';
import type { ProviderMessage } from '@automaker/types';
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';

Expand Down Expand Up @@ -51,63 +51,38 @@ describe('opencode-provider.ts', () => {
});

describe('getAvailableModels', () => {
it('should return 10 models', () => {
it('should return 5 models', () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(10);
expect(models).toHaveLength(5);
});

it('should include Claude Sonnet 4.5 (Bedrock) as default', () => {
const models = provider.getAvailableModels();
const sonnet = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
);

expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)');
expect(sonnet?.provider).toBe('opencode');
expect(sonnet?.default).toBe(true);
expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
});

it('should include Claude Opus 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const opus = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
);

expect(opus).toBeDefined();
expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)');
expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0');
});

it('should include Claude Haiku 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const haiku = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
);

expect(haiku).toBeDefined();
expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)');
expect(haiku?.tier).toBe('standard');
});

it('should include free tier Big Pickle model', () => {
it('should include Big Pickle as default', () => {
const models = provider.getAvailableModels();
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');

expect(bigPickle).toBeDefined();
expect(bigPickle?.name).toBe('Big Pickle (Free)');
expect(bigPickle?.provider).toBe('opencode');
expect(bigPickle?.default).toBe(true);
expect(bigPickle?.modelString).toBe('opencode/big-pickle');
expect(bigPickle?.tier).toBe('basic');
});

it('should include DeepSeek R1 (Bedrock)', () => {
it('should include free tier GLM model', () => {
const models = provider.getAvailableModels();
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');

expect(glm).toBeDefined();
expect(glm?.name).toBe('GLM 4.7 Free');
expect(glm?.tier).toBe('basic');
});

it('should include free tier MiniMax model', () => {
const models = provider.getAvailableModels();
const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0');
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');

expect(deepseek).toBeDefined();
expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)');
expect(deepseek?.tier).toBe('premium');
expect(minimax).toBeDefined();
expect(minimax?.name).toBe('MiniMax M2.1 Free');
expect(minimax?.tier).toBe('basic');
});

it('should have all models support tools', () => {
Expand All @@ -128,6 +103,24 @@ describe('opencode-provider.ts', () => {
});
});

describe('parseModelsOutput', () => {
it('should parse nested provider model IDs', () => {
const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n');

const parseModelsOutput = (
provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] }
).parseModelsOutput.bind(provider);
const models = parseModelsOutput(output);

expect(models).toHaveLength(2);
const openrouterModel = models.find((model) => model.id.startsWith('openrouter/'));

expect(openrouterModel).toBeDefined();
expect(openrouterModel?.provider).toBe('openrouter');
expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet');
});
});

describe('supportsFeature', () => {
it("should support 'tools' feature", () => {
expect(provider.supportsFeature('tools')).toBe(true);
Expand Down Expand Up @@ -1243,7 +1236,7 @@ describe('opencode-provider.ts', () => {
const defaultModels = models.filter((m) => m.default === true);

expect(defaultModels).toHaveLength(1);
expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
expect(defaultModels[0].id).toBe('opencode/big-pickle');
});

it('should have valid tier values for all models', () => {
Expand Down
Loading
Loading