Files
ChatLab/electron/main/ai/llm/index.ts
T
2026-05-06 23:56:45 +08:00

792 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 → v3activeConfigId → 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 baseUrlAnthropic 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 }
}
}