@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,96 @@
1
+ import { APICallError, AISDKError, LoadAPIKeyError } from '@ai-sdk/provider'
2
+ import {
3
+ AIError,
4
+ AuthenticationError,
5
+ NoApiKey,
6
+ PromptTooLarge,
7
+ RateLimitExceeded,
8
+ ServerOverloaded,
9
+ UpstreamError
10
+ } from '../errors.js'
11
+
12
+ const parseRetryAfter = (header: string | undefined): number | undefined => {
13
+ if (!header) return undefined
14
+ const seconds = Number(header)
15
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds
16
+ const epochMs = Date.parse(header)
17
+ if (!Number.isNaN(epochMs)) {
18
+ const delta = Math.max(0, Math.round((epochMs - Date.now()) / 1000))
19
+ return delta
20
+ }
21
+ return undefined
22
+ }
23
+
24
+ const looksLikeContextLength = (message: string | undefined): boolean => {
25
+ if (!message) return false
26
+ return /context length|too long|too many tokens|maximum.*tokens/i.test(
27
+ message
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Reduces an arbitrary unknown error coming out of the AI SDK to one of our
33
+ * {@link AIError} variants.
34
+ *
35
+ * Mapping rules:
36
+ * - Existing `AIError` → returned unchanged.
37
+ * - `LoadAPIKeyError` → `NoApiKey`.
38
+ * - `APICallError` with status:
39
+ * - 401 / 403 → `AuthenticationError`
40
+ * - 429 → `RateLimitExceeded` (with `retryAfter` parsed from header)
41
+ * - 503 / 529 → `ServerOverloaded` (with `retryAfter`)
42
+ * - 413, or any message that "looks like" a context-length overflow → `PromptTooLarge`
43
+ * - anything else → `UpstreamError` carrying status + cause
44
+ * - Other `AISDKError` / `Error` instances → `UpstreamError` (or `PromptTooLarge`
45
+ * if the message matches).
46
+ *
47
+ * @param providerId - Used to attribute the error in messages and downstream
48
+ * routing decisions.
49
+ */
50
+ export const mapAiSdkError = (error: unknown, providerId: string): AIError => {
51
+ if (error instanceof AIError) return error
52
+
53
+ if (LoadAPIKeyError.isInstance(error)) {
54
+ return new NoApiKey(providerId, null)
55
+ }
56
+
57
+ if (APICallError.isInstance(error)) {
58
+ const status = error.statusCode
59
+ const headers = error.responseHeaders
60
+ const retryAfter = parseRetryAfter(headers?.['retry-after'])
61
+ const message = error.message
62
+
63
+ if (status === 401 || status === 403) {
64
+ return new AuthenticationError(providerId, message)
65
+ }
66
+ if (status === 429) {
67
+ return new RateLimitExceeded(providerId, retryAfter, message)
68
+ }
69
+ if (status === 503 || status === 529) {
70
+ return new ServerOverloaded(providerId, retryAfter, message)
71
+ }
72
+ if (status === 413 || looksLikeContextLength(message)) {
73
+ return new PromptTooLarge(providerId, undefined, undefined, message)
74
+ }
75
+ return new UpstreamError(providerId, message, status, { cause: error })
76
+ }
77
+
78
+ if (AISDKError.isInstance(error)) {
79
+ return new UpstreamError(providerId, error.message, undefined, {
80
+ cause: error
81
+ })
82
+ }
83
+
84
+ if (error instanceof Error) {
85
+ if (looksLikeContextLength(error.message)) {
86
+ return new PromptTooLarge(providerId, undefined, undefined, error.message)
87
+ }
88
+ return new UpstreamError(providerId, error.message, undefined, {
89
+ cause: error
90
+ })
91
+ }
92
+
93
+ return new UpstreamError(providerId, 'Unknown upstream error', undefined, {
94
+ cause: error
95
+ })
96
+ }
@@ -0,0 +1,111 @@
1
+ import type {
2
+ LanguageModelUsage,
3
+ StreamTextResult,
4
+ TextStreamPart,
5
+ ToolSet
6
+ } from 'ai'
7
+ import type { FinishReason, StreamEvent, Usage } from '../stream-events.js'
8
+ import { mapAiSdkError } from './map-ai-sdk-error.js'
9
+
10
+ const mapUsage = (usage: LanguageModelUsage | undefined): Usage => {
11
+ if (!usage) return {}
12
+ return {
13
+ inputTokens: usage.inputTokens,
14
+ outputTokens: usage.outputTokens,
15
+ totalTokens: usage.totalTokens,
16
+ cacheReadInputTokens: usage.inputTokenDetails?.cacheReadTokens,
17
+ cacheCreationInputTokens: usage.inputTokenDetails?.cacheWriteTokens,
18
+ reasoningTokens: usage.outputTokenDetails?.reasoningTokens
19
+ }
20
+ }
21
+
22
+ const SDK_FINISH_REASONS: ReadonlySet<FinishReason> = new Set([
23
+ 'stop',
24
+ 'length',
25
+ 'content-filter',
26
+ 'tool-calls',
27
+ 'error',
28
+ 'other'
29
+ ])
30
+
31
+ const mapFinishReason = (raw: string | undefined): FinishReason => {
32
+ if (raw && (SDK_FINISH_REASONS as ReadonlySet<string>).has(raw)) {
33
+ return raw as FinishReason
34
+ }
35
+ return 'other'
36
+ }
37
+
38
+ /**
39
+ * Adapts the AI SDK's `streamText().fullStream` into the library's
40
+ * provider-agnostic {@link StreamEvent} stream.
41
+ *
42
+ * Three contracts that downstream code relies on:
43
+ *
44
+ * 1. **No throwing.** Errors thrown by the iterator (e.g. mid-stream HTTP
45
+ * failure) are caught and re-emitted as `{ kind: 'error', error }`.
46
+ * The iterator always terminates cleanly, so consumers that pump events
47
+ * over IPC don't need to wrap iteration in try/catch.
48
+ * 2. **Errors are terminal.** When an `error` event from the SDK is observed
49
+ * the iterator returns immediately — any subsequent SDK chunks are dropped.
50
+ * 3. **Unknown SDK part types are silently ignored.** Forward-compat with
51
+ * new AI SDK chunk variants without a library bump.
52
+ *
53
+ * Emits in this order over the lifetime of one completion:
54
+ * `text-delta*` → `usage-update?` → `finish | error`.
55
+ *
56
+ * @param result - The object returned by `streamText()`.
57
+ * Only its `fullStream` is consumed.
58
+ * @param providerId - Provider id used to attribute any wrapped errors.
59
+ */
60
+ export async function* mapAiSdkStream(
61
+ result: Pick<StreamTextResult<ToolSet, never, never>, 'fullStream'>,
62
+ providerId: string
63
+ ): AsyncIterable<StreamEvent> {
64
+ try {
65
+ for await (const part of result.fullStream as AsyncIterable<
66
+ TextStreamPart<ToolSet>
67
+ >) {
68
+ switch (part.type) {
69
+ case 'text-delta':
70
+ if (part.text.length > 0)
71
+ yield { kind: 'text-delta', delta: part.text }
72
+ break
73
+ case 'tool-call':
74
+ yield {
75
+ kind: 'tool-call',
76
+ id: part.toolCallId,
77
+ name: part.toolName,
78
+ input: part.input
79
+ }
80
+ break
81
+ case 'tool-result': {
82
+ const output = (part as unknown as { output?: unknown }).output
83
+ yield {
84
+ kind: 'tool-result',
85
+ id: part.toolCallId,
86
+ name: part.toolName,
87
+ result: output
88
+ }
89
+ break
90
+ }
91
+ case 'finish-step':
92
+ yield { kind: 'usage-update', usage: mapUsage(part.usage) }
93
+ break
94
+ case 'finish':
95
+ yield {
96
+ kind: 'finish',
97
+ finishReason: mapFinishReason(part.finishReason),
98
+ usage: mapUsage(part.totalUsage)
99
+ }
100
+ break
101
+ case 'error':
102
+ yield { kind: 'error', error: mapAiSdkError(part.error, providerId) }
103
+ return
104
+ default:
105
+ break
106
+ }
107
+ }
108
+ } catch (error) {
109
+ yield { kind: 'error', error: mapAiSdkError(error, providerId) }
110
+ }
111
+ }
@@ -0,0 +1,116 @@
1
+ import { AsyncEntry } from '@napi-rs/keyring'
2
+ import { UpstreamError } from './errors.js'
3
+ import { normalizeBaseURL } from './url.js'
4
+
5
+ const DEFAULT_SERVICE = 'inkdrop.ai'
6
+
7
+ export type KeyStoreOptions = {
8
+ /** Keyring service name. Defaults to `inkdrop.ai`. Override in tests. */
9
+ service?: string
10
+ }
11
+
12
+ /**
13
+ * URL-keyed credential storage. The keyring entry's account is the *normalised*
14
+ * baseURL, not the provider id — so two endpoints to the same provider (e.g.
15
+ * staging + prod, or two Ollama hosts) get separate entries.
16
+ *
17
+ * Resolution order in {@link KeyStore.getKey}:
18
+ * 1. The provider's environment variable (if a name is given and the var is non-empty).
19
+ * 2. The system keyring (`@napi-rs/keyring`) under `service` + normalised `baseURL`.
20
+ *
21
+ * The cache is keyed on normalised `baseURL` to avoid repeated keyring round-trips.
22
+ * Cache reads do not check the env var — env-var changes mid-process are not
23
+ * reflected unless {@link KeyStore.invalidate} is called.
24
+ */
25
+ export class KeyStore {
26
+ readonly service: string
27
+ private readonly cache = new Map<string, string>()
28
+
29
+ constructor(options: KeyStoreOptions = {}) {
30
+ this.service = options.service ?? DEFAULT_SERVICE
31
+ }
32
+
33
+ /**
34
+ * Resolves the API key for a given `baseURL`.
35
+ *
36
+ * @param envVarName - Provider's env-var name, or `null` to skip env lookup.
37
+ * @param baseURL - The endpoint URL. Normalised before keying.
38
+ * @returns The key, or `null` if neither env nor keyring has one.
39
+ */
40
+ async getKey(
41
+ envVarName: string | null,
42
+ baseURL: string
43
+ ): Promise<string | null> {
44
+ const account = normalizeBaseURL(baseURL)
45
+
46
+ if (envVarName) {
47
+ const fromEnv = process.env[envVarName]
48
+ if (fromEnv && fromEnv.length > 0) return fromEnv
49
+ }
50
+
51
+ const cached = this.cache.get(account)
52
+ if (cached !== undefined) return cached
53
+
54
+ try {
55
+ const entry = new AsyncEntry(this.service, account)
56
+ const value = await entry.getPassword()
57
+ if (value === undefined || value === null) return null
58
+ this.cache.set(account, value)
59
+ return value
60
+ } catch (cause) {
61
+ throw new UpstreamError(
62
+ '<keystore>',
63
+ `Failed to read credential for ${account} from system keyring`,
64
+ undefined,
65
+ { cause }
66
+ )
67
+ }
68
+ }
69
+
70
+ /** Stores `key` for `baseURL` in the system keyring and the in-memory cache. */
71
+ async setKey(baseURL: string, key: string): Promise<void> {
72
+ const account = normalizeBaseURL(baseURL)
73
+ try {
74
+ const entry = new AsyncEntry(this.service, account)
75
+ await entry.setPassword(key)
76
+ this.cache.set(account, key)
77
+ } catch (cause) {
78
+ throw new UpstreamError(
79
+ '<keystore>',
80
+ `Failed to write credential for ${account} to system keyring`,
81
+ undefined,
82
+ { cause }
83
+ )
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Removes the keyring entry for `baseURL` and drops it from the cache.
89
+ * No-ops if the entry doesn't exist.
90
+ */
91
+ async deleteKey(baseURL: string): Promise<void> {
92
+ const account = normalizeBaseURL(baseURL)
93
+ this.cache.delete(account)
94
+ try {
95
+ const entry = new AsyncEntry(this.service, account)
96
+ await entry.deleteCredential()
97
+ } catch {
98
+ // No entry → nothing to delete. Don't surface a "not found" as an error.
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Drops cached entries.
104
+ *
105
+ * @param baseURL - When provided, only that URL's cached key is dropped;
106
+ * when omitted, the entire cache is cleared. Call this when the user
107
+ * changes a provider's `baseURL` so the next request re-resolves the key.
108
+ */
109
+ invalidate(baseURL?: string): void {
110
+ if (baseURL === undefined) {
111
+ this.cache.clear()
112
+ return
113
+ }
114
+ this.cache.delete(normalizeBaseURL(baseURL))
115
+ }
116
+ }
@@ -0,0 +1,86 @@
1
+ import type {
2
+ CacheConfiguration,
3
+ ModelCapabilities
4
+ } from '@inkdropapp/ai-catalog'
5
+ import type { ModelMessage } from 'ai'
6
+ import type { StreamEvent } from './stream-events.js'
7
+
8
+ /**
9
+ * Inputs for a single one-shot streaming completion.
10
+ *
11
+ * `messages` reuses the AI SDK's `ModelMessage` type so callers can construct
12
+ * messages with the same shape the underlying SDK expects.
13
+ */
14
+ export type CompletionRequest = {
15
+ messages: ModelMessage[]
16
+ temperature?: number
17
+ maxOutputTokens?: number
18
+ /** Forwarded to the underlying SDK call; aborts in-flight HTTP and ends the stream. */
19
+ abortSignal?: AbortSignal
20
+ }
21
+
22
+ /**
23
+ * A single configured model belonging to a provider.
24
+ *
25
+ * `capabilities` are the source of truth for whether a feature (tools, images,
26
+ * thinking) is available — UI gating and request-construction logic should
27
+ * branch on these flags rather than on `provider.id`.
28
+ */
29
+ export interface AIModel {
30
+ /** Stable model id as the provider expects it on the wire. */
31
+ readonly id: string
32
+ /** Human-readable label for menus / picker UIs. */
33
+ readonly displayName: string
34
+ /** Back-reference to the owning provider. */
35
+ readonly provider: AIProvider
36
+ readonly capabilities: ModelCapabilities
37
+ /** Present when the provider supports prompt caching for this model (Anthropic). */
38
+ readonly cacheConfiguration?: CacheConfiguration
39
+
40
+ /**
41
+ * Streams a completion. The returned iterable always terminates — either
42
+ * with a `'finish'` event on success, or with an `'error'` event on failure.
43
+ * Consumers do not need to wrap iteration in try/catch.
44
+ */
45
+ streamCompletion(request: CompletionRequest): AsyncIterable<StreamEvent>
46
+ }
47
+
48
+ /**
49
+ * One AI integration (one auth identity + one base URL) exposing a list of
50
+ * `AIModel` instances. Multiple instances of the same provider class can
51
+ * coexist as long as their `id`s differ — that's how the long-tail
52
+ * OpenAI-compatible providers (OpenRouter, Together, Ollama, …) are modelled.
53
+ */
54
+ export interface AIProvider {
55
+ /** Stable provider id; appears in `AISettings.slots[*].providerId`. */
56
+ readonly id: string
57
+ /** Human-readable label. */
58
+ readonly name: string
59
+ /** Resolved base URL for HTTP calls; doubles as the keyring account key. */
60
+ readonly baseURL: string
61
+ /**
62
+ * Environment variable consulted before the keyring during auth resolution.
63
+ * `null` for providers that cannot have an env-var convention.
64
+ */
65
+ readonly envVarName: string | null
66
+
67
+ listModels(): AIModel[]
68
+ getModel(id: string): AIModel | undefined
69
+
70
+ /** "Best" model for long-form / general chat tasks. */
71
+ defaultModel(): AIModel | undefined
72
+ /** Cheap / low-latency model for autocomplete-style features. */
73
+ defaultFastModel(): AIModel | undefined
74
+
75
+ /**
76
+ * `true` iff a key is currently resolvable via env var or system keyring.
77
+ * Does not perform a network round-trip.
78
+ */
79
+ isAuthenticated(): Promise<boolean>
80
+
81
+ /** Persists `key` to the system keyring under this provider's `baseURL`. */
82
+ setApiKey(key: string): Promise<void>
83
+
84
+ /** Removes the keyring entry for this provider's `baseURL`. */
85
+ clearApiKey(): Promise<void>
86
+ }
@@ -0,0 +1,175 @@
1
+ import {
2
+ createAnthropic,
3
+ type AnthropicProvider as SdkAnthropicProvider
4
+ } from '@ai-sdk/anthropic'
5
+ import {
6
+ ANTHROPIC_DEFAULT_FAST_MODEL_ID,
7
+ ANTHROPIC_DEFAULT_MODEL_ID,
8
+ ANTHROPIC_MODELS,
9
+ DEFAULT_ANTHROPIC_CACHE_CONFIG,
10
+ DEFAULT_ANTHROPIC_CAPABILITIES,
11
+ type AnthropicCatalog,
12
+ type AnthropicModelDescriptor,
13
+ type CacheConfiguration,
14
+ type ModelCapabilities
15
+ } from '@inkdropapp/ai-catalog'
16
+ import { streamText } from 'ai'
17
+ import { NoApiKey } from '../errors.js'
18
+ import type { KeyStore } from '../key-store.js'
19
+ import { mapAiSdkError } from '../internal/map-ai-sdk-error.js'
20
+ import { mapAiSdkStream } from '../internal/map-ai-sdk-stream.js'
21
+ import type { AIModel, AIProvider, CompletionRequest } from '../provider.js'
22
+ import type {
23
+ AnthropicModelConfig,
24
+ AnthropicProviderConfig
25
+ } from '../settings.js'
26
+ import type { StreamEvent } from '../stream-events.js'
27
+
28
+ const PROVIDER_ID = 'anthropic'
29
+ const PROVIDER_NAME = 'Anthropic'
30
+ const ENV_VAR = 'ANTHROPIC_API_KEY'
31
+ const DEFAULT_BASE_URL = 'https://api.anthropic.com'
32
+
33
+ /**
34
+ * The Anthropic (Claude) provider.
35
+ *
36
+ * Wraps `@ai-sdk/anthropic`. Loads its model catalogue from one of:
37
+ * 1. The {@link AnthropicCatalog} passed at construction time (server-distributed).
38
+ * 2. The compiled-in {@link ANTHROPIC_MODELS} list as a fallback.
39
+ *
40
+ * User-declared `config.models` are layered on top, overriding entries with
41
+ * matching ids regardless of source.
42
+ *
43
+ * Lazily constructs the SDK client on first stream and caches it across
44
+ * requests. The cached client is invalidated whenever `setApiKey` /
45
+ * `clearApiKey` is called or the resolved API key changes.
46
+ */
47
+ export class AnthropicProvider implements AIProvider {
48
+ readonly id = PROVIDER_ID
49
+ readonly name = PROVIDER_NAME
50
+ readonly envVarName = ENV_VAR
51
+ readonly baseURL: string
52
+
53
+ private readonly keyStore: KeyStore
54
+ private readonly modelsById: Map<string, AnthropicAIModel>
55
+ private readonly defaultId: string
56
+ private readonly defaultFastId: string
57
+
58
+ private sdkClient: SdkAnthropicProvider | undefined
59
+ private sdkClientApiKey: string | undefined
60
+
61
+ constructor(
62
+ keyStore: KeyStore,
63
+ config: AnthropicProviderConfig = {},
64
+ catalog?: AnthropicCatalog
65
+ ) {
66
+ this.keyStore = keyStore
67
+ this.baseURL = config.baseURL ?? DEFAULT_BASE_URL
68
+
69
+ const baseModels = catalog?.models ?? ANTHROPIC_MODELS
70
+ this.defaultId = catalog?.defaultModelId ?? ANTHROPIC_DEFAULT_MODEL_ID
71
+ this.defaultFastId =
72
+ catalog?.defaultFastModelId ?? ANTHROPIC_DEFAULT_FAST_MODEL_ID
73
+
74
+ const map = new Map<string, AnthropicAIModel>()
75
+ for (const d of baseModels) {
76
+ map.set(d.id, new AnthropicAIModel(this, d))
77
+ }
78
+ for (const m of config.models ?? []) {
79
+ map.set(m.id, new AnthropicAIModel(this, descriptorFromUserConfig(m)))
80
+ }
81
+ this.modelsById = map
82
+ }
83
+
84
+ listModels(): AIModel[] {
85
+ return Array.from(this.modelsById.values())
86
+ }
87
+
88
+ getModel(id: string): AIModel | undefined {
89
+ return this.modelsById.get(id)
90
+ }
91
+
92
+ defaultModel(): AIModel | undefined {
93
+ return this.modelsById.get(this.defaultId)
94
+ }
95
+
96
+ defaultFastModel(): AIModel | undefined {
97
+ return this.modelsById.get(this.defaultFastId)
98
+ }
99
+
100
+ async isAuthenticated(): Promise<boolean> {
101
+ const key = await this.keyStore.getKey(this.envVarName, this.baseURL)
102
+ return key !== null && key.length > 0
103
+ }
104
+
105
+ async setApiKey(key: string): Promise<void> {
106
+ await this.keyStore.setKey(this.baseURL, key)
107
+ this.sdkClient = undefined
108
+ this.sdkClientApiKey = undefined
109
+ }
110
+
111
+ async clearApiKey(): Promise<void> {
112
+ await this.keyStore.deleteKey(this.baseURL)
113
+ this.sdkClient = undefined
114
+ this.sdkClientApiKey = undefined
115
+ }
116
+
117
+ /** Internal — used by AnthropicAIModel to resolve the SDK client lazily. */
118
+ async getSdkClient(): Promise<SdkAnthropicProvider> {
119
+ const apiKey = await this.keyStore.getKey(this.envVarName, this.baseURL)
120
+ if (apiKey === null || apiKey.length === 0) {
121
+ throw new NoApiKey(this.id, this.envVarName)
122
+ }
123
+ if (this.sdkClient && this.sdkClientApiKey === apiKey) return this.sdkClient
124
+ this.sdkClient = createAnthropic({ apiKey, baseURL: this.baseURL })
125
+ this.sdkClientApiKey = apiKey
126
+ return this.sdkClient
127
+ }
128
+ }
129
+
130
+ const descriptorFromUserConfig = (
131
+ m: AnthropicModelConfig
132
+ ): AnthropicModelDescriptor => ({
133
+ id: m.id,
134
+ displayName: m.displayName ?? m.id,
135
+ capabilities: { ...DEFAULT_ANTHROPIC_CAPABILITIES, ...m.capabilities },
136
+ cacheConfiguration: m.cacheConfiguration ?? DEFAULT_ANTHROPIC_CACHE_CONFIG
137
+ })
138
+
139
+ class AnthropicAIModel implements AIModel {
140
+ readonly id: string
141
+ readonly displayName: string
142
+ readonly capabilities: ModelCapabilities
143
+ readonly cacheConfiguration: CacheConfiguration
144
+
145
+ constructor(
146
+ readonly provider: AnthropicProvider,
147
+ descriptor: AnthropicModelDescriptor
148
+ ) {
149
+ this.id = descriptor.id
150
+ this.displayName = descriptor.displayName
151
+ this.capabilities = descriptor.capabilities
152
+ this.cacheConfiguration = descriptor.cacheConfiguration
153
+ }
154
+
155
+ async *streamCompletion(
156
+ request: CompletionRequest
157
+ ): AsyncIterable<StreamEvent> {
158
+ let result
159
+ try {
160
+ const sdk = await this.provider.getSdkClient()
161
+ result = streamText({
162
+ model: sdk(this.id),
163
+ messages: request.messages,
164
+ temperature: request.temperature,
165
+ maxOutputTokens: request.maxOutputTokens,
166
+ abortSignal: request.abortSignal
167
+ })
168
+ } catch (error) {
169
+ yield { kind: 'error', error: mapAiSdkError(error, this.provider.id) }
170
+ return
171
+ }
172
+
173
+ yield* mapAiSdkStream(result, this.provider.id)
174
+ }
175
+ }