@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
|
@@ -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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -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
|
+
};
|