opencode 対応
This commit is contained in:
parent
166d6d9b5c
commit
4bc759c893
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
23
src/__tests__/provider-model.test.ts
Normal file
23
src/__tests__/provider-model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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) */
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 }],
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
21
src/shared/utils/providerModel.ts
Normal file
21
src/shared/utils/providerModel.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user