205 lines
6.0 KiB
TypeScript
205 lines
6.0 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import os from 'node:os'
|
|
import { config } from '@/lib/config'
|
|
|
|
interface ProviderSubscription {
|
|
provider: string
|
|
type: string
|
|
source: 'env' | 'file'
|
|
}
|
|
|
|
interface SubscriptionDetectionResult {
|
|
active: Record<string, ProviderSubscription>
|
|
}
|
|
|
|
const NEGATIVE_TYPES = new Set(['none', 'no', 'false', 'free', 'unknown', 'api_key', 'apikey'])
|
|
|
|
const OPENAI_CREDENTIAL_PATHS = [
|
|
path.join(os.homedir(), '.config', 'openai', 'auth.json'),
|
|
path.join(os.homedir(), '.openai', 'auth.json'),
|
|
path.join(os.homedir(), '.codex', 'auth.json'),
|
|
]
|
|
|
|
let detectionCache: { ts: number; value: SubscriptionDetectionResult } | null = null
|
|
const CACHE_TTL_MS = 30_000
|
|
|
|
function normalizeProvider(provider: string): string {
|
|
return provider.trim().toLowerCase()
|
|
}
|
|
|
|
function normalizeType(value: string): string {
|
|
return value.trim().toLowerCase()
|
|
}
|
|
|
|
function isPositiveSubscription(type: string): boolean {
|
|
if (!type) return false
|
|
return !NEGATIVE_TYPES.has(normalizeType(type))
|
|
}
|
|
|
|
function parseJsonFile(filePath: string): unknown | null {
|
|
try {
|
|
if (!existsSync(filePath)) return null
|
|
return JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function findNestedString(root: unknown, keys: string[]): string | null {
|
|
const queue: unknown[] = [root]
|
|
const wanted = new Set(keys.map(k => k.toLowerCase()))
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()
|
|
if (!current || typeof current !== 'object') continue
|
|
if (Array.isArray(current)) {
|
|
for (const item of current) queue.push(item)
|
|
continue
|
|
}
|
|
|
|
for (const [rawKey, value] of Object.entries(current)) {
|
|
const key = rawKey.toLowerCase()
|
|
if (wanted.has(key) && typeof value === 'string' && value.trim()) {
|
|
return value.trim()
|
|
}
|
|
if (value && typeof value === 'object') queue.push(value)
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function detectAnthropicFromFile(): ProviderSubscription | null {
|
|
const credsPath = path.join(config.claudeHome, '.credentials.json')
|
|
const creds = parseJsonFile(credsPath) as Record<string, unknown> | null
|
|
if (!creds || typeof creds !== 'object') return null
|
|
|
|
const oauth = creds.claudeAiOauth as Record<string, unknown> | undefined
|
|
const subscriptionType = typeof oauth?.subscriptionType === 'string' ? oauth.subscriptionType : ''
|
|
if (!isPositiveSubscription(subscriptionType)) return null
|
|
|
|
return {
|
|
provider: 'anthropic',
|
|
type: normalizeType(subscriptionType),
|
|
source: 'file',
|
|
}
|
|
}
|
|
|
|
function detectOpenAIFromFile(): ProviderSubscription | null {
|
|
for (const credsPath of OPENAI_CREDENTIAL_PATHS) {
|
|
const creds = parseJsonFile(credsPath)
|
|
if (!creds) continue
|
|
const plan = findNestedString(creds, [
|
|
'subscriptionType',
|
|
'subscription_type',
|
|
'accountPlan',
|
|
'account_plan',
|
|
'plan',
|
|
'tier',
|
|
])
|
|
if (!plan || !isPositiveSubscription(plan)) continue
|
|
return {
|
|
provider: 'openai',
|
|
type: normalizeType(plan),
|
|
source: 'file',
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function detectFromEnv(): Record<string, ProviderSubscription> {
|
|
const active: Record<string, ProviderSubscription> = {}
|
|
|
|
const allProvidersRaw = process.env.MC_SUBSCRIBED_PROVIDERS || ''
|
|
if (allProvidersRaw.trim()) {
|
|
for (const raw of allProvidersRaw.split(',')) {
|
|
const provider = normalizeProvider(raw)
|
|
if (!provider) continue
|
|
active[provider] = {
|
|
provider,
|
|
type: 'subscription',
|
|
source: 'env',
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (!value) continue
|
|
|
|
const explicitMatch = key.match(/^MC_([A-Z0-9_]+)_SUBSCRIPTION(?:_TYPE)?$/)
|
|
if (explicitMatch) {
|
|
const provider = normalizeProvider(explicitMatch[1].replace(/_/g, '-'))
|
|
const type = normalizeType(value)
|
|
if (isPositiveSubscription(type)) {
|
|
active[provider] = { provider, type, source: 'env' }
|
|
} else {
|
|
delete active[provider]
|
|
}
|
|
continue
|
|
}
|
|
|
|
const providerMatch = key.match(/^([A-Z0-9_]+)_SUBSCRIPTION_TYPE$/)
|
|
if (providerMatch) {
|
|
const provider = normalizeProvider(providerMatch[1].replace(/_/g, '-'))
|
|
const type = normalizeType(value)
|
|
if (isPositiveSubscription(type)) {
|
|
active[provider] = { provider, type, source: 'env' }
|
|
} else {
|
|
delete active[provider]
|
|
}
|
|
}
|
|
}
|
|
|
|
return active
|
|
}
|
|
|
|
export function detectProviderSubscriptions(forceRefresh = false): SubscriptionDetectionResult {
|
|
const now = Date.now()
|
|
if (!forceRefresh && detectionCache && (now - detectionCache.ts) < CACHE_TTL_MS) {
|
|
return detectionCache.value
|
|
}
|
|
|
|
const active = detectFromEnv()
|
|
|
|
const anthropic = detectAnthropicFromFile()
|
|
if (anthropic) active.anthropic = anthropic
|
|
|
|
const openai = detectOpenAIFromFile()
|
|
if (openai) active.openai = openai
|
|
|
|
const value = { active }
|
|
detectionCache = { ts: now, value }
|
|
return value
|
|
}
|
|
|
|
export function getProviderSubscriptionFlags(forceRefresh = false): Record<string, boolean> {
|
|
const detected = detectProviderSubscriptions(forceRefresh)
|
|
return Object.fromEntries(
|
|
Object.keys(detected.active).map((provider) => [provider, true])
|
|
)
|
|
}
|
|
|
|
export function getPrimarySubscription(forceRefresh = false): ProviderSubscription | null {
|
|
const detected = detectProviderSubscriptions(forceRefresh).active
|
|
return detected.anthropic || detected.openai || Object.values(detected)[0] || null
|
|
}
|
|
|
|
export function getProviderFromModel(modelName: string): string {
|
|
const normalized = modelName.trim().toLowerCase()
|
|
if (!normalized) return 'unknown'
|
|
|
|
const [prefix] = normalized.split('/')
|
|
if (prefix && !prefix.includes(':')) {
|
|
// Most models are provider-prefixed, e.g., "anthropic/claude-sonnet-4-5".
|
|
if (prefix === 'claude') return 'anthropic'
|
|
if (prefix === 'gpt' || prefix === 'o1' || prefix === 'o3') return 'openai'
|
|
return prefix
|
|
}
|
|
|
|
if (normalized.includes('claude')) return 'anthropic'
|
|
if (normalized.includes('gpt') || normalized.includes('codex') || normalized.includes('o1') || normalized.includes('o3')) return 'openai'
|
|
return 'unknown'
|
|
}
|
|
|