@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.
@@ -0,0 +1,205 @@
1
+ import {
2
+ createOpenAICompatible,
3
+ type OpenAICompatibleProvider as SdkOpenAICompatibleProvider
4
+ } from '@ai-sdk/openai-compatible'
5
+ import type {
6
+ CacheConfiguration,
7
+ ModelCapabilities
8
+ } from '@inkdropapp/ai-catalog'
9
+ import { streamText } from 'ai'
10
+ import { NoApiKey } from '../errors.js'
11
+ import type { KeyStore } from '../key-store.js'
12
+ import { mapAiSdkError } from '../internal/map-ai-sdk-error.js'
13
+ import { mapAiSdkStream } from '../internal/map-ai-sdk-stream.js'
14
+ import type { AIModel, AIProvider, CompletionRequest } from '../provider.js'
15
+ import type {
16
+ OpenAICompatibleModelConfig,
17
+ OpenAICompatibleProviderConfig
18
+ } from '../settings.js'
19
+ import type { StreamEvent } from '../stream-events.js'
20
+
21
+ const DEFAULT_CAPABILITIES: ModelCapabilities = {
22
+ supportsTools: true,
23
+ supportsImages: false,
24
+ supportsThinking: false,
25
+ supportsStreamingTools: false,
26
+ maxTokens: 32_000
27
+ }
28
+
29
+ /**
30
+ * Derives the env-var name for a user-named provider entry.
31
+ *
32
+ * Rules: replace any non-alphanumeric character with `_`, collapse runs of `_`,
33
+ * uppercase, append `_API_KEY`.
34
+ *
35
+ * Examples:
36
+ * `openrouter` → `OPENROUTER_API_KEY`
37
+ * `together_ai` → `TOGETHER_AI_API_KEY`
38
+ * `ollama-local` → `OLLAMA_LOCAL_API_KEY`
39
+ * `my.proxy` → `MY_PROXY_API_KEY`
40
+ */
41
+ export const deriveEnvVarName = (id: string): string => {
42
+ const sanitized = id
43
+ .replace(/[^a-zA-Z0-9]+/g, '_')
44
+ .replace(/^_+|_+$/g, '')
45
+ .toUpperCase()
46
+ return `${sanitized}_API_KEY`
47
+ }
48
+
49
+ const mergeCapabilities = (
50
+ override: Partial<ModelCapabilities> | undefined
51
+ ): ModelCapabilities => ({
52
+ ...DEFAULT_CAPABILITIES,
53
+ ...override
54
+ })
55
+
56
+ /**
57
+ * Provider for any OpenAI-compatible chat-completions endpoint
58
+ * (OpenRouter, Together, Fireworks, Groq, vLLM, Ollama via `/v1`, LiteLLM, …).
59
+ *
60
+ * Each user-named entry in `AISettings.providers.openaiCompatible[]`
61
+ * becomes one instance. The env-var name is derived from the entry's `id`
62
+ * via {@link deriveEnvVarName}; the keyring account is the resolved `baseURL`.
63
+ *
64
+ * Capability flags are user-declared per model (with sensible defaults of
65
+ * `tools: true`, `images: false`, `thinking: false`); the host UI keys off
66
+ * those flags exactly the same as for Anthropic.
67
+ *
68
+ * Some endpoints (Ollama, vLLM, …) require no API key. The provider
69
+ * accommodates this by passing `apiKey: undefined` to the SDK rather than
70
+ * proactively throwing `NoApiKey` — auth failures only surface if the
71
+ * upstream actually returns 401.
72
+ */
73
+ export class OpenAICompatibleProvider implements AIProvider {
74
+ readonly id: string
75
+ readonly name: string
76
+ readonly baseURL: string
77
+ readonly envVarName: string
78
+
79
+ private readonly config: OpenAICompatibleProviderConfig
80
+ private readonly keyStore: KeyStore
81
+ private readonly modelsById: Map<string, OpenAICompatibleAIModel>
82
+
83
+ private sdkClient: SdkOpenAICompatibleProvider | undefined
84
+ private sdkClientApiKey: string | undefined
85
+
86
+ constructor(keyStore: KeyStore, config: OpenAICompatibleProviderConfig) {
87
+ this.config = config
88
+ this.keyStore = keyStore
89
+ this.id = config.id
90
+ this.name = config.displayName ?? config.id
91
+ this.baseURL = config.baseURL
92
+ this.envVarName = deriveEnvVarName(config.id)
93
+ this.modelsById = new Map(
94
+ config.models.map(m => [m.id, new OpenAICompatibleAIModel(this, m)])
95
+ )
96
+ }
97
+
98
+ listModels(): AIModel[] {
99
+ return Array.from(this.modelsById.values())
100
+ }
101
+
102
+ getModel(id: string): AIModel | undefined {
103
+ return this.modelsById.get(id)
104
+ }
105
+
106
+ defaultModel(): AIModel | undefined {
107
+ if (this.config.defaultModelId) {
108
+ const explicit = this.modelsById.get(this.config.defaultModelId)
109
+ if (explicit) return explicit
110
+ }
111
+ const first = this.config.models[0]
112
+ return first ? this.modelsById.get(first.id) : undefined
113
+ }
114
+
115
+ defaultFastModel(): AIModel | undefined {
116
+ if (this.config.defaultFastModelId) {
117
+ const explicit = this.modelsById.get(this.config.defaultFastModelId)
118
+ if (explicit) return explicit
119
+ }
120
+ return this.defaultModel()
121
+ }
122
+
123
+ async isAuthenticated(): Promise<boolean> {
124
+ const key = await this.keyStore.getKey(this.envVarName, this.baseURL)
125
+ return key !== null && key.length > 0
126
+ }
127
+
128
+ async setApiKey(key: string): Promise<void> {
129
+ await this.keyStore.setKey(this.baseURL, key)
130
+ this.sdkClient = undefined
131
+ this.sdkClientApiKey = undefined
132
+ }
133
+
134
+ async clearApiKey(): Promise<void> {
135
+ await this.keyStore.deleteKey(this.baseURL)
136
+ this.sdkClient = undefined
137
+ this.sdkClientApiKey = undefined
138
+ }
139
+
140
+ /** Internal — used by OpenAICompatibleAIModel to resolve the SDK client lazily. */
141
+ async getSdkClient(): Promise<SdkOpenAICompatibleProvider> {
142
+ const apiKey = await this.keyStore.getKey(this.envVarName, this.baseURL)
143
+ // OpenAI-compatible endpoints (Ollama, vLLM…) often need no API key.
144
+ // We pass `apiKey: undefined` and let the upstream return 401 if it does.
145
+ if (this.sdkClient && this.sdkClientApiKey === (apiKey ?? ''))
146
+ return this.sdkClient
147
+ this.sdkClient = createOpenAICompatible({
148
+ name: this.id,
149
+ baseURL: this.baseURL,
150
+ apiKey: apiKey ?? undefined
151
+ })
152
+ this.sdkClientApiKey = apiKey ?? ''
153
+ return this.sdkClient
154
+ }
155
+
156
+ /** Internal — exposed for tests / 401 handling: did the user configure an explicit auth value? */
157
+ async hasConfiguredKey(): Promise<boolean> {
158
+ const key = await this.keyStore.getKey(this.envVarName, this.baseURL)
159
+ return key !== null && key.length > 0
160
+ }
161
+
162
+ /** Internal — used by the model when upstream returns 401: surface a NoApiKey if the user never configured one. */
163
+ async ensureAuthenticatedOrThrow(): Promise<void> {
164
+ if (!(await this.hasConfiguredKey()))
165
+ throw new NoApiKey(this.id, this.envVarName)
166
+ }
167
+ }
168
+
169
+ class OpenAICompatibleAIModel implements AIModel {
170
+ readonly id: string
171
+ readonly displayName: string
172
+ readonly capabilities: ModelCapabilities
173
+ readonly cacheConfiguration: CacheConfiguration | undefined
174
+
175
+ constructor(
176
+ readonly provider: OpenAICompatibleProvider,
177
+ config: OpenAICompatibleModelConfig
178
+ ) {
179
+ this.id = config.id
180
+ this.displayName = config.displayName ?? config.id
181
+ this.capabilities = mergeCapabilities(config.capabilities)
182
+ this.cacheConfiguration = undefined
183
+ }
184
+
185
+ async *streamCompletion(
186
+ request: CompletionRequest
187
+ ): AsyncIterable<StreamEvent> {
188
+ let result
189
+ try {
190
+ const sdk = await this.provider.getSdkClient()
191
+ result = streamText({
192
+ model: sdk(this.id),
193
+ messages: request.messages,
194
+ temperature: request.temperature,
195
+ maxOutputTokens: request.maxOutputTokens,
196
+ abortSignal: request.abortSignal
197
+ })
198
+ } catch (error) {
199
+ yield { kind: 'error', error: mapAiSdkError(error, this.provider.id) }
200
+ return
201
+ }
202
+
203
+ yield* mapAiSdkStream(result, this.provider.id)
204
+ }
205
+ }
@@ -0,0 +1,190 @@
1
+ import type { AIModelCatalog } from '@inkdropapp/ai-catalog'
2
+ import { NoApiKey } from './errors.js'
3
+ import { KeyStore } from './key-store.js'
4
+ import type { AIModel, AIProvider, CompletionRequest } from './provider.js'
5
+ import { AnthropicProvider } from './providers/anthropic.js'
6
+ import { OpenAICompatibleProvider } from './providers/openai-compatible.js'
7
+ import type { AISettings, SlotConfig, SlotName } from './settings.js'
8
+ import type { StreamEvent } from './stream-events.js'
9
+
10
+ /** Result of resolving a slot to a concrete `(provider, model)` pair. */
11
+ export type ResolvedSlot = {
12
+ provider: AIProvider
13
+ model: AIModel
14
+ }
15
+
16
+ export type RegistryOptions = {
17
+ settings: AISettings
18
+ /**
19
+ * Optional server-distributed catalogue of supported models per provider.
20
+ * When omitted, each provider uses its compiled-in defaults (e.g. `ANTHROPIC_MODELS`).
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}.
23
+ */
24
+ catalog?: AIModelCatalog
25
+ /** Pass an existing `KeyStore` to share its in-memory cache across registries. */
26
+ keyStore?: KeyStore
27
+ }
28
+
29
+ /**
30
+ * Top-level entrypoint to the library.
31
+ *
32
+ * Owns one instance of every configured provider, resolves task slots with
33
+ * fallback, and is the only place feature code calls to start a completion.
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
37
+ * change to rebuild providers.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const registry = new Registry({ settings })
42
+ * const events = await registry.streamCompletion('default', { messages })
43
+ * for await (const event of events) {
44
+ * if (event.kind === 'text-delta') process.stdout.write(event.delta)
45
+ * }
46
+ * ```
47
+ */
48
+ export class Registry {
49
+ readonly keyStore: KeyStore
50
+ private providers: Map<string, AIProvider> = new Map()
51
+ private settings: AISettings
52
+ private catalog: AIModelCatalog | undefined
53
+
54
+ constructor(options: RegistryOptions) {
55
+ this.keyStore = options.keyStore ?? new KeyStore()
56
+ this.settings = options.settings
57
+ this.catalog = options.catalog
58
+ this.rebuildProviders()
59
+ }
60
+
61
+ /** All configured providers, sorted alphabetically by id. */
62
+ listProviders(): AIProvider[] {
63
+ return Array.from(this.providers.values())
64
+ }
65
+
66
+ getProvider(id: string): AIProvider | undefined {
67
+ return this.providers.get(id)
68
+ }
69
+
70
+ /** Flat list of every model across every provider. Useful for picker UIs. */
71
+ listAllModels(): { provider: AIProvider; model: AIModel }[] {
72
+ const out: { provider: AIProvider; model: AIModel }[] = []
73
+ for (const provider of this.providers.values()) {
74
+ for (const model of provider.listModels()) {
75
+ out.push({ provider, model })
76
+ }
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * Resolves a slot to a concrete `(provider, model)` pair.
83
+ *
84
+ * Order:
85
+ * 1. Explicit binding `settings.slots[slot]`, if both provider and model resolve.
86
+ * 2. For `'fast'` only: fall back to `settings.slots.default`.
87
+ * 3. First authenticated provider in alphabetical id order, taking that
88
+ * provider's `defaultModel()` (or `defaultFastModel()` for the fast slot).
89
+ * Mirrors Zed's `available_fallback_model` without the Zed-Cloud preference.
90
+ * 4. `undefined` if no provider can satisfy the slot.
91
+ *
92
+ * Async because the third step calls `provider.isAuthenticated()`, which may
93
+ * touch the keyring.
94
+ */
95
+ async resolveSlot(slot: SlotName): Promise<ResolvedSlot | undefined> {
96
+ const direct = this.tryBinding(this.settings.slots?.[slot])
97
+ if (direct) return direct
98
+
99
+ if (slot === 'fast') {
100
+ const fallback = this.tryBinding(this.settings.slots?.default)
101
+ if (fallback) return fallback
102
+ }
103
+
104
+ for (const provider of this.providers.values()) {
105
+ if (!(await provider.isAuthenticated())) continue
106
+ const model =
107
+ slot === 'fast' ? provider.defaultFastModel() : provider.defaultModel()
108
+ if (model) return { provider, model }
109
+ }
110
+
111
+ return undefined
112
+ }
113
+
114
+ private tryBinding(
115
+ binding: SlotConfig | undefined
116
+ ): ResolvedSlot | undefined {
117
+ if (!binding) return undefined
118
+ const provider = this.providers.get(binding.providerId)
119
+ const model = provider?.getModel(binding.modelId)
120
+ if (provider && model) return { provider, model }
121
+ return undefined
122
+ }
123
+
124
+ /**
125
+ * Streams a completion for a task slot.
126
+ *
127
+ * Resolves the slot via {@link Registry.resolveSlot}; throws {@link NoApiKey}
128
+ * if no provider can satisfy it (no slot binding, and no authenticated
129
+ * provider available for the implicit fallback). Otherwise returns the
130
+ * model's stream — note that *upstream* errors (auth, rate limit, etc.)
131
+ * arrive as `'error'` events inside the iterable, not as thrown exceptions.
132
+ */
133
+ async streamCompletion(
134
+ slot: SlotName,
135
+ request: CompletionRequest
136
+ ): Promise<AsyncIterable<StreamEvent>> {
137
+ const resolved = await this.resolveSlot(slot)
138
+ if (!resolved) {
139
+ throw new NoApiKey('<registry>', null)
140
+ }
141
+ return resolved.model.streamCompletion(request)
142
+ }
143
+
144
+ /**
145
+ * Replaces the active settings and rebuilds the provider list.
146
+ * The shared `KeyStore` cache is preserved.
147
+ */
148
+ updateSettings(next: AISettings): void {
149
+ this.settings = next
150
+ this.rebuildProviders()
151
+ }
152
+
153
+ /**
154
+ * Replaces the active model catalogue and rebuilds providers.
155
+ *
156
+ * Pass `undefined` to revert to providers' compiled-in defaults. Typical
157
+ * usage: app starts with no catalogue, fetches one from the server, then
158
+ * calls `updateCatalog(catalog)` once the response lands.
159
+ *
160
+ * The shared `KeyStore` cache and authentication state are preserved.
161
+ */
162
+ updateCatalog(next: AIModelCatalog | undefined): void {
163
+ this.catalog = next
164
+ this.rebuildProviders()
165
+ }
166
+
167
+ private rebuildProviders(): void {
168
+ const built: AIProvider[] = []
169
+
170
+ if (this.settings.providers.anthropic) {
171
+ built.push(
172
+ new AnthropicProvider(
173
+ this.keyStore,
174
+ this.settings.providers.anthropic,
175
+ this.catalog?.anthropic
176
+ )
177
+ )
178
+ }
179
+
180
+ for (const config of this.settings.providers.openaiCompatible ?? []) {
181
+ built.push(new OpenAICompatibleProvider(this.keyStore, config))
182
+ }
183
+
184
+ // Sort once at build time so `Map` iteration (insertion order) is
185
+ // alphabetical by id — the iteration order Zed uses for fallback resolution.
186
+ built.sort((a, b) => a.id.localeCompare(b.id))
187
+
188
+ this.providers = new Map(built.map(p => [p.id, p]))
189
+ }
190
+ }
@@ -0,0 +1,105 @@
1
+ import type {
2
+ CacheConfiguration,
3
+ ModelCapabilities
4
+ } from '@inkdropapp/ai-catalog'
5
+
6
+ /**
7
+ * Task-routing slot names.
8
+ *
9
+ * - `default` — long-form generation; the user's "main" model.
10
+ * - `fast` — latency-sensitive features (NES, quick rewrite, title suggest).
11
+ */
12
+ export type SlotName = 'default' | 'fast'
13
+
14
+ /** Explicit binding of a slot to a `(provider, model)` pair. */
15
+ export type SlotConfig = {
16
+ providerId: string
17
+ modelId: string
18
+ }
19
+
20
+ /**
21
+ * Fields every provider config carries. Per-provider configs extend this
22
+ * to add provider-specific knobs (and may narrow `baseURL` to required when
23
+ * there's no sensible default).
24
+ */
25
+ export type CommonProviderConfig = {
26
+ /** Endpoint root. Falls back to a provider-specific default when omitted. */
27
+ baseURL?: string
28
+ }
29
+
30
+ /**
31
+ * One entry under an OpenAI-compatible provider's `models[]`. Capability flags
32
+ * are user-declared (a partial — sensible defaults are applied for fields the
33
+ * user omits).
34
+ */
35
+ export type OpenAICompatibleModelConfig = {
36
+ id: string
37
+ displayName?: string
38
+ capabilities?: Partial<ModelCapabilities>
39
+ }
40
+
41
+ /**
42
+ * One user-named OpenAI-compatible provider entry (e.g. `openrouter`,
43
+ * `together_ai`, `ollama_local`). Each becomes a separate `AIProvider`
44
+ * instance at runtime with its own keyring entry.
45
+ *
46
+ * `baseURL` is required here — there's no sensible default when the user is
47
+ * pointing at an arbitrary endpoint.
48
+ */
49
+ export type OpenAICompatibleProviderConfig = CommonProviderConfig & {
50
+ id: string
51
+ displayName?: string
52
+ /** Endpoint root, e.g. `https://openrouter.ai/api/v1`. */
53
+ baseURL: string
54
+ models: OpenAICompatibleModelConfig[]
55
+ /** Defaults to `models[0]` when omitted. */
56
+ defaultModelId?: string
57
+ /** Defaults to `defaultModel()` when omitted. */
58
+ defaultFastModelId?: string
59
+ }
60
+
61
+ /**
62
+ * One user-declared Anthropic model on top of the built-in Claude catalogue.
63
+ * Useful when Anthropic ships a new model id before this library does.
64
+ *
65
+ * If `id` matches a built-in model, this entry overrides it. Otherwise it's
66
+ * added to the catalogue.
67
+ */
68
+ export type AnthropicModelConfig = {
69
+ id: string
70
+ displayName?: string
71
+ capabilities?: Partial<ModelCapabilities>
72
+ cacheConfiguration?: CacheConfiguration
73
+ }
74
+
75
+ /**
76
+ * Anthropic provider config.
77
+ *
78
+ * - `baseURL` is only needed for proxy / staging endpoints.
79
+ * - `models` adds (or overrides) Claude entries on top of the built-in
80
+ * {@link ANTHROPIC_MODELS} catalogue.
81
+ *
82
+ * Default-model selection is *not* configured here — use `slots` at the top
83
+ * of `AISettings` to bind features to specific models.
84
+ */
85
+ export type AnthropicProviderConfig = CommonProviderConfig & {
86
+ models?: AnthropicModelConfig[]
87
+ }
88
+
89
+ /**
90
+ * Top-level AI configuration. The host (Inkdrop main process) constructs this
91
+ * from whichever persistence layer it uses and passes it to `Registry`.
92
+ *
93
+ * The library never reads or writes settings to disk itself.
94
+ */
95
+ export type AISettings = {
96
+ providers: {
97
+ anthropic?: AnthropicProviderConfig
98
+ openaiCompatible?: OpenAICompatibleProviderConfig[]
99
+ }
100
+ /**
101
+ * Per-slot model overrides. Unset slots fall back to the first registered
102
+ * provider's `defaultModel` / `defaultFastModel`.
103
+ */
104
+ slots?: Partial<Record<SlotName, SlotConfig>>
105
+ }
@@ -0,0 +1,53 @@
1
+ import type { AIError } from './errors.js'
2
+
3
+ /**
4
+ * Why a completion stream stopped producing output.
5
+ *
6
+ * Mirrors the AI SDK's `FinishReason`. Unknown / provider-specific values are
7
+ * normalised to `'other'` rather than silently passed through.
8
+ */
9
+ export type FinishReason =
10
+ | 'stop'
11
+ | 'length'
12
+ | 'content-filter'
13
+ | 'tool-calls'
14
+ | 'error'
15
+ | 'other'
16
+
17
+ /**
18
+ * Token usage for a single completion. Every field is optional because
19
+ * not every provider reports every counter.
20
+ */
21
+ export type Usage = {
22
+ /** Tokens consumed from the prompt. */
23
+ inputTokens?: number
24
+ /** Tokens generated in the response. */
25
+ outputTokens?: number
26
+ /** `inputTokens + outputTokens` as reported by the provider. */
27
+ totalTokens?: number
28
+ /** Of `inputTokens`, how many were a cache hit (Anthropic prompt-cache reads). */
29
+ cacheReadInputTokens?: number
30
+ /** Of `inputTokens`, how many were written to the prompt cache for future reads. */
31
+ cacheCreationInputTokens?: number
32
+ /** Subset of `outputTokens` spent on reasoning / thinking content. */
33
+ reasoningTokens?: number
34
+ }
35
+
36
+ /**
37
+ * Provider-agnostic streaming events. Every concrete provider's stream is
38
+ * reduced to this discriminated union so consumers never branch on provider id.
39
+ *
40
+ * Errors arrive as `{ kind: 'error', ... }` events, not as thrown exceptions —
41
+ * this lets the host pump events across an Electron IPC boundary one at a time
42
+ * without needing try/catch around the iterator.
43
+ *
44
+ * `tool-call` and `tool-result` are typed but never produced in the current
45
+ * version (one-shot text completions only). They're reserved for forward compat.
46
+ */
47
+ export type StreamEvent =
48
+ | { kind: 'text-delta'; delta: string }
49
+ | { kind: 'tool-call'; id: string; name: string; input: unknown }
50
+ | { kind: 'tool-result'; id: string; name: string; result: unknown }
51
+ | { kind: 'usage-update'; usage: Usage }
52
+ | { kind: 'finish'; finishReason: FinishReason; usage: Usage }
53
+ | { kind: 'error'; error: AIError }
package/src/url.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Conservative baseURL normalisation for keying credential entries.
3
+ *
4
+ * Rules:
5
+ * - lowercase scheme + host
6
+ * - drop default port (`:443` for https, `:80` for http) for the matching scheme
7
+ * - strip a single trailing `/` from the path
8
+ *
9
+ * Path case, query, and fragment are preserved verbatim. Under-normalising
10
+ * splits keyring entries that should share a key; over-normalising merges
11
+ * entries that should be separate. The rules above are intentionally minimal.
12
+ *
13
+ * @throws if `input` is not a parseable absolute URL.
14
+ */
15
+ export const normalizeBaseURL = (input: string): string => {
16
+ let url: URL
17
+ try {
18
+ url = new URL(input)
19
+ } catch {
20
+ throw new Error(`Invalid baseURL: ${input}`)
21
+ }
22
+
23
+ url.protocol = url.protocol.toLowerCase()
24
+ url.hostname = url.hostname.toLowerCase()
25
+
26
+ if (
27
+ (url.protocol === 'https:' && url.port === '443') ||
28
+ (url.protocol === 'http:' && url.port === '80')
29
+ ) {
30
+ url.port = ''
31
+ }
32
+
33
+ let result = url.toString()
34
+ if (
35
+ result.endsWith('/') &&
36
+ (url.pathname === '/' || url.pathname.length > 1)
37
+ ) {
38
+ result = result.slice(0, -1)
39
+ }
40
+ return result
41
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Per-model capability flags. The host UI keys off these to enable / disable
3
+ * features (image attachments, tool calls, extended thinking, etc.) without
4
+ * having to know which provider the model belongs to.
5
+ */
6
+ export type ModelCapabilities = {
7
+ /** Model supports function-calling / tool-use. */
8
+ supportsTools: boolean;
9
+ /** Model accepts image content parts in user messages. */
10
+ supportsImages: boolean;
11
+ /** Model can produce explicit reasoning / thinking output. */
12
+ supportsThinking: boolean;
13
+ /** Tool-call arguments stream incrementally rather than arriving in one chunk. */
14
+ supportsStreamingTools: boolean;
15
+ /** Maximum total tokens (input + output) the model can handle in a single request. */
16
+ maxTokens: number;
17
+ /**
18
+ * Maximum tokens the model can produce in a single response.
19
+ * Falls back to `maxTokens` semantics when omitted.
20
+ */
21
+ maxOutputTokens?: number;
22
+ };
23
+ /**
24
+ * Prompt-cache thresholds, currently only meaningful for Anthropic models.
25
+ * Drives whether and where to insert cache breakpoints in the request.
26
+ */
27
+ export type CacheConfiguration = {
28
+ /** Don't cache anything unless the request totals at least this many tokens. */
29
+ minTotalTokens: number;
30
+ /** Minimum tokens that must be cacheable in a span before a breakpoint is worthwhile. */
31
+ minCachedTokens: number;
32
+ /**
33
+ * Whether to opportunistically cache the *most recent* user turn even if a future
34
+ * cache hit isn't certain — trades a small write cost for a likely future read.
35
+ */
36
+ shouldSpeculate: boolean;
37
+ };
@@ -0,0 +1,32 @@
1
+ import type { AnthropicModelDescriptor } from './providers/anthropic-models.js';
2
+ /**
3
+ * Per-provider catalogue entry.
4
+ *
5
+ * `models` replaces the built-in compiled-in list (e.g. `ANTHROPIC_MODELS`)
6
+ * for that provider. `defaultModelId` / `defaultFastModelId` override the
7
+ * provider's hard-coded defaults.
8
+ *
9
+ * User-declared `config.models` (under `AISettings.providers.<name>.models`)
10
+ * still override entries here — the user's local config always wins over a
11
+ * catalogue, regardless of where the catalogue came from.
12
+ */
13
+ export type AnthropicCatalog = {
14
+ models: readonly AnthropicModelDescriptor[];
15
+ defaultModelId?: string;
16
+ defaultFastModelId?: string;
17
+ };
18
+ /**
19
+ * Catalogue of built-in models for each provider, designed to be distributed
20
+ * by a server endpoint and swapped in at runtime.
21
+ *
22
+ * The motivation is to decouple model releases from app releases — when
23
+ * Anthropic ships a new Claude version, the host can publish an updated
24
+ * catalogue without forcing every Inkdrop user to update.
25
+ *
26
+ * Without a catalogue, each provider falls back to its compiled-in defaults.
27
+ * Each provider entry is independent and optional; the host can ship
28
+ * `{ anthropic: ... }` and leave other providers on their built-in catalogues.
29
+ */
30
+ export type AIModelCatalog = {
31
+ anthropic?: AnthropicCatalog;
32
+ };