@prysm-ai/llm 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/dist/base.d.ts +17 -0
- package/dist/base.d.ts.map +1 -0
- package/dist/base.js +26 -0
- package/dist/base.js.map +1 -0
- package/dist/constants.d.ts +12 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +12 -0
- package/dist/constants.js.map +1 -0
- package/dist/content.d.ts +18 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +18 -0
- package/dist/content.js.map +1 -0
- package/dist/factory/createModel.d.ts +38 -0
- package/dist/factory/createModel.d.ts.map +1 -0
- package/dist/factory/createModel.js +44 -0
- package/dist/factory/createModel.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/provider/anthropic.d.ts +45 -0
- package/dist/provider/anthropic.d.ts.map +1 -0
- package/dist/provider/anthropic.js +290 -0
- package/dist/provider/anthropic.js.map +1 -0
- package/dist/provider/openai.d.ts +84 -0
- package/dist/provider/openai.d.ts.map +1 -0
- package/dist/provider/openai.js +234 -0
- package/dist/provider/openai.js.map +1 -0
- package/dist/provider/proxy.d.ts +53 -0
- package/dist/provider/proxy.d.ts.map +1 -0
- package/dist/provider/proxy.js +113 -0
- package/dist/provider/proxy.js.map +1 -0
- package/dist/router.d.ts +21 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +86 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/base.ts +43 -0
- package/src/constants.ts +13 -0
- package/src/content.ts +36 -0
- package/src/factory/createModel.ts +64 -0
- package/src/index.ts +9 -0
- package/src/provider/anthropic.ts +359 -0
- package/src/provider/openai.ts +280 -0
- package/src/provider/proxy.ts +171 -0
- package/src/router.ts +113 -0
- package/src/types.ts +77 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import type { ChatMessage, LLMResponse, LLMRequestOptions, ModelInfo } from '../types';
|
|
3
|
+
import type { MessageContent, MessagePart } from '../content';
|
|
4
|
+
import { BaseLLMProvider } from '../base';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 流式输出选项
|
|
8
|
+
* 继承 LLMRequestOptions 的所有选项,额外提供流式回调
|
|
9
|
+
*/
|
|
10
|
+
export interface StreamingOptions extends Omit<LLMRequestOptions, 'stream'> {
|
|
11
|
+
/** 标记为流式请求 */
|
|
12
|
+
stream: true;
|
|
13
|
+
/** 流式块回调,每次收到内容片段时触发 */
|
|
14
|
+
onChunk?: (chunk: string) => void;
|
|
15
|
+
/** 流式完成回调,传入完整内容 */
|
|
16
|
+
onComplete?: (fullContent: string) => void;
|
|
17
|
+
/** 错误回调 */
|
|
18
|
+
onError?: (error: Error) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* OpenAI Provider 配置
|
|
23
|
+
*/
|
|
24
|
+
export interface OpenAIProviderConfig {
|
|
25
|
+
/** API 密钥 */
|
|
26
|
+
apiKey: string;
|
|
27
|
+
/** 可选的 API 基础地址,用于代理 */
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
/** 可选的 API 路径 */
|
|
30
|
+
apiPath?: string;
|
|
31
|
+
/** 可选的超时时间(毫秒) */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* OpenAI LLM Provider 实现类
|
|
37
|
+
* 支持 OpenAI GPT 系列模型的对话和流式输出
|
|
38
|
+
*/
|
|
39
|
+
export class OpenAIProvider extends BaseLLMProvider {
|
|
40
|
+
/** 提供商类型标识 */
|
|
41
|
+
readonly provider: 'openai' = 'openai';
|
|
42
|
+
|
|
43
|
+
/** 默认使用的模型 */
|
|
44
|
+
override readonly defaultModel = 'gpt-4o';
|
|
45
|
+
|
|
46
|
+
/** OpenAI SDK 客户端实例 */
|
|
47
|
+
private client: OpenAI;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 创建 OpenAI Provider 实例
|
|
51
|
+
* @param config - 提供者配置,包含 apiKey 和可选的 baseUrl
|
|
52
|
+
*/
|
|
53
|
+
constructor(config: OpenAIProviderConfig) {
|
|
54
|
+
super(config.apiKey, config.baseUrl);
|
|
55
|
+
this.client = new OpenAI({
|
|
56
|
+
apiKey: config.apiKey,
|
|
57
|
+
baseURL: config.baseUrl,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 发送对话请求(非流式)
|
|
63
|
+
* @param messages - 对话消息列表
|
|
64
|
+
* @param options - 请求选项,包含模型、温度等参数
|
|
65
|
+
* @returns LLM 响应结果
|
|
66
|
+
*/
|
|
67
|
+
async chat(messages: ChatMessage[], options: LLMRequestOptions = { model: this.defaultModel }): Promise<LLMResponse> {
|
|
68
|
+
const openaiMessages = messages.map(msg => this.normalizeMessage(msg));
|
|
69
|
+
|
|
70
|
+
const createOptions: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = {
|
|
71
|
+
model: options.model,
|
|
72
|
+
messages: openaiMessages as OpenAI.Chat.ChatCompletionMessageParam[],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (options.temperature !== undefined) {
|
|
76
|
+
createOptions.temperature = options.temperature;
|
|
77
|
+
}
|
|
78
|
+
if (options.maxTokens !== undefined) {
|
|
79
|
+
createOptions.max_tokens = options.maxTokens;
|
|
80
|
+
}
|
|
81
|
+
if (options.topP !== undefined) {
|
|
82
|
+
createOptions.top_p = options.topP;
|
|
83
|
+
}
|
|
84
|
+
if (options.tools !== undefined) {
|
|
85
|
+
createOptions.tools = options.tools as OpenAI.Chat.ChatCompletionTool[];
|
|
86
|
+
}
|
|
87
|
+
if (options.toolChoice !== undefined) {
|
|
88
|
+
createOptions.tool_choice = options.toolChoice as OpenAI.Chat.ChatCompletionToolChoiceOption;
|
|
89
|
+
}
|
|
90
|
+
if (options.stop !== undefined) {
|
|
91
|
+
createOptions.stop = options.stop;
|
|
92
|
+
}
|
|
93
|
+
if (options.responseFormat !== undefined) {
|
|
94
|
+
(createOptions as unknown as Record<string, unknown>).response_format = options.responseFormat;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const response = await this.client.chat.completions.create(createOptions);
|
|
99
|
+
|
|
100
|
+
const choice = response.choices[0]!;
|
|
101
|
+
const message = choice.message;
|
|
102
|
+
|
|
103
|
+
const result: LLMResponse = {
|
|
104
|
+
id: response.id,
|
|
105
|
+
model: response.model,
|
|
106
|
+
role: 'assistant',
|
|
107
|
+
content: message.content ?? '',
|
|
108
|
+
finishReason: mapFinishReason(choice.finish_reason),
|
|
109
|
+
usage: {
|
|
110
|
+
promptTokens: response.usage?.prompt_tokens ?? 0,
|
|
111
|
+
completionTokens: response.usage?.completion_tokens ?? 0,
|
|
112
|
+
totalTokens: response.usage?.total_tokens ?? 0,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (message.tool_calls) {
|
|
117
|
+
result.toolCalls = message.tool_calls.map(tc => ({
|
|
118
|
+
id: tc.id!,
|
|
119
|
+
type: 'function' as const,
|
|
120
|
+
function: {
|
|
121
|
+
name: tc.function!.name,
|
|
122
|
+
arguments: tc.function!.arguments,
|
|
123
|
+
},
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error instanceof Error) {
|
|
130
|
+
throw new Error(`OpenAI API error: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`OpenAI API error: ${String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 发送流式对话请求
|
|
138
|
+
* @param messages - 对话消息列表
|
|
139
|
+
* @param options - 流式选项,包含 onChunk、onComplete、onError 回调
|
|
140
|
+
*/
|
|
141
|
+
async chatStream(messages: ChatMessage[], options: StreamingOptions): Promise<void> {
|
|
142
|
+
const openaiMessages = messages.map(msg => this.normalizeMessage(msg));
|
|
143
|
+
|
|
144
|
+
const createOptions: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {
|
|
145
|
+
model: options.model,
|
|
146
|
+
messages: openaiMessages as OpenAI.Chat.ChatCompletionMessageParam[],
|
|
147
|
+
stream: true,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (options.temperature !== undefined) {
|
|
151
|
+
createOptions.temperature = options.temperature;
|
|
152
|
+
}
|
|
153
|
+
if (options.maxTokens !== undefined) {
|
|
154
|
+
createOptions.max_tokens = options.maxTokens;
|
|
155
|
+
}
|
|
156
|
+
if (options.topP !== undefined) {
|
|
157
|
+
createOptions.top_p = options.topP;
|
|
158
|
+
}
|
|
159
|
+
if (options.stop !== undefined) {
|
|
160
|
+
createOptions.stop = options.stop;
|
|
161
|
+
}
|
|
162
|
+
if (options.tools !== undefined) {
|
|
163
|
+
createOptions.tools = options.tools as OpenAI.Chat.ChatCompletionTool[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const response = await this.client.chat.completions.create(createOptions as any);
|
|
168
|
+
let fullContent = '';
|
|
169
|
+
|
|
170
|
+
// 处理异步迭代器和同步迭代器两种响应类型
|
|
171
|
+
const stream = response as any;
|
|
172
|
+
if (typeof stream[Symbol.asyncIterator] === 'function') {
|
|
173
|
+
for await (const chunk of stream) {
|
|
174
|
+
const content = chunk.choices?.[0]?.delta?.content;
|
|
175
|
+
if (content) {
|
|
176
|
+
fullContent += content;
|
|
177
|
+
options.onChunk?.(content);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} else if (typeof stream[Symbol.iterator] === 'function') {
|
|
181
|
+
for (const chunk of stream) {
|
|
182
|
+
const content = chunk.choices?.[0]?.delta?.content;
|
|
183
|
+
if (content) {
|
|
184
|
+
fullContent += content;
|
|
185
|
+
options.onChunk?.(content);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
options.onComplete?.(fullContent);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 规范化消息格式
|
|
199
|
+
* 将通用 ChatMessage 转换为 OpenAI 格式
|
|
200
|
+
* @param msg - 原始消息
|
|
201
|
+
* @returns OpenAI 格式的消息
|
|
202
|
+
*/
|
|
203
|
+
private normalizeMessage(msg: ChatMessage): { role: string; content: string | OpenAI.Chat.ChatCompletionContentPart[]; name?: string; tool_calls?: unknown; tool_call_id?: string } {
|
|
204
|
+
const result: { role: string; content: string | OpenAI.Chat.ChatCompletionContentPart[]; name?: string; tool_calls?: unknown; tool_call_id?: string } = {
|
|
205
|
+
role: msg.role,
|
|
206
|
+
content: this.normalizeContent(msg.content),
|
|
207
|
+
};
|
|
208
|
+
if (msg.name !== undefined) result.name = msg.name;
|
|
209
|
+
if (msg.toolCalls !== undefined) result.tool_calls = msg.toolCalls;
|
|
210
|
+
if (msg.toolCallId !== undefined) result.tool_call_id = msg.toolCallId;
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 规范化消息内容
|
|
216
|
+
* 将字符串或消息片段数组转换为 OpenAI 格式
|
|
217
|
+
* @param content - 原始内容
|
|
218
|
+
* @returns OpenAI 格式的内容
|
|
219
|
+
*/
|
|
220
|
+
private normalizeContent(content: MessageContent): string | OpenAI.Chat.ChatCompletionContentPart[] {
|
|
221
|
+
if (typeof content === 'string') {
|
|
222
|
+
return content;
|
|
223
|
+
}
|
|
224
|
+
return content.map((part: MessagePart) => {
|
|
225
|
+
if (part.type === 'text') {
|
|
226
|
+
return { type: 'text', text: part.text } as OpenAI.Chat.ChatCompletionContentPart;
|
|
227
|
+
}
|
|
228
|
+
if (part.type === 'image_url') {
|
|
229
|
+
return { type: 'image_url', image_url: part.image_url } as OpenAI.Chat.ChatCompletionContentPart;
|
|
230
|
+
}
|
|
231
|
+
return { type: 'text', text: '' } as OpenAI.Chat.ChatCompletionContentPart;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 获取可用模型列表
|
|
237
|
+
* @returns 模型信息列表
|
|
238
|
+
*/
|
|
239
|
+
async listModels(): Promise<ModelInfo[]> {
|
|
240
|
+
const models = await this.client.models.list();
|
|
241
|
+
return models.data.map(m => ({
|
|
242
|
+
provider: 'openai' as const,
|
|
243
|
+
model: m.id,
|
|
244
|
+
displayName: m.id,
|
|
245
|
+
contextWindow: 128000,
|
|
246
|
+
supportsTools: true,
|
|
247
|
+
supportsVision: m.id.includes('vision'),
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 验证 API 密钥是否有效
|
|
253
|
+
* @returns 密钥是否有效
|
|
254
|
+
*/
|
|
255
|
+
async validateKey(): Promise<boolean> {
|
|
256
|
+
try {
|
|
257
|
+
await this.client.models.list();
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 映射 finish reason
|
|
267
|
+
* 将 OpenAI 的 finish_reason 转换为通用格式
|
|
268
|
+
* @param reason - OpenAI 返回的 finish_reason
|
|
269
|
+
* @returns 通用 finish reason
|
|
270
|
+
*/
|
|
271
|
+
function mapFinishReason(reason: string | null): LLMResponse['finishReason'] {
|
|
272
|
+
switch (reason) {
|
|
273
|
+
case 'stop': return 'stop';
|
|
274
|
+
case 'length': return 'length';
|
|
275
|
+
case 'content_filter': return 'content_filter';
|
|
276
|
+
case 'tool_calls': return 'tool_calls';
|
|
277
|
+
case 'function_call': return 'function_call';
|
|
278
|
+
default: return 'stop';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ChatMessage, LLMResponse, LLMRequestOptions, ModelInfo } from '../types';
|
|
2
|
+
import { StreamingOptions } from './openai';
|
|
3
|
+
import { AnthropicStreamingOptions } from './anthropic';
|
|
4
|
+
import { BaseLLMProvider } from '../base';
|
|
5
|
+
import { LLMProviderType } from '../constants';
|
|
6
|
+
import { AnthropicProvider } from './anthropic';
|
|
7
|
+
import { OpenAIProvider } from './openai';
|
|
8
|
+
|
|
9
|
+
// 配置接口
|
|
10
|
+
export interface ProxyProviderConfig {
|
|
11
|
+
provider?: LLMProviderType;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
apiPath?: string;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
defaultModel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 运行时配置
|
|
20
|
+
export interface RuntimeConfig {
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 内部实现类
|
|
27
|
+
class ProxyProviderImpl extends BaseLLMProvider {
|
|
28
|
+
readonly provider: LLMProviderType;
|
|
29
|
+
override readonly defaultModel: string;
|
|
30
|
+
|
|
31
|
+
private actualProvider: BaseLLMProvider | null = null;
|
|
32
|
+
private currentConfig: ProxyProviderConfig;
|
|
33
|
+
|
|
34
|
+
constructor(config?: ProxyProviderConfig) {
|
|
35
|
+
super(config?.apiKey, config?.baseUrl);
|
|
36
|
+
this.provider = config?.provider ?? LLMProviderType.Anthropic;
|
|
37
|
+
this.defaultModel = config?.defaultModel ?? this.getDefaultModel(this.provider);
|
|
38
|
+
this.currentConfig = config ?? {};
|
|
39
|
+
|
|
40
|
+
if (config?.apiKey && config?.provider) {
|
|
41
|
+
this.actualProvider = this.createProvider(config);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private getDefaultModel(provider: LLMProviderType | string): string {
|
|
46
|
+
const providerType = provider as LLMProviderType;
|
|
47
|
+
return providerType === LLMProviderType.OpenAI ? 'gpt-4o' : 'claude-3-5-sonnet-20241022';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private createProvider(config: ProxyProviderConfig): BaseLLMProvider {
|
|
51
|
+
if (!config.provider || !config.apiKey) {
|
|
52
|
+
throw new Error('provider and apiKey are required');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const providerType = config.provider as LLMProviderType;
|
|
56
|
+
|
|
57
|
+
if (providerType === LLMProviderType.OpenAI) {
|
|
58
|
+
return new OpenAIProvider({
|
|
59
|
+
apiKey: config.apiKey,
|
|
60
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
61
|
+
...(config.apiPath ? { apiPath: config.apiPath } : {}),
|
|
62
|
+
...(config.timeoutMs ? { timeoutMs: config.timeoutMs } : {}),
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
return new AnthropicProvider({
|
|
66
|
+
apiKey: config.apiKey,
|
|
67
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
68
|
+
...(config.apiPath ? { apiPath: config.apiPath } : {}),
|
|
69
|
+
...(config.timeoutMs ? { timeoutMs: config.timeoutMs } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setProvider(config: ProxyProviderConfig): void {
|
|
75
|
+
if (!config.provider || !config.apiKey) {
|
|
76
|
+
throw new Error('provider and apiKey are required');
|
|
77
|
+
}
|
|
78
|
+
this.currentConfig = config;
|
|
79
|
+
this.actualProvider = this.createProvider(config);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
updateConfig(config: RuntimeConfig): void {
|
|
83
|
+
if (config.apiKey || config.baseUrl) {
|
|
84
|
+
const newConfig: ProxyProviderConfig = {
|
|
85
|
+
...this.currentConfig,
|
|
86
|
+
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
87
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
88
|
+
};
|
|
89
|
+
this.currentConfig = newConfig;
|
|
90
|
+
this.actualProvider = this.createProvider(newConfig);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setModel(model: string): void {
|
|
95
|
+
this.currentConfig.defaultModel = model;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private ensureProvider(): BaseLLMProvider {
|
|
99
|
+
if (!this.actualProvider) {
|
|
100
|
+
throw new Error('Provider not initialized. Call setProvider() first.');
|
|
101
|
+
}
|
|
102
|
+
return this.actualProvider;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async chat(messages: ChatMessage[], options?: LLMRequestOptions): Promise<LLMResponse> {
|
|
106
|
+
const provider = this.ensureProvider();
|
|
107
|
+
const finalOptions: LLMRequestOptions = {
|
|
108
|
+
...options,
|
|
109
|
+
model: options?.model ?? this.defaultModel,
|
|
110
|
+
};
|
|
111
|
+
return provider.chat(messages, finalOptions);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async chatStream(messages: ChatMessage[], options: StreamingOptions): Promise<void> {
|
|
115
|
+
const provider = this.ensureProvider();
|
|
116
|
+
const finalOptions = {
|
|
117
|
+
...options,
|
|
118
|
+
model: options.model ?? this.currentConfig.defaultModel,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (this.provider === LLMProviderType.Anthropic) {
|
|
122
|
+
return (provider as AnthropicProvider).chatStream(messages, finalOptions as AnthropicStreamingOptions);
|
|
123
|
+
}
|
|
124
|
+
return (provider as OpenAIProvider).chatStream(messages, finalOptions);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async listModels(): Promise<ModelInfo[]> {
|
|
128
|
+
return this.ensureProvider().listModels();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async validateKey(): Promise<boolean> {
|
|
132
|
+
return this.ensureProvider().validateKey();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Class 模式导出
|
|
137
|
+
export { ProxyProviderImpl as ProxyProvider };
|
|
138
|
+
|
|
139
|
+
// 函数模式配置(必须传参)
|
|
140
|
+
export interface ProxyConfig {
|
|
141
|
+
provider: LLMProviderType;
|
|
142
|
+
apiKey: string;
|
|
143
|
+
baseUrl?: string;
|
|
144
|
+
apiPath?: string;
|
|
145
|
+
timeoutMs?: number;
|
|
146
|
+
defaultModel?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 函数模式返回值
|
|
150
|
+
export interface ProxyFunctions {
|
|
151
|
+
chat: (messages: ChatMessage[], options?: LLMRequestOptions) => Promise<LLMResponse>;
|
|
152
|
+
chatStream: (messages: ChatMessage[], options: StreamingOptions) => Promise<void>;
|
|
153
|
+
listModels: () => Promise<ModelInfo[]>;
|
|
154
|
+
validateKey: () => Promise<boolean>;
|
|
155
|
+
setModel: (model: string) => void;
|
|
156
|
+
updateConfig: (config: RuntimeConfig) => void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 函数模式
|
|
160
|
+
export function proxyProvider(config: ProxyConfig): ProxyFunctions {
|
|
161
|
+
const provider = new ProxyProviderImpl(config);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
chat: (messages, options) => provider.chat(messages, options),
|
|
165
|
+
chatStream: (messages, options) => provider.chatStream(messages, options),
|
|
166
|
+
listModels: () => provider.listModels(),
|
|
167
|
+
validateKey: () => provider.validateKey(),
|
|
168
|
+
setModel: (model) => provider.setModel(model),
|
|
169
|
+
updateConfig: (config) => provider.updateConfig(config),
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ChatMessage, LLMResponse, LLMRequestOptions } from './types';
|
|
2
|
+
import { BaseLLMProvider } from './base';
|
|
3
|
+
|
|
4
|
+
class LLMError extends Error {
|
|
5
|
+
constructor(message: string, public context?: Record<string, unknown>) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'LLMError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RouterConfig {
|
|
12
|
+
primaryProvider: BaseLLMProvider;
|
|
13
|
+
fallbackProviders?: BaseLLMProvider[];
|
|
14
|
+
defaultModel?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ProviderHealth {
|
|
18
|
+
provider: BaseLLMProvider;
|
|
19
|
+
healthy: boolean;
|
|
20
|
+
lastCheck: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class LLMRouter {
|
|
24
|
+
private primaryProvider: BaseLLMProvider;
|
|
25
|
+
private fallbackProviders: BaseLLMProvider[] = [];
|
|
26
|
+
private providerHealth: Map<string, ProviderHealth> = new Map();
|
|
27
|
+
private healthCheckIntervalMs = 30000;
|
|
28
|
+
|
|
29
|
+
constructor(config: RouterConfig) {
|
|
30
|
+
this.primaryProvider = config.primaryProvider;
|
|
31
|
+
this.fallbackProviders = config.fallbackProviders ?? [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async chat(messages: ChatMessage[], options?: LLMRequestOptions): Promise<LLMResponse> {
|
|
35
|
+
const attemptOrder = this.getHealthyProviders();
|
|
36
|
+
|
|
37
|
+
for (const provider of attemptOrder) {
|
|
38
|
+
try {
|
|
39
|
+
return await provider.chat(messages, options);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
this.markProviderUnhealthy(provider);
|
|
42
|
+
console.error(`Provider ${provider.provider} failed:`, error);
|
|
43
|
+
|
|
44
|
+
if (provider === attemptOrder[attemptOrder.length - 1]) {
|
|
45
|
+
throw new LLMError(`All LLM providers failed`, { originalError: error });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new LLMError('No available LLM providers');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private getHealthyProviders(): BaseLLMProvider[] {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const healthy: BaseLLMProvider[] = [];
|
|
56
|
+
|
|
57
|
+
const primaryHealth = this.providerHealth.get(this.primaryProvider.provider);
|
|
58
|
+
if (!primaryHealth || (now - primaryHealth.lastCheck > this.healthCheckIntervalMs)) {
|
|
59
|
+
healthy.push(this.primaryProvider);
|
|
60
|
+
} else if (primaryHealth.healthy) {
|
|
61
|
+
healthy.push(this.primaryProvider);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const provider of this.fallbackProviders) {
|
|
65
|
+
const health = this.providerHealth.get(provider.provider);
|
|
66
|
+
if (!health || health.healthy) {
|
|
67
|
+
healthy.push(provider);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return healthy.length > 0 ? healthy : [this.primaryProvider];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private markProviderUnhealthy(provider: BaseLLMProvider): void {
|
|
75
|
+
this.providerHealth.set(provider.provider, {
|
|
76
|
+
provider,
|
|
77
|
+
healthy: false,
|
|
78
|
+
lastCheck: Date.now(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async validateAllProviders(): Promise<Map<string, boolean>> {
|
|
83
|
+
const results = new Map<string, boolean>();
|
|
84
|
+
|
|
85
|
+
const primary = await this.primaryProvider.validateKey();
|
|
86
|
+
results.set(this.primaryProvider.provider, primary);
|
|
87
|
+
this.providerHealth.set(this.primaryProvider.provider, {
|
|
88
|
+
provider: this.primaryProvider,
|
|
89
|
+
healthy: primary,
|
|
90
|
+
lastCheck: Date.now(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
for (const provider of this.fallbackProviders) {
|
|
94
|
+
const healthy = await provider.validateKey();
|
|
95
|
+
results.set(provider.provider, healthy);
|
|
96
|
+
this.providerHealth.set(provider.provider, {
|
|
97
|
+
provider,
|
|
98
|
+
healthy,
|
|
99
|
+
lastCheck: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getPrimaryProvider(): BaseLLMProvider {
|
|
107
|
+
return this.primaryProvider;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
addFallback(provider: BaseLLMProvider): void {
|
|
111
|
+
this.fallbackProviders.push(provider);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { MessageContent } from './content';
|
|
3
|
+
|
|
4
|
+
export const ChatMessageRoleSchema = z.enum(['system', 'user', 'assistant', 'tool']);
|
|
5
|
+
export type ChatMessageRole = z.infer<typeof ChatMessageRoleSchema>;
|
|
6
|
+
|
|
7
|
+
export interface ChatMessage {
|
|
8
|
+
id: string;
|
|
9
|
+
role: ChatMessageRole;
|
|
10
|
+
content: MessageContent;
|
|
11
|
+
name?: string;
|
|
12
|
+
toolCallId?: string;
|
|
13
|
+
toolCalls?: ToolCall[];
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ToolCall {
|
|
18
|
+
id: string;
|
|
19
|
+
type: 'function';
|
|
20
|
+
function: {
|
|
21
|
+
name: string;
|
|
22
|
+
arguments: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LLMResponse {
|
|
27
|
+
id: string;
|
|
28
|
+
model: string;
|
|
29
|
+
role: 'assistant';
|
|
30
|
+
content: string;
|
|
31
|
+
toolCalls?: ToolCall[];
|
|
32
|
+
finishReason: 'stop' | 'length' | 'content_filter' | 'tool_calls' | 'function_call';
|
|
33
|
+
usage: {
|
|
34
|
+
promptTokens: number;
|
|
35
|
+
completionTokens: number;
|
|
36
|
+
totalTokens: number;
|
|
37
|
+
};
|
|
38
|
+
metadata?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EmbedResponse {
|
|
42
|
+
embeddings: number[][];
|
|
43
|
+
model: string;
|
|
44
|
+
usage?: {
|
|
45
|
+
promptTokens: number;
|
|
46
|
+
totalTokens: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LLMRequestOptions {
|
|
51
|
+
model: string;
|
|
52
|
+
temperature?: number;
|
|
53
|
+
maxTokens?: number;
|
|
54
|
+
topP?: number;
|
|
55
|
+
tools?: unknown[];
|
|
56
|
+
toolChoice?: unknown;
|
|
57
|
+
responseFormat?: { type: 'text' | 'json_object' };
|
|
58
|
+
stop?: string[];
|
|
59
|
+
stream?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EmbedRequest {
|
|
63
|
+
model: string;
|
|
64
|
+
input: string | string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type LLMProvider = 'openai' | 'anthropic' | 'local';
|
|
68
|
+
|
|
69
|
+
export interface ModelInfo {
|
|
70
|
+
provider: LLMProvider;
|
|
71
|
+
model: string;
|
|
72
|
+
displayName: string;
|
|
73
|
+
contextWindow: number;
|
|
74
|
+
supportsTools: boolean;
|
|
75
|
+
supportsVision: boolean;
|
|
76
|
+
maxOutputTokens?: number;
|
|
77
|
+
}
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = dirname(__filename);
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
test: {
|
|
10
|
+
globals: true,
|
|
11
|
+
environment: 'node',
|
|
12
|
+
setupFiles: [resolve(__dirname, './tests/setup/globals.ts')],
|
|
13
|
+
},
|
|
14
|
+
});
|