opencode 対応
This commit is contained in:
parent
166d6d9b5c
commit
4bc759c893
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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 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) */
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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