From 4bc759c8939b7db8002a308a48c39c2fad50018f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 07:57:04 +0900 Subject: [PATCH] =?UTF-8?q?opencode=20=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/globalConfig-defaults.test.ts | 18 ++++++++++++--- src/__tests__/opencode-stream-handler.test.ts | 17 +++----------- src/__tests__/provider-model.test.ts | 23 +++++++++++++++++++ src/infra/config/global/globalConfig.ts | 15 +++++++++++- src/infra/opencode/OpenCodeStreamHandler.ts | 4 ++-- src/infra/opencode/client.ts | 16 +++++++++---- src/infra/opencode/types.ts | 2 +- src/infra/providers/opencode.ts | 4 ++++ src/shared/utils/providerModel.ts | 21 +++++++++++++++++ 9 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/provider-model.test.ts create mode 100644 src/shared/utils/providerModel.ts diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 511d54e..4753732 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -560,14 +560,14 @@ describe('loadGlobalConfig', () => { mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), - 'provider: opencode\nmodel: gpt-4o\n', + 'provider: opencode\nmodel: opencode/big-pickle\n', 'utf-8', ); 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'); mkdirSync(taktDir, { recursive: true }); writeFileSync( @@ -576,7 +576,19 @@ describe('loadGlobalConfig', () => { '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); }); }); }); diff --git a/src/__tests__/opencode-stream-handler.test.ts b/src/__tests__/opencode-stream-handler.test.ts index b4dbe4d..2456f05 100644 --- a/src/__tests__/opencode-stream-handler.test.ts +++ b/src/__tests__/opencode-stream-handler.test.ts @@ -32,28 +32,17 @@ describe('emitInit', () => { it('should emit init event with model and sessionId', () => { const onStream = vi.fn(); - emitInit(onStream, 'gpt-4', 'session-123'); + emitInit(onStream, 'opencode/big-pickle', 'session-123'); expect(onStream).toHaveBeenCalledOnce(); expect(onStream).toHaveBeenCalledWith({ type: 'init', - data: { model: 'gpt-4', 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' }, + data: { model: 'opencode/big-pickle', sessionId: 'session-123' }, }); }); it('should not emit when onStream is undefined', () => { - emitInit(undefined, 'gpt-4', 'session-123'); + emitInit(undefined, 'opencode/big-pickle', 'session-123'); }); }); diff --git a/src/__tests__/provider-model.test.ts b/src/__tests__/provider-model.test.ts new file mode 100644 index 0000000..74b29be --- /dev/null +++ b/src/__tests__/provider-model.test.ts @@ -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); + }); +}); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 1298b9a..e7a7ea6 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -11,13 +11,22 @@ import { GlobalConfigSchema } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; import { getGlobalConfigPath, getProjectConfigPath } from '../paths.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 */ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); /** Validate that provider and model are compatible */ 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)) { 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.` ); } + + if (provider === 'opencode') { + parseProviderModel(model, "Configuration error: model"); + } } /** Create default global configuration (fresh instance each call) */ diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts index f6cb7b4..dfd70d6 100644 --- a/src/infra/opencode/OpenCodeStreamHandler.ts +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -93,14 +93,14 @@ export function createStreamTrackingState(): StreamTrackingState { export function emitInit( onStream: StreamCallback | undefined, - model: string | undefined, + model: string, sessionId: string, ): void { if (!onStream) return; onStream({ type: 'init', data: { - model: model || 'opencode', + model, sessionId, }, }); diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 1a5dd25..d87c06d 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -9,6 +9,7 @@ import { createOpencode } from '@opencode-ai/sdk/v2'; import { createServer } from 'node:net'; import type { AgentResponse } from '../../core/models/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 { type OpenCodeStreamEvent, @@ -154,13 +155,20 @@ export class OpenCodeClient { attempt, }); + const parsedModel = parseProviderModel(options.model, 'OpenCode model'); + const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const port = await getFreePort(); + const config = { + model: fullModel, + small_model: fullModel, + ...(options.opencodeApiKey + ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } + : {}), + }; const { client, server } = await createOpencode({ port, signal: streamAbortController.signal, - ...(options.opencodeApiKey - ? { config: { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } } - : {}), + config, }); serverClose = server.close; @@ -179,7 +187,7 @@ export class OpenCodeClient { await client.session.promptAsync({ sessionID: sessionId, directory: options.cwd, - ...(options.model ? { model: { providerID: 'opencode', modelID: options.model } } : {}), + model: parsedModel, parts: [{ type: 'text' as const, text: fullPrompt }], }); diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts index c9ca828..ae3976f 100644 --- a/src/infra/opencode/types.ts +++ b/src/infra/opencode/types.ts @@ -23,7 +23,7 @@ export interface OpenCodeCallOptions { cwd: string; abortSignal?: AbortSignal; sessionId?: string; - model?: string; + model: string; systemPrompt?: string; /** Permission mode for automatic permission handling */ permissionMode?: PermissionMode; diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts index 5f83a11..aa02680 100644 --- a/src/infra/providers/opencode.ts +++ b/src/infra/providers/opencode.ts @@ -8,6 +8,10 @@ import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; 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 { cwd: options.cwd, abortSignal: options.abortSignal, diff --git a/src/shared/utils/providerModel.ts b/src/shared/utils/providerModel.ts new file mode 100644 index 0000000..a094765 --- /dev/null +++ b/src/shared/utils/providerModel.ts @@ -0,0 +1,21 @@ +/** + * Parse provider/model identifier. + * + * Expected format: "/" 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), + }; +}