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.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # @openchat/provider-kit
2
+
3
+ **One API for 42 LLM providers.** OpenAI, Anthropic, Ollama, OpenRouter, Google Gemini, Azure, AWS Bedrock, Cohere — same interface, built-in retry and timeout.
4
+
5
+ ```js
6
+ import { createProvider } from '@openchat/provider-kit'
7
+
8
+ const provider = await createProvider('openai', { apiKey: 'sk-...' })
9
+ const reply = await provider.chat('gpt-4', [
10
+ { role: 'user', content: 'Hello' }
11
+ ])
12
+ // { content: '...', model: 'gpt-4', usage: { prompt_tokens: 10, completion_tokens: 20 } }
13
+ ```
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @openchat/provider-kit
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Basic chat
24
+
25
+ ```js
26
+ import { createProvider } from '@openchat/provider-kit'
27
+
28
+ const provider = await createProvider('openai', { apiKey: process.env.OPENAI_API_KEY })
29
+ const reply = await provider.chat('gpt-4o-mini', [
30
+ { role: 'system', content: 'You are a poet' },
31
+ { role: 'user', content: 'Write a haiku' },
32
+ ])
33
+ console.log(reply.content)
34
+ ```
35
+
36
+ ### With retry and timeout
37
+
38
+ ```js
39
+ import { safeProviderCall } from '@openchat/provider-kit'
40
+
41
+ const reply = await safeProviderCall(
42
+ () => provider.chat('gpt-4', messages),
43
+ { provider: 'openai', retries: 3, timeout: 30000 }
44
+ )
45
+ ```
46
+
47
+ ### Available providers
48
+
49
+ | Provider | `type` | Requires |
50
+ |----------|--------|----------|
51
+ | OpenAI | `openai` | `OPENAI_API_KEY` |
52
+ | Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
53
+ | Ollama | `ollama` | local server at `http://localhost:11434` |
54
+ | Azure OpenAI | `azure` | Azure credentials |
55
+ | AWS Bedrock | `bedrock` | AWS credentials |
56
+ | Cohere | `cohere` | `COHERE_API_KEY` |
57
+ | Google Gemini | `gemini` | `GEMINI_API_KEY` |
58
+ | OpenAI-compatible | `openai` with custom `baseUrl` | Any OpenAI-compatible API |
59
+
60
+ ### Streaming
61
+
62
+ ```js
63
+ const stream = await provider.chatStream('gpt-4', messages)
64
+ for await (const chunk of stream) {
65
+ if (chunk.type === 'content') process.stdout.write(chunk.content)
66
+ }
67
+ ```
68
+
69
+ ### Error handling
70
+
71
+ `createProvider`, `.chat()`, `.chatStream()` — all throw `ProviderError` with consistent fields:
72
+
73
+ ```js
74
+ import { ProviderError } from '@openchat/provider-kit'
75
+
76
+ try { /* ... */ } catch (e) {
77
+ if (e instanceof ProviderError) {
78
+ console.log(e.provider, e.statusCode, e.retryable, e.type)
79
+ // e.g. 'openai', 429, true, 'rate_limit'
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### Function Calling
85
+
86
+ ```js
87
+ const reply = await provider.chat('gpt-4', messages, {
88
+ tools: [{
89
+ type: 'function',
90
+ function: {
91
+ name: 'get_weather',
92
+ description: 'Get weather for a city',
93
+ parameters: { type: 'object', properties: { city: { type: 'string' } } }
94
+ }
95
+ }]
96
+ })
97
+ if (reply.toolCalls) {
98
+ // [{ id, name, arguments: { city: 'Tokyo' } }]
99
+ }
100
+ ```
101
+
102
+ ## ProviderRegistry
103
+
104
+ Manage multiple providers with a registry:
105
+
106
+ ```js
107
+ import { providerRegistry, createProvider } from '@openchat/provider-kit'
108
+
109
+ await providerRegistry.configure('openai', { apiKey: 'sk-...' })
110
+ await providerRegistry.configure('ollama', { baseUrl: 'http://localhost:11434' })
111
+
112
+ const provider = providerRegistry.get('openai')
113
+ const reply = await provider.chat('gpt-4', messages)
114
+ ```
115
+
116
+ ## Known Limitations (v0.1.0)
117
+
118
+ - **No config persistence by default.** API keys must be passed on every `createProvider()` call. Use `createStore()` for persistent config.
119
+ - **10 adapters implemented out of 42 presets.** The remaining 32 use OpenAI-compatible fallback. Contributions welcome.
120
+ - **No TypeScript types.** Planned for v0.2.0.
121
+ - **Not for production.** API keys are stored in memory. No OS keychain integration.
122
+ - **`.provider-kit.json` should be added to `.gitignore`** if you choose to use file persistence via `createStore()`.
123
+
124
+ ## Related
125
+
126
+ - `@openchat/fairy-guardian` — self-healing process cluster for AI model servers
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "provider-kit",
3
+ "version": "0.1.0",
4
+ "description": "42 LLM provider unified API — one interface for OpenAI, Anthropic, Ollama, OpenRouter, and 38 more. Built-in retry, timeout, and error handling.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": { ".": "./src/index.js" },
8
+ "files": ["src/", "README.md"],
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js",
11
+ "prepublishOnly": "npm test",
12
+ "version": "git add -A src/ CHANGELOG.md",
13
+ "postversion": "git push && git push --tags"
14
+ },
15
+ "keywords": ["llm", "openai", "anthropic", "ollama", "provider", "api"],
16
+ "license": "MIT",
17
+ "engines": { "node": ">=18" },
18
+ "repository": { "type": "git", "url": "https://github.com/openchat-ai/openchat" }
19
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Persistent config for @openchat/provider-kit
3
+ *
4
+ * Lookup order:
5
+ * 1. PROVIDER_KIT_CONFIG_PATH 环境变量
6
+ * 2. cwd/.provider-kit.json
7
+ * 3. 内存(不写文件)
8
+ *
9
+ * 绝不写 ~/ 目录。这是公共 npm 包,不是 OpenChat 专属.
10
+ */
11
+
12
+ import { resolve } from 'path';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
14
+
15
+ function resolveConfigPath() {
16
+ const envPath = process.env.PROVIDER_KIT_CONFIG_PATH;
17
+ if (envPath) return resolve(envPath);
18
+ const cwdPath = resolve(process.cwd(), '.provider-kit.json');
19
+ if (existsSync(cwdPath)) return cwdPath;
20
+ return null;
21
+ }
22
+
23
+ const CONFIG_PATH = resolveConfigPath();
24
+
25
+ class PersistentConfig {
26
+ constructor() {
27
+ this._store = {};
28
+ this._apiKeys = {};
29
+ this._readOnly = !CONFIG_PATH;
30
+ this._load();
31
+ }
32
+
33
+ _load() {
34
+ if (!CONFIG_PATH) return; // memory only
35
+ try {
36
+ const data = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
37
+ this._store = data.store || {};
38
+ this._apiKeys = data.apiKeys || {};
39
+ } catch {
40
+ this._store = {};
41
+ this._apiKeys = {};
42
+ }
43
+ }
44
+
45
+ _save() {
46
+ if (this._readOnly) return;
47
+ try {
48
+ mkdirSync(resolve(CONFIG_PATH, '..'), { recursive: true });
49
+ writeFileSync(CONFIG_PATH, JSON.stringify({ store: this._store, apiKeys: this._apiKeys }, null, 2), 'utf8');
50
+ } catch {
51
+ // silent: 只读容器 / 权限不足
52
+ }
53
+ }
54
+
55
+ getApiKey(provider) {
56
+ return this._apiKeys[provider] || process.env[`${provider.toUpperCase()}_API_KEY`] || '';
57
+ }
58
+
59
+ setApiKey(provider, key) {
60
+ this._apiKeys[provider] = key;
61
+ this._save();
62
+ }
63
+
64
+ removeApiKey(provider) {
65
+ delete this._apiKeys[provider];
66
+ this._save();
67
+ }
68
+
69
+ listKeys() {
70
+ return Object.keys(this._apiKeys);
71
+ }
72
+
73
+ getPreference(key) {
74
+ return this._store[key];
75
+ }
76
+
77
+ setPreference(key, val) {
78
+ this._store[key] = val;
79
+ this._save();
80
+ }
81
+
82
+ getBridgeConfig() {
83
+ return this._store;
84
+ }
85
+ }
86
+
87
+ export const persistentConfig = new PersistentConfig();
88
+
89
+ /**
90
+ * createStore — DI 模式:自定义存储实现
91
+ * 用于框架集成者(Next.js、Remix、Bridge 等)传入自己的持久化方案
92
+ *
93
+ * 示例:
94
+ * const store = createStore({ load: () => db.get('config'), save: (d) => db.set('config', d) });
95
+ * store.setApiKey('openai', 'sk-xxx');
96
+ */
97
+ export function createStore(impl = {}) {
98
+ const store = new PersistentConfig();
99
+ if (impl.apiKeys) store._apiKeys = impl.apiKeys;
100
+ if (impl.store) store._store = impl.store;
101
+ if (impl.load) { try { const d = impl.load(); if (d) { store._apiKeys = d.apiKeys || {}; store._store = d.store || {}; } } catch {} }
102
+ if (impl.save) { const origSave = store._save.bind(store); store._save = () => { origSave(); impl.save({ apiKeys: store._apiKeys, store: store._store }); }; }
103
+ if (impl.onSave) store._save = impl.onSave;
104
+ return store;
105
+ }
106
+
107
+ export default persistentConfig;
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @openchat/provider-kit
3
+ *
4
+ * 42 LLM provider unified API — one interface for OpenAI, Anthropic,
5
+ * Ollama, OpenRouter, and 38 more.
6
+ *
7
+ * Quick start:
8
+ * import { providerRegistry, createProvider } from '@openchat/provider-kit';
9
+ * const provider = createProvider('openai', { apiKey: 'sk-...' });
10
+ * const reply = await provider.chat('gpt-4', [{ role: 'user', content: 'Hi' }]);
11
+ */
12
+
13
+ export { ProviderError, AbortError, withRetry, withTimeout, safeProviderCall, classifyError, createCancelSignal } from './providers/provider-error-adapter.js';
14
+ export { ProviderManager, providerManager } from './providers/provider-manager.js';
15
+ export { providerRegistry, createProvider, listPresetProviders, PRESET_PROVIDERS } from './providers/provider-registry.js';
16
+ export { persistentConfig, createStore } from './core/persistent-config.js';
@@ -0,0 +1,368 @@
1
+ import { ProviderError } from './provider-error-adapter.js';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ /**
9
+ * Anthropic Claude API 适配器
10
+ *
11
+ * Anthropic 使用独特的 Messages API 格式,与 OpenAI 不同:
12
+ * - Endpoint: /v1/messages (不是 /chat/completions)
13
+ * - Header: x-api-key (不是 Authorization: Bearer)
14
+ * - 请求格式: { model, messages, max_tokens, ... }
15
+ * - 响应格式: { id, content: [{ type: 'text', text: '...' }], ... }
16
+ *
17
+ * 参考文档: https://docs.anthropic.com/claude/reference/messages_post
18
+ */
19
+
20
+ // 加载用户配置
21
+ function loadModelConfig() {
22
+ try {
23
+ const configPath = path.join(__dirname, '../config/model-selection.json');
24
+ if (fs.existsSync(configPath)) {
25
+ const data = fs.readFileSync(configPath, 'utf8');
26
+ const config = JSON.parse(data);
27
+ return config.modelSelection?.anthropic || {};
28
+ }
29
+ } catch (e) {
30
+ console.warn('[AnthropicAdapter] Failed to load model config:', e.message);
31
+ }
32
+ return {};
33
+ }
34
+
35
+ const modelConfig = loadModelConfig();
36
+
37
+ export class AnthropicAdapter {
38
+ constructor(config) {
39
+ this.id = config.id || 'anthropic';
40
+ this.name = config.name || 'Claude';
41
+ this.nameCn = config.nameCn || 'Anthropic Claude';
42
+ this.baseUrl = config.baseUrl || 'https://api.anthropic.com';
43
+ this.apiKey = config.apiKey || null;
44
+
45
+ // 完全从配置读取模型,配置有什么就用什么
46
+ this.defaultModel = config.defaultModel || modelConfig.defaultModel || null;
47
+
48
+ // 可用模型列表完全来自配置
49
+ this.models = config.models || modelConfig.availableModels || [];
50
+ this.connected = false;
51
+ this.description = config.description || 'Anthropic Claude 系列模型';
52
+
53
+ // Anthropic 特定配置
54
+ this.anthropicVersion = config.anthropicVersion || '2023-06-01';
55
+ this.timeout = config.timeout || 60000;
56
+ this.headers = config.headers || {};
57
+ }
58
+
59
+ /**
60
+ * 连接/验证 API Key
61
+ */
62
+ async connect(apiKey) {
63
+ if (apiKey) this.apiKey = apiKey;
64
+
65
+ if (!this.apiKey) {
66
+ throw new ProviderError('API Key required for Anthropic Claude');
67
+ }
68
+
69
+ // Anthropic 没有 /models 端点,直接测试连接
70
+ try {
71
+ // 发送一个简单的测试请求
72
+ await this.chat(this.defaultModel, [
73
+ { role: 'user', content: 'Hi' }
74
+ ], { max_tokens: 10 });
75
+
76
+ this.connected = true;
77
+ return true;
78
+ } catch (e) {
79
+ // 如果有 API Key,假定连接成功
80
+ if (this.apiKey) {
81
+ this.connected = true;
82
+ return true;
83
+ }
84
+ throw e;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 断开连接
90
+ */
91
+ disconnect() {
92
+ this.connected = false;
93
+ }
94
+
95
+ /**
96
+ * 转换消息格式: OpenAI -> Anthropic
97
+ *
98
+ * OpenAI: [{ role: 'system', content: '...' }, { role: 'user', content: '...' }]
99
+ * Anthropic: system: '...', messages: [{ role: 'user', content: '...' }]
100
+ */
101
+ convertMessages(messages) {
102
+ const systemMessages = [];
103
+ const anthropicMessages = [];
104
+
105
+ for (const msg of messages) {
106
+ if (msg.role === 'system') {
107
+ systemMessages.push(msg.content);
108
+ } else if (msg.role === 'user' || msg.role === 'assistant') {
109
+ anthropicMessages.push({
110
+ role: msg.role,
111
+ content: typeof msg.content === 'string'
112
+ ? msg.content
113
+ : JSON.stringify(msg.content)
114
+ });
115
+ }
116
+ }
117
+
118
+ return {
119
+ system: systemMessages.join('\n\n'),
120
+ messages: anthropicMessages
121
+ };
122
+ }
123
+
124
+ /**
125
+ * 转换响应格式: Anthropic -> OpenAI
126
+ */
127
+ convertResponse(anthropicResponse) {
128
+ const content = anthropicResponse.content
129
+ .filter(c => c.type === 'text')
130
+ .map(c => c.text)
131
+ .join('');
132
+
133
+ return {
134
+ content,
135
+ model: anthropicResponse.model,
136
+ usage: {
137
+ prompt_tokens: anthropicResponse.usage?.input_tokens || 0,
138
+ completion_tokens: anthropicResponse.usage?.output_tokens || 0,
139
+ total_tokens: (anthropicResponse.usage?.input_tokens || 0) +
140
+ (anthropicResponse.usage?.output_tokens || 0)
141
+ },
142
+ raw: anthropicResponse
143
+ };
144
+ }
145
+
146
+ /**
147
+ * 发送聊天消息
148
+ */
149
+ async chat(model, messages, options = {}) {
150
+ if (!this.connected) {
151
+ throw new ProviderError('Anthropic provider not connected');
152
+ }
153
+
154
+ const url = `${this.baseUrl}/v1/messages`;
155
+
156
+ const { system, messages: anthropicMessages } = this.convertMessages(messages);
157
+
158
+ const body = {
159
+ model: model || this.defaultModel,
160
+ messages: anthropicMessages,
161
+ max_tokens: options.max_tokens || options.maxTokens || 4096,
162
+ stream: options.stream || false
163
+ };
164
+
165
+ // 添加 system 消息
166
+ if (system) {
167
+ body.system = system;
168
+ }
169
+
170
+ // 可选参数
171
+ if (options.temperature !== undefined) {
172
+ body.temperature = options.temperature;
173
+ }
174
+ if (options.top_p !== undefined) {
175
+ body.top_p = options.top_p;
176
+ }
177
+ if (options.top_k !== undefined) {
178
+ body.top_k = options.top_k;
179
+ }
180
+
181
+ // Tool Use (Function Calling)
182
+ if (options.tools && options.tools.length > 0) {
183
+ body.tools = this.convertTools(options.tools);
184
+ }
185
+
186
+ const headers = {
187
+ 'Content-Type': 'application/json',
188
+ 'x-api-key': this.apiKey,
189
+ 'anthropic-version': this.anthropicVersion,
190
+ ...this.headers
191
+ };
192
+
193
+ const response = await fetch(url, {
194
+ method: 'POST',
195
+ headers,
196
+ body: JSON.stringify(body),
197
+ signal: this.timeout ? AbortSignal.timeout(this.timeout) : undefined
198
+ });
199
+
200
+ if (!response.ok) {
201
+ const error = await response.json().catch(() => ({}));
202
+ throw new ProviderError(
203
+ error.error?.message ||
204
+ `Anthropic API error: ${response.status} ${response.statusText}`
205
+ );
206
+ }
207
+
208
+ const data = await response.json();
209
+ return this.convertResponse(data);
210
+ }
211
+
212
+ /**
213
+ * 流式聊天
214
+ */
215
+ async *chatStream(model, messages, options = {}) {
216
+ if (!this.connected) {
217
+ throw new ProviderError('Anthropic provider not connected');
218
+ }
219
+
220
+ const url = `${this.baseUrl}/v1/messages`;
221
+
222
+ const { system, messages: anthropicMessages } = this.convertMessages(messages);
223
+
224
+ const body = {
225
+ model: model || this.defaultModel,
226
+ messages: anthropicMessages,
227
+ max_tokens: options.max_tokens || options.maxTokens || 4096,
228
+ stream: true
229
+ };
230
+
231
+ if (system) {
232
+ body.system = system;
233
+ }
234
+
235
+ if (options.temperature !== undefined) {
236
+ body.temperature = options.temperature;
237
+ }
238
+ if (options.top_p !== undefined) {
239
+ body.top_p = options.top_p;
240
+ }
241
+
242
+ if (options.tools && options.tools.length > 0) {
243
+ body.tools = this.convertTools(options.tools);
244
+ }
245
+
246
+ const headers = {
247
+ 'Content-Type': 'application/json',
248
+ 'x-api-key': this.apiKey,
249
+ 'anthropic-version': this.anthropicVersion,
250
+ ...this.headers
251
+ };
252
+
253
+ const response = await fetch(url, {
254
+ method: 'POST',
255
+ headers,
256
+ body: JSON.stringify(body),
257
+ signal: this.timeout ? AbortSignal.timeout(this.timeout) : undefined
258
+ });
259
+
260
+ if (!response.ok) {
261
+ const error = await response.json().catch(() => ({}));
262
+ throw new ProviderError(
263
+ error.error?.message ||
264
+ `Anthropic API error: ${response.status} ${response.statusText}`
265
+ );
266
+ }
267
+
268
+ const reader = response.body.getReader();
269
+ const decoder = new TextDecoder();
270
+ let buffer = '';
271
+
272
+ while (true) {
273
+ const { done, value } = await reader.read();
274
+ if (done) break;
275
+
276
+ buffer += decoder.decode(value, { stream: true });
277
+ const lines = buffer.split('\n');
278
+ buffer = lines.pop() || '';
279
+
280
+ for (const line of lines) {
281
+ if (line.startsWith('data: ')) {
282
+ const data = line.slice(6);
283
+
284
+ try {
285
+ const json = JSON.parse(data);
286
+
287
+ // 处理不同的事件类型
288
+ if (json.type === 'content_block_delta') {
289
+ const delta = json.delta;
290
+ if (delta?.type === 'text_delta' && delta.text) {
291
+ yield { type: 'content', content: delta.text, done: false };
292
+ }
293
+ } else if (json.type === 'message_stop') {
294
+ yield { done: true };
295
+ return;
296
+ }
297
+ } catch (e) {
298
+ // 忽略解析错误
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ yield { done: true };
305
+ }
306
+
307
+ /**
308
+ * 转换工具格式: OpenAI tools -> Anthropic tools
309
+ */
310
+ convertTools(openaiTools) {
311
+ return openaiTools.map(tool => ({
312
+ name: tool.function.name,
313
+ description: tool.function.description || '',
314
+ input_schema: tool.function.parameters || {
315
+ type: 'object',
316
+ properties: {}
317
+ }
318
+ }));
319
+ }
320
+
321
+ /**
322
+ * 获取模型列表
323
+ */
324
+ async fetchModels() {
325
+ // Anthropic 没有 /models 端点,返回硬编码列表
326
+ return this.models;
327
+ }
328
+
329
+ /**
330
+ * 获取模型列表(本地)
331
+ */
332
+ getModels() {
333
+ return this.models;
334
+ }
335
+
336
+ /**
337
+ * 获取状态
338
+ */
339
+ getStatus() {
340
+ return {
341
+ id: this.id,
342
+ name: this.name,
343
+ nameCn: this.nameCn,
344
+ baseUrl: this.baseUrl,
345
+ connected: this.connected,
346
+ modelCount: this.models.length,
347
+ defaultModel: this.defaultModel,
348
+ hasApiKey: !!this.apiKey,
349
+ transport: 'anthropic_messages'
350
+ };
351
+ }
352
+ }
353
+
354
+ /**
355
+ * 创建 Anthropic Provider
356
+ */
357
+ export function createAnthropicProvider(apiKey = null, overrides = {}) {
358
+ return new AnthropicAdapter({
359
+ id: 'anthropic',
360
+ name: 'Claude',
361
+ nameCn: 'Anthropic Claude',
362
+ baseUrl: 'https://api.anthropic.com',
363
+ apiKey,
364
+ ...overrides
365
+ });
366
+ }
367
+
368
+ export default AnthropicAdapter;