feat(runtime-provider-management): expand opencode setup UI
This commit is contained in:
parent
c2e14ea9df
commit
238900f3cf
28 changed files with 2116 additions and 215 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)?.(
|
||||
{},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue