@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
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[]
@@ -370,6 +427,51 @@ const settings: AISettings = {
370
427
  `openrouter` looks up `OPENROUTER_API_KEY`; `ollama_local` looks up
371
428
  `OLLAMA_LOCAL_API_KEY`. Each gets its own keyring entry under its `baseURL`.
372
429
 
430
+ ### Adding an OpenAI-compatible provider at runtime
431
+
432
+ Two-step flow: persist the new entry into `AISettings` and rebuild the
433
+ registry, then set the API key on the resulting provider instance.
434
+
435
+ ```ts
436
+ // 1. Add the entry to settings and persist (host owns the config layer).
437
+ const next: AISettings = {
438
+ ...settings,
439
+ providers: {
440
+ ...settings.providers,
441
+ openaiCompatible: [
442
+ ...(settings.providers.openaiCompatible ?? []),
443
+ {
444
+ id: 'groq',
445
+ baseURL: 'https://api.groq.com/openai/v1',
446
+ models: [{ id: 'llama-3.3-70b-versatile' }]
447
+ }
448
+ ]
449
+ }
450
+ }
451
+ writeConfigFile(next) // your persistence layer
452
+ registry.updateSettings(next)
453
+
454
+ // 2. The provider now exists in the registry — set its API key.
455
+ const provider = registry.getProvider('groq')!
456
+ await provider.setApiKey('gsk-...')
457
+ ```
458
+
459
+ `updateSettings` rebuilds the provider list but preserves the shared
460
+ `KeyStore`, so any keys already in the keyring stay resolved.
461
+
462
+ If your UX needs to **validate the key before persisting the entry**, you can
463
+ write directly to the `KeyStore` first — keys are stored against the
464
+ normalised `baseURL`, not the provider id, so this works without the entry
465
+ being registered yet:
466
+
467
+ ```ts
468
+ await registry.keyStore.setKey('https://api.groq.com/openai/v1', 'gsk-...')
469
+ // later, once validated: persist the AISettings entry and updateSettings.
470
+ ```
471
+
472
+ Once you add the entry and rebuild, `provider.isAuthenticated()` returns
473
+ `true` because the key already lives under that `baseURL`.
474
+
373
475
  ### Mixing slots across providers
374
476
 
375
477
  ```ts
@@ -458,9 +560,11 @@ for (const { provider, model } of registry.listAllModels()) {
458
560
 
459
561
  ```sh
460
562
  npm install
461
- npm test # runs all 82 tests against the real system keyring
563
+ npm test # runs the full test suite against the real system keyring
462
564
  npm run lint
463
- 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/
464
568
  ```
465
569
 
466
570
  Tests use a unique per-run keyring service name (`inkdrop.ai.test.<hex>`) so they
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "@inkdropapp/ai",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "AI integration common module",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "types": "types/index.d.ts",
8
- "scripts": {
9
- "lint": "eslint src __tests__",
10
- "test": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/jest/bin/jest.js --config jest.config.cjs --runInBand",
11
- "build": "tsc --declaration --emitDeclarationOnly",
12
- "format": "prettier --write .",
13
- "prepublishOnly": "npm-run-all lint test build"
8
+ "exports": {
9
+ ".": {
10
+ "types": "./types/index.d.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./constants": {
14
+ "types": "./types/provider-constants.d.ts",
15
+ "default": "./src/provider-constants.ts"
16
+ }
14
17
  },
15
18
  "keywords": [
16
19
  "ai",
@@ -18,7 +21,6 @@
18
21
  ],
19
22
  "author": "Takuya Matsuyama <t@inkdrop.app>",
20
23
  "license": "UNLICENSED",
21
- "packageManager": "pnpm@11.1.1",
22
24
  "dependencies": {
23
25
  "@ai-sdk/anthropic": "4.0.0-beta.42",
24
26
  "@ai-sdk/openai-compatible": "3.0.0-beta.36",
@@ -26,6 +28,7 @@
26
28
  "@inkdropapp/ai-catalog": "^0.1.0",
27
29
  "@napi-rs/keyring": "^1.2.0",
28
30
  "ai": "7.0.0-beta.116",
31
+ "ajv": "^8.20.0",
29
32
  "npm-run-all2": "^8.0.4"
30
33
  },
31
34
  "devDependencies": {
@@ -42,5 +45,13 @@
42
45
  "files": [
43
46
  "src",
44
47
  "types"
45
- ]
46
- }
48
+ ],
49
+ "scripts": {
50
+ "lint": "eslint src __tests__",
51
+ "test": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/jest/bin/jest.js --config jest.config.cjs --runInBand",
52
+ "build:validators": "node scripts/build-validators.mjs",
53
+ "build:types": "tsc --declaration --emitDeclarationOnly",
54
+ "build": "npm-run-all build:validators build:types",
55
+ "format": "prettier --write ."
56
+ }
57
+ }
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,
@@ -36,11 +47,17 @@ export {
36
47
  } from './errors.js'
37
48
  export { KeyStore, type KeyStoreOptions } from './key-store.js'
38
49
  export { normalizeBaseURL } from './url.js'
39
- export { AnthropicProvider } from './providers/anthropic.js'
40
50
  export {
41
- OpenAICompatibleProvider,
51
+ ANTHROPIC_PROVIDER_ID,
52
+ ANTHROPIC_PROVIDER_NAME,
53
+ ANTHROPIC_ENV_VAR,
54
+ ANTHROPIC_DEFAULT_BASE_URL,
55
+ OPENAI_COMPATIBLE_PROVIDER_ID,
56
+ OPENAI_COMPATIBLE_PROVIDER_NAME,
42
57
  deriveEnvVarName
43
- } from './providers/openai-compatible.js'
58
+ } from './provider-constants.js'
59
+ export { AnthropicProvider } from './providers/anthropic/provider.js'
60
+ export { OpenAICompatibleProvider } from './providers/openai-compatible/provider.js'
44
61
  export {
45
62
  AIRegistry,
46
63
  type AIRegistryOptions,
@@ -0,0 +1,31 @@
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 {
22
+ ANTHROPIC_PROVIDER_ID,
23
+ ANTHROPIC_PROVIDER_NAME,
24
+ ANTHROPIC_ENV_VAR,
25
+ ANTHROPIC_DEFAULT_BASE_URL
26
+ } from './providers/anthropic/constants.js'
27
+ export {
28
+ OPENAI_COMPATIBLE_PROVIDER_ID,
29
+ OPENAI_COMPATIBLE_PROVIDER_NAME,
30
+ deriveEnvVarName
31
+ } from './providers/openai-compatible/constants.js'
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'
@@ -14,21 +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'
21
- import type { AIModel, AIProvider, CompletionRequest } from '../provider.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'
22
26
  import type {
23
27
  AnthropicModelConfig,
24
28
  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'
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'
33
+ import {
34
+ ANTHROPIC_DEFAULT_BASE_URL,
35
+ ANTHROPIC_ENV_VAR,
36
+ ANTHROPIC_PROVIDER_ID,
37
+ ANTHROPIC_PROVIDER_NAME
38
+ } from './constants.js'
32
39
 
33
40
  /**
34
41
  * The Anthropic (Claude) provider.
@@ -45,15 +52,16 @@ const DEFAULT_BASE_URL = 'https://api.anthropic.com'
45
52
  * `clearApiKey` is called or the resolved API key changes.
46
53
  */
47
54
  export class AnthropicProvider implements AIProvider {
48
- readonly id = PROVIDER_ID
49
- readonly name = PROVIDER_NAME
50
- readonly envVarName = ENV_VAR
55
+ readonly id = ANTHROPIC_PROVIDER_ID
56
+ readonly name = ANTHROPIC_PROVIDER_NAME
57
+ readonly envVarName = ANTHROPIC_ENV_VAR
51
58
  readonly baseURL: string
52
59
 
53
60
  private readonly keyStore: KeyStore
54
61
  private readonly modelsById: Map<string, AnthropicAIModel>
55
62
  private readonly defaultId: string
56
63
  private readonly defaultFastId: string
64
+ private readonly apiCallListener: AIModelApiCallListener | undefined
57
65
 
58
66
  private sdkClient: SdkAnthropicProvider | undefined
59
67
  private sdkClientApiKey: string | undefined
@@ -61,10 +69,12 @@ export class AnthropicProvider implements AIProvider {
61
69
  constructor(
62
70
  keyStore: KeyStore,
63
71
  config: AnthropicProviderConfig = {},
64
- catalog?: AnthropicCatalog
72
+ catalog?: AnthropicCatalog,
73
+ options: AIModelOptions = {}
65
74
  ) {
66
75
  this.keyStore = keyStore
67
- this.baseURL = config.baseURL ?? DEFAULT_BASE_URL
76
+ this.baseURL = config.baseURL ?? ANTHROPIC_DEFAULT_BASE_URL
77
+ this.apiCallListener = options.apiCallListener
68
78
 
69
79
  const baseModels = catalog?.models ?? ANTHROPIC_MODELS
70
80
  this.defaultId = catalog?.defaultModelId ?? ANTHROPIC_DEFAULT_MODEL_ID
@@ -121,7 +131,13 @@ export class AnthropicProvider implements AIProvider {
121
131
  throw new NoApiKey(this.id, this.envVarName)
122
132
  }
123
133
  if (this.sdkClient && this.sdkClientApiKey === apiKey) return this.sdkClient
124
- 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
+ })
125
141
  this.sdkClientApiKey = apiKey
126
142
  return this.sdkClient
127
143
  }
@@ -0,0 +1,32 @@
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 const OPENAI_COMPATIBLE_PROVIDER_ID = 'openaiCompatible'
11
+ export const OPENAI_COMPATIBLE_PROVIDER_NAME = 'OpenAI-Compatible'
12
+
13
+ /**
14
+ * Derives the env-var name for an OpenAI-compatible provider entry from its
15
+ * user-chosen `id`.
16
+ *
17
+ * Rules: replace any non-alphanumeric character with `_`, collapse runs of `_`,
18
+ * uppercase, append `_API_KEY`.
19
+ *
20
+ * Examples:
21
+ * `openrouter` → `OPENROUTER_API_KEY`
22
+ * `together_ai` → `TOGETHER_AI_API_KEY`
23
+ * `ollama-local` → `OLLAMA_LOCAL_API_KEY`
24
+ * `my.proxy` → `MY_PROXY_API_KEY`
25
+ */
26
+ export const deriveEnvVarName = (id: string): string => {
27
+ const sanitized = id
28
+ .replace(/[^a-zA-Z0-9]+/g, '_')
29
+ .replace(/^_+|_+$/g, '')
30
+ .toUpperCase()
31
+ return `${sanitized}_API_KEY`
32
+ }
@@ -7,16 +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 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'
15
19
  import type {
16
20
  OpenAICompatibleModelConfig,
17
21
  OpenAICompatibleProviderConfig
18
- } from '../settings.js'
19
- 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'
20
28
 
21
29
  const DEFAULT_CAPABILITIES: ModelCapabilities = {
22
30
  supportsTools: true,
@@ -26,26 +34,6 @@ const DEFAULT_CAPABILITIES: ModelCapabilities = {
26
34
  maxTokens: 32_000
27
35
  }
28
36
 
29
- /**
30
- * Derives the env-var name for a user-named provider entry.
31
- *
32
- * Rules: replace any non-alphanumeric character with `_`, collapse runs of `_`,
33
- * uppercase, append `_API_KEY`.
34
- *
35
- * Examples:
36
- * `openrouter` → `OPENROUTER_API_KEY`
37
- * `together_ai` → `TOGETHER_AI_API_KEY`
38
- * `ollama-local` → `OLLAMA_LOCAL_API_KEY`
39
- * `my.proxy` → `MY_PROXY_API_KEY`
40
- */
41
- export const deriveEnvVarName = (id: string): string => {
42
- const sanitized = id
43
- .replace(/[^a-zA-Z0-9]+/g, '_')
44
- .replace(/^_+|_+$/g, '')
45
- .toUpperCase()
46
- return `${sanitized}_API_KEY`
47
- }
48
-
49
37
  const mergeCapabilities = (
50
38
  override: Partial<ModelCapabilities> | undefined
51
39
  ): ModelCapabilities => ({
@@ -79,13 +67,20 @@ export class OpenAICompatibleProvider implements AIProvider {
79
67
  private readonly config: OpenAICompatibleProviderConfig
80
68
  private readonly keyStore: KeyStore
81
69
  private readonly modelsById: Map<string, OpenAICompatibleAIModel>
70
+ private readonly apiCallListener: AIModelApiCallListener | undefined
82
71
 
83
72
  private sdkClient: SdkOpenAICompatibleProvider | undefined
84
73
  private sdkClientApiKey: string | undefined
85
74
 
86
- constructor(keyStore: KeyStore, config: OpenAICompatibleProviderConfig) {
75
+ constructor(
76
+ keyStore: KeyStore,
77
+ config: OpenAICompatibleProviderConfig,
78
+ options: AIModelOptions = {}
79
+ ) {
80
+ validateOpenAICompatibleProviderConfig(config)
87
81
  this.config = config
88
82
  this.keyStore = keyStore
83
+ this.apiCallListener = options.apiCallListener
89
84
  this.id = config.id
90
85
  this.name = config.displayName ?? config.id
91
86
  this.baseURL = config.baseURL
@@ -147,7 +142,10 @@ export class OpenAICompatibleProvider implements AIProvider {
147
142
  this.sdkClient = createOpenAICompatible({
148
143
  name: this.id,
149
144
  baseURL: this.baseURL,
150
- apiKey: apiKey ?? undefined
145
+ apiKey: apiKey ?? undefined,
146
+ fetch: this.apiCallListener
147
+ ? createLoggingFetch(this.id, this.apiCallListener)
148
+ : undefined
151
149
  })
152
150
  this.sdkClientApiKey = apiKey ?? ''
153
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 }