@inkdropapp/ai 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 +472 -0
- package/package.json +44 -0
- package/src/errors.ts +117 -0
- package/src/index.ts +48 -0
- package/src/internal/map-ai-sdk-error.ts +96 -0
- package/src/internal/map-ai-sdk-stream.ts +111 -0
- package/src/key-store.ts +116 -0
- package/src/provider.ts +86 -0
- package/src/providers/anthropic.ts +175 -0
- package/src/providers/openai-compatible.ts +205 -0
- package/src/registry.ts +190 -0
- package/src/settings.ts +105 -0
- package/src/stream-events.ts +53 -0
- package/src/url.ts +41 -0
- package/types/capabilities.d.ts +37 -0
- package/types/catalog.d.ts +32 -0
- package/types/errors.d.ts +69 -0
- package/types/index.d.ts +10 -0
- package/types/internal/map-ai-sdk-error.d.ts +21 -0
- package/types/internal/map-ai-sdk-stream.d.ts +25 -0
- package/types/key-store.d.ts +45 -0
- package/types/provider.d.ts +74 -0
- package/types/providers/anthropic-models.d.ts +30 -0
- package/types/providers/anthropic.d.ts +41 -0
- package/types/providers/openai-compatible.d.ts +59 -0
- package/types/registry.d.ts +98 -0
- package/types/settings.d.ts +94 -0
- package/types/stream-events.d.ts +61 -0
- package/types/url.d.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# @inkdropapp/ai
|
|
2
|
+
|
|
3
|
+
Headless TypeScript library for multi-provider AI integration. Designed to run
|
|
4
|
+
in the Inkdrop Electron main process.
|
|
5
|
+
|
|
6
|
+
- **Two-interface design** — `AIProvider` (auth + model catalogue) is separate from `AIModel` (capabilities + streaming).
|
|
7
|
+
- **Registry with task-slot routing** — slot binding plus fallback to per-provider defaults, so a single AI feature gets a sensible model without any user setup.
|
|
8
|
+
- **Two providers ship out of the box**:
|
|
9
|
+
- `AnthropicProvider` (Claude 4.x) — built-in model catalogue, prompt-cache configuration.
|
|
10
|
+
- `OpenAICompatibleProvider` — instantiated per user-named entry; covers OpenRouter, Together, Fireworks, Groq, vLLM, Ollama (`/v1`), LiteLLM, etc. with a single class.
|
|
11
|
+
- **Swappable model catalogues** — providers' built-in model lists can be replaced at runtime with an `AIModelCatalog` (e.g. fetched from a server), so new model releases ship without an app update.
|
|
12
|
+
- **URL-keyed credential storage** via `@napi-rs/keyring`. Two endpoints to the same provider get separate keyring entries automatically.
|
|
13
|
+
- **Single streaming event type** — every provider's stream is reduced to a `StreamEvent` discriminated union.
|
|
14
|
+
- **Retry-aware error type** — `RateLimitExceeded` / `ServerOverloaded` carry `retryAfter` (seconds) parsed from the response header.
|
|
15
|
+
- **No tools, no agents, no UI, no persistence in v0.1** — see [Out of scope](#out-of-scope-deferred).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install @inkdropapp/ai
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Required peers (already declared as dependencies): `ai@beta`, `@ai-sdk/anthropic@beta`, `@ai-sdk/openai-compatible@beta`, `@napi-rs/keyring`.
|
|
26
|
+
|
|
27
|
+
The package is **ESM-only**. Use `import`, not `require`.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { Registry, type AISettings } from '@inkdropapp/ai'
|
|
35
|
+
|
|
36
|
+
const settings: AISettings = {
|
|
37
|
+
providers: {
|
|
38
|
+
anthropic: {},
|
|
39
|
+
openaiCompatible: [
|
|
40
|
+
{
|
|
41
|
+
id: 'openrouter',
|
|
42
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
43
|
+
models: [{ id: 'meta/llama-3-70b' }]
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
slots: {
|
|
48
|
+
default: { providerId: 'anthropic', modelId: 'claude-sonnet-4-6' },
|
|
49
|
+
fast: { providerId: 'anthropic', modelId: 'claude-haiku-4-5' }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const registry = new Registry({ settings })
|
|
54
|
+
|
|
55
|
+
// Configure once — keys go to the system keyring (or env var).
|
|
56
|
+
const anthropic = registry.getProvider('anthropic')!
|
|
57
|
+
if (!(await anthropic.isAuthenticated())) {
|
|
58
|
+
await anthropic.setApiKey('sk-ant-...')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const events = await registry.streamCompletion('default', {
|
|
62
|
+
messages: [{ role: 'user', content: 'Summarise this note in one sentence.' }]
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
for await (const event of events) {
|
|
66
|
+
switch (event.kind) {
|
|
67
|
+
case 'text-delta':
|
|
68
|
+
process.stdout.write(event.delta)
|
|
69
|
+
break
|
|
70
|
+
case 'finish':
|
|
71
|
+
console.log('\n[done]', event.usage)
|
|
72
|
+
break
|
|
73
|
+
case 'error':
|
|
74
|
+
console.error('[error]', event.error.kind, event.error.message)
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Concepts
|
|
83
|
+
|
|
84
|
+
### Provider vs Model
|
|
85
|
+
|
|
86
|
+
An `AIProvider` is one authenticated integration (one API key + one `baseURL`).
|
|
87
|
+
It exposes a list of `AIModel` instances. The model carries the capability flags
|
|
88
|
+
(`supportsTools`, `supportsImages`, `supportsThinking`, `maxTokens`, …) and the
|
|
89
|
+
streaming entrypoint.
|
|
90
|
+
|
|
91
|
+
UI gating and request-construction logic should branch on **model capabilities**,
|
|
92
|
+
not on `provider.id`. That keeps the host code provider-agnostic.
|
|
93
|
+
|
|
94
|
+
### Slots
|
|
95
|
+
|
|
96
|
+
Inkdrop's AI features map onto two slots:
|
|
97
|
+
|
|
98
|
+
| Slot | Used for |
|
|
99
|
+
| --------- | ---------------------------------------------------------------------------------- |
|
|
100
|
+
| `default` | Long-form generation. The user's "main" model. |
|
|
101
|
+
| `fast` | Latency-sensitive tasks: Next-Edit Suggestions, quick rewrites, title suggestions. |
|
|
102
|
+
|
|
103
|
+
A slot resolves to `(provider, model)` via:
|
|
104
|
+
|
|
105
|
+
1. Explicit binding from `settings.slots[slot]`, if both the provider id and model id resolve.
|
|
106
|
+
2. The first registered provider's `defaultModel()` (for `default`) or `defaultFastModel()` (for `fast`).
|
|
107
|
+
3. `undefined` if no provider can satisfy the slot.
|
|
108
|
+
|
|
109
|
+
Authentication is **not** checked during slot resolution — that's
|
|
110
|
+
`streamCompletion`'s job, which throws `NoApiKey` if no provider is configured
|
|
111
|
+
at all and surfaces upstream auth failures inline as `'error'` events.
|
|
112
|
+
|
|
113
|
+
### Credential resolution
|
|
114
|
+
|
|
115
|
+
`KeyStore` resolves an API key in this order:
|
|
116
|
+
|
|
117
|
+
1. The provider's environment variable (e.g. `ANTHROPIC_API_KEY`, or
|
|
118
|
+
`<UPPER_SNAKE_ID>_API_KEY` for an OpenAI-compatible entry — see
|
|
119
|
+
[`deriveEnvVarName`](#environment-variable-naming)).
|
|
120
|
+
2. System keyring entry under the service `inkdrop.ai` and account = the
|
|
121
|
+
_normalised_ `baseURL`.
|
|
122
|
+
|
|
123
|
+
The keyring account is the URL, not the provider id, so two endpoints to the
|
|
124
|
+
same provider get **separate** entries (e.g. staging + prod, or Ollama at home
|
|
125
|
+
|
|
126
|
+
- at work). The host never has to model that distinction.
|
|
127
|
+
|
|
128
|
+
The cache is in-memory, keyed on normalised `baseURL`. Env-var changes mid-process
|
|
129
|
+
are not reflected until you call `keyStore.invalidate()`. `setApiKey` /
|
|
130
|
+
`clearApiKey` invalidate automatically.
|
|
131
|
+
|
|
132
|
+
### Model catalogues
|
|
133
|
+
|
|
134
|
+
Each provider has a compiled-in list of models (e.g. `ANTHROPIC_MODELS`). That
|
|
135
|
+
list is fine until a new Claude version ships and you don't want to wait for an
|
|
136
|
+
Inkdrop release to use it.
|
|
137
|
+
|
|
138
|
+
An `AIModelCatalog` is a per-provider override of that list, designed to be
|
|
139
|
+
distributed by a server endpoint and swapped in at runtime:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { Registry, type AIModelCatalog } from '@inkdropapp/ai'
|
|
143
|
+
|
|
144
|
+
const catalog: AIModelCatalog = {
|
|
145
|
+
anthropic: {
|
|
146
|
+
models: [
|
|
147
|
+
{
|
|
148
|
+
id: 'claude-sonnet-4-7',
|
|
149
|
+
displayName: 'Claude Sonnet 4.7',
|
|
150
|
+
capabilities: {
|
|
151
|
+
supportsTools: true,
|
|
152
|
+
supportsImages: true,
|
|
153
|
+
supportsThinking: true,
|
|
154
|
+
supportsStreamingTools: true,
|
|
155
|
+
maxTokens: 200_000,
|
|
156
|
+
maxOutputTokens: 64_000
|
|
157
|
+
},
|
|
158
|
+
cacheConfiguration: {
|
|
159
|
+
minTotalTokens: 2048,
|
|
160
|
+
minCachedTokens: 1024,
|
|
161
|
+
shouldSpeculate: true
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
defaultModelId: 'claude-sonnet-4-7',
|
|
166
|
+
defaultFastModelId: 'claude-haiku-4-5'
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const registry = new Registry({ settings, catalog })
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Resolution order, from highest to lowest priority:
|
|
174
|
+
|
|
175
|
+
1. **User-declared `config.models`** (`settings.providers.<name>.models`) — the
|
|
176
|
+
user's local overrides always win, regardless of where the catalogue came from.
|
|
177
|
+
2. **The catalogue passed to `Registry`** — overrides the provider's built-in list.
|
|
178
|
+
3. **The provider's compiled-in catalogue** (`ANTHROPIC_MODELS`, …) — fallback.
|
|
179
|
+
|
|
180
|
+
`defaultModelId` / `defaultFastModelId` override the provider's hard-coded
|
|
181
|
+
defaults the same way.
|
|
182
|
+
|
|
183
|
+
Each provider entry on `AIModelCatalog` is independent and optional — you can
|
|
184
|
+
ship `{ anthropic: ... }` and leave other providers on their built-in
|
|
185
|
+
catalogues. Today `anthropic` is the only entry; other providers will be added
|
|
186
|
+
as they need it.
|
|
187
|
+
|
|
188
|
+
#### Updating the catalogue at runtime
|
|
189
|
+
|
|
190
|
+
Typical flow: app starts with no catalogue (built-in defaults apply), fetches
|
|
191
|
+
one from the server in the background, then swaps it in:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
const registry = new Registry({ settings })
|
|
195
|
+
|
|
196
|
+
fetch('https://config.inkdrop.app/ai-catalog.json')
|
|
197
|
+
.then(r => r.json() as Promise<AIModelCatalog>)
|
|
198
|
+
.then(catalog => registry.updateCatalog(catalog))
|
|
199
|
+
.catch(err => console.warn('Catalog fetch failed; using built-ins', err))
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`updateCatalog(undefined)` reverts to the providers' compiled-in catalogues.
|
|
203
|
+
The shared `KeyStore` cache and authentication state are preserved across the swap.
|
|
204
|
+
|
|
205
|
+
### Streaming events
|
|
206
|
+
|
|
207
|
+
Every provider's stream collapses into the same discriminated union:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
type StreamEvent =
|
|
211
|
+
| { kind: 'text-delta'; delta: string }
|
|
212
|
+
| { kind: 'tool-call'; id: string; name: string; input: unknown }
|
|
213
|
+
| { kind: 'tool-result'; id: string; name: string; result: unknown }
|
|
214
|
+
| { kind: 'usage-update'; usage: Usage }
|
|
215
|
+
| { kind: 'finish'; finishReason: FinishReason; usage: Usage }
|
|
216
|
+
| { kind: 'error'; error: AIError }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Three contracts you can rely on:
|
|
220
|
+
|
|
221
|
+
1. **Errors are events, not exceptions.** Mid-stream failures arrive as
|
|
222
|
+
`{ kind: 'error', ... }` and the iterator returns. No try/catch around the
|
|
223
|
+
`for await` loop is needed.
|
|
224
|
+
2. **The iterator always terminates.** Every successful stream ends with a
|
|
225
|
+
`'finish'` event; every failed one ends with an `'error'` event.
|
|
226
|
+
3. **Unknown SDK chunk types are silently dropped.** Forward-compat with future
|
|
227
|
+
AI SDK chunk variants without a library bump.
|
|
228
|
+
|
|
229
|
+
The library is currently text-only — `tool-call` / `tool-result` variants are
|
|
230
|
+
typed for forward compat but never produced.
|
|
231
|
+
|
|
232
|
+
### Errors
|
|
233
|
+
|
|
234
|
+
`AIError` is the base class. Variants:
|
|
235
|
+
|
|
236
|
+
| Class | Kind | When |
|
|
237
|
+
| --------------------- | --------------------- | -------------------------------------------------------------------------- |
|
|
238
|
+
| `NoApiKey` | `no-api-key` | The provider has no key configured (env var or keyring). |
|
|
239
|
+
| `AuthenticationError` | `authentication` | Upstream returned 401/403. |
|
|
240
|
+
| `RateLimitExceeded` | `rate-limit-exceeded` | Upstream returned 429. Carries `retryAfter` (seconds). |
|
|
241
|
+
| `ServerOverloaded` | `server-overloaded` | Upstream returned 503/529. Carries `retryAfter`. |
|
|
242
|
+
| `PromptTooLarge` | `prompt-too-large` | Upstream returned 413, or the message indicates a context-length overflow. |
|
|
243
|
+
| `UpstreamError` | `upstream` | Anything else; preserves `status` and `cause`. |
|
|
244
|
+
|
|
245
|
+
Switch on `error.kind` rather than on `instanceof`. Use `isAIError(value)` if
|
|
246
|
+
you need a type guard for the union.
|
|
247
|
+
|
|
248
|
+
### Environment variable naming
|
|
249
|
+
|
|
250
|
+
For Anthropic the env-var name is fixed: `ANTHROPIC_API_KEY`.
|
|
251
|
+
|
|
252
|
+
For OpenAI-compatible entries it's derived from the entry's `id`:
|
|
253
|
+
|
|
254
|
+
| Entry id | Env var |
|
|
255
|
+
| -------------- | ---------------------- |
|
|
256
|
+
| `openrouter` | `OPENROUTER_API_KEY` |
|
|
257
|
+
| `together_ai` | `TOGETHER_AI_API_KEY` |
|
|
258
|
+
| `ollama-local` | `OLLAMA_LOCAL_API_KEY` |
|
|
259
|
+
| `my.proxy` | `MY_PROXY_API_KEY` |
|
|
260
|
+
|
|
261
|
+
The transform: replace any non-alphanumeric character with `_`, collapse runs
|
|
262
|
+
of `_`, uppercase, append `_API_KEY`. Exposed as `deriveEnvVarName(id)` for
|
|
263
|
+
hosts that want to predict the name (e.g. for an onboarding hint).
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## API reference
|
|
268
|
+
|
|
269
|
+
### `Registry`
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
new Registry({
|
|
273
|
+
settings: AISettings,
|
|
274
|
+
catalog?: AIModelCatalog, // server-distributed model lists; see "Model catalogues"
|
|
275
|
+
keyStore?: KeyStore
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
registry.listProviders(): AIProvider[]
|
|
279
|
+
registry.getProvider(id: string): AIProvider | undefined
|
|
280
|
+
registry.listAllModels(): { provider: AIProvider; model: AIModel }[]
|
|
281
|
+
registry.resolveSlot(slot: SlotName): ResolvedSlot | undefined
|
|
282
|
+
registry.streamCompletion(slot: SlotName, request: CompletionRequest): Promise<AsyncIterable<StreamEvent>>
|
|
283
|
+
registry.updateSettings(next: AISettings): void
|
|
284
|
+
registry.updateCatalog(next: AIModelCatalog | undefined): void
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`updateSettings` and `updateCatalog` both rebuild providers but preserve the
|
|
288
|
+
`KeyStore` cache. Pass `undefined` to `updateCatalog` to revert to providers'
|
|
289
|
+
compiled-in catalogues.
|
|
290
|
+
|
|
291
|
+
### `KeyStore`
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
new KeyStore({ service?: string }) // service defaults to 'inkdrop.ai'
|
|
295
|
+
|
|
296
|
+
store.getKey(envVarName: string | null, baseURL: string): Promise<string | null>
|
|
297
|
+
store.setKey(baseURL: string, key: string): Promise<void>
|
|
298
|
+
store.deleteKey(baseURL: string): Promise<void> // no-op if absent
|
|
299
|
+
store.invalidate(baseURL?: string): void // omit url to clear all
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
You generally don't construct a `KeyStore` yourself — `Registry` creates one.
|
|
303
|
+
Pass an existing instance via `RegistryOptions.keyStore` if you want to share
|
|
304
|
+
its cache across registries.
|
|
305
|
+
|
|
306
|
+
### `AIProvider`
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
provider.id // stable id, appears in slot config
|
|
310
|
+
provider.name // human label
|
|
311
|
+
provider.baseURL // doubles as keyring account
|
|
312
|
+
provider.envVarName // string | null
|
|
313
|
+
|
|
314
|
+
provider.listModels(): AIModel[]
|
|
315
|
+
provider.getModel(id: string): AIModel | undefined
|
|
316
|
+
provider.defaultModel(): AIModel | undefined
|
|
317
|
+
provider.defaultFastModel(): AIModel | undefined
|
|
318
|
+
|
|
319
|
+
provider.isAuthenticated(): Promise<boolean>
|
|
320
|
+
provider.setApiKey(key: string): Promise<void>
|
|
321
|
+
provider.clearApiKey(): Promise<void>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### `AIModel`
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
model.id
|
|
328
|
+
model.displayName
|
|
329
|
+
model.provider // back-reference
|
|
330
|
+
model.capabilities // ModelCapabilities
|
|
331
|
+
model.cacheConfiguration? // CacheConfiguration | undefined
|
|
332
|
+
|
|
333
|
+
model.streamCompletion(request: CompletionRequest): AsyncIterable<StreamEvent>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Examples
|
|
339
|
+
|
|
340
|
+
### Multiple OpenAI-compatible providers
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
const settings: AISettings = {
|
|
344
|
+
providers: {
|
|
345
|
+
openaiCompatible: [
|
|
346
|
+
{
|
|
347
|
+
id: 'openrouter',
|
|
348
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
349
|
+
models: [{ id: 'meta/llama-3-70b' }]
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: 'ollama_local',
|
|
353
|
+
baseURL: 'http://localhost:11434/v1',
|
|
354
|
+
models: [
|
|
355
|
+
{
|
|
356
|
+
id: 'llama3.2',
|
|
357
|
+
displayName: 'Llama 3.2',
|
|
358
|
+
capabilities: { supportsImages: false, maxTokens: 128_000 }
|
|
359
|
+
}
|
|
360
|
+
]
|
|
361
|
+
}
|
|
362
|
+
]
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`openrouter` looks up `OPENROUTER_API_KEY`; `ollama_local` looks up
|
|
368
|
+
`OLLAMA_LOCAL_API_KEY`. Each gets its own keyring entry under its `baseURL`.
|
|
369
|
+
|
|
370
|
+
### Mixing slots across providers
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
const settings: AISettings = {
|
|
374
|
+
providers: {
|
|
375
|
+
anthropic: {},
|
|
376
|
+
openaiCompatible: [
|
|
377
|
+
{
|
|
378
|
+
id: 'ollama_local',
|
|
379
|
+
baseURL: 'http://localhost:11434/v1',
|
|
380
|
+
models: [{ id: 'llama3.2' }]
|
|
381
|
+
}
|
|
382
|
+
]
|
|
383
|
+
},
|
|
384
|
+
slots: {
|
|
385
|
+
default: { providerId: 'anthropic', modelId: 'claude-sonnet-4-6' },
|
|
386
|
+
fast: { providerId: 'ollama_local', modelId: 'llama3.2' }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Custom Anthropic baseURL (proxy / staging)
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
const settings: AISettings = {
|
|
395
|
+
providers: {
|
|
396
|
+
anthropic: { baseURL: 'https://anthropic-proxy.internal/v1' }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The keyring entry for the proxy is separate from the one for
|
|
402
|
+
`https://api.anthropic.com`, so you can switch back and forth without
|
|
403
|
+
re-entering the key.
|
|
404
|
+
|
|
405
|
+
### Aborting a stream
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
const controller = new AbortController()
|
|
409
|
+
const events = await registry.streamCompletion('default', {
|
|
410
|
+
messages: [{ role: 'user', content: 'Long task...' }],
|
|
411
|
+
abortSignal: controller.signal
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// Later:
|
|
415
|
+
controller.abort()
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Handling rate limits
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
for await (const event of events) {
|
|
422
|
+
if (event.kind === 'error') {
|
|
423
|
+
if (event.error.kind === 'rate-limit-exceeded') {
|
|
424
|
+
const retry = event.error.retryAfter ?? 5
|
|
425
|
+
console.warn(`Rate limited; retrying in ${retry}s`)
|
|
426
|
+
// schedule retry…
|
|
427
|
+
}
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Listing every model for a picker UI
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
for (const { provider, model } of registry.listAllModels()) {
|
|
437
|
+
console.log(`${provider.name} / ${model.displayName}`, model.capabilities)
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Out of scope (deferred)
|
|
444
|
+
|
|
445
|
+
- Tools / agentic loops / MCP integration.
|
|
446
|
+
- Embedding models.
|
|
447
|
+
- Auto-discovery (e.g. Ollama `/api/tags`).
|
|
448
|
+
- Settings file persistence — pass `AISettings` in as plain data; the host owns its config layer.
|
|
449
|
+
- Reactivity / observable settings — the host's React app should re-create the registry (or call `updateSettings`) when settings change.
|
|
450
|
+
- React / UI components.
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## Development
|
|
455
|
+
|
|
456
|
+
```sh
|
|
457
|
+
npm install
|
|
458
|
+
npm test # runs all 82 tests against the real system keyring
|
|
459
|
+
npm run lint
|
|
460
|
+
npm run gen # emits .d.ts files into types/
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Tests use a unique per-run keyring service name (`inkdrop.ai.test.<hex>`) so they
|
|
464
|
+
never touch production entries on a dev machine, and an `afterEach` hook deletes
|
|
465
|
+
every entry created during the test.
|
|
466
|
+
|
|
467
|
+
CI runs the same suite on macOS, Windows, and Ubuntu (with gnome-keyring +
|
|
468
|
+
dbus-run-session for the keyring).
|
|
469
|
+
|
|
470
|
+
## License
|
|
471
|
+
|
|
472
|
+
UNLICENSED — internal Inkdrop module.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inkdropapp/ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI integration common module",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "types/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"lint": "eslint src __tests__",
|
|
10
|
+
"test": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/jest/bin/jest.js --config jest.config.cjs --runInBand",
|
|
11
|
+
"gen": "tsc --declaration --emitDeclarationOnly",
|
|
12
|
+
"format": "prettier --write .",
|
|
13
|
+
"prepublishOnly": "npm-run-all lint test gen"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"ai",
|
|
17
|
+
"inkdrop"
|
|
18
|
+
],
|
|
19
|
+
"author": "Takuya Matsuyama <t@inkdrop.app>",
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@ai-sdk/anthropic": "^4.0.0-beta.40",
|
|
23
|
+
"@ai-sdk/openai-compatible": "^3.0.0-beta.33",
|
|
24
|
+
"@inkdropapp/ai-catalog": "^0.1.0",
|
|
25
|
+
"@napi-rs/keyring": "^1.2.0",
|
|
26
|
+
"ai": "^7.0.0-beta.113"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/jest": "^30.0.0",
|
|
30
|
+
"@types/node": "^25.7.0",
|
|
31
|
+
"eslint": "^10.3.0",
|
|
32
|
+
"eslint-config-prettier": "^10.1.8",
|
|
33
|
+
"jest": "^30.4.2",
|
|
34
|
+
"npm-run-all": "^4.1.5",
|
|
35
|
+
"prettier": "^3.8.3",
|
|
36
|
+
"ts-jest": "^29.4.9",
|
|
37
|
+
"typescript": "^6.0.3",
|
|
38
|
+
"typescript-eslint": "^8.59.3"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"types"
|
|
43
|
+
]
|
|
44
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Discriminant for {@link AIError} subclasses. */
|
|
2
|
+
export type AIErrorKind =
|
|
3
|
+
| 'no-api-key'
|
|
4
|
+
| 'rate-limit-exceeded'
|
|
5
|
+
| 'server-overloaded'
|
|
6
|
+
| 'authentication'
|
|
7
|
+
| 'prompt-too-large'
|
|
8
|
+
| 'upstream'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base class for every error this library surfaces. Concrete subclasses are
|
|
12
|
+
* tagged with `kind` so consumers can switch on it without instanceof chains.
|
|
13
|
+
*
|
|
14
|
+
* Errors are emitted as `{ kind: 'error', error }` events inside completion
|
|
15
|
+
* streams rather than thrown — consumers that iterate the stream do not need
|
|
16
|
+
* try/catch.
|
|
17
|
+
*/
|
|
18
|
+
export abstract class AIError extends Error {
|
|
19
|
+
abstract readonly kind: AIErrorKind
|
|
20
|
+
|
|
21
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
22
|
+
super(message, options)
|
|
23
|
+
this.name = new.target.name
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Thrown / emitted when a provider has no resolvable API key — neither in its
|
|
29
|
+
* environment variable nor in the system keyring.
|
|
30
|
+
*/
|
|
31
|
+
export class NoApiKey extends AIError {
|
|
32
|
+
readonly kind = 'no-api-key' as const
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
public readonly providerId: string,
|
|
36
|
+
public readonly envVarName: string | null
|
|
37
|
+
) {
|
|
38
|
+
const hint = envVarName
|
|
39
|
+
? ` Set ${envVarName} or store the key in the system keychain.`
|
|
40
|
+
: ' Store the key in the system keychain.'
|
|
41
|
+
super(`Provider "${providerId}" has no API key configured.${hint}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Upstream returned 401 / 403 — the configured key was rejected. */
|
|
46
|
+
export class AuthenticationError extends AIError {
|
|
47
|
+
readonly kind = 'authentication' as const
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
public readonly providerId: string,
|
|
51
|
+
message?: string
|
|
52
|
+
) {
|
|
53
|
+
super(message ?? `Authentication failed for provider "${providerId}".`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Upstream returned 429. `retryAfter` is in seconds, parsed from the response header. */
|
|
58
|
+
export class RateLimitExceeded extends AIError {
|
|
59
|
+
readonly kind = 'rate-limit-exceeded' as const
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
public readonly providerId: string,
|
|
63
|
+
public readonly retryAfter: number | undefined,
|
|
64
|
+
message?: string
|
|
65
|
+
) {
|
|
66
|
+
super(message ?? `Rate limit exceeded on provider "${providerId}".`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Upstream returned 503 / 529. `retryAfter` is in seconds, parsed from the response header. */
|
|
71
|
+
export class ServerOverloaded extends AIError {
|
|
72
|
+
readonly kind = 'server-overloaded' as const
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
public readonly providerId: string,
|
|
76
|
+
public readonly retryAfter: number | undefined,
|
|
77
|
+
message?: string
|
|
78
|
+
) {
|
|
79
|
+
super(message ?? `Provider "${providerId}" is overloaded.`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Prompt exceeds the model's context window (HTTP 413, or message says so). */
|
|
84
|
+
export class PromptTooLarge extends AIError {
|
|
85
|
+
readonly kind = 'prompt-too-large' as const
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
public readonly providerId: string,
|
|
89
|
+
public readonly tokenCount: number | undefined,
|
|
90
|
+
public readonly maxTokens: number | undefined,
|
|
91
|
+
message?: string
|
|
92
|
+
) {
|
|
93
|
+
super(message ?? `Prompt exceeds the model's context window.`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Catch-all for upstream failures that don't map to a more specific variant.
|
|
99
|
+
* `status` is the HTTP status code when known; `cause` preserves the original
|
|
100
|
+
* AI SDK error for debugging.
|
|
101
|
+
*/
|
|
102
|
+
export class UpstreamError extends AIError {
|
|
103
|
+
readonly kind = 'upstream' as const
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
public readonly providerId: string,
|
|
107
|
+
message: string,
|
|
108
|
+
public readonly status?: number,
|
|
109
|
+
options?: { cause?: unknown }
|
|
110
|
+
) {
|
|
111
|
+
super(message, options)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Type guard for {@link AIError} and any of its subclasses. */
|
|
116
|
+
export const isAIError = (error: unknown): error is AIError =>
|
|
117
|
+
error instanceof AIError
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type { AIProvider, AIModel, CompletionRequest } from './provider.js'
|
|
2
|
+
export type { StreamEvent, Usage, FinishReason } from './stream-events.js'
|
|
3
|
+
export type {
|
|
4
|
+
AISettings,
|
|
5
|
+
SlotName,
|
|
6
|
+
SlotConfig,
|
|
7
|
+
CommonProviderConfig,
|
|
8
|
+
AnthropicProviderConfig,
|
|
9
|
+
AnthropicModelConfig,
|
|
10
|
+
OpenAICompatibleProviderConfig,
|
|
11
|
+
OpenAICompatibleModelConfig
|
|
12
|
+
} from './settings.js'
|
|
13
|
+
export {
|
|
14
|
+
ANTHROPIC_MODELS,
|
|
15
|
+
ANTHROPIC_DEFAULT_MODEL_ID,
|
|
16
|
+
ANTHROPIC_DEFAULT_FAST_MODEL_ID,
|
|
17
|
+
DEFAULT_ANTHROPIC_CACHE_CONFIG,
|
|
18
|
+
DEFAULT_ANTHROPIC_CAPABILITIES,
|
|
19
|
+
BUILT_IN_CATALOG,
|
|
20
|
+
type AIModelCatalog,
|
|
21
|
+
type AnthropicCatalog,
|
|
22
|
+
type AnthropicModelDescriptor,
|
|
23
|
+
type CacheConfiguration,
|
|
24
|
+
type ModelCapabilities
|
|
25
|
+
} from '@inkdropapp/ai-catalog'
|
|
26
|
+
export {
|
|
27
|
+
AIError,
|
|
28
|
+
NoApiKey,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
RateLimitExceeded,
|
|
31
|
+
ServerOverloaded,
|
|
32
|
+
PromptTooLarge,
|
|
33
|
+
UpstreamError,
|
|
34
|
+
isAIError,
|
|
35
|
+
type AIErrorKind
|
|
36
|
+
} from './errors.js'
|
|
37
|
+
export { KeyStore, type KeyStoreOptions } from './key-store.js'
|
|
38
|
+
export { normalizeBaseURL } from './url.js'
|
|
39
|
+
export { AnthropicProvider } from './providers/anthropic.js'
|
|
40
|
+
export {
|
|
41
|
+
OpenAICompatibleProvider,
|
|
42
|
+
deriveEnvVarName
|
|
43
|
+
} from './providers/openai-compatible.js'
|
|
44
|
+
export {
|
|
45
|
+
Registry,
|
|
46
|
+
type RegistryOptions,
|
|
47
|
+
type ResolvedSlot
|
|
48
|
+
} from './registry.js'
|