@pwshub/aisdk 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dong Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,297 @@
1
+ # @pwshub/aisdk
2
+
3
+ A thin, unified AI client for OpenAI, Anthropic, Google, DashScope, and DeepSeek with automatic parameter normalization and fallback support.
4
+
5
+ ## Features
6
+
7
+ - **Unified API**: Single interface for multiple AI providers
8
+ - **Automatic parameter normalization**: Canonical camelCase params are translated to provider-specific wire format
9
+ - **Parameter clamping**: Values are automatically clamped to provider-accepted ranges
10
+ - **Fallback support**: Chain multiple models with automatic fallback on provider errors
11
+ - **Token usage tracking**: Detailed token counts and estimated cost per request
12
+ - **Provider-specific options**: Pass provider-specific parameters when needed
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm i @pwshub/aisdk
18
+ # or
19
+ pnpm i @pwshub/aisdk
20
+ # or
21
+ bun add @pwshub/aisdk
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```javascript
27
+ import { createAi } from '@pwshub/aisdk'
28
+
29
+ const ai = createAi()
30
+
31
+ // Basic usage
32
+ const result = await ai.ask({
33
+ model: 'gpt-4o',
34
+ apikey: 'your-api-key-here',
35
+ prompt: 'What is the capital of Vietnam?',
36
+ temperature: 0.5,
37
+ })
38
+
39
+ console.log(result.text)
40
+ console.log(result.usage) // { inputTokens, outputTokens, cacheTokens, estimatedCost }
41
+ ```
42
+
43
+ ## API
44
+
45
+ ### `createAi(options?)`
46
+
47
+ Creates an AI client instance.
48
+
49
+ **Options:**
50
+ - `gatewayUrl` (optional): Override the default API endpoint URL
51
+
52
+ **Returns:** An object with:
53
+ - `ask(params)`: Send a generation request
54
+ - `listModels()`: Get all available models from the registry
55
+
56
+ ### `ai.ask(params)`
57
+
58
+ Sends a text generation request.
59
+
60
+ **Parameters:**
61
+ - `model` (string, required): Model ID (must exist in models.json)
62
+ - `apikey` (string, required): API key for the provider
63
+ - `prompt` (string, required): The user message
64
+ - `system` (string, optional): Optional system prompt
65
+ - `fallbacks` (string[], optional): Ordered list of fallback model IDs
66
+ - `providerOptions` (object, optional): Provider-specific options
67
+ - `temperature` (number, optional): Sampling temperature
68
+ - `maxTokens` (number, optional): Maximum output tokens
69
+ - `topP` (number, optional): Nucleus sampling parameter
70
+ - `topK` (number, optional): Top-K sampling
71
+ - `frequencyPenalty` (number, optional): Frequency penalty
72
+ - `presencePenalty` (number, optional): Presence penalty
73
+
74
+ **Returns:** Promise resolving to:
75
+ ```javascript
76
+ {
77
+ text: string, // Generated text
78
+ model: string, // Model that responded
79
+ usage: {
80
+ inputTokens: number,
81
+ outputTokens: number,
82
+ cacheTokens: number,
83
+ estimatedCost: number // USD
84
+ }
85
+ }
86
+ ```
87
+
88
+ **Throws:**
89
+ - `ProviderError`: Transient provider errors (429, 5xx) — safe to retry
90
+ - `InputError`: Invalid input (400, 401, 403, 422) — fix input, do not retry
91
+
92
+ ## Examples
93
+
94
+ ### OpenAI
95
+
96
+ ```javascript
97
+ import { createAi } from '@pwshub/aisdk'
98
+
99
+ const ai = createAi()
100
+
101
+ const result = await ai.ask({
102
+ model: 'gpt-4o',
103
+ apikey: process.env.OPENAI_API_KEY,
104
+ prompt: 'Explain quantum entanglement',
105
+ temperature: 0.7,
106
+ maxTokens: 500,
107
+ })
108
+ ```
109
+
110
+ ### Anthropic
111
+
112
+ ```javascript
113
+ const result = await ai.ask({
114
+ model: 'claude-sonnet-4-6',
115
+ apikey: process.env.ANTHROPIC_API_KEY,
116
+ prompt: 'Write a haiku about TypeScript',
117
+ temperature: 0.5,
118
+ })
119
+ ```
120
+
121
+ ### Google
122
+
123
+ ```javascript
124
+ const result = await ai.ask({
125
+ model: 'gemini-2.5-flash',
126
+ apikey: process.env.GOOGLE_API_KEY,
127
+ prompt: 'What is 2+2?',
128
+ providerOptions: {
129
+ safetySettings: [
130
+ { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
131
+ ],
132
+ },
133
+ })
134
+ ```
135
+
136
+ ### With Fallbacks
137
+
138
+ ```javascript
139
+ try {
140
+ const result = await ai.ask({
141
+ model: 'gpt-4o',
142
+ apikey: process.env.OPENAI_API_KEY,
143
+ prompt: 'Hello',
144
+ fallbacks: ['gpt-4o-mini', 'claude-haiku-4-5'],
145
+ })
146
+
147
+ if (result.model !== 'gpt-4o') {
148
+ console.warn(`Fell back to ${result.model}`)
149
+ }
150
+ } catch (error) {
151
+ if (error instanceof ProviderError) {
152
+ console.error('All models failed:', error.message)
153
+ } else if (error instanceof InputError) {
154
+ console.error('Invalid request:', error.message)
155
+ }
156
+ }
157
+ ```
158
+
159
+ ### DashScope (Alibaba)
160
+
161
+ ```javascript
162
+ const result = await ai.ask({
163
+ model: 'qwen3.5-plus',
164
+ apikey: process.env.DASHSCOPE_API_KEY,
165
+ prompt: 'Hello',
166
+ })
167
+ ```
168
+
169
+ ### DeepSeek
170
+
171
+ ```javascript
172
+ const result = await ai.ask({
173
+ model: 'deepseek-chat',
174
+ apikey: process.env.DEEPSEEK_API_KEY,
175
+ prompt: 'Hello',
176
+ })
177
+ ```
178
+
179
+ ## Supported Models
180
+
181
+ This library does not ship with a predefined list of models. Instead, it accepts **any model** from the supported providers:
182
+
183
+ - **OpenAI**: Any OpenAI model
184
+ - **Anthropic**: Any Anthropic model
185
+ - **Google**: Any Google model
186
+ - **DashScope**: Any DashScope model
187
+ - **DeepSeek**: Any DeepSeek model
188
+
189
+ ### Loading Models
190
+
191
+ Models are loaded programmatically via `setModels()` from external sources (CMS, API, or local files for evaluation):
192
+
193
+ ```javascript
194
+ import { createAi, setModels } from '@pwshub/aisdk'
195
+
196
+ // Load models from your CMS or API
197
+ const modelsFromCms = await fetch('https://cms.example.com/api/models').then(r => r.json())
198
+ setModels(modelsFromCms)
199
+
200
+ const ai = createAi()
201
+ const result = await ai.ask({
202
+ model: 'gemini-2.5-flash',
203
+ apikey: 'your-api-key',
204
+ prompt: 'Hello!',
205
+ })
206
+ ```
207
+
208
+ ### Model Record Format
209
+
210
+ Each model record should include:
211
+ - `id`: Model identifier used in requests
212
+ - `name`: Official model name (used in API calls)
213
+ - `provider`: Provider ID (openai, anthropic, google, dashscope, deepseek)
214
+ - `input_price`: Price per 1M input tokens (USD)
215
+ - `output_price`: Price per 1M output tokens (USD)
216
+ - `cache_price`: Price per 1M cached tokens (USD)
217
+ - `max_in`: Maximum input tokens (context window)
218
+ - `max_out`: Maximum output tokens
219
+ - `enable`: Boolean to enable/disable the model
220
+ - `supportedParams` (optional): Array of supported parameter names
221
+
222
+ > **Note**: The `examples/` folder includes `models.json` as a reference for running evaluation scripts.
223
+
224
+ ## Error Handling
225
+
226
+ ```javascript
227
+ import { createAi, ProviderError, InputError } from '@pwshub/aisdk'
228
+
229
+ const ai = createAi()
230
+
231
+ try {
232
+ const result = await ai.ask({
233
+ model: 'gpt-4o',
234
+ apikey: process.env.OPENAI_API_KEY,
235
+ prompt: 'Hello',
236
+ })
237
+ } catch (error) {
238
+ if (error instanceof ProviderError) {
239
+ // Provider-side error (rate limit, server error)
240
+ // Safe to retry or fallback to another model
241
+ console.error('Provider error:', error.status, error.message)
242
+ } else if (error instanceof InputError) {
243
+ // Client-side error (bad request, invalid API key)
244
+ // Do NOT retry — fix the input
245
+ console.error('Input error:', error.status, error.message)
246
+ }
247
+ }
248
+ ```
249
+
250
+ ## Running Evaluation Scripts
251
+
252
+ The package includes evaluation scripts to test each provider:
253
+
254
+ ```bash
255
+ # OpenAI
256
+ OPENAI_API_KEY=your-key npm run eval:openai
257
+
258
+ # Anthropic
259
+ ANTHROPIC_API_KEY=your-key npm run eval:anthropic
260
+
261
+ # Google
262
+ GOOGLE_API_KEY=your-key npm run eval:google
263
+
264
+ # DashScope
265
+ DASHSCOPE_API_KEY=your-key npm run eval:dashscope
266
+
267
+ # DeepSeek
268
+ DEEPSEEK_API_KEY=your-key npm run eval:deepseek
269
+ ```
270
+
271
+ ## Development
272
+
273
+ ```bash
274
+ # Install dependencies
275
+ npm install
276
+
277
+ # Run tests
278
+ npm test
279
+
280
+ # Run linter
281
+ npm run lint
282
+
283
+ # Auto-fix linting issues
284
+ npm run lint:fix
285
+ ```
286
+
287
+ ## AI Agents team
288
+
289
+ - Claude Code: initiator
290
+ - Qwen Code: implementer
291
+ - Google Gemini: reviewer
292
+ - DeepSeek: supporter
293
+ - Ollama: supporter
294
+
295
+ ## License
296
+
297
+ MIT
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@pwshub/aisdk",
3
+ "version": "0.0.1",
4
+ "description": "A thin, unified AI client for OpenAI, Anthropic, Google, DashScope, and DeepSeek with automatic param normalization and fallback support",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/ndaidong/aisdk"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.0.0",
11
+ "bun": ">=1.0.0"
12
+ },
13
+ "type": "module",
14
+ "main": "./src/index.js",
15
+ "exports": {
16
+ ".": "./src/index.js"
17
+ },
18
+ "types": "./src/index.js",
19
+ "files": [
20
+ "src"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --test test/*.test.js",
24
+ "lint": "eslint src/ test/",
25
+ "lint:fix": "eslint src/ test/ --fix",
26
+ "eval:openai": "node examples/openai.js",
27
+ "eval:anthropic": "node examples/anthropic.js",
28
+ "eval:google": "node examples/google.js",
29
+ "eval:dashscope": "node examples/dashscope.js",
30
+ "eval:deepseek": "node examples/deepseek.js"
31
+ },
32
+ "devDependencies": {
33
+ "@eslint/js": "^10.0.1",
34
+ "eslint": "^10.0.3",
35
+ "globals": "^17.4.0"
36
+ },
37
+ "keywords": [
38
+ "ai",
39
+ "llm",
40
+ "openai",
41
+ "anthropic",
42
+ "google",
43
+ "gemini",
44
+ "claude",
45
+ "gpt",
46
+ "qwen",
47
+ "deepseek",
48
+ "chat",
49
+ "generation",
50
+ "sdk"
51
+ ],
52
+ "author": "ndaidong",
53
+ "license": "MIT"
54
+ }
package/src/coerce.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @fileoverview Per-provider param clamping.
3
+ *
4
+ * Coerces generation config values to each provider's acceptable ranges.
5
+ * Instead of throwing errors, values are silently clamped to min/max bounds
6
+ * with a console.warn for visibility.
7
+ *
8
+ * Uses the merged WIRE_KEYS config from config.js for range information.
9
+ */
10
+
11
+ import { getWireMap } from './config.js'
12
+
13
+ /**
14
+ * @typedef {import('./registry.js').ProviderId} ProviderId
15
+ */
16
+
17
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
18
+
19
+ /**
20
+ * Coerce config values to provider's acceptable ranges.
21
+ * Logs warnings for clamped values.
22
+ *
23
+ * @param {Record<string, unknown>} config
24
+ * @param {ProviderId} providerId
25
+ * @returns {Record<string, unknown>}
26
+ */
27
+ export const coerceConfig = (config, providerId) => {
28
+ const wireMap = getWireMap(providerId)
29
+ if (!wireMap) {
30
+ return config
31
+ }
32
+
33
+ const result = { ...config }
34
+
35
+ for (const [key, value] of Object.entries(config)) {
36
+ const descriptor = wireMap[key]
37
+ const range = descriptor?.range
38
+ if (!range || typeof value !== 'number') {
39
+ continue
40
+ }
41
+
42
+ const clamped = clamp(value, range.min, range.max)
43
+ if (clamped !== value) {
44
+ console.warn(
45
+ `[ai-client] "${key}" value ${value} out of range for ${providerId}, clamped to ${clamped}`
46
+ )
47
+ result[key] = clamped
48
+ }
49
+ }
50
+
51
+ return result
52
+ }
package/src/config.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @fileoverview Generation config normalizer.
3
+ *
4
+ * Validates canonical camelCase params against the model's supportedParams list
5
+ * (from registry), warns on unsupported ones, then translates to provider
6
+ * wire-format keys.
7
+ *
8
+ * Wire-key mappings are defined per-provider here. Google additionally needs
9
+ * some params nested under `generationConfig`.
10
+ */
11
+
12
+ /**
13
+ * @typedef {import('./registry.js').ProviderId} ProviderId
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} GenerationConfig
18
+ * @property {number} [temperature]
19
+ * @property {number} [maxTokens]
20
+ * @property {number} [topP]
21
+ * @property {number} [topK]
22
+ * @property {number} [frequencyPenalty]
23
+ * @property {number} [presencePenalty]
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} ParamRange
28
+ * @property {number} min
29
+ * @property {number} max
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} WireDescriptor
34
+ * @property {string} wireKey
35
+ * @property {'root'|'generationConfig'} [scope] - Google nests some params
36
+ * @property {ParamRange} [range] - Valid range for the param (for clamping)
37
+ */
38
+
39
+ /**
40
+ * Wire-key translation table per provider.
41
+ * Only lists params that exist for that provider.
42
+ * Includes range info for param clamping.
43
+ *
44
+ * @type {Record<ProviderId, Record<string, WireDescriptor>>}
45
+ */
46
+ const WIRE_KEYS = {
47
+ openai: {
48
+ maxTokens: { wireKey: 'max_completion_tokens' },
49
+ temperature: {
50
+ wireKey: 'temperature', range: {
51
+ min: 0, max: 2,
52
+ },
53
+ },
54
+ topP: {
55
+ wireKey: 'top_p', range: {
56
+ min: 0, max: 1,
57
+ },
58
+ },
59
+ frequencyPenalty: {
60
+ wireKey: 'frequency_penalty', range: {
61
+ min: -2, max: 2,
62
+ },
63
+ },
64
+ presencePenalty: {
65
+ wireKey: 'presence_penalty', range: {
66
+ min: -2, max: 2,
67
+ },
68
+ },
69
+ // https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
70
+ },
71
+ anthropic: {
72
+ maxTokens: { wireKey: 'max_tokens' },
73
+ temperature: {
74
+ wireKey: 'temperature', range: {
75
+ min: 0, max: 1,
76
+ },
77
+ },
78
+ topP: {
79
+ wireKey: 'top_p', range: {
80
+ min: 0, max: 1,
81
+ },
82
+ },
83
+ topK: {
84
+ wireKey: 'top_k', range: {
85
+ min: 1, max: 100,
86
+ },
87
+ },
88
+ },
89
+ google: {
90
+ temperature: {
91
+ wireKey: 'temperature', scope: 'generationConfig', range: {
92
+ min: 0, max: 2,
93
+ },
94
+ },
95
+ maxTokens: {
96
+ wireKey: 'maxOutputTokens', scope: 'generationConfig',
97
+ },
98
+ topP: {
99
+ wireKey: 'topP', scope: 'generationConfig', range: {
100
+ min: 0, max: 1,
101
+ },
102
+ },
103
+ topK: {
104
+ wireKey: 'topK', scope: 'generationConfig', range: {
105
+ min: 1, max: 100,
106
+ },
107
+ },
108
+ },
109
+ dashscope: {
110
+ temperature: {
111
+ wireKey: 'temperature', range: {
112
+ min: 0, max: 2,
113
+ },
114
+ },
115
+ maxTokens: { wireKey: 'max_tokens' },
116
+ topP: {
117
+ wireKey: 'top_p', range: {
118
+ min: 0, max: 1,
119
+ },
120
+ },
121
+ topK: {
122
+ wireKey: 'top_k', range: {
123
+ min: 1, max: 100,
124
+ },
125
+ },
126
+ },
127
+ deepseek: {
128
+ temperature: {
129
+ wireKey: 'temperature', range: {
130
+ min: 0, max: 2,
131
+ },
132
+ },
133
+ maxTokens: { wireKey: 'max_tokens' },
134
+ topP: {
135
+ wireKey: 'top_p', range: {
136
+ min: 0, max: 1,
137
+ },
138
+ },
139
+ frequencyPenalty: {
140
+ wireKey: 'frequency_penalty', range: {
141
+ min: -2, max: 2,
142
+ },
143
+ },
144
+ presencePenalty: {
145
+ wireKey: 'presence_penalty', range: {
146
+ min: -2, max: 2,
147
+ },
148
+ },
149
+ },
150
+ }
151
+
152
+ /**
153
+ * Normalizes a GenerationConfig to provider wire format, using the model's
154
+ * `supportedParams` list as the source of truth for what's allowed.
155
+ *
156
+ * - Params not in `supportedParams` → console.warn + drop
157
+ * - Params in `supportedParams` → translate to provider wire key
158
+ * - Google params with scope 'generationConfig' → nested automatically
159
+ *
160
+ * @param {GenerationConfig} config
161
+ * @param {ProviderId} providerId
162
+ * @param {string[]} supportedParams - From the model's registry entry
163
+ * @param {string} modelId - Used in warning messages
164
+ * @returns {Record<string, unknown>}
165
+ */
166
+ export const normalizeConfig = (config, providerId, supportedParams, modelId) => {
167
+ const wireMap = WIRE_KEYS[providerId]
168
+ const supported = new Set(supportedParams)
169
+ const root = {}
170
+ const generationConfig = {}
171
+
172
+ for (const [canonicalKey, value] of Object.entries(config)) {
173
+ if (value === null || value === undefined) {
174
+ continue
175
+ }
176
+
177
+ if (!supported.has(canonicalKey)) {
178
+ console.warn(
179
+ `[ai-client] "${canonicalKey}" is not supported by model "${modelId}" — skipping.`
180
+ )
181
+ continue
182
+ }
183
+
184
+ const descriptor = wireMap[canonicalKey]
185
+ if (!descriptor) {
186
+ continue
187
+ } // paranoia guard — should not happen if registry is correct
188
+
189
+ if (descriptor.scope === 'generationConfig') {
190
+ generationConfig[descriptor.wireKey] = value
191
+ } else {
192
+ root[descriptor.wireKey] = value
193
+ }
194
+ }
195
+
196
+ if (Object.keys(generationConfig).length > 0) {
197
+ root.generationConfig = generationConfig
198
+ }
199
+
200
+ return root
201
+ }
202
+
203
+ /**
204
+ * Returns the wire-key map for a provider.
205
+ * Used by coerce.js for param range validation.
206
+ * @param {ProviderId} providerId
207
+ * @returns {Record<string, WireDescriptor>}
208
+ */
209
+ export const getWireMap = (providerId) => WIRE_KEYS[providerId]