@inkdropapp/ai 0.1.0 → 0.1.2

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 CHANGED
@@ -4,7 +4,7 @@ Headless TypeScript library for multi-provider AI integration. Designed to run
4
4
  in the Inkdrop Electron main process.
5
5
 
6
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.
7
+ - **AIRegistry 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
8
  - **Two providers ship out of the box**:
9
9
  - `AnthropicProvider` (Claude 4.x) — built-in model catalogue, prompt-cache configuration.
10
10
  - `OpenAICompatibleProvider` — instantiated per user-named entry; covers OpenRouter, Together, Fireworks, Groq, vLLM, Ollama (`/v1`), LiteLLM, etc. with a single class.
@@ -31,7 +31,7 @@ The package is **ESM-only**. Use `import`, not `require`.
31
31
  ## Quickstart
32
32
 
33
33
  ```ts
34
- import { Registry, type AISettings } from '@inkdropapp/ai'
34
+ import { AIRegistry, type AISettings } from '@inkdropapp/ai'
35
35
 
36
36
  const settings: AISettings = {
37
37
  providers: {
@@ -44,13 +44,11 @@ const settings: AISettings = {
44
44
  }
45
45
  ]
46
46
  },
47
- slots: {
48
- default: { providerId: 'anthropic', modelId: 'claude-sonnet-4-6' },
49
- fast: { providerId: 'anthropic', modelId: 'claude-haiku-4-5' }
50
- }
47
+ // Pick one provider — slot models come from its catalogue defaults.
48
+ providerId: 'anthropic'
51
49
  }
52
50
 
53
- const registry = new Registry({ settings })
51
+ const registry = new AIRegistry({ settings })
54
52
 
55
53
  // Configure once — keys go to the system keyring (or env var).
56
54
  const anthropic = registry.getProvider('anthropic')!
@@ -102,13 +100,18 @@ Inkdrop's AI features map onto two slots:
102
100
 
103
101
  A slot resolves to `(provider, model)` via:
104
102
 
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.
103
+ 1. Explicit binding from `settings.slots[slot]`, if both the provider id and model id resolve. *(Advanced — pin a specific model per slot or mix providers across slots.)*
104
+ 2. For `'fast'` only: fall back to `settings.slots.default` if that resolves.
105
+ 3. Preferred provider from `settings.providerId`, if configured — takes its `defaultModel()` (for `default`) or `defaultFastModel()` (for `fast`). *(The simple UI knob — one provider dropdown drives every slot.)*
106
+ 4. First **authenticated** registered provider in alphabetical id order, taking its `defaultModel()` / `defaultFastModel()`.
107
+ 5. `undefined` if no provider can satisfy the slot.
108
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.
109
+ Steps 1 and 3 do **not** check authentication the user picked those
110
+ explicitly, so auth errors surface inline as `'error'` events at stream time
111
+ rather than silently swapping providers. Step 4 is a courtesy fallback for
112
+ hosts that don't wire any preference at all; it skips unauthenticated
113
+ providers. `streamCompletion` throws `NoApiKey` only when resolution returns
114
+ `undefined` (i.e. no provider is configured at all).
112
115
 
113
116
  ### Credential resolution
114
117
 
@@ -139,7 +142,7 @@ An `AIModelCatalog` is a per-provider override of that list, designed to be
139
142
  distributed by a server endpoint and swapped in at runtime:
140
143
 
141
144
  ```ts
142
- import { Registry, type AIModelCatalog } from '@inkdropapp/ai'
145
+ import { AIRegistry, type AIModelCatalog } from '@inkdropapp/ai'
143
146
 
144
147
  const catalog: AIModelCatalog = {
145
148
  anthropic: {
@@ -167,14 +170,14 @@ const catalog: AIModelCatalog = {
167
170
  }
168
171
  }
169
172
 
170
- const registry = new Registry({ settings, catalog })
173
+ const registry = new AIRegistry({ settings, catalog })
171
174
  ```
172
175
 
173
176
  Resolution order, from highest to lowest priority:
174
177
 
175
178
  1. **User-declared `config.models`** (`settings.providers.<name>.models`) — the
176
179
  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.
180
+ 2. **The catalogue passed to `AIRegistry`** — overrides the provider's built-in list.
178
181
  3. **The provider's compiled-in catalogue** (`ANTHROPIC_MODELS`, …) — fallback.
179
182
 
180
183
  `defaultModelId` / `defaultFastModelId` override the provider's hard-coded
@@ -191,7 +194,7 @@ Typical flow: app starts with no catalogue (built-in defaults apply), fetches
191
194
  one from the server in the background, then swaps it in:
192
195
 
193
196
  ```ts
194
- const registry = new Registry({ settings })
197
+ const registry = new AIRegistry({ settings })
195
198
 
196
199
  fetch('https://config.inkdrop.app/ai-catalog.json')
197
200
  .then(r => r.json() as Promise<AIModelCatalog>)
@@ -266,10 +269,10 @@ hosts that want to predict the name (e.g. for an onboarding hint).
266
269
 
267
270
  ## API reference
268
271
 
269
- ### `Registry`
272
+ ### `AIRegistry`
270
273
 
271
274
  ```ts
272
- new Registry({
275
+ new AIRegistry({
273
276
  settings: AISettings,
274
277
  catalog?: AIModelCatalog, // server-distributed model lists; see "Model catalogues"
275
278
  keyStore?: KeyStore
@@ -299,8 +302,8 @@ store.deleteKey(baseURL: string): Promise<void> // no-op if absent
299
302
  store.invalidate(baseURL?: string): void // omit url to clear all
300
303
  ```
301
304
 
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
305
+ You generally don't construct a `KeyStore` yourself — `AIRegistry` creates one.
306
+ Pass an existing instance via `AIRegistryOptions.keyStore` if you want to share
304
307
  its cache across registries.
305
308
 
306
309
  ### `AIProvider`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkdropapp/ai",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "AI integration common module",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -18,12 +18,14 @@
18
18
  ],
19
19
  "author": "Takuya Matsuyama <t@inkdrop.app>",
20
20
  "license": "UNLICENSED",
21
+ "packageManager": "pnpm@11.1.1",
21
22
  "dependencies": {
22
- "@ai-sdk/anthropic": "^4.0.0-beta.40",
23
- "@ai-sdk/openai-compatible": "^3.0.0-beta.33",
23
+ "@ai-sdk/anthropic": "4.0.0-beta.42",
24
+ "@ai-sdk/openai-compatible": "3.0.0-beta.36",
25
+ "@ai-sdk/provider": "4.0.0-beta.14",
24
26
  "@inkdropapp/ai-catalog": "^0.1.0",
25
27
  "@napi-rs/keyring": "^1.2.0",
26
- "ai": "^7.0.0-beta.113"
28
+ "ai": "7.0.0-beta.116"
27
29
  },
28
30
  "devDependencies": {
29
31
  "@types/jest": "^30.0.0",
package/src/index.ts CHANGED
@@ -42,7 +42,7 @@ export {
42
42
  deriveEnvVarName
43
43
  } from './providers/openai-compatible.js'
44
44
  export {
45
- Registry,
46
- type RegistryOptions,
45
+ AIRegistry,
46
+ type AIRegistryOptions,
47
47
  type ResolvedSlot
48
48
  } from './registry.js'
package/src/provider.ts CHANGED
@@ -52,7 +52,10 @@ export interface AIModel {
52
52
  * OpenAI-compatible providers (OpenRouter, Together, Ollama, …) are modelled.
53
53
  */
54
54
  export interface AIProvider {
55
- /** Stable provider id; appears in `AISettings.slots[*].providerId`. */
55
+ /**
56
+ * Stable provider id; appears in both `AISettings.providerId` and
57
+ * `AISettings.slots[*].providerId`.
58
+ */
56
59
  readonly id: string
57
60
  /** Human-readable label. */
58
61
  readonly name: string
package/src/registry.ts CHANGED
@@ -13,13 +13,13 @@ export type ResolvedSlot = {
13
13
  model: AIModel
14
14
  }
15
15
 
16
- export type RegistryOptions = {
16
+ export type AIRegistryOptions = {
17
17
  settings: AISettings
18
18
  /**
19
19
  * Optional server-distributed catalogue of supported models per provider.
20
20
  * When omitted, each provider uses its compiled-in defaults (e.g. `ANTHROPIC_MODELS`).
21
21
  * The host typically fetches this from an API and either passes it at
22
- * construction time or swaps it in later via {@link Registry.updateCatalog}.
22
+ * construction time or swaps it in later via {@link AIRegistry.updateCatalog}.
23
23
  */
24
24
  catalog?: AIModelCatalog
25
25
  /** Pass an existing `KeyStore` to share its in-memory cache across registries. */
@@ -32,26 +32,26 @@ export type RegistryOptions = {
32
32
  * Owns one instance of every configured provider, resolves task slots with
33
33
  * fallback, and is the only place feature code calls to start a completion.
34
34
  *
35
- * The `Registry` is stateless beyond its provider list — it doesn't observe
36
- * settings on its own. Call {@link Registry.updateSettings} when settings
35
+ * The `AIRegistry` is stateless beyond its provider list — it doesn't observe
36
+ * settings on its own. Call {@link AIRegistry.updateSettings} when settings
37
37
  * change to rebuild providers.
38
38
  *
39
39
  * @example
40
40
  * ```ts
41
- * const registry = new Registry({ settings })
41
+ * const registry = new AIRegistry({ settings })
42
42
  * const events = await registry.streamCompletion('default', { messages })
43
43
  * for await (const event of events) {
44
44
  * if (event.kind === 'text-delta') process.stdout.write(event.delta)
45
45
  * }
46
46
  * ```
47
47
  */
48
- export class Registry {
48
+ export class AIRegistry {
49
49
  readonly keyStore: KeyStore
50
50
  private providers: Map<string, AIProvider> = new Map()
51
51
  private settings: AISettings
52
52
  private catalog: AIModelCatalog | undefined
53
53
 
54
- constructor(options: RegistryOptions) {
54
+ constructor(options: AIRegistryOptions) {
55
55
  this.keyStore = options.keyStore ?? new KeyStore()
56
56
  this.settings = options.settings
57
57
  this.catalog = options.catalog
@@ -84,12 +84,17 @@ export class Registry {
84
84
  * Order:
85
85
  * 1. Explicit binding `settings.slots[slot]`, if both provider and model resolve.
86
86
  * 2. For `'fast'` only: fall back to `settings.slots.default`.
87
- * 3. First authenticated provider in alphabetical id order, taking that
87
+ * 3. Preferred provider `settings.providerId`, if that provider is configured,
88
+ * taking its `defaultModel()` (or `defaultFastModel()` for the fast slot).
89
+ * Authentication is not gated here — the user explicitly picked this
90
+ * provider; auth errors surface at stream time rather than silently
91
+ * swapping providers.
92
+ * 4. First authenticated provider in alphabetical id order, taking that
88
93
  * provider's `defaultModel()` (or `defaultFastModel()` for the fast slot).
89
94
  * Mirrors Zed's `available_fallback_model` without the Zed-Cloud preference.
90
- * 4. `undefined` if no provider can satisfy the slot.
95
+ * 5. `undefined` if no provider can satisfy the slot.
91
96
  *
92
- * Async because the third step calls `provider.isAuthenticated()`, which may
97
+ * Async because the fourth step calls `provider.isAuthenticated()`, which may
93
98
  * touch the keyring.
94
99
  */
95
100
  async resolveSlot(slot: SlotName): Promise<ResolvedSlot | undefined> {
@@ -101,6 +106,17 @@ export class Registry {
101
106
  if (fallback) return fallback
102
107
  }
103
108
 
109
+ if (this.settings.providerId) {
110
+ const provider = this.providers.get(this.settings.providerId)
111
+ if (provider) {
112
+ const model =
113
+ slot === 'fast'
114
+ ? provider.defaultFastModel()
115
+ : provider.defaultModel()
116
+ if (model) return { provider, model }
117
+ }
118
+ }
119
+
104
120
  for (const provider of this.providers.values()) {
105
121
  if (!(await provider.isAuthenticated())) continue
106
122
  const model =
@@ -124,7 +140,7 @@ export class Registry {
124
140
  /**
125
141
  * Streams a completion for a task slot.
126
142
  *
127
- * Resolves the slot via {@link Registry.resolveSlot}; throws {@link NoApiKey}
143
+ * Resolves the slot via {@link AIRegistry.resolveSlot}; throws {@link NoApiKey}
128
144
  * if no provider can satisfy it (no slot binding, and no authenticated
129
145
  * provider available for the implicit fallback). Otherwise returns the
130
146
  * model's stream — note that *upstream* errors (auth, rate limit, etc.)
package/src/settings.ts CHANGED
@@ -88,7 +88,7 @@ export type AnthropicProviderConfig = CommonProviderConfig & {
88
88
 
89
89
  /**
90
90
  * Top-level AI configuration. The host (Inkdrop main process) constructs this
91
- * from whichever persistence layer it uses and passes it to `Registry`.
91
+ * from whichever persistence layer it uses and passes it to `AIRegistry`.
92
92
  *
93
93
  * The library never reads or writes settings to disk itself.
94
94
  */
@@ -98,8 +98,17 @@ export type AISettings = {
98
98
  openaiCompatible?: OpenAICompatibleProviderConfig[]
99
99
  }
100
100
  /**
101
- * Per-slot model overrides. Unset slots fall back to the first registered
102
- * provider's `defaultModel` / `defaultFastModel`.
101
+ * Preferred provider for every task slot. When set (and the per-slot
102
+ * `slots[slot]` override isn't), each slot resolves to this provider's
103
+ * `defaultModel` / `defaultFastModel`. This is the simple UI knob — bind it
104
+ * to a single "AI provider" dropdown.
105
+ */
106
+ providerId?: string
107
+ /**
108
+ * Per-slot `(provider, model)` overrides. Wins over `providerId` when set.
109
+ * Useful for advanced configurations that pin a specific model per slot or
110
+ * mix providers across slots (e.g. Claude for `default`, a local model for
111
+ * `fast`).
103
112
  */
104
113
  slots?: Partial<Record<SlotName, SlotConfig>>
105
114
  }