@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,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
|
+
}
|
package/src/key-store.ts
ADDED
|
@@ -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
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|