provider-kit 0.1.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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ProviderError taxonomy for LLM API errors.
3
+ *
4
+ * Types: rate_limit | auth | timeout | server_error | quota | bad_request | network | unknown
5
+ *
6
+ * Each type has a default user-facing message so callers get
7
+ * actionable information instead of "Provider error".
8
+ */
9
+
10
+ const ERROR_MESSAGES = {
11
+ rate_limit: 'Rate limit exceeded — slow down or upgrade your plan',
12
+ auth: 'Authentication failed — check your API key',
13
+ timeout: 'Request timed out — the provider did not respond in time',
14
+ server_error: 'Provider server error — try again later',
15
+ quota: 'Quota exceeded — you have used up your allowed tokens',
16
+ bad_request: 'Bad request — check your input parameters',
17
+ network: 'Network error — unable to reach the provider',
18
+ unknown: 'An unknown error occurred',
19
+ };
20
+
21
+ export class ProviderError extends Error {
22
+ constructor(message, { provider, statusCode, retryable, type } = {}) {
23
+ const friendly = message || ERROR_MESSAGES[type] || ERROR_MESSAGES.unknown;
24
+ super(friendly);
25
+ this.name = 'ProviderError';
26
+ this.provider = provider || 'unknown';
27
+ this.statusCode = statusCode;
28
+ this.retryable = retryable ?? (statusCode >= 500 || statusCode === 429);
29
+ this.type = type || 'unknown';
30
+ this.timestamp = Date.now();
31
+ }
32
+ }
33
+
34
+ export class AbortError extends Error {
35
+ constructor(message = 'Request was aborted') {
36
+ super(message);
37
+ this.name = 'AbortError';
38
+ }
39
+ }
40
+
41
+ export async function withRetry(fn, { retries = 2, baseDelay = 1000, provider } = {}) {
42
+ for (let i = 0; i <= retries; i++) {
43
+ try {
44
+ return await fn();
45
+ } catch (e) {
46
+ if (e instanceof AbortError) throw e;
47
+ if (i === retries) throw e;
48
+ const isRetryable = e instanceof ProviderError ? e.retryable : true;
49
+ if (!isRetryable) throw e;
50
+ await new Promise(r => setTimeout(r, baseDelay * Math.pow(2, i)));
51
+ }
52
+ }
53
+ }
54
+
55
+ export function withTimeout(fn, timeoutMs = 15000) {
56
+ return Promise.race([
57
+ fn(),
58
+ new Promise((_, reject) => setTimeout(() => {
59
+ reject(new ProviderError(undefined, { type: 'timeout', retryable: true }));
60
+ }, timeoutMs)),
61
+ ]);
62
+ }
63
+
64
+ export async function safeProviderCall(fn, { provider, retries, timeout } = {}) {
65
+ return withRetry(() => withTimeout(fn, timeout), { retries, provider });
66
+ }
67
+
68
+ export function classifyError(error, provider) {
69
+ if (error instanceof ProviderError) return error;
70
+ if (error instanceof AbortError) return error;
71
+ if (error instanceof Error) {
72
+ const msg = error.message.toLowerCase();
73
+ const type = msg.includes('rate') || msg.includes('429') ? 'rate_limit'
74
+ : msg.includes('auth') || msg.includes('401') || msg.includes('key') ? 'auth'
75
+ : msg.includes('timeout') || msg.includes('timed out') ? 'timeout'
76
+ : msg.includes('5') || msg.includes('server') ? 'server_error'
77
+ : msg.includes('quota') || msg.includes('402') ? 'quota'
78
+ : msg.includes('4') || msg.includes('bad') || msg.includes('invalid') ? 'bad_request'
79
+ : msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('network') ? 'network'
80
+ : 'unknown';
81
+ return new ProviderError(error.message, { provider, type, retryable: type === 'timeout' || type === 'server_error' || type === 'network' || type === 'rate_limit' });
82
+ }
83
+ return new ProviderError(String(error), { provider, type: 'unknown' });
84
+ }
85
+
86
+ /**
87
+ * createCancelSignal — returns { signal, cancel } for abortable streaming
88
+ *
89
+ * Usage:
90
+ * const { signal, cancel } = createCancelSignal();
91
+ * const stream = provider.chatStream('gpt-4', messages, { signal });
92
+ * // later: cancel('User cancelled');
93
+ */
94
+ export function createCancelSignal() {
95
+ const controller = new AbortController();
96
+ return {
97
+ signal: controller.signal,
98
+ cancel: (reason) => controller.abort(new AbortError(reason || 'Cancelled by user')),
99
+ };
100
+ }
@@ -0,0 +1,321 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import * as os from 'os';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const PROVIDERS_PATH = path.join(__dirname, '../config/provider-models.json');
8
+
9
+ // 运行时配置路径
10
+ const RUNTIME_CONFIG_PATH = path.join(os.homedir(), '.openchat', 'config.json');
11
+
12
+ // 服务商配置
13
+ export let PRESET_PROVIDERS = {};
14
+ let _defaultProvider = 'openrouter';
15
+
16
+ // 运行时配置缓存
17
+ let _runtimeConfig = null;
18
+
19
+ /**
20
+ * 加载运行时配置 (C:\Users\Administrator\.openchat\config.json)
21
+ * 包含实际的 apiKey
22
+ */
23
+ function loadRuntimeConfig() {
24
+ try {
25
+ if (fs.existsSync(RUNTIME_CONFIG_PATH)) {
26
+ const data = fs.readFileSync(RUNTIME_CONFIG_PATH, 'utf8');
27
+ _runtimeConfig = JSON.parse(data);
28
+ return _runtimeConfig;
29
+ }
30
+ } catch (e) {
31
+ console.warn('[ProviderManager] Failed to load runtime config:', e.message);
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * 获取 provider 的运行时 API Key
38
+ * 优先从运行时配置读取,没有则返回 null
39
+ * 支持两种格式:
40
+ * 1. providers.xxx.apiKey
41
+ * 2. providers.xxx.options.apiKey
42
+ */
43
+ export function getRuntimeApiKey(providerName) {
44
+ if (!_runtimeConfig) {
45
+ loadRuntimeConfig();
46
+ }
47
+
48
+ if (!_runtimeConfig || !_runtimeConfig.providers) {
49
+ return null;
50
+ }
51
+
52
+ const providerKey = providerName.toLowerCase();
53
+ const providerConfig = _runtimeConfig.providers[providerKey];
54
+
55
+ if (!providerConfig) {
56
+ return null;
57
+ }
58
+
59
+ // 格式1: providers.xxx.options.apiKey (百度千帆格式)
60
+ if (providerConfig.options && providerConfig.options.apiKey) {
61
+ return providerConfig.options.apiKey;
62
+ }
63
+
64
+ // 格式2: providers.xxx.apiKey (硅基流动格式)
65
+ if (providerConfig.apiKey) {
66
+ return providerConfig.apiKey;
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * 重新加载运行时配置
74
+ */
75
+ export function reloadRuntimeConfig() {
76
+ _runtimeConfig = null;
77
+ return loadRuntimeConfig();
78
+ }
79
+
80
+ /**
81
+ * 获取 provider 的运行时 Base URL
82
+ * 支持两种格式:
83
+ * 1. providers.xxx.baseUrl
84
+ * 2. providers.xxx.options.baseURL
85
+ */
86
+ export function getRuntimeBaseUrl(providerName) {
87
+ if (!_runtimeConfig) {
88
+ loadRuntimeConfig();
89
+ }
90
+
91
+ if (!_runtimeConfig || !_runtimeConfig.providers) {
92
+ return null;
93
+ }
94
+
95
+ const providerKey = providerName.toLowerCase();
96
+ const providerConfig = _runtimeConfig.providers[providerKey];
97
+
98
+ if (!providerConfig) {
99
+ return null;
100
+ }
101
+
102
+ // 格式1: providers.xxx.baseUrl
103
+ if (providerConfig.baseUrl) {
104
+ return providerConfig.baseUrl;
105
+ }
106
+
107
+ // 格式2: providers.xxx.options.baseURL
108
+ if (providerConfig.options && providerConfig.options.baseURL) {
109
+ return providerConfig.options.baseURL;
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ // 加载的别名缓存
116
+ let _aliases = {};
117
+
118
+ function loadProviders() {
119
+ try {
120
+ if (fs.existsSync(PROVIDERS_PATH)) {
121
+ const data = fs.readFileSync(PROVIDERS_PATH, 'utf8');
122
+ const loaded = JSON.parse(data);
123
+
124
+ _defaultProvider = loaded._defaultProvider || 'openrouter';
125
+ _aliases = loaded._aliases || {};
126
+ delete loaded._defaultProvider;
127
+ delete loaded._aliases;
128
+
129
+ Object.assign(PRESET_PROVIDERS, loaded);
130
+ }
131
+ } catch (e) {
132
+ console.error('Failed to load providers:', e.message);
133
+ }
134
+ }
135
+
136
+ loadProviders();
137
+
138
+ export const DEFAULT_PROVIDER = _defaultProvider;
139
+ export const PROVIDER_ALIASES = _aliases;
140
+
141
+ export function saveProviders() {
142
+ try {
143
+ const data = {
144
+ _defaultProvider,
145
+ _aliases,
146
+ ...PRESET_PROVIDERS
147
+ };
148
+ fs.writeFileSync(PROVIDERS_PATH, JSON.stringify(data, null, 2), 'utf8');
149
+ return true;
150
+ } catch (e) {
151
+ console.error('Failed to save providers:', e.message);
152
+ return false;
153
+ }
154
+ }
155
+
156
+ export function updateProviderModels(providerKey, models) {
157
+ if (PRESET_PROVIDERS[providerKey]) {
158
+ PRESET_PROVIDERS[providerKey].models = models;
159
+ PRESET_PROVIDERS[providerKey].updatedAt = new Date().toISOString();
160
+ return saveProviders();
161
+ }
162
+ return false;
163
+ }
164
+
165
+ export function addProviderEntry(providerKey, config) {
166
+ PRESET_PROVIDERS[providerKey] = config;
167
+ return saveProviders();
168
+ }
169
+
170
+ function normalizeProvider(name) {
171
+ if (!name) return name;
172
+ const lower = name.toLowerCase();
173
+ if (PROVIDER_ALIASES[lower]) return PROVIDER_ALIASES[lower];
174
+ if (PROVIDER_ALIASES[name]) return PROVIDER_ALIASES[name];
175
+ return name;
176
+ }
177
+
178
+
179
+ export class ProviderManager {
180
+ constructor() {
181
+ this.customProviders = new Map();
182
+ }
183
+
184
+ getProviderConfig(name) {
185
+ const canonical = normalizeProvider(name);
186
+ // 从 PRESET_PROVIDERS (config/provider-models.json) 获取
187
+ if (PRESET_PROVIDERS[canonical]) {
188
+ return PRESET_PROVIDERS[canonical];
189
+ }
190
+ // 检查自定义服务商
191
+ if (this.customProviders.has(canonical)) {
192
+ return this.customProviders.get(canonical);
193
+ }
194
+ return null;
195
+ }
196
+
197
+ getProvider(name) {
198
+ return this.getProviderConfig(name);
199
+ }
200
+
201
+ listProviders() {
202
+ const result = [];
203
+ const seen = new Set();
204
+
205
+ // 从 PRESET_PROVIDERS (config/providers.json) 加载
206
+ for (const [name, config] of Object.entries(PRESET_PROVIDERS)) {
207
+ if (seen.has(name)) continue;
208
+ seen.add(name);
209
+ result.push({
210
+ name,
211
+ nameCn: config.nameCn || name,
212
+ baseUrl: config.baseUrl || '',
213
+ defaultModel: config.defaultModel || 'default',
214
+ models: config.models || [],
215
+ modelMeta: config.modelMeta || [],
216
+ connected: !!(config.models && config.models.length > 0),
217
+ transport: config.transport || 'openai_chat',
218
+ isAggregator: config.isAggregator || false,
219
+ description: config.description || '',
220
+ envVars: config.envVars || []
221
+ });
222
+ }
223
+
224
+ // 添加自定义服务商
225
+ for (const [name, config] of this.customProviders) {
226
+ if (seen.has(name)) continue;
227
+ seen.add(name);
228
+ result.push({
229
+ name,
230
+ nameCn: config.nameCn || name,
231
+ baseUrl: config.baseUrl,
232
+ defaultModel: config.defaultModel || 'default',
233
+ models: config.models || [],
234
+ modelMeta: [],
235
+ connected: false,
236
+ transport: config.transport || 'openai_chat',
237
+ isAggregator: false,
238
+ description: config.description || '',
239
+ envVars: []
240
+ });
241
+ }
242
+
243
+ return result;
244
+ }
245
+
246
+ _mergeProvider(name, overlay) {
247
+ const saved = PRESET_PROVIDERS[name] || {};
248
+ return {
249
+ ...overlay,
250
+ nameCn: saved.nameCn || overlay.nameCn || overlay.name,
251
+ name: overlay.name,
252
+ baseUrl: saved.baseUrl || overlay.baseUrl,
253
+ defaultModel: saved.defaultModel || overlay.defaultModel,
254
+ description: saved.description || overlay.description
255
+ };
256
+ }
257
+
258
+ listModels(providerName) {
259
+ const config = this.getProviderConfig(providerName);
260
+ if (!config) return [];
261
+ return config.models && config.models.length > 0
262
+ ? config.models
263
+ : [config.defaultModel].filter(Boolean);
264
+ }
265
+
266
+ addCustomProvider(name, baseUrl, apiKey, model = null) {
267
+ this.customProviders.set(name, {
268
+ nameCn: name,
269
+ baseUrl,
270
+ chatEndpoint: '/chat/completions',
271
+ defaultModel: model,
272
+ models: model ? [model] : [],
273
+ apiKey
274
+ });
275
+ }
276
+
277
+ getDefaultModel(providerName) {
278
+ const config = this.getProviderConfig(providerName);
279
+ if (!config) return 'default';
280
+ return config.defaultModel || 'default';
281
+ }
282
+
283
+ getBaseUrl(providerName) {
284
+ const config = this.getProviderConfig(providerName);
285
+ if (!config) return null;
286
+ return config.baseUrl;
287
+ }
288
+
289
+ getTransport(providerName) {
290
+ const config = this.getProviderConfig(providerName);
291
+ return config?.transport || 'openai_chat';
292
+ }
293
+
294
+ getApiMode(providerName) {
295
+ const transport = this.getTransport(providerName);
296
+ const map = {
297
+ 'openai_chat': 'chat_completions',
298
+ 'anthropic_messages': 'anthropic_messages',
299
+ 'codex_responses': 'codex_responses'
300
+ };
301
+ return map[transport] || 'chat_completions';
302
+ }
303
+
304
+ isAggregator(providerName) {
305
+ const config = this.getProviderConfig(providerName);
306
+ return config?.isAggregator || false;
307
+ }
308
+
309
+ getEnvVars(providerName) {
310
+ const config = this.getProviderConfig(providerName);
311
+ return config?.envVars || [];
312
+ }
313
+
314
+ reloadProviders() {
315
+ Object.keys(PRESET_PROVIDERS).forEach(k => delete PRESET_PROVIDERS[k]);
316
+ loadProviders();
317
+ }
318
+ }
319
+
320
+ export const providerManager = new ProviderManager();
321
+ export default providerManager;