@hasna/knowledge 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -5,15 +5,10 @@
5
5
  * Licensed under the Apache License, Version 2.0
6
6
  */
7
7
  import { defaultStorePath, loadStore, saveStore, withLock, makeId, makeShortId, ensureStore, type KnowledgeItem } from './store';
8
- import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace';
9
- import { getKnowledgeDbStats, migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
10
- import { createArtifactStore } from './artifact-store';
11
- import { initializeWikiLayout } from './wiki-layout';
12
- import { ingestOpenFilesManifest } from './manifest-ingest';
13
- import { ingestSourceRef } from './source-ingest';
14
- import { consumeOpenFilesOutbox } from './outbox-consume';
15
- import { resolveOpenFilesSource } from './source-resolver';
16
- import { approvalStatus, assertS3ReadAllowed, assertWebSearchAllowed, createApprovalGate, recordAuditEvent, recordRedactionFindings, redactSecrets, resolveSafetyPolicy } from './safety';
8
+ import { openKnowledgeDb } from './knowledge-db';
9
+ import { createKnowledgeService } from './service';
10
+ import { assertProviderCredentials, parseModelRef, resolveModelRef, type AiProviderId } from './providers';
11
+ import { approvalStatus, assertS3ReadAllowed, assertWebSearchAllowed, createApprovalGate, recordAuditEvent, recordRedactionFindings, redactSecrets } from './safety';
17
12
  import pkg from '../package.json' with { type: 'json' };
18
13
 
19
14
  type LogLevel = 'debug' | 'info' | 'warn' | 'error';
@@ -65,7 +60,7 @@ interface ParseResult {
65
60
  flags: Flags;
66
61
  }
67
62
 
68
- const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'source', 'ingest', 'reindex', 'safety', 'help'];
63
+ const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'source', 'ingest', 'reindex', 'providers', 'safety', 'help'];
69
64
  const COMMAND_ALIASES: Record<string, string> = {
70
65
  ls: 'list',
71
66
  rm: 'delete',
@@ -173,6 +168,7 @@ Commands:
173
168
  ingest manifest <file|s3://> Ingest an open-files manifest into knowledge.db
174
169
  ingest source <source-ref> Ingest a read-only source ref into knowledge.db
175
170
  reindex outbox <file|s3://> Consume open-files change events and invalidate chunks
171
+ providers status|models|check Inspect AI SDK provider config and credentials
176
172
  safety status|check|approve|audit|redact
177
173
  help [command] Show help
178
174
 
@@ -239,6 +235,7 @@ function printCommandHelp(command: string): void {
239
235
  if (command === 'source') { console.log('Usage: open-knowledge source resolve <source-ref> [--purpose knowledge_answer|knowledge_index] [--limit <n>] [--scope local|global|project] [--json]'); return; }
240
236
  if (command === 'ingest') { console.log('Usage: open-knowledge ingest manifest <file|s3://bucket/key> | source <source-ref> [--purpose knowledge_index] [--scope local|global|project] [--json]'); return; }
241
237
  if (command === 'reindex') { console.log('Usage: open-knowledge reindex outbox <file|s3://bucket/key> [--scope local|global|project] [--json]'); return; }
238
+ if (command === 'providers') { console.log('Usage: open-knowledge providers status|models|check [provider|model-alias] [--scope local|global|project] [--json]'); return; }
242
239
  if (command === 'safety') { console.log('Usage: open-knowledge safety status|check|approve|audit|redact [args] [--scope local|global|project] [--json]'); return; }
243
240
  printGlobalHelp();
244
241
  }
@@ -284,11 +281,11 @@ async function run(argv: string[]): Promise<void> {
284
281
  if (flags.completions) {
285
282
  const shell = flags.completions;
286
283
  if (shell === 'bash') {
287
- console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki source ingest reindex safety help ls rm edit unarchive --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --purpose --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
284
+ console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki source ingest reindex providers safety help ls rm edit unarchive --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --purpose --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
288
285
  } else if (shell === 'zsh') {
289
- console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki source ingest reindex safety help ls rm edit unarchive)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"\{created,title\}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--purpose)--purpose[purpose]:" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
286
+ console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki source ingest reindex providers safety help ls rm edit unarchive)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"\{created,title\}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--purpose)--purpose[purpose]:" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
290
287
  } else if (shell === 'fish') {
291
- console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki source ingest reindex safety help ls rm edit unarchive"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -l archived; complete -c open-knowledge -l include-archived; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l purpose; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
288
+ console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki source ingest reindex providers safety help ls rm edit unarchive"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -l archived; complete -c open-knowledge -l include-archived; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l purpose; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
292
289
  } else {
293
290
  throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
294
291
  }
@@ -299,71 +296,49 @@ async function run(argv: string[]): Promise<void> {
299
296
 
300
297
  if (!command || flags.help || command === 'help') { printCommandHelp(positional[1]); return; }
301
298
 
302
- const workspace = resolveScopedWorkspace(flags.scope);
299
+ const service = createKnowledgeService({ scope: flags.scope });
303
300
  let storePath = flags.store;
304
301
  if (!storePath) {
305
302
  if (flags.scope === 'project' || flags.scope === 'local') {
306
- storePath = ensureKnowledgeWorkspace(workspace.home).jsonStorePath;
303
+ storePath = service.jsonStorePath();
307
304
  } else {
308
305
  storePath = defaultStorePath();
309
306
  }
310
307
  }
311
308
 
312
309
  if (command === 'paths') {
313
- const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
314
- output({
315
- ok: true,
316
- scope: flags.scope ?? 'global',
317
- home: resolvedWorkspace.home,
318
- config_path: resolvedWorkspace.configPath,
319
- json_store_path: resolvedWorkspace.jsonStorePath,
320
- knowledge_db_path: resolvedWorkspace.knowledgeDbPath,
321
- artifacts_dir: resolvedWorkspace.artifactsDir,
322
- indexes_dir: resolvedWorkspace.indexesDir,
323
- logs_dir: resolvedWorkspace.logsDir,
324
- runs_dir: resolvedWorkspace.runsDir,
325
- schemas_dir: resolvedWorkspace.schemasDir,
326
- wiki_dir: resolvedWorkspace.wikiDir,
327
- config: readKnowledgeConfig(resolvedWorkspace.configPath),
328
- message: resolvedWorkspace.home,
329
- }, flags.json);
310
+ output(service.paths(), flags.json);
330
311
  return;
331
312
  }
332
313
 
333
314
  if (command === 'db') {
334
315
  const action = positional[1] ?? 'init';
335
- const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
336
316
  if (action !== 'init' && action !== 'stats') {
337
317
  throw new Error("Invalid db action. Use 'init' or 'stats'.");
338
318
  }
339
319
  if (action === 'init') {
340
- const result = migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
320
+ const result = service.initDb();
341
321
  output({ ok: true, ...result, message: `Initialized ${result.path}` }, flags.json);
342
322
  return;
343
323
  }
344
- migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
345
- const stats = getKnowledgeDbStats(resolvedWorkspace.knowledgeDbPath);
346
- output({ ok: true, path: resolvedWorkspace.knowledgeDbPath, ...stats, message: `knowledge.db schema v${stats.schema_version}` }, flags.json);
324
+ const stats = service.dbStats();
325
+ output({ ok: true, path: service.workspace.knowledgeDbPath, ...stats, message: `knowledge.db schema v${stats.schema_version}` }, flags.json);
347
326
  return;
348
327
  }
349
328
 
350
329
  if (command === 'wiki') {
351
330
  const action = positional[1] ?? 'init';
352
331
  if (action !== 'init') throw new Error("Invalid wiki action. Use 'init'.");
353
- const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
354
- const config = readKnowledgeConfig(resolvedWorkspace.configPath);
355
- const artifactStore = createArtifactStore(config, resolvedWorkspace);
356
- const result = await initializeWikiLayout(artifactStore);
357
- output({ ok: true, ...result, message: `Initialized wiki layout in ${resolvedWorkspace.home}` }, flags.json);
332
+ const result = await service.initWiki();
333
+ output({ ok: true, ...result, message: `Initialized wiki layout in ${service.workspace.home}` }, flags.json);
358
334
  return;
359
335
  }
360
336
 
361
337
  if (command === 'safety') {
362
338
  const action = positional[1] ?? 'status';
363
- const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
364
- const config = readKnowledgeConfig(resolvedWorkspace.configPath);
365
- const policy = resolveSafetyPolicy(config, resolvedWorkspace);
366
- migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
339
+ const resolvedWorkspace = service.ensureWorkspace();
340
+ const policy = service.safetyPolicy();
341
+ service.initDb();
367
342
  const db = openKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
368
343
  try {
369
344
  if (action === 'status') {
@@ -489,15 +464,9 @@ async function run(argv: string[]): Promise<void> {
489
464
  if (action !== 'resolve') throw new Error("Invalid source action. Use 'resolve'.");
490
465
  const sourceRef = positional[2];
491
466
  if (!sourceRef) throw new Error('Usage: open-knowledge source resolve <source-ref>');
492
- const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
493
- const config = readKnowledgeConfig(resolvedWorkspace.configPath);
494
- const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
495
- const result = await resolveOpenFilesSource({
496
- dbPath: resolvedWorkspace.knowledgeDbPath,
497
- sourceRef,
467
+ const result = await service.resolveSource(sourceRef, {
498
468
  purpose: flags.purpose,
499
469
  limit: flags.limit,
500
- safetyPolicy,
501
470
  });
502
471
  output({
503
472
  ok: true,
@@ -511,31 +480,17 @@ async function run(argv: string[]): Promise<void> {
511
480
 
512
481
  if (command === 'ingest') {
513
482
  const action = positional[1] ?? '';
514
- const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
515
- const config = readKnowledgeConfig(resolvedWorkspace.configPath);
516
- const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
517
483
  if (action === 'manifest') {
518
484
  const input = positional[2];
519
485
  if (!input) throw new Error('Usage: open-knowledge ingest manifest <file|s3://bucket/key>');
520
- const result = await ingestOpenFilesManifest({
521
- dbPath: resolvedWorkspace.knowledgeDbPath,
522
- input,
523
- config,
524
- safetyPolicy,
525
- });
486
+ const result = await service.ingestManifest(input);
526
487
  output({ ok: true, ...result, message: `Ingested ${result.items_seen} manifest item(s)` }, flags.json);
527
488
  return;
528
489
  }
529
490
  if (action === 'source') {
530
491
  const sourceRef = positional[2];
531
492
  if (!sourceRef) throw new Error('Usage: open-knowledge ingest source <source-ref>');
532
- const result = await ingestSourceRef({
533
- dbPath: resolvedWorkspace.knowledgeDbPath,
534
- sourceRef,
535
- purpose: flags.purpose,
536
- config,
537
- safetyPolicy,
538
- });
493
+ const result = await service.ingestSource(sourceRef, flags.purpose);
539
494
  output({ ok: true, ...result, message: `Ingested source ${result.source_ref} (${result.chunks_inserted} chunks)` }, flags.json);
540
495
  return;
541
496
  }
@@ -547,19 +502,35 @@ async function run(argv: string[]): Promise<void> {
547
502
  if (action !== 'outbox') throw new Error("Invalid reindex action. Use 'outbox'.");
548
503
  const input = positional[2];
549
504
  if (!input) throw new Error('Usage: open-knowledge reindex outbox <file|s3://bucket/key>');
550
- const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
551
- const config = readKnowledgeConfig(resolvedWorkspace.configPath);
552
- const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
553
- const result = await consumeOpenFilesOutbox({
554
- dbPath: resolvedWorkspace.knowledgeDbPath,
555
- input,
556
- config,
557
- safetyPolicy,
558
- });
505
+ const result = await service.consumeOutbox(input);
559
506
  output({ ok: true, ...result, message: `Consumed ${result.events_seen} outbox event(s)` }, flags.json);
560
507
  return;
561
508
  }
562
509
 
510
+ if (command === 'providers') {
511
+ const action = positional[1] ?? 'status';
512
+ if (action === 'status') {
513
+ const status = service.providerStatus();
514
+ const configured = status.providers.filter((entry) => entry.configured).length;
515
+ output({ ok: true, ...status, message: `${configured}/${status.providers.length} provider credential(s) configured` }, flags.json);
516
+ return;
517
+ }
518
+ if (action === 'models') {
519
+ const models = service.modelRegistry();
520
+ output({ ok: true, models, message: `${models.length} model alias(es)` }, flags.json);
521
+ return;
522
+ }
523
+ if (action === 'check') {
524
+ const target = positional[2] ?? 'default';
525
+ const modelRef = resolveModelRef(target, service.config());
526
+ const parsed = parseModelRef(modelRef);
527
+ const credential = assertProviderCredentials(parsed.provider as AiProviderId, service.config());
528
+ output({ ok: true, target, model_ref: modelRef, provider: parsed.provider, model: parsed.model, credential, message: `${parsed.provider} credentials configured` }, flags.json);
529
+ return;
530
+ }
531
+ throw new Error("Invalid providers action. Use 'status', 'models', or 'check'.");
532
+ }
533
+
563
534
  ensureStore(storePath);
564
535
 
565
536
  if (command === 'add') {
package/src/mcp.js CHANGED
@@ -5,10 +5,8 @@ import { z } from 'zod';
5
5
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import pkg from '../package.json' with { type: 'json' };
7
7
  import { defaultStorePath, loadStore, saveStore, makeId, withLock } from './store.ts';
8
- import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace.ts';
9
8
  import { parseSourceRef } from './source-ref.ts';
10
- import { resolveOpenFilesSource } from './source-resolver.ts';
11
- import { resolveSafetyPolicy } from './safety.ts';
9
+ import { createKnowledgeService } from './service.ts';
12
10
 
13
11
  const storePathField = z.string().optional().describe('Path to the JSON store file');
14
12
  const scopeField = z.enum(['local', 'global', 'project']).optional().describe('Workspace scope');
@@ -28,7 +26,7 @@ function shortIdFor(id) {
28
26
  function resolveStorePath(storePath, scope) {
29
27
  if (storePath) return storePath;
30
28
  if (scope === 'project' || scope === 'local') {
31
- return ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home).jsonStorePath;
29
+ return createKnowledgeService({ scope }).jsonStorePath();
32
30
  }
33
31
  return defaultStorePath();
34
32
  }
@@ -76,22 +74,7 @@ export function buildServer() {
76
74
  registerTool(server, 'ok_paths', 'Knowledge workspace paths', 'Show resolved workspace and store paths', {
77
75
  scope: scopeField,
78
76
  }, async ({ scope }) => {
79
- const workspace = ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home);
80
- return jsonText({
81
- ok: true,
82
- scope: scope ?? 'global',
83
- home: workspace.home,
84
- config_path: workspace.configPath,
85
- json_store_path: workspace.jsonStorePath,
86
- knowledge_db_path: workspace.knowledgeDbPath,
87
- artifacts_dir: workspace.artifactsDir,
88
- indexes_dir: workspace.indexesDir,
89
- logs_dir: workspace.logsDir,
90
- runs_dir: workspace.runsDir,
91
- schemas_dir: workspace.schemasDir,
92
- wiki_dir: workspace.wikiDir,
93
- config: readKnowledgeConfig(workspace.configPath),
94
- });
77
+ return jsonText(createKnowledgeService({ scope }).paths());
95
78
  });
96
79
 
97
80
  registerTool(server, 'ok_parse_source_ref', 'Parse source reference', 'Parse and validate an open-files, S3, file, or web source ref', {
@@ -110,16 +93,11 @@ export function buildServer() {
110
93
  limit: z.number().optional().describe('Maximum chunks to return, default 10'),
111
94
  scope: scopeField,
112
95
  }, async ({ source_ref, purpose, limit, scope }) => {
113
- const workspace = ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home);
114
- const config = readKnowledgeConfig(workspace.configPath);
115
- const safetyPolicy = resolveSafetyPolicy(config, workspace);
96
+ const service = createKnowledgeService({ scope });
116
97
  try {
117
- const result = await resolveOpenFilesSource({
118
- dbPath: workspace.knowledgeDbPath,
119
- sourceRef: source_ref,
98
+ const result = await service.resolveSource(source_ref, {
120
99
  purpose,
121
100
  limit,
122
- safetyPolicy,
123
101
  });
124
102
  return jsonText({ ok: true, ...result });
125
103
  } catch (error) {
@@ -127,6 +105,20 @@ export function buildServer() {
127
105
  }
128
106
  });
129
107
 
108
+ registerTool(server, 'ok_provider_status', 'AI provider status', 'Inspect configured AI SDK providers, model aliases, and BYOK credential availability', {
109
+ scope: scopeField,
110
+ }, async ({ scope }) => {
111
+ const service = createKnowledgeService({ scope });
112
+ return jsonText({ ok: true, ...service.providerStatus() });
113
+ });
114
+
115
+ registerTool(server, 'ok_provider_models', 'AI provider models', 'List AI SDK model aliases and capability metadata', {
116
+ scope: scopeField,
117
+ }, async ({ scope }) => {
118
+ const service = createKnowledgeService({ scope });
119
+ return jsonText({ ok: true, models: service.modelRegistry() });
120
+ });
121
+
130
122
  registerTool(server, 'ok_add', 'Add a knowledge item', 'Add a new item to the knowledge store', {
131
123
  title: z.string().describe('Item title'),
132
124
  content: z.string().describe('Item content/body'),
@@ -0,0 +1,308 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Database } from 'bun:sqlite';
3
+ import type { KnowledgeConfig } from './workspace';
4
+
5
+ export type AiProviderId = 'openai' | 'anthropic' | 'deepseek';
6
+
7
+ export interface AiProviderSettings {
8
+ api_key_env: string;
9
+ base_url?: string;
10
+ default_model: string;
11
+ }
12
+
13
+ export interface AiProvidersConfig {
14
+ default_model?: string;
15
+ aliases?: Record<string, string>;
16
+ openai?: Partial<AiProviderSettings>;
17
+ anthropic?: Partial<AiProviderSettings>;
18
+ deepseek?: Partial<AiProviderSettings>;
19
+ }
20
+
21
+ export interface ModelCapabilities {
22
+ text_generation: boolean;
23
+ structured_output: boolean;
24
+ tool_usage: boolean;
25
+ tool_streaming: boolean;
26
+ image_input: boolean;
27
+ native_web_search: boolean;
28
+ reasoning: boolean;
29
+ embeddings: boolean;
30
+ }
31
+
32
+ export interface ModelRegistryEntry {
33
+ alias: string;
34
+ model_ref: string;
35
+ provider: AiProviderId;
36
+ model: string;
37
+ default: boolean;
38
+ capabilities: ModelCapabilities;
39
+ }
40
+
41
+ export interface ProviderCredentialStatus {
42
+ provider: AiProviderId;
43
+ api_key_env: string;
44
+ configured: boolean;
45
+ source: 'env' | 'missing';
46
+ base_url: string | null;
47
+ default_model: string;
48
+ }
49
+
50
+ export interface ProviderStatusResult {
51
+ default_model: string;
52
+ providers: ProviderCredentialStatus[];
53
+ models: ModelRegistryEntry[];
54
+ }
55
+
56
+ export interface NormalizedProviderUsage {
57
+ provider: string;
58
+ model: string;
59
+ input_tokens: number;
60
+ output_tokens: number;
61
+ cost_usd: number;
62
+ metadata: Record<string, unknown>;
63
+ }
64
+
65
+ type ProviderFactory = (settings: { apiKey: string; baseURL?: string }) => unknown;
66
+
67
+ export interface AiProviderRuntimeOptions {
68
+ config?: KnowledgeConfig;
69
+ env?: Record<string, string | undefined>;
70
+ factories?: Partial<Record<AiProviderId, ProviderFactory>>;
71
+ }
72
+
73
+ const DEFAULT_PROVIDER_SETTINGS: Record<AiProviderId, AiProviderSettings> = {
74
+ openai: {
75
+ api_key_env: 'OPENAI_API_KEY',
76
+ default_model: 'gpt-5.2',
77
+ },
78
+ anthropic: {
79
+ api_key_env: 'ANTHROPIC_API_KEY',
80
+ default_model: 'claude-sonnet-4-6',
81
+ },
82
+ deepseek: {
83
+ api_key_env: 'DEEPSEEK_API_KEY',
84
+ default_model: 'deepseek-chat',
85
+ },
86
+ };
87
+
88
+ const PROVIDER_CAPABILITIES: Record<AiProviderId, ModelCapabilities> = {
89
+ openai: {
90
+ text_generation: true,
91
+ structured_output: true,
92
+ tool_usage: true,
93
+ tool_streaming: true,
94
+ image_input: true,
95
+ native_web_search: true,
96
+ reasoning: true,
97
+ embeddings: true,
98
+ },
99
+ anthropic: {
100
+ text_generation: true,
101
+ structured_output: true,
102
+ tool_usage: true,
103
+ tool_streaming: true,
104
+ image_input: true,
105
+ native_web_search: false,
106
+ reasoning: true,
107
+ embeddings: false,
108
+ },
109
+ deepseek: {
110
+ text_generation: true,
111
+ structured_output: true,
112
+ tool_usage: true,
113
+ tool_streaming: true,
114
+ image_input: false,
115
+ native_web_search: false,
116
+ reasoning: true,
117
+ embeddings: false,
118
+ },
119
+ };
120
+
121
+ const BUILTIN_ALIASES: Record<string, string> = {
122
+ default: 'openai:gpt-5.2',
123
+ fast: 'openai:gpt-5-mini',
124
+ reasoning: 'anthropic:claude-opus-4-6',
125
+ sonnet: 'anthropic:claude-sonnet-4-6',
126
+ deepseek: 'deepseek:deepseek-chat',
127
+ 'deepseek-reasoning': 'deepseek:deepseek-reasoner',
128
+ };
129
+
130
+ function providerConfig(config?: KnowledgeConfig): AiProvidersConfig {
131
+ return (config as KnowledgeConfig & { providers?: AiProvidersConfig }).providers ?? {};
132
+ }
133
+
134
+ export function providerSettings(config: KnowledgeConfig | undefined, provider: AiProviderId): AiProviderSettings {
135
+ const configured = providerConfig(config)[provider] ?? {};
136
+ return {
137
+ ...DEFAULT_PROVIDER_SETTINGS[provider],
138
+ ...configured,
139
+ };
140
+ }
141
+
142
+ export function modelAliases(config?: KnowledgeConfig): Record<string, string> {
143
+ const configured = providerConfig(config);
144
+ return {
145
+ ...BUILTIN_ALIASES,
146
+ ...(configured.default_model ? { default: configured.default_model } : {}),
147
+ ...(configured.aliases ?? {}),
148
+ };
149
+ }
150
+
151
+ export function parseModelRef(modelRef: string): { provider: AiProviderId; model: string } {
152
+ const [provider, ...rest] = modelRef.split(':');
153
+ const model = rest.join(':');
154
+ if (provider !== 'openai' && provider !== 'anthropic' && provider !== 'deepseek') {
155
+ throw new Error(`Unsupported AI provider: ${provider}`);
156
+ }
157
+ if (!model) throw new Error(`Invalid model ref: ${modelRef}. Expected provider:model.`);
158
+ return { provider, model };
159
+ }
160
+
161
+ export function resolveModelRef(aliasOrRef: string, config?: KnowledgeConfig): string {
162
+ const aliases = modelAliases(config);
163
+ return aliases[aliasOrRef] ?? aliasOrRef;
164
+ }
165
+
166
+ export function listModelRegistry(config?: KnowledgeConfig): ModelRegistryEntry[] {
167
+ const aliases = modelAliases(config);
168
+ return Object.entries(aliases).map(([alias, modelRef]) => {
169
+ const parsed = parseModelRef(modelRef);
170
+ return {
171
+ alias,
172
+ model_ref: modelRef,
173
+ provider: parsed.provider,
174
+ model: parsed.model,
175
+ default: alias === 'default',
176
+ capabilities: PROVIDER_CAPABILITIES[parsed.provider],
177
+ };
178
+ });
179
+ }
180
+
181
+ export function providerCredentialStatus(config: KnowledgeConfig | undefined, env: Record<string, string | undefined> = process.env): ProviderCredentialStatus[] {
182
+ return (Object.keys(DEFAULT_PROVIDER_SETTINGS) as AiProviderId[]).map((provider) => {
183
+ const settings = providerSettings(config, provider);
184
+ const configured = Boolean(env[settings.api_key_env]);
185
+ return {
186
+ provider,
187
+ api_key_env: settings.api_key_env,
188
+ configured,
189
+ source: configured ? 'env' : 'missing',
190
+ base_url: settings.base_url ?? null,
191
+ default_model: settings.default_model,
192
+ };
193
+ });
194
+ }
195
+
196
+ export function providerStatus(config?: KnowledgeConfig, env: Record<string, string | undefined> = process.env): ProviderStatusResult {
197
+ return {
198
+ default_model: resolveModelRef('default', config),
199
+ providers: providerCredentialStatus(config, env),
200
+ models: listModelRegistry(config),
201
+ };
202
+ }
203
+
204
+ export function assertProviderCredentials(provider: AiProviderId, config?: KnowledgeConfig, env: Record<string, string | undefined> = process.env): ProviderCredentialStatus {
205
+ const status = providerCredentialStatus(config, env).find((entry) => entry.provider === provider);
206
+ if (!status) throw new Error(`Unsupported AI provider: ${provider}`);
207
+ if (!status.configured) throw new Error(`Missing ${status.api_key_env} for ${provider}. Set the env var to use this provider.`);
208
+ return status;
209
+ }
210
+
211
+ async function defaultFactory(provider: AiProviderId): Promise<ProviderFactory> {
212
+ if (provider === 'openai') {
213
+ const { createOpenAI } = await import('@ai-sdk/openai');
214
+ return createOpenAI as ProviderFactory;
215
+ }
216
+ if (provider === 'anthropic') {
217
+ const { createAnthropic } = await import('@ai-sdk/anthropic');
218
+ return createAnthropic as ProviderFactory;
219
+ }
220
+ const { createDeepSeek } = await import('@ai-sdk/deepseek');
221
+ return createDeepSeek as ProviderFactory;
222
+ }
223
+
224
+ export async function createAiSdkProviderRegistry(options: AiProviderRuntimeOptions = {}) {
225
+ const { createProviderRegistry } = await import('ai');
226
+ const env = options.env ?? process.env;
227
+ const providers: Record<string, unknown> = {};
228
+ for (const provider of Object.keys(DEFAULT_PROVIDER_SETTINGS) as AiProviderId[]) {
229
+ const settings = providerSettings(options.config, provider);
230
+ const apiKey = env[settings.api_key_env];
231
+ if (!apiKey) continue;
232
+ const factory = options.factories?.[provider] ?? await defaultFactory(provider);
233
+ providers[provider] = factory({ apiKey, baseURL: settings.base_url });
234
+ }
235
+ return createProviderRegistry(providers as never);
236
+ }
237
+
238
+ export async function languageModelFor(aliasOrRef: string, options: AiProviderRuntimeOptions = {}) {
239
+ const modelRef = resolveModelRef(aliasOrRef, options.config);
240
+ const parsed = parseModelRef(modelRef);
241
+ assertProviderCredentials(parsed.provider, options.config, options.env);
242
+ const registry = await createAiSdkProviderRegistry(options);
243
+ return registry.languageModel(modelRef as `${string}:${string}`);
244
+ }
245
+
246
+ function usageNumber(usage: Record<string, unknown>, keys: string[]): number {
247
+ for (const key of keys) {
248
+ const value = usage[key];
249
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
250
+ }
251
+ return 0;
252
+ }
253
+
254
+ export function normalizeAiSdkUsage(input: {
255
+ provider: string;
256
+ model: string;
257
+ usage?: Record<string, unknown> | null;
258
+ providerMetadata?: Record<string, unknown> | null;
259
+ costUsd?: number;
260
+ }): NormalizedProviderUsage {
261
+ const usage = input.usage ?? {};
262
+ return {
263
+ provider: input.provider,
264
+ model: input.model,
265
+ input_tokens: usageNumber(usage, ['inputTokens', 'promptTokens', 'input_tokens', 'prompt_tokens']),
266
+ output_tokens: usageNumber(usage, ['outputTokens', 'completionTokens', 'output_tokens', 'completion_tokens']),
267
+ cost_usd: input.costUsd ?? 0,
268
+ metadata: {
269
+ usage,
270
+ provider_metadata: input.providerMetadata ?? {},
271
+ },
272
+ };
273
+ }
274
+
275
+ export function recordProviderUsage(db: Database, input: NormalizedProviderUsage & { run_id?: string | null; created_at?: string }): string {
276
+ const id = `usage_${randomUUID()}`;
277
+ db.run(
278
+ `INSERT INTO provider_usage (id, run_id, provider, model, input_tokens, output_tokens, cost_usd, metadata_json, created_at)
279
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
280
+ [
281
+ id,
282
+ input.run_id ?? null,
283
+ input.provider,
284
+ input.model,
285
+ input.input_tokens,
286
+ input.output_tokens,
287
+ input.cost_usd,
288
+ JSON.stringify(input.metadata),
289
+ input.created_at ?? new Date().toISOString(),
290
+ ],
291
+ );
292
+ return id;
293
+ }
294
+
295
+ export function createDeterministicFakeProvider(provider: AiProviderId): ProviderFactory {
296
+ return () => ({
297
+ languageModel: (modelId: string) => ({
298
+ provider,
299
+ modelId,
300
+ specificationVersion: 'v3',
301
+ }),
302
+ chat: (modelId: string) => ({
303
+ provider,
304
+ modelId,
305
+ specificationVersion: 'v3',
306
+ }),
307
+ });
308
+ }