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 { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv';
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
|
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
RuntimeProviderManagementApi,
|
RuntimeProviderManagementApi,
|
||||||
|
|
@ -141,7 +141,11 @@ async function resolveCliEnv(): Promise<{
|
||||||
binaryPath: string | null;
|
binaryPath: string | null;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}> {
|
}> {
|
||||||
const shellEnv = await resolveInteractiveShellEnv();
|
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||||
|
timeoutMs: 1_500,
|
||||||
|
fallbackEnv: process.env,
|
||||||
|
background: false,
|
||||||
|
});
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
|
||||||
'ssh',
|
'ssh',
|
||||||
]);
|
]);
|
||||||
const MAX_SNOOZE_MINUTES = 24 * 60;
|
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> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
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);
|
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 {
|
function isValidTrigger(trigger: unknown): trigger is NotificationTrigger {
|
||||||
if (!isPlainObject(trigger)) {
|
if (!isPlainObject(trigger)) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -496,7 +521,11 @@ function validateProviderConnectionsSection(
|
||||||
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
|
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
|
||||||
|
|
||||||
for (const [connectionKey, connectionValue] of Object.entries(value)) {
|
for (const [connectionKey, connectionValue] of Object.entries(value)) {
|
||||||
if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') {
|
if (
|
||||||
|
connectionKey !== 'authMode' &&
|
||||||
|
connectionKey !== 'fastModeDefault' &&
|
||||||
|
connectionKey !== 'compatibleEndpoint'
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
|
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
|
||||||
|
|
@ -519,6 +548,64 @@ function validateProviderConnectionsSection(
|
||||||
continue;
|
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') {
|
if (typeof connectionValue !== 'boolean') {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
|
|
|
||||||
|
|
@ -275,10 +275,16 @@ export interface RuntimeConfig {
|
||||||
|
|
||||||
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
|
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
|
||||||
|
|
||||||
|
export interface AnthropicCompatibleEndpointConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProviderConnectionsConfig {
|
export interface ProviderConnectionsConfig {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
authMode: ProviderConnectionAuthMode;
|
authMode: ProviderConnectionAuthMode;
|
||||||
fastModeDefault: boolean;
|
fastModeDefault: boolean;
|
||||||
|
compatibleEndpoint: AnthropicCompatibleEndpointConfig;
|
||||||
};
|
};
|
||||||
codex: {
|
codex: {
|
||||||
preferredAuthMode: CodexAccountAuthMode;
|
preferredAuthMode: CodexAccountAuthMode;
|
||||||
|
|
@ -376,6 +382,10 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
authMode: 'auto',
|
authMode: 'auto',
|
||||||
fastModeDefault: false,
|
fastModeDefault: false,
|
||||||
|
compatibleEndpoint: {
|
||||||
|
enabled: false,
|
||||||
|
baseUrl: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
preferredAuthMode: 'auto',
|
preferredAuthMode: 'auto',
|
||||||
|
|
@ -457,6 +467,22 @@ function normalizeCodexPreferredAuthMode(
|
||||||
return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode;
|
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 {
|
function shouldPersistNormalizedConfig(loaded: Partial<AppConfig>, normalized: AppConfig): boolean {
|
||||||
return JSON.stringify(loaded) !== JSON.stringify(normalized);
|
return JSON.stringify(loaded) !== JSON.stringify(normalized);
|
||||||
}
|
}
|
||||||
|
|
@ -634,6 +660,9 @@ export class ConfigManager {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
...DEFAULT_CONFIG.providerConnections.anthropic,
|
...DEFAULT_CONFIG.providerConnections.anthropic,
|
||||||
...(loaded.providerConnections?.anthropic ?? {}),
|
...(loaded.providerConnections?.anthropic ?? {}),
|
||||||
|
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
|
||||||
|
loaded.providerConnections?.anthropic?.compatibleEndpoint
|
||||||
|
),
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
preferredAuthMode: normalizeCodexPreferredAuthMode(
|
preferredAuthMode: normalizeCodexPreferredAuthMode(
|
||||||
|
|
@ -750,6 +779,10 @@ export class ConfigManager {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
...this.config.providerConnections.anthropic,
|
...this.config.providerConnections.anthropic,
|
||||||
...(connectionUpdate.anthropic ?? {}),
|
...(connectionUpdate.anthropic ?? {}),
|
||||||
|
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
|
||||||
|
connectionUpdate.anthropic?.compatibleEndpoint,
|
||||||
|
this.config.providerConnections.anthropic.compatibleEndpoint
|
||||||
|
),
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
...this.config.providerConnections.codex,
|
...this.config.providerConnections.codex,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import type {
|
||||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
|
|
||||||
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
|
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
|
||||||
|
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000;
|
||||||
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
|
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
|
||||||
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||||
const PROVIDER_MODELS_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(
|
private async buildCliEnv(
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||||
return buildProviderAwareCliEnv({ binaryPath, allowStoredApiKeyDecryption: false });
|
return buildProviderAwareCliEnv({
|
||||||
|
binaryPath,
|
||||||
|
allowStoredApiKeyDecryption: false,
|
||||||
|
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildProviderCliEnv(
|
private async buildProviderCliEnv(
|
||||||
|
|
@ -807,6 +812,8 @@ export class ClaudeMultimodelBridgeService {
|
||||||
binaryPath,
|
binaryPath,
|
||||||
providerId,
|
providerId,
|
||||||
allowStoredApiKeyDecryption: false,
|
allowStoredApiKeyDecryption: false,
|
||||||
|
allowedStoredApiKeyEnvVarNames:
|
||||||
|
providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -827,6 +834,12 @@ export class ClaudeMultimodelBridgeService {
|
||||||
return this.isRuntimeStatusCompatibilityError(error) || lower.includes('runtime status');
|
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(
|
private mapRuntimeProviderStatus(
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
||||||
|
|
@ -966,14 +979,14 @@ export class ClaudeMultimodelBridgeService {
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
connectionIssues: Partial<Record<CliProviderId, string>>,
|
connectionIssues: Partial<Record<CliProviderId, string>>,
|
||||||
options: { summary?: boolean } = {}
|
options: { summary?: boolean; timeoutMs?: number } = {}
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
const args = ['runtime', 'status', '--json', '--provider', providerId];
|
const args = ['runtime', 'status', '--json', '--provider', providerId];
|
||||||
if (options.summary) {
|
if (options.summary) {
|
||||||
args.push('--summary');
|
args.push('--summary');
|
||||||
}
|
}
|
||||||
const { stdout } = await execCli(binaryPath, args, {
|
const { stdout } = await execCli(binaryPath, args, {
|
||||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
|
|
@ -990,7 +1003,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
private async getProviderStatusFromScopedRuntimeStatus(
|
private async getProviderStatusFromScopedRuntimeStatus(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
options: { summary?: boolean } = {}
|
options: { summary?: boolean; timeoutMs?: number } = {}
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||||
return this.getProviderStatusFromRuntimeStatusCommand(
|
return this.getProviderStatusFromRuntimeStatusCommand(
|
||||||
|
|
@ -1005,7 +1018,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
private async getProviderStatusesFromScopedRuntimeStatus(
|
private async getProviderStatusesFromScopedRuntimeStatus(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
onUpdate?: (providers: CliProviderStatus[]) => void,
|
onUpdate?: (providers: CliProviderStatus[]) => void,
|
||||||
options: { summary?: boolean; providerIds?: readonly CliProviderId[] } = {}
|
options: { summary?: boolean; timeoutMs?: number; providerIds?: readonly CliProviderId[] } = {}
|
||||||
): Promise<CliProviderStatus[] | null> {
|
): Promise<CliProviderStatus[] | null> {
|
||||||
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
|
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
|
||||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||||
|
|
@ -1032,6 +1045,19 @@ export class ClaudeMultimodelBridgeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failures.length === providerIds.length) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1202,7 +1228,11 @@ export class ClaudeMultimodelBridgeService {
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
onCatalogUpdate?: (provider: CliProviderStatus) => void
|
onCatalogUpdate?: (provider: CliProviderStatus) => void
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
await resolveInteractiveShellEnvBestEffort({
|
||||||
|
timeoutMs: 1_500,
|
||||||
|
fallbackEnv: process.env,
|
||||||
|
background: false,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const generation = this.beginProviderStatusHydration([providerId]);
|
const generation = this.beginProviderStatusHydration([providerId]);
|
||||||
|
|
@ -1418,14 +1448,22 @@ export class ClaudeMultimodelBridgeService {
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||||
): Promise<CliProviderStatus[]> {
|
): Promise<CliProviderStatus[]> {
|
||||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
await resolveInteractiveShellEnvBestEffort({
|
||||||
|
timeoutMs: 1_500,
|
||||||
|
fallbackEnv: process.env,
|
||||||
|
background: false,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const generation = this.beginProviderStatusHydration(DEFAULT_PROVIDER_STATUS_IDS);
|
const generation = this.beginProviderStatusHydration(DEFAULT_PROVIDER_STATUS_IDS);
|
||||||
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
|
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
{ summary: true, providerIds: DEFAULT_PROVIDER_STATUS_IDS }
|
{
|
||||||
|
summary: true,
|
||||||
|
timeoutMs: PROVIDER_STATUS_SUMMARY_TIMEOUT_MS,
|
||||||
|
providerIds: DEFAULT_PROVIDER_STATUS_IDS,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
if (providers) {
|
if (providers) {
|
||||||
this.hydrateProviderCatalogs(binaryPath, providers, generation, onUpdate);
|
this.hydrateProviderCatalogs(binaryPath, providers, generation, onUpdate);
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,8 @@ export class CliProviderModelAvailabilityService {
|
||||||
binaryPath: context.binaryPath,
|
binaryPath: context.binaryPath,
|
||||||
providerId: context.provider.providerId,
|
providerId: context.provider.providerId,
|
||||||
allowStoredApiKeyDecryption: false,
|
allowStoredApiKeyDecryption: false,
|
||||||
|
allowedStoredApiKeyEnvVarNames:
|
||||||
|
context.provider.providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
|
||||||
}).then((result) => ({
|
}).then((result) => ({
|
||||||
env: result.env,
|
env: result.env,
|
||||||
providerArgs: result.providerArgs ?? [],
|
providerArgs: result.providerArgs ?? [],
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
||||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||||
|
|
||||||
|
import type { AnthropicCompatibleEndpointConfig } from '../infrastructure/ConfigManager';
|
||||||
import type {
|
import type {
|
||||||
CodexAccountAuthMode,
|
CodexAccountAuthMode,
|
||||||
CodexAccountSnapshotDto,
|
CodexAccountSnapshotDto,
|
||||||
|
|
@ -37,6 +38,7 @@ type ExternalCredential = {
|
||||||
|
|
||||||
interface StoredApiKeyAccessOptions {
|
interface StoredApiKeyAccessOptions {
|
||||||
allowStoredApiKeyDecryption?: boolean;
|
allowStoredApiKeyDecryption?: boolean;
|
||||||
|
allowedStoredApiKeyEnvVarNames?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_CAPABILITIES: Record<
|
const PROVIDER_CAPABILITIES: Record<
|
||||||
|
|
@ -71,6 +73,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
||||||
gemini: 'GEMINI_API_KEY',
|
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_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
||||||
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
||||||
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
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_TIMEOUT_MS = 10_000;
|
||||||
const ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS = 60_000;
|
const ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS = 60_000;
|
||||||
const ANTHROPIC_DEFAULT_API_BASE_URL = 'https://api.anthropic.com';
|
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';
|
type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown';
|
||||||
|
|
||||||
|
|
@ -161,10 +166,15 @@ function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const host = new URL(trimmed).host;
|
const url = new URL(trimmed);
|
||||||
return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com';
|
return (
|
||||||
|
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||||
|
!url.username &&
|
||||||
|
!url.password &&
|
||||||
|
!FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)
|
||||||
|
);
|
||||||
} catch {
|
} 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());
|
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(
|
async function verifyAnthropicApiKeyWithApi(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
|
|
@ -393,12 +421,122 @@ export class ProviderConnectionService {
|
||||||
return null;
|
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> {
|
async getConfiguredAnthropicApiKeyForTeamRuntime(env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||||
if (this.getConfiguredAuthMode('anthropic') !== 'api_key') {
|
if (this.getConfiguredAuthMode('anthropic') !== 'api_key') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAnthropicCompatibleAuthEnv(env)) {
|
const configuredEndpoint =
|
||||||
|
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||||
|
if (
|
||||||
|
configuredEndpoint?.enabled === true ||
|
||||||
|
isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,6 +556,10 @@ export class ProviderConnectionService {
|
||||||
options?: StoredApiKeyAccessOptions
|
options?: StoredApiKeyAccessOptions
|
||||||
): Promise<NodeJS.ProcessEnv> {
|
): Promise<NodeJS.ProcessEnv> {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasAnthropicCompatibleAuthEnv(env)) {
|
if (hasAnthropicCompatibleAuthEnv(env)) {
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
@ -516,6 +658,10 @@ export class ProviderConnectionService {
|
||||||
options?: StoredApiKeyAccessOptions
|
options?: StoredApiKeyAccessOptions
|
||||||
): Promise<NodeJS.ProcessEnv> {
|
): Promise<NodeJS.ProcessEnv> {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
@ -588,6 +734,15 @@ export class ProviderConnectionService {
|
||||||
runtimeBackendOverride?: string | null
|
runtimeBackendOverride?: string | null
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
const compatibleEndpointIssue = this.getConfiguredAnthropicCompatibleEndpointIssue();
|
||||||
|
if (compatibleEndpointIssue) {
|
||||||
|
return compatibleEndpointIssue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.getConfiguredAnthropicCompatibleEndpoint()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -829,6 +984,18 @@ export class ProviderConnectionService {
|
||||||
provider: CliProviderStatus
|
provider: CliProviderStatus
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
const connection = provider.connection;
|
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') {
|
if (connection?.configuredAuthMode !== 'api_key') {
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
@ -962,6 +1129,8 @@ export class ProviderConnectionService {
|
||||||
: hasStoredApiKey
|
: hasStoredApiKey
|
||||||
? 'Stored in app'
|
? 'Stored in app'
|
||||||
: (externalCredential?.label ?? null);
|
: (externalCredential?.label ?? null);
|
||||||
|
const compatibleEndpoint =
|
||||||
|
providerId === 'anthropic' ? await this.getAnthropicCompatibleEndpointConnectionInfo() : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...capabilities,
|
...capabilities,
|
||||||
|
|
@ -970,6 +1139,7 @@ export class ProviderConnectionService {
|
||||||
apiKeyConfigured,
|
apiKeyConfigured,
|
||||||
apiKeySource,
|
apiKeySource,
|
||||||
apiKeySourceLabel,
|
apiKeySourceLabel,
|
||||||
|
compatibleEndpoint,
|
||||||
codex:
|
codex:
|
||||||
providerId === 'codex' && codexSnapshot
|
providerId === 'codex' && codexSnapshot
|
||||||
? {
|
? {
|
||||||
|
|
@ -1017,7 +1187,9 @@ export class ProviderConnectionService {
|
||||||
envVarName: string,
|
envVarName: string,
|
||||||
options?: StoredApiKeyAccessOptions
|
options?: StoredApiKeyAccessOptions
|
||||||
): Promise<{ envVarName: string; value: string } | null> {
|
): Promise<{ envVarName: string; value: string } | null> {
|
||||||
if (options?.allowStoredApiKeyDecryption === false) {
|
const allowedWhenMetadataOnly =
|
||||||
|
options?.allowedStoredApiKeyEnvVarNames?.includes(envVarName) === true;
|
||||||
|
if (options?.allowStoredApiKeyDecryption === false && !allowedWhenMetadataOnly) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface ProviderAwareCliEnvOptions {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
connectionMode?: 'strict' | 'augment';
|
connectionMode?: 'strict' | 'augment';
|
||||||
allowStoredApiKeyDecryption?: boolean;
|
allowStoredApiKeyDecryption?: boolean;
|
||||||
|
allowedStoredApiKeyEnvVarNames?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderAwareCliEnvResult {
|
export interface ProviderAwareCliEnvResult {
|
||||||
|
|
@ -33,9 +34,15 @@ export async function buildProviderAwareCliEnv(
|
||||||
): Promise<ProviderAwareCliEnvResult> {
|
): Promise<ProviderAwareCliEnvResult> {
|
||||||
const connectionMode = options.connectionMode ?? 'strict';
|
const connectionMode = options.connectionMode ?? 'strict';
|
||||||
const storedApiKeyAccessArgs =
|
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 shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {};
|
||||||
const { env, resolvedProviderId } = buildRuntimeBaseEnv({
|
const { env, resolvedProviderId } = buildRuntimeBaseEnv({
|
||||||
binaryPath: options.binaryPath,
|
binaryPath: options.binaryPath,
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ import {
|
||||||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||||
import { isProcessAlive } from '@main/utils/processHealth';
|
import { isProcessAlive } from '@main/utils/processHealth';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
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 { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||||
import {
|
import {
|
||||||
listWindowsProcessTable,
|
listWindowsProcessTable,
|
||||||
|
|
@ -1291,6 +1291,17 @@ export function buildDirectTmuxRestartEnvAssignments(
|
||||||
}
|
}
|
||||||
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
|
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
|
||||||
assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
|
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 (
|
if (
|
||||||
providerId === 'anthropic' &&
|
providerId === 'anthropic' &&
|
||||||
env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER
|
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 {
|
try {
|
||||||
const host = new URL(trimmed).host;
|
const url = new URL(trimmed);
|
||||||
return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com';
|
return (
|
||||||
|
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||||
|
!url.username &&
|
||||||
|
!url.password &&
|
||||||
|
url.hostname !== 'api.anthropic.com' &&
|
||||||
|
url.hostname !== 'api-staging.anthropic.com'
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35089,7 +35106,12 @@ export class TeamProvisioningService {
|
||||||
teamRuntimeAuth?: TeamRuntimeAuthContext;
|
teamRuntimeAuth?: TeamRuntimeAuthContext;
|
||||||
}
|
}
|
||||||
): Promise<ProvisioningEnvResolution> {
|
): 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
|
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||||
// correctly on Windows. Prefer it over process.env which may be garbled.
|
// correctly on Windows. Prefer it over process.env which may be garbled.
|
||||||
const electronHome = getHomeDir();
|
const electronHome = getHomeDir();
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { useStore } from '@renderer/store';
|
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 {
|
import {
|
||||||
formatProviderAuthMethodLabelForProvider,
|
formatProviderAuthMethodLabelForProvider,
|
||||||
|
|
@ -65,7 +65,7 @@ import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@sha
|
||||||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini';
|
type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||||
type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | null;
|
type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | 'compatible' | null;
|
||||||
|
|
||||||
interface ConnectionMethodCardOption {
|
interface ConnectionMethodCardOption {
|
||||||
readonly authMode: CliProviderAuthMode;
|
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 {
|
function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProviderId {
|
||||||
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
|
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;
|
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 {
|
function getConnectionDescription(provider: CliProviderStatus): string {
|
||||||
switch (provider.providerId) {
|
switch (provider.providerId) {
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
|
|
@ -222,6 +250,12 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
|
||||||
const hasAnthropicSubscriptionSession =
|
const hasAnthropicSubscriptionSession =
|
||||||
provider.authMethod === 'oauth_token' || provider.authMethod === 'claude.ai';
|
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 (
|
if (
|
||||||
provider.providerId === 'anthropic' &&
|
provider.providerId === 'anthropic' &&
|
||||||
authMode === 'api_key' &&
|
authMode === 'api_key' &&
|
||||||
|
|
@ -304,6 +338,10 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderUsageLabel(provider: CliProviderStatus): string {
|
function getProviderUsageLabel(provider: CliProviderStatus): string {
|
||||||
|
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
|
||||||
|
return 'Using compatible endpoint';
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
provider.providerId === 'anthropic' &&
|
provider.providerId === 'anthropic' &&
|
||||||
provider.connection?.configuredAuthMode === 'api_key'
|
provider.connection?.configuredAuthMode === 'api_key'
|
||||||
|
|
@ -637,6 +675,10 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
const [runtimeSaving, setRuntimeSaving] = useState(false);
|
const [runtimeSaving, setRuntimeSaving] = useState(false);
|
||||||
const [pendingConnectionAction, setPendingConnectionAction] =
|
const [pendingConnectionAction, setPendingConnectionAction] =
|
||||||
useState<PendingConnectionAction>(null);
|
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 apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const apiKeys = useStore((s) => s.apiKeys);
|
const apiKeys = useStore((s) => s.apiKeys);
|
||||||
|
|
@ -679,11 +721,17 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
setConnectionSaving(false);
|
setConnectionSaving(false);
|
||||||
setRuntimeSaving(false);
|
setRuntimeSaving(false);
|
||||||
setPendingConnectionAction(null);
|
setPendingConnectionAction(null);
|
||||||
|
setCompatibleBaseUrl('');
|
||||||
|
setCompatibleTokenValue('');
|
||||||
|
setCompatibleEndpointError(null);
|
||||||
|
setCompatibleEndpointStatus(null);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setRuntimeError(null);
|
setRuntimeError(null);
|
||||||
|
setCompatibleEndpointError(null);
|
||||||
|
setCompatibleEndpointStatus(null);
|
||||||
}, [selectedProviderId]);
|
}, [selectedProviderId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -710,6 +758,15 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
const selectedApiKey = statusApiKeyConfig
|
const selectedApiKey = statusApiKeyConfig
|
||||||
? findPreferredApiKeyEntry(apiKeys, statusApiKeyConfig.envVarName)
|
? findPreferredApiKeyEntry(apiKeys, statusApiKeyConfig.envVarName)
|
||||||
: null;
|
: null;
|
||||||
|
const anthropicCompatibleConfig = appConfig?.providerConnections?.anthropic
|
||||||
|
.compatibleEndpoint ?? {
|
||||||
|
enabled: false,
|
||||||
|
baseUrl: '',
|
||||||
|
};
|
||||||
|
const selectedCompatibleToken = findPreferredApiKeyEntry(
|
||||||
|
apiKeys,
|
||||||
|
ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR
|
||||||
|
);
|
||||||
|
|
||||||
const selectedProvider = useMemo(() => {
|
const selectedProvider = useMemo(() => {
|
||||||
const mergedStatusProvider =
|
const mergedStatusProvider =
|
||||||
|
|
@ -729,6 +786,22 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
nextConnection.configuredAuthMode =
|
nextConnection.configuredAuthMode =
|
||||||
appConfig?.providerConnections?.anthropic.authMode ??
|
appConfig?.providerConnections?.anthropic.authMode ??
|
||||||
mergedStatusProvider.connection.configuredAuthMode;
|
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') {
|
if (mergedStatusProvider.providerId === 'codex') {
|
||||||
|
|
@ -754,14 +827,28 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
connection: nextConnection,
|
connection: nextConnection,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
anthropicCompatibleConfig.baseUrl,
|
||||||
|
anthropicCompatibleConfig.enabled,
|
||||||
appConfig?.providerConnections?.anthropic.authMode,
|
appConfig?.providerConnections?.anthropic.authMode,
|
||||||
appConfig?.providerConnections?.codex.preferredAuthMode,
|
appConfig?.providerConnections?.codex.preferredAuthMode,
|
||||||
codexAccount.snapshot,
|
codexAccount.snapshot,
|
||||||
|
selectedCompatibleToken,
|
||||||
selectedApiKey,
|
selectedApiKey,
|
||||||
statusApiKeyConfig,
|
statusApiKeyConfig,
|
||||||
statusSelectedProvider,
|
statusSelectedProvider,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || selectedProviderId !== 'anthropic') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompatibleBaseUrl(anthropicCompatibleConfig.baseUrl);
|
||||||
|
setCompatibleTokenValue('');
|
||||||
|
setCompatibleEndpointError(null);
|
||||||
|
setCompatibleEndpointStatus(null);
|
||||||
|
}, [anthropicCompatibleConfig.baseUrl, open, selectedProviderId]);
|
||||||
|
|
||||||
const selectedProviderLoading = selectedProvider
|
const selectedProviderLoading = selectedProvider
|
||||||
? providerStatusLoading[selectedProvider.providerId] === true
|
? providerStatusLoading[selectedProvider.providerId] === true
|
||||||
: false;
|
: false;
|
||||||
|
|
@ -884,9 +971,24 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
const canRequestSubscriptionLogin =
|
const canRequestSubscriptionLogin =
|
||||||
selectedProvider?.providerId === 'anthropic' &&
|
selectedProvider?.providerId === 'anthropic' &&
|
||||||
Boolean(selectedProvider.connection?.supportsOAuth && onRequestLogin) &&
|
Boolean(selectedProvider.connection?.supportsOAuth && onRequestLogin) &&
|
||||||
|
selectedProvider.connection?.compatibleEndpoint?.enabled !== true &&
|
||||||
configuredAuthMode !== 'api_key' &&
|
configuredAuthMode !== 'api_key' &&
|
||||||
selectedProvider.statusMessage !== 'Checking...' &&
|
selectedProvider.statusMessage !== 'Checking...' &&
|
||||||
(!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth');
|
(!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(() => {
|
useEffect(() => {
|
||||||
if (!showApiKeyForm) {
|
if (!showApiKeyForm) {
|
||||||
|
|
@ -932,6 +1034,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
return 'Switching to Anthropic subscription...';
|
return 'Switching to Anthropic subscription...';
|
||||||
case 'auto':
|
case 'auto':
|
||||||
return 'Switching to Auto...';
|
return 'Switching to Auto...';
|
||||||
|
case 'compatible':
|
||||||
|
return 'Saving compatible endpoint...';
|
||||||
default:
|
default:
|
||||||
return 'Applying connection changes...';
|
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> => {
|
const handleCodexAccountRefresh = async (): Promise<void> => {
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -1363,6 +1573,171 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
{configuredAuthMode && !hideConnectionMethodMeta ? (
|
{configuredAuthMode && !hideConnectionMethodMeta ? (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ import {
|
||||||
isGeminiUiFrozen,
|
isGeminiUiFrozen,
|
||||||
} from '@renderer/utils/geminiUiFreeze';
|
} from '@renderer/utils/geminiUiFreeze';
|
||||||
import {
|
import {
|
||||||
|
canUseCustomAnthropicCompatibleModel,
|
||||||
getAvailableTeamProviderModelOptions,
|
getAvailableTeamProviderModelOptions,
|
||||||
getOpenCodeOpenAiRouteAuthUnavailableReason,
|
getOpenCodeOpenAiRouteAuthUnavailableReason,
|
||||||
getTeamModelUiDisabledReason,
|
getTeamModelUiDisabledReason,
|
||||||
|
isAnthropicCompatibleRuntime,
|
||||||
isTeamProviderModelVerificationPending,
|
isTeamProviderModelVerificationPending,
|
||||||
normalizeTeamModelForUi,
|
normalizeTeamModelForUi,
|
||||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||||
|
|
@ -745,6 +747,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
);
|
);
|
||||||
const defaultModelTooltip = useMemo(() => {
|
const defaultModelTooltip = useMemo(() => {
|
||||||
if (effectiveProviderId === 'anthropic') {
|
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 =
|
const defaultLongContextModel =
|
||||||
getRuntimeAwareProviderScopedTeamModelLabel(
|
getRuntimeAwareProviderScopedTeamModelLabel(
|
||||||
'anthropic',
|
'anthropic',
|
||||||
|
|
@ -879,6 +891,23 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
}
|
}
|
||||||
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
|
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
|
||||||
}, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
|
}, [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 openCodeCatalogModelById = useMemo(() => {
|
||||||
const catalog = runtimeProviderStatus?.modelCatalog;
|
const catalog = runtimeProviderStatus?.modelCatalog;
|
||||||
const modelById = new Map<string, ProviderModelCatalogItem>();
|
const modelById = new Map<string, ProviderModelCatalogItem>();
|
||||||
|
|
@ -1620,6 +1649,30 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
list is syncing.
|
list is syncing.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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 ? (
|
{shouldShowModelSearch ? (
|
||||||
<div className="relative mb-2">
|
<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)]" />
|
<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'
|
| 'detailMessage'
|
||||||
| 'availableBackends'
|
| 'availableBackends'
|
||||||
| 'externalRuntimeDiagnostics'
|
| 'externalRuntimeDiagnostics'
|
||||||
|
| 'connection'
|
||||||
> &
|
> &
|
||||||
Partial<Pick<CliProviderStatus, 'verificationState' | 'statusMessage'>>;
|
Partial<Pick<CliProviderStatus, 'verificationState' | 'statusMessage'>>;
|
||||||
|
|
||||||
|
|
@ -230,6 +231,58 @@ function hasAnthropicRuntimeCatalog(
|
||||||
return providerStatus?.modelCatalog?.providerId === 'anthropic';
|
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(
|
function getAnthropicCatalogModel(
|
||||||
model: string,
|
model: string,
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
|
|
@ -247,16 +300,20 @@ function getRuntimeCatalogModels(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): string[] | null {
|
): string[] | null {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
return null;
|
if (!hasAnthropicCompatibleRuntimeCatalog(providerStatus)) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
if (
|
} else if (
|
||||||
(providerId !== 'codex' && providerId !== 'opencode') ||
|
(providerId !== 'codex' && providerId !== 'opencode') ||
|
||||||
providerStatus?.modelCatalog?.providerId !== providerId
|
providerStatus?.modelCatalog?.providerId !== providerId
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!providerStatus?.modelCatalog) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const models = providerStatus.modelCatalog.models
|
const models = providerStatus.modelCatalog.models
|
||||||
.filter((model) => !model.hidden)
|
.filter((model) => !model.hidden)
|
||||||
.map((model) => model.launchModel.trim() || model.id.trim())
|
.map((model) => model.launchModel.trim() || model.id.trim())
|
||||||
|
|
@ -269,7 +326,10 @@ function getRuntimeCatalogModelOption(
|
||||||
model: string,
|
model: string,
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): TeamRuntimeModelOption | 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,8 +340,9 @@ function getRuntimeCatalogModelOption(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchModel = catalogModel.launchModel.trim() || catalogModel.id.trim();
|
||||||
return {
|
return {
|
||||||
value: catalogModel.launchModel,
|
value: launchModel,
|
||||||
label:
|
label:
|
||||||
getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ??
|
getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ??
|
||||||
catalogModel.displayName,
|
catalogModel.displayName,
|
||||||
|
|
@ -290,12 +351,8 @@ function getRuntimeCatalogModelOption(
|
||||||
(getTeamProviderModelOptions(providerId).some((option) => option.value === model)
|
(getTeamProviderModelOptions(providerId).some((option) => option.value === model)
|
||||||
? undefined
|
? undefined
|
||||||
: 'New'),
|
: 'New'),
|
||||||
availabilityStatus: getRuntimeModelAvailability(
|
availabilityStatus: getRuntimeModelAvailability(providerId, launchModel, providerStatus),
|
||||||
providerId,
|
availabilityReason: getRuntimeModelAvailabilityReason(launchModel, providerStatus),
|
||||||
catalogModel.launchModel,
|
|
||||||
providerStatus
|
|
||||||
),
|
|
||||||
availabilityReason: getRuntimeModelAvailabilityReason(catalogModel.launchModel, providerStatus),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,6 +366,10 @@ function getRuntimeSelectorModels(
|
||||||
|
|
||||||
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
|
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
|
||||||
if (catalogModels) {
|
if (catalogModels) {
|
||||||
|
if (providerId === 'anthropic') {
|
||||||
|
return sortTeamProviderModels(providerId, catalogModels, providerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
const sourceModels =
|
const sourceModels =
|
||||||
providerId === 'opencode'
|
providerId === 'opencode'
|
||||||
? mergeModelLists(catalogModels, providerStatus.models)
|
? mergeModelLists(catalogModels, providerStatus.models)
|
||||||
|
|
@ -316,6 +377,10 @@ function getRuntimeSelectorModels(
|
||||||
return getVisibleTeamProviderModels(providerId, sourceModels, providerStatus);
|
return getVisibleTeamProviderModels(providerId, sourceModels, providerStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (providerId === 'anthropic' && isAnthropicCompatibleRuntime(providerStatus)) {
|
||||||
|
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
|
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,6 +449,18 @@ function getRuntimeModelAvailability(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): CliProviderModelAvailabilityStatus | null {
|
): CliProviderModelAvailabilityStatus | null {
|
||||||
if (providerId === 'anthropic') {
|
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)) {
|
if (!providerStatus || !hasAnthropicRuntimeCatalog(providerStatus)) {
|
||||||
return isSupportedAnthropicTeamModel(model) ? 'available' : null;
|
return isSupportedAnthropicTeamModel(model) ? 'available' : null;
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +495,9 @@ export function getTeamProviderModelVerificationCounts(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): TeamProviderModelVerificationCounts {
|
): TeamProviderModelVerificationCounts {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
const visibleAnthropicModels = getFallbackTeamProviderModels(providerId);
|
const visibleAnthropicModels = isAnthropicCompatibleRuntime(providerStatus)
|
||||||
|
? getRuntimeSelectorModels(providerId, providerStatus)
|
||||||
|
: getFallbackTeamProviderModels(providerId);
|
||||||
return {
|
return {
|
||||||
checkedCount: visibleAnthropicModels.length,
|
checkedCount: visibleAnthropicModels.length,
|
||||||
totalCount: visibleAnthropicModels.length,
|
totalCount: visibleAnthropicModels.length,
|
||||||
|
|
@ -440,6 +519,12 @@ export function getAvailableTeamProviderModels(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): string[] {
|
): string[] {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||||
|
return getVisibleRuntimeModels(providerId, providerStatus).filter(
|
||||||
|
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return getFallbackTeamProviderModels(providerId).filter(
|
return getFallbackTeamProviderModels(providerId).filter(
|
||||||
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
|
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
|
||||||
);
|
);
|
||||||
|
|
@ -461,6 +546,27 @@ export function getAvailableTeamProviderModelOptions(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): TeamRuntimeModelOption[] {
|
): TeamRuntimeModelOption[] {
|
||||||
if (providerId === 'anthropic') {
|
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) => ({
|
return getFallbackTeamProviderModelOptions(providerId, providerStatus).map((option) => ({
|
||||||
...option,
|
...option,
|
||||||
availabilityStatus:
|
availabilityStatus:
|
||||||
|
|
@ -538,6 +644,13 @@ export function isTeamModelAvailableForUi(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||||
|
return (
|
||||||
|
getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available' ||
|
||||||
|
canUseCustomAnthropicCompatibleModel(providerStatus)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isSupportedAnthropicTeamModel(trimmed)) {
|
if (!isSupportedAnthropicTeamModel(trimmed)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -576,6 +689,10 @@ export function normalizeTeamModelForUi(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
if (isAnthropicCompatibleRuntime(providerStatus)) {
|
||||||
|
return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : '';
|
||||||
|
}
|
||||||
|
|
||||||
return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : '';
|
return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ export interface CliProviderConnectionInfo {
|
||||||
apiKeyConfigured: boolean;
|
apiKeyConfigured: boolean;
|
||||||
apiKeySource: 'stored' | 'environment' | null;
|
apiKeySource: 'stored' | 'environment' | null;
|
||||||
apiKeySourceLabel?: string | null;
|
apiKeySourceLabel?: string | null;
|
||||||
|
compatibleEndpoint?: {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
tokenConfigured: boolean;
|
||||||
|
tokenSource: 'stored' | 'environment' | null;
|
||||||
|
tokenSourceLabel?: string | null;
|
||||||
|
} | null;
|
||||||
codex?: {
|
codex?: {
|
||||||
preferredAuthMode: CodexAccountAuthMode;
|
preferredAuthMode: CodexAccountAuthMode;
|
||||||
effectiveAuthMode: CodexAccountEffectiveAuthMode;
|
effectiveAuthMode: CodexAccountEffectiveAuthMode;
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,10 @@ export interface AppConfig {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
authMode: 'auto' | 'oauth' | 'api_key';
|
authMode: 'auto' | 'oauth' | 'api_key';
|
||||||
fastModeDefault: boolean;
|
fastModeDefault: boolean;
|
||||||
|
compatibleEndpoint: {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
codex: {
|
codex: {
|
||||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ vi.mock('@main/utils/childProcess', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@main/utils/shellEnv', () => ({
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
|
resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
|
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 * as path from 'path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { validateConfigUpdatePayload } from '../../../src/main/ipc/configValidation';
|
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', () => {
|
it('normalizes legacy Codex runtime backend updates to codex-native', () => {
|
||||||
const apiResult = validateConfigUpdatePayload('runtime', {
|
const apiResult = validateConfigUpdatePayload('runtime', {
|
||||||
providerBackends: {
|
providerBackends: {
|
||||||
|
|
|
||||||
|
|
@ -88,4 +88,217 @@ describe('ConfigManager Codex migration hardening', () => {
|
||||||
expect(persisted.runtime.providerBackends.codex).toBe('codex-native');
|
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
|
// @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 {
|
import {
|
||||||
getProviderConnectionModeSummary,
|
getProviderConnectionModeSummary,
|
||||||
getProviderCurrentRuntimeSummary,
|
getProviderCurrentRuntimeSummary,
|
||||||
isConnectionManagedRuntimeProvider,
|
isConnectionManagedRuntimeProvider,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} 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 execCliMock = vi.fn();
|
||||||
const buildProviderAwareCliEnvMock = vi.fn();
|
const buildProviderAwareCliEnvMock = vi.fn();
|
||||||
|
|
@ -379,6 +380,58 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
vi.mocked(console.warn).mockClear();
|
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 () => {
|
it('loads frontend providers with parallel provider-scoped runtime status probes', async () => {
|
||||||
const providerPayloads = {
|
const providerPayloads = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
|
|
@ -1386,6 +1439,13 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
verificationState: 'error',
|
verificationState: 'error',
|
||||||
});
|
});
|
||||||
expect(provider.statusMessage).toContain('ANTHROPIC_API_KEY');
|
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 () => {
|
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 originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||||
const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
|
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 {
|
return {
|
||||||
providerConnections: {
|
providerConnections: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
authMode,
|
authMode,
|
||||||
|
fastModeDefault: false,
|
||||||
|
compatibleEndpoint,
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
preferredAuthMode: 'auto' as const,
|
preferredAuthMode: 'auto' as const,
|
||||||
|
|
@ -217,6 +222,58 @@ describe('ProviderConnectionService', () => {
|
||||||
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
|
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 () => {
|
it('injects the stored Anthropic API key when api_key mode is selected', async () => {
|
||||||
const lookupPreferred = vi.fn().mockResolvedValue({
|
const lookupPreferred = vi.fn().mockResolvedValue({
|
||||||
envVarName: 'ANTHROPIC_API_KEY',
|
envVarName: 'ANTHROPIC_API_KEY',
|
||||||
|
|
@ -278,6 +335,262 @@ describe('ProviderConnectionService', () => {
|
||||||
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
|
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 () => {
|
it('does not decrypt stored Anthropic keys when metadata-only env building is requested', async () => {
|
||||||
const lookupPreferred = vi.fn().mockResolvedValue({
|
const lookupPreferred = vi.fn().mockResolvedValue({
|
||||||
envVarName: 'ANTHROPIC_API_KEY',
|
envVarName: 'ANTHROPIC_API_KEY',
|
||||||
|
|
@ -1986,6 +2299,61 @@ describe('ProviderConnectionService', () => {
|
||||||
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_API_KEY');
|
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 () => {
|
it('does not use token-only or OAuth credentials for Anthropic team helper mode', async () => {
|
||||||
const { ProviderConnectionService } =
|
const { ProviderConnectionService } =
|
||||||
await import('@main/services/runtime/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 () => {
|
it('builds shared env for generic CLI launches when no provider is specified', async () => {
|
||||||
const { buildProviderAwareCliEnv } =
|
const { buildProviderAwareCliEnv } =
|
||||||
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||||
|
|
||||||
vi.mock('@main/utils/shellEnv', () => ({
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
resolveInteractiveShellEnv: vi.fn(),
|
resolveInteractiveShellEnv: vi.fn(),
|
||||||
|
resolveInteractiveShellEnvBestEffort: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
||||||
|
|
@ -36,7 +37,7 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||||
|
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
type CodexProbeHarness = TeamProvisioningService & {
|
type CodexProbeHarness = TeamProvisioningService & {
|
||||||
probeClaudeRuntime: (
|
probeClaudeRuntime: (
|
||||||
|
|
@ -62,7 +63,7 @@ describe('TeamProvisioningService Codex create-team preflight', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-codex-preflight-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-codex-preflight-'));
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
|
||||||
PATH: '/usr/bin',
|
PATH: '/usr/bin',
|
||||||
SHELL: '/bin/zsh',
|
SHELL: '/bin/zsh',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||||
|
|
||||||
vi.mock('@main/utils/shellEnv', () => ({
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
resolveInteractiveShellEnv: vi.fn(),
|
resolveInteractiveShellEnv: vi.fn(),
|
||||||
|
resolveInteractiveShellEnvBestEffort: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const buildProviderAwareCliEnvMock = vi.fn();
|
const buildProviderAwareCliEnvMock = vi.fn();
|
||||||
|
|
@ -102,7 +103,7 @@ import {
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
} from '@main/services/team/TeamProvisioningService';
|
} from '@main/services/team/TeamProvisioningService';
|
||||||
import { spawnCli } from '@main/utils/childProcess';
|
import { spawnCli } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } {
|
function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } {
|
||||||
const workspaceRoot = process.cwd();
|
const workspaceRoot = process.cwd();
|
||||||
|
|
@ -335,7 +336,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
addTeamNotificationMock.mockResolvedValue(null);
|
addTeamNotificationMock.mockResolvedValue(null);
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
|
||||||
PATH: '/usr/bin',
|
PATH: '/usr/bin',
|
||||||
SHELL: '/bin/zsh',
|
SHELL: '/bin/zsh',
|
||||||
});
|
});
|
||||||
|
|
@ -405,6 +406,57 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
expect(assignments).toContain("CLAUDE_CODE_ENTRY_PROVIDER='anthropic'");
|
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 () => {
|
it('does not flatten Anthropic helper settings into non-Anthropic lead cross-provider args', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const helperSettingsPath = path.join(tempRoot, 'team-runtime-auth', 'helper-settings.json');
|
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 () => {
|
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
|
||||||
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
|
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
|
||||||
PATH: '/usr/bin',
|
PATH: '/usr/bin',
|
||||||
SHELL: '/bin/zsh',
|
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 () => {
|
it('preserves Anthropic-compatible Ollama auth token without mapping it into ANTHROPIC_API_KEY', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
|
||||||
ANTHROPIC_BASE_URL: 'http://localhost:11434',
|
ANTHROPIC_BASE_URL: 'http://localhost:11434',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ollama',
|
ANTHROPIC_AUTH_TOKEN: 'ollama',
|
||||||
ANTHROPIC_API_KEY: '',
|
ANTHROPIC_API_KEY: '',
|
||||||
|
|
@ -3205,9 +3257,45 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
expect(result.env.ANTHROPIC_API_KEY).toBe('');
|
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 () => {
|
it('prefers explicit ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
vi.mocked(resolveInteractiveShellEnvBestEffort).mockResolvedValue({
|
||||||
ANTHROPIC_API_KEY: 'real-key',
|
ANTHROPIC_API_KEY: 'real-key',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
|
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
|
||||||
PATH: '/usr/bin',
|
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 () => {
|
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ interface StoreState {
|
||||||
providerConnections: {
|
providerConnections: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
authMode: 'auto' | 'oauth' | 'api_key';
|
authMode: 'auto' | 'oauth' | 'api_key';
|
||||||
|
fastModeDefault: boolean;
|
||||||
|
compatibleEndpoint: {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
codex: {
|
codex: {
|
||||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||||
|
|
@ -317,6 +322,13 @@ function createAnthropicProvider(
|
||||||
apiKeyConfigured: overrides?.apiKeyConfigured ?? false,
|
apiKeyConfigured: overrides?.apiKeyConfigured ?? false,
|
||||||
apiKeySource: overrides?.apiKeySource ?? null,
|
apiKeySource: overrides?.apiKeySource ?? null,
|
||||||
apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null,
|
apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null,
|
||||||
|
compatibleEndpoint: overrides?.compatibleEndpoint ?? {
|
||||||
|
enabled: false,
|
||||||
|
baseUrl: '',
|
||||||
|
tokenConfigured: false,
|
||||||
|
tokenSource: null,
|
||||||
|
tokenSourceLabel: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +479,11 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
||||||
providerConnections: {
|
providerConnections: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
authMode: 'auto',
|
authMode: 'auto',
|
||||||
|
fastModeDefault: false,
|
||||||
|
compatibleEndpoint: {
|
||||||
|
enabled: false,
|
||||||
|
baseUrl: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
preferredAuthMode: 'auto',
|
preferredAuthMode: 'auto',
|
||||||
|
|
@ -493,6 +510,10 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
...storeState.appConfig.providerConnections.anthropic,
|
...storeState.appConfig.providerConnections.anthropic,
|
||||||
...(nextProviderConnections.anthropic ?? {}),
|
...(nextProviderConnections.anthropic ?? {}),
|
||||||
|
compatibleEndpoint: {
|
||||||
|
...storeState.appConfig.providerConnections.anthropic.compatibleEndpoint,
|
||||||
|
...(nextProviderConnections.anthropic?.compatibleEndpoint ?? {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
...storeState.appConfig.providerConnections.codex,
|
...storeState.appConfig.providerConnections.codex,
|
||||||
|
|
@ -685,6 +706,259 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
||||||
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
|
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 () => {
|
it('shows native-only Codex connection copy and API-key management without login actions', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeEffectiveTeamModel,
|
computeEffectiveTeamModel,
|
||||||
formatTeamModelSummary,
|
formatTeamModelSummary,
|
||||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||||
import {
|
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,
|
getAvailableTeamProviderModels,
|
||||||
getTeamModelSelectionError,
|
getTeamModelSelectionError,
|
||||||
getTeamModelUiDisabledReason,
|
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,
|
normalizeTeamModelForUi,
|
||||||
} from '@renderer/utils/teamModelAvailability';
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('formatTeamModelSummary', () => {
|
describe('formatTeamModelSummary', () => {
|
||||||
it('shows cross-provider Anthropic models as backend-routed instead of brand-mismatched', () => {
|
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', () => {
|
it('returns non-anthropic models as-is', () => {
|
||||||
expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4');
|
expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4');
|
||||||
expect(computeEffectiveTeamModel('custom-model[1m]', false, 'codex')).toBe('custom-model[1m]');
|
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 () => {
|
it('labels, sorts, and filters OpenCode models with real Agent Teams E2E recommendations', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliStatus = {
|
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', () => {
|
describe('teamModelAvailability', () => {
|
||||||
it('uses runtime-reported Codex models as the source of truth', () => {
|
it('uses runtime-reported Codex models as the source of truth', () => {
|
||||||
const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']);
|
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-opus-4-7')).toBeNull();
|
||||||
expect(getTeamModelSelectionError('anthropic', 'claude-haiku-4-5-20251001')).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');
|
).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', () => {
|
it('honors explicit 1M Sonnet selections unless 200K context is requested', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveAnthropicLaunchModel({
|
resolveAnthropicLaunchModel({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue