diff --git a/packages/core/src/agent/executor.ts b/packages/core/src/agent/executor.ts index 8fc1302..c136183 100644 --- a/packages/core/src/agent/executor.ts +++ b/packages/core/src/agent/executor.ts @@ -16,7 +16,7 @@ import type { ImageData, } from './types.js'; import { checkBashPermission } from './permission-merger.js'; -import { getModelFactory } from '../core/providers.js'; +import { getProviderRegistry } from '../provider/index.js'; /** * Agent 执行器 @@ -37,9 +37,10 @@ export class AgentExecutor { this.baseConfig = baseConfig; this.toolRegistry = toolRegistry; - // 获取模型工厂 + // 使用 ProviderRegistry 获取模型工厂 const provider = agentInfo.model?.provider ?? baseConfig.provider; - this.getModel = getModelFactory(provider, { + const registry = getProviderRegistry(); + this.getModel = registry.getModelFactory(provider, { apiKey: baseConfig.apiKey, baseUrl: baseConfig.baseUrl, }); diff --git a/packages/core/src/core/agent.ts b/packages/core/src/core/agent.ts index 48235e2..b70b540 100644 --- a/packages/core/src/core/agent.ts +++ b/packages/core/src/core/agent.ts @@ -18,7 +18,7 @@ import { import type { AgentInfo, ImageData } from '../agent/types.js'; import { agentRegistry, AgentExecutor } from '../agent/index.js'; import { loadVisionConfig } from '../utils/config.js'; -import { getModelFactory } from './providers.js'; +import { getProviderRegistry } from '../provider/index.js'; import { getHookManager } from '../hooks/index.js'; import { getGitManager } from '../git/index.js'; @@ -49,7 +49,9 @@ export class Agent { this.config = config; this.originalSystemPrompt = config.systemPrompt; - this.getModel = getModelFactory(config.provider, { + // 使用 ProviderRegistry 获取模型工厂 + const registry = getProviderRegistry(); + this.getModel = registry.getModelFactory(config.provider, { apiKey: config.apiKey, baseUrl: config.baseUrl, }); diff --git a/packages/core/src/core/providers.ts b/packages/core/src/core/providers.ts deleted file mode 100644 index 82e9b8b..0000000 --- a/packages/core/src/core/providers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createDeepSeek } from '@ai-sdk/deepseek'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createQwen } from 'qwen-ai-provider-v5'; -import type { LanguageModel } from 'ai'; -import type { ProviderType } from '../types/index.js'; - -/** - * Provider 配置选项 - */ -export interface ProviderOptions { - apiKey: string; - baseUrl?: string; -} - -/** - * Provider 工厂函数类型 - */ -export type ProviderFactory = (options: ProviderOptions) => (model: string) => LanguageModel; - -/** - * 检查 baseUrl 是否为阿里云百炼/DashScope - */ -function isDashScopeUrl(baseUrl?: string): boolean { - if (!baseUrl) return false; - return baseUrl.includes('dashscope'); -} - -/** - * Provider 注册表 - * 支持 Anthropic、DeepSeek、OpenAI 及 OpenAI 兼容 API(如阿里云百炼) - */ -export const providers: Record = { - anthropic: ({ apiKey, baseUrl }) => { - const client = createAnthropic({ apiKey, baseURL: baseUrl }); - return (model) => client(model); - }, - deepseek: ({ apiKey, baseUrl }) => { - const client = createDeepSeek({ apiKey, baseURL: baseUrl }); - return (model) => client(model); - }, - openai: ({ apiKey, baseUrl }) => { - // 如果是百炼的 URL,使用 qwen provider - if (isDashScopeUrl(baseUrl)) { - const client = createQwen({ apiKey, baseURL: baseUrl }); - return (model) => client(model); - } - const client = createOpenAI({ apiKey, baseURL: baseUrl }); - return (model) => client(model); - }, -}; - -/** - * 获取模型工厂函数 - */ -export function getModelFactory( - provider: ProviderType, - options: ProviderOptions -): (model: string) => LanguageModel { - const factory = providers[provider]; - if (!factory) { - throw new Error(`不支持的 provider: ${provider}`); - } - return factory(options); -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf8bf17..dbfb956 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -158,3 +158,37 @@ export { loadMCPConfig, createMCPToolAdapter, } from './mcp/index.js'; + +// Provider +export { + ProviderRegistry, + providerRegistry, + getProviderRegistry, + builtinProviders, + getBuiltinProviders, + getBuiltinProvider, + isBuiltinProvider, + loadProvidersConfig, + saveProvidersConfig, + resolveApiKey, + testOpenAICompatibleConnection, + createOpenAICompatibleFactory, + isValidProviderId, + isValidUrl, +} from './provider/index.js'; + +export type { + BuiltinProviderType, + ProviderType, + ModelCapabilities, + ModelInfo, + ProviderInfo, + ProviderConfig, + CustomProviderDefinition, + ConnectionTestResult, + ProviderFactory, + RegisteredProvider, + ProvidersConfigFile, + ProviderListItem, + ProviderDetail, +} from './provider/index.js'; diff --git a/packages/core/src/provider/builtin/anthropic.ts b/packages/core/src/provider/builtin/anthropic.ts new file mode 100644 index 0000000..745e575 --- /dev/null +++ b/packages/core/src/provider/builtin/anthropic.ts @@ -0,0 +1,50 @@ +/** + * Anthropic Provider Definition + */ + +import { createAnthropic } from '@ai-sdk/anthropic'; +import type { ProviderInfo, ProviderFactory } from '../types.js'; + +export const anthropicProvider: ProviderInfo = { + id: 'anthropic', + name: 'Anthropic', + description: 'Claude AI models by Anthropic', + builtin: true, + apiKeyEnvVar: 'ANTHROPIC_API_KEY', + models: [ + { + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + capabilities: { vision: true, functionCalling: true, streaming: true }, + contextWindow: 200000, + maxOutput: 8192, + }, + { + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + capabilities: { vision: true, functionCalling: true, streaming: true }, + contextWindow: 200000, + maxOutput: 8192, + }, + { + id: 'claude-3-opus-20240229', + name: 'Claude 3 Opus', + capabilities: { vision: true, functionCalling: true, streaming: true }, + contextWindow: 200000, + maxOutput: 4096, + }, + { + id: 'claude-3-haiku-20240307', + name: 'Claude 3 Haiku', + capabilities: { vision: true, functionCalling: true, streaming: true }, + contextWindow: 200000, + maxOutput: 4096, + }, + ], + allowCustomModels: true, +}; + +export const anthropicFactory: ProviderFactory = ({ apiKey, baseUrl }) => { + const client = createAnthropic({ apiKey, baseURL: baseUrl }); + return (model) => client(model); +}; diff --git a/packages/core/src/provider/builtin/deepseek.ts b/packages/core/src/provider/builtin/deepseek.ts new file mode 100644 index 0000000..5d52b77 --- /dev/null +++ b/packages/core/src/provider/builtin/deepseek.ts @@ -0,0 +1,43 @@ +/** + * DeepSeek Provider Definition + */ + +import { createDeepSeek } from '@ai-sdk/deepseek'; +import type { ProviderInfo, ProviderFactory } from '../types.js'; + +export const deepseekProvider: ProviderInfo = { + id: 'deepseek', + name: 'DeepSeek', + description: 'DeepSeek AI models', + builtin: true, + apiKeyEnvVar: 'DEEPSEEK_API_KEY', + models: [ + { + id: 'deepseek-chat', + name: 'DeepSeek Chat', + capabilities: { vision: false, functionCalling: true, streaming: true }, + contextWindow: 64000, + maxOutput: 8192, + }, + { + id: 'deepseek-coder', + name: 'DeepSeek Coder', + capabilities: { vision: false, functionCalling: true, streaming: true }, + contextWindow: 64000, + maxOutput: 8192, + }, + { + id: 'deepseek-reasoner', + name: 'DeepSeek Reasoner', + capabilities: { vision: false, functionCalling: false, streaming: true }, + contextWindow: 64000, + maxOutput: 8192, + }, + ], + allowCustomModels: true, +}; + +export const deepseekFactory: ProviderFactory = ({ apiKey, baseUrl }) => { + const client = createDeepSeek({ apiKey, baseURL: baseUrl }); + return (model) => client(model); +}; diff --git a/packages/core/src/provider/builtin/index.ts b/packages/core/src/provider/builtin/index.ts new file mode 100644 index 0000000..93647e0 --- /dev/null +++ b/packages/core/src/provider/builtin/index.ts @@ -0,0 +1,50 @@ +/** + * Builtin Providers + * + * 内置提供商定义 + */ + +import { anthropicProvider, anthropicFactory } from './anthropic.js'; +import { openaiProvider, openaiFactory } from './openai.js'; +import { deepseekProvider, deepseekFactory } from './deepseek.js'; +import type { ProviderInfo, ProviderFactory } from '../types.js'; + +export interface BuiltinProvider { + info: ProviderInfo; + factory: ProviderFactory; +} + +/** 所有内置提供商 */ +export const builtinProviders: Record = { + anthropic: { + info: anthropicProvider, + factory: anthropicFactory, + }, + openai: { + info: openaiProvider, + factory: openaiFactory, + }, + deepseek: { + info: deepseekProvider, + factory: deepseekFactory, + }, +}; + +/** 获取所有内置提供商列表 */ +export function getBuiltinProviders(): BuiltinProvider[] { + return Object.values(builtinProviders); +} + +/** 获取内置提供商 */ +export function getBuiltinProvider(id: string): BuiltinProvider | undefined { + return builtinProviders[id]; +} + +/** 检查是否为内置提供商 */ +export function isBuiltinProvider(id: string): boolean { + return id in builtinProviders; +} + +export { anthropicProvider, anthropicFactory } from './anthropic.js'; +export { openaiProvider, openaiFactory } from './openai.js'; +export { deepseekProvider, deepseekFactory } from './deepseek.js'; diff --git a/packages/core/src/provider/builtin/openai.ts b/packages/core/src/provider/builtin/openai.ts new file mode 100644 index 0000000..8c7a7a8 --- /dev/null +++ b/packages/core/src/provider/builtin/openai.ts @@ -0,0 +1,71 @@ +/** + * OpenAI Provider Definition + */ + +import { createOpenAI } from '@ai-sdk/openai'; +import { createQwen } from 'qwen-ai-provider-v5'; +import type { ProviderInfo, ProviderFactory } from '../types.js'; + +export const openaiProvider: ProviderInfo = { + id: 'openai', + name: 'OpenAI', + description: 'GPT models by OpenAI (also supports OpenAI-compatible APIs)', + builtin: true, + apiKeyEnvVar: 'OPENAI_API_KEY', + models: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + capabilities: { vision: true, functionCalling: true, streaming: true }, + contextWindow: 128000, + maxOutput: 16384, + }, + { + id: 'gpt-4o-mini', + name: 'GPT-4o Mini', + capabilities: { vision: true, functionCalling: true, streaming: true }, + contextWindow: 128000, + maxOutput: 16384, + }, + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + capabilities: { vision: true, functionCalling: true, streaming: true }, + contextWindow: 128000, + maxOutput: 4096, + }, + { + id: 'o1', + name: 'o1', + capabilities: { vision: false, functionCalling: false, streaming: true }, + contextWindow: 200000, + maxOutput: 100000, + }, + { + id: 'o1-mini', + name: 'o1 Mini', + capabilities: { vision: false, functionCalling: false, streaming: true }, + contextWindow: 128000, + maxOutput: 65536, + }, + ], + allowCustomModels: true, +}; + +/** + * 检查 baseUrl 是否为阿里云百炼/DashScope + */ +function isDashScopeUrl(baseUrl?: string): boolean { + if (!baseUrl) return false; + return baseUrl.includes('dashscope'); +} + +export const openaiFactory: ProviderFactory = ({ apiKey, baseUrl }) => { + // 如果是百炼的 URL,使用 qwen provider + if (isDashScopeUrl(baseUrl)) { + const client = createQwen({ apiKey, baseURL: baseUrl }); + return (model) => client(model); + } + const client = createOpenAI({ apiKey, baseURL: baseUrl }); + return (model) => client(model); +}; diff --git a/packages/core/src/provider/config.ts b/packages/core/src/provider/config.ts new file mode 100644 index 0000000..eab73a3 --- /dev/null +++ b/packages/core/src/provider/config.ts @@ -0,0 +1,110 @@ +/** + * Provider Configuration Persistence + * + * 提供商配置持久化 + */ + +import { existsSync } from 'node:fs'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import type { ProvidersConfigFile, CustomProviderDefinition, ProviderConfig } from './types.js'; + +/** 配置目录名 */ +const CONFIG_DIR = '.ai-terminal-assistant'; + +/** 配置文件名 */ +const CONFIG_FILE = 'providers.json'; + +/** + * 获取配置目录路径 + */ +export function getConfigDir(workdir?: string): string { + return workdir ? join(workdir, CONFIG_DIR) : join(homedir(), CONFIG_DIR); +} + +/** + * 获取配置文件路径 + */ +export function getConfigPath(workdir?: string): string { + return join(getConfigDir(workdir), CONFIG_FILE); +} + +/** + * 加载提供商配置 + */ +export async function loadProvidersConfig(workdir?: string): Promise { + const configPath = getConfigPath(workdir); + + if (!existsSync(configPath)) { + return { providers: {}, configs: {} }; + } + + try { + const content = await readFile(configPath, 'utf-8'); + const config = JSON.parse(content) as ProvidersConfigFile; + return { + providers: config.providers ?? {}, + configs: config.configs ?? {}, + }; + } catch { + // 解析失败返回空配置 + return { providers: {}, configs: {} }; + } +} + +/** + * 保存提供商配置 + */ +export async function saveProvidersConfig( + config: ProvidersConfigFile, + workdir?: string +): Promise { + const configDir = getConfigDir(workdir); + const configPath = getConfigPath(workdir); + + // 确保目录存在 + if (!existsSync(configDir)) { + await mkdir(configDir, { recursive: true }); + } + + await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); +} + +/** + * 获取提供商的 API Key + * 优先从环境变量获取,其次从配置获取 + */ +export function resolveApiKey(config?: ProviderConfig, envVar?: string): string | undefined { + // 优先使用配置中指定的环境变量 + if (config?.apiKeyEnvVar) { + const key = process.env[config.apiKeyEnvVar]; + if (key) return key; + } + + // 其次使用默认环境变量 + if (envVar) { + const key = process.env[envVar]; + if (key) return key; + } + + // 最后使用直接配置的 apiKey + return config?.apiKey; +} + +/** + * 合并自定义提供商定义和配置 + */ +export function mergeProviderConfig( + definition: CustomProviderDefinition, + config?: ProviderConfig +): CustomProviderDefinition & { enabled: boolean } { + return { + ...definition, + // 配置可以覆盖 baseUrl + baseUrl: config?.baseUrl ?? definition.baseUrl, + // 合并模型列表 + models: [...(definition.models ?? []), ...(config?.customModels ?? [])], + enabled: config?.enabled ?? true, + }; +} diff --git a/packages/core/src/provider/index.ts b/packages/core/src/provider/index.ts new file mode 100644 index 0000000..4b720ae --- /dev/null +++ b/packages/core/src/provider/index.ts @@ -0,0 +1,54 @@ +/** + * Provider Module + * + * 模型提供商管理模块 + */ + +// Types +export type { + BuiltinProviderType, + ProviderType, + ModelCapabilities, + ModelInfo, + ProviderInfo, + ProviderConfig, + CustomProviderDefinition, + ConnectionTestResult, + ProviderFactory, + RegisteredProvider, + ProvidersConfigFile, + ProviderListItem, + ProviderDetail, +} from './types.js'; + +// Registry +export { + ProviderRegistry, + providerRegistry, + getProviderRegistry, +} from './registry.js'; + +// Builtin providers +export { + builtinProviders, + getBuiltinProviders, + getBuiltinProvider, + isBuiltinProvider, +} from './builtin/index.js'; + +// Config utilities +export { + loadProvidersConfig, + saveProvidersConfig, + resolveApiKey, + getConfigDir, + getConfigPath, +} from './config.js'; + +// Utils +export { + testOpenAICompatibleConnection, + createOpenAICompatibleFactory, + isValidProviderId, + isValidUrl, +} from './utils.js'; diff --git a/packages/core/src/provider/registry.ts b/packages/core/src/provider/registry.ts new file mode 100644 index 0000000..96dfc10 --- /dev/null +++ b/packages/core/src/provider/registry.ts @@ -0,0 +1,442 @@ +/** + * Provider Registry + * + * 管理所有注册的提供商(内置 + 自定义) + */ + +import type { LanguageModel } from 'ai'; +import type { + ProviderInfo, + ProviderConfig, + CustomProviderDefinition, + RegisteredProvider, + ConnectionTestResult, + ProviderFactory, + ModelInfo, + ProviderListItem, + ProviderDetail, +} from './types.js'; +import { builtinProviders, isBuiltinProvider } from './builtin/index.js'; +import { + loadProvidersConfig, + saveProvidersConfig, + resolveApiKey, +} from './config.js'; +import { + testOpenAICompatibleConnection, + createOpenAICompatibleFactory, + isValidProviderId, + isValidUrl, +} from './utils.js'; + +/** + * Provider Registry + * 管理所有提供商的单例类 + */ +export class ProviderRegistry { + /** 已注册的提供商 */ + private providers: Map = new Map(); + + /** 提供商配置 */ + private configs: Map = new Map(); + + /** 自定义提供商定义 */ + private customDefinitions: Map = new Map(); + + /** 是否已完全初始化(包括用户配置) */ + private fullyInitialized = false; + + /** 工作目录 */ + private workdir?: string; + + constructor() { + // 同步初始化内置提供商(不需要异步加载) + this.initBuiltinProviders(); + } + + /** + * 同步初始化内置提供商 + */ + private initBuiltinProviders(): void { + for (const [id, builtin] of Object.entries(builtinProviders)) { + this.providers.set(id, { + info: builtin.info, + factory: builtin.factory, + }); + } + } + + /** + * 完整初始化 Registry + * 加载用户配置(自定义提供商和配置) + */ + async init(workdir?: string): Promise { + if (this.fullyInitialized) return; + + this.workdir = workdir; + + // 加载用户配置 + const config = await loadProvidersConfig(workdir); + + // 加载自定义提供商 + if (config.providers) { + for (const [id, definition] of Object.entries(config.providers)) { + this.registerCustomInternal(definition); + } + } + + // 加载提供商配置 + if (config.configs) { + for (const [id, providerConfig] of Object.entries(config.configs)) { + this.configs.set(id, providerConfig); + } + } + + this.fullyInitialized = true; + } + + /** + * 检查是否已完全初始化 + */ + isInitialized(): boolean { + return this.fullyInitialized; + } + + /** + * 确保内置提供商已加载(总是true,因为构造函数中已初始化) + * 保留此方法以便未来可能需要的检查 + */ + private ensureInitialized(): void { + // 内置提供商在构造函数中同步初始化,总是可用 + // 用户配置需要调用 init() 异步加载 + } + + /** + * 列出所有提供商 + */ + list(): ProviderInfo[] { + this.ensureInitialized(); + return Array.from(this.providers.values()).map((p) => p.info); + } + + /** + * 列出所有提供商(API 响应格式) + */ + listForApi(): ProviderListItem[] { + this.ensureInitialized(); + return Array.from(this.providers.entries()).map(([id, provider]) => { + const config = this.configs.get(id); + const apiKey = resolveApiKey(config, provider.info.apiKeyEnvVar); + const customModels = config?.customModels ?? []; + + return { + id, + name: provider.info.name, + description: provider.info.description, + builtin: provider.info.builtin, + enabled: config?.enabled ?? true, + hasApiKey: !!apiKey, + modelCount: provider.info.models.length + customModels.length, + }; + }); + } + + /** + * 获取提供商 + */ + get(id: string): RegisteredProvider | undefined { + this.ensureInitialized(); + return this.providers.get(id); + } + + /** + * 获取提供商信息 + */ + getInfo(id: string): ProviderInfo | undefined { + return this.get(id)?.info; + } + + /** + * 获取提供商详情(API 响应格式) + */ + getDetail(id: string): ProviderDetail | undefined { + this.ensureInitialized(); + const provider = this.providers.get(id); + if (!provider) return undefined; + + const config = this.configs.get(id); + const apiKey = resolveApiKey(config, provider.info.apiKeyEnvVar); + + return { + id, + name: provider.info.name, + description: provider.info.description, + builtin: provider.info.builtin, + baseUrl: config?.baseUrl ?? provider.info.baseUrl, + apiKeyEnvVar: provider.info.apiKeyEnvVar, + models: provider.info.models, + allowCustomModels: provider.info.allowCustomModels ?? false, + config: { + enabled: config?.enabled ?? true, + hasApiKey: !!apiKey, + baseUrl: config?.baseUrl, + customModels: config?.customModels ?? [], + }, + }; + } + + /** + * 检查提供商是否存在 + */ + has(id: string): boolean { + this.ensureInitialized(); + return this.providers.has(id); + } + + /** + * 内部注册自定义提供商 + */ + private registerCustomInternal(definition: CustomProviderDefinition): void { + const factory = createOpenAICompatibleFactory(definition.baseUrl); + + const info: ProviderInfo = { + id: definition.id, + name: definition.name, + description: definition.description, + builtin: false, + baseUrl: definition.baseUrl, + apiKeyEnvVar: definition.apiKeyEnvVar, + models: definition.models ?? [], + allowCustomModels: definition.allowCustomModels ?? true, + }; + + this.providers.set(definition.id, { info, factory }); + this.customDefinitions.set(definition.id, definition); + } + + /** + * 注册自定义提供商 + */ + registerCustom(definition: CustomProviderDefinition): void { + this.ensureInitialized(); + + // 验证 ID + if (!isValidProviderId(definition.id)) { + throw new Error(`Invalid provider ID: ${definition.id}`); + } + + // 不能覆盖内置提供商 + if (isBuiltinProvider(definition.id)) { + throw new Error(`Cannot override builtin provider: ${definition.id}`); + } + + // 验证 URL + if (!isValidUrl(definition.baseUrl)) { + throw new Error(`Invalid base URL: ${definition.baseUrl}`); + } + + this.registerCustomInternal(definition); + } + + /** + * 移除自定义提供商 + */ + removeCustom(id: string): boolean { + this.ensureInitialized(); + + // 不能删除内置提供商 + if (isBuiltinProvider(id)) { + throw new Error(`Cannot remove builtin provider: ${id}`); + } + + const removed = this.providers.delete(id); + this.customDefinitions.delete(id); + this.configs.delete(id); + + return removed; + } + + /** + * 设置提供商配置 + */ + setConfig(id: string, config: ProviderConfig): void { + this.ensureInitialized(); + + if (!this.providers.has(id)) { + throw new Error(`Provider not found: ${id}`); + } + + this.configs.set(id, { ...config, id }); + } + + /** + * 获取提供商配置 + */ + getConfig(id: string): ProviderConfig | undefined { + this.ensureInitialized(); + return this.configs.get(id); + } + + /** + * 获取所有配置 + */ + getAllConfigs(): Record { + this.ensureInitialized(); + return Object.fromEntries(this.configs); + } + + /** + * 获取提供商的模型列表 + */ + getModels(providerId: string): ModelInfo[] { + this.ensureInitialized(); + + const provider = this.providers.get(providerId); + if (!provider) return []; + + const config = this.configs.get(providerId); + const customModels = config?.customModels ?? []; + + return [...provider.info.models, ...customModels]; + } + + /** + * 添加自定义模型 + */ + addCustomModel(providerId: string, model: ModelInfo): void { + this.ensureInitialized(); + + if (!this.providers.has(providerId)) { + throw new Error(`Provider not found: ${providerId}`); + } + + const config = this.configs.get(providerId) ?? { id: providerId }; + const customModels = config.customModels ?? []; + + // 检查是否已存在 + if (customModels.some((m) => m.id === model.id)) { + throw new Error(`Model already exists: ${model.id}`); + } + + customModels.push(model); + this.configs.set(providerId, { ...config, customModels }); + } + + /** + * 移除自定义模型 + */ + removeCustomModel(providerId: string, modelId: string): boolean { + this.ensureInitialized(); + + const config = this.configs.get(providerId); + if (!config?.customModels) return false; + + const index = config.customModels.findIndex((m) => m.id === modelId); + if (index === -1) return false; + + config.customModels.splice(index, 1); + this.configs.set(providerId, config); + + return true; + } + + /** + * 测试提供商连接 + */ + async testConnection( + providerId: string, + apiKey?: string + ): Promise { + this.ensureInitialized(); + + const provider = this.providers.get(providerId); + if (!provider) { + return { success: false, error: `Provider not found: ${providerId}` }; + } + + const config = this.configs.get(providerId); + const resolvedApiKey = apiKey ?? resolveApiKey(config, provider.info.apiKeyEnvVar); + + if (!resolvedApiKey && !provider.info.baseUrl?.includes('localhost')) { + return { success: false, error: 'API key not configured' }; + } + + const baseUrl = config?.baseUrl ?? provider.info.baseUrl; + + // 对于自定义提供商,使用 OpenAI 兼容测试 + if (!provider.info.builtin && baseUrl) { + return testOpenAICompatibleConnection(resolvedApiKey ?? '', baseUrl); + } + + // 对于内置提供商,简单验证 API key 存在 + return { + success: !!resolvedApiKey, + error: resolvedApiKey ? undefined : 'API key not configured', + }; + } + + /** + * 获取模型工厂函数 + */ + getModelFactory( + providerId: string, + options?: { apiKey?: string; baseUrl?: string } + ): (model: string) => LanguageModel { + this.ensureInitialized(); + + const provider = this.providers.get(providerId); + if (!provider) { + throw new Error(`Provider not found: ${providerId}`); + } + + const config = this.configs.get(providerId); + const apiKey = options?.apiKey ?? resolveApiKey(config, provider.info.apiKeyEnvVar); + const baseUrl = options?.baseUrl ?? config?.baseUrl ?? provider.info.baseUrl; + + if (!apiKey) { + throw new Error(`API key not configured for provider: ${providerId}`); + } + + return provider.factory({ apiKey, baseUrl }); + } + + /** + * 保存配置到文件 + */ + async saveConfig(workdir?: string): Promise { + this.ensureInitialized(); + + const config = { + providers: Object.fromEntries(this.customDefinitions), + configs: Object.fromEntries(this.configs), + }; + + await saveProvidersConfig(config, workdir ?? this.workdir); + } + + /** + * 重新加载配置 + */ + async reloadConfig(workdir?: string): Promise { + this.fullyInitialized = false; + this.configs.clear(); + this.customDefinitions.clear(); + + // 清除自定义提供商,保留内置提供商 + for (const [id, provider] of this.providers.entries()) { + if (!provider.info.builtin) { + this.providers.delete(id); + } + } + + await this.init(workdir ?? this.workdir); + } +} + +/** 单例实例 */ +export const providerRegistry = new ProviderRegistry(); + +/** 获取 ProviderRegistry 实例 */ +export function getProviderRegistry(): ProviderRegistry { + return providerRegistry; +} diff --git a/packages/core/src/provider/types.ts b/packages/core/src/provider/types.ts new file mode 100644 index 0000000..b6ee0c9 --- /dev/null +++ b/packages/core/src/provider/types.ts @@ -0,0 +1,154 @@ +/** + * Provider Module Types + * + * 模型提供商类型定义 + */ + +import type { LanguageModel } from 'ai'; + +/** 内置提供商类型 */ +export type BuiltinProviderType = 'anthropic' | 'deepseek' | 'openai'; + +/** 提供商类型(内置 + 自定义) */ +export type ProviderType = BuiltinProviderType | string; + +/** 模型能力 */ +export interface ModelCapabilities { + /** 支持视觉/图片 */ + vision?: boolean; + /** 支持函数调用 */ + functionCalling?: boolean; + /** 支持流式输出 */ + streaming?: boolean; +} + +/** 模型信息 */ +export interface ModelInfo { + /** 模型 ID(如 'claude-sonnet-4-20250514') */ + id: string; + /** 显示名称(如 'Claude Sonnet 4') */ + name: string; + /** 模型能力 */ + capabilities?: ModelCapabilities; + /** 上下文窗口大小(tokens) */ + contextWindow?: number; + /** 最大输出 tokens */ + maxOutput?: number; +} + +/** 提供商元信息 */ +export interface ProviderInfo { + /** 提供商唯一 ID */ + id: string; + /** 显示名称 */ + name: string; + /** 描述 */ + description?: string; + /** 是否为内置提供商 */ + builtin: boolean; + /** API 基础 URL */ + baseUrl?: string; + /** API Key 环境变量名 */ + apiKeyEnvVar?: string; + /** 可用模型列表 */ + models: ModelInfo[]; + /** 是否允许自定义模型 */ + allowCustomModels?: boolean; +} + +/** 提供商配置(用户设置) */ +export interface ProviderConfig { + /** 提供商 ID */ + id: string; + /** API Key(直接存储,不推荐) */ + apiKey?: string; + /** API Key 环境变量名 */ + apiKeyEnvVar?: string; + /** 自定义 Base URL */ + baseUrl?: string; + /** 是否启用 */ + enabled?: boolean; + /** 用户添加的自定义模型 */ + customModels?: ModelInfo[]; +} + +/** 自定义提供商定义(用户注册时使用) */ +export interface CustomProviderDefinition { + /** 提供商唯一 ID */ + id: string; + /** 显示名称 */ + name: string; + /** 描述 */ + description?: string; + /** API 基础 URL(必填) */ + baseUrl: string; + /** API Key 环境变量名 */ + apiKeyEnvVar?: string; + /** 可用模型列表 */ + models?: ModelInfo[]; + /** 是否允许自定义模型 */ + allowCustomModels?: boolean; +} + +/** 连接测试结果 */ +export interface ConnectionTestResult { + /** 是否成功 */ + success: boolean; + /** 延迟(毫秒) */ + latency?: number; + /** 错误信息 */ + error?: string; +} + +/** Provider 工厂函数 */ +export type ProviderFactory = (config: { + apiKey: string; + baseUrl?: string; +}) => (model: string) => LanguageModel; + +/** 已注册的提供商(运行时) */ +export interface RegisteredProvider { + /** 提供商信息 */ + info: ProviderInfo; + /** 模型工厂函数 */ + factory: ProviderFactory; + /** 用户配置 */ + config?: ProviderConfig; +} + +/** 提供商配置文件格式 */ +export interface ProvidersConfigFile { + /** 自定义提供商定义 */ + providers?: Record; + /** 提供商配置(API Key、选项等) */ + configs?: Record; +} + +/** 提供商列表项(API 响应用) */ +export interface ProviderListItem { + id: string; + name: string; + description?: string; + builtin: boolean; + enabled: boolean; + hasApiKey: boolean; + modelCount: number; +} + +/** 提供商详情(API 响应用) */ +export interface ProviderDetail { + id: string; + name: string; + description?: string; + builtin: boolean; + baseUrl?: string; + apiKeyEnvVar?: string; + models: ModelInfo[]; + allowCustomModels: boolean; + config?: { + enabled: boolean; + hasApiKey: boolean; + baseUrl?: string; + customModels: ModelInfo[]; + }; +} diff --git a/packages/core/src/provider/utils.ts b/packages/core/src/provider/utils.ts new file mode 100644 index 0000000..5b3a3a7 --- /dev/null +++ b/packages/core/src/provider/utils.ts @@ -0,0 +1,131 @@ +/** + * Provider Utilities + * + * 提供商工具函数 + */ + +import { createOpenAI } from '@ai-sdk/openai'; +import type { ConnectionTestResult, ProviderFactory } from './types.js'; + +/** + * 测试提供商连接 + * 通过简单的模型列表请求测试连接是否正常 + */ +export async function testProviderConnection( + factory: ProviderFactory, + apiKey: string, + baseUrl?: string, + testModel?: string +): Promise { + const startTime = Date.now(); + + try { + // 创建模型实例 + const getModel = factory({ apiKey, baseUrl }); + const model = getModel(testModel ?? 'test'); + + // 尝试获取模型信息(这会触发连接验证) + // 由于不同提供商的验证方式不同,这里使用一个简单的方法 + // 实际上只是验证工厂函数能否正常创建模型 + if (!model) { + return { + success: false, + error: 'Failed to create model instance', + }; + } + + const latency = Date.now() - startTime; + + return { + success: true, + latency, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + latency: Date.now() - startTime, + }; + } +} + +/** + * 测试 OpenAI 兼容 API 连接 + * 通过 /v1/models 端点测试 + */ +export async function testOpenAICompatibleConnection( + apiKey: string, + baseUrl: string +): Promise { + const startTime = Date.now(); + + try { + // 构建 models 端点 URL + const modelsUrl = baseUrl.endsWith('/') + ? `${baseUrl}models` + : `${baseUrl}/models`; + + const response = await fetch(modelsUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + const latency = Date.now() - startTime; + + if (!response.ok) { + return { + success: false, + error: `HTTP ${response.status}: ${response.statusText}`, + latency, + }; + } + + return { + success: true, + latency, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error', + latency: Date.now() - startTime, + }; + } +} + +/** + * 创建 OpenAI 兼容的提供商工厂 + * 用于自定义 OpenAI 兼容服务(如 Ollama、vLLM 等) + */ +export function createOpenAICompatibleFactory(baseUrl: string): ProviderFactory { + return ({ apiKey }) => { + const client = createOpenAI({ + apiKey: apiKey || 'dummy-key', // 某些本地服务不需要 key + baseURL: baseUrl, + }); + return (model) => client(model); + }; +} + +/** + * 验证提供商 ID 格式 + */ +export function isValidProviderId(id: string): boolean { + // 只允许字母、数字、连字符和下划线 + return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(id); +} + +/** + * 验证 URL 格式 + */ +export function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index ff3e97e..1a476f3 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,4 +1,8 @@ import { z } from 'zod'; +import type { ProviderType } from '../provider/types.js'; + +// Re-export ProviderType from provider module +export type { ProviderType } from '../provider/types.js'; // 内容块类型(支持多模态) export interface TextContentBlock { @@ -65,9 +69,6 @@ export interface ToolCall { input: Record; } -// 支持的 Provider 类型 -export type ProviderType = 'anthropic' | 'deepseek' | 'openai'; - // Agent 配置 export interface AgentConfig { provider: ProviderType; diff --git a/packages/core/tests/unit/agent/executor-extended.test.ts b/packages/core/tests/unit/agent/executor-extended.test.ts index 7069519..9b6cc4d 100644 --- a/packages/core/tests/unit/agent/executor-extended.test.ts +++ b/packages/core/tests/unit/agent/executor-extended.test.ts @@ -35,10 +35,12 @@ vi.mock('../../../src/agent/permission-merger.js', () => ({ checkBashPermission: (...args: unknown[]) => mockCheckBashPermission(...args), })); -// Mock providers +// Mock provider registry const mockGetModelFactory = vi.fn(); -vi.mock('../../../src/core/providers.js', () => ({ - getModelFactory: (...args: unknown[]) => mockGetModelFactory(...args), +vi.mock('../../../src/provider/index.js', () => ({ + getProviderRegistry: () => ({ + getModelFactory: (...args: unknown[]) => mockGetModelFactory(...args), + }), })); import { AgentExecutor } from '../../../src/agent/executor.js'; diff --git a/packages/core/tests/unit/agent/executor.test.ts b/packages/core/tests/unit/agent/executor.test.ts index e03653a..1857675 100644 --- a/packages/core/tests/unit/agent/executor.test.ts +++ b/packages/core/tests/unit/agent/executor.test.ts @@ -116,7 +116,7 @@ describe('AgentExecutor - Agent 执行器', () => { it('不支持的 provider 抛出错误', () => { const config = { ...mockBaseConfig, provider: 'unknown' as any }; - expect(() => new AgentExecutor(mockAgentInfo, config, mockToolRegistry)).toThrow('不支持的 provider'); + expect(() => new AgentExecutor(mockAgentInfo, config, mockToolRegistry)).toThrow('Provider not found: unknown'); }); it('使用 Agent 指定的 provider', () => { diff --git a/packages/core/tests/unit/core/providers.test.ts b/packages/core/tests/unit/core/providers.test.ts deleted file mode 100644 index 2a1a2f4..0000000 --- a/packages/core/tests/unit/core/providers.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getModelFactory, providers } from '../../../src/core/providers.js'; - -// Mock AI SDK providers -vi.mock('@ai-sdk/anthropic', () => ({ - createAnthropic: vi.fn(() => { - const modelFn = (model: string) => ({ modelId: `anthropic:${model}` }); - return modelFn; - }), -})); - -vi.mock('@ai-sdk/deepseek', () => ({ - createDeepSeek: vi.fn(() => { - const modelFn = (model: string) => ({ modelId: `deepseek:${model}` }); - return modelFn; - }), -})); - -vi.mock('@ai-sdk/openai', () => ({ - createOpenAI: vi.fn(() => { - const modelFn = (model: string) => ({ modelId: `openai:${model}` }); - return modelFn; - }), -})); - -vi.mock('qwen-ai-provider-v5', () => ({ - createQwen: vi.fn(() => { - const modelFn = (model: string) => ({ modelId: `qwen:${model}` }); - return modelFn; - }), -})); - -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createDeepSeek } from '@ai-sdk/deepseek'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createQwen } from 'qwen-ai-provider-v5'; - -describe('providers', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('providers 注册表', () => { - it('包含所有支持的 provider 类型', () => { - expect(providers).toHaveProperty('anthropic'); - expect(providers).toHaveProperty('deepseek'); - expect(providers).toHaveProperty('openai'); - }); - - it('每个 provider 是一个工厂函数', () => { - expect(typeof providers.anthropic).toBe('function'); - expect(typeof providers.deepseek).toBe('function'); - expect(typeof providers.openai).toBe('function'); - }); - }); - - describe('Anthropic provider', () => { - it('创建 Anthropic 客户端', () => { - const factory = providers.anthropic({ - apiKey: 'test-api-key', - }); - - expect(createAnthropic).toHaveBeenCalledWith({ - apiKey: 'test-api-key', - baseURL: undefined, - }); - expect(typeof factory).toBe('function'); - }); - - it('支持自定义 baseUrl', () => { - providers.anthropic({ - apiKey: 'test-api-key', - baseUrl: 'https://custom.anthropic.com', - }); - - expect(createAnthropic).toHaveBeenCalledWith({ - apiKey: 'test-api-key', - baseURL: 'https://custom.anthropic.com', - }); - }); - - it('返回的工厂函数可以创建模型', () => { - const factory = providers.anthropic({ - apiKey: 'test-api-key', - }); - - const model = factory('claude-3-opus'); - expect(model).toEqual({ modelId: 'anthropic:claude-3-opus' }); - }); - }); - - describe('DeepSeek provider', () => { - it('创建 DeepSeek 客户端', () => { - const factory = providers.deepseek({ - apiKey: 'test-deepseek-key', - }); - - expect(createDeepSeek).toHaveBeenCalledWith({ - apiKey: 'test-deepseek-key', - baseURL: undefined, - }); - expect(typeof factory).toBe('function'); - }); - - it('支持自定义 baseUrl', () => { - providers.deepseek({ - apiKey: 'test-deepseek-key', - baseUrl: 'https://custom.deepseek.com', - }); - - expect(createDeepSeek).toHaveBeenCalledWith({ - apiKey: 'test-deepseek-key', - baseURL: 'https://custom.deepseek.com', - }); - }); - - it('返回的工厂函数可以创建模型', () => { - const factory = providers.deepseek({ - apiKey: 'test-deepseek-key', - }); - - const model = factory('deepseek-chat'); - expect(model).toEqual({ modelId: 'deepseek:deepseek-chat' }); - }); - }); - - describe('OpenAI provider', () => { - it('创建 OpenAI 客户端(标准 URL)', () => { - const factory = providers.openai({ - apiKey: 'test-openai-key', - }); - - expect(createOpenAI).toHaveBeenCalledWith({ - apiKey: 'test-openai-key', - baseURL: undefined, - }); - expect(createQwen).not.toHaveBeenCalled(); - expect(typeof factory).toBe('function'); - }); - - it('支持自定义 baseUrl(非 DashScope)', () => { - providers.openai({ - apiKey: 'test-openai-key', - baseUrl: 'https://custom.openai.com/v1', - }); - - expect(createOpenAI).toHaveBeenCalledWith({ - apiKey: 'test-openai-key', - baseURL: 'https://custom.openai.com/v1', - }); - expect(createQwen).not.toHaveBeenCalled(); - }); - - it('返回的工厂函数可以创建模型', () => { - const factory = providers.openai({ - apiKey: 'test-openai-key', - }); - - const model = factory('gpt-4'); - expect(model).toEqual({ modelId: 'openai:gpt-4' }); - }); - }); - - describe('DashScope (Qwen) 检测', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('检测 dashscope URL 并使用 Qwen provider', () => { - providers.openai({ - apiKey: 'test-qwen-key', - baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - }); - - expect(createQwen).toHaveBeenCalledWith({ - apiKey: 'test-qwen-key', - baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - }); - expect(createOpenAI).not.toHaveBeenCalled(); - }); - - it('检测包含 dashscope 的任意 URL', () => { - providers.openai({ - apiKey: 'test-qwen-key', - baseUrl: 'https://api.dashscope.example.com/v1', - }); - - expect(createQwen).toHaveBeenCalled(); - expect(createOpenAI).not.toHaveBeenCalled(); - }); - - it('Qwen 工厂函数可以创建模型', () => { - const factory = providers.openai({ - apiKey: 'test-qwen-key', - baseUrl: 'https://dashscope.aliyuncs.com/v1', - }); - - const model = factory('qwen-turbo'); - expect(model).toEqual({ modelId: 'qwen:qwen-turbo' }); - }); - }); -}); - -describe('getModelFactory', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('获取 Anthropic 模型工厂', () => { - const factory = getModelFactory('anthropic', { - apiKey: 'test-key', - }); - - expect(typeof factory).toBe('function'); - expect(createAnthropic).toHaveBeenCalled(); - }); - - it('获取 DeepSeek 模型工厂', () => { - const factory = getModelFactory('deepseek', { - apiKey: 'test-key', - }); - - expect(typeof factory).toBe('function'); - expect(createDeepSeek).toHaveBeenCalled(); - }); - - it('获取 OpenAI 模型工厂', () => { - const factory = getModelFactory('openai', { - apiKey: 'test-key', - }); - - expect(typeof factory).toBe('function'); - expect(createOpenAI).toHaveBeenCalled(); - }); - - it('传递正确的选项给 provider', () => { - getModelFactory('anthropic', { - apiKey: 'my-api-key', - baseUrl: 'https://my-proxy.com', - }); - - expect(createAnthropic).toHaveBeenCalledWith({ - apiKey: 'my-api-key', - baseURL: 'https://my-proxy.com', - }); - }); - - it('不支持的 provider 抛出错误', () => { - expect(() => { - getModelFactory('unsupported' as any, { - apiKey: 'test-key', - }); - }).toThrow('不支持的 provider: unsupported'); - }); - - it('返回的工厂函数可以创建模型实例', () => { - const factory = getModelFactory('anthropic', { - apiKey: 'test-key', - }); - - const model = factory('claude-3-sonnet'); - expect(model).toEqual({ modelId: 'anthropic:claude-3-sonnet' }); - }); -}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f013a96..90ffc2c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,7 +9,7 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { createBunWebSocket } from 'hono/bun'; -import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter } from './routes/index.js'; +import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter } from './routes/index.js'; import { handleWebSocket, handleWebSocketMessage, @@ -87,6 +87,7 @@ api.route('/mcp', mcpRouter); api.route('/hooks', hooksRouter); api.route('/agents', agentsRouter); api.route('/checkpoints', checkpointsRouter); +api.route('/providers', providersRouter); // SSE 事件流 api.get('/sessions/:id/events', handleSSE); diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index f43cddb..b5e11a9 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -13,3 +13,4 @@ export { mcpRouter } from './mcp.js'; export { hooksRouter } from './hooks.js'; export { agentsRouter } from './agents.js'; export { checkpointsRouter } from './checkpoints.js'; +export { providersRouter } from './providers.js'; diff --git a/packages/server/src/routes/providers.ts b/packages/server/src/routes/providers.ts new file mode 100644 index 0000000..05f393b --- /dev/null +++ b/packages/server/src/routes/providers.ts @@ -0,0 +1,425 @@ +/** + * Providers API Routes + * + * 模型提供商管理相关的 REST API + */ + +import { Hono } from 'hono'; + +// Types from core - dynamically import to avoid build dependency +interface ProviderListItem { + id: string; + name: string; + description?: string; + builtin: boolean; + enabled: boolean; + hasApiKey: boolean; + modelCount: number; +} + +interface ModelInfo { + id: string; + name: string; + capabilities?: { + vision?: boolean; + functionCalling?: boolean; + streaming?: boolean; + }; + contextWindow?: number; + maxOutput?: number; +} + +interface ProviderDetail { + id: string; + name: string; + description?: string; + builtin: boolean; + baseUrl?: string; + apiKeyEnvVar?: string; + models: ModelInfo[]; + allowCustomModels: boolean; + config: { + enabled: boolean; + hasApiKey: boolean; + baseUrl?: string; + customModels: ModelInfo[]; + }; +} + +interface CustomProviderDefinition { + id: string; + name: string; + description?: string; + baseUrl: string; + apiKeyEnvVar?: string; + models?: ModelInfo[]; + allowCustomModels?: boolean; +} + +interface ProviderConfig { + id?: string; + apiKey?: string; + apiKeyEnvVar?: string; + baseUrl?: string; + enabled?: boolean; + customModels?: ModelInfo[]; +} + +interface ConnectionTestResult { + success: boolean; + latency?: number; + error?: string; +} + +export const providersRouter = new Hono(); + +// Core module reference +let coreModule: any = null; + +/** + * Load core module dynamically + */ +async function getCoreModule() { + if (!coreModule) { + try { + const corePath = '@ai-assistant/core'; + coreModule = await import(corePath); + } catch { + return null; + } + } + return coreModule; +} + +/** + * GET /providers - List all providers + */ +providersRouter.get('/', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + try { + const registry = core.getProviderRegistry(); + const providers: ProviderListItem[] = registry.listForApi(); + + return c.json({ + success: true, + data: providers, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to list providers', + }, + 500 + ); + } +}); + +/** + * GET /providers/:id - Get provider detail + */ +providersRouter.get('/:id', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + const id = c.req.param('id'); + + try { + const registry = core.getProviderRegistry(); + const detail: ProviderDetail | undefined = registry.getDetail(id); + + if (!detail) { + return c.json({ success: false, error: `Provider not found: ${id}` }, 404); + } + + return c.json({ + success: true, + data: detail, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get provider', + }, + 500 + ); + } +}); + +/** + * GET /providers/:id/models - Get provider's model list + */ +providersRouter.get('/:id/models', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + const id = c.req.param('id'); + + try { + const registry = core.getProviderRegistry(); + + if (!registry.has(id)) { + return c.json({ success: false, error: `Provider not found: ${id}` }, 404); + } + + const models: ModelInfo[] = registry.getModels(id); + + return c.json({ + success: true, + data: models, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get models', + }, + 500 + ); + } +}); + +/** + * POST /providers/:id/test - Test provider connection + */ +providersRouter.post('/:id/test', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + const id = c.req.param('id'); + + try { + const body = await c.req.json().catch(() => ({})); + const apiKey = body.apiKey as string | undefined; + + const registry = core.getProviderRegistry(); + const result: ConnectionTestResult = await registry.testConnection(id, apiKey); + + return c.json({ + success: true, + data: result, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Connection test failed', + }, + 500 + ); + } +}); + +/** + * POST /providers - Register custom provider + */ +providersRouter.post('/', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + try { + const body: CustomProviderDefinition = await c.req.json(); + + // Validate required fields + if (!body.id || !body.name || !body.baseUrl) { + return c.json( + { + success: false, + error: 'Missing required fields: id, name, baseUrl', + }, + 400 + ); + } + + const registry = core.getProviderRegistry(); + registry.registerCustom(body); + await registry.saveConfig(); + + return c.json({ + success: true, + message: `Provider ${body.id} registered`, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to register provider', + }, + 400 + ); + } +}); + +/** + * PUT /providers/:id - Update provider config + */ +providersRouter.put('/:id', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + const id = c.req.param('id'); + + try { + const body: ProviderConfig = await c.req.json(); + + const registry = core.getProviderRegistry(); + + if (!registry.has(id)) { + return c.json({ success: false, error: `Provider not found: ${id}` }, 404); + } + + registry.setConfig(id, { ...body, id }); + await registry.saveConfig(); + + return c.json({ + success: true, + message: `Provider ${id} config updated`, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to update provider config', + }, + 400 + ); + } +}); + +/** + * DELETE /providers/:id - Delete custom provider + */ +providersRouter.delete('/:id', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + const id = c.req.param('id'); + + try { + const registry = core.getProviderRegistry(); + const removed = registry.removeCustom(id); + + if (!removed) { + return c.json({ success: false, error: `Provider not found: ${id}` }, 404); + } + + await registry.saveConfig(); + + return c.json({ + success: true, + message: `Provider ${id} removed`, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to remove provider', + }, + 400 + ); + } +}); + +/** + * POST /providers/:id/models - Add custom model + */ +providersRouter.post('/:id/models', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + const providerId = c.req.param('id'); + + try { + const body: ModelInfo = await c.req.json(); + + // Validate required fields + if (!body.id || !body.name) { + return c.json( + { + success: false, + error: 'Missing required fields: id, name', + }, + 400 + ); + } + + const registry = core.getProviderRegistry(); + registry.addCustomModel(providerId, body); + await registry.saveConfig(); + + return c.json({ + success: true, + message: `Model ${body.id} added to ${providerId}`, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to add model', + }, + 400 + ); + } +}); + +/** + * DELETE /providers/:id/models/:modelId - Delete custom model + */ +providersRouter.delete('/:id/models/:modelId', async (c) => { + const core = await getCoreModule(); + if (!core) { + return c.json({ success: false, error: 'Core module not available' }, 503); + } + + const providerId = c.req.param('id'); + const modelId = c.req.param('modelId'); + + try { + const registry = core.getProviderRegistry(); + const removed = registry.removeCustomModel(providerId, modelId); + + if (!removed) { + return c.json( + { + success: false, + error: `Model ${modelId} not found in ${providerId}`, + }, + 404 + ); + } + + await registry.saveConfig(); + + return c.json({ + success: true, + message: `Model ${modelId} removed from ${providerId}`, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to remove model', + }, + 400 + ); + } +}); diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index e4094b1..a34764f 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -38,6 +38,13 @@ import type { SafetyCheckResult, UnrevertStatus, UnrevertResult, + // Provider types + ProviderListItem, + ProviderDetail, + ModelInfo, + CustomProviderDefinition, + ProviderConfig, + ConnectionTestResult, } from './types.js'; // Re-export types @@ -96,6 +103,16 @@ export type { CheckpointStats, UnrevertStatus, UnrevertResult, + // Provider types + BuiltinProviderType, + ProviderType, + ModelCapabilities, + ModelInfo, + ProviderListItem, + ProviderDetail, + CustomProviderDefinition, + ProviderConfig, + ConnectionTestResult, } from './types.js'; // API Configuration @@ -804,3 +821,118 @@ export async function getMessageCheckpoints(messageId: string): Promise<{ }> { return request('GET', `/checkpoints/messages/${encodeURIComponent(messageId)}`); } + +// ============ Providers API ============ + +/** + * 获取所有提供商列表 + */ +export async function listProviders(): Promise<{ + success: boolean; + data: ProviderListItem[]; + error?: string; +}> { + return request('GET', '/providers'); +} + +/** + * 获取单个提供商详情 + */ +export async function getProvider(id: string): Promise<{ + success: boolean; + data?: ProviderDetail; + error?: string; +}> { + return request('GET', `/providers/${encodeURIComponent(id)}`); +} + +/** + * 获取提供商的模型列表 + */ +export async function getProviderModels(id: string): Promise<{ + success: boolean; + data: ModelInfo[]; + error?: string; +}> { + return request('GET', `/providers/${encodeURIComponent(id)}/models`); +} + +/** + * 测试提供商连接 + */ +export async function testProviderConnection( + id: string, + apiKey?: string +): Promise<{ + success: boolean; + data: ConnectionTestResult; + error?: string; +}> { + return request('POST', `/providers/${encodeURIComponent(id)}/test`, apiKey ? { apiKey } : {}); +} + +/** + * 注册自定义提供商 + */ +export async function registerProvider( + definition: CustomProviderDefinition +): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + return request('POST', '/providers', definition); +} + +/** + * 更新提供商配置 + */ +export async function updateProviderConfig( + id: string, + config: ProviderConfig +): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + return request('PUT', `/providers/${encodeURIComponent(id)}`, config); +} + +/** + * 删除自定义提供商 + */ +export async function deleteProvider(id: string): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + return request('DELETE', `/providers/${encodeURIComponent(id)}`); +} + +/** + * 添加自定义模型 + */ +export async function addProviderModel( + providerId: string, + model: ModelInfo +): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + return request('POST', `/providers/${encodeURIComponent(providerId)}/models`, model); +} + +/** + * 删除自定义模型 + */ +export async function deleteProviderModel( + providerId: string, + modelId: string +): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + return request('DELETE', `/providers/${encodeURIComponent(providerId)}/models/${encodeURIComponent(modelId)}`); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 6e678a8..f1552b1 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -590,3 +590,124 @@ export interface UnrevertResult { /** 错误信息 */ error?: string; } + +// ============ Provider 相关 ============ + +/** 内置提供商类型 */ +export type BuiltinProviderType = 'anthropic' | 'deepseek' | 'openai'; + +/** 提供商类型(内置 + 自定义) */ +export type ProviderType = BuiltinProviderType | string; + +/** 模型能力 */ +export interface ModelCapabilities { + /** 是否支持视觉 */ + vision?: boolean; + /** 是否支持函数调用 */ + functionCalling?: boolean; + /** 是否支持流式输出 */ + streaming?: boolean; +} + +/** 模型信息 */ +export interface ModelInfo { + /** 模型 ID */ + id: string; + /** 显示名称 */ + name: string; + /** 模型能力 */ + capabilities?: ModelCapabilities; + /** 上下文窗口大小 */ + contextWindow?: number; + /** 最大输出 Token */ + maxOutput?: number; +} + +/** 提供商列表项 */ +export interface ProviderListItem { + /** 提供商 ID */ + id: string; + /** 显示名称 */ + name: string; + /** 描述 */ + description?: string; + /** 是否为内置提供商 */ + builtin: boolean; + /** 是否启用 */ + enabled: boolean; + /** 是否配置了 API Key */ + hasApiKey: boolean; + /** 模型数量 */ + modelCount: number; +} + +/** 提供商详情 */ +export interface ProviderDetail { + /** 提供商 ID */ + id: string; + /** 显示名称 */ + name: string; + /** 描述 */ + description?: string; + /** 是否为内置提供商 */ + builtin: boolean; + /** API 基础 URL */ + baseUrl?: string; + /** API Key 环境变量名 */ + apiKeyEnvVar?: string; + /** 可用模型列表 */ + models: ModelInfo[]; + /** 是否允许自定义模型 */ + allowCustomModels: boolean; + /** 配置信息 */ + config: { + enabled: boolean; + hasApiKey: boolean; + baseUrl?: string; + customModels: ModelInfo[]; + }; +} + +/** 自定义提供商定义 */ +export interface CustomProviderDefinition { + /** 提供商 ID */ + id: string; + /** 显示名称 */ + name: string; + /** 描述 */ + description?: string; + /** API 基础 URL(必填) */ + baseUrl: string; + /** API Key 环境变量名 */ + apiKeyEnvVar?: string; + /** 预设模型列表 */ + models?: ModelInfo[]; + /** 是否允许自定义模型 */ + allowCustomModels?: boolean; +} + +/** 提供商配置(用户设置) */ +export interface ProviderConfig { + /** 提供商 ID */ + id?: string; + /** API Key */ + apiKey?: string; + /** API Key 环境变量名 */ + apiKeyEnvVar?: string; + /** 自定义 API 基础 URL */ + baseUrl?: string; + /** 是否启用 */ + enabled?: boolean; + /** 自定义模型列表 */ + customModels?: ModelInfo[]; +} + +/** 连接测试结果 */ +export interface ConnectionTestResult { + /** 是否成功 */ + success: boolean; + /** 延迟(毫秒) */ + latency?: number; + /** 错误信息 */ + error?: string; +} diff --git a/packages/ui/src/components/ProvidersPanel.tsx b/packages/ui/src/components/ProvidersPanel.tsx new file mode 100644 index 0000000..0170a38 --- /dev/null +++ b/packages/ui/src/components/ProvidersPanel.tsx @@ -0,0 +1,750 @@ +/** + * ProvidersPanel Component + * + * Provider management panel: list providers, test connections, manage custom providers/models + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + X, + RefreshCw, + Server, + Plus, + ChevronDown, + ChevronRight, + Trash2, + Check, + AlertCircle, + Loader2, + Cpu, + Key, + Globe, + Zap, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import { Input } from '../primitives/Input'; +import { Skeleton } from './Skeleton'; +import { + listProviders, + getProvider, + testProviderConnection, + registerProvider, + deleteProvider, + addProviderModel, + deleteProviderModel, + type ProviderListItem, + type ProviderDetail, + type ModelInfo, + type CustomProviderDefinition, +} from '../api/client.js'; + +interface ProvidersPanelProps { + onClose: () => void; + /** Enable responsive layout */ + responsive?: boolean; +} + +export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelProps) { + // Data state + const [providers, setProviders] = useState([]); + const [expandedProviders, setExpandedProviders] = useState>(new Set()); + const [providerDetails, setProviderDetails] = useState>({}); + + // UI state + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [testingProvider, setTestingProvider] = useState(null); + const [testResults, setTestResults] = useState>({}); + + // Editor state + const [showAddProvider, setShowAddProvider] = useState(false); + const [showAddModel, setShowAddModel] = useState(null); + const [newProvider, setNewProvider] = useState>({}); + const [newModel, setNewModel] = useState>({}); + + // Load provider list + const loadProviders = useCallback(async (showToast = false) => { + try { + const result = await listProviders(); + if (result.success) { + setProviders(result.data); + if (showToast) { + toast.success('Providers refreshed'); + } + } else { + toast.error(result.error || 'Failed to load providers'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load providers'); + } + }, []); + + // Initial load + useEffect(() => { + setLoading(true); + loadProviders().finally(() => setLoading(false)); + }, [loadProviders]); + + // Refresh + const handleRefresh = async () => { + setRefreshing(true); + await loadProviders(true); + setRefreshing(false); + }; + + // Load provider detail + const loadProviderDetail = async (id: string) => { + if (providerDetails[id]) return providerDetails[id]; + + try { + const result = await getProvider(id); + if (result.success && result.data) { + setProviderDetails((prev) => ({ ...prev, [id]: result.data! })); + return result.data; + } + } catch (err) { + toast.error(`Failed to load provider details: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + return null; + }; + + // Toggle expanded + const toggleExpanded = async (id: string) => { + const newExpanded = new Set(expandedProviders); + if (newExpanded.has(id)) { + newExpanded.delete(id); + } else { + newExpanded.add(id); + await loadProviderDetail(id); + } + setExpandedProviders(newExpanded); + }; + + // Test connection + const handleTestConnection = async (id: string) => { + setTestingProvider(id); + try { + const result = await testProviderConnection(id); + if (result.success) { + setTestResults((prev) => ({ ...prev, [id]: result.data })); + if (result.data.success) { + toast.success(`${id} connection successful`); + } else { + toast.error(result.data.error || `${id} connection failed`); + } + } else { + setTestResults((prev) => ({ ...prev, [id]: { success: false, error: result.error } })); + toast.error(result.error || 'Test failed'); + } + } catch (err) { + const error = err instanceof Error ? err.message : 'Test failed'; + setTestResults((prev) => ({ ...prev, [id]: { success: false, error } })); + toast.error(error); + } finally { + setTestingProvider(null); + } + }; + + // Add custom provider + const handleAddProvider = async () => { + if (!newProvider.id || !newProvider.name || !newProvider.baseUrl) { + toast.error('ID, Name, and Base URL are required'); + return; + } + + try { + const result = await registerProvider(newProvider as CustomProviderDefinition); + if (result.success) { + toast.success(`Provider ${newProvider.id} added`); + setShowAddProvider(false); + setNewProvider({}); + loadProviders(); + } else { + toast.error(result.error || 'Failed to add provider'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to add provider'); + } + }; + + // Delete custom provider + const handleDeleteProvider = async (id: string) => { + if (!confirm(`Are you sure you want to delete provider "${id}"?`)) return; + + try { + const result = await deleteProvider(id); + if (result.success) { + toast.success(`Provider ${id} deleted`); + loadProviders(); + setProviderDetails((prev) => { + const newDetails = { ...prev }; + delete newDetails[id]; + return newDetails; + }); + } else { + toast.error(result.error || 'Delete failed'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + // Add custom model + const handleAddModel = async (providerId: string) => { + if (!newModel.id || !newModel.name) { + toast.error('Model ID and Name are required'); + return; + } + + try { + const result = await addProviderModel(providerId, newModel as ModelInfo); + if (result.success) { + toast.success(`Model ${newModel.id} added to ${providerId}`); + setShowAddModel(null); + setNewModel({}); + // Reload provider detail + setProviderDetails((prev) => { + const newDetails = { ...prev }; + delete newDetails[providerId]; + return newDetails; + }); + await loadProviderDetail(providerId); + } else { + toast.error(result.error || 'Failed to add model'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to add model'); + } + }; + + // Delete custom model + const handleDeleteModel = async (providerId: string, modelId: string) => { + if (!confirm(`Are you sure you want to delete model "${modelId}"?`)) return; + + try { + const result = await deleteProviderModel(providerId, modelId); + if (result.success) { + toast.success(`Model ${modelId} deleted`); + // Reload provider detail + setProviderDetails((prev) => { + const newDetails = { ...prev }; + delete newDetails[providerId]; + return newDetails; + }); + await loadProviderDetail(providerId); + } else { + toast.error(result.error || 'Delete failed'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + // Statistics + const builtinProviders = providers.filter((p) => p.builtin); + const customProviders = providers.filter((p) => !p.builtin); + + // Loading skeleton + const LoadingSkeleton = () => ( +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); + + // Provider item component + const ProviderItem = ({ provider }: { provider: ProviderListItem }) => { + const isExpanded = expandedProviders.has(provider.id); + const isTesting = testingProvider === provider.id; + const testResult = testResults[provider.id]; + const detail = providerDetails[provider.id]; + + return ( + + {/* Provider Header */} +
toggleExpanded(provider.id)} + > + {/* Expand Icon */} + + + {/* Icon */} + {provider.builtin ? ( + + ) : ( + + )} + + {/* Info */} +
+
+ {provider.name} + + {provider.builtin ? 'Built-in' : 'Custom'} + +
+
+ {provider.modelCount} models + {provider.hasApiKey ? ( + + + Configured + + ) : ( + + + No API Key + + )} +
+
+ + {/* Status & Actions */} +
e.stopPropagation()}> + {/* Test Result */} + {testResult && ( + testResult.success ? ( + + ) : ( + + + + ) + )} + + {/* Test Button */} + + + {/* Delete (only for custom) */} + {!provider.builtin && ( + + )} +
+
+ + {/* Expanded Content */} + + {isExpanded && ( + +
+ {detail ? ( + <> + {/* Base URL */} + {detail.baseUrl && ( +
+ Base URL:{' '} + {detail.baseUrl} +
+ )} + + {/* API Key Env Var */} + {detail.apiKeyEnvVar && ( +
+ API Key Env:{' '} + {detail.apiKeyEnvVar} +
+ )} + + {/* Models */} +
+
+ + + Models ({detail.models.length + detail.config.customModels.length}) + + {detail.allowCustomModels && ( + + )} +
+ + {/* Model List */} +
+ {/* Built-in models */} + {detail.models.map((model) => ( +
+
+ {model.name} + ({model.id}) +
+
+ {model.capabilities?.vision && ( + Vision + )} + {model.capabilities?.functionCalling && ( + Tools + )} +
+
+ ))} + + {/* Custom models */} + {detail.config.customModels.map((model) => ( +
+
+ {model.name} + ({model.id}) + custom +
+ +
+ ))} +
+
+ + ) : ( +
+ +
+ )} +
+
+ )} +
+
+ ); + }; + + return ( + + + e.stopPropagation()} + className={cn( + 'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-2xl mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +
+

+ + Model Providers +

+

+ {providers.length} providers ({builtinProviders.length} built-in, {customProviders.length} custom) +

+
+
+ + + +
+
+ + {/* Provider List */} +
+ {loading ? ( + + ) : providers.length === 0 ? ( +
+ +

No providers available

+
+ ) : ( + + {/* Built-in Providers */} + {builtinProviders.length > 0 && ( +
+

+ + Built-in Providers +

+
+ {builtinProviders.map((provider) => ( + + ))} +
+
+ )} + + {/* Custom Providers */} +
+

+ + Custom Providers +

+ {customProviders.length > 0 ? ( +
+ {customProviders.map((provider) => ( + + ))} +
+ ) : ( +
+

No custom providers yet

+ +
+ )} +
+
+ )} +
+ + {/* Footer */} +
+ Config stored in{' '} + ~/.ai-terminal-assistant/providers.json +
+ + + {/* Add Provider Dialog */} + + {showAddProvider && ( + setShowAddProvider(false)} + > + e.stopPropagation()} + className="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4 space-y-4" + > +

Add Custom Provider

+
+
+ + setNewProvider((p) => ({ ...p, id: e.target.value }))} + placeholder="provider-id" + /> +
+
+ + setNewProvider((p) => ({ ...p, name: e.target.value }))} + placeholder="My Provider" + /> +
+
+ + setNewProvider((p) => ({ ...p, baseUrl: e.target.value }))} + placeholder="http://localhost:11434/v1" + /> +
+
+ + setNewProvider((p) => ({ ...p, apiKeyEnvVar: e.target.value }))} + placeholder="OLLAMA_API_KEY" + /> +
+
+
+ + +
+
+
+ )} +
+ + {/* Add Model Dialog */} + + {showAddModel && ( + setShowAddModel(null)} + > + e.stopPropagation()} + className="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4 space-y-4" + > +

Add Custom Model

+
+
+ + setNewModel((m) => ({ ...m, id: e.target.value }))} + placeholder="llama3.2" + /> +
+
+ + setNewModel((m) => ({ ...m, name: e.target.value }))} + placeholder="Llama 3.2" + /> +
+
+
+ + +
+
+
+ )} +
+ + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 747dc32..2247038 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -80,6 +80,16 @@ export { cleanupCheckpoints, getSessionCheckpoints, getMessageCheckpoints, + // Providers API + listProviders, + getProvider, + getProviderModels, + testProviderConnection, + registerProvider, + updateProviderConfig, + deleteProvider, + addProviderModel, + deleteProviderModel, } from './api/client.js'; // Types @@ -141,6 +151,16 @@ export type { CheckpointStats, UnrevertStatus, UnrevertResult, + // Provider types + BuiltinProviderType, + ProviderType, + ModelCapabilities, + ModelInfo, + ProviderListItem, + ProviderDetail, + CustomProviderDefinition, + ProviderConfig, + ConnectionTestResult, } from './api/client.js'; // Primitives (shadcn/ui style) @@ -162,6 +182,7 @@ export { HookEditor } from './components/HookEditor.js'; export { AgentsPanel } from './components/AgentsPanel.js'; export { AgentEditor } from './components/AgentEditor.js'; export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js'; +export { ProvidersPanel } from './components/ProvidersPanel.js'; export { CheckpointPanel } from './components/CheckpointPanel.js'; export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js'; export { RestoreDialog } from './components/RestoreDialog.js';