Skip to content

Commit f555101

Browse files
committed
feat: add OpenCode as an AI provider (#1684)
Adds OpenCode as a new CLI-backed provider, mirroring the existing claude-code, codex-cli, and gemini-cli integrations. Task Master delegates authentication, model routing, and provider selection to a running OpenCode server via ai-sdk-provider-opencode-sdk, staying agnostic about the underlying LLM. Unblocks users whose organisations permit OpenCode (including its GitHub Copilot backend) but prohibit direct LLM API credentials - the original ask in #965 that was closed against the rules-profile work in #970 rather than the provider request itself. - Add OpencodeProvider wrapping ai-sdk-provider-opencode-sdk - Register in provider index, ai-services-unified, and CUSTOM_PROVIDERS - Seed supported-models.json with representative OpenCode model IDs (provider/model format) across Anthropic, OpenAI, Copilot, Gemini - Add opencode settings section to config-manager with command-specific override support, matching the pattern used by other CLI providers - Unit tests mirroring codex-cli.test.js coverage
1 parent 487c3d3 commit f555101

10 files changed

Lines changed: 386 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"task-master-ai": minor
3+
---
4+
5+
Add OpenCode as an AI provider. Delegates authentication, model routing, and provider selection to a running OpenCode server via `ai-sdk-provider-opencode-sdk`, so Task Master stays agnostic about the underlying LLM. This unblocks enterprise users whose organisations permit OpenCode (including its GitHub Copilot backend) but prohibit direct LLM API credentials.

package-lock.json

Lines changed: 32 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"ai-sdk-provider-claude-code": "^2.2.4",
8686
"ai-sdk-provider-codex-cli": "^0.7.0",
8787
"ai-sdk-provider-gemini-cli": "^1.4.0",
88+
"ai-sdk-provider-opencode-sdk": "^0.0.2",
8889
"ajv": "^8.17.1",
8990
"ajv-formats": "^3.0.1",
9091
"beautiful-mermaid": "^0.1.3",

packages/tm-core/src/common/constants/providers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export const CUSTOM_PROVIDERS = {
3636
MCP: 'mcp',
3737
GEMINI_CLI: 'gemini-cli',
3838
GROK_CLI: 'grok-cli',
39-
CODEX_CLI: 'codex-cli'
39+
CODEX_CLI: 'codex-cli',
40+
OPENCODE: 'opencode'
4041
} as const;
4142

4243
export type CustomProvider =

scripts/modules/ai-services-unified.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
MiniMaxProvider,
5050
OpenAICompatibleProvider,
5151
OpenAIProvider,
52+
OpencodeProvider,
5253
OpenRouterAIProvider,
5354
PerplexityAIProvider,
5455
VertexAIProvider,
@@ -86,7 +87,8 @@ const PROVIDERS = {
8687
'claude-code': new ClaudeCodeProvider(),
8788
'codex-cli': new CodexCliProvider(),
8889
'gemini-cli': new GeminiCliProvider(),
89-
'grok-cli': new GrokCliProvider()
90+
'grok-cli': new GrokCliProvider(),
91+
opencode: new OpencodeProvider()
9092
};
9193

9294
function _getProvider(providerName) {

scripts/modules/config-manager.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const DEFAULTS = {
6262
},
6363
claudeCode: {},
6464
codexCli: {},
65+
opencode: {},
6566
grokCli: {
6667
timeout: 120000,
6768
workingDirectory: null,
@@ -161,6 +162,7 @@ function _loadAndValidateConfig(explicitRoot = null, options = {}) {
161162
global: { ...defaults.global, ...parsedConfig?.global },
162163
claudeCode: { ...defaults.claudeCode, ...parsedConfig?.claudeCode },
163164
codexCli: { ...defaults.codexCli, ...parsedConfig?.codexCli },
165+
opencode: { ...defaults.opencode, ...parsedConfig?.opencode },
164166
grokCli: { ...defaults.grokCli, ...parsedConfig?.grokCli }
165167
};
166168
configSource = `file (${configPath})`; // Update source info
@@ -504,6 +506,23 @@ function getClaudeCodeSettingsForCommand(
504506
return { ...settings, ...commandSpecific[commandName] };
505507
}
506508

509+
// --- Opencode Settings Getters ---
510+
511+
function getOpencodeSettings(explicitRoot = null, forceReload = false) {
512+
const config = getConfig(explicitRoot, forceReload);
513+
return { ...DEFAULTS.opencode, ...(config?.opencode || {}) };
514+
}
515+
516+
function getOpencodeSettingsForCommand(
517+
commandName,
518+
explicitRoot = null,
519+
forceReload = false
520+
) {
521+
const settings = getOpencodeSettings(explicitRoot, forceReload);
522+
const commandSpecific = settings?.commandSpecific || {};
523+
return { ...settings, ...commandSpecific[commandName] };
524+
}
525+
507526
function getGrokCliSettings(explicitRoot = null, forceReload = false) {
508527
const config = getConfig(explicitRoot, forceReload);
509528
// Ensure Grok CLI defaults are applied if Grok CLI section is missing
@@ -1265,6 +1284,9 @@ export {
12651284
// Codex CLI settings
12661285
getCodexCliSettings,
12671286
getCodexCliSettingsForCommand,
1287+
// Opencode settings
1288+
getOpencodeSettings,
1289+
getOpencodeSettingsForCommand,
12681290
// Grok CLI settings
12691291
getGrokCliSettings,
12701292
getGrokCliSettingsForCommand,

scripts/modules/supported-models.json

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,92 @@
292292
"supported": true
293293
}
294294
],
295+
"opencode": [
296+
{
297+
"id": "anthropic/claude-opus-4-5",
298+
"name": "Claude Opus 4.5 (via OpenCode)",
299+
"swe_score": 0.725,
300+
"cost_per_1m_tokens": {
301+
"input": 0,
302+
"output": 0
303+
},
304+
"allowed_roles": ["main", "fallback", "research"],
305+
"max_tokens": 32000,
306+
"supported": true
307+
},
308+
{
309+
"id": "anthropic/claude-sonnet-4-5",
310+
"name": "Claude Sonnet 4.5 (via OpenCode)",
311+
"swe_score": 0.727,
312+
"cost_per_1m_tokens": {
313+
"input": 0,
314+
"output": 0
315+
},
316+
"allowed_roles": ["main", "fallback", "research"],
317+
"max_tokens": 64000,
318+
"supported": true
319+
},
320+
{
321+
"id": "openai/gpt-5.2",
322+
"name": "GPT-5.2 (via OpenCode)",
323+
"swe_score": 0.8,
324+
"cost_per_1m_tokens": {
325+
"input": 0,
326+
"output": 0
327+
},
328+
"allowed_roles": ["main", "fallback", "research"],
329+
"max_tokens": 128000,
330+
"supported": true
331+
},
332+
{
333+
"id": "openai/gpt-5.2-codex",
334+
"name": "GPT-5.2 Codex (via OpenCode)",
335+
"swe_score": 0.82,
336+
"cost_per_1m_tokens": {
337+
"input": 0,
338+
"output": 0
339+
},
340+
"allowed_roles": ["main", "fallback", "research"],
341+
"max_tokens": 128000,
342+
"supported": true
343+
},
344+
{
345+
"id": "github-copilot/claude-sonnet-4.5",
346+
"name": "Claude Sonnet 4.5 via GitHub Copilot (OpenCode)",
347+
"swe_score": 0.727,
348+
"cost_per_1m_tokens": {
349+
"input": 0,
350+
"output": 0
351+
},
352+
"allowed_roles": ["main", "fallback", "research"],
353+
"max_tokens": 64000,
354+
"supported": true
355+
},
356+
{
357+
"id": "github-copilot/gpt-5",
358+
"name": "GPT-5 via GitHub Copilot (OpenCode)",
359+
"swe_score": 0.78,
360+
"cost_per_1m_tokens": {
361+
"input": 0,
362+
"output": 0
363+
},
364+
"allowed_roles": ["main", "fallback", "research"],
365+
"max_tokens": 128000,
366+
"supported": true
367+
},
368+
{
369+
"id": "google/gemini-2.5-pro",
370+
"name": "Gemini 2.5 Pro (via OpenCode)",
371+
"swe_score": 0.7,
372+
"cost_per_1m_tokens": {
373+
"input": 0,
374+
"output": 0
375+
},
376+
"allowed_roles": ["main", "fallback", "research"],
377+
"max_tokens": 128000,
378+
"supported": true
379+
}
380+
],
295381
"openai": [
296382
{
297383
"id": "gpt-4o",

src/ai-providers/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export { ClaudeCodeProvider } from './claude-code.js';
1818
export { GeminiCliProvider } from './gemini-cli.js';
1919
export { GrokCliProvider } from './grok-cli.js';
2020
export { CodexCliProvider } from './codex-cli.js';
21+
export { OpencodeProvider } from './opencode.js';
2122
export { OpenAICompatibleProvider } from './openai-compatible.js';
2223
export { ZAIProvider } from './zai.js';
2324
export { ZAICodingProvider } from './zai-coding.js';

src/ai-providers/opencode.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* src/ai-providers/opencode.js
3+
*
4+
* OpenCode provider implementation using the ai-sdk-provider-opencode-sdk package.
5+
* Delegates model selection, authentication, and provider routing to a running
6+
* OpenCode server. Task Master stays agnostic about which underlying LLM (Anthropic,
7+
* OpenAI, GitHub Copilot, etc.) OpenCode is configured to use.
8+
*
9+
* Authentication:
10+
* - Managed entirely by OpenCode — no API key required on the Task Master side.
11+
* - OpenCode users configure their providers via `opencode auth` or the config file.
12+
*
13+
* Model IDs follow the "providerID/modelID" format (e.g., "anthropic/claude-opus-4-5"),
14+
* or a bare "modelID" that routes to OpenCode's default provider.
15+
*/
16+
17+
import { execSync } from 'child_process';
18+
import { createOpencode } from 'ai-sdk-provider-opencode-sdk';
19+
import {
20+
getOpencodeSettingsForCommand,
21+
getSupportedModelsForProvider
22+
} from '../../scripts/modules/config-manager.js';
23+
import { log } from '../../scripts/modules/utils.js';
24+
import { BaseAIProvider } from './base-provider.js';
25+
26+
export class OpencodeProvider extends BaseAIProvider {
27+
constructor() {
28+
super();
29+
this.name = 'OpenCode';
30+
this.supportedModels = getSupportedModelsForProvider('opencode');
31+
32+
if (this.supportedModels.length === 0) {
33+
log(
34+
'warn',
35+
'No supported models found for opencode provider. Check supported-models.json configuration.'
36+
);
37+
}
38+
39+
this.needsExplicitJsonSchema = true;
40+
this.supportsTemperature = false;
41+
42+
this._opencodeCliChecked = false;
43+
this._opencodeCliAvailable = null;
44+
}
45+
46+
/**
47+
* OpenCode manages its own provider credentials; no Task Master API key is needed.
48+
* @returns {boolean}
49+
*/
50+
isRequiredApiKey() {
51+
return false;
52+
}
53+
54+
/**
55+
* Not used — OpenCode handles authentication. Returned for interface compatibility.
56+
* @returns {string}
57+
*/
58+
getRequiredApiKeyName() {
59+
return 'OPENCODE_API_KEY';
60+
}
61+
62+
/**
63+
* Optional CLI availability check. The SDK can auto-start an OpenCode server,
64+
* but surfacing a missing CLI early gives clearer guidance.
65+
*/
66+
validateAuth() {
67+
if (process.env.NODE_ENV === 'test') return;
68+
69+
if (!this._opencodeCliChecked) {
70+
try {
71+
execSync('opencode --version', { stdio: 'pipe', timeout: 1000 });
72+
this._opencodeCliAvailable = true;
73+
} catch (error) {
74+
this._opencodeCliAvailable = false;
75+
log(
76+
'warn',
77+
'OpenCode CLI not detected. Install it from: https://opencode.ai'
78+
);
79+
} finally {
80+
this._opencodeCliChecked = true;
81+
}
82+
}
83+
}
84+
85+
/**
86+
* Creates an OpenCode client instance.
87+
* @param {object} params
88+
* @param {string} [params.commandName] - Command name for settings lookup
89+
* @returns {Function} OpenCode provider function
90+
*/
91+
getClient(params = {}) {
92+
try {
93+
const settings = getOpencodeSettingsForCommand(params.commandName) || {};
94+
95+
return createOpencode(settings);
96+
} catch (error) {
97+
const msg = String(error?.message || '');
98+
const code = error?.code;
99+
if (code === 'ENOENT' || /opencode/i.test(msg)) {
100+
const enhancedError = new Error(
101+
`OpenCode not available. Install it from: https://opencode.ai - Original error: ${error.message}`
102+
);
103+
enhancedError.cause = error;
104+
this.handleError('OpenCode initialization', enhancedError);
105+
} else {
106+
this.handleError('client initialization', error);
107+
}
108+
}
109+
}
110+
111+
/**
112+
* @returns {string[]} List of supported model IDs
113+
*/
114+
getSupportedModels() {
115+
return this.supportedModels;
116+
}
117+
118+
/**
119+
* Check if a model is supported.
120+
* @param {string} modelId
121+
* @returns {boolean}
122+
*/
123+
isModelSupported(modelId) {
124+
if (!modelId) return false;
125+
return this.supportedModels.includes(String(modelId).toLowerCase());
126+
}
127+
}

0 commit comments

Comments
 (0)