@inkdropapp/ai 0.1.3 → 0.1.5

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.
Files changed (33) hide show
  1. package/README.md +110 -6
  2. package/package.json +21 -10
  3. package/src/events.ts +105 -0
  4. package/src/index.ts +22 -5
  5. package/src/provider-constants.ts +31 -0
  6. package/src/provider.ts +13 -1
  7. package/src/providers/anthropic/constants.ts +4 -0
  8. package/src/providers/{anthropic.ts → anthropic/provider.ts} +34 -18
  9. package/src/providers/openai-compatible/constants.ts +32 -0
  10. package/src/providers/{openai-compatible.ts → openai-compatible/provider.ts} +27 -29
  11. package/src/providers/openai-compatible/validator-compiled.d.ts +7 -0
  12. package/src/providers/openai-compatible/validator-compiled.js +4 -0
  13. package/src/providers/openai-compatible/validator.ts +33 -0
  14. package/src/registry.ts +30 -6
  15. package/src/utils/logging-fetch.ts +122 -0
  16. package/src/{internal → utils}/map-ai-sdk-stream.ts +1 -1
  17. package/types/events.d.ts +99 -0
  18. package/types/index.d.ts +5 -4
  19. package/types/provider-constants.d.ts +21 -0
  20. package/types/provider.d.ts +12 -1
  21. package/types/providers/anthropic/constants.d.ts +4 -0
  22. package/types/providers/anthropic/provider.d.ts +42 -0
  23. package/types/providers/openai-compatible/constants.d.ts +25 -0
  24. package/types/providers/openai-compatible/provider.d.ts +47 -0
  25. package/types/providers/openai-compatible/validator-compiled.d.ts +9 -0
  26. package/types/providers/openai-compatible/validator.d.ts +14 -0
  27. package/types/providers/openai-compatible.d.ts +0 -13
  28. package/types/registry.d.ts +12 -1
  29. package/types/utils/logging-fetch.d.ts +13 -0
  30. package/types/utils/map-ai-sdk-error.d.ts +21 -0
  31. package/types/utils/map-ai-sdk-stream.d.ts +25 -0
  32. package/src/stream-events.ts +0 -53
  33. /package/src/{internal → utils}/map-ai-sdk-error.ts +0 -0
@@ -0,0 +1,4 @@
1
+ // AUTO-GENERATED by scripts/build-validators.mjs. Do not edit by hand.
2
+ import __ajv_runtime_0 from "ajv/dist/runtime/ucs2length.js";
3
+ const func2 = __ajv_runtime_0.default ?? __ajv_runtime_0;
4
+ "use strict";export const validate = validate10;export default validate10;const schema11 = {"type":"object","required":["id","baseURL","models"],"additionalProperties":false,"properties":{"id":{"type":"string","minLength":1},"displayName":{"type":"string"},"baseURL":{"type":"string","minLength":1},"models":{"type":"array","minItems":1,"items":{"type":"object","required":["id"],"additionalProperties":false,"properties":{"id":{"type":"string","minLength":1},"displayName":{"type":"string"},"capabilities":{"type":"object","additionalProperties":false,"properties":{"supportsTools":{"type":"boolean"},"supportsImages":{"type":"boolean"},"supportsThinking":{"type":"boolean"},"supportsStreamingTools":{"type":"boolean"},"maxTokens":{"type":"integer","minimum":1}}}}}},"defaultModelId":{"type":"string"},"defaultFastModelId":{"type":"string"}}};function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(data && typeof data == "object" && !Array.isArray(data)){if(data.id === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.baseURL === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "baseURL"},message:"must have required property '"+"baseURL"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.models === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "models"},message:"must have required property '"+"models"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}for(const key0 in data){if(!((((((key0 === "id") || (key0 === "displayName")) || (key0 === "baseURL")) || (key0 === "models")) || (key0 === "defaultModelId")) || (key0 === "defaultFastModelId"))){const err3 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.id !== undefined){let data0 = data.id;if(typeof data0 === "string"){if(func2(data0) < 1){const err4 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {const err5 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.displayName !== undefined){if(typeof data.displayName !== "string"){const err6 = {instancePath:instancePath+"/displayName",schemaPath:"#/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.baseURL !== undefined){let data2 = data.baseURL;if(typeof data2 === "string"){if(func2(data2) < 1){const err7 = {instancePath:instancePath+"/baseURL",schemaPath:"#/properties/baseURL/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}else {const err8 = {instancePath:instancePath+"/baseURL",schemaPath:"#/properties/baseURL/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.models !== undefined){let data3 = data.models;if(Array.isArray(data3)){if(data3.length < 1){const err9 = {instancePath:instancePath+"/models",schemaPath:"#/properties/models/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}const len0 = data3.length;for(let i0=0; i0<len0; i0++){let data4 = data3[i0];if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err10 = {instancePath:instancePath+"/models/" + i0,schemaPath:"#/properties/models/items/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}for(const key1 in data4){if(!(((key1 === "id") || (key1 === "displayName")) || (key1 === "capabilities"))){const err11 = {instancePath:instancePath+"/models/" + i0,schemaPath:"#/properties/models/items/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data4.id !== undefined){let data5 = data4.id;if(typeof data5 === "string"){if(func2(data5) < 1){const err12 = {instancePath:instancePath+"/models/" + i0+"/id",schemaPath:"#/properties/models/items/properties/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {const err13 = {instancePath:instancePath+"/models/" + i0+"/id",schemaPath:"#/properties/models/items/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data4.displayName !== undefined){if(typeof data4.displayName !== "string"){const err14 = {instancePath:instancePath+"/models/" + i0+"/displayName",schemaPath:"#/properties/models/items/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data4.capabilities !== undefined){let data7 = data4.capabilities;if(data7 && typeof data7 == "object" && !Array.isArray(data7)){for(const key2 in data7){if(!(((((key2 === "supportsTools") || (key2 === "supportsImages")) || (key2 === "supportsThinking")) || (key2 === "supportsStreamingTools")) || (key2 === "maxTokens"))){const err15 = {instancePath:instancePath+"/models/" + i0+"/capabilities",schemaPath:"#/properties/models/items/properties/capabilities/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data7.supportsTools !== undefined){if(typeof data7.supportsTools !== "boolean"){const err16 = {instancePath:instancePath+"/models/" + i0+"/capabilities/supportsTools",schemaPath:"#/properties/models/items/properties/capabilities/properties/supportsTools/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data7.supportsImages !== undefined){if(typeof data7.supportsImages !== "boolean"){const err17 = {instancePath:instancePath+"/models/" + i0+"/capabilities/supportsImages",schemaPath:"#/properties/models/items/properties/capabilities/properties/supportsImages/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data7.supportsThinking !== undefined){if(typeof data7.supportsThinking !== "boolean"){const err18 = {instancePath:instancePath+"/models/" + i0+"/capabilities/supportsThinking",schemaPath:"#/properties/models/items/properties/capabilities/properties/supportsThinking/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data7.supportsStreamingTools !== undefined){if(typeof data7.supportsStreamingTools !== "boolean"){const err19 = {instancePath:instancePath+"/models/" + i0+"/capabilities/supportsStreamingTools",schemaPath:"#/properties/models/items/properties/capabilities/properties/supportsStreamingTools/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data7.maxTokens !== undefined){let data12 = data7.maxTokens;if(!(((typeof data12 == "number") && (!(data12 % 1) && !isNaN(data12))) && (isFinite(data12)))){const err20 = {instancePath:instancePath+"/models/" + i0+"/capabilities/maxTokens",schemaPath:"#/properties/models/items/properties/capabilities/properties/maxTokens/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if((typeof data12 == "number") && (isFinite(data12))){if(data12 < 1 || isNaN(data12)){const err21 = {instancePath:instancePath+"/models/" + i0+"/capabilities/maxTokens",schemaPath:"#/properties/models/items/properties/capabilities/properties/maxTokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}}else {const err22 = {instancePath:instancePath+"/models/" + i0+"/capabilities",schemaPath:"#/properties/models/items/properties/capabilities/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}else {const err23 = {instancePath:instancePath+"/models/" + i0,schemaPath:"#/properties/models/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}else {const err24 = {instancePath:instancePath+"/models",schemaPath:"#/properties/models/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.defaultModelId !== undefined){if(typeof data.defaultModelId !== "string"){const err25 = {instancePath:instancePath+"/defaultModelId",schemaPath:"#/properties/defaultModelId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.defaultFastModelId !== undefined){if(typeof data.defaultFastModelId !== "string"){const err26 = {instancePath:instancePath+"/defaultFastModelId",schemaPath:"#/properties/defaultFastModelId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}else {const err27 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}validate10.errors = vErrors;return errors === 0;}
@@ -0,0 +1,33 @@
1
+ import type { ErrorObject } from 'ajv'
2
+ import type { OpenAICompatibleProviderConfig } from '../../settings.js'
3
+ import validate from './validator-compiled.js'
4
+
5
+ const formatErrors = (
6
+ errors: ReadonlyArray<ErrorObject> | null | undefined
7
+ ): string => {
8
+ if (!errors || errors.length === 0) return 'unknown validation error'
9
+ return errors
10
+ .map(e => `config${e.instancePath ?? ''} ${e.message ?? ''}`.trim())
11
+ .join(', ')
12
+ }
13
+
14
+ /**
15
+ * Throws if `config` doesn't conform to the OpenAI-compatible provider schema.
16
+ *
17
+ * The validator is precompiled to a standalone ESM module by
18
+ * `scripts/build-validators.mjs`, so loading this module does not call
19
+ * `new Function(...)` — making it safe to use in Electron renderer processes
20
+ * under a strict CSP without `unsafe-eval`.
21
+ *
22
+ * Catches the kinds of mistakes a settings UI or hand-written config can make:
23
+ * missing required fields, wrong types, unknown keys, empty `models[]`, or an
24
+ * empty string where a non-empty one is required.
25
+ */
26
+ export const validateOpenAICompatibleProviderConfig = (
27
+ config: OpenAICompatibleProviderConfig
28
+ ): void => {
29
+ if (validate(config)) return
30
+ throw new Error(
31
+ `Invalid OpenAI-compatible provider config: ${formatErrors(validate.errors)}`
32
+ )
33
+ }
package/src/registry.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import type { AIModelCatalog } from '@inkdropapp/ai-catalog'
2
+ import type { AIModelApiCallListener, StreamEvent } from './events.js'
2
3
  import { NoApiKey } from './errors.js'
3
4
  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'
5
+ import type {
6
+ AIModel,
7
+ AIModelOptions,
8
+ AIProvider,
9
+ CompletionRequest
10
+ } from './provider.js'
11
+ import { AnthropicProvider } from './providers/anthropic/provider.js'
12
+ import { OpenAICompatibleProvider } from './providers/openai-compatible/provider.js'
7
13
  import type { AISettings, SlotConfig, SlotName } from './settings.js'
8
- import type { StreamEvent } from './stream-events.js'
9
14
 
10
15
  /** Result of resolving a slot to a concrete `(provider, model)` pair. */
11
16
  export type ResolvedSlot = {
@@ -24,6 +29,16 @@ export type AIRegistryOptions = {
24
29
  catalog?: AIModelCatalog
25
30
  /** Pass an existing `KeyStore` to share its in-memory cache across registries. */
26
31
  keyStore?: KeyStore
32
+ /**
33
+ * Invoked once per upstream HTTP request made by any provider in this
34
+ * registry. The hook is best-effort logging only — exceptions thrown from
35
+ * the listener are swallowed so they cannot interrupt the request path.
36
+ *
37
+ * Use this to back a user-facing "API invocation log" view. Streaming
38
+ * response bodies are not captured; only error bodies (status >= 400) are.
39
+ * See {@link AIModelApiCallEvent}.
40
+ */
41
+ apiCallListener?: AIModelApiCallListener
27
42
  }
28
43
 
29
44
  /**
@@ -50,11 +65,13 @@ export class AIRegistry {
50
65
  private providers: Map<string, AIProvider> = new Map()
51
66
  private settings: AISettings
52
67
  private catalog: AIModelCatalog | undefined
68
+ private readonly apiCallListener: AIModelApiCallListener | undefined
53
69
 
54
70
  constructor(options: AIRegistryOptions) {
55
71
  this.keyStore = options.keyStore ?? new KeyStore()
56
72
  this.settings = options.settings
57
73
  this.catalog = options.catalog
74
+ this.apiCallListener = options.apiCallListener
58
75
  this.rebuildProviders()
59
76
  }
60
77
 
@@ -183,18 +200,25 @@ export class AIRegistry {
183
200
  private rebuildProviders(): void {
184
201
  const built: AIProvider[] = []
185
202
 
203
+ const providerOptions: AIModelOptions = {
204
+ apiCallListener: this.apiCallListener
205
+ }
206
+
186
207
  if (this.settings.providers.anthropic) {
187
208
  built.push(
188
209
  new AnthropicProvider(
189
210
  this.keyStore,
190
211
  this.settings.providers.anthropic,
191
- this.catalog?.anthropic
212
+ this.catalog?.anthropic,
213
+ providerOptions
192
214
  )
193
215
  )
194
216
  }
195
217
 
196
218
  for (const config of this.settings.providers.openaiCompatible ?? []) {
197
- built.push(new OpenAICompatibleProvider(this.keyStore, config))
219
+ built.push(
220
+ new OpenAICompatibleProvider(this.keyStore, config, providerOptions)
221
+ )
198
222
  }
199
223
 
200
224
  // Sort once at build time so `Map` iteration (insertion order) is
@@ -0,0 +1,122 @@
1
+ import type { AIModelApiCallEvent, AIModelApiCallListener } from '../events.js'
2
+
3
+ type FetchFn = typeof fetch
4
+ type FetchInput = Parameters<FetchFn>[0]
5
+ type FetchInit = NonNullable<Parameters<FetchFn>[1]>
6
+ type FetchHeaders = FetchInit['headers']
7
+
8
+ const REDACTED_HEADERS = new Set([
9
+ 'authorization',
10
+ 'x-api-key',
11
+ 'anthropic-api-key',
12
+ 'api-key'
13
+ ])
14
+
15
+ const sanitizeHeaders = (
16
+ headers: FetchHeaders | undefined
17
+ ): Record<string, string> => {
18
+ const out: Record<string, string> = {}
19
+ const put = (key: string, value: string) => {
20
+ out[key] = REDACTED_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value
21
+ }
22
+ if (!headers) return out
23
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
24
+ headers.forEach((value, key) => put(key, value))
25
+ } else if (Array.isArray(headers)) {
26
+ for (const entry of headers) put(entry[0], entry[1])
27
+ } else {
28
+ for (const [key, value] of Object.entries(headers)) put(key, String(value))
29
+ }
30
+ return out
31
+ }
32
+
33
+ const parseBody = (body: unknown): unknown => {
34
+ if (body == null) return undefined
35
+ if (typeof body !== 'string') return '[non-string body]'
36
+ try {
37
+ return JSON.parse(body)
38
+ } catch {
39
+ return body
40
+ }
41
+ }
42
+
43
+ const extractUrl = (input: FetchInput): string => {
44
+ if (typeof input === 'string') return input
45
+ if (input instanceof URL) return input.toString()
46
+ return input.url
47
+ }
48
+
49
+ const extractMethod = (
50
+ input: FetchInput,
51
+ init: FetchInit | undefined
52
+ ): string => {
53
+ if (init?.method) return init.method
54
+ if (typeof input !== 'string' && !(input instanceof URL)) return input.method
55
+ return 'GET'
56
+ }
57
+
58
+ const safeNotify = (
59
+ listener: AIModelApiCallListener,
60
+ event: AIModelApiCallEvent
61
+ ): void => {
62
+ try {
63
+ listener(event)
64
+ } catch {
65
+ // Swallow — a misbehaving listener must not break the fetch path.
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Wraps an underlying `fetch` to emit an {@link AIModelApiCallEvent} per HTTP request.
71
+ *
72
+ * Emits exactly once per call. On HTTP error (`!response.ok`) the response is
73
+ * `clone()`'d and the body is read as text into `event.errorBody`; the
74
+ * original response is returned untouched so the SDK still consumes the
75
+ * stream normally. Successful streaming responses are *not* tee'd — only the
76
+ * status is recorded.
77
+ */
78
+ export const createLoggingFetch = (
79
+ providerId: string,
80
+ listener: AIModelApiCallListener,
81
+ inner: FetchFn = fetch
82
+ ): FetchFn => {
83
+ return async (input, init) => {
84
+ const startedAt = Date.now()
85
+ const url = extractUrl(input)
86
+ const method = extractMethod(input, init)
87
+ const headers = sanitizeHeaders(init?.headers)
88
+ const body = parseBody(init?.body)
89
+
90
+ const buildEvent = (extras: {
91
+ status: number | undefined
92
+ errorBody?: string
93
+ failure?: string
94
+ }): AIModelApiCallEvent => ({
95
+ providerId,
96
+ startedAt,
97
+ durationMs: Date.now() - startedAt,
98
+ request: { method, url, headers, body },
99
+ ...extras
100
+ })
101
+
102
+ try {
103
+ const response = await inner(input, init)
104
+ if (!response.ok) {
105
+ let errorBody: string
106
+ try {
107
+ errorBody = await response.clone().text()
108
+ } catch {
109
+ errorBody = '[could not read error body]'
110
+ }
111
+ safeNotify(listener, buildEvent({ status: response.status, errorBody }))
112
+ } else {
113
+ safeNotify(listener, buildEvent({ status: response.status }))
114
+ }
115
+ return response
116
+ } catch (err) {
117
+ const failure = err instanceof Error ? err.message : String(err)
118
+ safeNotify(listener, buildEvent({ status: undefined, failure }))
119
+ throw err
120
+ }
121
+ }
122
+ }
@@ -4,7 +4,7 @@ import type {
4
4
  TextStreamPart,
5
5
  ToolSet
6
6
  } from 'ai'
7
- import type { FinishReason, StreamEvent, Usage } from '../stream-events.js'
7
+ import type { FinishReason, StreamEvent, Usage } from '../events.js'
8
8
  import { mapAiSdkError } from './map-ai-sdk-error.js'
9
9
 
10
10
  const mapUsage = (usage: LanguageModelUsage | undefined): Usage => {
@@ -0,0 +1,99 @@
1
+ import type { AIError } from './errors.js';
2
+ /**
3
+ * Why a completion stream stopped producing output.
4
+ *
5
+ * Mirrors the AI SDK's `FinishReason`. Unknown / provider-specific values are
6
+ * normalised to `'other'` rather than silently passed through.
7
+ */
8
+ export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other';
9
+ /**
10
+ * Token usage for a single completion. Every field is optional because
11
+ * not every provider reports every counter.
12
+ */
13
+ export type Usage = {
14
+ /** Tokens consumed from the prompt. */
15
+ inputTokens?: number;
16
+ /** Tokens generated in the response. */
17
+ outputTokens?: number;
18
+ /** `inputTokens + outputTokens` as reported by the provider. */
19
+ totalTokens?: number;
20
+ /** Of `inputTokens`, how many were a cache hit (Anthropic prompt-cache reads). */
21
+ cacheReadInputTokens?: number;
22
+ /** Of `inputTokens`, how many were written to the prompt cache for future reads. */
23
+ cacheCreationInputTokens?: number;
24
+ /** Subset of `outputTokens` spent on reasoning / thinking content. */
25
+ reasoningTokens?: number;
26
+ };
27
+ /**
28
+ * Provider-agnostic streaming events. Every concrete provider's stream is
29
+ * reduced to this discriminated union so consumers never branch on provider id.
30
+ *
31
+ * Errors arrive as `{ kind: 'error', ... }` events, not as thrown exceptions —
32
+ * this lets the host pump events across an Electron IPC boundary one at a time
33
+ * without needing try/catch around the iterator.
34
+ *
35
+ * `tool-call` and `tool-result` are typed but never produced in the current
36
+ * version (one-shot text completions only). They're reserved for forward compat.
37
+ */
38
+ export type StreamEvent = {
39
+ kind: 'text-delta';
40
+ delta: string;
41
+ } | {
42
+ kind: 'tool-call';
43
+ id: string;
44
+ name: string;
45
+ input: unknown;
46
+ } | {
47
+ kind: 'tool-result';
48
+ id: string;
49
+ name: string;
50
+ result: unknown;
51
+ } | {
52
+ kind: 'usage-update';
53
+ usage: Usage;
54
+ } | {
55
+ kind: 'finish';
56
+ finishReason: FinishReason;
57
+ usage: Usage;
58
+ } | {
59
+ kind: 'error';
60
+ error: AIError;
61
+ };
62
+ /**
63
+ * Wire-level record of one HTTP request the library made to an upstream
64
+ * provider API. Emitted via {@link AIRegistryOptions.apiCallListener} so the host
65
+ * can persist or surface an invocation log to end users.
66
+ *
67
+ * The listener is invoked exactly once per HTTP request — after the response
68
+ * headers arrive (or after the fetch itself fails). Streaming response bodies
69
+ * are *not* tee'd; only error bodies (HTTP status >= 400) are captured.
70
+ *
71
+ * API keys are redacted from `request.headers` before the event is emitted.
72
+ */
73
+ export type AIModelApiCallEvent = {
74
+ /** Provider id that owns the SDK client which made the call. */
75
+ providerId: string;
76
+ /** Epoch ms at which the fetch was issued. */
77
+ startedAt: number;
78
+ /**
79
+ * Wall-clock time from fetch start to response headers (or to the fetch
80
+ * promise settling on failure). For streaming responses this is
81
+ * time-to-first-byte, not total stream duration.
82
+ */
83
+ durationMs: number;
84
+ request: {
85
+ method: string;
86
+ url: string;
87
+ /** Outgoing headers; `authorization` / `x-api-key` / `anthropic-api-key` are redacted. */
88
+ headers: Record<string, string>;
89
+ /** Parsed JSON when the body was a JSON string; the raw string otherwise. */
90
+ body: unknown;
91
+ };
92
+ /** HTTP status, or `undefined` if the fetch itself failed before a response. */
93
+ status: number | undefined;
94
+ /** Response body — populated only when `status >= 400`. */
95
+ errorBody?: string;
96
+ /** Failure reason — populated only when fetch itself threw (network error, abort, …). */
97
+ failure?: string;
98
+ };
99
+ export type AIModelApiCallListener = (event: AIModelApiCallEvent) => void;
package/types/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- export type { AIProvider, AIModel, CompletionRequest } from './provider.js';
2
- export type { StreamEvent, Usage, FinishReason } from './stream-events.js';
1
+ export type { AIProvider, AIModel, AIModelOptions, CompletionRequest } from './provider.js';
2
+ export type { StreamEvent, Usage, FinishReason, AIModelApiCallEvent, AIModelApiCallListener } from './events.js';
3
3
  export type { AISettings, SlotName, SlotConfig, CommonProviderConfig, AnthropicProviderConfig, AnthropicModelConfig, OpenAICompatibleProviderConfig, OpenAICompatibleModelConfig } from './settings.js';
4
4
  export { ANTHROPIC_MODELS, ANTHROPIC_DEFAULT_MODEL_ID, ANTHROPIC_DEFAULT_FAST_MODEL_ID, DEFAULT_ANTHROPIC_CACHE_CONFIG, DEFAULT_ANTHROPIC_CAPABILITIES, BUILT_IN_CATALOG, type AIModelCatalog, type AnthropicCatalog, type AnthropicModelDescriptor, type CacheConfiguration, type ModelCapabilities } from '@inkdropapp/ai-catalog';
5
5
  export { AIError, NoApiKey, AuthenticationError, RateLimitExceeded, ServerOverloaded, PromptTooLarge, UpstreamError, isAIError, type AIErrorKind } from './errors.js';
6
6
  export { KeyStore, type KeyStoreOptions } from './key-store.js';
7
7
  export { normalizeBaseURL } from './url.js';
8
- export { AnthropicProvider } from './providers/anthropic.js';
9
- export { OpenAICompatibleProvider, deriveEnvVarName } from './providers/openai-compatible.js';
8
+ export { ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ANTHROPIC_ENV_VAR, ANTHROPIC_DEFAULT_BASE_URL, OPENAI_COMPATIBLE_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_NAME, deriveEnvVarName } from './provider-constants.js';
9
+ export { AnthropicProvider } from './providers/anthropic/provider.js';
10
+ export { OpenAICompatibleProvider } from './providers/openai-compatible/provider.js';
10
11
  export { AIRegistry, type AIRegistryOptions, type ResolvedSlot } from './registry.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pure-data provider metadata for settings UIs.
3
+ *
4
+ * This module has no class or native-module imports, so it's safe to load in
5
+ * an Electron renderer process — unlike the main package entrypoint, which
6
+ * transitively pulls in `@napi-rs/keyring`.
7
+ *
8
+ * Consume via the `@inkdropapp/ai/constants` subpath:
9
+ *
10
+ * import {
11
+ * ANTHROPIC_PROVIDER_ID,
12
+ * ANTHROPIC_PROVIDER_NAME,
13
+ * ANTHROPIC_ENV_VAR,
14
+ * ANTHROPIC_DEFAULT_BASE_URL,
15
+ * OPENAI_COMPATIBLE_PROVIDER_ID,
16
+ * OPENAI_COMPATIBLE_PROVIDER_NAME,
17
+ * deriveEnvVarName
18
+ * } from '@inkdropapp/ai/constants'
19
+ */
20
+ export { ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ANTHROPIC_ENV_VAR, ANTHROPIC_DEFAULT_BASE_URL } from './providers/anthropic/constants.js';
21
+ export { OPENAI_COMPATIBLE_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_NAME, deriveEnvVarName } from './providers/openai-compatible/constants.js';
@@ -1,6 +1,17 @@
1
1
  import type { CacheConfiguration, ModelCapabilities } from '@inkdropapp/ai-catalog';
2
2
  import type { ModelMessage } from 'ai';
3
- import type { StreamEvent } from './stream-events.js';
3
+ import type { AIModelApiCallListener, StreamEvent } from './events.js';
4
+ /**
5
+ * Provider-construction options that apply to every model the provider hosts.
6
+ * Threaded by `AIRegistry` into each provider's constructor.
7
+ */
8
+ export type AIModelOptions = {
9
+ /**
10
+ * Invoked once per upstream HTTP request made by any model on this provider.
11
+ * See {@link AIModelApiCallListener}.
12
+ */
13
+ apiCallListener?: AIModelApiCallListener;
14
+ };
4
15
  /**
5
16
  * Inputs for a single one-shot streaming completion.
6
17
  *
@@ -0,0 +1,4 @@
1
+ export declare const ANTHROPIC_PROVIDER_ID = "anthropic";
2
+ export declare const ANTHROPIC_PROVIDER_NAME = "Anthropic";
3
+ export declare const ANTHROPIC_ENV_VAR = "ANTHROPIC_API_KEY";
4
+ export declare const ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com";
@@ -0,0 +1,42 @@
1
+ import { type AnthropicProvider as SdkAnthropicProvider } from '@ai-sdk/anthropic';
2
+ import { type AnthropicCatalog } from '@inkdropapp/ai-catalog';
3
+ import type { KeyStore } from '../../key-store.js';
4
+ import type { AIModel, AIModelOptions, AIProvider } from '../../provider.js';
5
+ import type { AnthropicProviderConfig } from '../../settings.js';
6
+ /**
7
+ * The Anthropic (Claude) provider.
8
+ *
9
+ * Wraps `@ai-sdk/anthropic`. Loads its model catalogue from one of:
10
+ * 1. The {@link AnthropicCatalog} passed at construction time (server-distributed).
11
+ * 2. The compiled-in {@link ANTHROPIC_MODELS} list as a fallback.
12
+ *
13
+ * User-declared `config.models` are layered on top, overriding entries with
14
+ * matching ids regardless of source.
15
+ *
16
+ * Lazily constructs the SDK client on first stream and caches it across
17
+ * requests. The cached client is invalidated whenever `setApiKey` /
18
+ * `clearApiKey` is called or the resolved API key changes.
19
+ */
20
+ export declare class AnthropicProvider implements AIProvider {
21
+ readonly id = "anthropic";
22
+ readonly name = "Anthropic";
23
+ readonly envVarName = "ANTHROPIC_API_KEY";
24
+ readonly baseURL: string;
25
+ private readonly keyStore;
26
+ private readonly modelsById;
27
+ private readonly defaultId;
28
+ private readonly defaultFastId;
29
+ private readonly apiCallListener;
30
+ private sdkClient;
31
+ private sdkClientApiKey;
32
+ constructor(keyStore: KeyStore, config?: AnthropicProviderConfig, catalog?: AnthropicCatalog, options?: AIModelOptions);
33
+ listModels(): AIModel[];
34
+ getModel(id: string): AIModel | undefined;
35
+ defaultModel(): AIModel | undefined;
36
+ defaultFastModel(): AIModel | undefined;
37
+ isAuthenticated(): Promise<boolean>;
38
+ setApiKey(key: string): Promise<void>;
39
+ clearApiKey(): Promise<void>;
40
+ /** Internal — used by AnthropicAIModel to resolve the SDK client lazily. */
41
+ getSdkClient(): Promise<SdkAnthropicProvider>;
42
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The `AISettings.providers` key for OpenAI-compatible entries. Use this to
3
+ * tag a settings-UI row as "an OpenAI-compatible provider" (vs. the built-in
4
+ * Anthropic row).
5
+ *
6
+ * Runtime provider ids for OpenAI-compatible entries are user-chosen
7
+ * (`AISettings.providers.openaiCompatible[].id`); this constant is the
8
+ * *type/kind*, not an instance id.
9
+ */
10
+ export declare const OPENAI_COMPATIBLE_PROVIDER_ID = "openaiCompatible";
11
+ export declare const OPENAI_COMPATIBLE_PROVIDER_NAME = "OpenAI-Compatible";
12
+ /**
13
+ * Derives the env-var name for an OpenAI-compatible provider entry from its
14
+ * user-chosen `id`.
15
+ *
16
+ * Rules: replace any non-alphanumeric character with `_`, collapse runs of `_`,
17
+ * uppercase, append `_API_KEY`.
18
+ *
19
+ * Examples:
20
+ * `openrouter` → `OPENROUTER_API_KEY`
21
+ * `together_ai` → `TOGETHER_AI_API_KEY`
22
+ * `ollama-local` → `OLLAMA_LOCAL_API_KEY`
23
+ * `my.proxy` → `MY_PROXY_API_KEY`
24
+ */
25
+ export declare const deriveEnvVarName: (id: string) => string;
@@ -0,0 +1,47 @@
1
+ import { type OpenAICompatibleProvider as SdkOpenAICompatibleProvider } from '@ai-sdk/openai-compatible';
2
+ import type { KeyStore } from '../../key-store.js';
3
+ import type { AIModel, AIModelOptions, AIProvider } from '../../provider.js';
4
+ import type { OpenAICompatibleProviderConfig } from '../../settings.js';
5
+ /**
6
+ * Provider for any OpenAI-compatible chat-completions endpoint
7
+ * (OpenRouter, Together, Fireworks, Groq, vLLM, Ollama via `/v1`, LiteLLM, …).
8
+ *
9
+ * Each user-named entry in `AISettings.providers.openaiCompatible[]`
10
+ * becomes one instance. The env-var name is derived from the entry's `id`
11
+ * via {@link deriveEnvVarName}; the keyring account is the resolved `baseURL`.
12
+ *
13
+ * Capability flags are user-declared per model (with sensible defaults of
14
+ * `tools: true`, `images: false`, `thinking: false`); the host UI keys off
15
+ * those flags exactly the same as for Anthropic.
16
+ *
17
+ * Some endpoints (Ollama, vLLM, …) require no API key. The provider
18
+ * accommodates this by passing `apiKey: undefined` to the SDK rather than
19
+ * proactively throwing `NoApiKey` — auth failures only surface if the
20
+ * upstream actually returns 401.
21
+ */
22
+ export declare class OpenAICompatibleProvider implements AIProvider {
23
+ readonly id: string;
24
+ readonly name: string;
25
+ readonly baseURL: string;
26
+ readonly envVarName: string;
27
+ private readonly config;
28
+ private readonly keyStore;
29
+ private readonly modelsById;
30
+ private readonly apiCallListener;
31
+ private sdkClient;
32
+ private sdkClientApiKey;
33
+ constructor(keyStore: KeyStore, config: OpenAICompatibleProviderConfig, options?: AIModelOptions);
34
+ listModels(): AIModel[];
35
+ getModel(id: string): AIModel | undefined;
36
+ defaultModel(): AIModel | undefined;
37
+ defaultFastModel(): AIModel | undefined;
38
+ isAuthenticated(): Promise<boolean>;
39
+ setApiKey(key: string): Promise<void>;
40
+ clearApiKey(): Promise<void>;
41
+ /** Internal — used by OpenAICompatibleAIModel to resolve the SDK client lazily. */
42
+ getSdkClient(): Promise<SdkOpenAICompatibleProvider>;
43
+ /** Internal — exposed for tests / 401 handling: did the user configure an explicit auth value? */
44
+ hasConfiguredKey(): Promise<boolean>;
45
+ /** Internal — used by the model when upstream returns 401: surface a NoApiKey if the user never configured one. */
46
+ ensureAuthenticatedOrThrow(): Promise<void>;
47
+ }
@@ -0,0 +1,9 @@
1
+ export function validate(data: any, { instancePath, parentData, parentDataProperty, rootData }?: {
2
+ instancePath?: string | undefined;
3
+ rootData?: any;
4
+ }): boolean;
5
+ export default validate10;
6
+ declare function validate10(data: any, { instancePath, parentData, parentDataProperty, rootData }?: {
7
+ instancePath?: string | undefined;
8
+ rootData?: any;
9
+ }): boolean;
@@ -0,0 +1,14 @@
1
+ import type { OpenAICompatibleProviderConfig } from '../../settings.js';
2
+ /**
3
+ * Throws if `config` doesn't conform to the OpenAI-compatible provider schema.
4
+ *
5
+ * The validator is precompiled to a standalone ESM module by
6
+ * `scripts/build-validators.mjs`, so loading this module does not call
7
+ * `new Function(...)` — making it safe to use in Electron renderer processes
8
+ * under a strict CSP without `unsafe-eval`.
9
+ *
10
+ * Catches the kinds of mistakes a settings UI or hand-written config can make:
11
+ * missing required fields, wrong types, unknown keys, empty `models[]`, or an
12
+ * empty string where a non-empty one is required.
13
+ */
14
+ export declare const validateOpenAICompatibleProviderConfig: (config: OpenAICompatibleProviderConfig) => void;
@@ -2,19 +2,6 @@ import { type OpenAICompatibleProvider as SdkOpenAICompatibleProvider } from '@a
2
2
  import type { KeyStore } from '../key-store.js';
3
3
  import type { AIModel, AIProvider } from '../provider.js';
4
4
  import type { OpenAICompatibleProviderConfig } from '../settings.js';
5
- /**
6
- * Derives the env-var name for a user-named provider entry.
7
- *
8
- * Rules: replace any non-alphanumeric character with `_`, collapse runs of `_`,
9
- * uppercase, append `_API_KEY`.
10
- *
11
- * Examples:
12
- * `openrouter` → `OPENROUTER_API_KEY`
13
- * `together_ai` → `TOGETHER_AI_API_KEY`
14
- * `ollama-local` → `OLLAMA_LOCAL_API_KEY`
15
- * `my.proxy` → `MY_PROXY_API_KEY`
16
- */
17
- export declare const deriveEnvVarName: (id: string) => string;
18
5
  /**
19
6
  * Provider for any OpenAI-compatible chat-completions endpoint
20
7
  * (OpenRouter, Together, Fireworks, Groq, vLLM, Ollama via `/v1`, LiteLLM, …).
@@ -1,8 +1,8 @@
1
1
  import type { AIModelCatalog } from '@inkdropapp/ai-catalog';
2
+ import type { AIModelApiCallListener, StreamEvent } from './events.js';
2
3
  import { KeyStore } from './key-store.js';
3
4
  import type { AIModel, AIProvider, CompletionRequest } from './provider.js';
4
5
  import type { AISettings, SlotName } from './settings.js';
5
- import type { StreamEvent } from './stream-events.js';
6
6
  /** Result of resolving a slot to a concrete `(provider, model)` pair. */
7
7
  export type ResolvedSlot = {
8
8
  provider: AIProvider;
@@ -19,6 +19,16 @@ export type AIRegistryOptions = {
19
19
  catalog?: AIModelCatalog;
20
20
  /** Pass an existing `KeyStore` to share its in-memory cache across registries. */
21
21
  keyStore?: KeyStore;
22
+ /**
23
+ * Invoked once per upstream HTTP request made by any provider in this
24
+ * registry. The hook is best-effort logging only — exceptions thrown from
25
+ * the listener are swallowed so they cannot interrupt the request path.
26
+ *
27
+ * Use this to back a user-facing "API invocation log" view. Streaming
28
+ * response bodies are not captured; only error bodies (status >= 400) are.
29
+ * See {@link AIModelApiCallEvent}.
30
+ */
31
+ apiCallListener?: AIModelApiCallListener;
22
32
  };
23
33
  /**
24
34
  * Top-level entrypoint to the library.
@@ -44,6 +54,7 @@ export declare class AIRegistry {
44
54
  private providers;
45
55
  private settings;
46
56
  private catalog;
57
+ private readonly apiCallListener;
47
58
  constructor(options: AIRegistryOptions);
48
59
  /** All configured providers, sorted alphabetically by id. */
49
60
  listProviders(): AIProvider[];
@@ -0,0 +1,13 @@
1
+ import type { AIModelApiCallListener } from '../events.js';
2
+ type FetchFn = typeof fetch;
3
+ /**
4
+ * Wraps an underlying `fetch` to emit an {@link AIModelApiCallEvent} per HTTP request.
5
+ *
6
+ * Emits exactly once per call. On HTTP error (`!response.ok`) the response is
7
+ * `clone()`'d and the body is read as text into `event.errorBody`; the
8
+ * original response is returned untouched so the SDK still consumes the
9
+ * stream normally. Successful streaming responses are *not* tee'd — only the
10
+ * status is recorded.
11
+ */
12
+ export declare const createLoggingFetch: (providerId: string, listener: AIModelApiCallListener, inner?: FetchFn) => FetchFn;
13
+ export {};