opencode 対応

This commit is contained in:
nrslib 2026-02-11 07:57:04 +09:00
parent 166d6d9b5c
commit 4bc759c893
9 changed files with 95 additions and 25 deletions

View File

@ -560,14 +560,14 @@ describe('loadGlobalConfig', () => {
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync( writeFileSync(
getGlobalConfigPath(), getGlobalConfigPath(),
'provider: opencode\nmodel: gpt-4o\n', 'provider: opencode\nmodel: opencode/big-pickle\n',
'utf-8', 'utf-8',
); );
expect(() => loadGlobalConfig()).not.toThrow(); expect(() => loadGlobalConfig()).not.toThrow();
}); });
it('should not throw when provider is opencode without a model', () => { it('should throw when provider is opencode without a model', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync( writeFileSync(
@ -576,7 +576,19 @@ describe('loadGlobalConfig', () => {
'utf-8', 'utf-8',
); );
expect(() => loadGlobalConfig()).not.toThrow(); expect(() => loadGlobalConfig()).toThrow(/provider 'opencode' requires model in 'provider\/model' format/i);
});
it('should throw when provider is opencode and model is not provider/model format', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'provider: opencode\nmodel: big-pickle\n',
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/must be in 'provider\/model' format/i);
}); });
}); });
}); });

View File

@ -32,28 +32,17 @@ describe('emitInit', () => {
it('should emit init event with model and sessionId', () => { it('should emit init event with model and sessionId', () => {
const onStream = vi.fn(); const onStream = vi.fn();
emitInit(onStream, 'gpt-4', 'session-123'); emitInit(onStream, 'opencode/big-pickle', 'session-123');
expect(onStream).toHaveBeenCalledOnce(); expect(onStream).toHaveBeenCalledOnce();
expect(onStream).toHaveBeenCalledWith({ expect(onStream).toHaveBeenCalledWith({
type: 'init', type: 'init',
data: { model: 'gpt-4', sessionId: 'session-123' }, data: { model: 'opencode/big-pickle', sessionId: 'session-123' },
});
});
it('should use default model name when model is undefined', () => {
const onStream = vi.fn();
emitInit(onStream, undefined, 'session-abc');
expect(onStream).toHaveBeenCalledWith({
type: 'init',
data: { model: 'opencode', sessionId: 'session-abc' },
}); });
}); });
it('should not emit when onStream is undefined', () => { it('should not emit when onStream is undefined', () => {
emitInit(undefined, 'gpt-4', 'session-123'); emitInit(undefined, 'opencode/big-pickle', 'session-123');
}); });
}); });

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { parseProviderModel } from '../shared/utils/providerModel.js';
describe('parseProviderModel', () => {
it('should parse provider/model format', () => {
expect(parseProviderModel('opencode/big-pickle', 'model')).toEqual({
providerID: 'opencode',
modelID: 'big-pickle',
});
});
it('should reject empty string', () => {
expect(() => parseProviderModel('', 'model')).toThrow(/must not be empty/i);
});
it('should reject missing slash', () => {
expect(() => parseProviderModel('big-pickle', 'model')).toThrow(/provider\/model/i);
});
it('should reject multiple slashes', () => {
expect(() => parseProviderModel('a/b/c', 'model')).toThrow(/provider\/model/i);
});
});

View File

@ -11,13 +11,22 @@ import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js';
import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { parseProviderModel } from '../../../shared/utils/providerModel.js';
/** Claude-specific model aliases that are not valid for other providers */ /** Claude-specific model aliases that are not valid for other providers */
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
/** Validate that provider and model are compatible */ /** Validate that provider and model are compatible */
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
if (!provider || !model) return; if (!provider) return;
if (provider === 'opencode' && !model) {
throw new Error(
"Configuration error: provider 'opencode' requires model in 'provider/model' format (e.g. 'opencode/big-pickle')."
);
}
if (!model) return;
if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) { if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) {
throw new Error( throw new Error(
@ -25,6 +34,10 @@ function validateProviderModelCompatibility(provider: string | undefined, model:
`Either change the provider to 'claude' or specify a ${provider}-compatible model.` `Either change the provider to 'claude' or specify a ${provider}-compatible model.`
); );
} }
if (provider === 'opencode') {
parseProviderModel(model, "Configuration error: model");
}
} }
/** Create default global configuration (fresh instance each call) */ /** Create default global configuration (fresh instance each call) */

View File

@ -93,14 +93,14 @@ export function createStreamTrackingState(): StreamTrackingState {
export function emitInit( export function emitInit(
onStream: StreamCallback | undefined, onStream: StreamCallback | undefined,
model: string | undefined, model: string,
sessionId: string, sessionId: string,
): void { ): void {
if (!onStream) return; if (!onStream) return;
onStream({ onStream({
type: 'init', type: 'init',
data: { data: {
model: model || 'opencode', model,
sessionId, sessionId,
}, },
}); });

View File

@ -9,6 +9,7 @@ import { createOpencode } from '@opencode-ai/sdk/v2';
import { createServer } from 'node:net'; import { createServer } from 'node:net';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { parseProviderModel } from '../../shared/utils/providerModel.js';
import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js'; import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js';
import { import {
type OpenCodeStreamEvent, type OpenCodeStreamEvent,
@ -154,13 +155,20 @@ export class OpenCodeClient {
attempt, attempt,
}); });
const parsedModel = parseProviderModel(options.model, 'OpenCode model');
const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`;
const port = await getFreePort(); const port = await getFreePort();
const config = {
model: fullModel,
small_model: fullModel,
...(options.opencodeApiKey
? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } }
: {}),
};
const { client, server } = await createOpencode({ const { client, server } = await createOpencode({
port, port,
signal: streamAbortController.signal, signal: streamAbortController.signal,
...(options.opencodeApiKey config,
? { config: { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } }
: {}),
}); });
serverClose = server.close; serverClose = server.close;
@ -179,7 +187,7 @@ export class OpenCodeClient {
await client.session.promptAsync({ await client.session.promptAsync({
sessionID: sessionId, sessionID: sessionId,
directory: options.cwd, directory: options.cwd,
...(options.model ? { model: { providerID: 'opencode', modelID: options.model } } : {}), model: parsedModel,
parts: [{ type: 'text' as const, text: fullPrompt }], parts: [{ type: 'text' as const, text: fullPrompt }],
}); });

View File

@ -23,7 +23,7 @@ export interface OpenCodeCallOptions {
cwd: string; cwd: string;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
sessionId?: string; sessionId?: string;
model?: string; model: string;
systemPrompt?: string; systemPrompt?: string;
/** Permission mode for automatic permission handling */ /** Permission mode for automatic permission handling */
permissionMode?: PermissionMode; permissionMode?: PermissionMode;

View File

@ -8,6 +8,10 @@ import type { AgentResponse } from '../../core/models/index.js';
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions {
if (!options.model) {
throw new Error("OpenCode provider requires model in 'provider/model' format (e.g. 'opencode/big-pickle').");
}
return { return {
cwd: options.cwd, cwd: options.cwd,
abortSignal: options.abortSignal, abortSignal: options.abortSignal,

View File

@ -0,0 +1,21 @@
/**
* Parse provider/model identifier.
*
* Expected format: "<provider>/<model>" with both segments non-empty.
*/
export function parseProviderModel(value: string, fieldName: string): { providerID: string; modelID: string } {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${fieldName} must not be empty`);
}
const slashIndex = trimmed.indexOf('/');
if (slashIndex <= 0 || slashIndex === trimmed.length - 1 || trimmed.indexOf('/', slashIndex + 1) !== -1) {
throw new Error(`${fieldName} must be in 'provider/model' format: received '${value}'`);
}
return {
providerID: trimmed.slice(0, slashIndex),
modelID: trimmed.slice(slashIndex + 1),
};
}