feat(runtime): support anthropic compatible endpoints

This commit is contained in:
777genius 2026-05-22 00:16:52 +03:00
parent 3d1b329221
commit 3c427ac617
26 changed files with 2652 additions and 57 deletions

View file

@ -1,7 +1,7 @@
import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import type {
RuntimeProviderManagementApi,
@ -141,7 +141,11 @@ async function resolveCliEnv(): Promise<{
binaryPath: string | null;
env: NodeJS.ProcessEnv;
}> {
const shellEnv = await resolveInteractiveShellEnv();
const shellEnv = await resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_500,
fallbackEnv: process.env,
background: false,
});
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
return {

View file

@ -51,6 +51,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
'ssh',
]);
const MAX_SNOOZE_MINUTES = 24 * 60;
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@ -64,6 +65,30 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function validateAnthropicCompatibleBaseUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must use http:// or https://';
}
if (url.username || url.password) {
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must not include credentials';
}
if (FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)) {
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must not be a first-party Anthropic API host';
}
} catch {
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must be a valid URL';
}
return null;
}
function isValidTrigger(trigger: unknown): trigger is NotificationTrigger {
if (!isPlainObject(trigger)) {
return false;
@ -496,7 +521,11 @@ function validateProviderConnectionsSection(
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
for (const [connectionKey, connectionValue] of Object.entries(value)) {
if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') {
if (
connectionKey !== 'authMode' &&
connectionKey !== 'fastModeDefault' &&
connectionKey !== 'compatibleEndpoint'
) {
return {
valid: false,
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
@ -519,6 +548,64 @@ function validateProviderConnectionsSection(
continue;
}
if (connectionKey === 'compatibleEndpoint') {
if (!isPlainObject(connectionValue)) {
return {
valid: false,
error: 'providerConnections.anthropic.compatibleEndpoint must be an object',
};
}
const compatibleEndpoint: Partial<
ProviderConnectionsConfig['anthropic']['compatibleEndpoint']
> = {};
for (const [endpointKey, endpointValue] of Object.entries(connectionValue)) {
if (endpointKey !== 'enabled' && endpointKey !== 'baseUrl') {
return {
valid: false,
error: `providerConnections.anthropic.compatibleEndpoint.${endpointKey} is not a valid setting`,
};
}
if (endpointKey === 'enabled') {
if (typeof endpointValue !== 'boolean') {
return {
valid: false,
error:
'providerConnections.anthropic.compatibleEndpoint.enabled must be a boolean',
};
}
compatibleEndpoint.enabled = endpointValue;
continue;
}
if (typeof endpointValue !== 'string') {
return {
valid: false,
error: 'providerConnections.anthropic.compatibleEndpoint.baseUrl must be a string',
};
}
const error = validateAnthropicCompatibleBaseUrl(endpointValue);
if (error) {
return { valid: false, error };
}
compatibleEndpoint.baseUrl = endpointValue.trim();
}
if (compatibleEndpoint.enabled === true && !compatibleEndpoint.baseUrl?.trim()) {
return {
valid: false,
error:
'providerConnections.anthropic.compatibleEndpoint.baseUrl is required when enabled',
};
}
anthropicUpdate.compatibleEndpoint =
compatibleEndpoint as ProviderConnectionsConfig['anthropic']['compatibleEndpoint'];
continue;
}
if (typeof connectionValue !== 'boolean') {
return {
valid: false,

View file

@ -275,10 +275,16 @@ export interface RuntimeConfig {
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
export interface AnthropicCompatibleEndpointConfig {
enabled: boolean;
baseUrl: string;
}
export interface ProviderConnectionsConfig {
anthropic: {
authMode: ProviderConnectionAuthMode;
fastModeDefault: boolean;
compatibleEndpoint: AnthropicCompatibleEndpointConfig;
};
codex: {
preferredAuthMode: CodexAccountAuthMode;
@ -376,6 +382,10 @@ const DEFAULT_CONFIG: AppConfig = {
anthropic: {
authMode: 'auto',
fastModeDefault: false,
compatibleEndpoint: {
enabled: false,
baseUrl: '',
},
},
codex: {
preferredAuthMode: 'auto',
@ -457,6 +467,22 @@ function normalizeCodexPreferredAuthMode(
return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode;
}
function normalizeAnthropicCompatibleEndpointConfig(
value: unknown,
fallback: AnthropicCompatibleEndpointConfig = DEFAULT_CONFIG.providerConnections.anthropic
.compatibleEndpoint
): AnthropicCompatibleEndpointConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { ...fallback };
}
const raw = value as Partial<AnthropicCompatibleEndpointConfig>;
return {
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : fallback.enabled,
baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : fallback.baseUrl,
};
}
function shouldPersistNormalizedConfig(loaded: Partial<AppConfig>, normalized: AppConfig): boolean {
return JSON.stringify(loaded) !== JSON.stringify(normalized);
}
@ -634,6 +660,9 @@ export class ConfigManager {
anthropic: {
...DEFAULT_CONFIG.providerConnections.anthropic,
...(loaded.providerConnections?.anthropic ?? {}),
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
loaded.providerConnections?.anthropic?.compatibleEndpoint
),
},
codex: {
preferredAuthMode: normalizeCodexPreferredAuthMode(
@ -750,6 +779,10 @@ export class ConfigManager {
anthropic: {
...this.config.providerConnections.anthropic,
...(connectionUpdate.anthropic ?? {}),
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
connectionUpdate.anthropic?.compatibleEndpoint,
this.config.providerConnections.anthropic.compatibleEndpoint
),
},
codex: {
...this.config.providerConnections.codex,

View file

@ -24,6 +24,7 @@ import type {
const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000;
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@ -796,7 +797,11 @@ export class ClaudeMultimodelBridgeService {
private async buildCliEnv(
binaryPath: string
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
return buildProviderAwareCliEnv({ binaryPath, allowStoredApiKeyDecryption: false });
return buildProviderAwareCliEnv({
binaryPath,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
});
}
private async buildProviderCliEnv(
@ -807,6 +812,8 @@ export class ClaudeMultimodelBridgeService {
binaryPath,
providerId,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames:
providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
});
}
@ -827,6 +834,12 @@ export class ClaudeMultimodelBridgeService {
return this.isRuntimeStatusCompatibilityError(error) || lower.includes('runtime status');
}
private isRuntimeStatusTimeoutError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return lower.includes('timed out') || lower.includes('timeout');
}
private mapRuntimeProviderStatus(
providerId: CliProviderId,
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
@ -966,14 +979,14 @@ export class ClaudeMultimodelBridgeService {
providerId: CliProviderId,
env: NodeJS.ProcessEnv,
connectionIssues: Partial<Record<CliProviderId, string>>,
options: { summary?: boolean } = {}
options: { summary?: boolean; timeoutMs?: number } = {}
): Promise<CliProviderStatus> {
const args = ['runtime', 'status', '--json', '--provider', providerId];
if (options.summary) {
args.push('--summary');
}
const { stdout } = await execCli(binaryPath, args, {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
});
@ -990,7 +1003,7 @@ export class ClaudeMultimodelBridgeService {
private async getProviderStatusFromScopedRuntimeStatus(
binaryPath: string,
providerId: CliProviderId,
options: { summary?: boolean } = {}
options: { summary?: boolean; timeoutMs?: number } = {}
): Promise<CliProviderStatus> {
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
return this.getProviderStatusFromRuntimeStatusCommand(
@ -1005,7 +1018,7 @@ export class ClaudeMultimodelBridgeService {
private async getProviderStatusesFromScopedRuntimeStatus(
binaryPath: string,
onUpdate?: (providers: CliProviderStatus[]) => void,
options: { summary?: boolean; providerIds?: readonly CliProviderId[] } = {}
options: { summary?: boolean; timeoutMs?: number; providerIds?: readonly CliProviderId[] } = {}
): Promise<CliProviderStatus[] | null> {
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
const providers = new Map<CliProviderId, CliProviderStatus>(
@ -1032,6 +1045,19 @@ export class ClaudeMultimodelBridgeService {
}
if (failures.length === providerIds.length) {
if (failures.every(({ error }) => this.isRuntimeStatusTimeoutError(error))) {
logger.warn(
`Provider-scoped runtime status timed out for ${failures
.map(({ providerId }) => providerId)
.join(', ')}; using error provider statuses without slower fallback probes`
);
for (const { providerId, error } of failures) {
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
}
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
return this.buildProviderStatusesSnapshot(providers, providerIds);
}
return null;
}
@ -1202,7 +1228,11 @@ export class ClaudeMultimodelBridgeService {
providerId: CliProviderId,
onCatalogUpdate?: (provider: CliProviderStatus) => void
): Promise<CliProviderStatus> {
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
await resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_500,
fallbackEnv: process.env,
background: false,
});
try {
const generation = this.beginProviderStatusHydration([providerId]);
@ -1418,14 +1448,22 @@ export class ClaudeMultimodelBridgeService {
binaryPath: string,
onUpdate?: (providers: CliProviderStatus[]) => void
): Promise<CliProviderStatus[]> {
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
await resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_500,
fallbackEnv: process.env,
background: false,
});
try {
const generation = this.beginProviderStatusHydration(DEFAULT_PROVIDER_STATUS_IDS);
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
binaryPath,
onUpdate,
{ summary: true, providerIds: DEFAULT_PROVIDER_STATUS_IDS }
{
summary: true,
timeoutMs: PROVIDER_STATUS_SUMMARY_TIMEOUT_MS,
providerIds: DEFAULT_PROVIDER_STATUS_IDS,
}
);
if (providers) {
this.hydrateProviderCatalogs(binaryPath, providers, generation, onUpdate);

View file

@ -194,6 +194,8 @@ export class CliProviderModelAvailabilityService {
binaryPath: context.binaryPath,
providerId: context.provider.providerId,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames:
context.provider.providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
}).then((result) => ({
env: result.env,
providerArgs: result.providerArgs ?? [],

View file

@ -12,6 +12,7 @@ import {
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
import { ConfigManager } from '../infrastructure/ConfigManager';
import type { AnthropicCompatibleEndpointConfig } from '../infrastructure/ConfigManager';
import type {
CodexAccountAuthMode,
CodexAccountSnapshotDto,
@ -37,6 +38,7 @@ type ExternalCredential = {
interface StoredApiKeyAccessOptions {
allowStoredApiKeyDecryption?: boolean;
allowedStoredApiKeyEnvVarNames?: readonly string[];
}
const PROVIDER_CAPABILITIES: Record<
@ -71,6 +73,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
gemini: 'GEMINI_API_KEY',
};
const ANTHROPIC_BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
const ANTHROPIC_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
@ -80,6 +84,7 @@ const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000;
const ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS = 10_000;
const ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS = 60_000;
const ANTHROPIC_DEFAULT_API_BASE_URL = 'https://api.anthropic.com';
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown';
@ -161,10 +166,15 @@ function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
}
try {
const host = new URL(trimmed).host;
return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com';
const url = new URL(trimmed);
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
!url.username &&
!url.password &&
!FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)
);
} catch {
return true;
return false;
}
}
@ -176,6 +186,24 @@ function hasAnthropicCompatibleAuthEnv(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim());
}
function isUsableAnthropicCompatibleEndpoint(
endpoint: AnthropicCompatibleEndpointConfig | undefined
): endpoint is AnthropicCompatibleEndpointConfig {
if (endpoint?.enabled !== true || !endpoint.baseUrl.trim()) {
return false;
}
try {
const url = new URL(endpoint.baseUrl.trim());
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
isAnthropicCompatibleBaseUrl(endpoint.baseUrl)
);
} catch {
return false;
}
}
async function verifyAnthropicApiKeyWithApi(
apiKey: string,
baseUrl?: string | null
@ -393,12 +421,122 @@ export class ProviderConnectionService {
return null;
}
private getConfiguredAnthropicCompatibleEndpoint(): AnthropicCompatibleEndpointConfig | null {
const endpoint =
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
return isUsableAnthropicCompatibleEndpoint(endpoint)
? { enabled: true, baseUrl: endpoint.baseUrl.trim() }
: null;
}
private getConfiguredAnthropicCompatibleEndpointIssue(): string | null {
const endpoint =
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
if (endpoint?.enabled !== true) {
return null;
}
const baseUrl = endpoint.baseUrl.trim();
if (!baseUrl) {
return 'Anthropic-compatible endpoint is enabled, but no base URL is configured.';
}
try {
const url = new URL(baseUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Anthropic-compatible endpoint base URL must use http:// or https://.';
}
if (url.username || url.password) {
return 'Anthropic-compatible endpoint base URL must not include credentials.';
}
if (!isAnthropicCompatibleBaseUrl(baseUrl)) {
return 'Anthropic-compatible endpoint cannot use the first-party Anthropic API host.';
}
} catch {
return 'Anthropic-compatible endpoint base URL is invalid.';
}
return null;
}
private async getConfiguredAnthropicCompatibleToken(
options?: StoredApiKeyAccessOptions
): Promise<ExternalCredential> {
const storedToken = await this.lookupStoredApiKeyValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR, options);
if (storedToken?.value.trim()) {
return {
label: 'Stored in app',
value: storedToken.value.trim(),
};
}
const envToken = this.getExternalEnvValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
return envToken
? {
label: `Detected from ${ANTHROPIC_AUTH_TOKEN_ENV_VAR}`,
value: envToken,
}
: null;
}
private async applyConfiguredAnthropicCompatibleEndpointEnv(
env: NodeJS.ProcessEnv,
options?: StoredApiKeyAccessOptions
): Promise<boolean> {
const endpoint = this.getConfiguredAnthropicCompatibleEndpoint();
if (!endpoint) {
return false;
}
env[ANTHROPIC_BASE_URL_ENV_VAR] = endpoint.baseUrl;
const token = await this.getConfiguredAnthropicCompatibleToken(options);
if (token?.value.trim()) {
env[ANTHROPIC_AUTH_TOKEN_ENV_VAR] = token.value.trim();
}
if (typeof env.ANTHROPIC_API_KEY !== 'string' || !env.ANTHROPIC_API_KEY.trim()) {
env.ANTHROPIC_API_KEY = '';
}
return true;
}
private async getAnthropicCompatibleEndpointConnectionInfo(): Promise<
NonNullable<CliProviderConnectionInfo['compatibleEndpoint']>
> {
const endpoint =
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
const hasStoredToken = await this.hasStoredApiKey(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
const envToken = this.getExternalEnvValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
const tokenSource = hasStoredToken ? 'stored' : envToken ? 'environment' : null;
return {
enabled: endpoint.enabled,
baseUrl: endpoint.baseUrl,
tokenConfigured: Boolean(tokenSource),
tokenSource,
tokenSourceLabel:
tokenSource === 'stored'
? 'Stored in app'
: tokenSource === 'environment'
? `Detected from ${ANTHROPIC_AUTH_TOKEN_ENV_VAR}`
: null,
};
}
async getConfiguredAnthropicApiKeyForTeamRuntime(env: NodeJS.ProcessEnv): Promise<string | null> {
if (this.getConfiguredAuthMode('anthropic') !== 'api_key') {
return null;
}
if (hasAnthropicCompatibleAuthEnv(env)) {
const configuredEndpoint =
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
if (
configuredEndpoint?.enabled === true ||
isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)
) {
return null;
}
@ -418,6 +556,10 @@ export class ProviderConnectionService {
options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
return env;
}
if (hasAnthropicCompatibleAuthEnv(env)) {
return env;
}
@ -516,6 +658,10 @@ export class ProviderConnectionService {
options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
return env;
}
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
return env;
}
@ -588,6 +734,15 @@ export class ProviderConnectionService {
runtimeBackendOverride?: string | null
): Promise<string | null> {
if (providerId === 'anthropic') {
const compatibleEndpointIssue = this.getConfiguredAnthropicCompatibleEndpointIssue();
if (compatibleEndpointIssue) {
return compatibleEndpointIssue;
}
if (this.getConfiguredAnthropicCompatibleEndpoint()) {
return null;
}
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
return null;
}
@ -829,6 +984,18 @@ export class ProviderConnectionService {
provider: CliProviderStatus
): Promise<CliProviderStatus> {
const connection = provider.connection;
if (connection?.compatibleEndpoint?.enabled === true) {
return {
...provider,
subscriptionRateLimits: null,
statusMessage:
provider.statusMessage ??
(connection.compatibleEndpoint.tokenConfigured
? 'Anthropic-compatible endpoint configured'
: 'Anthropic-compatible endpoint configured. Auth token is not set.'),
};
}
if (connection?.configuredAuthMode !== 'api_key') {
return provider;
}
@ -962,6 +1129,8 @@ export class ProviderConnectionService {
: hasStoredApiKey
? 'Stored in app'
: (externalCredential?.label ?? null);
const compatibleEndpoint =
providerId === 'anthropic' ? await this.getAnthropicCompatibleEndpointConnectionInfo() : null;
return {
...capabilities,
@ -970,6 +1139,7 @@ export class ProviderConnectionService {
apiKeyConfigured,
apiKeySource,
apiKeySourceLabel,
compatibleEndpoint,
codex:
providerId === 'codex' && codexSnapshot
? {
@ -1017,7 +1187,9 @@ export class ProviderConnectionService {
envVarName: string,
options?: StoredApiKeyAccessOptions
): Promise<{ envVarName: string; value: string } | null> {
if (options?.allowStoredApiKeyDecryption === false) {
const allowedWhenMetadataOnly =
options?.allowedStoredApiKeyEnvVarNames?.includes(envVarName) === true;
if (options?.allowStoredApiKeyDecryption === false && !allowedWhenMetadataOnly) {
return null;
}

View file

@ -20,6 +20,7 @@ export interface ProviderAwareCliEnvOptions {
env?: NodeJS.ProcessEnv;
connectionMode?: 'strict' | 'augment';
allowStoredApiKeyDecryption?: boolean;
allowedStoredApiKeyEnvVarNames?: readonly string[];
}
export interface ProviderAwareCliEnvResult {
@ -33,9 +34,15 @@ export async function buildProviderAwareCliEnv(
): Promise<ProviderAwareCliEnvResult> {
const connectionMode = options.connectionMode ?? 'strict';
const storedApiKeyAccessArgs =
options.allowStoredApiKeyDecryption === undefined
options.allowStoredApiKeyDecryption === undefined &&
options.allowedStoredApiKeyEnvVarNames === undefined
? []
: [{ allowStoredApiKeyDecryption: options.allowStoredApiKeyDecryption }];
: [
{
allowStoredApiKeyDecryption: options.allowStoredApiKeyDecryption,
allowedStoredApiKeyEnvVarNames: options.allowedStoredApiKeyEnvVarNames,
},
];
const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {};
const { env, resolvedProviderId } = buildRuntimeBaseEnv({
binaryPath: options.binaryPath,

View file

@ -75,7 +75,7 @@ import {
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import {
listWindowsProcessTable,
@ -1291,6 +1291,17 @@ export function buildDirectTmuxRestartEnvAssignments(
}
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
if (providerId === 'anthropic') {
if (hasAnthropicCompatibleAuthTokenEnv(env)) {
assignments.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL?.trim() ?? '');
assignments.set('ANTHROPIC_AUTH_TOKEN', env.ANTHROPIC_AUTH_TOKEN?.trim() ?? '');
if (!env.ANTHROPIC_API_KEY?.trim()) {
assignments.set('ANTHROPIC_API_KEY', '');
}
} else if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) {
assignments.set('ANTHROPIC_AUTH_TOKEN', '');
}
}
if (
providerId === 'anthropic' &&
env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER
@ -2460,10 +2471,16 @@ function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
}
try {
const host = new URL(trimmed).host;
return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com';
const url = new URL(trimmed);
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
!url.username &&
!url.password &&
url.hostname !== 'api.anthropic.com' &&
url.hostname !== 'api-staging.anthropic.com'
);
} catch {
return true;
return false;
}
}
@ -35089,7 +35106,12 @@ export class TeamProvisioningService {
teamRuntimeAuth?: TeamRuntimeAuthContext;
}
): Promise<ProvisioningEnvResolution> {
const shellEnv = await resolveInteractiveShellEnv();
const shellEnv = await resolveInteractiveShellEnvBestEffort({
source: 'team-provisioning',
timeoutMs: 1_500,
fallbackEnv: process.env,
background: false,
});
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
// correctly on Windows. Prefer it over process.env which may be garbled.
const electronHome = getHomeDir();

View file

@ -45,7 +45,7 @@ import {
} from '@renderer/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useStore } from '@renderer/store';
import { AlertTriangle, Download, Key, Link2, Loader2, Trash2 } from 'lucide-react';
import { AlertTriangle, Download, Key, Link2, Loader2, Save, Trash2 } from 'lucide-react';
import {
formatProviderAuthMethodLabelForProvider,
@ -65,7 +65,7 @@ import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@sha
import type { ApiKeyEntry } from '@shared/types/extensions';
type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini';
type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | null;
type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | 'compatible' | null;
interface ConnectionMethodCardOption {
readonly authMode: CliProviderAuthMode;
@ -127,6 +127,10 @@ const API_KEY_PROVIDER_CONFIG: Record<
},
};
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME = 'Anthropic-compatible Auth Token';
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProviderId {
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
}
@ -163,6 +167,30 @@ function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): A
return matches.find((entry) => entry.scope === 'user') ?? null;
}
function validateAnthropicCompatibleBaseUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return 'Base URL is required';
}
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Base URL must use http:// or https://';
}
if (url.username || url.password) {
return 'Base URL must not include credentials';
}
if (FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)) {
return 'Use Auto, Subscription, or API key for first-party Anthropic';
}
} catch {
return 'Invalid URL';
}
return null;
}
function getConnectionDescription(provider: CliProviderStatus): string {
switch (provider.providerId) {
case 'anthropic':
@ -222,6 +250,12 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
const hasAnthropicSubscriptionSession =
provider.authMethod === 'oauth_token' || provider.authMethod === 'claude.ai';
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
return provider.connection.compatibleEndpoint.tokenConfigured
? null
: 'Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.';
}
if (
provider.providerId === 'anthropic' &&
authMode === 'api_key' &&
@ -304,6 +338,10 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
}
function getProviderUsageLabel(provider: CliProviderStatus): string {
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
return 'Using compatible endpoint';
}
if (
provider.providerId === 'anthropic' &&
provider.connection?.configuredAuthMode === 'api_key'
@ -637,6 +675,10 @@ export const ProviderRuntimeSettingsDialog = ({
const [runtimeSaving, setRuntimeSaving] = useState(false);
const [pendingConnectionAction, setPendingConnectionAction] =
useState<PendingConnectionAction>(null);
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState('');
const [compatibleTokenValue, setCompatibleTokenValue] = useState('');
const [compatibleEndpointError, setCompatibleEndpointError] = useState<string | null>(null);
const [compatibleEndpointStatus, setCompatibleEndpointStatus] = useState<string | null>(null);
const apiKeyInputRef = useRef<HTMLInputElement>(null);
const apiKeys = useStore((s) => s.apiKeys);
@ -679,11 +721,17 @@ export const ProviderRuntimeSettingsDialog = ({
setConnectionSaving(false);
setRuntimeSaving(false);
setPendingConnectionAction(null);
setCompatibleBaseUrl('');
setCompatibleTokenValue('');
setCompatibleEndpointError(null);
setCompatibleEndpointStatus(null);
}, [open]);
useEffect(() => {
setConnectionError(null);
setRuntimeError(null);
setCompatibleEndpointError(null);
setCompatibleEndpointStatus(null);
}, [selectedProviderId]);
useEffect(() => {
@ -710,6 +758,15 @@ export const ProviderRuntimeSettingsDialog = ({
const selectedApiKey = statusApiKeyConfig
? findPreferredApiKeyEntry(apiKeys, statusApiKeyConfig.envVarName)
: null;
const anthropicCompatibleConfig = appConfig?.providerConnections?.anthropic
.compatibleEndpoint ?? {
enabled: false,
baseUrl: '',
};
const selectedCompatibleToken = findPreferredApiKeyEntry(
apiKeys,
ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR
);
const selectedProvider = useMemo(() => {
const mergedStatusProvider =
@ -729,6 +786,22 @@ export const ProviderRuntimeSettingsDialog = ({
nextConnection.configuredAuthMode =
appConfig?.providerConnections?.anthropic.authMode ??
mergedStatusProvider.connection.configuredAuthMode;
nextConnection.compatibleEndpoint = {
...(mergedStatusProvider.connection.compatibleEndpoint ?? {
enabled: false,
baseUrl: '',
tokenConfigured: false,
tokenSource: null,
tokenSourceLabel: null,
}),
enabled: anthropicCompatibleConfig.enabled,
baseUrl: anthropicCompatibleConfig.baseUrl,
};
if (selectedCompatibleToken) {
nextConnection.compatibleEndpoint.tokenConfigured = true;
nextConnection.compatibleEndpoint.tokenSource = 'stored';
nextConnection.compatibleEndpoint.tokenSourceLabel = 'Stored in app';
}
}
if (mergedStatusProvider.providerId === 'codex') {
@ -754,14 +827,28 @@ export const ProviderRuntimeSettingsDialog = ({
connection: nextConnection,
};
}, [
anthropicCompatibleConfig.baseUrl,
anthropicCompatibleConfig.enabled,
appConfig?.providerConnections?.anthropic.authMode,
appConfig?.providerConnections?.codex.preferredAuthMode,
codexAccount.snapshot,
selectedCompatibleToken,
selectedApiKey,
statusApiKeyConfig,
statusSelectedProvider,
]);
useEffect(() => {
if (!open || selectedProviderId !== 'anthropic') {
return;
}
setCompatibleBaseUrl(anthropicCompatibleConfig.baseUrl);
setCompatibleTokenValue('');
setCompatibleEndpointError(null);
setCompatibleEndpointStatus(null);
}, [anthropicCompatibleConfig.baseUrl, open, selectedProviderId]);
const selectedProviderLoading = selectedProvider
? providerStatusLoading[selectedProvider.providerId] === true
: false;
@ -884,9 +971,24 @@ export const ProviderRuntimeSettingsDialog = ({
const canRequestSubscriptionLogin =
selectedProvider?.providerId === 'anthropic' &&
Boolean(selectedProvider.connection?.supportsOAuth && onRequestLogin) &&
selectedProvider.connection?.compatibleEndpoint?.enabled !== true &&
configuredAuthMode !== 'api_key' &&
selectedProvider.statusMessage !== 'Checking...' &&
(!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth');
const anthropicCompatibleEndpoint =
selectedProvider?.providerId === 'anthropic'
? (selectedProvider.connection?.compatibleEndpoint ?? null)
: null;
const anthropicCompatibleEndpointEnabled = anthropicCompatibleEndpoint?.enabled === true;
const anthropicCompatibleTokenConfigured = Boolean(
selectedCompatibleToken || anthropicCompatibleEndpoint?.tokenConfigured
);
const anthropicCompatibleTokenStatus =
selectedCompatibleToken?.maskedValue ??
anthropicCompatibleEndpoint?.tokenSourceLabel ??
(anthropicCompatibleTokenConfigured ? 'Configured' : null);
const anthropicCompatibleMissingToken =
anthropicCompatibleEndpointEnabled && !anthropicCompatibleTokenConfigured;
useEffect(() => {
if (!showApiKeyForm) {
@ -932,6 +1034,8 @@ export const ProviderRuntimeSettingsDialog = ({
return 'Switching to Anthropic subscription...';
case 'auto':
return 'Switching to Auto...';
case 'compatible':
return 'Saving compatible endpoint...';
default:
return 'Applying connection changes...';
}
@ -1080,6 +1184,112 @@ export const ProviderRuntimeSettingsDialog = ({
}
};
const handleSaveAnthropicCompatibleEndpoint = async (): Promise<void> => {
if (selectedProvider?.providerId !== 'anthropic') {
return;
}
const baseUrl = compatibleBaseUrl.trim();
const validationError = validateAnthropicCompatibleBaseUrl(baseUrl);
if (validationError) {
setCompatibleEndpointError(validationError);
setCompatibleEndpointStatus(null);
return;
}
setConnectionSaving(true);
setPendingConnectionAction('compatible');
setConnectionError(null);
setCompatibleEndpointError(null);
setCompatibleEndpointStatus(null);
let updateSucceeded = false;
try {
if (compatibleTokenValue.trim()) {
await saveApiKey({
id: selectedCompatibleToken?.id,
name: ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME,
envVarName: ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR,
value: compatibleTokenValue.trim(),
scope: 'user',
});
}
await updateConfig('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl,
},
},
});
updateSucceeded = true;
setCompatibleTokenValue('');
setCompatibleEndpointStatus(
compatibleTokenValue.trim() || anthropicCompatibleTokenConfigured
? 'Endpoint saved'
: 'Endpoint saved. Auth token is not configured.'
);
} catch (error) {
setCompatibleEndpointError(
error instanceof Error ? error.message : 'Failed to save endpoint'
);
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.('anthropic');
} catch {
setConnectionError('Endpoint saved, but failed to refresh provider status.');
}
}
setConnectionSaving(false);
setPendingConnectionAction(null);
}
};
const handleDisableAnthropicCompatibleEndpoint = async (): Promise<void> => {
if (selectedProvider?.providerId !== 'anthropic') {
return;
}
setConnectionSaving(true);
setPendingConnectionAction('compatible');
setConnectionError(null);
setCompatibleEndpointError(null);
setCompatibleEndpointStatus(null);
let updateSucceeded = false;
try {
await updateConfig('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: false,
baseUrl: compatibleBaseUrl.trim(),
},
},
});
updateSucceeded = true;
setCompatibleTokenValue('');
setCompatibleEndpointStatus('Endpoint disabled. Saved token was kept.');
} catch (error) {
setCompatibleEndpointError(
error instanceof Error ? error.message : 'Failed to disable endpoint'
);
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.('anthropic');
} catch {
setConnectionError('Endpoint disabled, but failed to refresh provider status.');
}
}
setConnectionSaving(false);
setPendingConnectionAction(null);
}
};
const handleCodexAccountRefresh = async (): Promise<void> => {
setConnectionError(null);
try {
@ -1363,6 +1573,171 @@ export const ProviderRuntimeSettingsDialog = ({
</div>
) : null}
{selectedProvider.providerId === 'anthropic' ? (
<div
className="space-y-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Local / compatible endpoint
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Use an Anthropic-compatible local runtime endpoint.
</div>
</div>
<span
className="rounded-full px-2 py-0.5 text-[11px]"
style={{
color: anthropicCompatibleEndpointEnabled
? '#86efac'
: 'var(--color-text-muted)',
backgroundColor: anthropicCompatibleEndpointEnabled
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
{anthropicCompatibleEndpointEnabled ? 'Enabled' : 'Off'}
</span>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-1.5">
<Label htmlFor="anthropic-compatible-base-url" className="text-xs">
Base URL
</Label>
<Input
id="anthropic-compatible-base-url"
value={compatibleBaseUrl}
onChange={(event) => {
setCompatibleBaseUrl(event.currentTarget.value);
setCompatibleEndpointError(null);
setCompatibleEndpointStatus(null);
}}
placeholder="http://localhost:1234"
className="h-9 text-sm"
disabled={connectionBusy}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="anthropic-compatible-auth-token" className="text-xs">
Auth token
</Label>
<Input
id="anthropic-compatible-auth-token"
type="password"
value={compatibleTokenValue}
onChange={(event) => {
setCompatibleTokenValue(event.currentTarget.value);
setCompatibleEndpointError(null);
setCompatibleEndpointStatus(null);
}}
placeholder={
anthropicCompatibleTokenConfigured
? 'Leave blank to keep saved token'
: 'lmstudio'
}
className="h-9 text-sm"
disabled={connectionBusy || apiKeySaving}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span
className="rounded-full px-2 py-0.5"
style={{
color: anthropicCompatibleTokenConfigured
? '#86efac'
: 'var(--color-text-muted)',
backgroundColor: anthropicCompatibleTokenConfigured
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
Token {anthropicCompatibleTokenConfigured ? 'configured' : 'not set'}
</span>
{anthropicCompatibleTokenStatus ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
{anthropicCompatibleTokenStatus}
</span>
) : null}
{anthropicCompatibleEndpointEnabled &&
anthropicCompatibleEndpoint?.baseUrl ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
{anthropicCompatibleEndpoint.baseUrl}
</span>
) : null}
</div>
{compatibleEndpointError ? (
<div
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(248, 113, 113, 0.25)',
backgroundColor: 'rgba(248, 113, 113, 0.06)',
color: '#fca5a5',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{compatibleEndpointError}</span>
</div>
) : compatibleEndpointStatus ? (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(74, 222, 128, 0.22)',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
color: '#86efac',
}}
>
{compatibleEndpointStatus}
</div>
) : anthropicCompatibleMissingToken ? (
<div
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(245, 158, 11, 0.25)',
backgroundColor: 'rgba(245, 158, 11, 0.06)',
color: '#fbbf24',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>Auth token is not configured.</span>
</div>
) : null}
<div className="flex justify-end gap-2">
{anthropicCompatibleEndpointEnabled ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={connectionBusy}
onClick={() => void handleDisableAnthropicCompatibleEndpoint()}
>
Disable
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={connectionBusy || apiKeySaving || !compatibleBaseUrl.trim()}
onClick={() => void handleSaveAnthropicCompatibleEndpoint()}
>
{connectionSaving && pendingConnectionAction === 'compatible' ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<Save className="mr-1 size-3.5" />
)}
Save endpoint
</Button>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs">
{configuredAuthMode && !hideConnectionMethodMeta ? (
<span

View file

@ -17,9 +17,11 @@ import {
isGeminiUiFrozen,
} from '@renderer/utils/geminiUiFreeze';
import {
canUseCustomAnthropicCompatibleModel,
getAvailableTeamProviderModelOptions,
getOpenCodeOpenAiRouteAuthUnavailableReason,
getTeamModelUiDisabledReason,
isAnthropicCompatibleRuntime,
isTeamProviderModelVerificationPending,
normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
@ -745,6 +747,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
);
const defaultModelTooltip = useMemo(() => {
if (effectiveProviderId === 'anthropic') {
if (isAnthropicCompatibleRuntime(runtimeProviderStatus)) {
const defaultCompatibleModel =
runtimeProviderStatus?.modelCatalog?.defaultLaunchModel?.trim() ||
runtimeProviderStatus?.modelCatalog?.defaultModelId?.trim() ||
null;
return defaultCompatibleModel
? `Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to ${defaultCompatibleModel}.`
: 'Uses the Anthropic-compatible endpoint default model.';
}
const defaultLongContextModel =
getRuntimeAwareProviderScopedTeamModelLabel(
'anthropic',
@ -879,6 +891,23 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
}, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
const showAnthropicCompatibleCustomModelInput =
effectiveProviderId === 'anthropic' &&
canUseCustomAnthropicCompatibleModel(runtimeProviderStatus);
const selectedModelMatchesOption = modelOptions.some(
(option) => option.value === normalizedValue
);
const anthropicCompatibleCustomModelValue =
showAnthropicCompatibleCustomModelInput && normalizedValue && !selectedModelMatchesOption
? normalizedValue
: '';
const anthropicCompatibleCatalogWarning =
showAnthropicCompatibleCustomModelInput &&
runtimeProviderStatus?.modelCatalog?.providerId === 'anthropic'
? (runtimeProviderStatus.modelCatalog.diagnostics.message ??
runtimeProviderStatus.modelCatalog.diagnostics.code ??
null)
: null;
const openCodeCatalogModelById = useMemo(() => {
const catalog = runtimeProviderStatus?.modelCatalog;
const modelById = new Map<string, ProviderModelCatalogItem>();
@ -1620,6 +1649,30 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
list is syncing.
</p>
) : null}
{showAnthropicCompatibleCustomModelInput ? (
<div className="mb-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-2">
<Label
htmlFor="anthropic-compatible-custom-model"
className="mb-1 block text-[11px] font-medium text-[var(--color-text-secondary)]"
>
Custom model id
</Label>
<Input
id="anthropic-compatible-custom-model"
data-testid="team-model-selector-anthropic-compatible-custom-model"
value={anthropicCompatibleCustomModelValue}
onChange={(event) => onValueChange(event.currentTarget.value.trim())}
placeholder="openai/gpt-oss-20b"
className="h-8 text-xs"
disabled={isInspectingInactiveProvider || !activeProviderSelectable}
/>
{anthropicCompatibleCatalogWarning ? (
<p className="mt-1.5 text-[10px] leading-relaxed text-amber-200">
{anthropicCompatibleCatalogWarning}
</p>
) : null}
</div>
) : null}
{shouldShowModelSearch ? (
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />

View file

@ -53,6 +53,7 @@ export type TeamModelRuntimeProviderStatus = Pick<
| 'detailMessage'
| 'availableBackends'
| 'externalRuntimeDiagnostics'
| 'connection'
> &
Partial<Pick<CliProviderStatus, 'verificationState' | 'statusMessage'>>;
@ -230,6 +231,58 @@ function hasAnthropicRuntimeCatalog(
return providerStatus?.modelCatalog?.providerId === 'anthropic';
}
function hasAnthropicCompatibleRuntimeCatalog(
providerStatus?: TeamModelRuntimeProviderStatus | null
): boolean {
return (
providerStatus?.modelCatalog?.providerId === 'anthropic' &&
providerStatus.modelCatalog.source === 'anthropic-compatible-api'
);
}
export function isAnthropicCompatibleRuntime(
providerStatus?: TeamModelRuntimeProviderStatus | null
): boolean {
return (
hasAnthropicCompatibleRuntimeCatalog(providerStatus) ||
providerStatus?.runtimeCapabilities?.modelCatalog?.source === 'anthropic-compatible-api' ||
providerStatus?.connection?.compatibleEndpoint?.enabled === true
);
}
function hasVisibleAnthropicCompatibleCatalogModels(
providerStatus?: TeamModelRuntimeProviderStatus | null
): boolean {
const catalog = hasAnthropicCompatibleRuntimeCatalog(providerStatus)
? providerStatus?.modelCatalog
: null;
return Boolean(
catalog?.models.some((model) => {
const launchModel = model.launchModel.trim() || model.id.trim();
return !model.hidden && launchModel.length > 0;
})
);
}
export function canUseCustomAnthropicCompatibleModel(
providerStatus?: TeamModelRuntimeProviderStatus | null
): boolean {
if (!isAnthropicCompatibleRuntime(providerStatus)) {
return false;
}
const catalog = providerStatus?.modelCatalog;
if (!catalog || catalog.providerId !== 'anthropic') {
return true;
}
if (catalog.source !== 'anthropic-compatible-api') {
return true;
}
return catalog.status !== 'ready' || !hasVisibleAnthropicCompatibleCatalogModels(providerStatus);
}
function getAnthropicCatalogModel(
model: string,
providerStatus?: TeamModelRuntimeProviderStatus | null
@ -247,16 +300,20 @@ function getRuntimeCatalogModels(
providerStatus?: TeamModelRuntimeProviderStatus | null
): string[] | null {
if (providerId === 'anthropic') {
return null;
}
if (
if (!hasAnthropicCompatibleRuntimeCatalog(providerStatus)) {
return null;
}
} else if (
(providerId !== 'codex' && providerId !== 'opencode') ||
providerStatus?.modelCatalog?.providerId !== providerId
) {
return null;
}
if (!providerStatus?.modelCatalog) {
return null;
}
const models = providerStatus.modelCatalog.models
.filter((model) => !model.hidden)
.map((model) => model.launchModel.trim() || model.id.trim())
@ -269,7 +326,10 @@ function getRuntimeCatalogModelOption(
model: string,
providerStatus?: TeamModelRuntimeProviderStatus | null
): TeamRuntimeModelOption | null {
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
const canUseCatalog =
(providerId === 'codex' && providerStatus?.modelCatalog?.providerId === 'codex') ||
(providerId === 'anthropic' && hasAnthropicCompatibleRuntimeCatalog(providerStatus));
if (!canUseCatalog || !providerStatus?.modelCatalog) {
return null;
}
@ -280,8 +340,9 @@ function getRuntimeCatalogModelOption(
return null;
}
const launchModel = catalogModel.launchModel.trim() || catalogModel.id.trim();
return {
value: catalogModel.launchModel,
value: launchModel,
label:
getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ??
catalogModel.displayName,
@ -290,12 +351,8 @@ function getRuntimeCatalogModelOption(
(getTeamProviderModelOptions(providerId).some((option) => option.value === model)
? undefined
: 'New'),
availabilityStatus: getRuntimeModelAvailability(
providerId,
catalogModel.launchModel,
providerStatus
),
availabilityReason: getRuntimeModelAvailabilityReason(catalogModel.launchModel, providerStatus),
availabilityStatus: getRuntimeModelAvailability(providerId, launchModel, providerStatus),
availabilityReason: getRuntimeModelAvailabilityReason(launchModel, providerStatus),
};
}
@ -309,6 +366,10 @@ function getRuntimeSelectorModels(
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
if (catalogModels) {
if (providerId === 'anthropic') {
return sortTeamProviderModels(providerId, catalogModels, providerStatus);
}
const sourceModels =
providerId === 'opencode'
? mergeModelLists(catalogModels, providerStatus.models)
@ -316,6 +377,10 @@ function getRuntimeSelectorModels(
return getVisibleTeamProviderModels(providerId, sourceModels, providerStatus);
}
if (providerId === 'anthropic' && isAnthropicCompatibleRuntime(providerStatus)) {
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
}
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
}
@ -384,6 +449,18 @@ function getRuntimeModelAvailability(
providerStatus?: TeamModelRuntimeProviderStatus | null
): CliProviderModelAvailabilityStatus | null {
if (providerId === 'anthropic') {
if (isAnthropicCompatibleRuntime(providerStatus)) {
const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
if (visibleModels.includes(model)) {
const runtimeAvailability = getModelAvailabilityMap(providerStatus).get(model)?.status;
return runtimeAvailability === 'unavailable' ? 'unavailable' : 'available';
}
return canUseCustomAnthropicCompatibleModel(providerStatus) && model.trim()
? 'available'
: null;
}
if (!providerStatus || !hasAnthropicRuntimeCatalog(providerStatus)) {
return isSupportedAnthropicTeamModel(model) ? 'available' : null;
}
@ -418,7 +495,9 @@ export function getTeamProviderModelVerificationCounts(
providerStatus?: TeamModelRuntimeProviderStatus | null
): TeamProviderModelVerificationCounts {
if (providerId === 'anthropic') {
const visibleAnthropicModels = getFallbackTeamProviderModels(providerId);
const visibleAnthropicModels = isAnthropicCompatibleRuntime(providerStatus)
? getRuntimeSelectorModels(providerId, providerStatus)
: getFallbackTeamProviderModels(providerId);
return {
checkedCount: visibleAnthropicModels.length,
totalCount: visibleAnthropicModels.length,
@ -440,6 +519,12 @@ export function getAvailableTeamProviderModels(
providerStatus?: TeamModelRuntimeProviderStatus | null
): string[] {
if (providerId === 'anthropic') {
if (isAnthropicCompatibleRuntime(providerStatus)) {
return getVisibleRuntimeModels(providerId, providerStatus).filter(
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
);
}
return getFallbackTeamProviderModels(providerId).filter(
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
);
@ -461,6 +546,27 @@ export function getAvailableTeamProviderModelOptions(
providerStatus?: TeamModelRuntimeProviderStatus | null
): TeamRuntimeModelOption[] {
if (providerId === 'anthropic') {
if (isAnthropicCompatibleRuntime(providerStatus)) {
const visibleModels = getRuntimeSelectorModels(providerId, providerStatus);
return [
{ value: '', label: 'Default', badgeLabel: 'Default' },
...visibleModels.map((model) => {
const catalogOption = getRuntimeCatalogModelOption(providerId, model, providerStatus);
if (catalogOption) {
return catalogOption;
}
return {
value: model,
label: getProviderScopedTeamModelLabel(providerId, model) ?? model,
badgeLabel: getRuntimeAwareTeamModelBadgeLabel(providerId, model, providerStatus),
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
};
}),
];
}
return getFallbackTeamProviderModelOptions(providerId, providerStatus).map((option) => ({
...option,
availabilityStatus:
@ -538,6 +644,13 @@ export function isTeamModelAvailableForUi(
}
if (providerId === 'anthropic') {
if (isAnthropicCompatibleRuntime(providerStatus)) {
return (
getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available' ||
canUseCustomAnthropicCompatibleModel(providerStatus)
);
}
if (!isSupportedAnthropicTeamModel(trimmed)) {
return false;
}
@ -576,6 +689,10 @@ export function normalizeTeamModelForUi(
}
if (providerId === 'anthropic') {
if (isAnthropicCompatibleRuntime(providerStatus)) {
return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : '';
}
return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : '';
}

View file

@ -44,6 +44,13 @@ export interface CliProviderConnectionInfo {
apiKeyConfigured: boolean;
apiKeySource: 'stored' | 'environment' | null;
apiKeySourceLabel?: string | null;
compatibleEndpoint?: {
enabled: boolean;
baseUrl: string;
tokenConfigured: boolean;
tokenSource: 'stored' | 'environment' | null;
tokenSourceLabel?: string | null;
} | null;
codex?: {
preferredAuthMode: CodexAccountAuthMode;
effectiveAuthMode: CodexAccountEffectiveAuthMode;

View file

@ -354,6 +354,10 @@ export interface AppConfig {
anthropic: {
authMode: 'auto' | 'oauth' | 'api_key';
fastModeDefault: boolean;
compatibleEndpoint: {
enabled: boolean;
baseUrl: string;
};
};
codex: {
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';

View file

@ -62,7 +62,7 @@ vi.mock('@main/utils/childProcess', () => ({
}));
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(),
}));
import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient';

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
import { validateConfigUpdatePayload } from '../../../src/main/ipc/configValidation';
@ -239,6 +239,91 @@ describe('configValidation', () => {
}
});
it('accepts Anthropic-compatible endpoint provider connection updates', () => {
const result = validateConfigUpdatePayload('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl: ' http://localhost:1234/v1 ',
},
},
});
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.data).toEqual({
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234/v1',
},
},
});
}
});
it.each([
'https://api.anthropic.com',
'https://api.anthropic.com:443/v1',
'HTTPS://API.ANTHROPIC.COM/v1',
'https://api-staging.anthropic.com',
'http://token@localhost:1234',
'http://user:pass@localhost:1234',
'ftp://localhost:1234',
'not a url',
])('rejects invalid Anthropic-compatible endpoint URL %s', (baseUrl) => {
const result = validateConfigUpdatePayload('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl,
},
},
});
expect(result.valid).toBe(false);
});
it('rejects UI-derived Anthropic-compatible endpoint status fields', () => {
const result = validateConfigUpdatePayload('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
},
},
});
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain('tokenConfigured is not a valid setting');
}
});
it('allows disabling Anthropic-compatible endpoint with an empty base URL', () => {
const result = validateConfigUpdatePayload('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: false,
baseUrl: '',
},
},
});
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.data).toEqual({
anthropic: {
compatibleEndpoint: {
enabled: false,
baseUrl: '',
},
},
});
}
});
it('normalizes legacy Codex runtime backend updates to codex-native', () => {
const apiResult = validateConfigUpdatePayload('runtime', {
providerBackends: {

View file

@ -88,4 +88,217 @@ describe('ConfigManager Codex migration hardening', () => {
expect(persisted.runtime.providerBackends.codex).toBe('codex-native');
});
});
it('loads legacy Anthropic provider connections with compatible endpoint defaults', async () => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-default-'));
const configPath = path.join(tempRoot, 'agent-teams-config.json');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
fs.writeFileSync(
configPath,
JSON.stringify({
providerConnections: {
anthropic: {
authMode: 'oauth',
fastModeDefault: true,
},
},
})
);
const { ConfigManager } = await import(
'../../../../src/main/services/infrastructure/ConfigManager'
);
const manager = new ConfigManager(configPath);
const config = manager.getConfig();
expect(config.providerConnections.anthropic).toEqual({
authMode: 'oauth',
fastModeDefault: true,
compatibleEndpoint: {
enabled: false,
baseUrl: '',
},
});
await vi.waitFor(() => {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
providerConnections: {
anthropic: {
compatibleEndpoint: { enabled: boolean; baseUrl: string };
};
};
};
expect(persisted.providerConnections.anthropic.compatibleEndpoint).toEqual({
enabled: false,
baseUrl: '',
});
});
});
it('deep-merges partial Anthropic compatible endpoint updates', async () => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-update-'));
const configPath = path.join(tempRoot, 'agent-teams-config.json');
const { ConfigManager } = await import(
'../../../../src/main/services/infrastructure/ConfigManager'
);
const manager = new ConfigManager(configPath);
manager.updateConfig('providerConnections', {
anthropic: {
authMode: 'oauth',
fastModeDefault: true,
},
} as never);
await vi.waitFor(() => {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
providerConnections: {
anthropic: {
authMode: string;
fastModeDefault: boolean;
};
};
};
expect(persisted.providerConnections.anthropic.authMode).toBe('oauth');
expect(persisted.providerConnections.anthropic.fastModeDefault).toBe(true);
});
const updated = manager.updateConfig('providerConnections', {
anthropic: {
compatibleEndpoint: {
baseUrl: ' http://localhost:1234 ',
},
},
} as never);
expect(updated.providerConnections.anthropic).toEqual({
authMode: 'oauth',
fastModeDefault: true,
compatibleEndpoint: {
enabled: false,
baseUrl: 'http://localhost:1234',
},
});
await vi.waitFor(() => {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
providerConnections: {
anthropic: {
authMode: string;
fastModeDefault: boolean;
compatibleEndpoint: { enabled: boolean; baseUrl: string };
};
};
};
expect(persisted.providerConnections.anthropic).toEqual({
authMode: 'oauth',
fastModeDefault: true,
compatibleEndpoint: {
enabled: false,
baseUrl: 'http://localhost:1234',
},
});
});
});
it('strips derived Anthropic compatible endpoint token status when loading config', async () => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-derived-'));
const configPath = path.join(tempRoot, 'agent-teams-config.json');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
fs.writeFileSync(
configPath,
JSON.stringify({
providerConnections: {
anthropic: {
authMode: 'auto',
compatibleEndpoint: {
enabled: true,
baseUrl: ' http://localhost:1234 ',
tokenConfigured: true,
tokenSource: 'stored',
tokenSourceLabel: 'Stored in app',
},
},
},
})
);
const { ConfigManager } = await import(
'../../../../src/main/services/infrastructure/ConfigManager'
);
const manager = new ConfigManager(configPath);
expect(manager.getConfig().providerConnections.anthropic.compatibleEndpoint).toEqual({
enabled: true,
baseUrl: 'http://localhost:1234',
});
await vi.waitFor(() => {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
providerConnections: {
anthropic: {
compatibleEndpoint: Record<string, unknown>;
};
};
};
expect(persisted.providerConnections.anthropic.compatibleEndpoint).toEqual({
enabled: true,
baseUrl: 'http://localhost:1234',
});
});
});
it('strips derived Anthropic compatible endpoint token status from partial updates', async () => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-derived-update-'));
const configPath = path.join(tempRoot, 'agent-teams-config.json');
const { ConfigManager } = await import(
'../../../../src/main/services/infrastructure/ConfigManager'
);
const manager = new ConfigManager(configPath);
const updated = manager.updateConfig('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
tokenSource: 'environment',
},
},
} as never);
expect(updated.providerConnections.anthropic.compatibleEndpoint).toEqual({
enabled: true,
baseUrl: 'http://localhost:1234',
});
await vi.waitFor(() => {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
providerConnections: {
anthropic: {
compatibleEndpoint: Record<string, unknown>;
};
};
};
expect(persisted.providerConnections.anthropic.compatibleEndpoint).toEqual({
enabled: true,
baseUrl: 'http://localhost:1234',
});
});
});
});

View file

@ -1,13 +1,14 @@
// @vitest-environment node
import type { PathLike } from 'fs';
import { readFile as readFileFixture, writeFile } from 'fs/promises';
import * as path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
getProviderConnectionModeSummary,
getProviderCurrentRuntimeSummary,
isConnectionManagedRuntimeProvider,
} from '@renderer/components/runtime/providerConnectionUi';
import { readFile as readFileFixture, writeFile } from 'fs/promises';
import * as path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PathLike } from 'fs';
const execCliMock = vi.fn();
const buildProviderAwareCliEnvMock = vi.fn();
@ -379,6 +380,58 @@ describe('ClaudeMultimodelBridgeService', () => {
vi.mocked(console.warn).mockClear();
});
it('does not cascade aggregate summary timeouts into slower fallback probes', async () => {
execCliMock.mockImplementation((_binaryPath, args, options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (
normalizedArgs === 'runtime status --json --provider anthropic --summary' ||
normalizedArgs === 'runtime status --json --provider codex --summary' ||
normalizedArgs === 'runtime status --json --provider opencode --summary'
) {
return Promise.reject(
new Error(
`Command timed out after ${options?.timeout}ms: /mock/agent_teams_orchestrator ${normalizedArgs}`
)
);
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
expect(execCliMock).toHaveBeenCalledTimes(3);
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([
15000,
15000,
15000,
]);
expect(calls).toEqual([
'runtime status --json --provider anthropic --summary',
'runtime status --json --provider codex --summary',
'runtime status --json --provider opencode --summary',
]);
expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
expect(providers.every((provider) => provider.verificationState === 'error')).toBe(true);
expect(providers.every((provider) => provider.statusMessage === 'Provider status unavailable'))
.toBe(true);
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
expect.stringContaining(
'Provider-scoped runtime status timed out for anthropic, codex, opencode'
),
]);
vi.mocked(console.warn).mockClear();
});
it('loads frontend providers with parallel provider-scoped runtime status probes', async () => {
const providerPayloads = {
anthropic: {
@ -1386,6 +1439,13 @@ describe('ClaudeMultimodelBridgeService', () => {
verificationState: 'error',
});
expect(provider.statusMessage).toContain('ANTHROPIC_API_KEY');
expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'anthropic',
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
})
);
});
it('falls back conservatively when the runtime omits extension capability metadata', async () => {

View file

@ -41,11 +41,16 @@ describe('ProviderConnectionService', () => {
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
function createConfig(authMode: 'auto' | 'oauth' | 'api_key' = 'auto') {
function createConfig(
authMode: 'auto' | 'oauth' | 'api_key' = 'auto',
compatibleEndpoint: { enabled: boolean; baseUrl: string } = { enabled: false, baseUrl: '' }
) {
return {
providerConnections: {
anthropic: {
authMode,
fastModeDefault: false,
compatibleEndpoint,
},
codex: {
preferredAuthMode: 'auto' as const,
@ -217,6 +222,58 @@ describe('ProviderConnectionService', () => {
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
});
it('does not treat first-party Anthropic base URLs as compatible OAuth env', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('oauth'),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_BASE_URL: 'HTTPS://API.ANTHROPIC.COM/v1',
ANTHROPIC_AUTH_TOKEN: 'stale-first-party-token',
},
'anthropic'
);
expect(result.ANTHROPIC_BASE_URL).toBe('HTTPS://API.ANTHROPIC.COM/v1');
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
});
it('does not preserve malformed Anthropic-compatible shell env', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('oauth'),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_BASE_URL: 'not a url',
ANTHROPIC_AUTH_TOKEN: 'local-token',
},
'anthropic'
);
expect(result.ANTHROPIC_BASE_URL).toBe('not a url');
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
});
it('injects the stored Anthropic API key when api_key mode is selected', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
@ -278,6 +335,262 @@ describe('ProviderConnectionService', () => {
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
});
it('injects app-managed Anthropic-compatible endpoint env without stored Anthropic API key', async () => {
const lookupPreferred = vi.fn(async (envVarName: string) => {
if (envVarName === 'ANTHROPIC_AUTH_TOKEN') {
return {
envVarName,
value: 'stored-local-token',
};
}
if (envVarName === 'ANTHROPIC_API_KEY') {
return {
envVarName,
value: 'stored-real-anthropic-key',
};
}
return null;
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () =>
createConfig('api_key', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const result = await service.applyConfiguredConnectionEnv({}, 'anthropic');
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_AUTH_TOKEN');
expect(lookupPreferred).not.toHaveBeenCalledWith('ANTHROPIC_API_KEY');
expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:1234');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('stored-local-token');
expect(result.ANTHROPIC_API_KEY).toBe('');
});
it('uses shell ANTHROPIC_AUTH_TOKEN for app-managed compatible endpoint when no stored token exists', async () => {
getCachedShellEnvMock.mockReturnValue({
ANTHROPIC_AUTH_TOKEN: 'shell-local-token',
});
const lookupPreferred = vi.fn().mockResolvedValue(null);
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () =>
createConfig('oauth', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const result = await service.applyConfiguredConnectionEnv({}, 'anthropic');
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_AUTH_TOKEN');
expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:1234');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('shell-local-token');
expect(result.ANTHROPIC_API_KEY).toBe('');
});
it('can decrypt only the stored Anthropic-compatible token for metadata-only runtime status', async () => {
const lookupPreferred = vi.fn(async (envVarName: string) => {
if (envVarName === 'ANTHROPIC_AUTH_TOKEN') {
return {
envVarName,
value: 'stored-local-token',
};
}
if (envVarName === 'ANTHROPIC_API_KEY') {
return {
envVarName,
value: 'stored-real-anthropic-key',
};
}
return null;
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () =>
createConfig('api_key', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const result = await service.applyConfiguredConnectionEnv({}, 'anthropic', undefined, {
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
});
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_AUTH_TOKEN');
expect(lookupPreferred).not.toHaveBeenCalledWith('ANTHROPIC_API_KEY');
expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:1234');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('stored-local-token');
expect(result.ANTHROPIC_API_KEY).toBe('');
});
it('preserves explicit env ANTHROPIC_API_KEY for an app-managed compatible endpoint', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () =>
createConfig('auto', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_API_KEY: 'explicit-local-token',
},
'anthropic'
);
expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:1234');
expect(result.ANTHROPIC_API_KEY).toBe('explicit-local-token');
});
it('does not require an Anthropic API key when app-managed compatible endpoint is enabled', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () =>
createConfig('api_key', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const issue = await service.getConfiguredConnectionIssue({}, 'anthropic');
expect(issue).toBeNull();
});
it('reports invalid app-managed compatible endpoint URLs before mutating Anthropic env', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () =>
createConfig('auto', {
enabled: true,
baseUrl: 'http://token@localhost:1234',
}),
} as never
);
await expect(service.getConfiguredConnectionIssue({}, 'anthropic')).resolves.toContain(
'must not include credentials'
);
const env = await service.applyConfiguredConnectionEnv({}, 'anthropic');
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
});
it('reports app-managed Anthropic-compatible token source without decrypting it', async () => {
const lookupPreferred = vi.fn().mockResolvedValue(null);
const hasPreferred = vi.fn(async (envVarName: string) => envVarName === 'ANTHROPIC_AUTH_TOKEN');
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
hasPreferred,
} as never,
{
getConfig: () =>
createConfig('auto', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const info = await service.getConnectionInfo('anthropic');
expect(info.compatibleEndpoint).toEqual({
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
tokenSource: 'stored',
tokenSourceLabel: 'Stored in app',
});
expect(lookupPreferred).not.toHaveBeenCalled();
});
it('reports environment Anthropic-compatible token source when no stored token exists', async () => {
getCachedShellEnvMock.mockReturnValue({
ANTHROPIC_AUTH_TOKEN: 'env-local-token',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
hasPreferred: vi.fn().mockResolvedValue(false),
} as never,
{
getConfig: () =>
createConfig('auto', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const info = await service.getConnectionInfo('anthropic');
expect(info.compatibleEndpoint).toMatchObject({
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
tokenSource: 'environment',
tokenSourceLabel: 'Detected from ANTHROPIC_AUTH_TOKEN',
});
});
it('does not decrypt stored Anthropic keys when metadata-only env building is requested', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
@ -1986,6 +2299,61 @@ describe('ProviderConnectionService', () => {
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_API_KEY');
});
it('does not use stored Anthropic API keys for team helper mode with a compatible base URL', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
value: 'stored-real-anthropic-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () =>
createConfig('api_key', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
await expect(
service.getConfiguredAnthropicApiKeyForTeamRuntime({
ANTHROPIC_BASE_URL: 'http://localhost:1234',
ANTHROPIC_API_KEY: '',
})
).resolves.toBeNull();
expect(lookupPreferred).not.toHaveBeenCalled();
});
it('ignores malformed Anthropic-compatible shell base URLs for team helper mode', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
value: 'stored-team-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () => createConfig('api_key'),
} as never
);
await expect(
service.getConfiguredAnthropicApiKeyForTeamRuntime({
ANTHROPIC_BASE_URL: 'not a url',
})
).resolves.toBe('stored-team-key');
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_API_KEY');
});
it('does not use token-only or OAuth credentials for Anthropic team helper mode', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');

View file

@ -193,6 +193,47 @@ describe('buildProviderAwareCliEnv', () => {
);
});
it('passes a stored API key decrypt allowlist through provider env building', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
await buildProviderAwareCliEnv({
providerId: 'anthropic',
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
});
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
expect.objectContaining({
CLAUDE_CODE_ENTRY_PROVIDER: 'anthropic',
}),
'anthropic',
undefined,
{
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
}
);
});
it('passes a stored API key decrypt allowlist through augment env building', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
await buildProviderAwareCliEnv({
connectionMode: 'augment',
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
});
expect(augmentAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(
expect.any(Object),
{
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
}
);
expect(applyAllConfiguredConnectionEnvMock).not.toHaveBeenCalled();
});
it('builds shared env for generic CLI launches when no provider is specified', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');

View file

@ -13,6 +13,7 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: vi.fn(),
resolveInteractiveShellEnvBestEffort: vi.fn(),
}));
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
@ -36,7 +37,7 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
type CodexProbeHarness = TeamProvisioningService & {
probeClaudeRuntime: (
@ -62,7 +63,7 @@ describe('TeamProvisioningService Codex create-team preflight', () => {
vi.clearAllMocks();
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-codex-preflight-'));
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});

View file

@ -11,6 +11,7 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: vi.fn(),
resolveInteractiveShellEnvBestEffort: vi.fn(),
}));
const buildProviderAwareCliEnvMock = vi.fn();
@ -102,7 +103,7 @@ import {
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { spawnCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } {
const workspaceRoot = process.cwd();
@ -335,7 +336,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
addTeamNotificationMock.mockResolvedValue(null);
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});
@ -405,6 +406,57 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(assignments).toContain("CLAUDE_CODE_ENTRY_PROVIDER='anthropic'");
});
it('preserves Anthropic-compatible direct restart env while blanking stale first-party tokens', () => {
const compatibleAssignments = buildDirectTmuxRestartEnvAssignments(
{
ANTHROPIC_BASE_URL: 'http://localhost:1234',
ANTHROPIC_AUTH_TOKEN: 'lmstudio',
ANTHROPIC_API_KEY: '',
},
'anthropic'
);
expect(compatibleAssignments).toContain("ANTHROPIC_BASE_URL='http://localhost:1234'");
expect(compatibleAssignments).toContain("ANTHROPIC_AUTH_TOKEN='lmstudio'");
expect(compatibleAssignments).toContain("ANTHROPIC_API_KEY=''");
const firstPartyAssignments = buildDirectTmuxRestartEnvAssignments(
{
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
ANTHROPIC_AUTH_TOKEN: 'stale-oauth-token',
},
'anthropic'
);
expect(firstPartyAssignments).toContain("ANTHROPIC_BASE_URL='https://api.anthropic.com'");
expect(firstPartyAssignments).toContain("ANTHROPIC_AUTH_TOKEN=''");
expect(firstPartyAssignments).not.toContain('stale-oauth-token');
const malformedAssignments = buildDirectTmuxRestartEnvAssignments(
{
ANTHROPIC_BASE_URL: 'not a url',
ANTHROPIC_AUTH_TOKEN: 'malformed-local-token',
},
'anthropic'
);
expect(malformedAssignments).toContain("ANTHROPIC_BASE_URL='not a url'");
expect(malformedAssignments).toContain("ANTHROPIC_AUTH_TOKEN=''");
expect(malformedAssignments).not.toContain('malformed-local-token');
const credentialUrlAssignments = buildDirectTmuxRestartEnvAssignments(
{
ANTHROPIC_BASE_URL: 'http://token@localhost:1234',
ANTHROPIC_AUTH_TOKEN: 'credential-url-token',
},
'anthropic'
);
expect(credentialUrlAssignments).toContain("ANTHROPIC_BASE_URL='http://token@localhost:1234'");
expect(credentialUrlAssignments).toContain("ANTHROPIC_AUTH_TOKEN=''");
expect(credentialUrlAssignments).not.toContain('credential-url-token');
});
it('does not flatten Anthropic helper settings into non-Anthropic lead cross-provider args', async () => {
const svc = new TeamProvisioningService();
const helperSettingsPath = path.join(tempRoot, 'team-runtime-auth', 'helper-settings.json');
@ -3175,7 +3227,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
PATH: '/usr/bin',
SHELL: '/bin/zsh',
@ -3189,7 +3241,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
it('preserves Anthropic-compatible Ollama auth token without mapping it into ANTHROPIC_API_KEY', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
ANTHROPIC_BASE_URL: 'http://localhost:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_API_KEY: '',
@ -3205,9 +3257,45 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.env.ANTHROPIC_API_KEY).toBe('');
});
it('does not materialize the Anthropic API-key helper for compatible endpoints without a token', async () => {
const svc = new TeamProvisioningService();
const getConfiguredAnthropicApiKeyForTeamRuntime = vi.fn().mockResolvedValue(null);
(svc as any).providerConnectionService = {
getConfiguredAnthropicApiKeyForTeamRuntime,
augmentConfiguredConnectionEnv: vi.fn(),
};
buildProviderAwareCliEnvMock.mockResolvedValue({
env: {
ANTHROPIC_BASE_URL: 'http://localhost:1234',
ANTHROPIC_API_KEY: '',
PATH: '/usr/bin',
SHELL: '/bin/zsh',
},
connectionIssues: {},
providerArgs: [],
});
const result = await (svc as any).buildProvisioningEnv('anthropic', undefined, {
teamRuntimeAuth: {
allowAnthropicApiKeyHelper: true,
teamName: 'local-team',
authMaterialId: 'auth-local',
},
});
expect(getConfiguredAnthropicApiKeyForTeamRuntime).toHaveBeenCalledWith(
expect.objectContaining({
ANTHROPIC_BASE_URL: 'http://localhost:1234',
ANTHROPIC_API_KEY: '',
})
);
expect(result.authSource).toBe('none');
expect(result.providerArgs).toEqual([]);
});
it('prefers explicit ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
ANTHROPIC_API_KEY: 'real-key',
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
PATH: '/usr/bin',
@ -3248,6 +3336,33 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
}
});
it('uses no-background best-effort shell env for provisioning launch env', async () => {
const svc = new TeamProvisioningService();
const buildProvisioningEnv = (
svc as unknown as {
buildProvisioningEnv(): Promise<{ env: NodeJS.ProcessEnv }>;
}
).buildProvisioningEnv.bind(svc);
await buildProvisioningEnv();
const [options] = vi.mocked(resolveInteractiveShellEnvBestEffort).mock.calls.at(-1) ?? [];
expect(options).toMatchObject({
source: 'team-provisioning',
timeoutMs: 1_500,
background: false,
});
expect(options?.fallbackEnv).toBe(process.env);
expect(buildProviderAwareCliEnvMock).toHaveBeenLastCalledWith(
expect.objectContaining({
shellEnv: expect.objectContaining({
PATH: '/usr/bin',
SHELL: '/bin/zsh',
}),
})
);
});
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>

View file

@ -11,6 +11,11 @@ interface StoreState {
providerConnections: {
anthropic: {
authMode: 'auto' | 'oauth' | 'api_key';
fastModeDefault: boolean;
compatibleEndpoint: {
enabled: boolean;
baseUrl: string;
};
};
codex: {
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
@ -317,6 +322,13 @@ function createAnthropicProvider(
apiKeyConfigured: overrides?.apiKeyConfigured ?? false,
apiKeySource: overrides?.apiKeySource ?? null,
apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null,
compatibleEndpoint: overrides?.compatibleEndpoint ?? {
enabled: false,
baseUrl: '',
tokenConfigured: false,
tokenSource: null,
tokenSourceLabel: null,
},
},
};
}
@ -467,6 +479,11 @@ describe('ProviderRuntimeSettingsDialog', () => {
providerConnections: {
anthropic: {
authMode: 'auto',
fastModeDefault: false,
compatibleEndpoint: {
enabled: false,
baseUrl: '',
},
},
codex: {
preferredAuthMode: 'auto',
@ -493,6 +510,10 @@ describe('ProviderRuntimeSettingsDialog', () => {
anthropic: {
...storeState.appConfig.providerConnections.anthropic,
...(nextProviderConnections.anthropic ?? {}),
compatibleEndpoint: {
...storeState.appConfig.providerConnections.anthropic.compatibleEndpoint,
...(nextProviderConnections.anthropic?.compatibleEndpoint ?? {}),
},
},
codex: {
...storeState.appConfig.providerConnections.codex,
@ -685,6 +706,259 @@ describe('ProviderRuntimeSettingsDialog', () => {
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
});
it('enables and saves an Anthropic-compatible endpoint with encrypted token storage', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [createAnthropicProvider()],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Local / compatible endpoint');
const baseUrlInput = host.querySelector(
'#anthropic-compatible-base-url'
) as HTMLInputElement | null;
const tokenInput = host.querySelector(
'#anthropic-compatible-auth-token'
) as HTMLInputElement | null;
expect(baseUrlInput).not.toBeNull();
expect(tokenInput).not.toBeNull();
await act(async () => {
setInputValue(baseUrlInput!, 'http://localhost:1234');
setInputValue(tokenInput!, 'lmstudio');
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Save endpoint').click();
await Promise.resolve();
});
expect(storeState.saveApiKey).toHaveBeenCalledWith({
id: undefined,
name: 'Anthropic-compatible Auth Token',
envVarName: 'ANTHROPIC_AUTH_TOKEN',
value: 'lmstudio',
scope: 'user',
});
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234',
},
},
});
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
expect(tokenInput!.value).toBe('');
});
it('saves an Anthropic-compatible endpoint without a token and shows a warning status', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [createAnthropicProvider()],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
const baseUrlInput = host.querySelector(
'#anthropic-compatible-base-url'
) as HTMLInputElement | null;
expect(baseUrlInput).not.toBeNull();
await act(async () => {
setInputValue(baseUrlInput!, 'http://127.0.0.1:1234/v1');
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Save endpoint').click();
await Promise.resolve();
});
expect(storeState.saveApiKey).not.toHaveBeenCalled();
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://127.0.0.1:1234/v1',
},
},
});
expect(host.textContent).toContain('Endpoint saved. Auth token is not configured.');
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
});
it('rejects Anthropic-compatible endpoint URLs with embedded credentials', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [createAnthropicProvider()],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
const baseUrlInput = host.querySelector(
'#anthropic-compatible-base-url'
) as HTMLInputElement | null;
expect(baseUrlInput).not.toBeNull();
await act(async () => {
setInputValue(baseUrlInput!, 'http://token@localhost:1234');
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Save endpoint').click();
await Promise.resolve();
});
expect(host.textContent).toContain('Base URL must not include credentials');
expect(storeState.saveApiKey).not.toHaveBeenCalled();
expect(storeState.updateConfig).not.toHaveBeenCalled();
expect(onRefreshProvider).not.toHaveBeenCalled();
});
it('rejects first-party Anthropic API hosts for compatible endpoint mode', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [createAnthropicProvider()],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
const baseUrlInput = host.querySelector(
'#anthropic-compatible-base-url'
) as HTMLInputElement | null;
expect(baseUrlInput).not.toBeNull();
await act(async () => {
setInputValue(baseUrlInput!, 'HTTPS://API.ANTHROPIC.COM/v1');
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Save endpoint').click();
await Promise.resolve();
});
expect(host.textContent).toContain('Use Auto, Subscription, or API key');
expect(storeState.saveApiKey).not.toHaveBeenCalled();
expect(storeState.updateConfig).not.toHaveBeenCalled();
expect(onRefreshProvider).not.toHaveBeenCalled();
});
it('disables Anthropic-compatible endpoint without deleting its saved token', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
storeState.appConfig.providerConnections.anthropic.compatibleEndpoint = {
enabled: true,
baseUrl: 'http://localhost:1234',
};
storeState.apiKeys = [
{
id: 'local-token',
envVarName: 'ANTHROPIC_AUTH_TOKEN',
scope: 'user',
name: 'Anthropic-compatible Auth Token',
maskedValue: 'lm...io',
},
];
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
tokenSource: 'stored',
tokenSourceLabel: 'Stored in app',
},
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('lm...io');
await act(async () => {
findButtonByText(host, 'Disable').click();
await Promise.resolve();
});
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
anthropic: {
compatibleEndpoint: {
enabled: false,
baseUrl: 'http://localhost:1234',
},
},
});
expect(storeState.deleteApiKey).not.toHaveBeenCalled();
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
});
it('shows native-only Codex connection copy and API-key management without login actions', async () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -1,18 +1,17 @@
import { describe, expect, it } from 'vitest';
import {
computeEffectiveTeamModel,
formatTeamModelSummary,
} from '@renderer/components/team/dialogs/TeamModelSelector';
import {
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_2_CODEX_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
getAvailableTeamProviderModels,
getTeamModelSelectionError,
getTeamModelUiDisabledReason,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_2_CODEX_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
normalizeTeamModelForUi,
} from '@renderer/utils/teamModelAvailability';
import { describe, expect, it } from 'vitest';
describe('formatTeamModelSummary', () => {
it('shows cross-provider Anthropic models as backend-routed instead of brand-mismatched', () => {
@ -249,6 +248,57 @@ describe('computeEffectiveTeamModel', () => {
);
});
it('does not append [1m] to non-Claude Anthropic-compatible local model ids', () => {
expect(computeEffectiveTeamModel('openai/gpt-oss-20b', false, 'anthropic')).toBe(
'openai/gpt-oss-20b'
);
expect(computeEffectiveTeamModel('qwen/qwen3-coder', false, 'anthropic')).toBe(
'qwen/qwen3-coder'
);
});
it('uses Anthropic-compatible catalog defaults as raw launch ids', () => {
const providerStatus = {
providerId: 'anthropic' as const,
modelCatalog: {
schemaVersion: 1 as const,
providerId: 'anthropic' as const,
source: 'anthropic-compatible-api' as const,
status: 'ready' as const,
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: 'openai/gpt-oss-20b',
defaultLaunchModel: 'openai/gpt-oss-20b',
models: [
{
id: 'openai/gpt-oss-20b',
launchModel: 'openai/gpt-oss-20b',
displayName: 'GPT OSS 20B',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text' as const],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'anthropic-compatible-api' as const,
},
],
diagnostics: {
configReadState: 'ready' as const,
appServerState: 'healthy' as const,
},
},
};
expect(computeEffectiveTeamModel('', false, 'anthropic', providerStatus)).toBe(
'openai/gpt-oss-20b'
);
expect(computeEffectiveTeamModel('', true, 'anthropic', providerStatus)).toBe(
'openai/gpt-oss-20b'
);
});
it('returns non-anthropic models as-is', () => {
expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4');
expect(computeEffectiveTeamModel('custom-model[1m]', false, 'codex')).toBe('custom-model[1m]');

View file

@ -313,6 +313,209 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('renders Anthropic-compatible catalog models instead of Claude fallback aliases', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const onValueChange = vi.fn();
storeState.cliStatus = {
providers: [
{
providerId: 'anthropic',
models: [],
authMethod: 'auth_token',
authenticated: true,
supported: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['auto', 'oauth', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: false,
apiKeySource: null,
apiKeySourceLabel: null,
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
tokenSource: 'stored',
tokenSourceLabel: 'Stored in app',
},
},
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-compatible-api',
status: 'ready',
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: 'openai/gpt-oss-20b',
defaultLaunchModel: 'openai/gpt-oss-20b',
models: [
{
id: 'openai/gpt-oss-20b',
launchModel: 'openai/gpt-oss-20b',
displayName: 'GPT OSS 20B',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'anthropic-compatible-api',
badgeLabel: 'Local',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange: () => undefined,
value: '',
onValueChange,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('GPT OSS 20B');
expect(host.textContent).not.toContain('Opus 4.7');
expect(
host.querySelector('[data-testid="team-model-selector-anthropic-compatible-custom-model"]')
).toBeNull();
const defaultModelButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Default')
);
expect(defaultModelButton?.getAttribute('title')).toContain(
'Anthropic-compatible endpoint default model'
);
expect(defaultModelButton?.getAttribute('title')).toContain('openai/gpt-oss-20b');
const localModelButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('GPT OSS 20B')
);
expect(localModelButton).toBeDefined();
await act(async () => {
localModelButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onValueChange).toHaveBeenCalledWith('openai/gpt-oss-20b');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders Anthropic-compatible custom model input for degraded catalogs', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const onValueChange = vi.fn();
storeState.cliStatus = {
providers: [
{
providerId: 'anthropic',
models: [],
authMethod: 'auth_token',
authenticated: true,
supported: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['auto', 'oauth', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: false,
apiKeySource: null,
apiKeySourceLabel: null,
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
tokenSource: 'stored',
tokenSourceLabel: 'Stored in app',
},
},
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-compatible-api',
status: 'degraded',
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: null,
defaultLaunchModel: null,
models: [],
diagnostics: {
configReadState: 'failed',
appServerState: 'degraded',
message: 'Local catalog unavailable',
},
},
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange: () => undefined,
value: 'openai/gpt-oss-20b',
onValueChange,
})
);
await Promise.resolve();
});
const customInput = host.querySelector<HTMLInputElement>(
'[data-testid="team-model-selector-anthropic-compatible-custom-model"]'
);
expect(customInput).toBeTruthy();
expect(customInput?.value).toBe('openai/gpt-oss-20b');
expect(host.textContent).toContain('Local catalog unavailable');
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set;
setValue?.call(customInput, 'qwen/qwen3-coder');
customInput?.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
expect(onValueChange).toHaveBeenCalledWith('qwen/qwen3-coder');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('labels, sorts, and filters OpenCode models with real Agent Teams E2E recommendations', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {

View file

@ -51,6 +51,38 @@ function createOpenCodeProviderStatus(
};
}
function createAnthropicCompatibleProviderStatus(
overrides: Partial<TeamModelRuntimeProviderStatus> = {}
): TeamModelRuntimeProviderStatus {
return {
providerId: 'anthropic',
models: [],
authMethod: 'api_key',
backend: null,
authenticated: true,
supported: true,
modelVerificationState: 'idle',
modelAvailability: [],
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['auto', 'oauth', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: false,
apiKeySource: null,
apiKeySourceLabel: null,
compatibleEndpoint: {
enabled: true,
baseUrl: 'http://localhost:1234',
tokenConfigured: true,
tokenSource: 'stored',
tokenSourceLabel: 'Stored in app',
},
},
...overrides,
};
}
describe('teamModelAvailability', () => {
it('uses runtime-reported Codex models as the source of truth', () => {
const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']);
@ -561,4 +593,217 @@ describe('teamModelAvailability', () => {
expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-7')).toBeNull();
expect(getTeamModelSelectionError('anthropic', 'claude-haiku-4-5-20251001')).toBeNull();
});
it('uses Anthropic-compatible runtime catalog models instead of curated Claude aliases', () => {
const providerStatus = createAnthropicCompatibleProviderStatus({
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-compatible-api',
status: 'ready',
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: 'openai/gpt-oss-20b',
defaultLaunchModel: 'openai/gpt-oss-20b',
models: [
{
id: 'openai/gpt-oss-20b',
launchModel: 'openai/gpt-oss-20b',
displayName: 'GPT OSS 20B',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'anthropic-compatible-api',
badgeLabel: 'Local',
},
{
id: 'hidden-local',
launchModel: 'hidden-local',
displayName: 'Hidden',
hidden: true,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: false,
upgrade: false,
source: 'anthropic-compatible-api',
badgeLabel: null,
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
});
expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([
'openai/gpt-oss-20b',
]);
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
{ value: '', label: 'Default', badgeLabel: 'Default' },
{
value: 'openai/gpt-oss-20b',
label: 'GPT OSS 20B',
badgeLabel: 'Local',
availabilityStatus: 'available',
availabilityReason: null,
},
]);
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe(
'openai/gpt-oss-20b'
);
expect(normalizeTeamModelForUi('anthropic', 'opus', providerStatus)).toBe('');
});
it('keeps custom Anthropic-compatible model ids selectable when the catalog is degraded', () => {
const providerStatus = createAnthropicCompatibleProviderStatus({
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-compatible-api',
status: 'degraded',
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: null,
defaultLaunchModel: null,
models: [],
diagnostics: {
configReadState: 'failed',
appServerState: 'degraded',
message: 'Local catalog unavailable',
},
},
});
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe(
'openai/gpt-oss-20b'
);
expect(
getTeamModelSelectionError('anthropic', 'openai/gpt-oss-20b', providerStatus)
).toBeNull();
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
{ value: '', label: 'Default', badgeLabel: 'Default' },
]);
});
it('allows custom Anthropic-compatible model ids before a runtime catalog is available', () => {
const providerStatus = createAnthropicCompatibleProviderStatus({
modelCatalog: null,
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'anthropic-compatible-api',
},
reasoningEffort: {
supported: false,
values: [],
configPassthrough: true,
},
},
});
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
{ value: '', label: 'Default', badgeLabel: 'Default' },
]);
expect(normalizeTeamModelForUi('anthropic', 'qwen/qwen3-coder', providerStatus)).toBe(
'qwen/qwen3-coder'
);
expect(getTeamModelSelectionError('anthropic', 'qwen/qwen3-coder', providerStatus)).toBeNull();
});
it('keeps stale Anthropic-compatible catalog models visible while allowing custom ids', () => {
const providerStatus = createAnthropicCompatibleProviderStatus({
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-compatible-api',
status: 'stale',
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: 'local-default',
defaultLaunchModel: 'local-default',
models: [
{
id: 'local-default',
launchModel: 'local-default',
displayName: 'Local Default',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'anthropic-compatible-api',
badgeLabel: 'Stale',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'degraded',
message: 'Using stale local catalog',
},
},
});
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
{ value: '', label: 'Default', badgeLabel: 'Default' },
{
value: 'local-default',
label: 'Local Default',
badgeLabel: 'Stale',
availabilityStatus: 'available',
availabilityReason: null,
},
]);
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe(
'openai/gpt-oss-20b'
);
});
it('rejects custom Anthropic-compatible ids when a ready compatible catalog has visible models', () => {
const providerStatus = createAnthropicCompatibleProviderStatus({
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-compatible-api',
status: 'ready',
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: 'local-default',
defaultLaunchModel: 'local-default',
models: [
{
id: 'local-default',
launchModel: 'local-default',
displayName: 'Local Default',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'anthropic-compatible-api',
badgeLabel: 'Local',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
});
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe('');
expect(
getTeamModelSelectionError('anthropic', 'openai/gpt-oss-20b', providerStatus)
).toContain('not available');
});
});

View file

@ -99,6 +99,25 @@ describe('resolveAnthropicLaunchModel', () => {
).toBe('qwen3.6');
});
it('uses Anthropic-compatible runtime defaults without manufacturing 1M variants', () => {
expect(
resolveAnthropicLaunchModel({
selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION,
limitContext: false,
defaultLaunchModel: 'openai/gpt-oss-20b',
availableLaunchModels: ['openai/gpt-oss-20b'],
})
).toBe('openai/gpt-oss-20b');
expect(
resolveAnthropicLaunchModel({
selectedModel: '',
limitContext: true,
defaultLaunchModel: 'qwen/qwen3-coder',
availableLaunchModels: ['qwen/qwen3-coder'],
})
).toBe('qwen/qwen3-coder');
});
it('honors explicit 1M Sonnet selections unless 200K context is requested', () => {
expect(
resolveAnthropicLaunchModel({