@inkdropapp/ai 0.1.4 → 0.1.6

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 +65 -6
  2. package/package.json +26 -14
  3. package/src/constants.ts +43 -0
  4. package/src/events.ts +105 -0
  5. package/src/index.ts +19 -6
  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} +28 -13
  9. package/src/{provider-constants.ts → providers/openai-compatible/constants.ts} +0 -25
  10. package/src/providers/{openai-compatible.ts → openai-compatible/provider.ts} +27 -10
  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/constants.d.ts +30 -0
  18. package/types/events.d.ts +99 -0
  19. package/types/index.d.ts +5 -5
  20. package/types/provider-constants.d.ts +2 -29
  21. package/types/provider.d.ts +12 -1
  22. package/types/providers/anthropic/constants.d.ts +4 -0
  23. package/types/providers/anthropic/provider.d.ts +42 -0
  24. package/types/providers/openai-compatible/constants.d.ts +25 -0
  25. package/types/providers/openai-compatible/provider.d.ts +47 -0
  26. package/types/providers/openai-compatible/validator-compiled.d.ts +9 -0
  27. package/types/providers/openai-compatible/validator.d.ts +14 -0
  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
package/README.md CHANGED
@@ -12,6 +12,7 @@ in the Inkdrop Electron main process.
12
12
  - **URL-keyed credential storage** via `@napi-rs/keyring`. Two endpoints to the same provider get separate keyring entries automatically.
13
13
  - **Single streaming event type** — every provider's stream is reduced to a `StreamEvent` discriminated union.
14
14
  - **Retry-aware error type** — `RateLimitExceeded` / `ServerOverloaded` carry `retryAfter` (seconds) parsed from the response header.
15
+ - **Per-call observability** — opt-in `apiCallListener` receives a structured event per upstream HTTP request (URL, sanitized headers, body, status, duration). Designed to back a user-facing API invocation log.
15
16
  - **No tools, no agents, no UI, no persistence in v0.1** — see [Out of scope](#out-of-scope-deferred).
16
17
 
17
18
  ---
@@ -100,9 +101,9 @@ Inkdrop's AI features map onto two slots:
100
101
 
101
102
  A slot resolves to `(provider, model)` via:
102
103
 
103
- 1. Explicit binding from `settings.slots[slot]`, if both the provider id and model id resolve. *(Advanced — pin a specific model per slot or mix providers across slots.)*
104
+ 1. Explicit binding from `settings.slots[slot]`, if both the provider id and model id resolve. _(Advanced — pin a specific model per slot or mix providers across slots.)_
104
105
  2. For `'fast'` only: fall back to `settings.slots.default` if that resolves.
105
- 3. Preferred provider from `settings.providerId`, if configured — takes its `defaultModel()` (for `default`) or `defaultFastModel()` (for `fast`). *(The simple UI knob — one provider dropdown drives every slot.)*
106
+ 3. Preferred provider from `settings.providerId`, if configured — takes its `defaultModel()` (for `default`) or `defaultFastModel()` (for `fast`). _(The simple UI knob — one provider dropdown drives every slot.)_
106
107
  4. First **authenticated** registered provider in alphabetical id order, taking its `defaultModel()` / `defaultFastModel()`.
107
108
  5. `undefined` if no provider can satisfy the slot.
108
109
 
@@ -232,6 +233,61 @@ Three contracts you can rely on:
232
233
  The library is currently text-only — `tool-call` / `tool-result` variants are
233
234
  typed for forward compat but never produced.
234
235
 
236
+ ### API call logging
237
+
238
+ Pass `apiCallListener` to `AIRegistry` to receive one event per upstream HTTP
239
+ request. Useful for backing a user-facing "API invocation log" view, or for
240
+ ad-hoc debugging in development.
241
+
242
+ ```ts
243
+ import { AIRegistry, type AIModelApiCallEvent } from '@inkdropapp/ai'
244
+
245
+ const registry = new AIRegistry({
246
+ settings,
247
+ apiCallListener: (event: AIModelApiCallEvent) => {
248
+ const status = event.status ?? `failed: ${event.failure}`
249
+ console.log(
250
+ `[${event.providerId}] ${event.request.method} ${event.request.url}`,
251
+ `→ ${status} (${event.durationMs}ms)`
252
+ )
253
+ if (event.errorBody) console.error(event.errorBody)
254
+ }
255
+ })
256
+ ```
257
+
258
+ The listener fires exactly once per HTTP request, after response headers
259
+ arrive (or after the fetch itself fails). For streaming responses, `durationMs`
260
+ is time-to-first-byte — the response stream is _not_ tee'd. Only error bodies
261
+ (`status >= 400`) are captured. API keys are redacted from `request.headers`
262
+ before the event is emitted.
263
+
264
+ The hook is best-effort logging only: exceptions thrown from the listener are
265
+ swallowed so they cannot interrupt the request path.
266
+
267
+ Event shape:
268
+
269
+ ```ts
270
+ type AIModelApiCallEvent = {
271
+ providerId: string
272
+ startedAt: number // epoch ms
273
+ durationMs: number // headers received (TTFB for streams)
274
+ request: {
275
+ method: string
276
+ url: string
277
+ headers: Record<string, string> // authorization / x-api-key redacted
278
+ body: unknown // parsed JSON when possible, else string
279
+ }
280
+ status: number | undefined // undefined when fetch itself threw
281
+ errorBody?: string // populated only when status >= 400
282
+ failure?: string // populated only when fetch itself threw
283
+ }
284
+ ```
285
+
286
+ **Gap to be aware of:** mid-stream errors (e.g. Anthropic's `event: error`
287
+ frames inside a `200 OK` SSE body) are surfaced via the `StreamEvent` union
288
+ (`{ kind: 'error' }`), _not_ through the API call listener. The listener only
289
+ sees what the HTTP layer sees.
290
+
235
291
  ### Errors
236
292
 
237
293
  `AIError` is the base class. Variants:
@@ -274,8 +330,9 @@ hosts that want to predict the name (e.g. for an onboarding hint).
274
330
  ```ts
275
331
  new AIRegistry({
276
332
  settings: AISettings,
277
- catalog?: AIModelCatalog, // server-distributed model lists; see "Model catalogues"
278
- keyStore?: KeyStore
333
+ catalog?: AIModelCatalog, // server-distributed model lists; see "Model catalogues"
334
+ keyStore?: KeyStore,
335
+ apiCallListener?: AIModelApiCallListener // wire-level invocation log; see "API call logging"
279
336
  })
280
337
 
281
338
  registry.listProviders(): AIProvider[]
@@ -503,9 +560,11 @@ for (const { provider, model } of registry.listAllModels()) {
503
560
 
504
561
  ```sh
505
562
  npm install
506
- npm test # runs all 82 tests against the real system keyring
563
+ npm test # runs the full test suite against the real system keyring
507
564
  npm run lint
508
- npm run gen # emits .d.ts files into types/
565
+ npm run build # regenerates standalone validators, then emits .d.ts into types/
566
+ npm run build:validators # regenerates standalone validators only (run after a schema change)
567
+ npm run build:types # emits .d.ts files into types/
509
568
  ```
510
569
 
511
570
  Tests use a unique per-run keyring service name (`inkdrop.ai.test.<hex>`) so they
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkdropapp/ai",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "AI integration common module",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,24 +11,27 @@
11
11
  "default": "./src/index.ts"
12
12
  },
13
13
  "./constants": {
14
- "types": "./types/provider-constants.d.ts",
15
- "default": "./src/provider-constants.ts"
14
+ "types": "./types/constants.d.ts",
15
+ "default": "./src/constants.ts"
16
16
  }
17
17
  },
18
- "scripts": {
19
- "lint": "eslint src __tests__",
20
- "test": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/jest/bin/jest.js --config jest.config.cjs --runInBand",
21
- "build": "tsc --declaration --emitDeclarationOnly",
22
- "format": "prettier --write .",
23
- "prepublishOnly": "npm-run-all lint test build"
24
- },
25
18
  "keywords": [
26
19
  "ai",
27
20
  "inkdrop"
28
21
  ],
29
- "author": "Takuya Matsuyama <t@inkdrop.app>",
22
+ "author": {
23
+ "name": "Takuya Matsuyama",
24
+ "email": "t@inkdrop.app"
25
+ },
26
+ "homepage": "https://github.com/inkdropapp/ai#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/inkdropapp/ai.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/inkdropapp/ai/issues"
33
+ },
30
34
  "license": "UNLICENSED",
31
- "packageManager": "pnpm@11.1.1",
32
35
  "dependencies": {
33
36
  "@ai-sdk/anthropic": "4.0.0-beta.42",
34
37
  "@ai-sdk/openai-compatible": "3.0.0-beta.36",
@@ -36,6 +39,7 @@
36
39
  "@inkdropapp/ai-catalog": "^0.1.0",
37
40
  "@napi-rs/keyring": "^1.2.0",
38
41
  "ai": "7.0.0-beta.116",
42
+ "ajv": "^8.20.0",
39
43
  "npm-run-all2": "^8.0.4"
40
44
  },
41
45
  "devDependencies": {
@@ -52,5 +56,13 @@
52
56
  "files": [
53
57
  "src",
54
58
  "types"
55
- ]
56
- }
59
+ ],
60
+ "scripts": {
61
+ "lint": "eslint src __tests__",
62
+ "test": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/jest/bin/jest.js --config jest.config.cjs --runInBand",
63
+ "build:validators": "node scripts/build-validators.mjs",
64
+ "build:types": "tsc --declaration --emitDeclarationOnly",
65
+ "build": "npm-run-all build:validators build:types",
66
+ "format": "prettier --write ."
67
+ }
68
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pure-data constants 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
+ * SLOT_DEFAULT,
19
+ * SLOT_FAST
20
+ * } from '@inkdropapp/ai/constants'
21
+ */
22
+
23
+ import type { SlotName } from './settings.js'
24
+
25
+ export {
26
+ ANTHROPIC_PROVIDER_ID,
27
+ ANTHROPIC_PROVIDER_NAME,
28
+ ANTHROPIC_ENV_VAR,
29
+ ANTHROPIC_DEFAULT_BASE_URL
30
+ } from './providers/anthropic/constants.js'
31
+ export {
32
+ OPENAI_COMPATIBLE_PROVIDER_ID,
33
+ OPENAI_COMPATIBLE_PROVIDER_NAME,
34
+ deriveEnvVarName
35
+ } from './providers/openai-compatible/constants.js'
36
+
37
+ /**
38
+ * String literals for the two task slots exposed by {@link SlotName}.
39
+ * `satisfies SlotName` preserves the narrow literal type while verifying at
40
+ * compile time that the value is a valid `SlotName`.
41
+ */
42
+ export const SLOT_DEFAULT = 'default' satisfies SlotName
43
+ export const SLOT_FAST = 'fast' satisfies SlotName
package/src/events.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { AIError } from './errors.js'
2
+
3
+ // ─── In-stream completion events ─────────────────────────────────────────────
4
+ //
5
+ // Fire while a single `streamCompletion` call is in flight. Provider-agnostic:
6
+ // every concrete provider's stream is reduced to `StreamEvent` so consumers
7
+ // never branch on provider id.
8
+
9
+ /**
10
+ * Why a completion stream stopped producing output.
11
+ *
12
+ * Mirrors the AI SDK's `FinishReason`. Unknown / provider-specific values are
13
+ * normalised to `'other'` rather than silently passed through.
14
+ */
15
+ export type FinishReason =
16
+ | 'stop'
17
+ | 'length'
18
+ | 'content-filter'
19
+ | 'tool-calls'
20
+ | 'error'
21
+ | 'other'
22
+
23
+ /**
24
+ * Token usage for a single completion. Every field is optional because
25
+ * not every provider reports every counter.
26
+ */
27
+ export type Usage = {
28
+ /** Tokens consumed from the prompt. */
29
+ inputTokens?: number
30
+ /** Tokens generated in the response. */
31
+ outputTokens?: number
32
+ /** `inputTokens + outputTokens` as reported by the provider. */
33
+ totalTokens?: number
34
+ /** Of `inputTokens`, how many were a cache hit (Anthropic prompt-cache reads). */
35
+ cacheReadInputTokens?: number
36
+ /** Of `inputTokens`, how many were written to the prompt cache for future reads. */
37
+ cacheCreationInputTokens?: number
38
+ /** Subset of `outputTokens` spent on reasoning / thinking content. */
39
+ reasoningTokens?: number
40
+ }
41
+
42
+ /**
43
+ * Provider-agnostic streaming events. Every concrete provider's stream is
44
+ * reduced to this discriminated union so consumers never branch on provider id.
45
+ *
46
+ * Errors arrive as `{ kind: 'error', ... }` events, not as thrown exceptions —
47
+ * this lets the host pump events across an Electron IPC boundary one at a time
48
+ * without needing try/catch around the iterator.
49
+ *
50
+ * `tool-call` and `tool-result` are typed but never produced in the current
51
+ * version (one-shot text completions only). They're reserved for forward compat.
52
+ */
53
+ export type StreamEvent =
54
+ | { kind: 'text-delta'; delta: string }
55
+ | { kind: 'tool-call'; id: string; name: string; input: unknown }
56
+ | { kind: 'tool-result'; id: string; name: string; result: unknown }
57
+ | { kind: 'usage-update'; usage: Usage }
58
+ | { kind: 'finish'; finishReason: FinishReason; usage: Usage }
59
+ | { kind: 'error'; error: AIError }
60
+
61
+ // ─── HTTP-level invocation events ────────────────────────────────────────────
62
+ //
63
+ // Fire once per upstream HTTP request, independent of the in-stream
64
+ // `StreamEvent` pipeline. Threaded into providers via
65
+ // `AIRegistryOptions.apiCallListener`.
66
+
67
+ /**
68
+ * Wire-level record of one HTTP request the library made to an upstream
69
+ * provider API. Emitted via {@link AIRegistryOptions.apiCallListener} so the host
70
+ * can persist or surface an invocation log to end users.
71
+ *
72
+ * The listener is invoked exactly once per HTTP request — after the response
73
+ * headers arrive (or after the fetch itself fails). Streaming response bodies
74
+ * are *not* tee'd; only error bodies (HTTP status >= 400) are captured.
75
+ *
76
+ * API keys are redacted from `request.headers` before the event is emitted.
77
+ */
78
+ export type AIModelApiCallEvent = {
79
+ /** Provider id that owns the SDK client which made the call. */
80
+ providerId: string
81
+ /** Epoch ms at which the fetch was issued. */
82
+ startedAt: number
83
+ /**
84
+ * Wall-clock time from fetch start to response headers (or to the fetch
85
+ * promise settling on failure). For streaming responses this is
86
+ * time-to-first-byte, not total stream duration.
87
+ */
88
+ durationMs: number
89
+ request: {
90
+ method: string
91
+ url: string
92
+ /** Outgoing headers; `authorization` / `x-api-key` / `anthropic-api-key` are redacted. */
93
+ headers: Record<string, string>
94
+ /** Parsed JSON when the body was a JSON string; the raw string otherwise. */
95
+ body: unknown
96
+ }
97
+ /** HTTP status, or `undefined` if the fetch itself failed before a response. */
98
+ status: number | undefined
99
+ /** Response body — populated only when `status >= 400`. */
100
+ errorBody?: string
101
+ /** Failure reason — populated only when fetch itself threw (network error, abort, …). */
102
+ failure?: string
103
+ }
104
+
105
+ export type AIModelApiCallListener = (event: AIModelApiCallEvent) => void
package/src/index.ts CHANGED
@@ -1,5 +1,16 @@
1
- export type { AIProvider, AIModel, CompletionRequest } from './provider.js'
2
- export type { StreamEvent, Usage, FinishReason } from './stream-events.js'
1
+ export type {
2
+ AIProvider,
3
+ AIModel,
4
+ AIModelOptions,
5
+ CompletionRequest
6
+ } from './provider.js'
7
+ export type {
8
+ StreamEvent,
9
+ Usage,
10
+ FinishReason,
11
+ AIModelApiCallEvent,
12
+ AIModelApiCallListener
13
+ } from './events.js'
3
14
  export type {
4
15
  AISettings,
5
16
  SlotName,
@@ -43,10 +54,12 @@ export {
43
54
  ANTHROPIC_DEFAULT_BASE_URL,
44
55
  OPENAI_COMPATIBLE_PROVIDER_ID,
45
56
  OPENAI_COMPATIBLE_PROVIDER_NAME,
46
- deriveEnvVarName
47
- } from './provider-constants.js'
48
- export { AnthropicProvider } from './providers/anthropic.js'
49
- export { OpenAICompatibleProvider } from './providers/openai-compatible.js'
57
+ deriveEnvVarName,
58
+ SLOT_DEFAULT,
59
+ SLOT_FAST
60
+ } from './constants.js'
61
+ export { AnthropicProvider } from './providers/anthropic/provider.js'
62
+ export { OpenAICompatibleProvider } from './providers/openai-compatible/provider.js'
50
63
  export {
51
64
  AIRegistry,
52
65
  type AIRegistryOptions,
package/src/provider.ts CHANGED
@@ -3,7 +3,19 @@ import type {
3
3
  ModelCapabilities
4
4
  } from '@inkdropapp/ai-catalog'
5
5
  import type { ModelMessage } from 'ai'
6
- import type { StreamEvent } from './stream-events.js'
6
+ import type { AIModelApiCallListener, StreamEvent } from './events.js'
7
+
8
+ /**
9
+ * Provider-construction options that apply to every model the provider hosts.
10
+ * Threaded by `AIRegistry` into each provider's constructor.
11
+ */
12
+ export type AIModelOptions = {
13
+ /**
14
+ * Invoked once per upstream HTTP request made by any model on this provider.
15
+ * See {@link AIModelApiCallListener}.
16
+ */
17
+ apiCallListener?: AIModelApiCallListener
18
+ }
7
19
 
8
20
  /**
9
21
  * Inputs for a single one-shot streaming completion.
@@ -0,0 +1,4 @@
1
+ export const ANTHROPIC_PROVIDER_ID = 'anthropic'
2
+ export const ANTHROPIC_PROVIDER_NAME = 'Anthropic'
3
+ export const ANTHROPIC_ENV_VAR = 'ANTHROPIC_API_KEY'
4
+ export const ANTHROPIC_DEFAULT_BASE_URL = 'https://api.anthropic.com/v1'
@@ -14,22 +14,28 @@ import {
14
14
  type ModelCapabilities
15
15
  } from '@inkdropapp/ai-catalog'
16
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'
17
+ import { NoApiKey } from '../../errors.js'
18
+ import type { AIModelApiCallListener, StreamEvent } from '../../events.js'
19
+ import type { KeyStore } from '../../key-store.js'
20
+ import type {
21
+ AIModel,
22
+ AIModelOptions,
23
+ AIProvider,
24
+ CompletionRequest
25
+ } from '../../provider.js'
26
+ import type {
27
+ AnthropicModelConfig,
28
+ AnthropicProviderConfig
29
+ } from '../../settings.js'
30
+ import { createLoggingFetch } from '../../utils/logging-fetch.js'
31
+ import { mapAiSdkError } from '../../utils/map-ai-sdk-error.js'
32
+ import { mapAiSdkStream } from '../../utils/map-ai-sdk-stream.js'
21
33
  import {
22
34
  ANTHROPIC_DEFAULT_BASE_URL,
23
35
  ANTHROPIC_ENV_VAR,
24
36
  ANTHROPIC_PROVIDER_ID,
25
37
  ANTHROPIC_PROVIDER_NAME
26
- } from '../provider-constants.js'
27
- import type { AIModel, AIProvider, CompletionRequest } from '../provider.js'
28
- import type {
29
- AnthropicModelConfig,
30
- AnthropicProviderConfig
31
- } from '../settings.js'
32
- import type { StreamEvent } from '../stream-events.js'
38
+ } from './constants.js'
33
39
 
34
40
  /**
35
41
  * The Anthropic (Claude) provider.
@@ -55,6 +61,7 @@ export class AnthropicProvider implements AIProvider {
55
61
  private readonly modelsById: Map<string, AnthropicAIModel>
56
62
  private readonly defaultId: string
57
63
  private readonly defaultFastId: string
64
+ private readonly apiCallListener: AIModelApiCallListener | undefined
58
65
 
59
66
  private sdkClient: SdkAnthropicProvider | undefined
60
67
  private sdkClientApiKey: string | undefined
@@ -62,10 +69,12 @@ export class AnthropicProvider implements AIProvider {
62
69
  constructor(
63
70
  keyStore: KeyStore,
64
71
  config: AnthropicProviderConfig = {},
65
- catalog?: AnthropicCatalog
72
+ catalog?: AnthropicCatalog,
73
+ options: AIModelOptions = {}
66
74
  ) {
67
75
  this.keyStore = keyStore
68
76
  this.baseURL = config.baseURL ?? ANTHROPIC_DEFAULT_BASE_URL
77
+ this.apiCallListener = options.apiCallListener
69
78
 
70
79
  const baseModels = catalog?.models ?? ANTHROPIC_MODELS
71
80
  this.defaultId = catalog?.defaultModelId ?? ANTHROPIC_DEFAULT_MODEL_ID
@@ -122,7 +131,13 @@ export class AnthropicProvider implements AIProvider {
122
131
  throw new NoApiKey(this.id, this.envVarName)
123
132
  }
124
133
  if (this.sdkClient && this.sdkClientApiKey === apiKey) return this.sdkClient
125
- this.sdkClient = createAnthropic({ apiKey, baseURL: this.baseURL })
134
+ this.sdkClient = createAnthropic({
135
+ apiKey,
136
+ baseURL: this.baseURL,
137
+ fetch: this.apiCallListener
138
+ ? createLoggingFetch(this.id, this.apiCallListener)
139
+ : undefined
140
+ })
126
141
  this.sdkClientApiKey = apiKey
127
142
  return this.sdkClient
128
143
  }
@@ -1,28 +1,3 @@
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
-
21
- export const ANTHROPIC_PROVIDER_ID = 'anthropic'
22
- export const ANTHROPIC_PROVIDER_NAME = 'Anthropic'
23
- export const ANTHROPIC_ENV_VAR = 'ANTHROPIC_API_KEY'
24
- export const ANTHROPIC_DEFAULT_BASE_URL = 'https://api.anthropic.com'
25
-
26
1
  /**
27
2
  * The `AISettings.providers` key for OpenAI-compatible entries. Use this to
28
3
  * tag a settings-UI row as "an OpenAI-compatible provider" (vs. the built-in
@@ -7,17 +7,24 @@ import type {
7
7
  ModelCapabilities
8
8
  } from '@inkdropapp/ai-catalog'
9
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 { deriveEnvVarName } from '../provider-constants.js'
15
- import type { AIModel, AIProvider, CompletionRequest } from '../provider.js'
10
+ import { NoApiKey } from '../../errors.js'
11
+ import type { AIModelApiCallListener, StreamEvent } from '../../events.js'
12
+ import type { KeyStore } from '../../key-store.js'
13
+ import type {
14
+ AIModel,
15
+ AIModelOptions,
16
+ AIProvider,
17
+ CompletionRequest
18
+ } from '../../provider.js'
16
19
  import type {
17
20
  OpenAICompatibleModelConfig,
18
21
  OpenAICompatibleProviderConfig
19
- } from '../settings.js'
20
- import type { StreamEvent } from '../stream-events.js'
22
+ } from '../../settings.js'
23
+ import { createLoggingFetch } from '../../utils/logging-fetch.js'
24
+ import { mapAiSdkError } from '../../utils/map-ai-sdk-error.js'
25
+ import { mapAiSdkStream } from '../../utils/map-ai-sdk-stream.js'
26
+ import { deriveEnvVarName } from './constants.js'
27
+ import { validateOpenAICompatibleProviderConfig } from './validator.js'
21
28
 
22
29
  const DEFAULT_CAPABILITIES: ModelCapabilities = {
23
30
  supportsTools: true,
@@ -60,13 +67,20 @@ export class OpenAICompatibleProvider implements AIProvider {
60
67
  private readonly config: OpenAICompatibleProviderConfig
61
68
  private readonly keyStore: KeyStore
62
69
  private readonly modelsById: Map<string, OpenAICompatibleAIModel>
70
+ private readonly apiCallListener: AIModelApiCallListener | undefined
63
71
 
64
72
  private sdkClient: SdkOpenAICompatibleProvider | undefined
65
73
  private sdkClientApiKey: string | undefined
66
74
 
67
- constructor(keyStore: KeyStore, config: OpenAICompatibleProviderConfig) {
75
+ constructor(
76
+ keyStore: KeyStore,
77
+ config: OpenAICompatibleProviderConfig,
78
+ options: AIModelOptions = {}
79
+ ) {
80
+ validateOpenAICompatibleProviderConfig(config)
68
81
  this.config = config
69
82
  this.keyStore = keyStore
83
+ this.apiCallListener = options.apiCallListener
70
84
  this.id = config.id
71
85
  this.name = config.displayName ?? config.id
72
86
  this.baseURL = config.baseURL
@@ -128,7 +142,10 @@ export class OpenAICompatibleProvider implements AIProvider {
128
142
  this.sdkClient = createOpenAICompatible({
129
143
  name: this.id,
130
144
  baseURL: this.baseURL,
131
- apiKey: apiKey ?? undefined
145
+ apiKey: apiKey ?? undefined,
146
+ fetch: this.apiCallListener
147
+ ? createLoggingFetch(this.id, this.apiCallListener)
148
+ : undefined
132
149
  })
133
150
  this.sdkClientApiKey = apiKey ?? ''
134
151
  return this.sdkClient
@@ -0,0 +1,7 @@
1
+ // AUTO-GENERATED by scripts/build-validators.mjs. Do not edit by hand.
2
+ import type { ValidateFunction } from 'ajv'
3
+ import type { OpenAICompatibleProviderConfig } from '../../settings.js'
4
+
5
+ declare const validate: ValidateFunction<OpenAICompatibleProviderConfig>
6
+ export default validate
7
+ export { validate }
@@ -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
+ }