mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
890 lines
32 KiB
TypeScript
890 lines
32 KiB
TypeScript
/**
|
||
* Provider Service — preset-based provider configuration
|
||
*
|
||
* Storage: ~/.claude/cc-haha/providers.json (lightweight index)
|
||
* Active provider env vars written to ~/.claude/cc-haha/settings.json
|
||
* (isolated from the original Claude Code's ~/.claude/settings.json)
|
||
*/
|
||
|
||
import * as fs from 'fs/promises'
|
||
import * as path from 'path'
|
||
import * as os from 'os'
|
||
import { ApiError } from '../middleware/errorHandler.js'
|
||
import { readRecoverableJsonFile } from './recoverableJsonFile.js'
|
||
import { ManagedSettingsService } from './managedSettingsService.js'
|
||
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
|
||
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
|
||
import { hoistToolResultMediaForCompatibility } from '../proxy/transform/anthropicMediaHoist.js'
|
||
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
|
||
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
|
||
import type { AnthropicRequest } from '../proxy/transform/types.js'
|
||
import {
|
||
OPENAI_OFFICIAL_PROVIDER,
|
||
isOpenAIOfficialProviderId,
|
||
} from './openaiOfficialProvider.js'
|
||
import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js'
|
||
import { hahaOAuthService } from './hahaOAuthService.js'
|
||
import {
|
||
GROK_OFFICIAL_PROVIDER,
|
||
isGrokOfficialProviderId,
|
||
} from './grokOfficialProvider.js'
|
||
import { hahaGrokOAuthService } from './hahaGrokOAuthService.js'
|
||
import {
|
||
CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||
ensurePersistentStorageUpgraded,
|
||
} from './persistentStorageMigrations.js'
|
||
import {
|
||
buildProviderAuthEnv,
|
||
buildProviderManagedEnv,
|
||
getManagedEnvKeys,
|
||
getPresetAuthStrategy,
|
||
getPresetDefaultEnv,
|
||
normalizeImageGeneration,
|
||
normalizeModelMapping,
|
||
normalizeProvidersIndex,
|
||
providerNeedsProxy,
|
||
resolveProviderApiKey,
|
||
} from './providerRuntimeEnv.js'
|
||
import {
|
||
getNetworkProxyFetchOptions,
|
||
loadNetworkSettings,
|
||
type NetworkSettings,
|
||
} from './networkSettings.js'
|
||
import { normalizeModelStringForAPI } from '../../utils/model/model.js'
|
||
import type {
|
||
SavedProvider,
|
||
ProvidersIndex,
|
||
CreateProviderInput,
|
||
UpdateProviderInput,
|
||
TestProviderInput,
|
||
ProviderTestResult,
|
||
ProviderTestStepResult,
|
||
ApiFormat,
|
||
ProviderAuthStrategy,
|
||
} from '../types/provider.js'
|
||
import {
|
||
BUILT_IN_PROVIDER_IDS,
|
||
} from '../types/provider.js'
|
||
|
||
const DEFAULT_INDEX: ProvidersIndex = {
|
||
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||
activeId: null,
|
||
providers: [],
|
||
providerOrder: [...BUILT_IN_PROVIDER_IDS],
|
||
}
|
||
|
||
function isPermutation(candidateIds: string[], expectedIds: string[]): boolean {
|
||
const expectedSet = new Set(expectedIds)
|
||
const candidateSet = new Set(candidateIds)
|
||
return (
|
||
candidateIds.length === expectedIds.length &&
|
||
candidateSet.size === candidateIds.length &&
|
||
expectedIds.every((id) => candidateSet.has(id)) &&
|
||
candidateIds.every((id) => expectedSet.has(id))
|
||
)
|
||
}
|
||
|
||
function savedProviderIds(providers: SavedProvider[]): string[] {
|
||
return providers.map((provider) => provider.id)
|
||
}
|
||
|
||
function fullProviderOrderIds(providers: SavedProvider[]): string[] {
|
||
return [
|
||
...savedProviderIds(providers),
|
||
...BUILT_IN_PROVIDER_IDS,
|
||
]
|
||
}
|
||
|
||
function sortSavedProvidersByOrder(providers: SavedProvider[], providerOrder: string[]): SavedProvider[] {
|
||
const byId = new Map(providers.map((provider) => [provider.id, provider]))
|
||
return providerOrder
|
||
.map((id) => byId.get(id))
|
||
.filter((provider): provider is SavedProvider => provider !== undefined)
|
||
}
|
||
|
||
function mergeSavedOrderIntoDisplayOrder(providerOrder: string[], savedOrder: string[]): string[] {
|
||
const savedSet = new Set(savedOrder)
|
||
const queue = [...savedOrder]
|
||
return providerOrder.map((id) => {
|
||
if (!savedSet.has(id)) return id
|
||
return queue.shift() ?? id
|
||
})
|
||
}
|
||
|
||
function buildSavedProvider(input: CreateProviderInput): SavedProvider {
|
||
const imageGeneration = normalizeImageGeneration(input.imageGeneration)
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
presetId: input.presetId,
|
||
name: input.name,
|
||
apiKey: input.apiKey,
|
||
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
|
||
baseUrl: input.baseUrl,
|
||
apiFormat: input.apiFormat ?? 'anthropic',
|
||
runtimeKind: input.runtimeKind ?? 'anthropic_compatible',
|
||
models: normalizeModelMapping(input.models),
|
||
...(input.model1mSupport !== undefined && { model1mSupport: input.model1mSupport }),
|
||
...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }),
|
||
...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }),
|
||
toolSearchEnabled: input.toolSearchEnabled ?? false,
|
||
...(input.disableExperimentalBetas === true && { disableExperimentalBetas: true }),
|
||
...(input.supportsNestedToolResultMedia !== undefined && { supportsNestedToolResultMedia: input.supportsNestedToolResultMedia }),
|
||
...(imageGeneration !== undefined && { imageGeneration }),
|
||
...(input.notes !== undefined && { notes: input.notes }),
|
||
}
|
||
}
|
||
|
||
function appendNewProviderToOrder(providerOrder: string[], providerId: string, existingProviders: SavedProvider[]): string[] {
|
||
const existingProviderIds = new Set(existingProviders.map((provider) => provider.id))
|
||
const lastSavedIndex = providerOrder.reduce(
|
||
(latest, id, index) => existingProviderIds.has(id) ? index : latest,
|
||
-1,
|
||
)
|
||
if (lastSavedIndex !== -1) {
|
||
return [
|
||
...providerOrder.slice(0, lastSavedIndex + 1),
|
||
providerId,
|
||
...providerOrder.slice(lastSavedIndex + 1),
|
||
]
|
||
}
|
||
|
||
const firstBuiltInIndex = providerOrder.findIndex((id) => BUILT_IN_PROVIDER_IDS.includes(id as typeof BUILT_IN_PROVIDER_IDS[number]))
|
||
if (firstBuiltInIndex === -1) return [...providerOrder, providerId]
|
||
return [
|
||
...providerOrder.slice(0, firstBuiltInIndex),
|
||
providerId,
|
||
...providerOrder.slice(firstBuiltInIndex),
|
||
]
|
||
}
|
||
|
||
export class ProviderService {
|
||
private static serverPort = 3456
|
||
private managedSettingsService = new ManagedSettingsService()
|
||
|
||
static setServerPort(port: number): void {
|
||
ProviderService.serverPort = port
|
||
}
|
||
|
||
static getServerPort(): number {
|
||
return ProviderService.serverPort
|
||
}
|
||
private getConfigDir(): string {
|
||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||
}
|
||
|
||
private getCcHahaDir(): string {
|
||
return path.join(this.getConfigDir(), 'cc-haha')
|
||
}
|
||
|
||
private getIndexPath(): string {
|
||
return path.join(this.getCcHahaDir(), 'providers.json')
|
||
}
|
||
|
||
private async readIndex(): Promise<ProvidersIndex> {
|
||
await ensurePersistentStorageUpgraded()
|
||
return readRecoverableJsonFile({
|
||
filePath: this.getIndexPath(),
|
||
label: 'providers index',
|
||
defaultValue: DEFAULT_INDEX,
|
||
normalize: normalizeProvidersIndex,
|
||
})
|
||
}
|
||
|
||
private async writeIndex(index: ProvidersIndex): Promise<void> {
|
||
const filePath = this.getIndexPath()
|
||
const dir = path.dirname(filePath)
|
||
await fs.mkdir(dir, { recursive: true })
|
||
|
||
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
||
try {
|
||
await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8')
|
||
await fs.rename(tmpFile, filePath)
|
||
} catch (err) {
|
||
await fs.unlink(tmpFile).catch(() => {})
|
||
throw ApiError.internal(`Failed to write providers index: ${err}`)
|
||
}
|
||
}
|
||
|
||
private async readSettings(): Promise<Record<string, unknown>> {
|
||
return this.managedSettingsService.readSettings()
|
||
}
|
||
|
||
async getManagedSettings(): Promise<Record<string, unknown>> {
|
||
return this.readSettings()
|
||
}
|
||
|
||
async updateManagedSettings(settings: Record<string, unknown>): Promise<void> {
|
||
await this.managedSettingsService.updateSettings((current) => ({
|
||
settings: Object.assign({}, current, settings),
|
||
result: undefined,
|
||
}))
|
||
}
|
||
|
||
// --- CRUD ---
|
||
|
||
async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null; providerOrder: string[] }> {
|
||
const index = await this.readIndex()
|
||
return {
|
||
providers: index.providers,
|
||
activeId: index.activeId,
|
||
providerOrder: index.providerOrder,
|
||
}
|
||
}
|
||
|
||
async getProvider(id: string): Promise<SavedProvider> {
|
||
if (isOpenAIOfficialProviderId(id)) {
|
||
return OPENAI_OFFICIAL_PROVIDER
|
||
}
|
||
if (isGrokOfficialProviderId(id)) {
|
||
return GROK_OFFICIAL_PROVIDER
|
||
}
|
||
|
||
const index = await this.readIndex()
|
||
const provider = index.providers.find((p) => p.id === id)
|
||
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
||
return provider
|
||
}
|
||
|
||
async addProvider(input: CreateProviderInput): Promise<SavedProvider> {
|
||
const index = await this.readIndex()
|
||
|
||
const provider = buildSavedProvider(input)
|
||
|
||
index.providerOrder = appendNewProviderToOrder(index.providerOrder, provider.id, index.providers)
|
||
index.providers.push(provider)
|
||
await this.writeIndex(index)
|
||
return provider
|
||
}
|
||
|
||
/**
|
||
* Append several providers in one pass.
|
||
*
|
||
* Bulk imports (cc-switch) read the index once and write it once so a partial
|
||
* failure cannot leave half the batch behind. The persisted shape is identical
|
||
* to addProvider, so no storage migration is involved.
|
||
*/
|
||
async importProviders(inputs: CreateProviderInput[]): Promise<SavedProvider[]> {
|
||
if (inputs.length === 0) return []
|
||
|
||
const index = await this.readIndex()
|
||
|
||
const imported: SavedProvider[] = []
|
||
for (const input of inputs) {
|
||
const provider = buildSavedProvider(input)
|
||
index.providerOrder = appendNewProviderToOrder(index.providerOrder, provider.id, index.providers)
|
||
index.providers.push(provider)
|
||
imported.push(provider)
|
||
}
|
||
|
||
await this.writeIndex(index)
|
||
return imported
|
||
}
|
||
|
||
async updateProvider(id: string, input: UpdateProviderInput): Promise<SavedProvider> {
|
||
const index = await this.readIndex()
|
||
const idx = index.providers.findIndex((p) => p.id === id)
|
||
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
||
|
||
const existing = index.providers[idx]
|
||
const imageGeneration = input.imageGeneration
|
||
? normalizeImageGeneration(input.imageGeneration)
|
||
: input.imageGeneration
|
||
const updated: SavedProvider = {
|
||
...existing,
|
||
...(input.name !== undefined && { name: input.name }),
|
||
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
||
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
|
||
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
|
||
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
|
||
...(input.runtimeKind !== undefined && { runtimeKind: input.runtimeKind }),
|
||
...(input.models !== undefined && { models: normalizeModelMapping(input.models) }),
|
||
...(input.model1mSupport !== undefined && input.model1mSupport !== null && { model1mSupport: input.model1mSupport }),
|
||
...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }),
|
||
...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }),
|
||
...(input.toolSearchEnabled !== undefined && { toolSearchEnabled: input.toolSearchEnabled }),
|
||
...(input.supportsNestedToolResultMedia !== undefined && { supportsNestedToolResultMedia: input.supportsNestedToolResultMedia }),
|
||
...(input.disableExperimentalBetas === true && { disableExperimentalBetas: true }),
|
||
...(imageGeneration !== undefined && imageGeneration !== null && { imageGeneration }),
|
||
...(input.notes !== undefined && { notes: input.notes }),
|
||
}
|
||
if (input.model1mSupport === null) {
|
||
delete updated.model1mSupport
|
||
}
|
||
if (input.autoCompactWindow === null) {
|
||
delete updated.autoCompactWindow
|
||
}
|
||
if (input.modelContextWindows === null) {
|
||
delete updated.modelContextWindows
|
||
}
|
||
if (input.disableExperimentalBetas === false) {
|
||
delete updated.disableExperimentalBetas
|
||
}
|
||
if (imageGeneration === null) {
|
||
delete updated.imageGeneration
|
||
}
|
||
|
||
index.providers[idx] = updated
|
||
await this.writeIndex(index)
|
||
|
||
if (index.activeId === id) {
|
||
await this.syncToSettings(updated)
|
||
}
|
||
|
||
return updated
|
||
}
|
||
|
||
async deleteProvider(id: string): Promise<void> {
|
||
const index = await this.readIndex()
|
||
const idx = index.providers.findIndex((p) => p.id === id)
|
||
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
||
|
||
if (index.activeId === id) {
|
||
throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.')
|
||
}
|
||
|
||
index.providers.splice(idx, 1)
|
||
index.providerOrder = index.providerOrder.filter((providerId) => providerId !== id)
|
||
await this.writeIndex(index)
|
||
}
|
||
|
||
/**
|
||
* Reorder providers to match the given display id order.
|
||
*
|
||
* New clients send a full display-order permutation including the built-in
|
||
* official providers. Legacy clients may still send only saved provider ids;
|
||
* in that case the saved providers are reordered inside the current display
|
||
* order without moving the built-in official rows.
|
||
*/
|
||
async reorderProviders(orderedIds: string[]): Promise<{ providers: SavedProvider[]; providerOrder: string[] }> {
|
||
const index = await this.readIndex()
|
||
|
||
const currentSavedIds = savedProviderIds(index.providers)
|
||
const isFullDisplayPermutation = isPermutation(orderedIds, fullProviderOrderIds(index.providers))
|
||
const isLegacySavedPermutation = isPermutation(orderedIds, currentSavedIds)
|
||
|
||
if (!isFullDisplayPermutation && !isLegacySavedPermutation) {
|
||
throw ApiError.badRequest('orderedIds must be a permutation of existing provider ids and built-in provider ids')
|
||
}
|
||
|
||
index.providerOrder = isFullDisplayPermutation
|
||
? orderedIds
|
||
: mergeSavedOrderIntoDisplayOrder(index.providerOrder, orderedIds)
|
||
index.providers = sortSavedProvidersByOrder(index.providers, index.providerOrder)
|
||
await this.writeIndex(index)
|
||
|
||
return {
|
||
providers: index.providers,
|
||
providerOrder: index.providerOrder,
|
||
}
|
||
}
|
||
|
||
// --- Activation ---
|
||
|
||
async activateProvider(id: string): Promise<void> {
|
||
const index = await this.readIndex()
|
||
const provider = isOpenAIOfficialProviderId(id)
|
||
? OPENAI_OFFICIAL_PROVIDER
|
||
: isGrokOfficialProviderId(id)
|
||
? GROK_OFFICIAL_PROVIDER
|
||
: index.providers.find((p) => p.id === id)
|
||
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
||
|
||
index.activeId = id
|
||
await this.writeIndex(index)
|
||
|
||
if (provider.runtimeKind === 'openai_oauth' || provider.runtimeKind === 'grok_oauth') {
|
||
await this.syncToSettings(provider)
|
||
} else if (provider.presetId === 'official') {
|
||
await this.clearProviderFromSettings()
|
||
} else {
|
||
await this.syncToSettings(provider)
|
||
}
|
||
}
|
||
|
||
async activateOfficial(): Promise<void> {
|
||
const index = await this.readIndex()
|
||
index.activeId = null
|
||
await this.writeIndex(index)
|
||
await this.clearProviderFromSettings()
|
||
}
|
||
|
||
// --- Settings sync ---
|
||
|
||
private buildManagedEnv(
|
||
provider: SavedProvider,
|
||
options?: { proxyPath?: string },
|
||
): Record<string, string> {
|
||
return buildProviderManagedEnv(provider, {
|
||
proxyPath: options?.proxyPath,
|
||
serverPort: ProviderService.serverPort,
|
||
})
|
||
}
|
||
|
||
async getProviderRuntimeEnv(id: string): Promise<Record<string, string>> {
|
||
const provider = await this.getProvider(id)
|
||
return this.buildManagedEnv(provider, {
|
||
proxyPath: `/proxy/providers/${provider.id}`,
|
||
})
|
||
}
|
||
|
||
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
||
await this.managedSettingsService.updateSettings((settings) => {
|
||
const existingEnv = (settings.env as Record<string, string>) || {}
|
||
const cleanedEnv = { ...existingEnv }
|
||
|
||
for (const key of getManagedEnvKeys()) {
|
||
delete cleanedEnv[key]
|
||
}
|
||
|
||
return {
|
||
settings: {
|
||
...settings,
|
||
env: {
|
||
...cleanedEnv,
|
||
...this.buildManagedEnv(provider),
|
||
},
|
||
},
|
||
result: undefined,
|
||
}
|
||
})
|
||
}
|
||
|
||
private async clearProviderFromSettings(): Promise<void> {
|
||
await this.managedSettingsService.updateSettings((settings) => {
|
||
const env = { ...((settings.env as Record<string, string>) || {}) }
|
||
|
||
for (const key of getManagedEnvKeys()) {
|
||
delete env[key]
|
||
}
|
||
|
||
const nextSettings: Record<string, unknown> = {
|
||
...settings,
|
||
}
|
||
|
||
if (Object.keys(env).length === 0) {
|
||
delete nextSettings.env
|
||
} else {
|
||
nextSettings.env = env
|
||
}
|
||
|
||
return {
|
||
settings: nextSettings,
|
||
result: undefined,
|
||
}
|
||
})
|
||
}
|
||
|
||
// --- Auth status ---
|
||
|
||
/**
|
||
* Check whether any usable auth exists:
|
||
* 1. The active cc-haha provider or built-in OAuth provider has auth
|
||
* 2. Claude Official has a desktop-managed OAuth token
|
||
* 3. process.env already has ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
|
||
* 4. Original ~/.claude/settings.json contains one of those auth variables
|
||
* 5. None of the above → needs setup
|
||
*/
|
||
async checkAuthStatus(): Promise<{
|
||
hasAuth: boolean
|
||
source: 'cc-haha-provider' | 'claude-oauth' | 'openai-oauth' | 'grok-oauth' | 'original-settings' | 'env' | 'none'
|
||
activeProvider?: string
|
||
}> {
|
||
// 1–2. Check the selected provider, including Claude Official (activeId=null).
|
||
const index = await this.readIndex()
|
||
if (index.activeId) {
|
||
if (isOpenAIOfficialProviderId(index.activeId)) {
|
||
const tokens = await hahaOpenAIOAuthService.ensureFreshTokens()
|
||
if (tokens?.accessToken && tokens.refreshToken) {
|
||
return {
|
||
hasAuth: true,
|
||
source: 'openai-oauth',
|
||
activeProvider: OPENAI_OFFICIAL_PROVIDER.name,
|
||
}
|
||
}
|
||
return {
|
||
hasAuth: false,
|
||
source: 'none',
|
||
activeProvider: OPENAI_OFFICIAL_PROVIDER.name,
|
||
}
|
||
}
|
||
if (isGrokOfficialProviderId(index.activeId)) {
|
||
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
|
||
if (tokens?.accessToken && tokens.refreshToken) {
|
||
return {
|
||
hasAuth: true,
|
||
source: 'grok-oauth',
|
||
activeProvider: GROK_OFFICIAL_PROVIDER.name,
|
||
}
|
||
}
|
||
return {
|
||
hasAuth: false,
|
||
source: 'none',
|
||
activeProvider: GROK_OFFICIAL_PROVIDER.name,
|
||
}
|
||
}
|
||
|
||
const provider = index.providers.find(p => p.id === index.activeId)
|
||
if (provider) {
|
||
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
|
||
const needsProxy = providerNeedsProxy(
|
||
provider.apiFormat ?? 'anthropic',
|
||
provider.supportsNestedToolResultMedia,
|
||
)
|
||
const authEnv = buildProviderAuthEnv(provider, presetDefaultEnv, needsProxy)
|
||
if (Object.values(authEnv).some(value => value.length > 0)) {
|
||
return { hasAuth: true, source: 'cc-haha-provider', activeProvider: provider.name }
|
||
}
|
||
}
|
||
} else {
|
||
const tokens = await hahaOAuthService.ensureFreshTokens()
|
||
if (tokens?.accessToken) {
|
||
return {
|
||
hasAuth: true,
|
||
source: 'claude-oauth',
|
||
activeProvider: 'Claude Official',
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Check process.env (covers .env file + inherited env)
|
||
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) {
|
||
return { hasAuth: true, source: 'env' }
|
||
}
|
||
|
||
// 4. Check original ~/.claude/settings.json
|
||
try {
|
||
const originalPath = path.join(this.getConfigDir(), 'settings.json')
|
||
const raw = await fs.readFile(originalPath, 'utf-8')
|
||
const settings = JSON.parse(raw) as { env?: Record<string, string> }
|
||
const env = settings.env ?? {}
|
||
if (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY) {
|
||
return { hasAuth: true, source: 'original-settings' }
|
||
}
|
||
} catch {
|
||
// File doesn't exist or invalid
|
||
}
|
||
|
||
return { hasAuth: false, source: 'none' }
|
||
}
|
||
|
||
// --- Proxy support ---
|
||
|
||
async getProviderForProxy(providerId?: string): Promise<{
|
||
id: string
|
||
name: string
|
||
baseUrl: string
|
||
apiKey: string
|
||
apiFormat: ApiFormat
|
||
supportsNestedToolResultMedia: boolean
|
||
authStrategy: ProviderAuthStrategy
|
||
} | null> {
|
||
const toProxyConfig = (provider: SavedProvider) => {
|
||
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
|
||
return {
|
||
id: provider.id,
|
||
name: provider.name,
|
||
baseUrl: provider.baseUrl,
|
||
apiKey: resolveProviderApiKey(provider, presetDefaultEnv),
|
||
apiFormat: provider.apiFormat ?? 'anthropic',
|
||
supportsNestedToolResultMedia: provider.supportsNestedToolResultMedia ?? true,
|
||
authStrategy: provider.authStrategy ?? getPresetAuthStrategy(provider.presetId),
|
||
}
|
||
}
|
||
if (providerId) {
|
||
if (isOpenAIOfficialProviderId(providerId) || isGrokOfficialProviderId(providerId)) {
|
||
return null
|
||
}
|
||
return toProxyConfig(await this.getProvider(providerId))
|
||
}
|
||
|
||
const index = await this.readIndex()
|
||
if (!index.activeId) return null
|
||
if (isOpenAIOfficialProviderId(index.activeId) || isGrokOfficialProviderId(index.activeId)) {
|
||
return null
|
||
}
|
||
const provider = await this.getProvider(index.activeId).catch(() => null)
|
||
return provider ? toProxyConfig(provider) : null
|
||
}
|
||
|
||
async getActiveProviderForProxy(): Promise<{
|
||
baseUrl: string
|
||
apiKey: string
|
||
apiFormat: ApiFormat
|
||
supportsNestedToolResultMedia: boolean
|
||
authStrategy: ProviderAuthStrategy
|
||
} | null> {
|
||
return this.getProviderForProxy()
|
||
}
|
||
|
||
// --- Test ---
|
||
|
||
async testProvider(
|
||
id: string,
|
||
overrides?: { modelId?: string },
|
||
): Promise<ProviderTestResult> {
|
||
const provider = await this.getProvider(id)
|
||
const baseUrl = provider.baseUrl
|
||
const modelId = overrides?.modelId || provider.models.main
|
||
const apiFormat = provider.apiFormat ?? 'anthropic'
|
||
const authStrategy = provider.authStrategy ?? getPresetAuthStrategy(provider.presetId)
|
||
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
|
||
const apiKey = resolveProviderApiKey(provider, presetDefaultEnv)
|
||
|| (authStrategy === 'dual_dummy' ? 'dummy' : '')
|
||
|
||
if (!baseUrl || !apiKey) {
|
||
return { connectivity: { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } }
|
||
}
|
||
return this.testProviderConfig({
|
||
baseUrl,
|
||
apiKey,
|
||
modelId,
|
||
authStrategy,
|
||
apiFormat,
|
||
supportsNestedToolResultMedia: provider.supportsNestedToolResultMedia,
|
||
})
|
||
}
|
||
|
||
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
|
||
const format: ApiFormat = input.apiFormat ?? 'anthropic'
|
||
const authStrategy = input.authStrategy ?? 'api_key'
|
||
const base = input.baseUrl.replace(/\/+$/, '')
|
||
const modelId = normalizeModelStringForAPI(input.modelId)
|
||
const networkSettings = await loadNetworkSettings()
|
||
|
||
// ── Step 1: Basic connectivity ───────────────────────────
|
||
// Directly call the upstream API to verify URL, key, and model.
|
||
const step1 = await this.testConnectivity(base, input.apiKey, modelId, format, authStrategy, networkSettings)
|
||
|
||
// If connectivity failed, no point running step 2
|
||
if (!step1.success) {
|
||
return { connectivity: step1 }
|
||
}
|
||
|
||
if (!providerNeedsProxy(format, input.supportsNestedToolResultMedia)) {
|
||
return { connectivity: step1 }
|
||
}
|
||
|
||
// ── Step 2: Full proxy pipeline ──────────────────────────
|
||
// Anthropic request → transform → upstream → transform back → validate
|
||
const step2 = await this.testProxyPipeline(
|
||
base,
|
||
input.apiKey,
|
||
modelId,
|
||
format,
|
||
authStrategy,
|
||
networkSettings,
|
||
)
|
||
|
||
return { connectivity: step1, proxy: step2 }
|
||
}
|
||
|
||
/** Step 1: Direct upstream call to verify connectivity, auth, and model. */
|
||
private async testConnectivity(
|
||
base: string,
|
||
apiKey: string,
|
||
modelId: string,
|
||
format: ApiFormat,
|
||
authStrategy: ProviderAuthStrategy,
|
||
networkSettings: NetworkSettings,
|
||
): Promise<ProviderTestStepResult> {
|
||
const start = Date.now()
|
||
try {
|
||
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format, authStrategy)
|
||
const proxyOptions = getNetworkProxyFetchOptions(networkSettings, url)
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
signal: AbortSignal.timeout(networkSettings.aiRequestTimeoutMs),
|
||
...proxyOptions,
|
||
})
|
||
|
||
const latencyMs = Date.now() - start
|
||
const resBody = await response.json().catch(() => null) as Record<string, unknown> | null
|
||
|
||
if (!response.ok) {
|
||
let error = `HTTP ${response.status}`
|
||
if (resBody?.error && typeof resBody.error === 'object') {
|
||
error = ((resBody.error as Record<string, unknown>).message as string) || error
|
||
}
|
||
return { success: false, latencyMs, error, modelUsed: modelId, httpStatus: response.status }
|
||
}
|
||
|
||
// Validate response structure
|
||
const valid = validateResponseBody(resBody, format)
|
||
if (!valid.ok) {
|
||
return { success: false, latencyMs, error: valid.error, modelUsed: modelId, httpStatus: response.status }
|
||
}
|
||
|
||
return { success: true, latencyMs, modelUsed: valid.model || modelId, httpStatus: response.status }
|
||
} catch (err: unknown) {
|
||
const latencyMs = Date.now() - start
|
||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||
return { success: false, latencyMs, error: `Request timed out (${Math.round(networkSettings.aiRequestTimeoutMs / 1000)}s)`, modelUsed: modelId }
|
||
}
|
||
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
||
}
|
||
}
|
||
|
||
/** Step 2: Full proxy pipeline — Anthropic → transform → upstream → transform back → validate. */
|
||
private async testProxyPipeline(
|
||
base: string,
|
||
apiKey: string,
|
||
modelId: string,
|
||
format: ApiFormat,
|
||
authStrategy: ProviderAuthStrategy,
|
||
networkSettings: NetworkSettings,
|
||
): Promise<ProviderTestStepResult> {
|
||
const start = Date.now()
|
||
try {
|
||
// Build an Anthropic Messages API request (same shape as what CLI sends)
|
||
const anthropicReq: AnthropicRequest = {
|
||
model: modelId,
|
||
max_tokens: 64,
|
||
messages: [{ role: 'user', content: 'Say "ok" and nothing else.' }],
|
||
}
|
||
|
||
let upstreamUrl: string
|
||
let transformedBody: unknown
|
||
let headers: Record<string, string>
|
||
if (format === 'openai_chat') {
|
||
transformedBody = anthropicToOpenaiChat(anthropicReq)
|
||
upstreamUrl = `${base}/v1/chat/completions`
|
||
headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }
|
||
} else if (format === 'openai_responses') {
|
||
transformedBody = anthropicToOpenaiResponses(anthropicReq)
|
||
upstreamUrl = `${base}/v1/responses`
|
||
headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }
|
||
} else {
|
||
transformedBody = hoistToolResultMediaForCompatibility(anthropicReq)
|
||
upstreamUrl = `${base}/v1/messages`
|
||
headers = {
|
||
'Content-Type': 'application/json',
|
||
'anthropic-version': '2023-06-01',
|
||
...buildAnthropicAuthHeaders(apiKey, authStrategy),
|
||
}
|
||
}
|
||
const proxyOptions = getNetworkProxyFetchOptions(networkSettings, upstreamUrl)
|
||
|
||
// Call upstream with transformed request
|
||
const response = await fetch(upstreamUrl, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(transformedBody),
|
||
signal: AbortSignal.timeout(networkSettings.aiRequestTimeoutMs),
|
||
...proxyOptions,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const latencyMs = Date.now() - start
|
||
const errText = await response.text().catch(() => '')
|
||
return { success: false, latencyMs, modelUsed: modelId, httpStatus: response.status,
|
||
error: `Upstream HTTP ${response.status}: ${errText.slice(0, 200)}` }
|
||
}
|
||
|
||
// Transform response back to Anthropic format
|
||
const responseBody = await response.json()
|
||
const anthropicRes = format === 'openai_chat'
|
||
? openaiChatToAnthropic(responseBody, modelId)
|
||
: format === 'openai_responses'
|
||
? openaiResponsesToAnthropic(responseBody, modelId)
|
||
: responseBody
|
||
|
||
const latencyMs = Date.now() - start
|
||
|
||
// Validate the final Anthropic response
|
||
if (anthropicRes.type !== 'message' || !Array.isArray(anthropicRes.content)) {
|
||
return { success: false, latencyMs, modelUsed: modelId,
|
||
error: 'Proxy transform produced invalid Anthropic response' }
|
||
}
|
||
|
||
return { success: true, latencyMs, modelUsed: anthropicRes.model || modelId, httpStatus: response.status }
|
||
} catch (err: unknown) {
|
||
const latencyMs = Date.now() - start
|
||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||
return { success: false, latencyMs, error: `Proxy pipeline timed out (${Math.round(networkSettings.aiRequestTimeoutMs / 1000)}s)`, modelUsed: modelId }
|
||
}
|
||
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ───────────────────────────────────────────────
|
||
|
||
function buildDirectTestRequest(
|
||
base: string,
|
||
apiKey: string,
|
||
modelId: string,
|
||
format: ApiFormat,
|
||
authStrategy: ProviderAuthStrategy,
|
||
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } {
|
||
const prompt = 'Say "ok" and nothing else.'
|
||
|
||
if (format === 'openai_chat') {
|
||
return {
|
||
url: `${base}/v1/chat/completions`,
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||
body: { model: modelId, max_tokens: 16, stream: false, messages: [{ role: 'user', content: prompt }] },
|
||
}
|
||
}
|
||
if (format === 'openai_responses') {
|
||
return {
|
||
url: `${base}/v1/responses`,
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||
body: { model: modelId, max_output_tokens: 16, input: [{ type: 'message', role: 'user', content: prompt }] },
|
||
}
|
||
}
|
||
// anthropic
|
||
return {
|
||
url: `${base}/v1/messages`,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'anthropic-version': '2023-06-01',
|
||
...buildAnthropicAuthHeaders(apiKey, authStrategy),
|
||
},
|
||
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
|
||
}
|
||
}
|
||
|
||
function buildAnthropicAuthHeaders(apiKey: string, authStrategy: ProviderAuthStrategy): Record<string, string> {
|
||
switch (authStrategy) {
|
||
case 'api_key':
|
||
return { 'x-api-key': apiKey }
|
||
case 'auth_token':
|
||
case 'auth_token_empty_api_key':
|
||
return { Authorization: `Bearer ${apiKey}` }
|
||
case 'dual_same_token':
|
||
return { 'x-api-key': apiKey, Authorization: `Bearer ${apiKey}` }
|
||
case 'dual_dummy':
|
||
return { 'x-api-key': 'dummy', Authorization: 'Bearer dummy' }
|
||
}
|
||
}
|
||
|
||
function validateResponseBody(
|
||
body: Record<string, unknown> | null,
|
||
format: ApiFormat,
|
||
): { ok: true; model?: string } | { ok: false; error: string } {
|
||
if (!body) return { ok: false, error: 'Empty response — not a valid API endpoint' }
|
||
if (body.error && typeof body.error === 'object') {
|
||
return { ok: false, error: ((body.error as Record<string, unknown>).message as string) || 'Error in response body' }
|
||
}
|
||
|
||
if (format === 'openai_chat') {
|
||
if (!Array.isArray(body.choices) || body.choices.length === 0) {
|
||
return { ok: false, error: 'Response missing choices — not a valid Chat Completions endpoint' }
|
||
}
|
||
return { ok: true, model: (body.model as string) || undefined }
|
||
}
|
||
if (format === 'openai_responses') {
|
||
if (!Array.isArray(body.output)) {
|
||
return { ok: false, error: 'Response missing output — not a valid Responses API endpoint' }
|
||
}
|
||
return { ok: true, model: (body.model as string) || undefined }
|
||
}
|
||
// anthropic
|
||
if (body.type !== 'message' || !Array.isArray(body.content)) {
|
||
return { ok: false, error: 'Not a valid Anthropic Messages endpoint' }
|
||
}
|
||
return { ok: true, model: (body.model as string) || undefined }
|
||
}
|