fix(opencode): harden default model resolution

This commit is contained in:
777genius 2026-05-20 23:02:13 +03:00
parent fd50f736b8
commit 635d13d804
3 changed files with 620 additions and 69 deletions

View file

@ -674,6 +674,8 @@ const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const RUN_TIMEOUT_MS = 300_000;
const VERIFY_TIMEOUT_MS = 15_000;
const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000;
const PROVIDER_MODEL_LIST_TIMEOUT_MS = 30_000;
const PROVIDER_RUNTIME_STATUS_TIMEOUT_MS = 20_000;
const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000;
function asRuntimeRecord(value: unknown): Record<string, unknown> {
@ -1344,16 +1346,77 @@ function extractJsonObjectFromCli<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return JSON.parse(trimmed.slice(start, end + 1)) as T;
} catch (initialError) {
const candidates: T[] = [];
let lastParseError: unknown = null;
for (let start = trimmed.indexOf('{'); start >= 0; start = trimmed.indexOf('{', start + 1)) {
const end = findJsonObjectEnd(trimmed, start);
if (end < 0) {
continue;
}
try {
candidates.push(JSON.parse(trimmed.slice(start, end + 1)) as T);
} catch (error) {
lastParseError = error;
}
}
let providerResponse: T | null = null;
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const record = candidates[index] as Record<string, unknown> | null;
const providers = record && typeof record === 'object' ? record.providers : null;
if (providers && typeof providers === 'object' && !Array.isArray(providers)) {
providerResponse = candidates[index];
break;
}
}
if (providerResponse) {
return providerResponse;
}
if (candidates.length > 0) {
throw new Error('No provider JSON object found in CLI output');
}
if (lastParseError instanceof Error) {
throw lastParseError;
}
if (trimmed.includes('{') && initialError instanceof Error) {
throw initialError;
}
throw new Error('No JSON object found in CLI output');
}
}
function findJsonObjectEnd(source: string, start: number): number {
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const char = source[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return index;
}
}
}
return -1;
}
function getExplicitLaunchModelSelection(model: string | undefined): string | undefined {
const trimmed = model?.trim();
if (!trimmed || isDefaultProviderModelSelection(trimmed)) {
@ -7256,7 +7319,7 @@ export class TeamProvisioningService {
{
cwd: params.cwd,
env: params.env,
timeout: 10_000,
timeout: PROVIDER_MODEL_LIST_TIMEOUT_MS,
}
);
const runtimeStatusPromise =
@ -20440,28 +20503,39 @@ export class TeamProvisioningService {
providerArgs: string[] = [],
limitContext: boolean
): Promise<string | null> {
const { stdout } = await execCli(
claudePath,
buildProviderCliCommandArgs(providerArgs, [
'model',
'list',
'--json',
'--provider',
providerId,
]),
{
cwd,
env,
timeout: 10_000,
}
);
let parsed: ProviderModelListCommandResponse;
try {
const { stdout } = await execCli(
claudePath,
buildProviderCliCommandArgs(providerArgs, [
'model',
'list',
'--json',
'--provider',
providerId,
]),
{
cwd,
env,
timeout: PROVIDER_MODEL_LIST_TIMEOUT_MS,
}
);
parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
} catch (error) {
const fallbackDefaultModel = await this.resolveProviderDefaultModelFromRuntimeStatus(
claudePath,
cwd,
providerId,
env,
providerArgs,
limitContext
).catch(() => null);
if (fallbackDefaultModel) {
return fallbackDefaultModel;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to parse runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}`
`Failed to load runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}`
);
}
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
@ -20482,6 +20556,46 @@ export class TeamProvisioningService {
return normalizedDefaultModel;
}
private async resolveProviderDefaultModelFromRuntimeStatus(
claudePath: string,
cwd: string,
providerId: TeamProviderId,
env: NodeJS.ProcessEnv,
providerArgs: string[] = [],
limitContext: boolean
): Promise<string | null> {
const { stdout } = await execCli(
claudePath,
buildProviderCliCommandArgs(providerArgs, [
'runtime',
'status',
'--json',
'--provider',
providerId,
]),
{
cwd,
env,
timeout: PROVIDER_RUNTIME_STATUS_TIMEOUT_MS,
}
);
const parsed = extractJsonObjectFromCli<RuntimeStatusCommandResponse>(stdout);
const providerStatus = parsed.providers?.[providerId] ?? null;
const modelCatalog =
providerStatus?.modelCatalog?.providerId === providerId ? providerStatus.modelCatalog : null;
const defaultLaunchModel = modelCatalog?.defaultLaunchModel?.trim() || null;
if (providerId === 'anthropic') {
return resolveAnthropicLaunchModel({
limitContext,
availableLaunchModels: modelCatalog?.models.map((model) => model.launchModel) ?? [],
defaultLaunchModel,
});
}
return defaultLaunchModel;
}
private async materializeEffectiveTeamMemberSpecs(params: {
claudePath: string;
cwd: string;
@ -20605,6 +20719,111 @@ export class TeamProvisioningService {
);
}
private async materializeOpenCodeRuntimeAdapterDefaults<
TRequest extends TeamCreateRequest | TeamLaunchRequest,
>(params: {
request: TRequest;
members: TeamCreateRequest['members'];
}): Promise<{
request: TRequest;
members: TeamCreateRequest['members'];
}> {
const effectiveMembers = buildEffectiveTeamMemberSpecs(params.members, {
providerId: params.request.providerId,
model: params.request.model,
effort: params.request.effort,
});
const explicitRootModel = getExplicitLaunchModelSelection(params.request.model);
const memberModels = [
...new Set(
effectiveMembers
.map((member) => member.model?.trim())
.filter((model): model is string => Boolean(model))
),
];
if (!explicitRootModel && memberModels.length > 1) {
throw new Error(
'OpenCode runtime adapter launch supports one selected model per lane. Select one team model or align OpenCode teammate models.'
);
}
const inheritedRootModel = explicitRootModel ? undefined : memberModels[0];
const rootModel = explicitRootModel ?? inheritedRootModel;
const needsMemberModel = effectiveMembers.some((member) => {
const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode';
return providerId === 'opencode' && !member.model?.trim();
});
if (rootModel && !needsMemberModel) {
return {
request: {
...params.request,
model: rootModel,
} as TRequest,
members: effectiveMembers,
};
}
if (rootModel) {
return {
request: {
...params.request,
model: rootModel,
} as TRequest,
members: effectiveMembers.map((member) => {
const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode';
if (providerId !== 'opencode' || member.model?.trim()) {
return member;
}
return {
...member,
model: rootModel,
};
}),
};
}
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) {
throw buildMissingCliError();
}
const provisioningEnv = await this.buildProvisioningEnv(
'opencode',
params.request.providerBackendId
);
if (provisioningEnv.warning) {
throw new Error(provisioningEnv.warning);
}
const resolvedDefaultModel = await this.resolveProviderDefaultModel(
claudePath,
params.request.cwd,
'opencode',
provisioningEnv.env,
provisioningEnv.providerArgs ?? [],
params.request.limitContext === true
);
const normalizedDefaultModel = resolvedDefaultModel?.trim();
if (!normalizedDefaultModel) {
throw new Error(
'Could not resolve the runtime default model for OpenCode teammates. Select an explicit model and retry.'
);
}
return {
request: {
...params.request,
model: normalizedDefaultModel,
} as TRequest,
members: effectiveMembers.map((member) => {
const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode';
if (providerId !== 'opencode' || member.model?.trim()) {
return member;
}
return {
...member,
model: normalizedDefaultModel,
};
}),
};
}
private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
teamName: string;
baseCwd: string;
@ -22228,46 +22447,47 @@ export class TeamProvisioningService {
}
await ensureCwdExists(request.cwd);
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: request.teamName,
baseCwd: request.cwd,
leadProviderId: request.providerId,
members: buildEffectiveTeamMemberSpecs(request.members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
}),
const materialized = await this.materializeOpenCodeRuntimeAdapterDefaults({
request,
members: request.members,
});
const teamDir = path.join(getTeamsBasePath(), request.teamName);
const tasksDir = path.join(getTasksBasePath(), request.teamName);
const launchRequest = materialized.request;
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: launchRequest.teamName,
baseCwd: launchRequest.cwd,
leadProviderId: launchRequest.providerId,
members: materialized.members,
});
const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName);
const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName);
await fs.promises.mkdir(teamDir, { recursive: true });
await fs.promises.mkdir(tasksDir, { recursive: true });
await this.teamMetaStore.writeMeta(request.teamName, {
displayName: request.displayName,
description: request.description,
color: request.color,
cwd: request.cwd,
prompt: request.prompt,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
limitContext: request.limitContext,
await this.teamMetaStore.writeMeta(launchRequest.teamName, {
displayName: launchRequest.displayName,
description: launchRequest.description,
color: launchRequest.color,
cwd: launchRequest.cwd,
prompt: launchRequest.prompt,
providerId: launchRequest.providerId,
providerBackendId: launchRequest.providerBackendId,
model: launchRequest.model,
effort: launchRequest.effort,
skipPermissions: launchRequest.skipPermissions,
worktree: launchRequest.worktree,
extraCliArgs: launchRequest.extraCliArgs,
limitContext: launchRequest.limitContext,
createdAt: Date.now(),
});
const membersToWrite = this.buildMembersMetaWritePayload(effectiveMembers);
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
providerBackendId: request.providerBackendId,
await this.membersMetaStore.writeMembers(launchRequest.teamName, membersToWrite, {
providerBackendId: launchRequest.providerBackendId,
});
await this.writeOpenCodeTeamConfig(request, effectiveMembers);
await this.writeOpenCodeTeamConfig(launchRequest, effectiveMembers);
return this.runOpenCodeTeamRuntimeAdapterLaunch({
request,
request: launchRequest,
members: effectiveMembers,
prompt: request.prompt?.trim() ?? '',
prompt: launchRequest.prompt?.trim() ?? '',
sourceWarning: undefined,
onProgress,
});
@ -22291,17 +22511,18 @@ export class TeamProvisioningService {
configRaw,
request.providerId
);
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: request.teamName,
baseCwd: request.cwd,
leadProviderId: request.providerId,
members: buildEffectiveTeamMemberSpecs(members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
}),
const materialized = await this.materializeOpenCodeRuntimeAdapterDefaults({
request,
members,
});
await this.updateConfigProjectPath(request.teamName, request.cwd);
const launchRequest = materialized.request;
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: launchRequest.teamName,
baseCwd: launchRequest.cwd,
leadProviderId: launchRequest.providerId,
members: materialized.members,
});
await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd);
let existingTasks: TeamTask[] = [];
try {
@ -22312,14 +22533,14 @@ export class TeamProvisioningService {
);
}
const prompt = buildDeterministicLaunchHydrationPrompt(
request,
launchRequest,
effectiveMembers,
existingTasks,
false
);
return this.runOpenCodeTeamRuntimeAdapterLaunch({
request,
request: launchRequest,
members: effectiveMembers,
prompt,
sourceWarning: warning,
@ -22695,7 +22916,7 @@ export class TeamProvisioningService {
name: member.name,
providerId: 'opencode',
providerBackendId: undefined,
model: member.model?.trim() || undefined,
model: member.model?.trim() || evidence?.model?.trim() || undefined,
effort: member.effort,
cwd: member.cwd?.trim() || undefined,
laneId: 'primary',
@ -26842,9 +27063,10 @@ export class TeamProvisioningService {
}
const activeRunMember = this.findEffectiveRunMember(run, memberName);
const activeRunModel = activeRunMember?.model?.trim();
const evidenceModel = currentRuntimeAdapterRun?.members?.[memberName]?.model?.trim();
const activeRunProviderId =
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
inferTeamProviderIdFromModel(activeRunModel);
inferTeamProviderIdFromModel(activeRunModel ?? evidenceModel);
const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId;
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
upsertMetadata(memberName, {
@ -26865,9 +27087,11 @@ export class TeamProvisioningService {
persistedMember.lastRuntimeAliveAt,
...(activeRunModel
? { model: activeRunModel }
: persistedMember.model?.trim()
? { model: persistedMember.model.trim() }
: {}),
: evidenceModel
? { model: evidenceModel }
: persistedMember.model?.trim()
? { model: persistedMember.model.trim() }
: {}),
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
currentRuntimeAdapterEvidence.runtimePid > 0
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }

View file

@ -74,6 +74,7 @@ export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePr
export interface TeamRuntimeMemberLaunchEvidence {
memberName: string;
providerId: TeamRuntimeProviderId;
model?: string;
launchState: MemberLaunchState;
agentToolAccepted: boolean;
runtimeAlive: boolean;

View file

@ -2821,6 +2821,332 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
vi.mocked(console.warn).mockClear();
});
it('resolves the OpenCode default model when CLI JSON is surrounded by noisy structured logs', async () => {
const modelList = {
schemaVersion: 1,
providers: {
opencode: {
defaultModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
label: 'Big Pickle',
description: 'Default OpenCode free model',
},
{
id: 'opencode/minimax-m2.5-free',
label: 'MiniMax M2.5 Free',
description: 'Free OpenCode model',
},
],
},
},
};
execCliMock.mockResolvedValue({
stdout: [
'debug {"event":"starting model list"}',
JSON.stringify(modelList),
'debug {"providers":"log-only"}',
].join('\n'),
stderr: '',
exitCode: 0,
});
const svc = new TeamProvisioningService();
const serviceWithDefaultModelResolver = svc as unknown as {
resolveProviderDefaultModel: (
claudePath: string,
cwd: string,
providerId: string,
env: NodeJS.ProcessEnv,
providerArgs: string[],
limitContext: boolean
) => Promise<string | null>;
};
await expect(
serviceWithDefaultModelResolver.resolveProviderDefaultModel(
'/fake/claude',
tempRoot,
'opencode',
{ PATH: '/usr/bin' },
[],
false
)
).resolves.toBe('opencode/big-pickle');
});
it('falls back to OpenCode runtime status when the default model list is truncated', async () => {
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) {
return {
stdout: [
'{',
' "schemaVersion": 1,',
' "providers": {',
' "opencode": {',
' "defaultModel": "opencode/big-pickle",',
' "models": [',
' {"id":"opencode/big-pickle","label":"Big Pickle","description":"Free"}',
].join('\n'),
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'runtime' && args[1] === 'status' && args.includes('opencode')) {
return {
stdout: JSON.stringify({
providers: {
opencode: {
providerId: 'opencode',
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: new Date(0).toISOString(),
staleAt: new Date(60_000).toISOString(),
defaultModelId: 'opencode/big-pickle',
defaultLaunchModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'Big Pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
badgeLabel: 'Free',
statusMessage: null,
metadata: { free: true },
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
};
}
return defaultExecCliMockImplementation(_binaryPath, args);
});
const svc = new TeamProvisioningService();
const serviceWithDefaultModelResolver = svc as unknown as {
resolveProviderDefaultModel: (
claudePath: string,
cwd: string,
providerId: string,
env: NodeJS.ProcessEnv,
providerArgs: string[],
limitContext: boolean
) => Promise<string | null>;
};
await expect(
serviceWithDefaultModelResolver.resolveProviderDefaultModel(
'/fake/claude',
tempRoot,
'opencode',
{ PATH: '/usr/bin' },
[],
false
)
).resolves.toBe('opencode/big-pickle');
});
it('falls back to OpenCode runtime status when the default model list command fails', async () => {
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) {
const error = new Error('stdout maxBuffer exceeded');
Object.assign(error, { stdout: '{"providers":', stderr: '' });
throw error;
}
if (args[0] === 'runtime' && args[1] === 'status' && args.includes('opencode')) {
return {
stdout: JSON.stringify({
providers: {
opencode: {
providerId: 'opencode',
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: new Date(0).toISOString(),
staleAt: new Date(60_000).toISOString(),
defaultModelId: 'opencode/big-pickle',
defaultLaunchModel: 'opencode/big-pickle',
models: [],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
};
}
return defaultExecCliMockImplementation(_binaryPath, args);
});
const svc = new TeamProvisioningService();
const serviceWithDefaultModelResolver = svc as unknown as {
resolveProviderDefaultModel: (
claudePath: string,
cwd: string,
providerId: string,
env: NodeJS.ProcessEnv,
providerArgs: string[],
limitContext: boolean
) => Promise<string | null>;
};
await expect(
serviceWithDefaultModelResolver.resolveProviderDefaultModel(
'/fake/claude',
tempRoot,
'opencode',
{ PATH: '/usr/bin' },
[],
false
)
).resolves.toBe('opencode/big-pickle');
});
it('materializes pure OpenCode runtime adapter Default selections before launch', async () => {
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) {
return {
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
opencode: {
defaultModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
label: 'Big Pickle',
description: 'Free OpenCode model',
},
],
},
},
}),
stderr: '',
exitCode: 0,
};
}
return defaultExecCliMockImplementation(_binaryPath, args);
});
const svc = new TeamProvisioningService();
const serviceWithMaterializer = svc as unknown as {
materializeOpenCodeRuntimeAdapterDefaults: (params: {
request: {
teamName: string;
cwd: string;
providerId: 'opencode';
skipPermissions: boolean;
model?: string;
};
members: Array<{
name: string;
providerId?: 'opencode';
model?: string;
}>;
}) => Promise<{
request: { model?: string };
members: Array<{ name: string; model?: string }>;
}>;
};
const result = await serviceWithMaterializer.materializeOpenCodeRuntimeAdapterDefaults({
request: {
teamName: 'default-opencode-team',
cwd: tempRoot,
providerId: 'opencode',
skipPermissions: true,
},
members: [
{
name: 'atlas',
providerId: 'opencode',
},
],
});
expect(result.request.model).toBe('opencode/big-pickle');
expect(result.members).toEqual([
{
name: 'atlas',
providerId: 'opencode',
model: 'opencode/big-pickle',
effort: undefined,
},
]);
expect(execCliMock).toHaveBeenCalledWith(
'/fake/claude',
['model', 'list', '--json', '--provider', 'opencode'],
expect.objectContaining({ cwd: tempRoot })
);
});
it('materializes pure OpenCode runtime adapter root model from a saved teammate model', async () => {
const svc = new TeamProvisioningService();
const serviceWithMaterializer = svc as unknown as {
materializeOpenCodeRuntimeAdapterDefaults: (params: {
request: {
teamName: string;
cwd: string;
providerId: 'opencode';
skipPermissions: boolean;
model?: string;
};
members: Array<{
name: string;
providerId?: 'opencode';
model?: string;
}>;
}) => Promise<{
request: { model?: string };
members: Array<{ name: string; model?: string }>;
}>;
};
const result = await serviceWithMaterializer.materializeOpenCodeRuntimeAdapterDefaults({
request: {
teamName: 'saved-opencode-team',
cwd: tempRoot,
providerId: 'opencode',
skipPermissions: true,
},
members: [
{
name: 'atlas',
providerId: 'opencode',
model: 'opencode/big-pickle',
},
],
});
expect(result.request.model).toBe('opencode/big-pickle');
expect(result.members[0]?.model).toBe('opencode/big-pickle');
expect(execCliMock).not.toHaveBeenCalled();
});
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({