feat(runtime-provider-management): expand opencode setup UI

This commit is contained in:
777genius 2026-04-25 23:36:28 +03:00
parent c2e14ea9df
commit 238900f3cf
28 changed files with 2116 additions and 215 deletions

View file

@ -12,5 +12,16 @@ For new features:
- Reference implementation: `src/features/recent-projects`
- Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md)
## Review guidelines
- Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority.
- Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints.
- Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases.
- Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`.
- Flag changes that can break `isMeta` semantics, chunk generation, teammate message parsing, task/subagent filtering, or structured task references.
- Ensure IPC and main-process handlers validate inputs, fail gracefully, and do not expose unsafe filesystem or process access.
- Confirm user-visible workflows have focused tests or a clear verification path when they touch parsing, persistence, IPC, Git, provider auth, or review flows.
- Prefer `pnpm` commands for verification and avoid recommending `pnpm lint:fix` unless the PR explicitly intends broad formatting changes.
Do not treat this file as a second source of truth.
Keep architecture rules centralized in [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md).

View file

@ -1,14 +1,17 @@
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from './types';
@ -20,6 +23,12 @@ export interface RuntimeProviderManagementApi {
loadProviderDirectory(
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse>;
loadSetupForm(
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse>;
connectProvider(
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse>;
connectWithApiKey(
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse>;

View file

@ -1,5 +1,7 @@
export const RUNTIME_PROVIDER_MANAGEMENT_VIEW = 'runtimeProviderManagement:view';
export const RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY = 'runtimeProviderManagement:directory';
export const RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM = 'runtimeProviderManagement:setupForm';
export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT = 'runtimeProviderManagement:connect';
export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY =
'runtimeProviderManagement:connectApiKey';
export const RUNTIME_PROVIDER_MANAGEMENT_FORGET = 'runtimeProviderManagement:forget';

View file

@ -17,6 +17,55 @@ export type RuntimeProviderOwnershipDto = 'managed' | 'local' | 'env' | 'project
export type RuntimeProviderAuthMethodDto = 'api' | 'oauth' | 'wellknown';
export type RuntimeProviderSetupMethodDto = 'api' | 'oauth' | 'manual';
export type RuntimeProviderSetupPromptTypeDto = 'text' | 'select';
export interface RuntimeProviderSetupPromptOptionDto {
label: string;
value: string;
hint: string | null;
}
export interface RuntimeProviderSetupPromptConditionDto {
key: string;
op: string;
value: string;
}
export interface RuntimeProviderSetupPromptDto {
key: string;
type: RuntimeProviderSetupPromptTypeDto;
label: string;
placeholder: string | null;
required: boolean;
secret: boolean;
options: readonly RuntimeProviderSetupPromptOptionDto[];
when: RuntimeProviderSetupPromptConditionDto | null;
}
export type RuntimeProviderSetupFormSourceDto = 'opencode-auth' | 'curated' | 'oauth' | 'manual';
export interface RuntimeProviderSetupFormDto {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
displayName: string;
method: RuntimeProviderSetupMethodDto;
supported: boolean;
title: string;
description: string | null;
submitLabel: string;
disabledReason: string | null;
source: RuntimeProviderSetupFormSourceDto;
secret: {
key: 'key';
label: string;
placeholder: string | null;
required: boolean;
} | null;
prompts: readonly RuntimeProviderSetupPromptDto[];
}
export type RuntimeProviderActionIdDto =
| 'connect'
| 'use'
@ -165,6 +214,13 @@ export interface RuntimeProviderManagementProviderResponse {
error?: RuntimeProviderManagementErrorDto;
}
export interface RuntimeProviderManagementSetupFormResponse {
schemaVersion: 1;
runtimeId: RuntimeProviderManagementRuntimeId;
setupForm?: RuntimeProviderSetupFormDto;
error?: RuntimeProviderManagementErrorDto;
}
export type RuntimeProviderModelAvailabilityDto =
| 'available'
| 'unavailable'
@ -235,6 +291,21 @@ export interface RuntimeProviderManagementConnectApiKeyInput {
projectPath?: string | null;
}
export interface RuntimeProviderManagementLoadSetupFormInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
projectPath?: string | null;
}
export interface RuntimeProviderManagementConnectInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
method: RuntimeProviderSetupMethodDto;
apiKey?: string | null;
metadata?: Record<string, string> | null;
projectPath?: string | null;
}
export interface RuntimeProviderManagementForgetInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;

View file

@ -1,15 +1,18 @@
import type { RuntimeProviderManagementPort } from './RuntimeProviderManagementPort';
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
@ -28,6 +31,20 @@ export function loadRuntimeProviderDirectory(
return port.loadProviderDirectory(input);
}
export function loadRuntimeProviderSetupForm(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> {
return port.loadSetupForm(input);
}
export function connectRuntimeProvider(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> {
return port.connectProvider(input);
}
export function connectRuntimeProviderWithApiKey(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementConnectApiKeyInput

View file

@ -1,9 +1,11 @@
import {
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
} from '@features/runtime-provider-management/contracts';
@ -12,15 +14,18 @@ import { createLogger } from '@shared/utils/logger';
import type { RuntimeProviderManagementFeatureFacade } from '../../composition/createRuntimeProviderManagementFeature';
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
@ -78,6 +83,55 @@ export function registerRuntimeProviderManagementIpc(
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
async (
_event,
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> => {
try {
return await feature.loadSetupForm(input);
} catch (error) {
logger.error('Failed to load runtime provider setup form', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider setup form',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
async (
_event,
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> => {
try {
return await feature.connectProvider(input);
} catch (error) {
logger.error(
'Failed to connect runtime provider',
error instanceof Error ? error.name : error
);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
async (
@ -200,6 +254,8 @@ export function registerRuntimeProviderManagementIpc(
export function removeRuntimeProviderManagementIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_VIEW);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_FORGET);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_MODELS);

View file

@ -4,15 +4,18 @@ import type { RuntimeProviderManagementPort } from '../../core/application';
import type {
RuntimeProviderManagementApi,
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
@ -33,6 +36,12 @@ export function createRuntimeProviderManagementFeature(
loadProviderDirectory: (
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> => port.loadProviderDirectory(input),
loadSetupForm: (
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> => port.loadSetupForm(input),
connectProvider: (
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> => port.connectProvider(input),
connectWithApiKey: (
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> => port.connectWithApiKey(input),

View file

@ -6,10 +6,12 @@ import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import type {
RuntimeProviderManagementApi,
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementErrorDto,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelsResponse,
@ -17,6 +19,7 @@ import type {
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementRuntimeId,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
@ -30,6 +33,7 @@ type RuntimeProviderManagementErrorResponse =
| RuntimeProviderManagementViewResponse
| RuntimeProviderManagementDirectoryResponse
| RuntimeProviderManagementProviderResponse
| RuntimeProviderManagementSetupFormResponse
| RuntimeProviderManagementModelsResponse
| RuntimeProviderManagementModelTestResponse;
@ -288,6 +292,121 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
}
}
async loadSetupForm(
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementSetupFormResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const { stdout } = await execCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'setup-form',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--json',
],
projectPath
),
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
);
return extractJsonObject<RuntimeProviderManagementSetupFormResponse>(stdout);
} catch (error) {
const response =
extractJsonObjectFromError<RuntimeProviderManagementSetupFormResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementSetupFormResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async connectProvider(
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const child = spawnCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'connect',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--stdin-json',
'--json',
],
projectPath
),
runtimeProviderCommandOptions(
{
env,
stdio: 'pipe' as const,
},
projectPath
)
) as ChildProcessWithoutNullStreams;
const result = await collectSpawnOutput(
child,
JSON.stringify({
method: input.method,
apiKey: input.apiKey ?? null,
metadata: input.metadata ?? {},
})
);
if (result.code === 0) {
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
}
try {
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
} catch {
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
`Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.`
);
}
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementProviderResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async connectWithApiKey(
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> {

View file

@ -1,9 +1,11 @@
import {
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
type RuntimeProviderManagementApi,
@ -11,15 +13,18 @@ import {
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
@ -37,6 +42,14 @@ export function createRuntimeProviderManagementBridge(
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, input),
loadSetupForm: (
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, input),
connectProvider: (
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_CONNECT, input),
connectWithApiKey: (
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> =>

View file

@ -16,6 +16,7 @@ import type {
RuntimeProviderManagementViewDto,
RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto,
RuntimeProviderSetupFormDto,
} from '@features/runtime-provider-management/contracts';
interface UseRuntimeProviderManagementOptions {
@ -45,6 +46,11 @@ export interface RuntimeProviderManagementState {
directorySelectedProviderId: string | null;
directorySupported: boolean;
activeFormProviderId: string | null;
setupForm: RuntimeProviderSetupFormDto | null;
setupFormLoading: boolean;
setupFormError: string | null;
setupSubmitError: string | null;
setupMetadata: Readonly<Record<string, string>>;
apiKeyValue: string;
modelPickerProviderId: string | null;
modelPickerMode: RuntimeProviderModelPickerMode | null;
@ -77,6 +83,7 @@ export interface RuntimeProviderManagementActions {
startConnect: (providerId: string) => void;
cancelConnect: () => void;
setApiKeyValue: (value: string) => void;
setSetupMetadataValue: (key: string, value: string) => void;
submitConnect: (providerId: string) => Promise<void>;
forgetProvider: (providerId: string) => Promise<void>;
openModelPicker: (providerId: string, mode: RuntimeProviderModelPickerMode) => void;
@ -186,6 +193,11 @@ export function useRuntimeProviderManagement(
);
const [directorySupported, setDirectorySupported] = useState(true);
const [activeFormProviderId, setActiveFormProviderId] = useState<string | null>(null);
const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null);
const [setupFormLoading, setSetupFormLoading] = useState(false);
const [setupFormError, setSetupFormError] = useState<string | null>(null);
const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null);
const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({});
const [apiKeyValue, setApiKeyValue] = useState('');
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
const [modelPickerMode, setModelPickerMode] = useState<RuntimeProviderModelPickerMode | null>(
@ -206,6 +218,7 @@ export function useRuntimeProviderManagement(
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const directoryRequestSeq = useRef(0);
const setupFormRequestSeq = useRef(0);
const refresh = useCallback(async (): Promise<void> => {
if (!options.enabled) {
@ -342,6 +355,11 @@ export function useRuntimeProviderManagement(
setDirectoryLoaded(false);
setDirectorySelectedProviderId(null);
setApiKeyValue('');
setSetupMetadata({});
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupSubmitError(null);
setActiveFormProviderId(null);
const reset = resetModelState();
setModelPickerProviderId(reset.modelPickerProviderId);
@ -530,6 +548,11 @@ export function useRuntimeProviderManagement(
setDirectorySelectedProviderId(providerId);
setSelectedProviderId(providerId);
setActiveFormProviderId(null);
setSetupForm(null);
setSetupFormError(null);
setSetupSubmitError(null);
setSetupMetadata({});
setApiKeyValue('');
const compactProvider = view?.providers.find(
(provider) => provider.providerId === providerId
@ -561,15 +584,60 @@ export function useRuntimeProviderManagement(
setDirectoryNextCursor(null);
}, []);
const startConnect = useCallback((providerId: string): void => {
setSelectedProviderId(providerId);
setActiveFormProviderId(providerId);
setModelPickerProviderId(null);
setModelPickerMode(null);
setApiKeyValue('');
setError(null);
setSuccessMessage(null);
}, []);
const startConnect = useCallback(
(providerId: string): void => {
setSelectedProviderId(providerId);
setActiveFormProviderId(providerId);
setModelPickerProviderId(null);
setModelPickerMode(null);
setApiKeyValue('');
setSetupMetadata({});
setSetupForm(null);
setSetupFormError(null);
setSetupSubmitError(null);
setSetupFormLoading(true);
setError(null);
setSuccessMessage(null);
const requestSeq = setupFormRequestSeq.current + 1;
setupFormRequestSeq.current = requestSeq;
void withUiTimeout(
api.runtimeProviderManagement.loadSetupForm({
runtimeId: options.runtimeId,
providerId,
projectPath: options.projectPath ?? null,
}),
'Provider setup form load timed out'
)
.then((response) => {
if (setupFormRequestSeq.current !== requestSeq) {
return;
}
if (response.error) {
setSetupFormError(response.error.message);
return;
}
setSetupForm(response.setupForm ?? null);
if (!response.setupForm) {
setSetupFormError('Provider setup form response was empty');
}
})
.catch((setupError) => {
if (setupFormRequestSeq.current !== requestSeq) {
return;
}
setSetupFormError(
setupError instanceof Error ? setupError.message : 'Failed to load provider setup form'
);
})
.finally(() => {
if (setupFormRequestSeq.current === requestSeq) {
setSetupFormLoading(false);
}
});
},
[options.projectPath, options.runtimeId]
);
const updateProviderQuery = useCallback(
(value: string): void => {
@ -584,43 +652,79 @@ export function useRuntimeProviderManagement(
);
const cancelConnect = useCallback((): void => {
setupFormRequestSeq.current += 1;
setActiveFormProviderId(null);
setApiKeyValue('');
setSetupMetadata({});
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupSubmitError(null);
setError(null);
}, []);
const updateApiKeyValue = useCallback((value: string): void => {
setApiKeyValue(value);
setSetupSubmitError(null);
}, []);
const setSetupMetadataValue = useCallback((key: string, value: string): void => {
setSetupMetadata((current) => ({
...current,
[key]: value,
}));
setSetupSubmitError(null);
}, []);
const submitConnect = useCallback(
async (providerId: string): Promise<void> => {
const apiKey = apiKeyValue.trim();
if (!apiKey) {
setError('API key is required');
setSetupSubmitError('API key is required');
return;
}
if (!setupForm) {
setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded');
return;
}
if (!setupForm.supported) {
setSetupSubmitError(
setupForm.disabledReason ?? 'Provider setup is not supported in the app'
);
return;
}
setSavingProviderId(providerId);
setError(null);
setSetupSubmitError(null);
setSuccessMessage(null);
try {
const response = await withUiTimeout(
api.runtimeProviderManagement.connectWithApiKey({
api.runtimeProviderManagement.connectProvider({
runtimeId: options.runtimeId,
providerId,
method: setupForm.method,
apiKey,
metadata: setupMetadata,
projectPath: options.projectPath ?? null,
}),
'Provider connect timed out'
);
if (response.error) {
setError(response.error.message);
setSetupSubmitError(response.error.message);
return;
}
if (response.provider) {
setView((current) => replaceProvider(current, response.provider!));
}
setActiveFormProviderId(null);
setSuccessMessage('Provider connected');
setSuccessMessage(null);
setSavingProviderId(null);
setApiKeyValue('');
setSetupMetadata({});
setSetupForm(null);
setSetupFormError(null);
setSetupSubmitError(null);
void Promise.resolve(options.onProviderChanged?.())
.then(() => refresh())
.then(() => loadDirectoryPage({ refresh: true, cursor: null }))
@ -630,14 +734,14 @@ export function useRuntimeProviderManagement(
);
});
} catch (connectError) {
setError(
setSetupSubmitError(
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
);
} finally {
setSavingProviderId(null);
}
},
[apiKeyValue, loadDirectoryPage, options, refresh]
[apiKeyValue, loadDirectoryPage, options, refresh, setupForm, setupFormError, setupMetadata]
);
const forgetProvider = useCallback(
@ -806,7 +910,14 @@ export function useRuntimeProviderManagement(
);
const selectProvider = useCallback((providerId: string): void => {
setupFormRequestSeq.current += 1;
setSelectedProviderId(providerId);
setActiveFormProviderId(null);
setSetupForm(null);
setSetupFormError(null);
setSetupSubmitError(null);
setSetupMetadata({});
setApiKeyValue('');
}, []);
const state = useMemo<RuntimeProviderManagementState>(
@ -828,6 +939,11 @@ export function useRuntimeProviderManagement(
directorySelectedProviderId,
directorySupported,
activeFormProviderId,
setupForm,
setupFormLoading,
setupFormError,
setupSubmitError,
setupMetadata,
apiKeyValue,
modelPickerProviderId,
modelPickerMode,
@ -847,6 +963,11 @@ export function useRuntimeProviderManagement(
[
activeFormProviderId,
apiKeyValue,
setupForm,
setupFormError,
setupFormLoading,
setupSubmitError,
setupMetadata,
directoryEntries,
directoryError,
directoryFilter,
@ -894,7 +1015,8 @@ export function useRuntimeProviderManagement(
searchAllProviders,
startConnect,
cancelConnect,
setApiKeyValue,
setApiKeyValue: updateApiKeyValue,
setSetupMetadataValue,
submitConnect,
forgetProvider,
openModelPicker,
@ -920,9 +1042,11 @@ export function useRuntimeProviderManagement(
selectProvider,
setDefaultModel,
setDirectoryFilter,
setSetupMetadataValue,
startConnect,
submitConnect,
testModel,
updateApiKeyValue,
updateDirectoryQuery,
updateProviderQuery,
useModelForNewTeams,

View file

@ -5,6 +5,13 @@ import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import {
compareOpenCodeTeamModelRecommendations,
getOpenCodeTeamModelRecommendation,
@ -41,6 +48,7 @@ import type {
RuntimeProviderDirectoryFilterDto,
RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto,
RuntimeProviderSetupPromptDto,
} from '@features/runtime-provider-management/contracts';
import type { CSSProperties, JSX, KeyboardEvent } from 'react';
@ -64,7 +72,6 @@ interface ProviderRowProps {
readonly state: RuntimeProviderManagementState;
readonly active: boolean;
readonly formOpen: boolean;
readonly apiKeyValue: string;
readonly busy: boolean;
readonly disabled: boolean;
readonly actions: RuntimeProviderManagementActions;
@ -93,7 +100,7 @@ function formatDirectorySetupKind(provider: RuntimeProviderDirectoryEntryDto): s
case 'connect-api-key':
return 'Connect';
case 'configure-manually':
return 'Configure manually';
return 'Manual setup required';
case 'requires-environment':
return 'Requires environment';
case 'available-readonly':
@ -139,7 +146,7 @@ function directoryEntryMatchesQuery(
function directorySetupKindClassName(provider: RuntimeProviderDirectoryEntryDto): string {
switch (provider.setupKind) {
case 'connected':
return 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200';
return 'border-emerald-300/70 bg-emerald-600 text-emerald-50';
case 'connect-api-key':
case 'available-readonly':
return 'border-sky-400/30 bg-sky-400/10 text-sky-200';
@ -189,12 +196,193 @@ function stateStyle(provider: RuntimeProviderConnectionDto): CSSProperties | und
}
return {
color: '#86efac',
borderColor: 'rgba(74, 222, 128, 0.38)',
backgroundColor: 'rgba(74, 222, 128, 0.11)',
color: '#ecfdf5',
borderColor: 'rgba(134, 239, 172, 0.72)',
backgroundColor: '#16a34a',
};
}
function setupPromptVisible(
prompt: RuntimeProviderSetupPromptDto,
values: Readonly<Record<string, string>>
): boolean {
if (!prompt.when) {
return true;
}
const currentValue = values[prompt.when.key] ?? '';
switch (prompt.when.op) {
case 'eq':
return currentValue === prompt.when.value;
case 'neq':
case 'ne':
return currentValue !== prompt.when.value;
default:
return true;
}
}
function setupFormCanSubmit(state: RuntimeProviderManagementState, providerId: string): boolean {
const form = state.setupForm?.providerId === providerId ? state.setupForm : null;
if (!form?.supported || !form.secret || !state.apiKeyValue.trim()) {
return false;
}
return form.prompts
.filter((prompt) => setupPromptVisible(prompt, state.setupMetadata))
.every((prompt) => !prompt.required || Boolean(state.setupMetadata[prompt.key]?.trim()));
}
function ProviderSetupFormPanel({
provider,
state,
busy,
disabled,
actions,
}: {
readonly provider: RuntimeProviderConnectionDto;
readonly state: RuntimeProviderManagementState;
readonly busy: boolean;
readonly disabled: boolean;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null;
const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
const error = state.setupFormError;
const submitError =
state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null;
const canSubmit = setupFormCanSubmit(state, provider.providerId);
return (
<div
className="mt-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
onClick={(event) => event.stopPropagation()}
>
{loading ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<Loader2 className="size-3.5 animate-spin" />
Loading provider setup...
</div>
) : null}
{!loading && error ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{error}
</div>
) : null}
{!loading && form ? (
<div className="space-y-3">
<div>
<div className="text-xs font-medium text-[var(--color-text)]">{form.title}</div>
{form.description ? (
<div className="mt-1 text-[11px] text-[var(--color-text-muted)]">
{form.description}
</div>
) : null}
</div>
{form.secret ? (
<div className="space-y-1.5">
<Label htmlFor={`runtime-provider-key-${provider.providerId}`} className="text-xs">
{form.secret.label}
</Label>
<Input
id={`runtime-provider-key-${provider.providerId}`}
type="password"
value={state.apiKeyValue}
disabled={disabled || busy || !form.supported}
onChange={(event) => actions.setApiKeyValue(event.target.value)}
placeholder={form.secret.placeholder ?? 'Paste API key'}
className="h-9 text-sm"
autoFocus
/>
</div>
) : null}
{form.prompts
.filter((prompt) => setupPromptVisible(prompt, state.setupMetadata))
.map((prompt) => (
<div key={prompt.key} className="space-y-1.5">
<Label
htmlFor={`runtime-provider-${provider.providerId}-${prompt.key}`}
className="text-xs"
>
{prompt.label}
</Label>
{prompt.type === 'select' ? (
<Select
value={state.setupMetadata[prompt.key] ?? ''}
disabled={disabled || busy || !form.supported}
onValueChange={(value) => actions.setSetupMetadataValue(prompt.key, value)}
>
<SelectTrigger
id={`runtime-provider-${provider.providerId}-${prompt.key}`}
className="h-9 text-sm"
>
<SelectValue placeholder={prompt.placeholder ?? 'Select value'} />
</SelectTrigger>
<SelectContent>
{prompt.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`runtime-provider-${provider.providerId}-${prompt.key}`}
type={prompt.secret ? 'password' : 'text'}
value={state.setupMetadata[prompt.key] ?? ''}
disabled={disabled || busy || !form.supported}
onChange={(event) =>
actions.setSetupMetadataValue(prompt.key, event.target.value)
}
placeholder={prompt.placeholder ?? undefined}
className="h-9 text-sm"
/>
)}
</div>
))}
{form.disabledReason && !form.supported ? (
<div className="rounded-md border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-[var(--color-text-muted)]">
{form.disabledReason}
</div>
) : null}
</div>
) : null}
{submitError ? (
<div className="mt-3 rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{submitError}
</div>
) : null}
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={actions.cancelConnect}
>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={disabled || busy || loading || !canSubmit}
onClick={() => void actions.submitConnect(provider.providerId)}
>
{busy ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
{form?.submitLabel ?? 'Connect'}
</Button>
</div>
</div>
);
}
function RuntimeSummary({
state,
onRefresh,
@ -457,7 +645,10 @@ function ProviderActions({
variant="outline"
disabled={disabled || busy || !connect.enabled}
title={connect.disabledReason ?? undefined}
onClick={onStartConnect}
onClick={(event) => {
event.stopPropagation();
onStartConnect();
}}
>
{busy ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
@ -478,7 +669,10 @@ function ProviderActions({
variant="ghost"
disabled={disabled || busy || !forget.enabled}
title={forget.disabledReason ?? undefined}
onClick={onForget}
onClick={(event) => {
event.stopPropagation();
onForget();
}}
>
{busy ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
@ -508,20 +702,39 @@ function ProviderRow({
state,
active,
formOpen,
apiKeyValue,
busy,
disabled,
actions,
}: ProviderRowProps): JSX.Element {
const connect = getProviderAction(provider, 'connect');
const canOpenConnect = provider.state !== 'connected' && connect?.enabled === true;
const canSelectModels = provider.state === 'connected' && provider.modelCount > 0;
const clickable = !disabled && (canOpenConnect || canSelectModels);
const visuallyActive = active && (canSelectModels || formOpen);
const handleActivate = (): void => {
if (!clickable) {
return;
}
if (canOpenConnect) {
actions.startConnect(provider.providerId);
return;
}
actions.selectProvider(provider.providerId);
};
return (
<div
data-testid={`runtime-provider-row-${provider.providerId}`}
className={`cursor-pointer rounded-lg border p-3 transition-all hover:border-sky-300/60 hover:bg-sky-400/[0.08] hover:shadow-[0_0_0_1px_rgba(125,211,252,0.18)] ${
active
className={`rounded-lg border p-3 transition-all ${
clickable
? 'cursor-pointer hover:border-sky-300/60 hover:bg-sky-400/[0.08] hover:shadow-[0_0_0_1px_rgba(125,211,252,0.18)]'
: 'cursor-default'
} ${
visuallyActive
? 'border-sky-300/70 bg-sky-400/[0.075] shadow-[0_0_0_1px_rgba(125,211,252,0.22)]'
: 'border-[var(--color-border-subtle)] bg-white/[0.02]'
}`}
onClick={() => actions.selectProvider(provider.providerId)}
onClick={handleActivate}
>
<div className="grid w-full grid-cols-[1fr_auto] items-start gap-3">
<div className="min-w-0 text-left">
@ -575,46 +788,13 @@ function ProviderRow({
</div>
{formOpen ? (
<div
className="mt-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="space-y-1.5">
<Label htmlFor={`runtime-provider-key-${provider.providerId}`} className="text-xs">
{provider.displayName} API key
</Label>
<Input
id={`runtime-provider-key-${provider.providerId}`}
type="password"
value={apiKeyValue}
disabled={disabled || busy}
onChange={(event) => actions.setApiKeyValue(event.target.value)}
placeholder="Paste API key"
className="h-9 text-sm"
autoFocus
/>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={actions.cancelConnect}
>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={disabled || busy || !apiKeyValue.trim()}
onClick={() => void actions.submitConnect(provider.providerId)}
>
{busy ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
Save key
</Button>
</div>
</div>
<ProviderSetupFormPanel
provider={provider}
state={state}
busy={busy}
disabled={disabled}
actions={actions}
/>
) : null}
{active && provider.state === 'connected' && provider.modelCount > 0 ? (
@ -634,7 +814,6 @@ function DirectoryProviderRow({
state,
active,
formOpen,
apiKeyValue,
disabled,
busy,
actions,
@ -643,7 +822,6 @@ function DirectoryProviderRow({
readonly state: RuntimeProviderManagementState;
readonly active: boolean;
readonly formOpen: boolean;
readonly apiKeyValue: string;
readonly disabled: boolean;
readonly busy: boolean;
readonly actions: RuntimeProviderManagementActions;
@ -651,24 +829,42 @@ function DirectoryProviderRow({
const connect = getDirectoryAction(provider, 'connect');
const configure = getDirectoryAction(provider, 'configure');
const forget = getDirectoryAction(provider, 'forget');
const canOpenConnect = provider.state !== 'connected' && connect?.enabled === true;
const canSelectModels = provider.state === 'connected' && provider.modelCount !== 0;
const clickable = !disabled && (canOpenConnect || canSelectModels);
const visuallyActive = active && (canSelectModels || formOpen);
const handleActivate = (): void => {
if (!clickable) {
return;
}
if (canOpenConnect) {
actions.startConnect(provider.providerId);
return;
}
actions.selectDirectoryProvider(provider.providerId);
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
tabIndex={clickable ? 0 : -1}
data-testid={`runtime-provider-directory-row-${provider.providerId}`}
className={`cursor-pointer rounded-lg border p-3 transition-all hover:border-sky-300/60 hover:bg-sky-400/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 ${
active
className={`rounded-lg border p-3 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 ${
clickable
? 'cursor-pointer hover:border-sky-300/60 hover:bg-sky-400/[0.08]'
: 'cursor-default'
} ${
visuallyActive
? 'border-sky-300/70 bg-sky-400/[0.075] shadow-[0_0_0_1px_rgba(125,211,252,0.22)]'
: 'border-[var(--color-border-subtle)] bg-white/[0.02]'
}`}
onClick={() => actions.selectDirectoryProvider(provider.providerId)}
onClick={handleActivate}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
if (!clickable || (event.key !== 'Enter' && event.key !== ' ')) {
return;
}
event.preventDefault();
actions.selectDirectoryProvider(provider.providerId);
handleActivate();
}}
>
<div className="grid grid-cols-[1fr_auto] gap-3">
@ -760,47 +956,13 @@ function DirectoryProviderRow({
</div>
{formOpen ? (
<div
className="mt-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
onClick={(event) => event.stopPropagation()}
>
<div className="space-y-1.5">
<Label htmlFor={`runtime-provider-key-${provider.providerId}`} className="text-xs">
{provider.displayName} API key
</Label>
<Input
id={`runtime-provider-key-${provider.providerId}`}
type="password"
value={apiKeyValue}
disabled={disabled || busy}
onChange={(event) => actions.setApiKeyValue(event.target.value)}
placeholder="Paste API key"
className="h-9 text-sm"
autoFocus
/>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={actions.cancelConnect}
>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={disabled || busy || !apiKeyValue.trim()}
onClick={() => void actions.submitConnect(provider.providerId)}
>
{busy ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
Save key
</Button>
</div>
</div>
<ProviderSetupFormPanel
provider={directoryEntryToProviderConnection(provider)}
state={state}
busy={busy}
disabled={disabled}
actions={actions}
/>
) : null}
{active && provider.state === 'connected' && provider.modelCount !== 0 ? (
@ -909,7 +1071,6 @@ function ProviderDirectoryPanel({
state={state}
active={active}
formOpen={state.activeFormProviderId === provider.providerId}
apiKeyValue={state.apiKeyValue}
disabled={disabled || state.directoryLoading}
busy={state.savingProviderId === provider.providerId}
actions={actions}
@ -962,15 +1123,20 @@ function ModelBadges({
<Badge
className={
modelRecommendation.level === 'recommended'
? 'bg-amber-400/15 px-1.5 py-0 text-[10px] text-amber-200'
: 'bg-red-400/15 px-1.5 py-0 text-[10px] text-red-200'
? 'bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200'
: modelRecommendation.level === 'recommended-with-limits'
? 'bg-amber-400/15 px-1.5 py-0 text-[10px] text-amber-200'
: modelRecommendation.level === 'unavailable-in-opencode'
? 'bg-slate-400/15 px-1.5 py-0 text-[10px] text-slate-200'
: 'bg-red-400/15 px-1.5 py-0 text-[10px] text-red-200'
}
title={modelRecommendation.reason}
>
{modelRecommendation.level === 'recommended' ? (
<Star className="mr-1 size-3 fill-current" />
) : (
{modelRecommendation.level === 'not-recommended' ||
modelRecommendation.level === 'unavailable-in-opencode' ? (
<AlertTriangle className="mr-1 size-3" />
) : (
<Star className="mr-1 size-3 fill-current" />
)}
{modelRecommendation.label}
</Badge>
@ -1036,6 +1202,7 @@ function ModelRow({
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.stopPropagation();
event.preventDefault();
chooseModel();
};
@ -1047,7 +1214,10 @@ function ModelRow({
aria-pressed={selected}
data-testid={`runtime-provider-model-row-${model.modelId}`}
className="cursor-pointer rounded-md border px-3 py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45"
onClick={chooseModel}
onClick={(event) => {
event.stopPropagation();
chooseModel();
}}
onKeyDown={handleKeyDown}
style={{
borderColor: selected ? 'rgba(96, 165, 250, 0.45)' : 'var(--color-border-subtle)',
@ -1146,13 +1316,19 @@ function ProviderModelList({
value={state.modelQuery}
disabled={disabled || state.modelsLoading}
onChange={(event) => actions.setModelQuery(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search models"
className="h-10 pl-10 pr-3 text-sm leading-5"
style={{ paddingLeft: 42 }}
/>
</div>
{hasRecommendedModels ? (
<div className="flex h-10 items-center gap-2 rounded-md border border-white/10 px-3">
<div
className="flex h-10 items-center gap-2 rounded-md border border-white/10 px-3"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Checkbox
id={`runtime-provider-${provider.providerId}-recommended-only`}
checked={recommendedOnly}
@ -1341,7 +1517,6 @@ export function RuntimeProviderManagementPanelView({
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
apiKeyValue={state.apiKeyValue}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.directoryLoading}
actions={actions}
@ -1376,7 +1551,6 @@ export function RuntimeProviderManagementPanelView({
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
apiKeyValue={state.apiKeyValue}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.loading}
actions={actions}

View file

@ -1,4 +1,4 @@
import opencodeIconUrl from '../assets/provider-icons/opencode-favicon.png';
import { useEffect, useState } from 'react';
import type { CSSProperties, JSX } from 'react';
@ -49,6 +49,8 @@ const MINIMAX_PATH =
'M11.43 3.92a.86.86 0 1 0-1.718 0v14.236a1.999 1.999 0 0 1-3.997 0V9.022a.86.86 0 1 0-1.718 0v3.87a1.999 1.999 0 0 1-3.997 0V11.49a.57.57 0 0 1 1.139 0v1.404a.86.86 0 0 0 1.719 0V9.022a1.999 1.999 0 0 1 3.997 0v9.134a.86.86 0 0 0 1.719 0V3.92a1.998 1.998 0 1 1 3.996 0v11.788a.57.57 0 1 1-1.139 0zm10.572 3.105a2 2 0 0 0-1.999 1.997v7.63a.86.86 0 0 1-1.718 0V3.923a1.999 1.999 0 0 0-3.997 0v16.16a.86.86 0 0 1-1.719 0V18.08a.57.57 0 1 0-1.138 0v2a1.998 1.998 0 0 0 3.996 0V3.92a.86.86 0 0 1 1.719 0v12.73a1.999 1.999 0 0 0 3.996 0V9.023a.86.86 0 1 1 1.72 0v6.686a.57.57 0 0 0 1.138 0V9.022a2 2 0 0 0-1.998-1.997';
const NVIDIA_PATH =
'M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z';
const OPENCODE_PATH =
'M8.40005 17.4H19.2001V21H4.80005V13.8H8.40005V17.4ZM15.6001 10.2V13.8H8.40005V10.2H15.6001ZM19.2001 10.2H15.6001V6.6H4.80005V3H19.2001V10.2Z';
const PERPLEXITY_PATH =
'M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z';
const VERCEL_PATH = 'm12 1.608 12 20.784H0Z';
@ -224,10 +226,12 @@ const BRAND_ICONS: Record<string, BrandIconDescriptor> = {
paths: [{ d: NVIDIA_PATH }],
},
opencode: {
kind: 'image',
src: opencodeIconUrl,
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(148, 163, 184, 0.12)',
border: 'rgba(148, 163, 184, 0.32)',
color: '#94A3B8',
paths: [{ d: OPENCODE_PATH }],
},
openai: {
kind: 'svg',
@ -389,6 +393,111 @@ const BRAND_ALIASES: Record<string, string> = {
vertex: 'google-vertex',
};
// Verified against https://models.dev/logos/{provider}.svg by comparing each
// current provider logo to the Models.dev default fallback SVG.
const MODELS_DEV_LOGO_PROVIDER_IDS = new Set([
'302ai',
'abacus',
'aihubmix',
'alibaba',
'alibaba-cn',
'alibaba-coding-plan',
'alibaba-coding-plan-cn',
'amazon-bedrock',
'anthropic',
'azure',
'bailing',
'baseten',
'berget',
'cerebras',
'cloudferro-sherlock',
'cloudflare-ai-gateway',
'cloudflare-workers-ai',
'cohere',
'deepinfra',
'deepseek',
'digitalocean',
'dinference',
'drun',
'evroc',
'fastrouter',
'fireworks-ai',
'firmware',
'friendli',
'github-copilot',
'github-models',
'gitlab',
'google',
'google-vertex',
'groq',
'helicone',
'hpc-ai',
'huggingface',
'iflowcn',
'inception',
'inference',
'io-net',
'jiekou',
'kilo',
'kimi-for-coding',
'kuae-cloud-coding-plan',
'llama',
'llmgateway',
'lucidquery',
'meganova',
'minimax',
'minimax-cn',
'mistral',
'mixlayer',
'moark',
'modelscope',
'moonshotai',
'moonshotai-cn',
'nano-gpt',
'nebius',
'nova',
'novita-ai',
'nvidia',
'ollama-cloud',
'openai',
'opencode',
'opencode-go',
'openrouter',
'ovhcloud',
'perplexity',
'perplexity-agent',
'poe',
'privatemode-ai',
'qihang-ai',
'qiniu-ai',
'regolo-ai',
'scaleway',
'siliconflow',
'siliconflow-cn',
'stackit',
'submodel',
'tencent-coding-plan',
'tencent-tokenhub',
'the-grid-ai',
'togetherai',
'v0',
'venice',
'vercel',
'vivgrid',
'vultr',
'wafer.ai',
'xai',
'xiaomi',
'xiaomi-token-plan-ams',
'xiaomi-token-plan-cn',
'xiaomi-token-plan-sgp',
'zai',
'zai-coding-plan',
'zenmux',
'zhipuai',
'zhipuai-coding-plan',
]);
function normalizeProviderKey(value: string): string {
return value
.trim()
@ -397,26 +506,80 @@ function normalizeProviderKey(value: string): string {
.replace(/(?:^-)|(?:-$)/g, '');
}
function getBrandIconKey(provider: ProviderBrand): string | null {
function normalizeModelsDevProviderId(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/(?:^-)|(?:-$)/g, '');
}
function hasLocalGraphicIcon(key: string): boolean {
const descriptor = BRAND_ICONS[key];
return Boolean(descriptor && descriptor.kind !== 'letters');
}
function hasLetterIcon(key: string): boolean {
return Boolean(BRAND_ICONS[key]?.kind === 'letters' || LETTER_BRANDS[key]);
}
function getLocalBrandIconKey(provider: ProviderBrand): string | null {
const providerId = normalizeProviderKey(provider.providerId);
const displayName = normalizeProviderKey(provider.displayName);
const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId;
const aliasedDisplayName = BRAND_ALIASES[displayName] ?? displayName;
const direct = BRAND_ICONS[aliasedProviderId]
const direct = hasLocalGraphicIcon(aliasedProviderId)
? aliasedProviderId
: LETTER_BRANDS[aliasedProviderId]
? aliasedProviderId
: BRAND_ICONS[aliasedDisplayName]
? aliasedDisplayName
: LETTER_BRANDS[aliasedDisplayName]
? aliasedDisplayName
: null;
: hasLocalGraphicIcon(aliasedDisplayName)
? aliasedDisplayName
: null;
if (direct) {
return direct;
}
for (const [needle, iconKey] of Object.entries(BRAND_ALIASES)) {
if (displayName.includes(needle) || providerId.includes(needle)) {
if (
(displayName.includes(needle) || providerId.includes(needle)) &&
hasLocalGraphicIcon(iconKey)
) {
return iconKey;
}
}
return null;
}
function getModelsDevLogoKey(provider: ProviderBrand): string | null {
const providerId = normalizeProviderKey(provider.providerId);
const displayName = normalizeProviderKey(provider.displayName);
const candidates = [
normalizeModelsDevProviderId(provider.providerId),
BRAND_ALIASES[providerId],
providerId,
normalizeModelsDevProviderId(provider.displayName),
BRAND_ALIASES[displayName],
displayName,
].filter((candidate): candidate is string => Boolean(candidate));
return candidates.find((candidate) => MODELS_DEV_LOGO_PROVIDER_IDS.has(candidate)) ?? null;
}
function getLetterBrandIconKey(provider: ProviderBrand): string | null {
const providerId = normalizeProviderKey(provider.providerId);
const displayName = normalizeProviderKey(provider.displayName);
const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId;
const aliasedDisplayName = BRAND_ALIASES[displayName] ?? displayName;
const direct = hasLetterIcon(aliasedProviderId)
? aliasedProviderId
: hasLetterIcon(aliasedDisplayName)
? aliasedDisplayName
: null;
if (direct) {
return direct;
}
for (const [needle, iconKey] of Object.entries(BRAND_ALIASES)) {
if ((displayName.includes(needle) || providerId.includes(needle)) && hasLetterIcon(iconKey)) {
return iconKey;
}
}
@ -436,42 +599,78 @@ function fallbackDescriptor(provider: ProviderBrand): BrandIconDescriptor {
}
function descriptorFor(provider: ProviderBrand): BrandIconDescriptor {
const key = getBrandIconKey(provider);
return key
? (BRAND_ICONS[key] ?? LETTER_BRANDS[key] ?? fallbackDescriptor(provider))
: fallbackDescriptor(provider);
const localKey = getLocalBrandIconKey(provider);
if (localKey) {
return BRAND_ICONS[localKey] ?? fallbackDescriptor(provider);
}
const modelsDevKey = getModelsDevLogoKey(provider);
if (modelsDevKey) {
return {
kind: 'image',
src: `https://models.dev/logos/${encodeURIComponent(modelsDevKey)}.svg`,
background: 'rgba(148, 163, 184, 0.12)',
border: 'rgba(148, 163, 184, 0.28)',
};
}
const letterKey = getLetterBrandIconKey(provider);
if (letterKey) {
return LETTER_BRANDS[letterKey] ?? fallbackDescriptor(provider);
}
return fallbackDescriptor(provider);
}
function shellStyle(descriptor: BrandIconDescriptor): CSSProperties {
return {
backgroundColor: descriptor.background,
borderColor: descriptor.border,
color: descriptor.kind === 'image' ? undefined : descriptor.color,
};
const style: CSSProperties & Record<string, string | undefined> = {};
style['--runtime-provider-brand-fallback-background'] = descriptor.background;
style['--runtime-provider-brand-fallback-border'] = descriptor.border;
if (descriptor.kind !== 'image') {
style['--runtime-provider-brand-fallback-color'] = descriptor.color;
}
return style;
}
export function ProviderBrandIcon({ provider }: { readonly provider: ProviderBrand }): JSX.Element {
const descriptor = descriptorFor(provider);
const [imageFailed, setImageFailed] = useState(false);
const imageSrc = descriptor.kind === 'image' ? descriptor.src : null;
useEffect(() => {
setImageFailed(false);
}, [imageSrc]);
const renderedDescriptor =
descriptor.kind === 'image' && imageFailed ? fallbackDescriptor(provider) : descriptor;
return (
<span
data-testid={`runtime-provider-logo-${provider.providerId}`}
aria-hidden="true"
className="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border"
style={shellStyle(descriptor)}
className="runtime-provider-brand-icon inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border"
style={shellStyle(renderedDescriptor)}
>
{descriptor.kind === 'image' ? (
<img src={descriptor.src} alt="" className="size-5 object-contain" draggable={false} />
{renderedDescriptor.kind === 'image' ? (
<img
src={renderedDescriptor.src}
alt=""
className="size-5 object-contain"
draggable={false}
onError={() => setImageFailed(true)}
/>
) : null}
{descriptor.kind === 'svg' ? (
<svg viewBox={descriptor.viewBox} className="h-[18px] w-[18px]" focusable="false">
{descriptor.paths.map((path) => (
{renderedDescriptor.kind === 'svg' ? (
<svg viewBox={renderedDescriptor.viewBox} className="h-[18px] w-[18px]" focusable="false">
{renderedDescriptor.paths.map((path) => (
<path key={path.d} d={path.d} fill={path.fill ?? 'currentColor'} />
))}
</svg>
) : null}
{descriptor.kind === 'letters' ? (
<span className="text-[10px] font-semibold leading-none">{descriptor.label}</span>
{renderedDescriptor.kind === 'letters' ? (
<span className="text-[10px] font-semibold leading-none">{renderedDescriptor.label}</span>
) : null}
</span>
);

View file

@ -5081,10 +5081,7 @@ export class TeamProvisioningService {
});
const records = await ledger.list().catch(() => []);
for (const record of records) {
if (
record.status === 'failed_terminal' ||
(record.status === 'responded' && record.inboxReadCommittedAt)
) {
if (record.status === 'failed_terminal' || record.status === 'responded') {
continue;
}
const nextAttemptMs = record.nextAttemptAt ? Date.parse(record.nextAttemptAt) : NaN;
@ -5340,13 +5337,37 @@ export class TeamProvisioningService {
? this.createOpenCodePromptDeliveryLedger(teamName, laneIdentity.laneId)
: null;
const now = nowIso();
const active = ledger
let active = ledger
? await ledger.getActiveForMember({
teamName,
memberName: canonicalMemberName,
laneId: laneIdentity.laneId,
})
: null;
if (active && active.inboxMessageId !== messageId && ledger) {
const proof = await this.applyOpenCodeVisibleDestinationProof({
ledger,
ledgerRecord: active,
teamName,
replyRecipient: active.replyRecipient,
memberName: canonicalMemberName,
});
active = proof.ledgerRecord;
const activeReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
responseState: active.responseState,
actionMode: active.actionMode ?? undefined,
taskRefs: active.taskRefs,
visibleReply: proof.visibleReply,
ledgerRecord: active,
});
if (activeReadAllowed) {
this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_response_observed', active, {
visibleReplySemanticallySufficient: true,
unblockedNextDelivery: true,
});
active = null;
}
}
if (active && active.inboxMessageId !== messageId) {
const activeDueMs = active.nextAttemptAt ? Date.parse(active.nextAttemptAt) : NaN;
this.scheduleOpenCodePromptDeliveryWatchdog({

View file

@ -786,10 +786,7 @@ function isTaskRefArray(value: unknown): value is TaskRef[] {
}
function isTerminalForAutomaticSelection(record: OpenCodePromptDeliveryLedgerRecord): boolean {
return (
record.status === 'failed_terminal' ||
(record.status === 'responded' && record.inboxReadCommittedAt != null)
);
return record.status === 'failed_terminal' || record.status === 'responded';
}
function compareOpenCodePromptDeliveryDueOrder(

View file

@ -1207,6 +1207,24 @@ export class HttpAPIClient implements ElectronAPI {
recoverable: true,
},
}),
loadSetupForm: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
connectProvider: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
connectWithApiKey: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,

View file

@ -537,15 +537,20 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
className={cn(
'inline-flex items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold',
modelRecommendation.level === 'recommended'
? 'bg-amber-300/12 border-amber-300/35 text-amber-200'
: 'border-red-300/35 bg-red-400/10 text-red-200'
? 'bg-emerald-300/12 border-emerald-300/35 text-emerald-200'
: modelRecommendation.level === 'recommended-with-limits'
? 'bg-amber-300/12 border-amber-300/35 text-amber-200'
: modelRecommendation.level === 'unavailable-in-opencode'
? 'border-slate-300/30 bg-slate-400/10 text-slate-200'
: 'border-red-300/35 bg-red-400/10 text-red-200'
)}
title={modelRecommendation.reason}
>
{modelRecommendation.level === 'recommended' ? (
<Star className="size-3 shrink-0 fill-current" />
) : (
{modelRecommendation.level === 'not-recommended' ||
modelRecommendation.level === 'unavailable-in-opencode' ? (
<AlertTriangle className="size-3 shrink-0" />
) : (
<Star className="size-3 shrink-0 fill-current" />
)}
<span>{modelRecommendation.label}</span>
</span>

View file

@ -419,6 +419,36 @@
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
}
/* Provider logos - keep verified brand marks legible on dark provider cards */
.runtime-provider-brand-icon {
background-color: var(
--runtime-provider-brand-background,
var(--runtime-provider-brand-fallback-background, rgba(148, 163, 184, 0.12))
);
border-color: var(
--runtime-provider-brand-border,
var(--runtime-provider-brand-fallback-border, rgba(148, 163, 184, 0.28))
);
color: var(--runtime-provider-brand-fallback-color, inherit);
}
.runtime-provider-brand-icon > img {
filter: var(--runtime-provider-brand-image-filter, none);
}
:root:not(.light) .runtime-provider-brand-icon {
--runtime-provider-brand-background: rgba(15, 23, 42, 0.72);
--runtime-provider-brand-border: rgba(226, 232, 240, 0.2);
--runtime-provider-brand-mark: #f8fafc;
--runtime-provider-brand-image-filter: invert(1);
color: var(--runtime-provider-brand-mark);
}
:root.light .runtime-provider-brand-icon {
--runtime-provider-brand-image-filter: none;
}
/* Light theme overrides - Warm neutral palette for eye comfort */
:root.light {

View file

@ -1,4 +1,8 @@
export type OpenCodeTeamModelRecommendationLevel = 'recommended' | 'not-recommended';
export type OpenCodeTeamModelRecommendationLevel =
| 'recommended'
| 'recommended-with-limits'
| 'unavailable-in-opencode'
| 'not-recommended';
export interface OpenCodeTeamModelRecommendation {
readonly level: OpenCodeTeamModelRecommendationLevel;
@ -9,20 +13,68 @@ export interface OpenCodeTeamModelRecommendation {
const PASSED_REAL_AGENT_TEAMS_E2E_REASON =
'This exact model route passed real OpenCode Agent Teams E2E: launch, direct reply, and teammate-to-teammate relay.';
const PASSED_FREE_ROUTE_REAL_AGENT_TEAMS_E2E_REASON =
'This exact free model route passed real OpenCode Agent Teams E2E, but free routes can still have capacity limits, rate limits, and variable latency.';
const OPENCODE_TEAM_RECOMMENDED_MODELS = new Set<string>([
'opencode/minimax-m2.5-free',
'openrouter/anthropic/claude-haiku-4.5',
'openrouter/anthropic/claude-opus-4.6',
'openrouter/anthropic/claude-opus-4.7',
'openrouter/anthropic/claude-sonnet-4.5',
'openrouter/deepseek/deepseek-v3.2',
'openrouter/anthropic/claude-sonnet-4.6',
'openrouter/google/gemini-2.5-flash',
'openrouter/google/gemini-2.5-flash-lite',
'openrouter/google/gemini-3.1-flash-lite-preview',
'openrouter/google/gemini-3.1-pro-preview',
'openrouter/google/gemini-3-flash-preview',
'openrouter/minimax/minimax-m2.5',
'openrouter/minimax/minimax-m2.7',
'openrouter/moonshotai/kimi-k2.6',
'openrouter/mistralai/codestral-2508',
'openrouter/mistralai/devstral-2512',
'openrouter/mistralai/mistral-medium-3.1',
'openrouter/openai/gpt-5.1',
'openrouter/openai/gpt-5.1-codex',
'openrouter/openai/gpt-5.1-codex-mini',
'openrouter/openai/gpt-5.3-codex',
'openrouter/openai/gpt-5.4',
'openrouter/openai/gpt-5.4-mini',
'openrouter/openai/gpt-oss-120b:free',
'openrouter/qwen/qwen3-max',
'openrouter/qwen/qwen3-coder',
'openrouter/qwen/qwen3-coder-flash',
'openrouter/x-ai/grok-4.1-fast',
'openrouter/x-ai/grok-4-fast',
'openrouter/xiaomi/mimo-v2-pro',
'openrouter/z-ai/glm-4.6',
'openrouter/z-ai/glm-5',
'openrouter/z-ai/glm-5.1',
]);
const OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS = new Set<string>([
'opencode/minimax-m2.5-free',
'openrouter/openai/gpt-oss-120b:free',
]);
const OPENCODE_TEAM_UNAVAILABLE_MODELS = new Map<string, string>([
[
'openrouter/qwen/qwen3-coder-plus',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/qwen/qwen3-coder-next',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/qwen/qwen3-max-thinking',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/mistralai/devstral-medium',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/mistralai/mistral-large-2512',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
]);
const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map<string, string>([
@ -38,10 +90,18 @@ const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map<string, string>([
'openrouter/google/gemini-2.5-pro',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay.',
],
[
'openrouter/google/gemini-2.5-flash-lite',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay with plain/control-character output instead of MCP message_send.',
],
[
'openrouter/google/gemini-3-pro-preview',
'OpenRouter reported no runnable endpoints for this model during execution verification.',
],
[
'openrouter/deepseek/deepseek-v3.2',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay after treating Agent Teams MCP tools as unavailable.',
],
[
'openrouter/meta-llama/llama-3.3-70b-instruct:free',
'Execution verification timed out before Agent Teams launch could proceed.',
@ -50,6 +110,18 @@ const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map<string, string>([
'openrouter/minimax/minimax-m2.5:free',
'This OpenRouter free route for MiniMax M2.5 passed direct reply but failed teammate-to-teammate relay. The non-free OpenRouter route and the OpenCode free alias are tracked separately.',
],
[
'openrouter/moonshotai/kimi-k2-thinking',
'Real OpenCode Agent Teams E2E failed during launch reconciliation with an aborted assistant message.',
],
[
'openrouter/openai/gpt-5.2-codex',
'Real OpenCode Agent Teams E2E failed launch readiness because model verification timed out.',
],
[
'openrouter/openai/gpt-5.1-chat',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay by delegating to the lead instead of messaging the requested teammate.',
],
[
'openrouter/openai/gpt-oss-20b:free',
'Execution verification passed, but real Agent Teams E2E produced fake tool text instead of MCP message_send.',
@ -58,6 +130,10 @@ const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map<string, string>([
'openrouter/openrouter/free',
'Aggregator routing was unstable in real Agent Teams E2E and timed out during peer relay.',
],
[
'openrouter/x-ai/grok-code-fast-1',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay by delegating to the lead instead of messaging the requested teammate.',
],
[
'openrouter/z-ai/glm-4.5-air:free',
'Real OpenCode Agent Teams E2E was slow and failed peer relay with empty assistant turns.',
@ -84,6 +160,23 @@ export function getOpenCodeTeamModelRecommendation(
};
}
if (OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS.has(normalizedModelId)) {
return {
level: 'recommended-with-limits',
label: 'Recommended with limits',
reason: PASSED_FREE_ROUTE_REAL_AGENT_TEAMS_E2E_REASON,
};
}
const unavailableReason = OPENCODE_TEAM_UNAVAILABLE_MODELS.get(normalizedModelId);
if (unavailableReason) {
return {
level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode',
reason: unavailableReason,
};
}
const notRecommendedReason = OPENCODE_TEAM_NOT_RECOMMENDED_MODELS.get(normalizedModelId);
if (notRecommendedReason) {
return {
@ -97,7 +190,10 @@ export function getOpenCodeTeamModelRecommendation(
}
export function isOpenCodeTeamModelRecommended(modelId: string | null | undefined): boolean {
return getOpenCodeTeamModelRecommendation(modelId)?.level === 'recommended';
const recommendation = getOpenCodeTeamModelRecommendation(modelId);
return (
recommendation?.level === 'recommended' || recommendation?.level === 'recommended-with-limits'
);
}
export function getOpenCodeTeamModelRecommendationSortRank(
@ -107,10 +203,16 @@ export function getOpenCodeTeamModelRecommendationSortRank(
if (recommendation?.level === 'recommended') {
return 0;
}
if (recommendation?.level === 'not-recommended') {
return 2;
if (recommendation?.level === 'recommended-with-limits') {
return 1;
}
return 1;
if (recommendation?.level === 'unavailable-in-opencode') {
return 3;
}
if (recommendation?.level === 'not-recommended') {
return 4;
}
return 2;
}
export function compareOpenCodeTeamModelRecommendations(

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
const buildProviderAwareCliEnvMock = vi.fn();
const resolveBinaryMock = vi.fn();
@ -6,6 +7,43 @@ const execCliMock = vi.fn();
const spawnCliMock = vi.fn();
const resolveInteractiveShellEnvMock = vi.fn();
function createSpawnProcess(stdoutPayload: unknown, exitCode = 0): {
child: {
stdout: EventEmitter;
stderr: EventEmitter;
stdin: {
write: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
};
once: EventEmitter['once'];
};
stdinWrite: ReturnType<typeof vi.fn>;
} {
const processEvents = new EventEmitter();
const stdout = new EventEmitter();
const stderr = new EventEmitter();
const stdinWrite = vi.fn();
const stdinEnd = vi.fn(() => {
queueMicrotask(() => {
stdout.emit('data', Buffer.from(JSON.stringify(stdoutPayload)));
processEvents.emit('close', exitCode);
});
});
return {
child: {
stdout,
stderr,
stdin: {
write: stdinWrite,
end: stdinEnd,
},
once: processEvents.once.bind(processEvents),
},
stdinWrite,
};
}
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
buildProviderAwareCliEnv: (...args: unknown[]) => buildProviderAwareCliEnvMock(...args),
}));
@ -209,4 +247,121 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
);
expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
});
it('loads provider setup forms through the CLI contract', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
setupForm: {
runtimeId: 'opencode',
providerId: 'openrouter',
displayName: 'OpenRouter',
method: 'api',
supported: true,
title: 'Connect OpenRouter',
description: null,
submitLabel: 'Connect',
disabledReason: null,
source: 'curated',
secret: {
key: 'key',
label: 'API key',
placeholder: 'Paste API key',
required: true,
},
prompts: [],
},
}),
stderr: '',
});
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadSetupForm({
runtimeId: 'opencode',
providerId: 'openrouter',
projectPath: '/Users/test/project',
});
expect(response.setupForm?.providerId).toBe('openrouter');
expect(execCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
[
'runtime',
'providers',
'setup-form',
'--runtime',
'opencode',
'--provider',
'openrouter',
'--json',
'--project-path',
'/Users/test/project',
],
expect.objectContaining({ cwd: '/Users/test/project' })
);
});
it('passes generic provider setup payload through stdin JSON only', async () => {
const { child, stdinWrite } = createSpawnProcess({
schemaVersion: 1,
runtimeId: 'opencode',
provider: {
providerId: 'cloudflare-ai-gateway',
displayName: 'Cloudflare AI Gateway',
state: 'connected',
ownership: ['managed'],
recommended: false,
modelCount: 0,
defaultModelId: null,
authMethods: ['api'],
actions: [],
detail: null,
},
});
spawnCliMock.mockReturnValue(child);
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.connectProvider({
runtimeId: 'opencode',
providerId: 'cloudflare-ai-gateway',
method: 'api',
apiKey: 'sk-secret-value',
metadata: {
accountId: 'account-123',
gatewayId: 'gateway-456',
},
projectPath: '/Users/test/project',
});
expect(response.provider?.providerId).toBe('cloudflare-ai-gateway');
expect(spawnCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
[
'runtime',
'providers',
'connect',
'--runtime',
'opencode',
'--provider',
'cloudflare-ai-gateway',
'--stdin-json',
'--json',
'--project-path',
'/Users/test/project',
],
expect.objectContaining({ cwd: '/Users/test/project' })
);
expect(JSON.stringify(spawnCliMock.mock.calls[0])).not.toContain('sk-secret-value');
expect(stdinWrite).toHaveBeenCalledWith(
JSON.stringify({
method: 'api',
apiKey: 'sk-secret-value',
metadata: {
accountId: 'account-123',
gatewayId: 'gateway-456',
},
})
);
});
});

View file

@ -2,9 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
import {
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
} from '../../../../src/features/runtime-provider-management/contracts';
@ -12,6 +14,7 @@ import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/fea
import type {
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementViewResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementModelTestResponse,
@ -118,9 +121,34 @@ describe('registerRuntimeProviderManagementIpc', () => {
diagnostics: [],
},
};
const setupFormResponse: RuntimeProviderManagementSetupFormResponse = {
schemaVersion: 1,
runtimeId: 'opencode',
setupForm: {
runtimeId: 'opencode',
providerId: 'openrouter',
displayName: 'OpenRouter',
method: 'api',
supported: true,
title: 'Connect OpenRouter',
description: null,
submitLabel: 'Connect',
disabledReason: null,
source: 'curated',
secret: {
key: 'key',
label: 'API key',
placeholder: 'Paste API key',
required: true,
},
prompts: [],
},
};
const feature: RuntimeProviderManagementFeatureFacade = {
loadView: vi.fn(() => Promise.resolve(viewResponse)),
loadProviderDirectory: vi.fn(() => Promise.resolve(directoryResponse)),
loadSetupForm: vi.fn(() => Promise.resolve(setupFormResponse)),
connectProvider: vi.fn(() => Promise.resolve(connectedResponse)),
connectWithApiKey: vi.fn(() => Promise.resolve(connectedResponse)),
forgetCredential: vi.fn(() => Promise.resolve(forgottenResponse)),
loadModels: vi.fn(() => Promise.resolve(modelsResponse)),
@ -147,6 +175,38 @@ describe('registerRuntimeProviderManagementIpc', () => {
limit: 10,
});
await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM)?.(
{},
{
runtimeId: 'opencode',
providerId: 'openrouter',
}
);
expect(feature.loadSetupForm).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'openrouter',
});
const genericConnectResponse = await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT)?.(
{},
{
runtimeId: 'opencode',
providerId: 'openrouter',
method: 'api',
apiKey: 'sk-secret-value',
metadata: {},
}
);
expect(feature.connectProvider).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'openrouter',
method: 'api',
apiKey: 'sk-secret-value',
metadata: {},
});
expect(JSON.stringify(genericConnectResponse)).not.toContain('sk-secret-value');
const response = await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY)?.(
{},
{

View file

@ -286,6 +286,72 @@ describe('OpenCodePromptDeliveryLedger', () => {
expect(observed.observedAssistantPreview).toBe('Понял');
});
it('does not keep responded live deliveries active when no inbox commit is needed', async () => {
const store = createStore();
const direct = await store.ensurePending({
teamName: 'team-a',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
inboxMessageId: 'direct-ui-send',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'ui-send',
replyRecipient: 'user',
actionMode: 'ask',
taskRefs: [],
payloadHash: 'sha256:direct',
now: '2026-04-25T10:00:00.000Z',
});
const responded = await store.applyDeliveryResult({
id: direct.id,
accepted: true,
attempted: true,
responseObservation: {
state: 'responded_visible_message',
deliveredUserMessageId: 'oc-user-direct',
assistantMessageId: 'oc-assistant-direct',
toolCallNames: ['agent-teams_message_send'],
visibleMessageToolCallId: 'tool-call-direct',
visibleReplyMessageId: 'reply-direct',
visibleReplyCorrelation: 'direct_child_message_send',
latestAssistantPreview: 'I will send the requested update.',
reason: null,
},
now: '2026-04-25T10:00:05.000Z',
});
expect(responded.status).toBe('responded');
expect(responded.inboxReadCommittedAt).toBeNull();
await expect(store.getActiveForMember({
teamName: 'team-a',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
})).resolves.toBeNull();
const peer = await store.ensurePending({
teamName: 'team-a',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
inboxMessageId: 'peer-relay',
inboxTimestamp: '2026-04-25T10:01:00.000Z',
source: 'manual',
replyRecipient: 'jack',
actionMode: 'delegate',
taskRefs: [],
payloadHash: 'sha256:peer',
now: '2026-04-25T10:01:00.000Z',
});
await expect(store.getActiveForMember({
teamName: 'team-a',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
})).resolves.toMatchObject({
id: peer.id,
inboxMessageId: 'peer-relay',
});
});
it('lists due nonterminal records in deterministic due order', async () => {
const store = createStore();
const first = await store.ensurePending({

View file

@ -17,6 +17,7 @@ import {
import {
createOpenCodeLiveHarness,
getRuntimeTranscript,
waitForOpenCodeMemberIdle,
type InboxMessage,
waitForMemberInboxMessage,
waitForOpenCodeLanesStopped,
@ -95,6 +96,7 @@ async function runModelScenario(input: {
const projectPath = path.join(tempDir, 'project');
const teamName = `${input.scenario.teamNamePrefix}-${sanitizeModelForTeamName(input.model)}-${Date.now()}`;
let harness: Awaited<ReturnType<typeof createOpenCodeLiveHarness>> | null = null;
let keepTempDir = false;
try {
await fs.mkdir(tempClaudeRoot, { recursive: true });
@ -160,6 +162,7 @@ async function runModelScenario(input: {
source: 'manual',
text: input.scenario.directDelivery.textLines.join('\n'),
});
diagnostics.push(`directDelivery=${formatDeliveryDiagnostic(directDelivery)}`);
if (!directDelivery.delivered) {
throw new Error(`Direct OpenCode delivery failed: ${JSON.stringify(directDelivery, null, 2)}`);
}
@ -178,6 +181,13 @@ async function runModelScenario(input: {
});
stages.directReply = true;
stages.taskRefs = hasTaskRef(directReply, directTaskRef);
await waitForOpenCodeMemberIdle({
bridgeClient: harness.bridgeClient,
teamName,
memberName: input.scenario.directDelivery.memberName,
projectPath,
timeoutMs: 90_000,
});
const peerTaskRef = taskRefForScenario(
input.scenario,
@ -193,17 +203,44 @@ async function runModelScenario(input: {
source: 'manual',
text: input.scenario.peerDelivery.textLines.join('\n'),
});
diagnostics.push(`peerDelivery=${formatDeliveryDiagnostic(peerDelivery)}`);
if (!peerDelivery.delivered) {
throw new Error(`Peer OpenCode delivery failed: ${JSON.stringify(peerDelivery, null, 2)}`);
}
if (peerDelivery.accepted === false || peerDelivery.queuedBehindMessageId) {
throw new Error(
`Peer OpenCode delivery was not accepted immediately: ${JSON.stringify(
peerDelivery,
null,
2
)}`
);
}
const peerMessage = await waitForMemberInboxMessage(
teamName,
input.scenario.peerDelivery.recipientName,
input.scenario.peerDelivery.senderName,
input.scenario.peerDelivery.peerToken,
180_000
);
let peerMessage: Awaited<ReturnType<typeof waitForMemberInboxMessage>>;
try {
peerMessage = await waitForMemberInboxMessage(
teamName,
input.scenario.peerDelivery.recipientName,
input.scenario.peerDelivery.senderName,
input.scenario.peerDelivery.peerToken,
180_000
);
} catch (error) {
const transcript = await getRuntimeTranscript({
bridgeClient: harness.bridgeClient,
teamName,
memberName: input.scenario.peerDelivery.senderName,
projectPath,
});
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nSender transcript: ${JSON.stringify(
transcript,
null,
2
)}`
);
}
assertVisibleReplyContract(peerMessage, {
expectedFrom: input.scenario.peerDelivery.senderName,
expectedTo: input.scenario.peerDelivery.recipientName,
@ -240,6 +277,10 @@ async function runModelScenario(input: {
diagnostics,
};
} catch (error) {
if (process.env.OPENCODE_E2E_KEEP_FAILED === '1') {
keepTempDir = true;
diagnostics.push(`tempDir=${tempDir}`);
}
diagnostics.push(error instanceof Error ? error.message : String(error));
return {
model: input.model,
@ -256,7 +297,9 @@ async function runModelScenario(input: {
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
}
setClaudeBasePathOverride(null);
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
if (!keepTempDir) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
@ -336,6 +379,34 @@ function scoreModel(stages: ModelResult['stages']): number {
);
}
function formatDeliveryDiagnostic(delivery: {
delivered?: unknown;
accepted?: unknown;
responsePending?: unknown;
responseState?: unknown;
ledgerStatus?: unknown;
queuedBehindMessageId?: unknown;
reason?: unknown;
visibleReplyMessageId?: unknown;
visibleReplyCorrelation?: unknown;
diagnostics?: unknown;
}): string {
return JSON.stringify({
delivered: delivery.delivered,
accepted: delivery.accepted,
responsePending: delivery.responsePending,
responseState: delivery.responseState,
ledgerStatus: delivery.ledgerStatus,
queuedBehindMessageId: delivery.queuedBehindMessageId,
reason: delivery.reason,
visibleReplyMessageId: delivery.visibleReplyMessageId,
visibleReplyCorrelation: delivery.visibleReplyCorrelation,
diagnostics: Array.isArray(delivery.diagnostics)
? delivery.diagnostics.slice(0, 5)
: delivery.diagnostics,
});
}
async function writeModelMatrixReport(report: ModelMatrixReport): Promise<void> {
const outputDir = process.env.OPENCODE_E2E_REPORT_DIR?.trim()
? path.resolve(process.env.OPENCODE_E2E_REPORT_DIR.trim())

View file

@ -4068,6 +4068,153 @@ describe('TeamProvisioningService', () => {
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
});
it('unblocks newer OpenCode deliveries when the previous pending delivery now has visible proof', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: 'cursor-before',
responseObservation: {
state: 'empty_assistant_turn' as const,
deliveredUserMessageId: 'oc-user-empty',
assistantMessageId: 'oc-assistant-empty',
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'empty_assistant_turn',
},
diagnostics: [],
}));
const observeMessageDelivery = vi.fn(async () => ({
ok: true,
providerId: 'opencode',
memberName: 'bob',
responseObservation: {
state: 'empty_assistant_turn' as const,
deliveredUserMessageId: 'oc-user-empty',
assistantMessageId: 'oc-assistant-empty',
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'empty_assistant_turn',
},
diagnostics: [],
}));
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
observeMessageDelivery,
} as any,
])
);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'First prompt.',
messageId: 'msg-active-old',
replyRecipient: 'user',
actionMode: 'ask',
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: true,
responseState: 'empty_assistant_turn',
});
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
await fsPromises.mkdir(inboxDir, { recursive: true });
await fsPromises.writeFile(
path.join(inboxDir, 'user.json'),
`${JSON.stringify(
[
{
from: 'bob',
to: 'user',
text: 'Delayed but sufficient answer.',
timestamp: '2026-04-25T10:00:03.000Z',
read: false,
messageId: 'reply-old-1',
relayOfMessageId: 'msg-active-old',
source: 'runtime_delivery',
},
],
null,
2
)}\n`,
'utf8'
);
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Second prompt.',
messageId: 'msg-active-new',
replyRecipient: 'user',
actionMode: 'ask',
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:05.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: true,
responseState: 'empty_assistant_turn',
});
expect(sendMessageToMember).toHaveBeenCalledTimes(2);
expect(observeMessageDelivery).not.toHaveBeenCalled();
});
it('uses lane-scoped manifest activeRunId for OpenCode member delivery after restart', async () => {
const svc = new TeamProvisioningService();
const teamName = 'team-a';

View file

@ -275,6 +275,44 @@ export async function getRuntimeTranscript(input: {
}));
}
export async function waitForOpenCodeMemberIdle(input: {
bridgeClient: OpenCodeBridgeCommandClient;
teamName: string;
memberName: string;
projectPath: string;
timeoutMs: number;
}): Promise<void> {
const deadline = Date.now() + input.timeoutMs;
let lastState: string | null = null;
while (Date.now() < deadline) {
const transcript = await getRuntimeTranscript(input);
lastState = getTranscriptDurableState(transcript);
if (lastState === 'idle') {
return;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
throw new Error(
`Timed out waiting for OpenCode member ${input.memberName} to become idle. Last durableState: ${
lastState ?? 'unknown'
}`
);
}
function getTranscriptDurableState(transcript: unknown): string | null {
if (!transcript || typeof transcript !== 'object') {
return null;
}
const data = (transcript as { data?: unknown }).data;
if (!data || typeof data !== 'object') {
return null;
}
const durableState = (data as { durableState?: unknown }).durableState;
return typeof durableState === 'string' ? durableState : null;
}
async function startLiveTeamControlApi(svc: TeamProvisioningService): Promise<{
baseUrl: string;
close: () => Promise<void>;

View file

@ -251,7 +251,9 @@ describe('TeamModelSelector disabled Codex models', () => {
},
models: [
'openrouter/openai/gpt-oss-20b:free',
'openrouter/qwen/qwen3-coder-plus',
'opencode/big-pickle',
'openrouter/openai/gpt-oss-120b:free',
'openrouter/qwen/qwen3-coder-flash',
],
modelVerificationState: 'idle',
@ -279,7 +281,11 @@ describe('TeamModelSelector disabled Codex models', () => {
expect(host.textContent).toContain('Recommended only');
expect(host.textContent).toContain('qwen/qwen3-coder-flash');
expect(host.textContent).toContain('Recommended');
expect(host.textContent).toContain('openai/gpt-oss-120b:free');
expect(host.textContent).toContain('Recommended with limits');
expect(host.textContent).toContain('big-pickle');
expect(host.textContent).toContain('qwen/qwen3-coder-plus');
expect(host.textContent).toContain('Unavailable in OpenCode');
expect(host.textContent).toContain('openai/gpt-oss-20b:free');
expect(host.textContent).toContain('Not recommended');
@ -290,12 +296,20 @@ describe('TeamModelSelector disabled Codex models', () => {
text.includes('qwen/qwen3-coder-flash')
);
const neutralIndex = buttonTexts.findIndex((text) => text.includes('big-pickle'));
const limitedIndex = buttonTexts.findIndex((text) =>
text.includes('openai/gpt-oss-120b:free')
);
const notRecommendedIndex = buttonTexts.findIndex((text) =>
text.includes('openai/gpt-oss-20b:free')
);
const unavailableIndex = buttonTexts.findIndex((text) =>
text.includes('qwen/qwen3-coder-plus')
);
expect(recommendedIndex).toBeGreaterThanOrEqual(0);
expect(neutralIndex).toBeGreaterThan(recommendedIndex);
expect(notRecommendedIndex).toBeGreaterThan(neutralIndex);
expect(limitedIndex).toBeGreaterThan(recommendedIndex);
expect(neutralIndex).toBeGreaterThan(limitedIndex);
expect(unavailableIndex).toBeGreaterThan(neutralIndex);
expect(notRecommendedIndex).toBeGreaterThan(unavailableIndex);
await act(async () => {
const checkbox = Array.from(host.querySelectorAll('button')).find(
@ -306,7 +320,9 @@ describe('TeamModelSelector disabled Codex models', () => {
});
expect(host.textContent).toContain('qwen/qwen3-coder-flash');
expect(host.textContent).toContain('openai/gpt-oss-120b:free');
expect(host.textContent).not.toContain('big-pickle');
expect(host.textContent).not.toContain('qwen/qwen3-coder-plus');
expect(host.textContent).not.toContain('openai/gpt-oss-20b:free');
await act(async () => {

View file

@ -66,6 +66,11 @@ function createState(
directorySelectedProviderId: null,
directorySupported: true,
activeFormProviderId: null,
setupForm: null,
setupFormLoading: false,
setupFormError: null,
setupSubmitError: null,
setupMetadata: {},
apiKeyValue: '',
modelPickerProviderId: null,
modelPickerMode: null,
@ -101,6 +106,7 @@ function createActions(): RuntimeProviderManagementActions {
startConnect: vi.fn(),
cancelConnect: vi.fn(),
setApiKeyValue: vi.fn(),
setSetupMetadataValue: vi.fn(),
submitConnect: vi.fn(() => Promise.resolve()),
forgetProvider: vi.fn(() => Promise.resolve()),
openModelPicker: vi.fn(),
@ -188,7 +194,10 @@ describe('RuntimeProviderManagementPanelView', () => {
await Promise.resolve();
});
expect(actions.selectProvider).toHaveBeenCalledWith('openrouter');
expect(actions.startConnect).toHaveBeenCalledWith('openrouter');
expect(actions.selectProvider).not.toHaveBeenCalled();
vi.mocked(actions.startConnect).mockClear();
await act(async () => {
const connect = Array.from(host.querySelectorAll('button')).find((button) =>
@ -208,6 +217,25 @@ describe('RuntimeProviderManagementPanelView', () => {
providers: state.view?.providers ?? [],
activeFormProviderId: 'openrouter',
apiKeyValue: 'sk-secret-value',
setupForm: {
runtimeId: 'opencode',
providerId: 'openrouter',
displayName: 'OpenRouter',
method: 'api',
supported: true,
title: 'Connect OpenRouter',
description: null,
submitLabel: 'Connect',
disabledReason: null,
source: 'curated',
secret: {
key: 'key',
label: 'API key',
placeholder: 'Paste API key',
required: true,
},
prompts: [],
},
},
actions,
disabled: false,
@ -359,6 +387,36 @@ describe('RuntimeProviderManagementPanelView', () => {
supportedInlineAuth: false,
},
},
{
providerId: 'cloudflare-workers-ai',
displayName: 'Cloudflare Workers AI',
state: 'not-connected',
setupKind: 'connect-api-key',
ownership: [],
recommended: false,
modelCount: 8,
defaultModelId: null,
authMethods: ['api'],
actions: [
{
id: 'connect',
label: 'Connect',
enabled: true,
disabledReason: null,
requiresSecret: true,
ownershipScope: 'managed',
},
],
sources: ['opencode-provider'],
sourceLabel: 'OpenCode catalog',
providerSource: 'models.dev',
detail: 'App-managed API-key setup is available for this provider',
metadata: {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: true,
},
},
],
}),
actions,
@ -370,6 +428,7 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).toContain('115 OpenCode providers');
expect(host.textContent).toContain('DeepSeek');
expect(host.textContent).toContain('Cloudflare Workers AI');
expect(host.textContent).toContain('62 models');
expect(host.textContent).toContain('OpenCode catalog');
expect(host.querySelector('[data-testid="runtime-provider-search"]')).not.toBeNull();
@ -381,7 +440,18 @@ describe('RuntimeProviderManagementPanelView', () => {
await Promise.resolve();
});
expect(actions.selectDirectoryProvider).toHaveBeenCalledWith('deepseek');
expect(actions.selectDirectoryProvider).not.toHaveBeenCalled();
expect(actions.startConnect).not.toHaveBeenCalled();
await act(async () => {
host
.querySelector('[data-testid="runtime-provider-directory-row-cloudflare-workers-ai"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(actions.startConnect).toHaveBeenCalledWith('cloudflare-workers-ai');
expect(actions.selectDirectoryProvider).not.toHaveBeenCalled();
});
it('uses the unified provider search when compact search has no matches', async () => {
@ -496,6 +566,24 @@ describe('RuntimeProviderManagementPanelView', () => {
default: false,
availability: 'untested',
},
{
providerId: 'openrouter',
modelId: 'openrouter/qwen/qwen3-coder-plus',
displayName: 'qwen/qwen3-coder-plus',
sourceLabel: 'OpenRouter',
free: false,
default: false,
availability: 'untested',
},
{
providerId: 'openrouter',
modelId: 'openrouter/openai/gpt-oss-120b:free',
displayName: 'openai/gpt-oss-120b:free',
sourceLabel: 'OpenRouter',
free: true,
default: false,
availability: 'untested',
},
{
providerId: 'openrouter',
modelId: 'openrouter/qwen/qwen3-coder-flash',
@ -534,8 +622,10 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).toContain('Used for new teams');
expect(host.textContent).toContain('Model probe passed');
expect(host.textContent).toContain('Not recommended');
expect(host.textContent).toContain('Unavailable in OpenCode');
expect(host.textContent).toContain('Recommended only');
expect(host.textContent).toContain('Recommended');
expect(host.textContent).toContain('Recommended with limits');
expect(host.textContent).not.toContain('Set OpenCode default');
expect(
Array.from(host.querySelectorAll('button')).some(
@ -570,9 +660,15 @@ describe('RuntimeProviderManagementPanelView', () => {
) as HTMLElement | null;
expect(modelResult?.style.color).toBe('#86efac');
expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-flash')).toBeLessThan(
(host.textContent ?? '').indexOf('openai/gpt-oss-120b:free')
);
expect((host.textContent ?? '').indexOf('openai/gpt-oss-120b:free')).toBeLessThan(
(host.textContent ?? '').indexOf('opencode/big-pickle')
);
expect((host.textContent ?? '').indexOf('opencode/big-pickle')).toBeLessThan(
(host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')
);
expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')).toBeLessThan(
(host.textContent ?? '').indexOf('openrouter/openai/gpt-oss-20b:free')
);
@ -585,7 +681,9 @@ describe('RuntimeProviderManagementPanelView', () => {
});
expect(host.textContent).toContain('qwen/qwen3-coder-flash');
expect(host.textContent).toContain('openai/gpt-oss-120b:free');
expect(host.textContent).not.toContain('opencode/big-pickle');
expect(host.textContent).not.toContain('qwen/qwen3-coder-plus');
expect(host.textContent).not.toContain('openrouter/openai/gpt-oss-20b:free');
await act(async () => {
@ -606,6 +704,7 @@ describe('RuntimeProviderManagementPanelView', () => {
});
expect(actions.useModelForNewTeams).toHaveBeenCalledWith('openrouter/openai/gpt-oss-20b:free');
expect(actions.selectProvider).not.toHaveBeenCalled();
vi.mocked(actions.useModelForNewTeams).mockClear();
await act(async () => {
@ -626,6 +725,81 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(actions.useModelForNewTeams).not.toHaveBeenCalled();
});
it('keeps directory provider models visible when a model row is selected', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const provider = {
providerId: 'openrouter',
displayName: 'OpenRouter',
state: 'connected' as const,
ownership: ['managed'] as const,
recommended: true,
modelCount: 174,
defaultModelId: null,
authMethods: ['api'] as const,
actions: [],
sources: ['opencode-provider'] as const,
sourceLabel: 'OpenCode catalog',
providerSource: 'models.dev',
detail: 'Connected via app-managed OpenCode credential',
setupKind: 'connected' as const,
metadata: {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: true,
},
};
const state = createState({
providers: [],
directoryLoaded: true,
directoryEntries: [provider],
directoryTotalCount: 1,
selectedProviderId: 'openrouter',
modelPickerProviderId: 'openrouter',
modelPickerMode: 'use',
models: [
{
providerId: 'openrouter',
modelId: 'openrouter/google/gemini-3-flash-preview',
displayName: 'google/gemini-3-flash-preview',
sourceLabel: 'OpenRouter',
free: false,
default: false,
availability: 'untested',
},
],
});
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state,
actions,
disabled: false,
})
);
await Promise.resolve();
});
await act(async () => {
host
.querySelector(
'[data-testid="runtime-provider-model-row-openrouter/google/gemini-3-flash-preview"]'
)
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(actions.useModelForNewTeams).toHaveBeenCalledWith(
'openrouter/google/gemini-3-flash-preview'
);
expect(actions.selectDirectoryProvider).not.toHaveBeenCalled();
expect(host.textContent).toContain('google/gemini-3-flash-preview');
expect(host.textContent).not.toContain('No models found.');
});
it('renders verified brand icons for common OpenCode providers', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
@ -676,29 +850,40 @@ describe('RuntimeProviderManagementPanelView', () => {
for (const provider of providers) {
const logo = host.querySelector(
`[data-testid="runtime-provider-logo-${provider.providerId}"]`
);
) as HTMLElement | null;
expect(logo).not.toBeNull();
expect(logo?.className).toContain('runtime-provider-brand-icon');
expect(logo?.querySelector('svg,img')).not.toBeNull();
expect(logo?.getAttribute('style')).toContain(
'--runtime-provider-brand-fallback-background'
);
expect(logo?.getAttribute('style')).toContain('--runtime-provider-brand-fallback-border');
if (logo?.querySelector('svg')) {
expect(logo.getAttribute('style')).toContain(
'--runtime-provider-brand-fallback-color'
);
}
}
});
it('uses branded initials for popular providers without verified compact logo assets', async () => {
it('uses Models.dev logos only for verified providers and initials for unknown providers', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const baseProvider = createState().view!.providers[0];
const providers = [
{ providerId: 'xai', displayName: 'xAI', label: 'xAI' },
{ providerId: 'groq', displayName: 'Groq', label: 'G' },
{ providerId: 'deepseek', displayName: 'DeepSeek', label: 'DS' },
{ providerId: 'deepinfra', displayName: 'Deep Infra', label: 'DI' },
{ providerId: 'fireworks-ai', displayName: 'Fireworks AI', label: 'FW' },
{ providerId: 'togetherai', displayName: 'Together AI', label: 'TA' },
{ providerId: 'amazon-bedrock', displayName: 'Amazon Bedrock', label: 'AWS' },
{ providerId: 'azure', displayName: 'Azure', label: 'AZ' },
{ providerId: 'cohere', displayName: 'Cohere', label: 'CO' },
{ providerId: 'ollama-cloud', displayName: 'Ollama Cloud', label: 'OL' },
{ providerId: 'xai', displayName: 'xAI', logo: 'xai' },
{ providerId: 'groq', displayName: 'Groq', logo: 'groq' },
{ providerId: 'deepseek', displayName: 'DeepSeek', logo: 'deepseek' },
{ providerId: 'cohere', displayName: 'Cohere', logo: 'cohere' },
{
providerId: 'cloudferro-sherlock',
displayName: 'CloudFerro Sherlock',
logo: 'cloudferro-sherlock',
},
{ providerId: 'clarifai', displayName: 'Clarifai', label: 'CL' },
{ providerId: 'unknown-provider', displayName: 'Unknown Provider', label: 'UN' },
].map((provider) => ({
...baseProvider,
...provider,
@ -727,7 +912,13 @@ describe('RuntimeProviderManagementPanelView', () => {
const logo = host.querySelector(
`[data-testid="runtime-provider-logo-${provider.providerId}"]`
);
expect(logo?.textContent).toBe(provider.label);
if ('logo' in provider) {
const image = logo?.querySelector('img') as HTMLImageElement | null;
expect(image?.src).toContain(`https://models.dev/logos/${provider.logo}.svg`);
expect(logo?.className).toContain('runtime-provider-brand-icon');
} else {
expect(logo?.textContent).toBe(provider.label);
}
}
});
});

View file

@ -292,7 +292,32 @@ describe('useRuntimeProviderManagement', () => {
});
it('keeps the API key draft when provider connect fails', async () => {
const connectWithApiKey = vi.fn(() =>
const loadSetupForm = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
setupForm: {
runtimeId: 'opencode',
providerId: 'openrouter',
displayName: 'OpenRouter',
method: 'api',
supported: true,
title: 'Connect OpenRouter',
description: null,
submitLabel: 'Connect',
disabledReason: null,
source: 'curated',
secret: {
key: 'key',
label: 'API key',
placeholder: 'Paste API key',
required: true,
},
prompts: [],
},
})
);
const connectProvider = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
@ -306,7 +331,8 @@ describe('useRuntimeProviderManagement', () => {
configurable: true,
value: {
runtimeProviderManagement: {
connectWithApiKey,
loadSetupForm,
connectProvider,
},
} as unknown as ElectronAPI,
});
@ -322,20 +348,25 @@ describe('useRuntimeProviderManagement', () => {
actions?.setApiKeyValue('sk-bad-value');
});
await act(async () => {
await Promise.resolve();
await vi.waitFor(() => {
expect(loadSetupForm).toHaveBeenCalled();
});
});
await act(async () => {
await actions?.submitConnect('openrouter');
});
expect(connectWithApiKey).toHaveBeenCalledWith({
expect(connectProvider).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'openrouter',
method: 'api',
apiKey: 'sk-bad-value',
metadata: {},
projectPath: null,
});
expect(state?.error).toBe('Invalid API key');
expect(state?.error).toBeNull();
expect(state?.setupSubmitError).toBe('Invalid API key');
expect(state?.apiKeyValue).toBe('sk-bad-value');
});

View file

@ -13,7 +13,87 @@ describe('getOpenCodeTeamModelRecommendation', () => {
label: 'Recommended',
});
expect(
getOpenCodeTeamModelRecommendation(' OPENROUTER/GOOGLE/GEMINI-2.5-FLASH-LITE ')
getOpenCodeTeamModelRecommendation(' OPENROUTER/GOOGLE/GEMINI-3-FLASH-PREVIEW ')
).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/moonshotai/kimi-k2.6')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-5.1')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-5')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.7')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3.1-pro-preview')
).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(
getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-sonnet-4.6')
).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-opus-4.6')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-opus-4.7')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-2512')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.4')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.3-codex')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4-fast')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.1-fast')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2-pro')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.1-codex')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-max')).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-medium-3.1')
).toMatchObject({
level: 'recommended',
label: 'Recommended',
});
expect(
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3.1-flash-lite-preview')
).toMatchObject({
level: 'recommended',
label: 'Recommended',
@ -23,7 +103,8 @@ describe('getOpenCodeTeamModelRecommendation', () => {
it('keeps similarly named models distinct when real E2E disagreed', () => {
expect(getOpenCodeTeamModelRecommendation('opencode/minimax-m2.5-free')).toMatchObject({
level: 'recommended',
level: 'recommended-with-limits',
label: 'Recommended with limits',
});
expect(
getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.5:free')
@ -32,6 +113,16 @@ describe('getOpenCodeTeamModelRecommendation', () => {
});
});
it('marks passing free routes as recommended with limits', () => {
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-oss-120b:free')).toMatchObject(
{
level: 'recommended-with-limits',
label: 'Recommended with limits',
}
);
expect(isOpenCodeTeamModelRecommended('openrouter/openai/gpt-oss-120b:free')).toBe(true);
});
it('marks models with real launch or messaging failures as not recommended', () => {
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-oss-20b:free')).toMatchObject({
level: 'not-recommended',
@ -43,18 +134,74 @@ describe('getOpenCodeTeamModelRecommendation', () => {
level: 'not-recommended',
label: 'Not recommended',
});
expect(
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.5-flash-lite')
).toMatchObject({
level: 'not-recommended',
label: 'Not recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-v3.2')).toMatchObject({
level: 'not-recommended',
label: 'Not recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-code-fast-1')).toMatchObject({
level: 'not-recommended',
label: 'Not recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.2-codex')).toMatchObject({
level: 'not-recommended',
label: 'Not recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/moonshotai/kimi-k2-thinking')).toMatchObject({
level: 'not-recommended',
label: 'Not recommended',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.1-chat')).toMatchObject({
level: 'not-recommended',
label: 'Not recommended',
});
});
it('marks OpenRouter routes missing from the OpenCode catalog as unavailable, not bad', () => {
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-plus')).toMatchObject({
level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-next')).toMatchObject({
level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-max-thinking')).toMatchObject({
level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode',
});
expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2512')
).toMatchObject({
level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode',
});
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-medium')).toMatchObject(
{
level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode',
}
);
expect(isOpenCodeTeamModelRecommended('openrouter/qwen/qwen3-coder-plus')).toBe(false);
});
it('does not label noisy or unproven models as good or bad', () => {
expect(getOpenCodeTeamModelRecommendation('opencode/big-pickle')).toBeNull();
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-code-fast-1')).toBeNull();
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20')).toBeNull();
expect(getOpenCodeTeamModelRecommendation('')).toBeNull();
});
it('sorts recommended routes before neutral routes and not-recommended routes last', () => {
it('sorts recommended, limited, neutral, unavailable, and not-recommended routes by status', () => {
const models = [
'openrouter/openai/gpt-oss-20b:free',
'openrouter/qwen/qwen3-coder-plus',
'opencode/big-pickle',
'openrouter/openai/gpt-oss-120b:free',
'openrouter/qwen/qwen3-coder-flash',
];
@ -62,7 +209,9 @@ describe('getOpenCodeTeamModelRecommendation', () => {
[...models].sort((left, right) => compareOpenCodeTeamModelRecommendations(left, right))
).toEqual([
'openrouter/qwen/qwen3-coder-flash',
'openrouter/openai/gpt-oss-120b:free',
'opencode/big-pickle',
'openrouter/qwen/qwen3-coder-plus',
'openrouter/openai/gpt-oss-20b:free',
]);
});