cc-haha/src/server/services/providerRuntimeEnv.ts
程序员阿江(Relakkes) 3a42ca2067 Prevent stale provider proxy env in direct TUI launches
Direct TUI launches read cc-haha managed settings without the desktop host's session-env injection, so a stale proxy URL could survive after switching to an Anthropic-compatible provider. The provider runtime env calculation now has one shared source of truth and CLI config loading recomputes managed provider env from the active provider index before applying it.

Constraint: Direct source-based TUI usage must keep working with .env when no desktop provider is configured
Rejected: Inject desktop runtime only from the embedded terminal shell | native terminals and source TUI launches would still read stale managed settings
Confidence: high
Scope-risk: moderate
Directive: Keep provider env derivation shared between ProviderService and CLI managedEnv loading
Tested: bun test src/server/__tests__/provider-runtime-env.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/conversation-service.test.ts
Tested: bun run check:server
Not-tested: Live provider request from embedded terminal TUI
2026-05-20 00:11:22 +08:00

275 lines
8.5 KiB
TypeScript

import * as fs from 'fs'
import * as path from 'path'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import type {
ApiFormat,
ProviderAuthStrategy,
ProvidersIndex,
SavedProvider,
} from '../types/provider.js'
import {
ATTRIBUTION_HEADER_ENV_KEY,
attributionHeaderEnvForModel,
} from './attributionHeaderPolicy.js'
import {
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
OPENAI_OAUTH_PROVIDER_ENV_KEY,
buildOpenAIOfficialRuntimeEnv,
isOpenAIOfficialProviderId,
} from './openaiOfficialProvider.js'
export const MANAGED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
ATTRIBUTION_HEADER_ENV_KEY,
MODEL_CONTEXT_WINDOWS_ENV_KEY,
OPENAI_OAUTH_PROVIDER_ENV_KEY,
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
] as const
const CUSTOM_PROVIDER_MODEL_CAPABILITIES = 'thinking,effort,adaptive_thinking,max_effort'
const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isProviderModels(value: unknown): value is SavedProvider['models'] {
return (
isRecord(value) &&
typeof value.main === 'string' &&
typeof value.haiku === 'string' &&
typeof value.sonnet === 'string' &&
typeof value.opus === 'string'
)
}
function isSavedProvider(value: unknown): value is SavedProvider {
if (!isRecord(value)) return false
const runtimeKind = value.runtimeKind
return (
typeof value.id === 'string' &&
typeof value.presetId === 'string' &&
typeof value.name === 'string' &&
typeof value.apiKey === 'string' &&
typeof value.baseUrl === 'string' &&
(
runtimeKind === undefined ||
runtimeKind === 'anthropic_compatible' ||
runtimeKind === 'openai_oauth'
) &&
isProviderModels(value.models)
)
}
export function normalizeModelMapping(models: SavedProvider['models']): SavedProvider['models'] {
const main = models.main.trim()
return {
main,
haiku: models.haiku.trim() || main,
sonnet: models.sonnet.trim() || main,
opus: models.opus.trim() || main,
}
}
export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
return {
...provider,
apiFormat: provider.apiFormat ?? 'anthropic',
runtimeKind: provider.runtimeKind ?? 'anthropic_compatible',
models: normalizeModelMapping(provider.models),
}
}
export function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
if (!isRecord(value) || !Array.isArray(value.providers)) {
return null
}
const { activeProviderId: legacyActiveProviderId, ...rest } = value
const providers = value.providers
.filter(isSavedProvider)
.map((provider) => normalizeSavedProvider(provider))
const rawActiveId =
typeof value.activeId === 'string'
? value.activeId
: typeof legacyActiveProviderId === 'string'
? legacyActiveProviderId
: null
const activeId = rawActiveId && (
providers.some((provider) => provider.id === rawActiveId) ||
isOpenAIOfficialProviderId(rawActiveId)
)
? rawActiveId
: null
return {
...rest,
schemaVersion: typeof value.schemaVersion === 'number' ? value.schemaVersion : 1,
activeId,
providers,
}
}
export function getPresetDefaultEnv(presetId: string): Record<string, string> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {}
}
function omitAuthEnv(env: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())),
)
}
export function getPresetAuthStrategy(presetId: string): ProviderAuthStrategy {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.authStrategy ?? 'auth_token'
}
function getPresetModelContextWindows(presetId: string): Record<string, number> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.modelContextWindows ?? {}
}
export function buildProviderAuthEnv(
provider: SavedProvider,
presetDefaultEnv: Record<string, string>,
needsProxy: boolean,
): Record<string, string> {
if (needsProxy) {
return { ANTHROPIC_API_KEY: 'proxy-managed' }
}
const strategy = provider.authStrategy ?? getPresetAuthStrategy(provider.presetId)
const key = provider.apiKey || presetDefaultEnv.ANTHROPIC_AUTH_TOKEN || presetDefaultEnv.ANTHROPIC_API_KEY || ''
switch (strategy) {
case 'api_key':
return key ? { ANTHROPIC_API_KEY: key } : {}
case 'auth_token':
case 'auth_token_empty_api_key':
return {
ANTHROPIC_API_KEY: '',
...(key ? { ANTHROPIC_AUTH_TOKEN: key } : {}),
}
case 'dual_same_token':
return key ? { ANTHROPIC_API_KEY: key, ANTHROPIC_AUTH_TOKEN: key } : {}
case 'dual_dummy':
return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' }
}
}
export function getManagedEnvKeys(): string[] {
const keys = new Set<string>(MANAGED_PROVIDER_ENV_KEYS)
for (const preset of PROVIDER_PRESETS) {
for (const key of Object.keys(preset.defaultEnv ?? {})) {
keys.add(key)
}
}
return [...keys]
}
export function buildProviderManagedEnv(
provider: SavedProvider,
options?: { proxyPath?: string; serverPort?: number },
): Record<string, string> {
if (provider.runtimeKind === 'openai_oauth') {
return buildOpenAIOfficialRuntimeEnv()
}
const apiFormat: ApiFormat = provider.apiFormat ?? 'anthropic'
const needsProxy = apiFormat !== 'anthropic'
const proxyPath = options?.proxyPath ?? '/proxy'
const serverPort = options?.serverPort ?? 3456
const baseUrl = needsProxy
? `http://127.0.0.1:${serverPort}${proxyPath}`
: provider.baseUrl
const models = normalizeModelMapping(provider.models)
const modelContextWindows = {
...getPresetModelContextWindows(provider.presetId),
...(provider.modelContextWindows ?? {}),
}
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
const customProviderCapabilityEnv =
provider.presetId === 'custom'
? {
ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES: CUSTOM_PROVIDER_MODEL_CAPABILITIES,
ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES: CUSTOM_PROVIDER_MODEL_CAPABILITIES,
ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES: CUSTOM_PROVIDER_MODEL_CAPABILITIES,
}
: {}
return {
...omitAuthEnv(presetDefaultEnv),
...customProviderCapabilityEnv,
...(provider.autoCompactWindow !== undefined && {
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(provider.autoCompactWindow),
}),
...(Object.keys(modelContextWindows).length > 0 && {
[MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows),
}),
ANTHROPIC_BASE_URL: baseUrl,
...buildProviderAuthEnv(provider, presetDefaultEnv, needsProxy),
ANTHROPIC_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
...attributionHeaderEnvForModel(models.main),
}
}
export function readActiveProviderManagedEnv(
configDir: string,
options?: { serverPort?: number },
): Record<string, string> | null {
try {
const raw = fs.readFileSync(path.join(configDir, 'cc-haha', 'providers.json'), 'utf-8')
const index = normalizeProvidersIndex(JSON.parse(raw))
if (!index?.activeId) return null
if (isOpenAIOfficialProviderId(index.activeId)) {
return buildOpenAIOfficialRuntimeEnv()
}
const provider = index.providers.find((entry) => entry.id === index.activeId)
if (!provider) return null
return buildProviderManagedEnv(provider, {
serverPort: options?.serverPort,
})
} catch {
return null
}
}
export function mergeActiveProviderManagedEnv(
settingsEnv: Record<string, string>,
configDir: string,
options?: { serverPort?: number },
): Record<string, string> {
const activeProviderEnv = readActiveProviderManagedEnv(configDir, options)
if (!activeProviderEnv) {
return settingsEnv
}
const cleanedEnv = { ...settingsEnv }
for (const key of getManagedEnvKeys()) {
delete cleanedEnv[key]
}
return {
...cleanedEnv,
...activeProviderEnv,
}
}