mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-27 09:11:48 +08:00
792 lines
25 KiB
TypeScript
792 lines
25 KiB
TypeScript
/**
|
||
* LLM 服务模块入口
|
||
* 提供统一的 LLM 服务管理(支持多配置)
|
||
*/
|
||
|
||
import * as fs from 'fs'
|
||
import * as path from 'path'
|
||
import { randomUUID } from 'crypto'
|
||
import { getAiDataDir } from '../../paths'
|
||
import type { LLMProvider, ProviderInfo, AIServiceConfig, AIConfigStore } from './types'
|
||
import { MAX_CONFIG_COUNT } from './types'
|
||
import { aiLogger } from '../logger'
|
||
import { encryptApiKey, decryptApiKey, isEncrypted } from './crypto'
|
||
import { buildChatLabUserAgentHeaders } from '../../utils/httpHeaders'
|
||
import { t } from '../../i18n'
|
||
import { completeSimple, type Model as PiModel, type Api as PiApi } from '@mariozechner/pi-ai'
|
||
|
||
// 新模型系统导出
|
||
export { BUILTIN_PROVIDERS, getBuiltinProviderById } from './provider-registry'
|
||
export { BUILTIN_MODELS, getBuiltinModelsByProvider, getBuiltinModelById } from './model-catalog'
|
||
export {
|
||
loadCustomProviders,
|
||
addCustomProvider,
|
||
updateCustomProvider,
|
||
deleteCustomProvider,
|
||
} from './custom-provider-store'
|
||
export { loadCustomModels, addCustomModel, updateCustomModel, deleteCustomModel } from './custom-model-store'
|
||
export * from './model-types'
|
||
|
||
// 兼容类型导出
|
||
export * from './types'
|
||
|
||
// ==================== 合并 Registry / Catalog(内置 + 自定义)====================
|
||
|
||
import { BUILTIN_PROVIDERS, getBuiltinProviderById } from './provider-registry'
|
||
import { BUILTIN_MODELS, getBuiltinModelsByProvider } from './model-catalog'
|
||
import { loadCustomProviders } from './custom-provider-store'
|
||
import { loadCustomModels } from './custom-model-store'
|
||
import type { ProviderDefinition, ModelDefinition } from './model-types'
|
||
|
||
/** 获取完整 provider registry(内置 + 自定义) */
|
||
export function getProviderRegistry(): ProviderDefinition[] {
|
||
return [...BUILTIN_PROVIDERS, ...loadCustomProviders()]
|
||
}
|
||
|
||
/** 获取完整 model catalog(内置 + 自定义) */
|
||
export function getModelCatalog(): ModelDefinition[] {
|
||
return [...BUILTIN_MODELS, ...loadCustomModels()]
|
||
}
|
||
|
||
/** 获取指定 provider 下的全部模型(内置 + 自定义) */
|
||
export function getModelsByProvider(providerId: string): ModelDefinition[] {
|
||
return [...getBuiltinModelsByProvider(providerId), ...loadCustomModels().filter((m) => m.providerId === providerId)]
|
||
}
|
||
|
||
/** 按 id 查找 provider(内置优先) */
|
||
export function getProviderDefinitionById(id: string): ProviderDefinition | null {
|
||
return getBuiltinProviderById(id) || loadCustomProviders().find((p) => p.id === id) || null
|
||
}
|
||
|
||
/** 按 providerId + modelId 查找模型定义(内置优先,再查自定义,最后跨 provider 兜底) */
|
||
export function findModelDefinition(providerId: string, modelId: string): ModelDefinition | null {
|
||
return (
|
||
getBuiltinModelById(providerId, modelId) ||
|
||
loadCustomModels().find((m) => m.providerId === providerId && m.id === modelId) ||
|
||
BUILTIN_MODELS.find((m) => m.id === modelId) ||
|
||
loadCustomModels().find((m) => m.id === modelId) ||
|
||
null
|
||
)
|
||
}
|
||
|
||
function providerDefinitionToInfo(def: ProviderDefinition): ProviderInfo {
|
||
const models = getBuiltinModelsByProvider(def.id)
|
||
return {
|
||
id: def.id,
|
||
name: def.name,
|
||
defaultBaseUrl: def.defaultBaseUrl,
|
||
models: models
|
||
.filter((m) => !m.capabilities.includes('embedding') && !m.capabilities.includes('ranking'))
|
||
.map((m) => ({ id: m.id, name: m.name, description: m.description })),
|
||
}
|
||
}
|
||
|
||
export const PROVIDERS: ProviderInfo[] = BUILTIN_PROVIDERS.map(providerDefinitionToInfo)
|
||
|
||
// 配置文件路径
|
||
let CONFIG_PATH: string | null = null
|
||
|
||
function getConfigPath(): string {
|
||
if (CONFIG_PATH) return CONFIG_PATH
|
||
CONFIG_PATH = path.join(getAiDataDir(), 'llm-config.json')
|
||
return CONFIG_PATH
|
||
}
|
||
|
||
// ==================== 旧配置格式(用于迁移)====================
|
||
|
||
interface LegacyStoredConfig {
|
||
provider: LLMProvider
|
||
apiKey: string
|
||
model?: string
|
||
maxTokens?: number
|
||
}
|
||
|
||
function isLegacyConfig(data: unknown): data is LegacyStoredConfig {
|
||
if (!data || typeof data !== 'object') return false
|
||
const obj = data as Record<string, unknown>
|
||
return 'provider' in obj && 'apiKey' in obj && !('configs' in obj)
|
||
}
|
||
|
||
function migrateLegacyConfig(legacy: LegacyStoredConfig): AIConfigStore {
|
||
const now = Date.now()
|
||
const providerDef = getBuiltinProviderById(legacy.provider)
|
||
const newConfig: AIServiceConfig = {
|
||
id: randomUUID(),
|
||
name: providerDef?.name || legacy.provider,
|
||
provider: legacy.provider,
|
||
apiKey: legacy.apiKey,
|
||
model: legacy.model,
|
||
maxTokens: legacy.maxTokens,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}
|
||
|
||
return {
|
||
configs: [newConfig],
|
||
defaultAssistant: { configId: newConfig.id, modelId: newConfig.model || '' },
|
||
fastModel: null,
|
||
}
|
||
}
|
||
|
||
// ==================== Schema 版本迁移 ====================
|
||
|
||
import { addCustomProvider as _addCustomProviderDirect } from './custom-provider-store'
|
||
import { addCustomModel as _addCustomModelDirect } from './custom-model-store'
|
||
import { getBuiltinModelById } from './model-catalog'
|
||
|
||
const CURRENT_SCHEMA_VERSION = 3
|
||
|
||
/**
|
||
* MiniMax 等旧 provider 的兼容映射表
|
||
* 这些旧 provider 不在新 BUILTIN_PROVIDERS 中,需要自动迁移为自定义 provider
|
||
*/
|
||
const LEGACY_PROVIDER_FALLBACKS: Record<string, { name: string; defaultBaseUrl: string }> = {
|
||
minimax: { name: 'MiniMax', defaultBaseUrl: 'https://api.minimaxi.com/v1' },
|
||
}
|
||
|
||
/**
|
||
* 将旧 AIConfigStore (schemaVersion=1 或无) 迁移到 schemaVersion=2
|
||
* - provider 在新 registry 中存在 → 保持不变
|
||
* - provider 不存在 → 自动创建自定义 provider
|
||
* - model 不在 catalog 中 → 自动创建自定义 model
|
||
* - disableThinking / isReasoningModel → 不迁移(兼容期通过 compat 字段保留)
|
||
*/
|
||
function migrateToSchemaV2(store: AIConfigStore): AIConfigStore {
|
||
aiLogger.info('LLM', 'Migrating config store to schema v2')
|
||
|
||
for (const config of store.configs) {
|
||
const providerId = config.provider
|
||
|
||
if (!getBuiltinProviderById(providerId)) {
|
||
const fallback = LEGACY_PROVIDER_FALLBACKS[providerId]
|
||
if (fallback) {
|
||
try {
|
||
_addCustomProviderDirect({
|
||
name: fallback.name,
|
||
kind: 'openai-compatible',
|
||
defaultBaseUrl: fallback.defaultBaseUrl,
|
||
authMode: 'api-key',
|
||
supportsCustomModels: true,
|
||
modelIds: [],
|
||
})
|
||
aiLogger.info('LLM', `Created custom provider for legacy provider: ${providerId}`)
|
||
} catch {
|
||
// already exists
|
||
}
|
||
}
|
||
}
|
||
|
||
if (config.model && getBuiltinProviderById(providerId)) {
|
||
const modelInCatalog = getBuiltinModelById(providerId, config.model)
|
||
if (!modelInCatalog) {
|
||
try {
|
||
_addCustomModelDirect({
|
||
id: config.model,
|
||
providerId,
|
||
name: config.model,
|
||
capabilities: ['chat'],
|
||
recommendedFor: ['chat'],
|
||
status: 'stable',
|
||
})
|
||
aiLogger.info('LLM', `Created custom model "${config.model}" under provider "${providerId}"`)
|
||
} catch {
|
||
// already exists
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
...store,
|
||
configs: store.configs.map((c) => {
|
||
const { disableThinking: _dt, isReasoningModel: _rm, ...rest } = c as AIServiceConfig & Record<string, unknown>
|
||
return rest as AIServiceConfig
|
||
}),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Schema v2 → v3:activeConfigId → defaultAssistant { configId, modelId }
|
||
*/
|
||
function migrateToSchemaV3(store: AIConfigStore & { activeConfigId?: string | null }): AIConfigStore {
|
||
aiLogger.info('LLM', 'Migrating config store to schema v3 (dual-slot model selection)')
|
||
const legacyActiveId = store.activeConfigId ?? null
|
||
const resolvedConfig =
|
||
legacyActiveId && store.configs.find((c) => c.id === legacyActiveId)
|
||
? store.configs.find((c) => c.id === legacyActiveId)!
|
||
: (store.configs[0] ?? null)
|
||
|
||
return {
|
||
configs: store.configs,
|
||
defaultAssistant: resolvedConfig ? { configId: resolvedConfig.id, modelId: resolvedConfig.model || '' } : null,
|
||
fastModel: null,
|
||
}
|
||
}
|
||
|
||
/** 解析 ModelSlot:如果 configId 无效,回退到 configs[0] */
|
||
function resolveSlot(
|
||
slot: import('./model-types').ModelSlot | null | undefined,
|
||
configs: AIServiceConfig[]
|
||
): import('./model-types').ModelSlot | null {
|
||
if (slot && configs.some((c) => c.id === slot.configId)) return slot
|
||
const fallback = configs[0]
|
||
return fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null
|
||
}
|
||
|
||
// ==================== 多配置管理 ====================
|
||
|
||
/**
|
||
* 加载配置存储(自动处理迁移和解密)
|
||
*/
|
||
export function loadConfigStore(): AIConfigStore {
|
||
const configPath = getConfigPath()
|
||
|
||
if (!fs.existsSync(configPath)) {
|
||
return { configs: [], defaultAssistant: null, fastModel: null }
|
||
}
|
||
|
||
try {
|
||
const content = fs.readFileSync(configPath, 'utf-8')
|
||
const data = JSON.parse(content)
|
||
|
||
if (isLegacyConfig(data)) {
|
||
aiLogger.info('LLM', 'Old config format detected, migrating')
|
||
const migrated = migrateLegacyConfig(data)
|
||
saveConfigStore(migrated)
|
||
return loadConfigStore()
|
||
}
|
||
|
||
let store = data as AIConfigStore & { schemaVersion?: number; activeConfigId?: string | null }
|
||
let needsSchemaSave = false
|
||
|
||
// Schema v1 → v2 迁移
|
||
if (!store.schemaVersion || store.schemaVersion < 2) {
|
||
store = { ...migrateToSchemaV2(store), schemaVersion: 2 } as typeof store
|
||
needsSchemaSave = true
|
||
}
|
||
|
||
// Schema v2 → v3 迁移
|
||
if (store.schemaVersion < 3) {
|
||
store = { ...migrateToSchemaV3(store), schemaVersion: CURRENT_SCHEMA_VERSION } as typeof store
|
||
needsSchemaSave = true
|
||
}
|
||
|
||
let needsEncryptionMigration = false
|
||
const decryptedConfigs = store.configs.map((config) => {
|
||
if (config.apiKey && !isEncrypted(config.apiKey)) {
|
||
needsEncryptionMigration = true
|
||
aiLogger.info('LLM', `Config "${config.name}" API Key needs encryption migration`)
|
||
}
|
||
return {
|
||
...config,
|
||
apiKey: config.apiKey ? decryptApiKey(config.apiKey) : '',
|
||
}
|
||
})
|
||
|
||
if (needsEncryptionMigration || needsSchemaSave) {
|
||
aiLogger.info('LLM', 'Saving migrated config store')
|
||
saveConfigStoreRaw({
|
||
...store,
|
||
configs: store.configs.map((config) => ({
|
||
...config,
|
||
apiKey: config.apiKey ? encryptApiKey(decryptApiKey(config.apiKey)) : '',
|
||
})),
|
||
})
|
||
}
|
||
|
||
return {
|
||
...store,
|
||
configs: decryptedConfigs,
|
||
}
|
||
} catch (error) {
|
||
aiLogger.error('LLM', 'Failed to load configs', error)
|
||
return { configs: [], defaultAssistant: null, fastModel: null }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存配置存储(自动加密 API Key)
|
||
*/
|
||
export function saveConfigStore(store: AIConfigStore): void {
|
||
const encryptedStore: AIConfigStore = {
|
||
...store,
|
||
configs: store.configs.map((config) => ({
|
||
...config,
|
||
apiKey: config.apiKey ? encryptApiKey(config.apiKey) : '',
|
||
})),
|
||
}
|
||
saveConfigStoreRaw(encryptedStore)
|
||
}
|
||
|
||
function saveConfigStoreRaw(store: AIConfigStore): void {
|
||
const configPath = getConfigPath()
|
||
const dir = path.dirname(configPath)
|
||
|
||
if (!fs.existsSync(dir)) {
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
}
|
||
|
||
fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf-8')
|
||
}
|
||
|
||
export function getAllConfigs(): AIServiceConfig[] {
|
||
return loadConfigStore().configs
|
||
}
|
||
|
||
/** 获取默认助手 slot(含 configId + modelId) */
|
||
export function getDefaultAssistantSlot(): import('./model-types').ModelSlot | null {
|
||
const store = loadConfigStore()
|
||
return resolveSlot(store.defaultAssistant, store.configs)
|
||
}
|
||
|
||
/** 获取默认助手模型配置(AI 对话、工具调用、SQL 助手、上下文压缩)。自动覆盖 config.model 为 slot.modelId */
|
||
export function getDefaultAssistantConfig(): AIServiceConfig | null {
|
||
const store = loadConfigStore()
|
||
const slot = resolveSlot(store.defaultAssistant, store.configs)
|
||
if (!slot) return null
|
||
const config = store.configs.find((c) => c.id === slot.configId)
|
||
if (!config) return null
|
||
return { ...config, model: slot.modelId || config.model }
|
||
}
|
||
|
||
/** 获取快速模型 slot */
|
||
export function getFastModelSlot(): import('./model-types').ModelSlot | null {
|
||
const store = loadConfigStore()
|
||
return resolveSlot(store.fastModel, store.configs)
|
||
}
|
||
|
||
/** 获取快速模型配置(会话摘要),未配置时回退到默认助手 */
|
||
export function getFastModelConfig(): AIServiceConfig | null {
|
||
const store = loadConfigStore()
|
||
const slot = resolveSlot(store.fastModel, store.configs)
|
||
if (slot) {
|
||
const config = store.configs.find((c) => c.id === slot.configId)
|
||
if (config) return { ...config, model: slot.modelId || config.model }
|
||
}
|
||
return getDefaultAssistantConfig()
|
||
}
|
||
|
||
export function getConfigById(id: string): AIServiceConfig | null {
|
||
const store = loadConfigStore()
|
||
return store.configs.find((c) => c.id === id) || null
|
||
}
|
||
|
||
export function addConfig(config: Omit<AIServiceConfig, 'id' | 'createdAt' | 'updatedAt'>): {
|
||
success: boolean
|
||
config?: AIServiceConfig
|
||
error?: string
|
||
} {
|
||
const store = loadConfigStore()
|
||
|
||
if (store.configs.length >= MAX_CONFIG_COUNT) {
|
||
return { success: false, error: t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) }
|
||
}
|
||
|
||
const now = Date.now()
|
||
const newConfig: AIServiceConfig = {
|
||
...config,
|
||
id: randomUUID(),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}
|
||
|
||
store.configs.push(newConfig)
|
||
|
||
if (store.configs.length === 1) {
|
||
store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' }
|
||
}
|
||
|
||
saveConfigStore(store)
|
||
return { success: true, config: newConfig }
|
||
}
|
||
|
||
export function updateConfig(
|
||
id: string,
|
||
updates: Partial<Omit<AIServiceConfig, 'id' | 'createdAt' | 'updatedAt'>>
|
||
): { success: boolean; error?: string } {
|
||
const store = loadConfigStore()
|
||
const index = store.configs.findIndex((c) => c.id === id)
|
||
|
||
if (index === -1) {
|
||
return { success: false, error: t('llm.configNotFound') }
|
||
}
|
||
|
||
store.configs[index] = {
|
||
...store.configs[index],
|
||
...updates,
|
||
updatedAt: Date.now(),
|
||
}
|
||
|
||
saveConfigStore(store)
|
||
return { success: true }
|
||
}
|
||
|
||
export function deleteConfig(id: string): { success: boolean; error?: string } {
|
||
const store = loadConfigStore()
|
||
const index = store.configs.findIndex((c) => c.id === id)
|
||
|
||
if (index === -1) {
|
||
return { success: false, error: t('llm.configNotFound') }
|
||
}
|
||
|
||
store.configs.splice(index, 1)
|
||
|
||
const fallback = store.configs[0]
|
||
if (store.defaultAssistant?.configId === id) {
|
||
store.defaultAssistant = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null
|
||
}
|
||
if (store.fastModel?.configId === id) {
|
||
store.fastModel = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null
|
||
}
|
||
|
||
saveConfigStore(store)
|
||
return { success: true }
|
||
}
|
||
|
||
/** 设置默认助手模型(configId + modelId) */
|
||
export function setDefaultAssistantModel(configId: string, modelId: string): { success: boolean; error?: string } {
|
||
const store = loadConfigStore()
|
||
const config = store.configs.find((c) => c.id === configId)
|
||
|
||
if (!config) {
|
||
return { success: false, error: t('llm.configNotFound') }
|
||
}
|
||
|
||
store.defaultAssistant = { configId, modelId }
|
||
saveConfigStore(store)
|
||
return { success: true }
|
||
}
|
||
|
||
/** 设置快速模型(configId + modelId),传 null 表示跟随默认助手 */
|
||
export function setFastModel(slot: import('./model-types').ModelSlot | null): { success: boolean; error?: string } {
|
||
const store = loadConfigStore()
|
||
|
||
if (slot !== null) {
|
||
const config = store.configs.find((c) => c.id === slot.configId)
|
||
if (!config) {
|
||
return { success: false, error: t('llm.configNotFound') }
|
||
}
|
||
}
|
||
|
||
store.fastModel = slot
|
||
saveConfigStore(store)
|
||
return { success: true }
|
||
}
|
||
|
||
export function hasActiveConfig(): boolean {
|
||
return getDefaultAssistantConfig() !== null
|
||
}
|
||
|
||
function validateProviderBaseUrl(provider: LLMProvider, baseUrl?: string): void {
|
||
if (!baseUrl) return
|
||
|
||
const normalized = baseUrl.replace(/\/+$/, '')
|
||
|
||
if (provider === 'deepseek') {
|
||
if (normalized.endsWith('/chat/completions')) {
|
||
throw new Error('DeepSeek Base URL 请填写到 /v1 层级,不要包含 /chat/completions')
|
||
}
|
||
if (!normalized.endsWith('/v1')) {
|
||
throw new Error('DeepSeek Base URL 需要以 /v1 结尾')
|
||
}
|
||
}
|
||
|
||
if (provider === 'qwen') {
|
||
if (normalized.endsWith('/chat/completions')) {
|
||
throw new Error('通义千问 Base URL 请填写到 /v1 层级,不要包含 /chat/completions')
|
||
}
|
||
if (!normalized.endsWith('/v1')) {
|
||
throw new Error('通义千问 Base URL 需要以 /v1 结尾')
|
||
}
|
||
if (normalized.includes('dashscope.aliyuncs.com') && !normalized.includes('/compatible-mode/')) {
|
||
throw new Error('通义千问 Base URL 需要包含 /compatible-mode/v1')
|
||
}
|
||
}
|
||
}
|
||
|
||
export function getProviderInfo(provider: LLMProvider): ProviderInfo | null {
|
||
return PROVIDERS.find((p) => p.id === provider) || null
|
||
}
|
||
|
||
// ==================== pi-ai Model 构建 ====================
|
||
|
||
/**
|
||
* 规范化 Anthropic baseUrl:Anthropic SDK 内部会拼接 /v1/messages,
|
||
* 因此 baseUrl 不应包含 /v1 后缀,否则会导致 /v1/v1/messages 双重路径。
|
||
*/
|
||
function normalizeAnthropicBaseUrl(url: string): string {
|
||
return url.replace(/\/v1\/?$/, '')
|
||
}
|
||
|
||
/**
|
||
* 规范化 OpenAI Compatible baseUrl:
|
||
* 用户经常忘记在域名后加 /v1,OpenAI SDK 不会自动补全。
|
||
* 如果 URL 没有以 /v1 结尾且路径部分为空或仅有 /,自动补上。
|
||
* 已有具体路径(如 /api/v1、/proxy)的不做修改。
|
||
*/
|
||
function normalizeOpenAICompatibleBaseUrl(url: string): string {
|
||
if (!url) return url
|
||
const trimmed = url.replace(/\/+$/, '')
|
||
if (trimmed.endsWith('/v1')) return trimmed
|
||
try {
|
||
const parsed = new URL(trimmed)
|
||
// 仅当路径为空或 "/" 时补全 /v1,避免破坏已有的自定义路径
|
||
if (parsed.pathname === '/' || parsed.pathname === '') {
|
||
return trimmed + '/v1'
|
||
}
|
||
} catch {
|
||
// URL 解析失败,不做处理
|
||
}
|
||
return trimmed
|
||
}
|
||
|
||
const DEFAULT_CONTEXT_WINDOW = 128000
|
||
|
||
export function buildPiModel(config: AIServiceConfig): PiModel<PiApi> {
|
||
const providerDef = getBuiltinProviderById(config.provider)
|
||
const providerInfo = getProviderInfo(config.provider)
|
||
const baseUrl = config.baseUrl || providerDef?.defaultBaseUrl || providerInfo?.defaultBaseUrl || ''
|
||
const modelId = config.model || providerInfo?.models?.[0]?.id || ''
|
||
|
||
validateProviderBaseUrl(config.provider, baseUrl)
|
||
|
||
const modelDef = findModelDefinition(config.provider, modelId)
|
||
const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW
|
||
|
||
const BUILTIN_PROVIDER_API: Record<string, PiApi> = {
|
||
gemini: 'google-generative-ai',
|
||
anthropic: 'anthropic-messages',
|
||
}
|
||
|
||
const apiFormat: PiApi = (config.apiFormat as PiApi) || BUILTIN_PROVIDER_API[config.provider] || 'openai-completions'
|
||
|
||
if (apiFormat === 'google-generative-ai') {
|
||
return {
|
||
id: modelId,
|
||
name: modelId,
|
||
api: 'google-generative-ai',
|
||
provider: 'google',
|
||
baseUrl,
|
||
reasoning: false,
|
||
input: ['text'],
|
||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||
contextWindow,
|
||
maxTokens: config.maxTokens ?? 8192,
|
||
}
|
||
}
|
||
|
||
if (apiFormat === 'anthropic-messages') {
|
||
return {
|
||
id: modelId,
|
||
name: modelId,
|
||
api: 'anthropic-messages',
|
||
provider: 'anthropic',
|
||
baseUrl: normalizeAnthropicBaseUrl(baseUrl),
|
||
reasoning: false,
|
||
input: ['text'],
|
||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||
contextWindow,
|
||
maxTokens: config.maxTokens ?? 8192,
|
||
}
|
||
}
|
||
|
||
// openai-compatible + openai-completions/openai-responses:自动补全 /v1(用户经常忘记)
|
||
const resolvedBaseUrl =
|
||
config.provider === 'openai-compatible' && (apiFormat === 'openai-completions' || apiFormat === 'openai-responses')
|
||
? normalizeOpenAICompatibleBaseUrl(baseUrl)
|
||
: baseUrl
|
||
|
||
return {
|
||
id: modelId,
|
||
name: modelId,
|
||
api: apiFormat,
|
||
provider: config.provider,
|
||
baseUrl: resolvedBaseUrl,
|
||
headers: config.provider === 'openai-compatible' ? buildChatLabUserAgentHeaders() : undefined,
|
||
reasoning: config.isReasoningModel ?? false,
|
||
input: ['text'],
|
||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||
contextWindow,
|
||
maxTokens: config.maxTokens ?? 4096,
|
||
compat: config.disableThinking ? { thinkingFormat: 'qwen' } : undefined,
|
||
}
|
||
}
|
||
|
||
// ==================== 远程模型列表获取 ====================
|
||
|
||
export interface RemoteModel {
|
||
id: string
|
||
name: string
|
||
ownedBy?: string
|
||
contextWindow?: number
|
||
}
|
||
|
||
export interface FetchRemoteModelsResult {
|
||
success: boolean
|
||
models?: RemoteModel[]
|
||
error?: string
|
||
}
|
||
|
||
/**
|
||
* 根据 API 格式决定 baseUrl 到 /models 端点的映射:
|
||
* - openai-completions / openai-responses → {resolvedBaseUrl}/models
|
||
* - google-generative-ai → {baseUrl}/v1beta/models?key={apiKey}
|
||
* - anthropic-messages → 不支持
|
||
*/
|
||
export async function fetchRemoteModels(
|
||
provider: string,
|
||
apiKey: string,
|
||
baseUrl?: string,
|
||
apiFormat?: string
|
||
): Promise<FetchRemoteModelsResult> {
|
||
const effectiveApiFormat = apiFormat || 'openai-completions'
|
||
|
||
if (effectiveApiFormat === 'anthropic-messages') {
|
||
return { success: false, error: 'Anthropic does not support model listing via API' }
|
||
}
|
||
|
||
const rawBaseUrl = baseUrl || getBuiltinProviderById(provider)?.defaultBaseUrl || ''
|
||
if (!rawBaseUrl) {
|
||
return { success: false, error: 'No base URL provided' }
|
||
}
|
||
|
||
const abortController = new AbortController()
|
||
const timeout = setTimeout(() => abortController.abort(), 15000)
|
||
|
||
try {
|
||
let url: string
|
||
const headers: Record<string, string> = {
|
||
...buildChatLabUserAgentHeaders(),
|
||
}
|
||
|
||
if (effectiveApiFormat === 'google-generative-ai') {
|
||
const trimmed = rawBaseUrl.replace(/\/+$/, '').replace(/\/v1(beta)?$/, '')
|
||
url = `${trimmed}/v1beta/models?key=${apiKey}`
|
||
} else {
|
||
// openai-completions / openai-responses: resolve /v1 auto
|
||
let resolved = rawBaseUrl.replace(/\/+$/, '')
|
||
try {
|
||
const parsed = new URL(resolved)
|
||
if (!resolved.endsWith('/v1') && (parsed.pathname === '/' || parsed.pathname === '')) {
|
||
resolved = resolved + '/v1'
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
url = `${resolved}/models`
|
||
headers['Authorization'] = `Bearer ${apiKey}`
|
||
}
|
||
|
||
aiLogger.info('LLM', 'Fetching remote models', { url: url.replace(/key=[^&]+/, 'key=***'), provider })
|
||
|
||
const response = await fetch(url, {
|
||
method: 'GET',
|
||
headers,
|
||
signal: abortController.signal,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const body = await response.text().catch(() => '')
|
||
return { success: false, error: `HTTP ${response.status}: ${body.slice(0, 200)}` }
|
||
}
|
||
|
||
const json = await response.json()
|
||
|
||
let models: RemoteModel[]
|
||
|
||
if (effectiveApiFormat === 'google-generative-ai') {
|
||
const geminiModels = (json.models || []) as Array<{
|
||
name?: string
|
||
displayName?: string
|
||
inputTokenLimit?: number
|
||
}>
|
||
models = geminiModels.map((m) => {
|
||
const id = (m.name || '').replace(/^models\//, '')
|
||
return {
|
||
id,
|
||
name: m.displayName || id,
|
||
ownedBy: 'google',
|
||
contextWindow: m.inputTokenLimit || undefined,
|
||
}
|
||
})
|
||
} else {
|
||
// OpenAI-standard format: { data: [{ id, owned_by, context_length? }] }
|
||
const data = (json.data || []) as Array<{
|
||
id?: string
|
||
owned_by?: string
|
||
context_length?: number
|
||
}>
|
||
models = data
|
||
.filter((m) => m.id)
|
||
.map((m) => ({
|
||
id: m.id!,
|
||
name: m.id!,
|
||
ownedBy: m.owned_by,
|
||
contextWindow: m.context_length || undefined,
|
||
}))
|
||
}
|
||
|
||
aiLogger.info('LLM', `Fetched ${models.length} remote models`, { provider })
|
||
return { success: true, models }
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
if (message.includes('aborted') || message.includes('AbortError')) {
|
||
return { success: false, error: 'Request timed out (15s)' }
|
||
}
|
||
return { success: false, error: message }
|
||
} finally {
|
||
clearTimeout(timeout)
|
||
}
|
||
}
|
||
|
||
export async function validateApiKey(
|
||
provider: LLMProvider,
|
||
apiKey: string,
|
||
baseUrl?: string,
|
||
model?: string
|
||
): Promise<{ success: boolean; error?: string }> {
|
||
try {
|
||
const providerInfo = getProviderInfo(provider)
|
||
const config: AIServiceConfig = {
|
||
id: 'validate-temp',
|
||
name: 'validate-temp',
|
||
provider,
|
||
apiKey,
|
||
baseUrl,
|
||
model: model || providerInfo?.models?.[0]?.id,
|
||
createdAt: 0,
|
||
updatedAt: 0,
|
||
}
|
||
const piModel = buildPiModel(config)
|
||
|
||
const abortController = new AbortController()
|
||
const timeout = setTimeout(() => abortController.abort(), 15000)
|
||
|
||
try {
|
||
const result = await completeSimple(
|
||
piModel,
|
||
{
|
||
messages: [{ role: 'user', content: 'Hi', timestamp: Date.now() }],
|
||
},
|
||
{
|
||
apiKey,
|
||
maxTokens: 1,
|
||
signal: abortController.signal,
|
||
}
|
||
)
|
||
if (result.stopReason === 'error' || result.stopReason === 'aborted') {
|
||
return { success: false, error: result.errorMessage || 'Connection failed' }
|
||
}
|
||
return { success: true }
|
||
} finally {
|
||
clearTimeout(timeout)
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
if (message.includes('aborted') || message.includes('AbortError')) {
|
||
return { success: false, error: 'Request timed out (15s)' }
|
||
}
|
||
return { success: false, error: message }
|
||
}
|
||
}
|