feat(runtime): support anthropic compatible endpoints
This commit is contained in:
parent
3d1b329221
commit
3c427ac617
26 changed files with 2652 additions and 57 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
|
|||
'ssh',
|
||||
]);
|
||||
const MAX_SNOOZE_MINUTES = 24 * 60;
|
||||
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
|
@ -64,6 +65,30 @@ function isFiniteNumber(value: unknown): value is number {
|
|||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function validateAnthropicCompatibleBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must use http:// or https://';
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must not include credentials';
|
||||
}
|
||||
if (FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)) {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must not be a first-party Anthropic API host';
|
||||
}
|
||||
} catch {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must be a valid URL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidTrigger(trigger: unknown): trigger is NotificationTrigger {
|
||||
if (!isPlainObject(trigger)) {
|
||||
return false;
|
||||
|
|
@ -496,7 +521,11 @@ function validateProviderConnectionsSection(
|
|||
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
|
||||
|
||||
for (const [connectionKey, connectionValue] of Object.entries(value)) {
|
||||
if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') {
|
||||
if (
|
||||
connectionKey !== 'authMode' &&
|
||||
connectionKey !== 'fastModeDefault' &&
|
||||
connectionKey !== 'compatibleEndpoint'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
|
||||
|
|
@ -519,6 +548,64 @@ function validateProviderConnectionsSection(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (connectionKey === 'compatibleEndpoint') {
|
||||
if (!isPlainObject(connectionValue)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.anthropic.compatibleEndpoint must be an object',
|
||||
};
|
||||
}
|
||||
|
||||
const compatibleEndpoint: Partial<
|
||||
ProviderConnectionsConfig['anthropic']['compatibleEndpoint']
|
||||
> = {};
|
||||
for (const [endpointKey, endpointValue] of Object.entries(connectionValue)) {
|
||||
if (endpointKey !== 'enabled' && endpointKey !== 'baseUrl') {
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.anthropic.compatibleEndpoint.${endpointKey} is not a valid setting`,
|
||||
};
|
||||
}
|
||||
|
||||
if (endpointKey === 'enabled') {
|
||||
if (typeof endpointValue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'providerConnections.anthropic.compatibleEndpoint.enabled must be a boolean',
|
||||
};
|
||||
}
|
||||
compatibleEndpoint.enabled = endpointValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof endpointValue !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.anthropic.compatibleEndpoint.baseUrl must be a string',
|
||||
};
|
||||
}
|
||||
|
||||
const error = validateAnthropicCompatibleBaseUrl(endpointValue);
|
||||
if (error) {
|
||||
return { valid: false, error };
|
||||
}
|
||||
compatibleEndpoint.baseUrl = endpointValue.trim();
|
||||
}
|
||||
|
||||
if (compatibleEndpoint.enabled === true && !compatibleEndpoint.baseUrl?.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'providerConnections.anthropic.compatibleEndpoint.baseUrl is required when enabled',
|
||||
};
|
||||
}
|
||||
|
||||
anthropicUpdate.compatibleEndpoint =
|
||||
compatibleEndpoint as ProviderConnectionsConfig['anthropic']['compatibleEndpoint'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof connectionValue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
|
|||
|
|
@ -275,10 +275,16 @@ export interface RuntimeConfig {
|
|||
|
||||
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
|
||||
|
||||
export interface AnthropicCompatibleEndpointConfig {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface ProviderConnectionsConfig {
|
||||
anthropic: {
|
||||
authMode: ProviderConnectionAuthMode;
|
||||
fastModeDefault: boolean;
|
||||
compatibleEndpoint: AnthropicCompatibleEndpointConfig;
|
||||
};
|
||||
codex: {
|
||||
preferredAuthMode: CodexAccountAuthMode;
|
||||
|
|
@ -376,6 +382,10 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
anthropic: {
|
||||
authMode: 'auto',
|
||||
fastModeDefault: false,
|
||||
compatibleEndpoint: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
|
|
@ -457,6 +467,22 @@ function normalizeCodexPreferredAuthMode(
|
|||
return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode;
|
||||
}
|
||||
|
||||
function normalizeAnthropicCompatibleEndpointConfig(
|
||||
value: unknown,
|
||||
fallback: AnthropicCompatibleEndpointConfig = DEFAULT_CONFIG.providerConnections.anthropic
|
||||
.compatibleEndpoint
|
||||
): AnthropicCompatibleEndpointConfig {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ...fallback };
|
||||
}
|
||||
|
||||
const raw = value as Partial<AnthropicCompatibleEndpointConfig>;
|
||||
return {
|
||||
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : fallback.enabled,
|
||||
baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : fallback.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldPersistNormalizedConfig(loaded: Partial<AppConfig>, normalized: AppConfig): boolean {
|
||||
return JSON.stringify(loaded) !== JSON.stringify(normalized);
|
||||
}
|
||||
|
|
@ -634,6 +660,9 @@ export class ConfigManager {
|
|||
anthropic: {
|
||||
...DEFAULT_CONFIG.providerConnections.anthropic,
|
||||
...(loaded.providerConnections?.anthropic ?? {}),
|
||||
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
|
||||
loaded.providerConnections?.anthropic?.compatibleEndpoint
|
||||
),
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: normalizeCodexPreferredAuthMode(
|
||||
|
|
@ -750,6 +779,10 @@ export class ConfigManager {
|
|||
anthropic: {
|
||||
...this.config.providerConnections.anthropic,
|
||||
...(connectionUpdate.anthropic ?? {}),
|
||||
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
|
||||
connectionUpdate.anthropic?.compatibleEndpoint,
|
||||
this.config.providerConnections.anthropic.compatibleEndpoint
|
||||
),
|
||||
},
|
||||
codex: {
|
||||
...this.config.providerConnections.codex,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type {
|
|||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
|
||||
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000;
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
|
||||
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
|
|
@ -796,7 +797,11 @@ export class ClaudeMultimodelBridgeService {
|
|||
private async buildCliEnv(
|
||||
binaryPath: string
|
||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||
return buildProviderAwareCliEnv({ binaryPath, allowStoredApiKeyDecryption: false });
|
||||
return buildProviderAwareCliEnv({
|
||||
binaryPath,
|
||||
allowStoredApiKeyDecryption: false,
|
||||
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
|
||||
});
|
||||
}
|
||||
|
||||
private async buildProviderCliEnv(
|
||||
|
|
@ -807,6 +812,8 @@ export class ClaudeMultimodelBridgeService {
|
|||
binaryPath,
|
||||
providerId,
|
||||
allowStoredApiKeyDecryption: false,
|
||||
allowedStoredApiKeyEnvVarNames:
|
||||
providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -827,6 +834,12 @@ export class ClaudeMultimodelBridgeService {
|
|||
return this.isRuntimeStatusCompatibilityError(error) || lower.includes('runtime status');
|
||||
}
|
||||
|
||||
private isRuntimeStatusTimeoutError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
return lower.includes('timed out') || lower.includes('timeout');
|
||||
}
|
||||
|
||||
private mapRuntimeProviderStatus(
|
||||
providerId: CliProviderId,
|
||||
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
||||
|
|
@ -966,14 +979,14 @@ export class ClaudeMultimodelBridgeService {
|
|||
providerId: CliProviderId,
|
||||
env: NodeJS.ProcessEnv,
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>,
|
||||
options: { summary?: boolean } = {}
|
||||
options: { summary?: boolean; timeoutMs?: number } = {}
|
||||
): Promise<CliProviderStatus> {
|
||||
const args = ['runtime', 'status', '--json', '--provider', providerId];
|
||||
if (options.summary) {
|
||||
args.push('--summary');
|
||||
}
|
||||
const { stdout } = await execCli(binaryPath, args, {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
|
||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||
env,
|
||||
});
|
||||
|
|
@ -990,7 +1003,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
private async getProviderStatusFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId,
|
||||
options: { summary?: boolean } = {}
|
||||
options: { summary?: boolean; timeoutMs?: number } = {}
|
||||
): Promise<CliProviderStatus> {
|
||||
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||
return this.getProviderStatusFromRuntimeStatusCommand(
|
||||
|
|
@ -1005,7 +1018,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
private async getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void,
|
||||
options: { summary?: boolean; providerIds?: readonly CliProviderId[] } = {}
|
||||
options: { summary?: boolean; timeoutMs?: number; providerIds?: readonly CliProviderId[] } = {}
|
||||
): Promise<CliProviderStatus[] | null> {
|
||||
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
|
|
@ -1032,6 +1045,19 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
|
||||
if (failures.length === providerIds.length) {
|
||||
if (failures.every(({ error }) => this.isRuntimeStatusTimeoutError(error))) {
|
||||
logger.warn(
|
||||
`Provider-scoped runtime status timed out for ${failures
|
||||
.map(({ providerId }) => providerId)
|
||||
.join(', ')}; using error provider statuses without slower fallback probes`
|
||||
);
|
||||
for (const { providerId, error } of failures) {
|
||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||
}
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1202,7 +1228,11 @@ export class ClaudeMultimodelBridgeService {
|
|||
providerId: CliProviderId,
|
||||
onCatalogUpdate?: (provider: CliProviderStatus) => void
|
||||
): Promise<CliProviderStatus> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const generation = this.beginProviderStatusHydration([providerId]);
|
||||
|
|
@ -1418,14 +1448,22 @@ export class ClaudeMultimodelBridgeService {
|
|||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[]> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const generation = this.beginProviderStatusHydration(DEFAULT_PROVIDER_STATUS_IDS);
|
||||
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath,
|
||||
onUpdate,
|
||||
{ summary: true, providerIds: DEFAULT_PROVIDER_STATUS_IDS }
|
||||
{
|
||||
summary: true,
|
||||
timeoutMs: PROVIDER_STATUS_SUMMARY_TIMEOUT_MS,
|
||||
providerIds: DEFAULT_PROVIDER_STATUS_IDS,
|
||||
}
|
||||
);
|
||||
if (providers) {
|
||||
this.hydrateProviderCatalogs(binaryPath, providers, generation, onUpdate);
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type { AnthropicCompatibleEndpointConfig } from '../infrastructure/ConfigManager';
|
||||
import type {
|
||||
CodexAccountAuthMode,
|
||||
CodexAccountSnapshotDto,
|
||||
|
|
@ -37,6 +38,7 @@ type ExternalCredential = {
|
|||
|
||||
interface StoredApiKeyAccessOptions {
|
||||
allowStoredApiKeyDecryption?: boolean;
|
||||
allowedStoredApiKeyEnvVarNames?: readonly string[];
|
||||
}
|
||||
|
||||
const PROVIDER_CAPABILITIES: Record<
|
||||
|
|
@ -71,6 +73,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
|||
gemini: 'GEMINI_API_KEY',
|
||||
};
|
||||
|
||||
const ANTHROPIC_BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
|
||||
const ANTHROPIC_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
|
||||
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
||||
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
||||
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
||||
|
|
@ -80,6 +84,7 @@ const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000;
|
|||
const ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS = 10_000;
|
||||
const ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS = 60_000;
|
||||
const ANTHROPIC_DEFAULT_API_BASE_URL = 'https://api.anthropic.com';
|
||||
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
|
||||
|
||||
type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown';
|
||||
|
||||
|
|
@ -161,10 +166,15 @@ function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
|
|||
}
|
||||
|
||||
try {
|
||||
const host = new URL(trimmed).host;
|
||||
return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com';
|
||||
const url = new URL(trimmed);
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +186,24 @@ function hasAnthropicCompatibleAuthEnv(env: NodeJS.ProcessEnv): boolean {
|
|||
return Boolean(env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim());
|
||||
}
|
||||
|
||||
function isUsableAnthropicCompatibleEndpoint(
|
||||
endpoint: AnthropicCompatibleEndpointConfig | undefined
|
||||
): endpoint is AnthropicCompatibleEndpointConfig {
|
||||
if (endpoint?.enabled !== true || !endpoint.baseUrl.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(endpoint.baseUrl.trim());
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
isAnthropicCompatibleBaseUrl(endpoint.baseUrl)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAnthropicApiKeyWithApi(
|
||||
apiKey: string,
|
||||
baseUrl?: string | null
|
||||
|
|
@ -393,12 +421,122 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
private getConfiguredAnthropicCompatibleEndpoint(): AnthropicCompatibleEndpointConfig | null {
|
||||
const endpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
return isUsableAnthropicCompatibleEndpoint(endpoint)
|
||||
? { enabled: true, baseUrl: endpoint.baseUrl.trim() }
|
||||
: null;
|
||||
}
|
||||
|
||||
private getConfiguredAnthropicCompatibleEndpointIssue(): string | null {
|
||||
const endpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
if (endpoint?.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = endpoint.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
return 'Anthropic-compatible endpoint is enabled, but no base URL is configured.';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'Anthropic-compatible endpoint base URL must use http:// or https://.';
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
return 'Anthropic-compatible endpoint base URL must not include credentials.';
|
||||
}
|
||||
|
||||
if (!isAnthropicCompatibleBaseUrl(baseUrl)) {
|
||||
return 'Anthropic-compatible endpoint cannot use the first-party Anthropic API host.';
|
||||
}
|
||||
} catch {
|
||||
return 'Anthropic-compatible endpoint base URL is invalid.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getConfiguredAnthropicCompatibleToken(
|
||||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<ExternalCredential> {
|
||||
const storedToken = await this.lookupStoredApiKeyValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR, options);
|
||||
if (storedToken?.value.trim()) {
|
||||
return {
|
||||
label: 'Stored in app',
|
||||
value: storedToken.value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const envToken = this.getExternalEnvValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
|
||||
return envToken
|
||||
? {
|
||||
label: `Detected from ${ANTHROPIC_AUTH_TOKEN_ENV_VAR}`,
|
||||
value: envToken,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
private async applyConfiguredAnthropicCompatibleEndpointEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<boolean> {
|
||||
const endpoint = this.getConfiguredAnthropicCompatibleEndpoint();
|
||||
if (!endpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
env[ANTHROPIC_BASE_URL_ENV_VAR] = endpoint.baseUrl;
|
||||
const token = await this.getConfiguredAnthropicCompatibleToken(options);
|
||||
if (token?.value.trim()) {
|
||||
env[ANTHROPIC_AUTH_TOKEN_ENV_VAR] = token.value.trim();
|
||||
}
|
||||
|
||||
if (typeof env.ANTHROPIC_API_KEY !== 'string' || !env.ANTHROPIC_API_KEY.trim()) {
|
||||
env.ANTHROPIC_API_KEY = '';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getAnthropicCompatibleEndpointConnectionInfo(): Promise<
|
||||
NonNullable<CliProviderConnectionInfo['compatibleEndpoint']>
|
||||
> {
|
||||
const endpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
const hasStoredToken = await this.hasStoredApiKey(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
|
||||
const envToken = this.getExternalEnvValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
|
||||
const tokenSource = hasStoredToken ? 'stored' : envToken ? 'environment' : null;
|
||||
|
||||
return {
|
||||
enabled: endpoint.enabled,
|
||||
baseUrl: endpoint.baseUrl,
|
||||
tokenConfigured: Boolean(tokenSource),
|
||||
tokenSource,
|
||||
tokenSourceLabel:
|
||||
tokenSource === 'stored'
|
||||
? 'Stored in app'
|
||||
: tokenSource === 'environment'
|
||||
? `Detected from ${ANTHROPIC_AUTH_TOKEN_ENV_VAR}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getConfiguredAnthropicApiKeyForTeamRuntime(env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
if (this.getConfiguredAuthMode('anthropic') !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasAnthropicCompatibleAuthEnv(env)) {
|
||||
const configuredEndpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
if (
|
||||
configuredEndpoint?.enabled === true ||
|
||||
isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -418,6 +556,10 @@ export class ProviderConnectionService {
|
|||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
if (hasAnthropicCompatibleAuthEnv(env)) {
|
||||
return env;
|
||||
}
|
||||
|
|
@ -516,6 +658,10 @@ export class ProviderConnectionService {
|
|||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
return env;
|
||||
}
|
||||
|
|
@ -588,6 +734,15 @@ export class ProviderConnectionService {
|
|||
runtimeBackendOverride?: string | null
|
||||
): Promise<string | null> {
|
||||
if (providerId === 'anthropic') {
|
||||
const compatibleEndpointIssue = this.getConfiguredAnthropicCompatibleEndpointIssue();
|
||||
if (compatibleEndpointIssue) {
|
||||
return compatibleEndpointIssue;
|
||||
}
|
||||
|
||||
if (this.getConfiguredAnthropicCompatibleEndpoint()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -829,6 +984,18 @@ export class ProviderConnectionService {
|
|||
provider: CliProviderStatus
|
||||
): Promise<CliProviderStatus> {
|
||||
const connection = provider.connection;
|
||||
if (connection?.compatibleEndpoint?.enabled === true) {
|
||||
return {
|
||||
...provider,
|
||||
subscriptionRateLimits: null,
|
||||
statusMessage:
|
||||
provider.statusMessage ??
|
||||
(connection.compatibleEndpoint.tokenConfigured
|
||||
? 'Anthropic-compatible endpoint configured'
|
||||
: 'Anthropic-compatible endpoint configured. Auth token is not set.'),
|
||||
};
|
||||
}
|
||||
|
||||
if (connection?.configuredAuthMode !== 'api_key') {
|
||||
return provider;
|
||||
}
|
||||
|
|
@ -962,6 +1129,8 @@ export class ProviderConnectionService {
|
|||
: hasStoredApiKey
|
||||
? 'Stored in app'
|
||||
: (externalCredential?.label ?? null);
|
||||
const compatibleEndpoint =
|
||||
providerId === 'anthropic' ? await this.getAnthropicCompatibleEndpointConnectionInfo() : null;
|
||||
|
||||
return {
|
||||
...capabilities,
|
||||
|
|
@ -970,6 +1139,7 @@ export class ProviderConnectionService {
|
|||
apiKeyConfigured,
|
||||
apiKeySource,
|
||||
apiKeySourceLabel,
|
||||
compatibleEndpoint,
|
||||
codex:
|
||||
providerId === 'codex' && codexSnapshot
|
||||
? {
|
||||
|
|
@ -1017,7 +1187,9 @@ export class ProviderConnectionService {
|
|||
envVarName: string,
|
||||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<{ envVarName: string; value: string } | null> {
|
||||
if (options?.allowStoredApiKeyDecryption === false) {
|
||||
const allowedWhenMetadataOnly =
|
||||
options?.allowedStoredApiKeyEnvVarNames?.includes(envVarName) === true;
|
||||
if (options?.allowStoredApiKeyDecryption === false && !allowedWhenMetadataOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface ProviderAwareCliEnvOptions {
|
|||
env?: NodeJS.ProcessEnv;
|
||||
connectionMode?: 'strict' | 'augment';
|
||||
allowStoredApiKeyDecryption?: boolean;
|
||||
allowedStoredApiKeyEnvVarNames?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ProviderAwareCliEnvResult {
|
||||
|
|
@ -33,9 +34,15 @@ export async function buildProviderAwareCliEnv(
|
|||
): Promise<ProviderAwareCliEnvResult> {
|
||||
const connectionMode = options.connectionMode ?? 'strict';
|
||||
const storedApiKeyAccessArgs =
|
||||
options.allowStoredApiKeyDecryption === undefined
|
||||
options.allowStoredApiKeyDecryption === undefined &&
|
||||
options.allowedStoredApiKeyEnvVarNames === undefined
|
||||
? []
|
||||
: [{ allowStoredApiKeyDecryption: options.allowStoredApiKeyDecryption }];
|
||||
: [
|
||||
{
|
||||
allowStoredApiKeyDecryption: options.allowStoredApiKeyDecryption,
|
||||
allowedStoredApiKeyEnvVarNames: options.allowedStoredApiKeyEnvVarNames,
|
||||
},
|
||||
];
|
||||
const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {};
|
||||
const { env, resolvedProviderId } = buildRuntimeBaseEnv({
|
||||
binaryPath: options.binaryPath,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ import {
|
|||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||
import { isProcessAlive } from '@main/utils/processHealth';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||
import {
|
||||
listWindowsProcessTable,
|
||||
|
|
@ -1291,6 +1291,17 @@ export function buildDirectTmuxRestartEnvAssignments(
|
|||
}
|
||||
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
|
||||
assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
|
||||
if (providerId === 'anthropic') {
|
||||
if (hasAnthropicCompatibleAuthTokenEnv(env)) {
|
||||
assignments.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL?.trim() ?? '');
|
||||
assignments.set('ANTHROPIC_AUTH_TOKEN', env.ANTHROPIC_AUTH_TOKEN?.trim() ?? '');
|
||||
if (!env.ANTHROPIC_API_KEY?.trim()) {
|
||||
assignments.set('ANTHROPIC_API_KEY', '');
|
||||
}
|
||||
} else if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) {
|
||||
assignments.set('ANTHROPIC_AUTH_TOKEN', '');
|
||||
}
|
||||
}
|
||||
if (
|
||||
providerId === 'anthropic' &&
|
||||
env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER
|
||||
|
|
@ -2460,10 +2471,16 @@ function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
|
|||
}
|
||||
|
||||
try {
|
||||
const host = new URL(trimmed).host;
|
||||
return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com';
|
||||
const url = new URL(trimmed);
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
url.hostname !== 'api.anthropic.com' &&
|
||||
url.hostname !== 'api-staging.anthropic.com'
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35089,7 +35106,12 @@ export class TeamProvisioningService {
|
|||
teamRuntimeAuth?: TeamRuntimeAuthContext;
|
||||
}
|
||||
): Promise<ProvisioningEnvResolution> {
|
||||
const shellEnv = await resolveInteractiveShellEnv();
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
source: 'team-provisioning',
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||
// correctly on Windows. Prefer it over process.env which may be garbled.
|
||||
const electronHome = getHomeDir();
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { AlertTriangle, Download, Key, Link2, Loader2, Trash2 } from 'lucide-react';
|
||||
import { AlertTriangle, Download, Key, Link2, Loader2, Save, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
formatProviderAuthMethodLabelForProvider,
|
||||
|
|
@ -65,7 +65,7 @@ import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@sha
|
|||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||
|
||||
type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | null;
|
||||
type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | 'compatible' | null;
|
||||
|
||||
interface ConnectionMethodCardOption {
|
||||
readonly authMode: CliProviderAuthMode;
|
||||
|
|
@ -127,6 +127,10 @@ const API_KEY_PROVIDER_CONFIG: Record<
|
|||
},
|
||||
};
|
||||
|
||||
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
|
||||
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME = 'Anthropic-compatible Auth Token';
|
||||
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
|
||||
|
||||
function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProviderId {
|
||||
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
|
||||
}
|
||||
|
|
@ -163,6 +167,30 @@ function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): A
|
|||
return matches.find((entry) => entry.scope === 'user') ?? null;
|
||||
}
|
||||
|
||||
function validateAnthropicCompatibleBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return 'Base URL is required';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'Base URL must use http:// or https://';
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return 'Base URL must not include credentials';
|
||||
}
|
||||
if (FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)) {
|
||||
return 'Use Auto, Subscription, or API key for first-party Anthropic';
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid URL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectionDescription(provider: CliProviderStatus): string {
|
||||
switch (provider.providerId) {
|
||||
case 'anthropic':
|
||||
|
|
@ -222,6 +250,12 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
|
|||
const hasAnthropicSubscriptionSession =
|
||||
provider.authMethod === 'oauth_token' || provider.authMethod === 'claude.ai';
|
||||
|
||||
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
|
||||
return provider.connection.compatibleEndpoint.tokenConfigured
|
||||
? null
|
||||
: 'Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.';
|
||||
}
|
||||
|
||||
if (
|
||||
provider.providerId === 'anthropic' &&
|
||||
authMode === 'api_key' &&
|
||||
|
|
@ -304,6 +338,10 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
|
|||
}
|
||||
|
||||
function getProviderUsageLabel(provider: CliProviderStatus): string {
|
||||
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
|
||||
return 'Using compatible endpoint';
|
||||
}
|
||||
|
||||
if (
|
||||
provider.providerId === 'anthropic' &&
|
||||
provider.connection?.configuredAuthMode === 'api_key'
|
||||
|
|
@ -637,6 +675,10 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
const [runtimeSaving, setRuntimeSaving] = useState(false);
|
||||
const [pendingConnectionAction, setPendingConnectionAction] =
|
||||
useState<PendingConnectionAction>(null);
|
||||
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState('');
|
||||
const [compatibleTokenValue, setCompatibleTokenValue] = useState('');
|
||||
const [compatibleEndpointError, setCompatibleEndpointError] = useState<string | null>(null);
|
||||
const [compatibleEndpointStatus, setCompatibleEndpointStatus] = useState<string | null>(null);
|
||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const apiKeys = useStore((s) => s.apiKeys);
|
||||
|
|
@ -679,11 +721,17 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
setConnectionSaving(false);
|
||||
setRuntimeSaving(false);
|
||||
setPendingConnectionAction(null);
|
||||
setCompatibleBaseUrl('');
|
||||
setCompatibleTokenValue('');
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setConnectionError(null);
|
||||
setRuntimeError(null);
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
}, [selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -710,6 +758,15 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
const selectedApiKey = statusApiKeyConfig
|
||||
? findPreferredApiKeyEntry(apiKeys, statusApiKeyConfig.envVarName)
|
||||
: null;
|
||||
const anthropicCompatibleConfig = appConfig?.providerConnections?.anthropic
|
||||
.compatibleEndpoint ?? {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
};
|
||||
const selectedCompatibleToken = findPreferredApiKeyEntry(
|
||||
apiKeys,
|
||||
ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR
|
||||
);
|
||||
|
||||
const selectedProvider = useMemo(() => {
|
||||
const mergedStatusProvider =
|
||||
|
|
@ -729,6 +786,22 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
nextConnection.configuredAuthMode =
|
||||
appConfig?.providerConnections?.anthropic.authMode ??
|
||||
mergedStatusProvider.connection.configuredAuthMode;
|
||||
nextConnection.compatibleEndpoint = {
|
||||
...(mergedStatusProvider.connection.compatibleEndpoint ?? {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
tokenConfigured: false,
|
||||
tokenSource: null,
|
||||
tokenSourceLabel: null,
|
||||
}),
|
||||
enabled: anthropicCompatibleConfig.enabled,
|
||||
baseUrl: anthropicCompatibleConfig.baseUrl,
|
||||
};
|
||||
if (selectedCompatibleToken) {
|
||||
nextConnection.compatibleEndpoint.tokenConfigured = true;
|
||||
nextConnection.compatibleEndpoint.tokenSource = 'stored';
|
||||
nextConnection.compatibleEndpoint.tokenSourceLabel = 'Stored in app';
|
||||
}
|
||||
}
|
||||
|
||||
if (mergedStatusProvider.providerId === 'codex') {
|
||||
|
|
@ -754,14 +827,28 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
connection: nextConnection,
|
||||
};
|
||||
}, [
|
||||
anthropicCompatibleConfig.baseUrl,
|
||||
anthropicCompatibleConfig.enabled,
|
||||
appConfig?.providerConnections?.anthropic.authMode,
|
||||
appConfig?.providerConnections?.codex.preferredAuthMode,
|
||||
codexAccount.snapshot,
|
||||
selectedCompatibleToken,
|
||||
selectedApiKey,
|
||||
statusApiKeyConfig,
|
||||
statusSelectedProvider,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || selectedProviderId !== 'anthropic') {
|
||||
return;
|
||||
}
|
||||
|
||||
setCompatibleBaseUrl(anthropicCompatibleConfig.baseUrl);
|
||||
setCompatibleTokenValue('');
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
}, [anthropicCompatibleConfig.baseUrl, open, selectedProviderId]);
|
||||
|
||||
const selectedProviderLoading = selectedProvider
|
||||
? providerStatusLoading[selectedProvider.providerId] === true
|
||||
: false;
|
||||
|
|
@ -884,9 +971,24 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
const canRequestSubscriptionLogin =
|
||||
selectedProvider?.providerId === 'anthropic' &&
|
||||
Boolean(selectedProvider.connection?.supportsOAuth && onRequestLogin) &&
|
||||
selectedProvider.connection?.compatibleEndpoint?.enabled !== true &&
|
||||
configuredAuthMode !== 'api_key' &&
|
||||
selectedProvider.statusMessage !== 'Checking...' &&
|
||||
(!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth');
|
||||
const anthropicCompatibleEndpoint =
|
||||
selectedProvider?.providerId === 'anthropic'
|
||||
? (selectedProvider.connection?.compatibleEndpoint ?? null)
|
||||
: null;
|
||||
const anthropicCompatibleEndpointEnabled = anthropicCompatibleEndpoint?.enabled === true;
|
||||
const anthropicCompatibleTokenConfigured = Boolean(
|
||||
selectedCompatibleToken || anthropicCompatibleEndpoint?.tokenConfigured
|
||||
);
|
||||
const anthropicCompatibleTokenStatus =
|
||||
selectedCompatibleToken?.maskedValue ??
|
||||
anthropicCompatibleEndpoint?.tokenSourceLabel ??
|
||||
(anthropicCompatibleTokenConfigured ? 'Configured' : null);
|
||||
const anthropicCompatibleMissingToken =
|
||||
anthropicCompatibleEndpointEnabled && !anthropicCompatibleTokenConfigured;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showApiKeyForm) {
|
||||
|
|
@ -932,6 +1034,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
return 'Switching to Anthropic subscription...';
|
||||
case 'auto':
|
||||
return 'Switching to Auto...';
|
||||
case 'compatible':
|
||||
return 'Saving compatible endpoint...';
|
||||
default:
|
||||
return 'Applying connection changes...';
|
||||
}
|
||||
|
|
@ -1080,6 +1184,112 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleSaveAnthropicCompatibleEndpoint = async (): Promise<void> => {
|
||||
if (selectedProvider?.providerId !== 'anthropic') {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = compatibleBaseUrl.trim();
|
||||
const validationError = validateAnthropicCompatibleBaseUrl(baseUrl);
|
||||
if (validationError) {
|
||||
setCompatibleEndpointError(validationError);
|
||||
setCompatibleEndpointStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionSaving(true);
|
||||
setPendingConnectionAction('compatible');
|
||||
setConnectionError(null);
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
let updateSucceeded = false;
|
||||
|
||||
try {
|
||||
if (compatibleTokenValue.trim()) {
|
||||
await saveApiKey({
|
||||
id: selectedCompatibleToken?.id,
|
||||
name: ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME,
|
||||
envVarName: ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR,
|
||||
value: compatibleTokenValue.trim(),
|
||||
scope: 'user',
|
||||
});
|
||||
}
|
||||
|
||||
await updateConfig('providerConnections', {
|
||||
anthropic: {
|
||||
compatibleEndpoint: {
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
updateSucceeded = true;
|
||||
setCompatibleTokenValue('');
|
||||
setCompatibleEndpointStatus(
|
||||
compatibleTokenValue.trim() || anthropicCompatibleTokenConfigured
|
||||
? 'Endpoint saved'
|
||||
: 'Endpoint saved. Auth token is not configured.'
|
||||
);
|
||||
} catch (error) {
|
||||
setCompatibleEndpointError(
|
||||
error instanceof Error ? error.message : 'Failed to save endpoint'
|
||||
);
|
||||
} finally {
|
||||
if (updateSucceeded) {
|
||||
try {
|
||||
await onRefreshProvider?.('anthropic');
|
||||
} catch {
|
||||
setConnectionError('Endpoint saved, but failed to refresh provider status.');
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionSaving(false);
|
||||
setPendingConnectionAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableAnthropicCompatibleEndpoint = async (): Promise<void> => {
|
||||
if (selectedProvider?.providerId !== 'anthropic') {
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionSaving(true);
|
||||
setPendingConnectionAction('compatible');
|
||||
setConnectionError(null);
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
let updateSucceeded = false;
|
||||
|
||||
try {
|
||||
await updateConfig('providerConnections', {
|
||||
anthropic: {
|
||||
compatibleEndpoint: {
|
||||
enabled: false,
|
||||
baseUrl: compatibleBaseUrl.trim(),
|
||||
},
|
||||
},
|
||||
});
|
||||
updateSucceeded = true;
|
||||
setCompatibleTokenValue('');
|
||||
setCompatibleEndpointStatus('Endpoint disabled. Saved token was kept.');
|
||||
} catch (error) {
|
||||
setCompatibleEndpointError(
|
||||
error instanceof Error ? error.message : 'Failed to disable endpoint'
|
||||
);
|
||||
} finally {
|
||||
if (updateSucceeded) {
|
||||
try {
|
||||
await onRefreshProvider?.('anthropic');
|
||||
} catch {
|
||||
setConnectionError('Endpoint disabled, but failed to refresh provider status.');
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionSaving(false);
|
||||
setPendingConnectionAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodexAccountRefresh = async (): Promise<void> => {
|
||||
setConnectionError(null);
|
||||
try {
|
||||
|
|
@ -1363,6 +1573,171 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedProvider.providerId === 'anthropic' ? (
|
||||
<div
|
||||
className="space-y-3 rounded-md border p-3"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Local / compatible endpoint
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Use an Anthropic-compatible local runtime endpoint.
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[11px]"
|
||||
style={{
|
||||
color: anthropicCompatibleEndpointEnabled
|
||||
? '#86efac'
|
||||
: 'var(--color-text-muted)',
|
||||
backgroundColor: anthropicCompatibleEndpointEnabled
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{anthropicCompatibleEndpointEnabled ? 'Enabled' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="anthropic-compatible-base-url" className="text-xs">
|
||||
Base URL
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-compatible-base-url"
|
||||
value={compatibleBaseUrl}
|
||||
onChange={(event) => {
|
||||
setCompatibleBaseUrl(event.currentTarget.value);
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
}}
|
||||
placeholder="http://localhost:1234"
|
||||
className="h-9 text-sm"
|
||||
disabled={connectionBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="anthropic-compatible-auth-token" className="text-xs">
|
||||
Auth token
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-compatible-auth-token"
|
||||
type="password"
|
||||
value={compatibleTokenValue}
|
||||
onChange={(event) => {
|
||||
setCompatibleTokenValue(event.currentTarget.value);
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
}}
|
||||
placeholder={
|
||||
anthropicCompatibleTokenConfigured
|
||||
? 'Leave blank to keep saved token'
|
||||
: 'lmstudio'
|
||||
}
|
||||
className="h-9 text-sm"
|
||||
disabled={connectionBusy || apiKeySaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color: anthropicCompatibleTokenConfigured
|
||||
? '#86efac'
|
||||
: 'var(--color-text-muted)',
|
||||
backgroundColor: anthropicCompatibleTokenConfigured
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
Token {anthropicCompatibleTokenConfigured ? 'configured' : 'not set'}
|
||||
</span>
|
||||
{anthropicCompatibleTokenStatus ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{anthropicCompatibleTokenStatus}
|
||||
</span>
|
||||
) : null}
|
||||
{anthropicCompatibleEndpointEnabled &&
|
||||
anthropicCompatibleEndpoint?.baseUrl ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{anthropicCompatibleEndpoint.baseUrl}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{compatibleEndpointError ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.25)',
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.06)',
|
||||
color: '#fca5a5',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{compatibleEndpointError}</span>
|
||||
</div>
|
||||
) : compatibleEndpointStatus ? (
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.22)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
color: '#86efac',
|
||||
}}
|
||||
>
|
||||
{compatibleEndpointStatus}
|
||||
</div>
|
||||
) : anthropicCompatibleMissingToken ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.25)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.06)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>Auth token is not configured.</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{anthropicCompatibleEndpointEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={connectionBusy}
|
||||
onClick={() => void handleDisableAnthropicCompatibleEndpoint()}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={connectionBusy || apiKeySaving || !compatibleBaseUrl.trim()}
|
||||
onClick={() => void handleSaveAnthropicCompatibleEndpoint()}
|
||||
>
|
||||
{connectionSaving && pendingConnectionAction === 'compatible' ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 size-3.5" />
|
||||
)}
|
||||
Save endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{configuredAuthMode && !hideConnectionMethodMeta ? (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ import {
|
|||
isGeminiUiFrozen,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import {
|
||||
canUseCustomAnthropicCompatibleModel,
|
||||
getAvailableTeamProviderModelOptions,
|
||||
getOpenCodeOpenAiRouteAuthUnavailableReason,
|
||||
getTeamModelUiDisabledReason,
|
||||
isAnthropicCompatibleRuntime,
|
||||
isTeamProviderModelVerificationPending,
|
||||
normalizeTeamModelForUi,
|
||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||
|
|
@ -745,6 +747,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
);
|
||||
const defaultModelTooltip = useMemo(() => {
|
||||
if (effectiveProviderId === 'anthropic') {
|
||||
if (isAnthropicCompatibleRuntime(runtimeProviderStatus)) {
|
||||
const defaultCompatibleModel =
|
||||
runtimeProviderStatus?.modelCatalog?.defaultLaunchModel?.trim() ||
|
||||
runtimeProviderStatus?.modelCatalog?.defaultModelId?.trim() ||
|
||||
null;
|
||||
return defaultCompatibleModel
|
||||
? `Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to ${defaultCompatibleModel}.`
|
||||
: 'Uses the Anthropic-compatible endpoint default model.';
|
||||
}
|
||||
|
||||
const defaultLongContextModel =
|
||||
getRuntimeAwareProviderScopedTeamModelLabel(
|
||||
'anthropic',
|
||||
|
|
@ -879,6 +891,23 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
}
|
||||
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
|
||||
}, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
|
||||
const showAnthropicCompatibleCustomModelInput =
|
||||
effectiveProviderId === 'anthropic' &&
|
||||
canUseCustomAnthropicCompatibleModel(runtimeProviderStatus);
|
||||
const selectedModelMatchesOption = modelOptions.some(
|
||||
(option) => option.value === normalizedValue
|
||||
);
|
||||
const anthropicCompatibleCustomModelValue =
|
||||
showAnthropicCompatibleCustomModelInput && normalizedValue && !selectedModelMatchesOption
|
||||
? normalizedValue
|
||||
: '';
|
||||
const anthropicCompatibleCatalogWarning =
|
||||
showAnthropicCompatibleCustomModelInput &&
|
||||
runtimeProviderStatus?.modelCatalog?.providerId === 'anthropic'
|
||||
? (runtimeProviderStatus.modelCatalog.diagnostics.message ??
|
||||
runtimeProviderStatus.modelCatalog.diagnostics.code ??
|
||||
null)
|
||||
: null;
|
||||
const openCodeCatalogModelById = useMemo(() => {
|
||||
const catalog = runtimeProviderStatus?.modelCatalog;
|
||||
const modelById = new Map<string, ProviderModelCatalogItem>();
|
||||
|
|
@ -1620,6 +1649,30 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
list is syncing.
|
||||
</p>
|
||||
) : null}
|
||||
{showAnthropicCompatibleCustomModelInput ? (
|
||||
<div className="mb-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-2">
|
||||
<Label
|
||||
htmlFor="anthropic-compatible-custom-model"
|
||||
className="mb-1 block text-[11px] font-medium text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Custom model id
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-compatible-custom-model"
|
||||
data-testid="team-model-selector-anthropic-compatible-custom-model"
|
||||
value={anthropicCompatibleCustomModelValue}
|
||||
onChange={(event) => onValueChange(event.currentTarget.value.trim())}
|
||||
placeholder="openai/gpt-oss-20b"
|
||||
className="h-8 text-xs"
|
||||
disabled={isInspectingInactiveProvider || !activeProviderSelectable}
|
||||
/>
|
||||
{anthropicCompatibleCatalogWarning ? (
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-amber-200">
|
||||
{anthropicCompatibleCatalogWarning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowModelSearch ? (
|
||||
<div className="relative mb-2">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export type TeamModelRuntimeProviderStatus = Pick<
|
|||
| 'detailMessage'
|
||||
| 'availableBackends'
|
||||
| 'externalRuntimeDiagnostics'
|
||||
| 'connection'
|
||||
> &
|
||||
Partial<Pick<CliProviderStatus, 'verificationState' | 'statusMessage'>>;
|
||||
|
||||
|
|
@ -230,6 +231,58 @@ function hasAnthropicRuntimeCatalog(
|
|||
return providerStatus?.modelCatalog?.providerId === 'anthropic';
|
||||
}
|
||||
|
||||
function hasAnthropicCompatibleRuntimeCatalog(
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): boolean {
|
||||
return (
|
||||
providerStatus?.modelCatalog?.providerId === 'anthropic' &&
|
||||
providerStatus.modelCatalog.source === 'anthropic-compatible-api'
|
||||
);
|
||||
}
|
||||
|
||||
export function isAnthropicCompatibleRuntime(
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): boolean {
|
||||
return (
|
||||
hasAnthropicCompatibleRuntimeCatalog(providerStatus) ||
|
||||
providerStatus?.runtimeCapabilities?.modelCatalog?.source === 'anthropic-compatible-api' ||
|
||||
providerStatus?.connection?.compatibleEndpoint?.enabled === true
|
||||
);
|
||||
}
|
||||
|
||||
function hasVisibleAnthropicCompatibleCatalogModels(
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): boolean {
|
||||
const catalog = hasAnthropicCompatibleRuntimeCatalog(providerStatus)
|
||||
? providerStatus?.modelCatalog
|
||||
: null;
|
||||
return Boolean(
|
||||
catalog?.models.some((model) => {
|
||||
const launchModel = model.launchModel.trim() || model.id.trim();
|
||||
return !model.hidden && launchModel.length > 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function canUseCustomAnthropicCompatibleModel(
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): boolean {
|
||||
if (!isAnthropicCompatibleRuntime(providerStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const catalog = providerStatus?.modelCatalog;
|
||||
if (!catalog || catalog.providerId !== 'anthropic') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (catalog.source !== 'anthropic-compatible-api') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return catalog.status !== 'ready' || !hasVisibleAnthropicCompatibleCatalogModels(providerStatus);
|
||||
}
|
||||
|
||||
function getAnthropicCatalogModel(
|
||||
model: string,
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
|
|
@ -247,16 +300,20 @@ function getRuntimeCatalogModels(
|
|||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): string[] | null {
|
||||
if (providerId === 'anthropic') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
if (!hasAnthropicCompatibleRuntimeCatalog(providerStatus)) {
|
||||
return null;
|
||||
}
|
||||
} else if (
|
||||
(providerId !== 'codex' && providerId !== 'opencode') ||
|
||||
providerStatus?.modelCatalog?.providerId !== providerId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!providerStatus?.modelCatalog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const models = providerStatus.modelCatalog.models
|
||||
.filter((model) => !model.hidden)
|
||||
.map((model) => model.launchModel.trim() || model.id.trim())
|
||||
|
|
@ -269,7 +326,10 @@ function getRuntimeCatalogModelOption(
|
|||
model: string,
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): TeamRuntimeModelOption | null {
|
||||
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
|
||||
const canUseCatalog =
|
||||
(providerId === 'codex' && providerStatus?.modelCatalog?.providerId === 'codex') ||
|
||||
(providerId === 'anthropic' && hasAnthropicCompatibleRuntimeCatalog(providerStatus));
|
||||
if (!canUseCatalog || !providerStatus?.modelCatalog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -280,8 +340,9 @@ function getRuntimeCatalogModelOption(
|
|||
return null;
|
||||
}
|
||||
|
||||
const launchModel = catalogModel.launchModel.trim() || catalogModel.id.trim();
|
||||
return {
|
||||
value: catalogModel.launchModel,
|
||||
value: launchModel,
|
||||
label:
|
||||
getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ??
|
||||
catalogModel.displayName,
|
||||
|
|
@ -290,12 +351,8 @@ function getRuntimeCatalogModelOption(
|
|||
(getTeamProviderModelOptions(providerId).some((option) => option.value === model)
|
||||
? undefined
|
||||
: 'New'),
|
||||
availabilityStatus: getRuntimeModelAvailability(
|
||||
providerId,
|
||||
catalogModel.launchModel,
|
||||
providerStatus
|
||||
),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(catalogModel.launchModel, providerStatus),
|
||||
availabilityStatus: getRuntimeModelAvailability(providerId, launchModel, providerStatus),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(launchModel, providerStatus),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +366,10 @@ function getRuntimeSelectorModels(
|
|||
|
||||
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
|
||||
if (catalogModels) {
|
||||
if (providerId === 'anthropic') {
|
||||
return sortTeamProviderModels(providerId, catalogModels, providerStatus);
|
||||
}
|
||||
|
||||
const sourceModels =
|
||||
providerId === 'opencode'
|
||||
? mergeModelLists(catalogModels, providerStatus.models)
|
||||
|
|
@ -316,6 +377,10 @@ function getRuntimeSelectorModels(
|
|||
return getVisibleTeamProviderModels(providerId, sourceModels, providerStatus);
|
||||
}
|
||||
|
||||
if (providerId === 'anthropic' && isAnthropicCompatibleRuntime(providerStatus)) {
|
||||
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
|
||||
}
|
||||
|
||||
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
|
||||
}
|
||||
|
||||
|
|
@ -384,6 +449,18 @@ function getRuntimeModelAvailability(
|
|||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): CliProviderModelAvailabilityStatus | null {
|
||||
if (providerId === 'anthropic') {
|
||||
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||
const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
|
||||
if (visibleModels.includes(model)) {
|
||||
const runtimeAvailability = getModelAvailabilityMap(providerStatus).get(model)?.status;
|
||||
return runtimeAvailability === 'unavailable' ? 'unavailable' : 'available';
|
||||
}
|
||||
|
||||
return canUseCustomAnthropicCompatibleModel(providerStatus) && model.trim()
|
||||
? 'available'
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!providerStatus || !hasAnthropicRuntimeCatalog(providerStatus)) {
|
||||
return isSupportedAnthropicTeamModel(model) ? 'available' : null;
|
||||
}
|
||||
|
|
@ -418,7 +495,9 @@ export function getTeamProviderModelVerificationCounts(
|
|||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): TeamProviderModelVerificationCounts {
|
||||
if (providerId === 'anthropic') {
|
||||
const visibleAnthropicModels = getFallbackTeamProviderModels(providerId);
|
||||
const visibleAnthropicModels = isAnthropicCompatibleRuntime(providerStatus)
|
||||
? getRuntimeSelectorModels(providerId, providerStatus)
|
||||
: getFallbackTeamProviderModels(providerId);
|
||||
return {
|
||||
checkedCount: visibleAnthropicModels.length,
|
||||
totalCount: visibleAnthropicModels.length,
|
||||
|
|
@ -440,6 +519,12 @@ export function getAvailableTeamProviderModels(
|
|||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): string[] {
|
||||
if (providerId === 'anthropic') {
|
||||
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||
return getVisibleRuntimeModels(providerId, providerStatus).filter(
|
||||
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
|
||||
);
|
||||
}
|
||||
|
||||
return getFallbackTeamProviderModels(providerId).filter(
|
||||
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
|
||||
);
|
||||
|
|
@ -461,6 +546,27 @@ export function getAvailableTeamProviderModelOptions(
|
|||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): TeamRuntimeModelOption[] {
|
||||
if (providerId === 'anthropic') {
|
||||
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||
const visibleModels = getRuntimeSelectorModels(providerId, providerStatus);
|
||||
return [
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
...visibleModels.map((model) => {
|
||||
const catalogOption = getRuntimeCatalogModelOption(providerId, model, providerStatus);
|
||||
if (catalogOption) {
|
||||
return catalogOption;
|
||||
}
|
||||
|
||||
return {
|
||||
value: model,
|
||||
label: getProviderScopedTeamModelLabel(providerId, model) ?? model,
|
||||
badgeLabel: getRuntimeAwareTeamModelBadgeLabel(providerId, model, providerStatus),
|
||||
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
return getFallbackTeamProviderModelOptions(providerId, providerStatus).map((option) => ({
|
||||
...option,
|
||||
availabilityStatus:
|
||||
|
|
@ -538,6 +644,13 @@ export function isTeamModelAvailableForUi(
|
|||
}
|
||||
|
||||
if (providerId === 'anthropic') {
|
||||
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||
return (
|
||||
getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available' ||
|
||||
canUseCustomAnthropicCompatibleModel(providerStatus)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSupportedAnthropicTeamModel(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -576,6 +689,10 @@ export function normalizeTeamModelForUi(
|
|||
}
|
||||
|
||||
if (providerId === 'anthropic') {
|
||||
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||
return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : '';
|
||||
}
|
||||
|
||||
return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -88,4 +88,217 @@ describe('ConfigManager Codex migration hardening', () => {
|
|||
expect(persisted.runtime.providerBackends.codex).toBe('codex-native');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads legacy Anthropic provider connections with compatible endpoint defaults', async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-default-'));
|
||||
const configPath = path.join(tempRoot, 'agent-teams-config.json');
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'oauth',
|
||||
fastModeDefault: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { ConfigManager } = await import(
|
||||
'../../../../src/main/services/infrastructure/ConfigManager'
|
||||
);
|
||||
|
||||
const manager = new ConfigManager(configPath);
|
||||
const config = manager.getConfig();
|
||||
|
||||
expect(config.providerConnections.anthropic).toEqual({
|
||||
authMode: 'oauth',
|
||||
fastModeDefault: true,
|
||||
compatibleEndpoint: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
compatibleEndpoint: { enabled: boolean; baseUrl: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(persisted.providerConnections.anthropic.compatibleEndpoint).toEqual({
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('deep-merges partial Anthropic compatible endpoint updates', async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-update-'));
|
||||
const configPath = path.join(tempRoot, 'agent-teams-config.json');
|
||||
|
||||
const { ConfigManager } = await import(
|
||||
'../../../../src/main/services/infrastructure/ConfigManager'
|
||||
);
|
||||
|
||||
const manager = new ConfigManager(configPath);
|
||||
manager.updateConfig('providerConnections', {
|
||||
anthropic: {
|
||||
authMode: 'oauth',
|
||||
fastModeDefault: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: string;
|
||||
fastModeDefault: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(persisted.providerConnections.anthropic.authMode).toBe('oauth');
|
||||
expect(persisted.providerConnections.anthropic.fastModeDefault).toBe(true);
|
||||
});
|
||||
|
||||
const updated = manager.updateConfig('providerConnections', {
|
||||
anthropic: {
|
||||
compatibleEndpoint: {
|
||||
baseUrl: ' http://localhost:1234 ',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(updated.providerConnections.anthropic).toEqual({
|
||||
authMode: 'oauth',
|
||||
fastModeDefault: true,
|
||||
compatibleEndpoint: {
|
||||
enabled: false,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: string;
|
||||
fastModeDefault: boolean;
|
||||
compatibleEndpoint: { enabled: boolean; baseUrl: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(persisted.providerConnections.anthropic).toEqual({
|
||||
authMode: 'oauth',
|
||||
fastModeDefault: true,
|
||||
compatibleEndpoint: {
|
||||
enabled: false,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('strips derived Anthropic compatible endpoint token status when loading config', async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-derived-'));
|
||||
const configPath = path.join(tempRoot, 'agent-teams-config.json');
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
compatibleEndpoint: {
|
||||
enabled: true,
|
||||
baseUrl: ' http://localhost:1234 ',
|
||||
tokenConfigured: true,
|
||||
tokenSource: 'stored',
|
||||
tokenSourceLabel: 'Stored in app',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { ConfigManager } = await import(
|
||||
'../../../../src/main/services/infrastructure/ConfigManager'
|
||||
);
|
||||
|
||||
const manager = new ConfigManager(configPath);
|
||||
expect(manager.getConfig().providerConnections.anthropic.compatibleEndpoint).toEqual({
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
compatibleEndpoint: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(persisted.providerConnections.anthropic.compatibleEndpoint).toEqual({
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('strips derived Anthropic compatible endpoint token status from partial updates', async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-anthropic-compatible-derived-update-'));
|
||||
const configPath = path.join(tempRoot, 'agent-teams-config.json');
|
||||
|
||||
const { ConfigManager } = await import(
|
||||
'../../../../src/main/services/infrastructure/ConfigManager'
|
||||
);
|
||||
|
||||
const manager = new ConfigManager(configPath);
|
||||
const updated = manager.updateConfig('providerConnections', {
|
||||
anthropic: {
|
||||
compatibleEndpoint: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
tokenConfigured: true,
|
||||
tokenSource: 'environment',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(updated.providerConnections.anthropic.compatibleEndpoint).toEqual({
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
compatibleEndpoint: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(persisted.providerConnections.anthropic.compatibleEndpoint).toEqual({
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
|
|
@ -313,6 +313,209 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders Anthropic-compatible catalog models instead of Claude fallback aliases', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const onValueChange = vi.fn();
|
||||
storeState.cliStatus = {
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
models: [],
|
||||
authMethod: 'auth_token',
|
||||
authenticated: true,
|
||||
supported: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'auto',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
compatibleEndpoint: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
tokenConfigured: true,
|
||||
tokenSource: 'stored',
|
||||
tokenSourceLabel: 'Stored in app',
|
||||
},
|
||||
},
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-compatible-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-21T00:00:00.000Z',
|
||||
staleAt: '2026-05-21T00:10:00.000Z',
|
||||
defaultModelId: 'openai/gpt-oss-20b',
|
||||
defaultLaunchModel: 'openai/gpt-oss-20b',
|
||||
models: [
|
||||
{
|
||||
id: 'openai/gpt-oss-20b',
|
||||
launchModel: 'openai/gpt-oss-20b',
|
||||
displayName: 'GPT OSS 20B',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-compatible-api',
|
||||
badgeLabel: 'Local',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TeamModelSelector, {
|
||||
providerId: 'anthropic',
|
||||
onProviderChange: () => undefined,
|
||||
value: '',
|
||||
onValueChange,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('GPT OSS 20B');
|
||||
expect(host.textContent).not.toContain('Opus 4.7');
|
||||
expect(
|
||||
host.querySelector('[data-testid="team-model-selector-anthropic-compatible-custom-model"]')
|
||||
).toBeNull();
|
||||
const defaultModelButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Default')
|
||||
);
|
||||
expect(defaultModelButton?.getAttribute('title')).toContain(
|
||||
'Anthropic-compatible endpoint default model'
|
||||
);
|
||||
expect(defaultModelButton?.getAttribute('title')).toContain('openai/gpt-oss-20b');
|
||||
const localModelButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('GPT OSS 20B')
|
||||
);
|
||||
expect(localModelButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
localModelButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('openai/gpt-oss-20b');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Anthropic-compatible custom model input for degraded catalogs', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const onValueChange = vi.fn();
|
||||
storeState.cliStatus = {
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
models: [],
|
||||
authMethod: 'auth_token',
|
||||
authenticated: true,
|
||||
supported: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'auto',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
compatibleEndpoint: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
tokenConfigured: true,
|
||||
tokenSource: 'stored',
|
||||
tokenSourceLabel: 'Stored in app',
|
||||
},
|
||||
},
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-compatible-api',
|
||||
status: 'degraded',
|
||||
fetchedAt: '2026-05-21T00:00:00.000Z',
|
||||
staleAt: '2026-05-21T00:10:00.000Z',
|
||||
defaultModelId: null,
|
||||
defaultLaunchModel: null,
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'failed',
|
||||
appServerState: 'degraded',
|
||||
message: 'Local catalog unavailable',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TeamModelSelector, {
|
||||
providerId: 'anthropic',
|
||||
onProviderChange: () => undefined,
|
||||
value: 'openai/gpt-oss-20b',
|
||||
onValueChange,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const customInput = host.querySelector<HTMLInputElement>(
|
||||
'[data-testid="team-model-selector-anthropic-compatible-custom-model"]'
|
||||
);
|
||||
expect(customInput).toBeTruthy();
|
||||
expect(customInput?.value).toBe('openai/gpt-oss-20b');
|
||||
expect(host.textContent).toContain('Local catalog unavailable');
|
||||
|
||||
await act(async () => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)?.set;
|
||||
setValue?.call(customInput, 'qwen/qwen3-coder');
|
||||
customInput?.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('qwen/qwen3-coder');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('labels, sorts, and filters OpenCode models with real Agent Teams E2E recommendations', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,38 @@ function createOpenCodeProviderStatus(
|
|||
};
|
||||
}
|
||||
|
||||
function createAnthropicCompatibleProviderStatus(
|
||||
overrides: Partial<TeamModelRuntimeProviderStatus> = {}
|
||||
): TeamModelRuntimeProviderStatus {
|
||||
return {
|
||||
providerId: 'anthropic',
|
||||
models: [],
|
||||
authMethod: 'api_key',
|
||||
backend: null,
|
||||
authenticated: true,
|
||||
supported: true,
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'auto',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
compatibleEndpoint: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://localhost:1234',
|
||||
tokenConfigured: true,
|
||||
tokenSource: 'stored',
|
||||
tokenSourceLabel: 'Stored in app',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('teamModelAvailability', () => {
|
||||
it('uses runtime-reported Codex models as the source of truth', () => {
|
||||
const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']);
|
||||
|
|
@ -561,4 +593,217 @@ describe('teamModelAvailability', () => {
|
|||
expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-7')).toBeNull();
|
||||
expect(getTeamModelSelectionError('anthropic', 'claude-haiku-4-5-20251001')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses Anthropic-compatible runtime catalog models instead of curated Claude aliases', () => {
|
||||
const providerStatus = createAnthropicCompatibleProviderStatus({
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-compatible-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-21T00:00:00.000Z',
|
||||
staleAt: '2026-05-21T00:10:00.000Z',
|
||||
defaultModelId: 'openai/gpt-oss-20b',
|
||||
defaultLaunchModel: 'openai/gpt-oss-20b',
|
||||
models: [
|
||||
{
|
||||
id: 'openai/gpt-oss-20b',
|
||||
launchModel: 'openai/gpt-oss-20b',
|
||||
displayName: 'GPT OSS 20B',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-compatible-api',
|
||||
badgeLabel: 'Local',
|
||||
},
|
||||
{
|
||||
id: 'hidden-local',
|
||||
launchModel: 'hidden-local',
|
||||
displayName: 'Hidden',
|
||||
hidden: true,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'anthropic-compatible-api',
|
||||
badgeLabel: null,
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([
|
||||
'openai/gpt-oss-20b',
|
||||
]);
|
||||
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
{
|
||||
value: 'openai/gpt-oss-20b',
|
||||
label: 'GPT OSS 20B',
|
||||
badgeLabel: 'Local',
|
||||
availabilityStatus: 'available',
|
||||
availabilityReason: null,
|
||||
},
|
||||
]);
|
||||
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe(
|
||||
'openai/gpt-oss-20b'
|
||||
);
|
||||
expect(normalizeTeamModelForUi('anthropic', 'opus', providerStatus)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps custom Anthropic-compatible model ids selectable when the catalog is degraded', () => {
|
||||
const providerStatus = createAnthropicCompatibleProviderStatus({
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-compatible-api',
|
||||
status: 'degraded',
|
||||
fetchedAt: '2026-05-21T00:00:00.000Z',
|
||||
staleAt: '2026-05-21T00:10:00.000Z',
|
||||
defaultModelId: null,
|
||||
defaultLaunchModel: null,
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'failed',
|
||||
appServerState: 'degraded',
|
||||
message: 'Local catalog unavailable',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe(
|
||||
'openai/gpt-oss-20b'
|
||||
);
|
||||
expect(
|
||||
getTeamModelSelectionError('anthropic', 'openai/gpt-oss-20b', providerStatus)
|
||||
).toBeNull();
|
||||
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows custom Anthropic-compatible model ids before a runtime catalog is available', () => {
|
||||
const providerStatus = createAnthropicCompatibleProviderStatus({
|
||||
modelCatalog: null,
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: {
|
||||
dynamic: true,
|
||||
source: 'anthropic-compatible-api',
|
||||
},
|
||||
reasoningEffort: {
|
||||
supported: false,
|
||||
values: [],
|
||||
configPassthrough: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
]);
|
||||
expect(normalizeTeamModelForUi('anthropic', 'qwen/qwen3-coder', providerStatus)).toBe(
|
||||
'qwen/qwen3-coder'
|
||||
);
|
||||
expect(getTeamModelSelectionError('anthropic', 'qwen/qwen3-coder', providerStatus)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps stale Anthropic-compatible catalog models visible while allowing custom ids', () => {
|
||||
const providerStatus = createAnthropicCompatibleProviderStatus({
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-compatible-api',
|
||||
status: 'stale',
|
||||
fetchedAt: '2026-05-21T00:00:00.000Z',
|
||||
staleAt: '2026-05-21T00:10:00.000Z',
|
||||
defaultModelId: 'local-default',
|
||||
defaultLaunchModel: 'local-default',
|
||||
models: [
|
||||
{
|
||||
id: 'local-default',
|
||||
launchModel: 'local-default',
|
||||
displayName: 'Local Default',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-compatible-api',
|
||||
badgeLabel: 'Stale',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'degraded',
|
||||
message: 'Using stale local catalog',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
{
|
||||
value: 'local-default',
|
||||
label: 'Local Default',
|
||||
badgeLabel: 'Stale',
|
||||
availabilityStatus: 'available',
|
||||
availabilityReason: null,
|
||||
},
|
||||
]);
|
||||
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe(
|
||||
'openai/gpt-oss-20b'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects custom Anthropic-compatible ids when a ready compatible catalog has visible models', () => {
|
||||
const providerStatus = createAnthropicCompatibleProviderStatus({
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-compatible-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-21T00:00:00.000Z',
|
||||
staleAt: '2026-05-21T00:10:00.000Z',
|
||||
defaultModelId: 'local-default',
|
||||
defaultLaunchModel: 'local-default',
|
||||
models: [
|
||||
{
|
||||
id: 'local-default',
|
||||
launchModel: 'local-default',
|
||||
displayName: 'Local Default',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-compatible-api',
|
||||
badgeLabel: 'Local',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalizeTeamModelForUi('anthropic', 'openai/gpt-oss-20b', providerStatus)).toBe('');
|
||||
expect(
|
||||
getTeamModelSelectionError('anthropic', 'openai/gpt-oss-20b', providerStatus)
|
||||
).toContain('not available');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue