feat(provider): 添加独立的 Provider 模块管理模型提供商
实现可扩展的 Provider 系统,支持动态注册自定义提供商: Core 模块 (packages/core/src/provider/): - types.ts: Provider 相关类型定义 - builtin/: 内置提供商 (Anthropic, OpenAI, DeepSeek) - registry.ts: ProviderRegistry 单例类 - config.ts: 配置持久化 (~/.ai-terminal-assistant/providers.json) - utils.ts: 连接测试等工具函数 Server API (packages/server/src/routes/providers.ts): - GET/POST/PUT/DELETE /providers 提供商管理 - POST /providers/:id/test 连接测试 - 自定义模型管理接口 Frontend (packages/ui/): - ProvidersPanel 组件用于管理提供商 - API client 函数和类型定义 主要功能: - 支持动态注册 OpenAI 兼容服务 (Ollama, vLLM 等) - 每个提供商独立的 API Key 配置 - 预设模型列表 + 自定义模型输入 - 连接测试验证
This commit is contained in:
@@ -16,7 +16,7 @@ import type {
|
|||||||
ImageData,
|
ImageData,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { checkBashPermission } from './permission-merger.js';
|
import { checkBashPermission } from './permission-merger.js';
|
||||||
import { getModelFactory } from '../core/providers.js';
|
import { getProviderRegistry } from '../provider/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 执行器
|
* Agent 执行器
|
||||||
@@ -37,9 +37,10 @@ export class AgentExecutor {
|
|||||||
this.baseConfig = baseConfig;
|
this.baseConfig = baseConfig;
|
||||||
this.toolRegistry = toolRegistry;
|
this.toolRegistry = toolRegistry;
|
||||||
|
|
||||||
// 获取模型工厂
|
// 使用 ProviderRegistry 获取模型工厂
|
||||||
const provider = agentInfo.model?.provider ?? baseConfig.provider;
|
const provider = agentInfo.model?.provider ?? baseConfig.provider;
|
||||||
this.getModel = getModelFactory(provider, {
|
const registry = getProviderRegistry();
|
||||||
|
this.getModel = registry.getModelFactory(provider, {
|
||||||
apiKey: baseConfig.apiKey,
|
apiKey: baseConfig.apiKey,
|
||||||
baseUrl: baseConfig.baseUrl,
|
baseUrl: baseConfig.baseUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import type { AgentInfo, ImageData } from '../agent/types.js';
|
import type { AgentInfo, ImageData } from '../agent/types.js';
|
||||||
import { agentRegistry, AgentExecutor } from '../agent/index.js';
|
import { agentRegistry, AgentExecutor } from '../agent/index.js';
|
||||||
import { loadVisionConfig } from '../utils/config.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 { getHookManager } from '../hooks/index.js';
|
||||||
import { getGitManager } from '../git/index.js';
|
import { getGitManager } from '../git/index.js';
|
||||||
|
|
||||||
@@ -49,7 +49,9 @@ export class Agent {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this.originalSystemPrompt = config.systemPrompt;
|
this.originalSystemPrompt = config.systemPrompt;
|
||||||
|
|
||||||
this.getModel = getModelFactory(config.provider, {
|
// 使用 ProviderRegistry 获取模型工厂
|
||||||
|
const registry = getProviderRegistry();
|
||||||
|
this.getModel = registry.getModelFactory(config.provider, {
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: config.baseUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<ProviderType, ProviderFactory> = {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -158,3 +158,37 @@ export {
|
|||||||
loadMCPConfig,
|
loadMCPConfig,
|
||||||
createMCPToolAdapter,
|
createMCPToolAdapter,
|
||||||
} from './mcp/index.js';
|
} 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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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<string, BuiltinProvider> = {
|
||||||
|
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';
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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<ProvidersConfigFile> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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<string, RegisteredProvider> = new Map();
|
||||||
|
|
||||||
|
/** 提供商配置 */
|
||||||
|
private configs: Map<string, ProviderConfig> = new Map();
|
||||||
|
|
||||||
|
/** 自定义提供商定义 */
|
||||||
|
private customDefinitions: Map<string, CustomProviderDefinition> = 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<void> {
|
||||||
|
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<string, ProviderConfig> {
|
||||||
|
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<ConnectionTestResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, CustomProviderDefinition>;
|
||||||
|
/** 提供商配置(API Key、选项等) */
|
||||||
|
configs?: Record<string, ProviderConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提供商列表项(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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ConnectionTestResult> {
|
||||||
|
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<ConnectionTestResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { z } from 'zod';
|
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 {
|
export interface TextContentBlock {
|
||||||
@@ -65,9 +69,6 @@ export interface ToolCall {
|
|||||||
input: Record<string, unknown>;
|
input: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持的 Provider 类型
|
|
||||||
export type ProviderType = 'anthropic' | 'deepseek' | 'openai';
|
|
||||||
|
|
||||||
// Agent 配置
|
// Agent 配置
|
||||||
export interface AgentConfig {
|
export interface AgentConfig {
|
||||||
provider: ProviderType;
|
provider: ProviderType;
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ vi.mock('../../../src/agent/permission-merger.js', () => ({
|
|||||||
checkBashPermission: (...args: unknown[]) => mockCheckBashPermission(...args),
|
checkBashPermission: (...args: unknown[]) => mockCheckBashPermission(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock providers
|
// Mock provider registry
|
||||||
const mockGetModelFactory = vi.fn();
|
const mockGetModelFactory = vi.fn();
|
||||||
vi.mock('../../../src/core/providers.js', () => ({
|
vi.mock('../../../src/provider/index.js', () => ({
|
||||||
|
getProviderRegistry: () => ({
|
||||||
getModelFactory: (...args: unknown[]) => mockGetModelFactory(...args),
|
getModelFactory: (...args: unknown[]) => mockGetModelFactory(...args),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { AgentExecutor } from '../../../src/agent/executor.js';
|
import { AgentExecutor } from '../../../src/agent/executor.js';
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('AgentExecutor - Agent 执行器', () => {
|
|||||||
|
|
||||||
it('不支持的 provider 抛出错误', () => {
|
it('不支持的 provider 抛出错误', () => {
|
||||||
const config = { ...mockBaseConfig, provider: 'unknown' as any };
|
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', () => {
|
it('使用 Agent 指定的 provider', () => {
|
||||||
|
|||||||
@@ -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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
|
|||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
import { createBunWebSocket } from 'hono/bun';
|
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 {
|
import {
|
||||||
handleWebSocket,
|
handleWebSocket,
|
||||||
handleWebSocketMessage,
|
handleWebSocketMessage,
|
||||||
@@ -87,6 +87,7 @@ api.route('/mcp', mcpRouter);
|
|||||||
api.route('/hooks', hooksRouter);
|
api.route('/hooks', hooksRouter);
|
||||||
api.route('/agents', agentsRouter);
|
api.route('/agents', agentsRouter);
|
||||||
api.route('/checkpoints', checkpointsRouter);
|
api.route('/checkpoints', checkpointsRouter);
|
||||||
|
api.route('/providers', providersRouter);
|
||||||
|
|
||||||
// SSE 事件流
|
// SSE 事件流
|
||||||
api.get('/sessions/:id/events', handleSSE);
|
api.get('/sessions/:id/events', handleSSE);
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export { mcpRouter } from './mcp.js';
|
|||||||
export { hooksRouter } from './hooks.js';
|
export { hooksRouter } from './hooks.js';
|
||||||
export { agentsRouter } from './agents.js';
|
export { agentsRouter } from './agents.js';
|
||||||
export { checkpointsRouter } from './checkpoints.js';
|
export { checkpointsRouter } from './checkpoints.js';
|
||||||
|
export { providersRouter } from './providers.js';
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -38,6 +38,13 @@ import type {
|
|||||||
SafetyCheckResult,
|
SafetyCheckResult,
|
||||||
UnrevertStatus,
|
UnrevertStatus,
|
||||||
UnrevertResult,
|
UnrevertResult,
|
||||||
|
// Provider types
|
||||||
|
ProviderListItem,
|
||||||
|
ProviderDetail,
|
||||||
|
ModelInfo,
|
||||||
|
CustomProviderDefinition,
|
||||||
|
ProviderConfig,
|
||||||
|
ConnectionTestResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
@@ -96,6 +103,16 @@ export type {
|
|||||||
CheckpointStats,
|
CheckpointStats,
|
||||||
UnrevertStatus,
|
UnrevertStatus,
|
||||||
UnrevertResult,
|
UnrevertResult,
|
||||||
|
// Provider types
|
||||||
|
BuiltinProviderType,
|
||||||
|
ProviderType,
|
||||||
|
ModelCapabilities,
|
||||||
|
ModelInfo,
|
||||||
|
ProviderListItem,
|
||||||
|
ProviderDetail,
|
||||||
|
CustomProviderDefinition,
|
||||||
|
ProviderConfig,
|
||||||
|
ConnectionTestResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
@@ -804,3 +821,118 @@ export async function getMessageCheckpoints(messageId: string): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
return request('GET', `/checkpoints/messages/${encodeURIComponent(messageId)}`);
|
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)}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -590,3 +590,124 @@ export interface UnrevertResult {
|
|||||||
/** 错误信息 */
|
/** 错误信息 */
|
||||||
error?: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ProviderListItem[]>([]);
|
||||||
|
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
|
||||||
|
const [providerDetails, setProviderDetails] = useState<Record<string, ProviderDetail>>({});
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [testingProvider, setTestingProvider] = useState<string | null>(null);
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; error?: string }>>({});
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
const [showAddProvider, setShowAddProvider] = useState(false);
|
||||||
|
const [showAddModel, setShowAddModel] = useState<string | null>(null);
|
||||||
|
const [newProvider, setNewProvider] = useState<Partial<CustomProviderDefinition>>({});
|
||||||
|
const [newModel, setNewModel] = useState<Partial<ModelInfo>>({});
|
||||||
|
|
||||||
|
// 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 = () => (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-gray-900/50 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Provider Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-3',
|
||||||
|
'hover:bg-gray-900/80 transition-colors cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={() => toggleExpanded(provider.id)}
|
||||||
|
>
|
||||||
|
{/* Expand Icon */}
|
||||||
|
<button className="text-gray-500 hover:text-gray-300">
|
||||||
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{provider.builtin ? (
|
||||||
|
<Server size={16} className="text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<Globe size={16} className="text-green-400" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-200">{provider.name}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full',
|
||||||
|
provider.builtin ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{provider.builtin ? 'Built-in' : 'Custom'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span>{provider.modelCount} models</span>
|
||||||
|
{provider.hasApiKey ? (
|
||||||
|
<span className="text-green-400 flex items-center gap-1">
|
||||||
|
<Key size={10} />
|
||||||
|
Configured
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-yellow-400 flex items-center gap-1">
|
||||||
|
<Key size={10} />
|
||||||
|
No API Key
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status & Actions */}
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Test Result */}
|
||||||
|
{testResult && (
|
||||||
|
testResult.success ? (
|
||||||
|
<Check size={16} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<span title={testResult.error}>
|
||||||
|
<AlertCircle size={16} className="text-red-400" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTestConnection(provider.id)}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="text-gray-400 hover:text-gray-300"
|
||||||
|
title="Test Connection"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Zap size={14} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Delete (only for custom) */}
|
||||||
|
{!provider.builtin && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteProvider(provider.id)}
|
||||||
|
className="text-red-400 hover:text-red-300"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 pb-3 pt-1 border-t border-gray-700/50 space-y-3">
|
||||||
|
{detail ? (
|
||||||
|
<>
|
||||||
|
{/* Base URL */}
|
||||||
|
{detail.baseUrl && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-gray-400">Base URL:</span>{' '}
|
||||||
|
<code className="text-gray-300 bg-gray-800 px-1 rounded">{detail.baseUrl}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API Key Env Var */}
|
||||||
|
{detail.apiKeyEnvVar && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-gray-400">API Key Env:</span>{' '}
|
||||||
|
<code className="text-gray-300 bg-gray-800 px-1 rounded">{detail.apiKeyEnvVar}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Models */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||||
|
<Cpu size={12} />
|
||||||
|
Models ({detail.models.length + detail.config.customModels.length})
|
||||||
|
</span>
|
||||||
|
{detail.allowCustomModels && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAddModel(provider.id)}
|
||||||
|
className="text-xs text-primary-400"
|
||||||
|
>
|
||||||
|
<Plus size={12} className="mr-1" />
|
||||||
|
Add Model
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model List */}
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{/* Built-in models */}
|
||||||
|
{detail.models.map((model) => (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className="flex items-center justify-between text-xs p-2 bg-gray-800/50 rounded"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-200">{model.name}</span>
|
||||||
|
<span className="text-gray-500 ml-2">({model.id})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
{model.capabilities?.vision && (
|
||||||
|
<span title="Vision" className="text-[10px]">Vision</span>
|
||||||
|
)}
|
||||||
|
{model.capabilities?.functionCalling && (
|
||||||
|
<span title="Function Calling" className="text-[10px]">Tools</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Custom models */}
|
||||||
|
{detail.config.customModels.map((model) => (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className="flex items-center justify-between text-xs p-2 bg-gray-800/50 rounded border border-green-500/20"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-200">{model.name}</span>
|
||||||
|
<span className="text-gray-500 ml-2">({model.id})</span>
|
||||||
|
<span className="text-green-400 ml-2 text-[10px]">custom</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteModel(provider.id, model.id)}
|
||||||
|
className="text-red-400 hover:text-red-300 p-1"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="animate-spin h-5 w-5 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={modalOverlay}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-black/50 flex z-50',
|
||||||
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||||
|
)}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={modalContent}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={smoothTransition}
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between border-b border-gray-700',
|
||||||
|
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{responsive && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||||
|
)}
|
||||||
|
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Server size={20} className="text-primary-400" />
|
||||||
|
Model Providers
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{providers.length} providers ({builtinProviders.length} built-in, {customProviders.length} custom)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowAddProvider(true)}
|
||||||
|
title="Add Provider"
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Refresh"
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSkeleton />
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||||
|
<Server size={48} className="mb-4 opacity-50" />
|
||||||
|
<p className="text-center">No providers available</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={cn('space-y-4', responsive ? 'p-4' : 'p-4')}
|
||||||
|
>
|
||||||
|
{/* Built-in Providers */}
|
||||||
|
{builtinProviders.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||||
|
<Server size={12} />
|
||||||
|
Built-in Providers
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{builtinProviders.map((provider) => (
|
||||||
|
<ProviderItem key={provider.id} provider={provider} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Providers */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||||
|
<Globe size={12} />
|
||||||
|
Custom Providers
|
||||||
|
</h3>
|
||||||
|
{customProviders.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{customProviders.map((provider) => (
|
||||||
|
<ProviderItem key={provider.id} provider={provider} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-gray-500 text-sm">
|
||||||
|
<p>No custom providers yet</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => setShowAddProvider(true)}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
Add one
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-t border-gray-700 text-xs text-gray-500 text-center',
|
||||||
|
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Config stored in{' '}
|
||||||
|
<code className="font-mono bg-gray-900 px-1 rounded">~/.ai-terminal-assistant/providers.json</code>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Add Provider Dialog */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAddProvider && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/70 flex items-center justify-center z-60"
|
||||||
|
onClick={() => setShowAddProvider(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4 space-y-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold">Add Custom Provider</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">ID (e.g., ollama)</label>
|
||||||
|
<Input
|
||||||
|
value={newProvider.id || ''}
|
||||||
|
onChange={(e) => setNewProvider((p) => ({ ...p, id: e.target.value }))}
|
||||||
|
placeholder="provider-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">Name</label>
|
||||||
|
<Input
|
||||||
|
value={newProvider.name || ''}
|
||||||
|
onChange={(e) => setNewProvider((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="My Provider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">Base URL (OpenAI compatible)</label>
|
||||||
|
<Input
|
||||||
|
value={newProvider.baseUrl || ''}
|
||||||
|
onChange={(e) => setNewProvider((p) => ({ ...p, baseUrl: e.target.value }))}
|
||||||
|
placeholder="http://localhost:11434/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">API Key Env Var (optional)</label>
|
||||||
|
<Input
|
||||||
|
value={newProvider.apiKeyEnvVar || ''}
|
||||||
|
onChange={(e) => setNewProvider((p) => ({ ...p, apiKeyEnvVar: e.target.value }))}
|
||||||
|
placeholder="OLLAMA_API_KEY"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" onClick={() => setShowAddProvider(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddProvider}>
|
||||||
|
Add Provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Add Model Dialog */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAddModel && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/70 flex items-center justify-center z-60"
|
||||||
|
onClick={() => setShowAddModel(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4 space-y-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold">Add Custom Model</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">Model ID</label>
|
||||||
|
<Input
|
||||||
|
value={newModel.id || ''}
|
||||||
|
onChange={(e) => setNewModel((m) => ({ ...m, id: e.target.value }))}
|
||||||
|
placeholder="llama3.2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">Display Name</label>
|
||||||
|
<Input
|
||||||
|
value={newModel.name || ''}
|
||||||
|
onChange={(e) => setNewModel((m) => ({ ...m, name: e.target.value }))}
|
||||||
|
placeholder="Llama 3.2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" onClick={() => setShowAddModel(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => showAddModel && handleAddModel(showAddModel)}>
|
||||||
|
Add Model
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,6 +80,16 @@ export {
|
|||||||
cleanupCheckpoints,
|
cleanupCheckpoints,
|
||||||
getSessionCheckpoints,
|
getSessionCheckpoints,
|
||||||
getMessageCheckpoints,
|
getMessageCheckpoints,
|
||||||
|
// Providers API
|
||||||
|
listProviders,
|
||||||
|
getProvider,
|
||||||
|
getProviderModels,
|
||||||
|
testProviderConnection,
|
||||||
|
registerProvider,
|
||||||
|
updateProviderConfig,
|
||||||
|
deleteProvider,
|
||||||
|
addProviderModel,
|
||||||
|
deleteProviderModel,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -141,6 +151,16 @@ export type {
|
|||||||
CheckpointStats,
|
CheckpointStats,
|
||||||
UnrevertStatus,
|
UnrevertStatus,
|
||||||
UnrevertResult,
|
UnrevertResult,
|
||||||
|
// Provider types
|
||||||
|
BuiltinProviderType,
|
||||||
|
ProviderType,
|
||||||
|
ModelCapabilities,
|
||||||
|
ModelInfo,
|
||||||
|
ProviderListItem,
|
||||||
|
ProviderDetail,
|
||||||
|
CustomProviderDefinition,
|
||||||
|
ProviderConfig,
|
||||||
|
ConnectionTestResult,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
|
||||||
// Primitives (shadcn/ui style)
|
// Primitives (shadcn/ui style)
|
||||||
@@ -162,6 +182,7 @@ export { HookEditor } from './components/HookEditor.js';
|
|||||||
export { AgentsPanel } from './components/AgentsPanel.js';
|
export { AgentsPanel } from './components/AgentsPanel.js';
|
||||||
export { AgentEditor } from './components/AgentEditor.js';
|
export { AgentEditor } from './components/AgentEditor.js';
|
||||||
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
|
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
|
||||||
|
export { ProvidersPanel } from './components/ProvidersPanel.js';
|
||||||
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
||||||
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
||||||
export { RestoreDialog } from './components/RestoreDialog.js';
|
export { RestoreDialog } from './components/RestoreDialog.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user