mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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
275 lines
8.5 KiB
TypeScript
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,
|
|
}
|
|
}
|