diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index f8e579af..9f3327f0 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -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 { diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index aa18640b..8c6a03a2 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -51,6 +51,7 @@ const VALID_SECTIONS = new Set([ '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 { 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 = {}; 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, diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 9e0d1b04..a1d8972d 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -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; + return { + enabled: typeof raw.enabled === 'boolean' ? raw.enabled : fallback.enabled, + baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : fallback.baseUrl, + }; +} + function shouldPersistNormalizedConfig(loaded: Partial, 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, diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index c462d184..a01eef47 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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>> { - 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[string] | undefined @@ -966,14 +979,14 @@ export class ClaudeMultimodelBridgeService { providerId: CliProviderId, env: NodeJS.ProcessEnv, connectionIssues: Partial>, - options: { summary?: boolean } = {} + options: { summary?: boolean; timeoutMs?: number } = {} ): Promise { 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 { 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 { const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS; const providers = new Map( @@ -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 { - 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 { - 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); diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts index d4f5b9d8..b9c8b520 100644 --- a/src/main/services/runtime/CliProviderModelAvailabilityService.ts +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -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 ?? [], diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index bf38e904..1a89b335 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -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> = { 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 { + 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 { + 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 + > { + 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 { 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 { 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 { 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 { 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 { 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; } diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index 26b2eee4..cedafaaa 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -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 { 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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 48f46481..29a41b5b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { - 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(); diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 2eebc58e..22f145de 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -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(null); + const [compatibleBaseUrl, setCompatibleBaseUrl] = useState(''); + const [compatibleTokenValue, setCompatibleTokenValue] = useState(''); + const [compatibleEndpointError, setCompatibleEndpointError] = useState(null); + const [compatibleEndpointStatus, setCompatibleEndpointStatus] = useState(null); const apiKeyInputRef = useRef(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 => { + 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 => { + 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 => { setConnectionError(null); try { @@ -1363,6 +1573,171 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null} + {selectedProvider.providerId === 'anthropic' ? ( +
+
+
+
+ Local / compatible endpoint +
+
+ Use an Anthropic-compatible local runtime endpoint. +
+
+ + {anthropicCompatibleEndpointEnabled ? 'Enabled' : 'Off'} + +
+ +
+
+ + { + setCompatibleBaseUrl(event.currentTarget.value); + setCompatibleEndpointError(null); + setCompatibleEndpointStatus(null); + }} + placeholder="http://localhost:1234" + className="h-9 text-sm" + disabled={connectionBusy} + /> +
+ +
+ + { + 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} + /> +
+
+ +
+ + Token {anthropicCompatibleTokenConfigured ? 'configured' : 'not set'} + + {anthropicCompatibleTokenStatus ? ( + + {anthropicCompatibleTokenStatus} + + ) : null} + {anthropicCompatibleEndpointEnabled && + anthropicCompatibleEndpoint?.baseUrl ? ( + + {anthropicCompatibleEndpoint.baseUrl} + + ) : null} +
+ + {compatibleEndpointError ? ( +
+ + {compatibleEndpointError} +
+ ) : compatibleEndpointStatus ? ( +
+ {compatibleEndpointStatus} +
+ ) : anthropicCompatibleMissingToken ? ( +
+ + Auth token is not configured. +
+ ) : null} + +
+ {anthropicCompatibleEndpointEnabled ? ( + + ) : null} + +
+
+ ) : null} +
{configuredAuthMode && !hideConnectionMethodMeta ? ( = ({ ); 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 = ({ } 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(); @@ -1620,6 +1649,30 @@ export const TeamModelSelector: React.FC = ({ list is syncing.

) : null} + {showAnthropicCompatibleCustomModelInput ? ( +
+ + onValueChange(event.currentTarget.value.trim())} + placeholder="openai/gpt-oss-20b" + className="h-8 text-xs" + disabled={isInspectingInactiveProvider || !activeProviderSelectable} + /> + {anthropicCompatibleCatalogWarning ? ( +

+ {anthropicCompatibleCatalogWarning} +

+ ) : null} +
+ ) : null} {shouldShowModelSearch ? (
diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 2dabc4b7..68d5af04 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -53,6 +53,7 @@ export type TeamModelRuntimeProviderStatus = Pick< | 'detailMessage' | 'availableBackends' | 'externalRuntimeDiagnostics' + | 'connection' > & Partial>; @@ -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 : ''; } diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 80735c30..994048f9 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -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; diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 2ff31565..c9715fb2 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -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'; diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index c8eb6eed..f7caca21 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -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'; diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index bb3b0c95..45d9a7cd 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -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: { diff --git a/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts b/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts index cec72155..16070b20 100644 --- a/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts +++ b/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts @@ -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; + }; + }; + }; + + 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; + }; + }; + }; + + expect(persisted.providerConnections.anthropic.compatibleEndpoint).toEqual({ + enabled: true, + baseUrl: 'http://localhost:1234', + }); + }); + }); }); diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 29d69644..b6e2fef5 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -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 () => { diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 5caa893c..2edded8e 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -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'); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 7a196661..045b6bea 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -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'); diff --git a/test/main/services/team/TeamProvisioningServiceCodexPreflight.test.ts b/test/main/services/team/TeamProvisioningServiceCodexPreflight.test.ts index 289e4a64..8fc59037 100644 --- a/test/main/services/team/TeamProvisioningServiceCodexPreflight.test.ts +++ b/test/main/services/team/TeamProvisioningServiceCodexPreflight.test.ts @@ -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', }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index c2e291bb..de377a28 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -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 }) => diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index 0e35785e..faa397a1 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -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); diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 2ff640d0..9b216efa 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -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]'); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 5ae544a9..9df6bf60 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -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( + '[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 = { diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index f3f6c418..04316b4b 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -51,6 +51,38 @@ function createOpenCodeProviderStatus( }; } +function createAnthropicCompatibleProviderStatus( + overrides: Partial = {} +): 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'); + }); }); diff --git a/test/shared/utils/anthropicLaunchModel.test.ts b/test/shared/utils/anthropicLaunchModel.test.ts index 3c72fbc1..179028e1 100644 --- a/test/shared/utils/anthropicLaunchModel.test.ts +++ b/test/shared/utils/anthropicLaunchModel.test.ts @@ -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({