@aeye/ai 0.3.9 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +898 -798
  2. package/aeye-ai.md +237 -219
  3. package/dist/ai.d.ts +7 -1
  4. package/dist/ai.d.ts.map +1 -1
  5. package/dist/ai.js +50 -77
  6. package/dist/ai.js.map +1 -1
  7. package/dist/apis/batch.d.ts +74 -0
  8. package/dist/apis/batch.d.ts.map +1 -0
  9. package/dist/apis/batch.js +127 -0
  10. package/dist/apis/batch.js.map +1 -0
  11. package/dist/apis/chat.d.ts.map +1 -1
  12. package/dist/apis/chat.js +55 -35
  13. package/dist/apis/chat.js.map +1 -1
  14. package/dist/apis/decide.d.ts +33 -0
  15. package/dist/apis/decide.d.ts.map +1 -0
  16. package/dist/apis/decide.js +52 -0
  17. package/dist/apis/decide.js.map +1 -0
  18. package/dist/apis/embed.d.ts +4 -11
  19. package/dist/apis/embed.d.ts.map +1 -1
  20. package/dist/apis/embed.js +5 -32
  21. package/dist/apis/embed.js.map +1 -1
  22. package/dist/apis/get.d.ts +18 -0
  23. package/dist/apis/get.d.ts.map +1 -0
  24. package/dist/apis/get.js +30 -0
  25. package/dist/apis/get.js.map +1 -0
  26. package/dist/apis/image.d.ts +2 -2
  27. package/dist/apis/image.d.ts.map +1 -1
  28. package/dist/apis/image.js +45 -40
  29. package/dist/apis/image.js.map +1 -1
  30. package/dist/apis/rerank.d.ts +24 -0
  31. package/dist/apis/rerank.d.ts.map +1 -0
  32. package/dist/apis/rerank.js +40 -0
  33. package/dist/apis/rerank.js.map +1 -0
  34. package/dist/apis/schema-fallback.d.ts +9 -14
  35. package/dist/apis/schema-fallback.d.ts.map +1 -1
  36. package/dist/apis/schema-fallback.js +15 -64
  37. package/dist/apis/schema-fallback.js.map +1 -1
  38. package/dist/common.d.ts +20 -2
  39. package/dist/common.d.ts.map +1 -1
  40. package/dist/common.js +36 -0
  41. package/dist/common.js.map +1 -1
  42. package/dist/index.d.ts +4 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/registry.d.ts +30 -1
  47. package/dist/registry.d.ts.map +1 -1
  48. package/dist/registry.js +61 -1
  49. package/dist/registry.js.map +1 -1
  50. package/dist/types.d.ts +198 -55
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +2 -2
package/README.md CHANGED
@@ -1,798 +1,898 @@
1
- # @aeye/ai
2
-
3
- > **Multi-provider AI library with intelligent model selection, type-safe context management, and comprehensive hooks system.**
4
-
5
- The `@aeye/ai` package is the main AI library built on `@aeye/core`, providing a unified interface for working with multiple AI providers with automatic model selection, cost tracking, and extensible architecture.
6
-
7
- ```ts
8
- import { AI } from '@aeye/ai';
9
- import { OpenAIProvider } from '@aeye/openai';
10
- import z from 'zod';
11
-
12
- const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
13
-
14
- const ai = AI.with<MyContext>()
15
- .providers({ openai })
16
- .create({ /* defaultContext, providedContext, defaultWeights, hooks, ... */ });
17
-
18
- // Low-level APIs
19
- ai.chat.get(request, ctx?) // or .stream(request, ctx?)
20
- ai.image.generate.get(request, ctx?) // or .stream(request, ctx?)
21
- ai.image.edit.get(request, ctx?) // or .stream(request, ctx?)
22
- ai.image.analyze.get(request, ctx?) // or .stream(request, ctx?)
23
- ai.transcribe.get(request, ctx?) // or .stream(request, ctx?)
24
- ai.speech.get(request, ctx?) // or .stream(request, ctx?)
25
- ai.embed.get(request, ctx?)
26
- ai.models.list() // .get(id), .search(criteria), .select(criteria), .refresh()
27
-
28
- // Components bound to this AI instance
29
- const myTool = ai.tool({ name, description, schema, call, ... });
30
- const myPrompt = ai.prompt({ name, description, content, tools, schema, ... });
31
- const myAgent = ai.agent({ name, description, refs, call });
32
-
33
- myTool.run(input, ctx?)
34
- myPrompt.run(input, ctx?) // streaming generator
35
- myPrompt.get('result', input, ctx?) // awaitable result
36
- myPrompt.get('stream', input, ctx?) // stream all events
37
- myPrompt.get('streamContent', input, ctx?) // stream text only
38
- myAgent.run(input, ctx?)
39
- ```
40
-
41
- ## Features
42
-
43
- - **Multi-Provider Support**: Single interface for OpenAI, OpenRouter, Replicate, AWS Bedrock, and custom providers
44
- - **Intelligent Model Selection**: Automatic model selection based on capabilities, cost, speed, and quality
45
- - **Type-Safe Context**: Strongly-typed context and metadata with compiler validation
46
- - **Comprehensive APIs**: Chat, Image Generation/Analysis/Editing, Speech Synthesis, Transcription, Embeddings
47
- - **Lifecycle Hooks**: Intercept and modify operations at every stage
48
- - **Cost Tracking**: Automatic token usage and cost calculation
49
- - **Streaming Support**: Full streaming support across all compatible capabilities
50
- - **Model Registry**: Centralized model management with external sources (OpenRouter, etc.)
51
- - **Extensible**: Custom providers, model handlers, and transformers
52
-
53
- ## Table of Contents
54
-
55
- - [Installation](#installation)
56
- - [Quick Start](#quick-start)
57
- - [Architecture](#architecture)
58
- - [Core Concepts](#core-concepts)
59
- - [API Reference](#api-reference)
60
- - [Chat API](#chat-api)
61
- - [Image API](#image-api)
62
- - [Speech API](#speech-api)
63
- - [Transcribe API](#transcribe-api)
64
- - [Embed API](#embed-api)
65
- - [Models API](#models-api)
66
- - [Tools, Prompts & Agents](#tools-prompts--agents)
67
- - [Advanced Features](#advanced-features)
68
- - [Custom Context & Metadata](#custom-context--metadata)
69
- - [Lifecycle Hooks](#lifecycle-hooks)
70
- - [Model Selection](#model-selection)
71
- - [Model Sources](#model-sources)
72
- - [Custom Providers](#custom-providers)
73
- - [Extending AI Instances](#extending-ai-instances)
74
- - [Examples](#examples)
75
-
76
- ## Installation
77
-
78
- ```bash
79
- npm install @aeye/ai @aeye/core
80
- ```
81
-
82
- You'll also need provider packages:
83
-
84
- ```bash
85
- npm install @aeye/openai openai # OpenAI
86
- npm install @aeye/openrouter # OpenRouter
87
- npm install @aeye/replicate replicate # Replicate
88
- npm install @aeye/aws # AWS Bedrock
89
- ```
90
-
91
- ## Quick Start
92
-
93
- ```typescript
94
- import { AI } from '@aeye/ai';
95
- import { OpenAIProvider } from '@aeye/openai';
96
- import { OpenRouterProvider } from '@aeye/openrouter';
97
-
98
- const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
99
- const openrouter = new OpenRouterProvider({ apiKey: process.env.OPENROUTER_API_KEY! });
100
-
101
- const ai = AI.with()
102
- .providers({ openai, openrouter })
103
- .create();
104
-
105
- // Simple chat completion
106
- const response = await ai.chat.get({
107
- messages: [{ role: 'user', content: 'What is TypeScript?' }]
108
- });
109
- console.log(response.content);
110
-
111
- // Streaming
112
- for await (const chunk of ai.chat.stream({
113
- messages: [{ role: 'user', content: 'Write a poem' }]
114
- })) {
115
- if (chunk.content) {
116
- process.stdout.write(chunk.content);
117
- }
118
- }
119
- ```
120
-
121
- ## Architecture
122
-
123
- ```mermaid
124
- graph TD
125
- AI["<b>AI Class</b><br/>Context Management<br/>Model Registry<br/>Lifecycle Hooks"]
126
- APIs["<b>APIs</b><br/>Chat · Image<br/>Speech · Embed"]
127
- Registry["<b>Registry</b><br/>Models · Search · Select"]
128
- Providers["<b>Providers</b><br/>OpenAI · OpenRouter<br/>Replicate · AWS · Custom"]
129
-
130
- AI --> APIs
131
- AI --> Registry
132
- Registry --> Providers
133
- APIs --> Providers
134
- ```
135
-
136
- ## Core Concepts
137
-
138
- ### Context
139
-
140
- Context is data passed through your entire AI operation:
141
-
142
- - **Default Context**: Static values provided at AI creation via `defaultContext`
143
- - **Provided Context**: Async-loaded values (e.g., from a database) via `providedContext`
144
- - **Required Context**: Values that must be supplied per-call
145
-
146
- ```typescript
147
- interface AppContext {
148
- userId: string;
149
- user?: User; // loaded by providedContext
150
- db?: Database; // loaded by providedContext
151
- }
152
-
153
- const ai = AI.with<AppContext>()
154
- .providers({ openai })
155
- .create({
156
- providedContext: async (ctx) => ({
157
- user: await db.users.findById(ctx.userId),
158
- db,
159
- }),
160
- });
161
-
162
- // At call time only userId is required — the rest is loaded automatically
163
- const response = await ai.chat.get(
164
- { messages: [{ role: 'user', content: 'Hi!' }] },
165
- { userId: 'user123' }
166
- );
167
- ```
168
-
169
- ### Metadata
170
-
171
- Metadata controls model selection and per-request configuration:
172
-
173
- ```typescript
174
- interface AppMetadata {
175
- priority: 'cost' | 'speed' | 'quality';
176
- }
177
-
178
- const ai = AI.with<{}, AppMetadata>()
179
- .providers({ openai, openrouter })
180
- .create({
181
- defaultMetadata: { priority: 'cost' },
182
- });
183
-
184
- // Override per request via context
185
- const response = await ai.chat.get(
186
- { messages: [{ role: 'user', content: 'Hello' }] },
187
- {
188
- metadata: {
189
- model: 'openai/gpt-4o', // pin a specific model
190
- weights: { cost: 0.2, speed: 0.3, accuracy: 0.5 },
191
- priority: 'quality',
192
- },
193
- }
194
- );
195
- ```
196
-
197
- ## API Reference
198
-
199
- ### Chat API
200
-
201
- **`chat.get(request, ctx?)`** — Non-streaming chat completion.
202
-
203
- ```typescript
204
- const response = await ai.chat.get({
205
- messages: [
206
- { role: 'system', content: 'You are a helpful assistant.' },
207
- { role: 'user', content: 'Hello!' },
208
- ],
209
- temperature: 0.7,
210
- maxTokens: 1024,
211
- });
212
-
213
- console.log(response.content);
214
- console.log(response.usage?.text?.input, 'input tokens');
215
- console.log(response.usage?.text?.output, 'output tokens');
216
- console.log(response.usage?.cost, 'cost in dollars');
217
- console.log(response.finishReason); // 'stop' | 'length' | 'tool_calls' | ...
218
- ```
219
-
220
- **`chat.stream(request, ctx?)`** — Streaming chat completion.
221
-
222
- ```typescript
223
- for await (const chunk of ai.chat.stream({
224
- messages: [{ role: 'user', content: 'Count to 10' }]
225
- })) {
226
- if (chunk.content) {
227
- process.stdout.write(chunk.content);
228
- }
229
- if (chunk.finishReason) {
230
- console.log('\nFinished:', chunk.finishReason);
231
- }
232
- }
233
- ```
234
-
235
- **Vision:**
236
-
237
- ```typescript
238
- const response = await ai.chat.get({
239
- messages: [{
240
- role: 'user',
241
- content: [
242
- { type: 'text', content: 'What is in this image?' },
243
- { type: 'image', content: 'https://example.com/image.jpg' },
244
- ],
245
- }],
246
- });
247
- // vision capability is detected automatically from the message content
248
- ```
249
-
250
- ### Image API
251
-
252
- **`image.generate.get(request, ctx?)`** — Generate images from a text prompt.
253
-
254
- ```typescript
255
- const response = await ai.image.generate.get({
256
- prompt: 'A futuristic city at sunset',
257
- n: 2,
258
- size: '1024x1024',
259
- quality: 'high',
260
- });
261
-
262
- for (const image of response.images) {
263
- console.log(image.url ?? image.b64_json);
264
- }
265
- ```
266
-
267
- **`image.edit.get(request, ctx?)`** — Edit an image with inpainting.
268
-
269
- ```typescript
270
- import fs from 'fs';
271
-
272
- const response = await ai.image.edit.get({
273
- prompt: 'Add a sunset in the background',
274
- image: fs.readFileSync('photo.png'),
275
- mask: fs.readFileSync('mask.png'),
276
- size: '1024x1024',
277
- });
278
- ```
279
-
280
- **`image.analyze.get(request, ctx?)`** — Analyze image content (uses a vision chat model).
281
-
282
- ```typescript
283
- const response = await ai.image.analyze.get({
284
- prompt: 'Describe this image in detail',
285
- images: ['https://example.com/photo.jpg'],
286
- });
287
- console.log(response.content);
288
- ```
289
-
290
- ### Speech API
291
-
292
- **`speech.get(request, ctx?)`** — Text-to-speech synthesis.
293
-
294
- ```typescript
295
- import fs from 'fs';
296
- import { Readable } from 'stream';
297
-
298
- const response = await ai.speech.get({
299
- text: 'Hello, this is a text-to-speech example.',
300
- voice: 'alloy',
301
- speed: 1.0,
302
- });
303
-
304
- // Pipe the audio stream to a file
305
- const fileStream = fs.createWriteStream('output.mp3');
306
- Readable.fromWeb(response.audio).pipe(fileStream);
307
- ```
308
-
309
- ### Transcribe API
310
-
311
- **`transcribe.get(request, ctx?)`** — Speech-to-text transcription.
312
-
313
- ```typescript
314
- import fs from 'fs';
315
-
316
- const response = await ai.transcribe.get({
317
- audio: fs.readFileSync('recording.mp3'),
318
- language: 'en',
319
- });
320
-
321
- console.log(response.text);
322
- ```
323
-
324
- ### Embed API
325
-
326
- **`embed.get(request, ctx?)`** — Generate text embeddings.
327
-
328
- ```typescript
329
- const response = await ai.embed.get({
330
- texts: [
331
- 'TypeScript is a typed superset of JavaScript',
332
- 'Python is a high-level programming language',
333
- ],
334
- });
335
-
336
- response.embeddings.forEach(({ embedding, index }) => {
337
- console.log(`Text ${index}: ${embedding.length} dimensions`);
338
- });
339
- ```
340
-
341
- ### Models API
342
-
343
- ```typescript
344
- // List all registered models
345
- const models = ai.models.list();
346
-
347
- // Get a specific model
348
- const model = ai.models.get('gpt-4o');
349
- if (model) {
350
- console.log('Context window:', model.contextWindow);
351
- console.log('Capabilities:', Array.from(model.capabilities).join(', '));
352
- console.log('Input cost:', model.pricing.text?.input, 'per 1M tokens');
353
- }
354
-
355
- // Search with selection criteria
356
- const results = ai.models.search({
357
- capabilities: new Set(['chat', 'structured']),
358
- weights: { cost: 0.6, speed: 0.4 },
359
- providers: { allow: ['openai', 'openrouter'] },
360
- contextWindow: { min: 100000 },
361
- });
362
- console.log('Best match:', results[0]?.model.id);
363
-
364
- // Refresh model list from all sources
365
- await ai.models.refresh();
366
- ```
367
-
368
- ## Tools, Prompts & Agents
369
-
370
- Use `ai.tool()`, `ai.prompt()`, and `ai.agent()` to create components that are automatically wired to the AI instance (executor, streamer, and context are injected automatically).
371
-
372
- ### Tool
373
-
374
- ```typescript
375
- import z from 'zod';
376
-
377
- const searchKnowledge = ai.tool({
378
- name: 'searchKnowledge',
379
- description: 'Search the knowledge base for relevant information',
380
- instructions: 'Use this to find information relevant to the query: "{{query}}"',
381
- schema: z.object({
382
- query: z.string().describe('Search query'),
383
- limit: z.number().int().min(1).max(20).optional().describe('Max results (default: 5)'),
384
- }),
385
- call: async ({ query, limit = 5 }, _refs, ctx) => {
386
- // ctx is the full AI context including your custom fields
387
- return { results: await vectorSearch(query, limit) };
388
- },
389
- });
390
- ```
391
-
392
- ### Prompt
393
-
394
- ```typescript
395
- const knowledgeAssistant = ai.prompt({
396
- name: 'knowledgeAssistant',
397
- description: 'Answers questions using the knowledge base',
398
- content: `You are a helpful assistant. Use searchKnowledge to find information, then answer the question.
399
-
400
- Question: {{question}}`,
401
- input: (input: { question: string }) => ({ question: input.question }),
402
- tools: [searchKnowledge],
403
- schema: z.object({
404
- answer: z.string(),
405
- sources: z.array(z.string()),
406
- }),
407
- });
408
-
409
- // Get structured result
410
- const result = await knowledgeAssistant.get('result', { question: 'How does X work?' });
411
- console.log(result?.answer);
412
-
413
- // Stream text
414
- for await (const text of knowledgeAssistant.get('streamContent', { question: 'How does X work?' })) {
415
- process.stdout.write(text);
416
- }
417
- ```
418
-
419
- ### Agent
420
-
421
- ```typescript
422
- const chatAgent = ai.agent({
423
- name: 'chatAgent',
424
- description: 'Chat with the user and analyze the response quality',
425
- refs: [searchKnowledge, knowledgeAssistant] as const,
426
- call: async (
427
- input: { userId: string; question: string },
428
- [search, assistant],
429
- ctx
430
- ) => {
431
- // Step 1: Search for relevant context
432
- const { results } = await search.run({ query: input.question }, ctx);
433
-
434
- // Step 2: Generate answer with knowledge context
435
- const answer = await assistant.get(
436
- 'result',
437
- { question: input.question },
438
- { ...ctx, messages: [] }
439
- );
440
-
441
- return {
442
- userId: input.userId,
443
- question: input.question,
444
- answer: answer?.answer,
445
- sources: answer?.sources ?? [],
446
- contextUsed: results.length,
447
- };
448
- },
449
- });
450
-
451
- const result = await chatAgent.run({ userId: 'user1', question: 'What is aeye?' });
452
- console.log(result.answer);
453
- ```
454
-
455
- ## Advanced Features
456
-
457
- ### Custom Context & Metadata
458
-
459
- ```typescript
460
- interface AppContext {
461
- userId: string;
462
- user?: User;
463
- db?: Database;
464
- }
465
-
466
- interface AppMetadata {
467
- feature: 'chat' | 'analysis';
468
- priority: 'low' | 'normal' | 'high';
469
- }
470
-
471
- const ai = AI.with<AppContext, AppMetadata>()
472
- .providers({ openai, openrouter })
473
- .create({
474
- providedContext: async (ctx) => {
475
- const user = await db.users.findById(ctx.userId);
476
- return { user, db };
477
- },
478
- defaultMetadata: {
479
- priority: 'normal',
480
- },
481
- });
482
-
483
- // At call time only userId is required
484
- const response = await ai.chat.get(
485
- { messages: [{ role: 'user', content: 'Hello' }] },
486
- { userId: '123', metadata: { feature: 'chat' } }
487
- );
488
- ```
489
-
490
- ### Lifecycle Hooks
491
-
492
- ```typescript
493
- const ai = AI.with<AppContext>()
494
- .providers({ openai })
495
- .create({ /* ... */ })
496
- .withHooks({
497
- beforeModelSelection: async (ctx, request, metadata) => {
498
- // Adjust weights based on context (return modified metadata)
499
- if (ctx.user?.tier === 'free') {
500
- return { ...metadata, weights: { cost: 1.0 } };
501
- }
502
- return metadata;
503
- },
504
-
505
- onModelSelected: async (ctx, request, selected) => {
506
- console.log(`Selected: ${selected.model.id} (score: ${selected.score.toFixed(3)})`);
507
- // Return a modified selected model to override, or undefined to accept
508
- },
509
-
510
- beforeRequest: async (ctx, request, selected, estimatedUsage, estimatedCost) => {
511
- // Throw to cancel the request
512
- if (ctx.user && estimatedCost > ctx.user.budgetRemaining) {
513
- throw new Error('Insufficient budget');
514
- }
515
- console.log(
516
- `[${ctx.userId}] ${selected.model.id} — ` +
517
- `~${estimatedUsage.text?.input ?? 0} input tokens, ~$${estimatedCost.toFixed(5)}`
518
- );
519
- },
520
-
521
- afterRequest: async (ctx, request, response, responseComplete, selected, usage, cost) => {
522
- // Record actual usage
523
- if (ctx.user) {
524
- ctx.user.budgetRemaining -= cost;
525
- ctx.user.totalSpent += cost;
526
- await ctx.user.save();
527
- }
528
- console.log(
529
- `[${ctx.userId}] ${usage.text?.input ?? 0} in / ${usage.text?.output ?? 0} out, ` +
530
- `cost: $${cost.toFixed(5)}`
531
- );
532
- },
533
-
534
- onError: (errorType, message, error, ctx) => {
535
- console.error(`[AI ${errorType}] ${message}`, { error, userId: ctx?.userId });
536
- },
537
- });
538
- ```
539
-
540
- ### Model Selection
541
-
542
- ```typescript
543
- // Cost-optimized
544
- await ai.chat.get(
545
- { messages },
546
- { metadata: { weights: { cost: 0.9, speed: 0.05, accuracy: 0.05 } } }
547
- );
548
-
549
- // Quality-optimized with large context
550
- await ai.chat.get(
551
- { messages },
552
- {
553
- metadata: {
554
- weights: { cost: 0.1, speed: 0.4, accuracy: 0.5 },
555
- contextWindow: { min: 128000 },
556
- }
557
- }
558
- );
559
-
560
- // Provider-specific
561
- await ai.chat.get(
562
- { messages },
563
- { metadata: { providers: { allow: ['openai'], deny: ['replicate'] } } }
564
- );
565
-
566
- // Explicit model override
567
- await ai.chat.get(
568
- { messages },
569
- { metadata: { model: 'openai/gpt-4o' } }
570
- );
571
- ```
572
-
573
- ### Named Weight Profiles
574
-
575
- ```typescript
576
- const ai = AI.with()
577
- .providers({ openai, openrouter })
578
- .create({
579
- weightProfiles: {
580
- costPriority: { cost: 0.9, speed: 0.1 },
581
- balanced: { cost: 0.4, speed: 0.3, accuracy: 0.3 },
582
- performance: { cost: 0.1, speed: 0.4, accuracy: 0.5 },
583
- },
584
- });
585
-
586
- await ai.chat.get(
587
- { messages },
588
- { metadata: { weightProfile: 'balanced' } }
589
- );
590
- ```
591
-
592
- ### Model Sources
593
-
594
- ```typescript
595
- import { OpenRouterModelSource } from '@aeye/openrouter';
596
-
597
- const source = new OpenRouterModelSource({ apiKey: process.env.OPENROUTER_API_KEY });
598
-
599
- const ai = AI.with()
600
- .providers({ openai, openrouter })
601
- .create({
602
- modelSources: [source],
603
- });
604
-
605
- // Fetch and register all models from sources
606
- await ai.models.refresh();
607
- ```
608
-
609
- ### Model Overrides
610
-
611
- ```typescript
612
- const ai = AI.with()
613
- .providers({ openai })
614
- .create({
615
- modelOverrides: [
616
- {
617
- modelPattern: /gpt-4/,
618
- overrides: {
619
- pricing: { text: { input: 30, output: 60 } },
620
- },
621
- },
622
- ],
623
- });
624
- ```
625
-
626
- ### Strict Mode
627
-
628
- Tool and Prompt schemas can ride the LLM's grammar-constrained strict mode for guaranteed type-safe output. Mechanics — descriptors, dialects, schema rewriting — live in [`@aeye/core`](../core/README.md#strict-mode). What `@aeye/ai` adds: model selection respects the `strict` flag (selection filters to strict-capable models when `strict: true`, scores them higher when `strict: <number>`).
629
-
630
- Splat the curated `strictSupport` overrides from `@aeye/models` so model selection knows which models actually support strict — without it, every model defaults to lenient even when the underlying API supports strict:
631
-
632
- ```typescript
633
- import { models, strictSupport } from '@aeye/models';
634
-
635
- const ai = AI.with()
636
- .providers({ openai })
637
- .create({
638
- models,
639
- modelOverrides: [...strictSupport],
640
- });
641
- ```
642
-
643
- `strictSupport` is a `ModelOverride[]` you can combine with your own overrides:
644
-
645
- ```typescript
646
- modelOverrides: [
647
- ...strictSupport,
648
- { modelPattern: /gpt-4/, overrides: { tier: 'flagship' } },
649
- ],
650
- ```
651
-
652
- ### Custom Providers
653
-
654
- Extend an existing provider or implement the `Provider<TConfig>` interface from `@aeye/core`:
655
-
656
- ```typescript
657
- import { OpenAIProvider, OpenAIConfig } from '@aeye/openai';
658
- import OpenAI from 'openai';
659
-
660
- class MyProvider extends OpenAIProvider {
661
- readonly name = 'my-provider';
662
-
663
- protected createClient(config: OpenAIConfig) {
664
- return new OpenAI({
665
- apiKey: config.apiKey,
666
- baseURL: 'https://my-api.example.com/v1',
667
- });
668
- }
669
- }
670
- ```
671
-
672
- ### Extending AI Instances
673
-
674
- Share providers and configuration between multiple AI instances:
675
-
676
- ```typescript
677
- const baseAI = AI.with<BaseContext>()
678
- .providers({ openai, openrouter })
679
- .create({ /* base config */ });
680
-
681
- // Extend with additional context for a specific feature
682
- interface ChatFeatureContext extends BaseContext {
683
- chatId: string;
684
- chatHistory: Message[];
685
- }
686
-
687
- const chatAI = baseAI.extend<ChatFeatureContext>({
688
- defaultContext: { chatHistory: [] },
689
- });
690
-
691
- const response = await chatAI.chat.get(
692
- { messages: [{ role: 'user', content: 'Hello' }] },
693
- { chatId: 'chat-123', chatHistory: [] }
694
- );
695
- ```
696
-
697
- ## Examples
698
-
699
- ### Complete Application
700
-
701
- ```typescript
702
- import { AI } from '@aeye/ai';
703
- import { OpenAIProvider } from '@aeye/openai';
704
- import { OpenRouterProvider } from '@aeye/openrouter';
705
- import z from 'zod';
706
-
707
- interface User {
708
- id: string;
709
- budgetRemaining: number;
710
- totalSpent: number;
711
- save: () => Promise<void>;
712
- }
713
-
714
- interface AppContext {
715
- userId: string;
716
- user?: User;
717
- }
718
-
719
- const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
720
- const openrouter = new OpenRouterProvider({ apiKey: process.env.OPENROUTER_API_KEY! });
721
-
722
- export const ai = AI.with<AppContext>()
723
- .providers({ openai, openrouter })
724
- .create({
725
- providedContext: async (ctx) => ({
726
- user: await getUser(ctx.userId),
727
- }),
728
- defaultWeights: { cost: 0.5, speed: 0.3, accuracy: 0.2 },
729
- hooks: {
730
- beforeRequest: async (ctx, _request, selected, estimatedUsage, estimatedCost) => {
731
- if (ctx.user && estimatedCost > ctx.user.budgetRemaining) {
732
- throw new Error(
733
- `Estimated cost $${estimatedCost.toFixed(4)} exceeds budget ` +
734
- `$${ctx.user.budgetRemaining.toFixed(4)}`
735
- );
736
- }
737
- },
738
- afterRequest: async (ctx, _request, _response, _complete, _selected, usage, cost) => {
739
- if (ctx.user) {
740
- ctx.user.budgetRemaining -= cost;
741
- ctx.user.totalSpent += cost;
742
- await ctx.user.save();
743
- }
744
- },
745
- onError: (errorType, message, error, ctx) => {
746
- console.error(`[AI ${errorType}] ${message}`, { userId: ctx?.userId, error });
747
- },
748
- },
749
- });
750
-
751
- // Define a tool
752
- const webSearch = ai.tool({
753
- name: 'webSearch',
754
- description: 'Search the web for recent information',
755
- schema: z.object({ query: z.string() }),
756
- call: async ({ query }) => {
757
- // Integrate with a search API
758
- return { results: [`Result for: ${query}`] };
759
- },
760
- });
761
-
762
- // Define a research prompt
763
- const researcher = ai.prompt({
764
- name: 'researcher',
765
- description: 'Researches a topic and provides a structured summary',
766
- content: `You are a research assistant. Research the following topic and provide a clear summary.
767
-
768
- Topic: {{topic}}`,
769
- input: (input: { topic: string }) => ({ topic: input.topic }),
770
- tools: [webSearch],
771
- schema: z.object({
772
- summary: z.string(),
773
- keyFindings: z.array(z.string()),
774
- sources: z.array(z.string()),
775
- }),
776
- });
777
-
778
- // Use the prompt
779
- async function researchTopic(userId: string, topic: string) {
780
- const result = await researcher.get('result', { topic }, { userId });
781
- return result;
782
- }
783
-
784
- const findings = await researchTopic('user123', 'TypeScript 5.0 features');
785
- console.log(findings?.summary);
786
- console.log(findings?.keyFindings);
787
- ```
788
-
789
- ## License
790
-
791
- GPL-3.0 © [ClickerMonkey](https://github.com/ClickerMonkey)
792
-
793
- ## Links
794
-
795
- - [Root README](../../README.md)
796
- - [@aeye/core README](../core/README.md)
797
- - [GitHub Repository](https://github.com/ClickerMonkey/aeye)
798
- - [Issue Tracker](https://github.com/ClickerMonkey/aeye/issues)
1
+ # @aeye/ai
2
+
3
+ > **Multi-provider AI library with intelligent model selection, type-safe context management, and comprehensive hooks system.**
4
+
5
+ The `@aeye/ai` package is the main AI library built on `@aeye/core`, providing a unified interface for working with multiple AI providers with automatic model selection, cost tracking, and extensible architecture.
6
+
7
+ ```ts
8
+ import { AI } from '@aeye/ai';
9
+ import { OpenAIProvider } from '@aeye/openai';
10
+ import z from 'zod';
11
+
12
+ const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
13
+
14
+ const ai = AI.with<MyContext>()
15
+ .providers({ openai })
16
+ .create({ /* defaultContext, providedContext, defaultWeights, ... */ })
17
+ .withHooks({ /* beforeRequest, afterRequest, afterBatch, onError, ... */ });
18
+
19
+ // Low-level APIs
20
+ ai.chat.get(request, ctx?) // or .stream(request, ctx?)
21
+ ai.image.generate.get(request, ctx?) // or .stream(request, ctx?)
22
+ ai.image.edit.get(request, ctx?) // or .stream(request, ctx?)
23
+ ai.image.analyze.get(request, ctx?) // or .stream(request, ctx?)
24
+ ai.transcribe.get(request, ctx?) // or .stream(request, ctx?)
25
+ ai.speech.get(request, ctx?) // or .stream(request, ctx?)
26
+ ai.embed.get(request, ctx?)
27
+ ai.rerank.get(request, ctx?) // order documents by relevance
28
+ ai.decide.get(request, ctx?) // typed answers from decision models
29
+ ai.batch.submit(requests, ctx) // .get(job, ctx), .wait(job, ctx, options?)
30
+ ai.models.list() // .get(id), .search(criteria), .select(criteria), .refresh()
31
+
32
+ // Components bound to this AI instance
33
+ const myTool = ai.tool({ name, description, schema, call, ... });
34
+ const myPrompt = ai.prompt({ name, description, content, tools, schema, ... });
35
+ const myAgent = ai.agent({ name, description, refs, call });
36
+
37
+ myTool.run(input, ctx?)
38
+ myPrompt.run(input, ctx?) // streaming generator
39
+ myPrompt.get('result', input, ctx?) // awaitable result
40
+ myPrompt.get('stream', input, ctx?) // stream all events
41
+ myPrompt.get('streamContent', input, ctx?) // stream text only
42
+ myAgent.run(input, ctx?)
43
+ ```
44
+
45
+ ## Features
46
+
47
+ - **Multi-Provider Support**: Single interface for OpenAI, OpenRouter, Replicate, AWS Bedrock, and custom providers
48
+ - **Intelligent Model Selection**: Automatic model selection based on capabilities, cost, speed, and quality
49
+ - **Type-Safe Context**: Strongly-typed context and metadata with compiler validation
50
+ - **Comprehensive APIs**: Chat, Image Generation/Analysis/Editing, Speech Synthesis, Transcription, Embeddings, Reranking, Decisions, Batches
51
+ - **Lifecycle Hooks**: Intercept and modify operations at every stage
52
+ - **Cost Tracking**: Automatic token usage and cost calculation
53
+ - **Streaming Support**: Full streaming support across all compatible capabilities
54
+ - **Model Registry**: Centralized model management with external sources (OpenRouter, etc.)
55
+ - **Extensible**: Custom providers, model handlers, and transformers
56
+
57
+ ## Table of Contents
58
+
59
+ - [Installation](#installation)
60
+ - [Quick Start](#quick-start)
61
+ - [Architecture](#architecture)
62
+ - [Core Concepts](#core-concepts)
63
+ - [API Reference](#api-reference)
64
+ - [Chat API](#chat-api)
65
+ - [Image API](#image-api)
66
+ - [Speech API](#speech-api)
67
+ - [Transcribe API](#transcribe-api)
68
+ - [Embed API](#embed-api)
69
+ - [Rerank API](#rerank-api)
70
+ - [Decide API](#decide-api)
71
+ - [Batch API](#batch-api)
72
+ - [Models API](#models-api)
73
+ - [Tools, Prompts & Agents](#tools-prompts--agents)
74
+ - [Advanced Features](#advanced-features)
75
+ - [Custom Context & Metadata](#custom-context--metadata)
76
+ - [Lifecycle Hooks](#lifecycle-hooks)
77
+ - [Model Selection](#model-selection)
78
+ - [Model Sources](#model-sources)
79
+ - [Custom Providers](#custom-providers)
80
+ - [Extending AI Instances](#extending-ai-instances)
81
+ - [Examples](#examples)
82
+
83
+ ## Installation
84
+
85
+ ```bash
86
+ npm install @aeye/ai @aeye/core
87
+ ```
88
+
89
+ You'll also need provider packages:
90
+
91
+ ```bash
92
+ npm install @aeye/openai openai # OpenAI
93
+ npm install @aeye/openrouter # OpenRouter
94
+ npm install @aeye/replicate replicate # Replicate
95
+ npm install @aeye/aws # AWS Bedrock
96
+ ```
97
+
98
+ ## Quick Start
99
+
100
+ ```typescript
101
+ import { AI } from '@aeye/ai';
102
+ import { OpenAIProvider } from '@aeye/openai';
103
+ import { OpenRouterProvider } from '@aeye/openrouter';
104
+
105
+ const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
106
+ const openrouter = new OpenRouterProvider({ apiKey: process.env.OPENROUTER_API_KEY! });
107
+
108
+ const ai = AI.with()
109
+ .providers({ openai, openrouter })
110
+ .create();
111
+
112
+ // Simple chat completion
113
+ const response = await ai.chat.get({
114
+ messages: [{ role: 'user', content: 'What is TypeScript?' }]
115
+ });
116
+ console.log(response.content);
117
+
118
+ // Streaming
119
+ for await (const chunk of ai.chat.stream({
120
+ messages: [{ role: 'user', content: 'Write a poem' }]
121
+ })) {
122
+ if (chunk.content) {
123
+ process.stdout.write(chunk.content);
124
+ }
125
+ }
126
+ ```
127
+
128
+ ## Architecture
129
+
130
+ ```mermaid
131
+ graph TD
132
+ AI["<b>AI Class</b><br/>Context Management<br/>Model Registry<br/>Lifecycle Hooks"]
133
+ APIs["<b>APIs</b><br/>Chat · Image<br/>Speech · Embed"]
134
+ Registry["<b>Registry</b><br/>Models · Search · Select"]
135
+ Providers["<b>Providers</b><br/>OpenAI · OpenRouter<br/>Replicate · AWS · Custom"]
136
+
137
+ AI --> APIs
138
+ AI --> Registry
139
+ Registry --> Providers
140
+ APIs --> Providers
141
+ ```
142
+
143
+ ## Core Concepts
144
+
145
+ ### Context
146
+
147
+ Context is data passed through your entire AI operation:
148
+
149
+ - **Default Context**: Static values provided at AI creation via `defaultContext`
150
+ - **Provided Context**: Async-loaded values (e.g., from a database) via `providedContext`
151
+ - **Required Context**: Values that must be supplied per-call
152
+
153
+ ```typescript
154
+ interface AppContext {
155
+ userId: string;
156
+ user?: User; // loaded by providedContext
157
+ db?: Database; // loaded by providedContext
158
+ }
159
+
160
+ const ai = AI.with<AppContext>()
161
+ .providers({ openai })
162
+ .create({
163
+ providedContext: async (ctx) => ({
164
+ user: await db.users.findById(ctx.userId),
165
+ db,
166
+ }),
167
+ });
168
+
169
+ // At call time only userId is required — the rest is loaded automatically
170
+ const response = await ai.chat.get(
171
+ { messages: [{ role: 'user', content: 'Hi!' }] },
172
+ { userId: 'user123' }
173
+ );
174
+ ```
175
+
176
+ ### Metadata
177
+
178
+ Metadata controls model selection and per-request configuration:
179
+
180
+ ```typescript
181
+ interface AppMetadata {
182
+ priority: 'cost' | 'speed' | 'quality';
183
+ }
184
+
185
+ const ai = AI.with<{}, AppMetadata>()
186
+ .providers({ openai, openrouter })
187
+ .create({
188
+ defaultMetadata: { priority: 'cost' },
189
+ });
190
+
191
+ // Override per request via context
192
+ const response = await ai.chat.get(
193
+ { messages: [{ role: 'user', content: 'Hello' }] },
194
+ {
195
+ metadata: {
196
+ model: 'openai/gpt-4o', // pin a specific model
197
+ weights: { cost: 0.2, speed: 0.3, accuracy: 0.5 },
198
+ priority: 'quality',
199
+ },
200
+ }
201
+ );
202
+ ```
203
+
204
+ ## API Reference
205
+
206
+ ### Chat API
207
+
208
+ **`chat.get(request, ctx?)`** — Non-streaming chat completion.
209
+
210
+ ```typescript
211
+ const response = await ai.chat.get({
212
+ messages: [
213
+ { role: 'system', content: 'You are a helpful assistant.' },
214
+ { role: 'user', content: 'Hello!' },
215
+ ],
216
+ temperature: 0.7,
217
+ maxTokens: 1024,
218
+ });
219
+
220
+ console.log(response.content);
221
+ console.log(response.usage?.text?.input, 'input tokens');
222
+ console.log(response.usage?.text?.output, 'output tokens');
223
+ console.log(response.usage?.cost, 'cost in dollars');
224
+ console.log(response.finishReason); // 'stop' | 'length' | 'tool_calls' | ...
225
+ ```
226
+
227
+ **`chat.stream(request, ctx?)`** — Streaming chat completion.
228
+
229
+ ```typescript
230
+ for await (const chunk of ai.chat.stream({
231
+ messages: [{ role: 'user', content: 'Count to 10' }]
232
+ })) {
233
+ if (chunk.content) {
234
+ process.stdout.write(chunk.content);
235
+ }
236
+ if (chunk.finishReason) {
237
+ console.log('\nFinished:', chunk.finishReason);
238
+ }
239
+ }
240
+ ```
241
+
242
+ **Vision:**
243
+
244
+ ```typescript
245
+ const response = await ai.chat.get({
246
+ messages: [{
247
+ role: 'user',
248
+ content: [
249
+ { type: 'text', content: 'What is in this image?' },
250
+ { type: 'image', content: 'https://example.com/image.jpg' },
251
+ ],
252
+ }],
253
+ });
254
+ // vision capability is detected automatically from the message content
255
+ ```
256
+
257
+ **Reasoning:**
258
+
259
+ ```typescript
260
+ const response = await ai.chat.get({
261
+ messages: [{ role: 'user', content: 'Solve step by step: 15! / 13!' }],
262
+ reason: { effort: 'low' },
263
+ });
264
+ console.log(response.reasoning?.content);
265
+ ```
266
+
267
+ The effort is clamped to the nearest one the model accepts (`clampReasoningEffort`), and reasoning asked for stays on: `none` is only sent when asked for or when nothing else is accepted. Keep the assistant message's `reasoning` in the conversation: each provider replays only the reasoning it produced (`Reasoning.provider`) or that names none, so a conversation can move between providers. See the [reasoning guide](../docs/guides/reasoning.md).
268
+
269
+ ### Image API
270
+
271
+ **`image.generate.get(request, ctx?)`** — Generate images from a text prompt.
272
+
273
+ ```typescript
274
+ const response = await ai.image.generate.get({
275
+ prompt: 'A futuristic city at sunset',
276
+ n: 2,
277
+ size: '1024x1024',
278
+ quality: 'high',
279
+ });
280
+
281
+ for (const image of response.images) {
282
+ console.log(image.url ?? image.b64_json);
283
+ }
284
+ ```
285
+
286
+ **`image.edit.get(request, ctx?)`** — Edit an image with inpainting.
287
+
288
+ ```typescript
289
+ import fs from 'fs';
290
+
291
+ const response = await ai.image.edit.get({
292
+ prompt: 'Add a sunset in the background',
293
+ image: fs.readFileSync('photo.png'),
294
+ mask: fs.readFileSync('mask.png'),
295
+ size: '1024x1024',
296
+ });
297
+ ```
298
+
299
+ **`image.analyze.get(request, ctx?)`** — Analyze image content (uses a vision chat model).
300
+
301
+ ```typescript
302
+ const response = await ai.image.analyze.get({
303
+ prompt: 'Describe this image in detail',
304
+ images: ['https://example.com/photo.jpg'],
305
+ });
306
+ console.log(response.content);
307
+ ```
308
+
309
+ ### Speech API
310
+
311
+ **`speech.get(request, ctx?)`** — Text-to-speech synthesis.
312
+
313
+ ```typescript
314
+ import fs from 'fs';
315
+ import { Readable } from 'stream';
316
+
317
+ const response = await ai.speech.get({
318
+ text: 'Hello, this is a text-to-speech example.',
319
+ voice: 'alloy',
320
+ speed: 1.0,
321
+ });
322
+
323
+ // Pipe the audio stream to a file
324
+ const fileStream = fs.createWriteStream('output.mp3');
325
+ Readable.fromWeb(response.audio).pipe(fileStream);
326
+ ```
327
+
328
+ ### Transcribe API
329
+
330
+ **`transcribe.get(request, ctx?)`** — Speech-to-text transcription.
331
+
332
+ ```typescript
333
+ import fs from 'fs';
334
+
335
+ const response = await ai.transcribe.get({
336
+ audio: fs.readFileSync('recording.mp3'),
337
+ language: 'en',
338
+ });
339
+
340
+ console.log(response.text);
341
+ ```
342
+
343
+ ### Embed API
344
+
345
+ **`embed.get(request, ctx?)`** — Generate text embeddings.
346
+
347
+ ```typescript
348
+ const response = await ai.embed.get({
349
+ texts: [
350
+ 'TypeScript is a typed superset of JavaScript',
351
+ 'Python is a high-level programming language',
352
+ ],
353
+ });
354
+
355
+ response.embeddings.forEach(({ embedding, index }) => {
356
+ console.log(`Text ${index}: ${embedding.length} dimensions`);
357
+ });
358
+ ```
359
+
360
+ ### Rerank API
361
+
362
+ **`rerank.get(request, ctx?)`** — Order documents by relevance to a query. Needs a model with the `rerank` capability (OpenRouter: Cohere, Voyage, Qwen, Nemotron).
363
+
364
+ ```typescript
365
+ const documents = ['Paris is the capital of France.', 'Berlin is the capital of Germany.'];
366
+ const { results } = await ai.rerank.get({ query: 'capital of France', documents, topN: 1 });
367
+
368
+ console.log(documents[results[0].index], results[0].score);
369
+ ```
370
+
371
+ ### Decide API
372
+
373
+ **`decide.get(request, ctx?)`** — Typed answers about text or a conversation from a decision model (`decision` capability; OpenAI `gpt-6-luna`, OpenRouter System One models). Questions are `predicate` (probability), `choice` (one of a set), or `score` (ordered levels). Much faster and cheaper than structured output for classification and routing.
374
+
375
+ ```typescript
376
+ const { answers } = await ai.decide.get({
377
+ input: 'I was charged twice for my order.',
378
+ questions: {
379
+ billing: { type: 'predicate', instructions: 'Is this a billing problem?' },
380
+ team: { type: 'choice', instructions: 'Which team handles it?', choices: { payments: 'Charges and refunds', web: 'Website bugs' } },
381
+ urgency: { type: 'score', instructions: 'How urgent is it?', levels: ['Can wait', 'This week', 'Now'] },
382
+ },
383
+ });
384
+
385
+ const team = answers.team;
386
+ if (team.type === 'choice') {
387
+ console.log(team.choice, team.confidence);
388
+ }
389
+ ```
390
+
391
+ ### Batch API
392
+
393
+ **`batch.submit(requests, ctx)`** — Run chat requests as one provider batch (OpenAI, OpenRouter): about half the price, with results within a day. `batch.get(job, ctx)` checks it; `batch.wait(job, ctx, { interval?, signal? })` waits for it to finish. Results come back in request order, and a failed request carries `error`. `submit` runs the `onModelSelected` hook once and `beforeRequest` for each request at the batch price estimate (so a budget check can stop the batch), and a completed batch is reported once to `afterBatch` with its usage and cost.
394
+
395
+ ```typescript
396
+ const job = await ai.batch.submit(
397
+ reviews.map(review => ({ messages: [{ role: 'user', content: `Classify: ${review}` }] })),
398
+ {},
399
+ );
400
+
401
+ const done = await ai.batch.wait(job, {});
402
+ for (const result of done.results ?? []) {
403
+ console.log(result.response?.content ?? result.error);
404
+ }
405
+ ```
406
+
407
+ ### Models API
408
+
409
+ ```typescript
410
+ // List all registered models
411
+ const models = ai.models.list();
412
+
413
+ // Get a specific model
414
+ const model = ai.models.get('gpt-4o');
415
+ if (model) {
416
+ console.log('Context window:', model.contextWindow);
417
+ console.log('Capabilities:', Array.from(model.capabilities).join(', '));
418
+ console.log('Input cost:', model.pricing.text?.input, 'per 1M tokens');
419
+ }
420
+
421
+ // Search with selection criteria
422
+ const results = ai.models.search({
423
+ capabilities: new Set(['chat', 'structured']),
424
+ weights: { cost: 0.6, speed: 0.4 },
425
+ providers: { allow: ['openai', 'openrouter'] },
426
+ contextWindow: { min: 100000 },
427
+ });
428
+ console.log('Best match:', results[0]?.model.id);
429
+
430
+ // Refresh model list from all sources
431
+ await ai.models.refresh();
432
+ ```
433
+
434
+ ## Tools, Prompts & Agents
435
+
436
+ Use `ai.tool()`, `ai.prompt()`, and `ai.agent()` to create components that are automatically wired to the AI instance (executor, streamer, and context are injected automatically).
437
+
438
+ ### Tool
439
+
440
+ ```typescript
441
+ import z from 'zod';
442
+
443
+ const searchKnowledge = ai.tool({
444
+ name: 'searchKnowledge',
445
+ description: 'Search the knowledge base for relevant information',
446
+ instructions: 'Use this to find information relevant to the query: "{{query}}"',
447
+ schema: z.object({
448
+ query: z.string().describe('Search query'),
449
+ limit: z.number().int().min(1).max(20).optional().describe('Max results (default: 5)'),
450
+ }),
451
+ call: async ({ query, limit = 5 }, _refs, ctx) => {
452
+ // ctx is the full AI context including your custom fields
453
+ return { results: await vectorSearch(query, limit) };
454
+ },
455
+ });
456
+ ```
457
+
458
+ ### Prompt
459
+
460
+ ```typescript
461
+ const knowledgeAssistant = ai.prompt({
462
+ name: 'knowledgeAssistant',
463
+ description: 'Answers questions using the knowledge base',
464
+ content: `You are a helpful assistant. Use searchKnowledge to find information, then answer the question.
465
+
466
+ Question: {{question}}`,
467
+ input: (input: { question: string }) => ({ question: input.question }),
468
+ tools: [searchKnowledge],
469
+ schema: z.object({
470
+ answer: z.string(),
471
+ sources: z.array(z.string()),
472
+ }),
473
+ });
474
+
475
+ // Get structured result
476
+ const result = await knowledgeAssistant.get('result', { question: 'How does X work?' });
477
+ console.log(result?.answer);
478
+
479
+ // Stream text
480
+ for await (const text of knowledgeAssistant.get('streamContent', { question: 'How does X work?' })) {
481
+ process.stdout.write(text);
482
+ }
483
+ ```
484
+
485
+ A prompt can `review` its valid output, for example with another prompt, and send feedback for another round (up to `reviewRetries`):
486
+
487
+ ```typescript
488
+ const checked = ai.prompt({
489
+ name: 'checked',
490
+ description: 'Answers, then checks the answer cites a source',
491
+ content: 'Answer with a source: {{question}}',
492
+ input: (input: { question: string }) => input,
493
+ schema: z.object({ answer: z.string(), sources: z.array(z.string()) }),
494
+ review: ({ sources }) => sources.length ? { pass: true } : { feedback: 'Cite at least one source.' },
495
+ });
496
+ ```
497
+
498
+ When a provider rejects a schema, the prompt steps down its schema fallbacks (non-strict, then JSON mode, then text) instead of failing. The chat API sends a `'json'` or `'prompt'` schema as JSON mode or prompt text, and picks a model with the `json` capability rather than `structured` for it. Set the steps AI-wide through the default context:
499
+
500
+ ```typescript
501
+ import { SCHEMA_FALLBACKS, type SchemaFallback } from '@aeye/core';
502
+
503
+ const ai = AI.with<{ schemaFallbacks?: readonly SchemaFallback[] }>()
504
+ .providers({ openai })
505
+ .create({ defaultContext: { schemaFallbacks: SCHEMA_FALLBACKS.filter(step => step.name !== 'json') } });
506
+ ```
507
+
508
+ Run without a conversation, a prompt stays the system message and adds a user turn (`userTurn` on the prompt or context, default `'Begin.'`), since providers need one.
509
+
510
+ The prompt's callbacks (`validate`, `review`, `onToolIntercept`, and the rest) receive the full AI context.
511
+
512
+ Tool errors and thrown prompt errors carry `kind: 'model' | 'tool'`, telling a model's mistake from a tool's own failure; see the core README.
513
+
514
+ ### Agent
515
+
516
+ ```typescript
517
+ const chatAgent = ai.agent({
518
+ name: 'chatAgent',
519
+ description: 'Chat with the user and analyze the response quality',
520
+ refs: [searchKnowledge, knowledgeAssistant] as const,
521
+ call: async (
522
+ input: { userId: string; question: string },
523
+ [search, assistant],
524
+ ctx
525
+ ) => {
526
+ // Step 1: Search for relevant context
527
+ const { results } = await search.run({ query: input.question }, ctx);
528
+
529
+ // Step 2: Generate answer with knowledge context
530
+ const answer = await assistant.get(
531
+ 'result',
532
+ { question: input.question },
533
+ { ...ctx, messages: [] }
534
+ );
535
+
536
+ return {
537
+ userId: input.userId,
538
+ question: input.question,
539
+ answer: answer?.answer,
540
+ sources: answer?.sources ?? [],
541
+ contextUsed: results.length,
542
+ };
543
+ },
544
+ });
545
+
546
+ const result = await chatAgent.run({ userId: 'user1', question: 'What is aeye?' });
547
+ console.log(result.answer);
548
+ ```
549
+
550
+ ## Advanced Features
551
+
552
+ ### Custom Context & Metadata
553
+
554
+ ```typescript
555
+ interface AppContext {
556
+ userId: string;
557
+ user?: User;
558
+ db?: Database;
559
+ }
560
+
561
+ interface AppMetadata {
562
+ feature: 'chat' | 'analysis';
563
+ priority: 'low' | 'normal' | 'high';
564
+ }
565
+
566
+ const ai = AI.with<AppContext, AppMetadata>()
567
+ .providers({ openai, openrouter })
568
+ .create({
569
+ providedContext: async (ctx) => {
570
+ const user = await db.users.findById(ctx.userId);
571
+ return { user, db };
572
+ },
573
+ defaultMetadata: {
574
+ priority: 'normal',
575
+ },
576
+ });
577
+
578
+ // At call time only userId is required
579
+ const response = await ai.chat.get(
580
+ { messages: [{ role: 'user', content: 'Hello' }] },
581
+ { userId: '123', metadata: { feature: 'chat' } }
582
+ );
583
+ ```
584
+
585
+ ### Lifecycle Hooks
586
+
587
+ ```typescript
588
+ const ai = AI.with<AppContext>()
589
+ .providers({ openai })
590
+ .create({ /* ... */ })
591
+ .withHooks({
592
+ beforeModelSelection: async (ctx, request, metadata) => {
593
+ // Adjust weights based on context (return modified metadata)
594
+ if (ctx.user?.tier === 'free') {
595
+ return { ...metadata, weights: { cost: 1.0 } };
596
+ }
597
+ return metadata;
598
+ },
599
+
600
+ onModelSelected: async (ctx, request, selected) => {
601
+ console.log(`Selected: ${selected.model.id} (score: ${selected.score.toFixed(3)})`);
602
+ // Return a modified selected model to override, or undefined to accept
603
+ },
604
+
605
+ beforeRequest: async (ctx, request, selected, estimatedUsage, estimatedCost) => {
606
+ // Throw to cancel the request
607
+ if (ctx.user && estimatedCost > ctx.user.budgetRemaining) {
608
+ throw new Error('Insufficient budget');
609
+ }
610
+ console.log(
611
+ `[${ctx.userId}] ${selected.model.id} — ` +
612
+ `~${estimatedUsage.text?.input ?? 0} input tokens, ~$${estimatedCost.toFixed(5)}`
613
+ );
614
+ },
615
+
616
+ afterBatch: async (ctx, job, usage, cost) => {
617
+ // A completed batch, reported once with its total usage and cost
618
+ console.log(`[${ctx.userId}] batch ${job.id}: $${cost.toFixed(5)}`);
619
+ },
620
+
621
+ afterRequest: async (ctx, request, response, responseComplete, selected, usage, cost) => {
622
+ // Record actual usage
623
+ if (ctx.user) {
624
+ ctx.user.budgetRemaining -= cost;
625
+ ctx.user.totalSpent += cost;
626
+ await ctx.user.save();
627
+ }
628
+ console.log(
629
+ `[${ctx.userId}] ${usage.text?.input ?? 0} in / ${usage.text?.output ?? 0} out, ` +
630
+ `cost: $${cost.toFixed(5)}`
631
+ );
632
+ },
633
+
634
+ onError: (errorType, message, error, ctx) => {
635
+ console.error(`[AI ${errorType}] ${message}`, { error, userId: ctx?.userId });
636
+ },
637
+ });
638
+ ```
639
+
640
+ ### Model Selection
641
+
642
+ ```typescript
643
+ // Cost-optimized
644
+ await ai.chat.get(
645
+ { messages },
646
+ { metadata: { weights: { cost: 0.9, speed: 0.05, accuracy: 0.05 } } }
647
+ );
648
+
649
+ // Quality-optimized with large context
650
+ await ai.chat.get(
651
+ { messages },
652
+ {
653
+ metadata: {
654
+ weights: { cost: 0.1, speed: 0.4, accuracy: 0.5 },
655
+ contextWindow: { min: 128000 },
656
+ }
657
+ }
658
+ );
659
+
660
+ // Provider-specific
661
+ await ai.chat.get(
662
+ { messages },
663
+ { metadata: { providers: { allow: ['openai'], deny: ['replicate'] } } }
664
+ );
665
+
666
+ // Explicit model override
667
+ await ai.chat.get(
668
+ { messages },
669
+ { metadata: { model: 'openai/gpt-4o' } }
670
+ );
671
+ ```
672
+
673
+ ### Named Weight Profiles
674
+
675
+ ```typescript
676
+ const ai = AI.with()
677
+ .providers({ openai, openrouter })
678
+ .create({
679
+ weightProfiles: {
680
+ costPriority: { cost: 0.9, speed: 0.1 },
681
+ balanced: { cost: 0.4, speed: 0.3, accuracy: 0.3 },
682
+ performance: { cost: 0.1, speed: 0.4, accuracy: 0.5 },
683
+ },
684
+ });
685
+
686
+ await ai.chat.get(
687
+ { messages },
688
+ { metadata: { weightProfile: 'balanced' } }
689
+ );
690
+ ```
691
+
692
+ ### Model Sources
693
+
694
+ ```typescript
695
+ import { OpenRouterModelSource } from '@aeye/openrouter';
696
+
697
+ const source = new OpenRouterModelSource({ apiKey: process.env.OPENROUTER_API_KEY });
698
+
699
+ const ai = AI.with()
700
+ .providers({ openai, openrouter })
701
+ .create({
702
+ modelSources: [source],
703
+ });
704
+
705
+ // Fetch and register all models from sources
706
+ await ai.models.refresh();
707
+ ```
708
+
709
+ ### Model Overrides
710
+
711
+ ```typescript
712
+ const ai = AI.with()
713
+ .providers({ openai })
714
+ .create({
715
+ modelOverrides: [
716
+ {
717
+ modelPattern: /gpt-4/,
718
+ overrides: {
719
+ pricing: { text: { input: 30, output: 60 } },
720
+ },
721
+ },
722
+ ],
723
+ });
724
+ ```
725
+
726
+ ### Strict Mode
727
+
728
+ Tool and Prompt schemas can ride the LLM's grammar-constrained strict mode for guaranteed type-safe output. Mechanics — descriptors, dialects, schema rewriting — live in [`@aeye/core`](../core/README.md#strict-mode). What `@aeye/ai` adds: model selection respects the `strict` flag (selection filters to strict-capable models when `strict: true`, scores them higher when `strict: <number>`).
729
+
730
+ Splat the curated `strictSupport` overrides from `@aeye/models` so model selection knows which models actually support strict — without it, every model defaults to lenient even when the underlying API supports strict:
731
+
732
+ ```typescript
733
+ import { models, strictSupport } from '@aeye/models';
734
+
735
+ const ai = AI.with()
736
+ .providers({ openai })
737
+ .create({
738
+ models,
739
+ modelOverrides: [...strictSupport],
740
+ });
741
+ ```
742
+
743
+ `strictSupport` is a `ModelOverride[]` you can combine with your own overrides:
744
+
745
+ ```typescript
746
+ modelOverrides: [
747
+ ...strictSupport,
748
+ { modelPattern: /gpt-4/, overrides: { tier: 'flagship' } },
749
+ ],
750
+ ```
751
+
752
+ ### Custom Providers
753
+
754
+ Extend an existing provider or implement the `Provider<TConfig>` interface from `@aeye/core`:
755
+
756
+ ```typescript
757
+ import { OpenAIProvider, OpenAIConfig } from '@aeye/openai';
758
+ import OpenAI from 'openai';
759
+
760
+ class MyProvider extends OpenAIProvider {
761
+ readonly name = 'my-provider';
762
+
763
+ protected createClient(config: OpenAIConfig) {
764
+ return new OpenAI({
765
+ apiKey: config.apiKey,
766
+ baseURL: 'https://my-api.example.com/v1',
767
+ });
768
+ }
769
+ }
770
+ ```
771
+
772
+ ### Extending AI Instances
773
+
774
+ Share providers and configuration between multiple AI instances:
775
+
776
+ ```typescript
777
+ const baseAI = AI.with<BaseContext>()
778
+ .providers({ openai, openrouter })
779
+ .create({ /* base config */ });
780
+
781
+ // Extend with additional context for a specific feature
782
+ interface ChatFeatureContext extends BaseContext {
783
+ chatId: string;
784
+ chatHistory: Message[];
785
+ }
786
+
787
+ const chatAI = baseAI.extend<ChatFeatureContext>({
788
+ defaultContext: { chatHistory: [] },
789
+ });
790
+
791
+ const response = await chatAI.chat.get(
792
+ { messages: [{ role: 'user', content: 'Hello' }] },
793
+ { chatId: 'chat-123', chatHistory: [] }
794
+ );
795
+ ```
796
+
797
+ ## Examples
798
+
799
+ ### Complete Application
800
+
801
+ ```typescript
802
+ import { AI } from '@aeye/ai';
803
+ import { OpenAIProvider } from '@aeye/openai';
804
+ import { OpenRouterProvider } from '@aeye/openrouter';
805
+ import z from 'zod';
806
+
807
+ interface User {
808
+ id: string;
809
+ budgetRemaining: number;
810
+ totalSpent: number;
811
+ save: () => Promise<void>;
812
+ }
813
+
814
+ interface AppContext {
815
+ userId: string;
816
+ user?: User;
817
+ }
818
+
819
+ const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
820
+ const openrouter = new OpenRouterProvider({ apiKey: process.env.OPENROUTER_API_KEY! });
821
+
822
+ export const ai = AI.with<AppContext>()
823
+ .providers({ openai, openrouter })
824
+ .create({
825
+ providedContext: async (ctx) => ({
826
+ user: await getUser(ctx.userId),
827
+ }),
828
+ defaultWeights: { cost: 0.5, speed: 0.3, accuracy: 0.2 },
829
+ })
830
+ .withHooks({
831
+ beforeRequest: async (ctx, _request, selected, estimatedUsage, estimatedCost) => {
832
+ if (ctx.user && estimatedCost > ctx.user.budgetRemaining) {
833
+ throw new Error(
834
+ `Estimated cost $${estimatedCost.toFixed(4)} exceeds budget ` +
835
+ `$${ctx.user.budgetRemaining.toFixed(4)}`
836
+ );
837
+ }
838
+ },
839
+ afterRequest: async (ctx, _request, _response, _complete, _selected, usage, cost) => {
840
+ if (ctx.user) {
841
+ ctx.user.budgetRemaining -= cost;
842
+ ctx.user.totalSpent += cost;
843
+ await ctx.user.save();
844
+ }
845
+ },
846
+ onError: (errorType, message, error, ctx) => {
847
+ console.error(`[AI ${errorType}] ${message}`, { userId: ctx?.userId, error });
848
+ },
849
+ });
850
+
851
+ // Define a tool
852
+ const webSearch = ai.tool({
853
+ name: 'webSearch',
854
+ description: 'Search the web for recent information',
855
+ schema: z.object({ query: z.string() }),
856
+ call: async ({ query }) => {
857
+ // Integrate with a search API
858
+ return { results: [`Result for: ${query}`] };
859
+ },
860
+ });
861
+
862
+ // Define a research prompt
863
+ const researcher = ai.prompt({
864
+ name: 'researcher',
865
+ description: 'Researches a topic and provides a structured summary',
866
+ content: `You are a research assistant. Research the following topic and provide a clear summary.
867
+
868
+ Topic: {{topic}}`,
869
+ input: (input: { topic: string }) => ({ topic: input.topic }),
870
+ tools: [webSearch],
871
+ schema: z.object({
872
+ summary: z.string(),
873
+ keyFindings: z.array(z.string()),
874
+ sources: z.array(z.string()),
875
+ }),
876
+ });
877
+
878
+ // Use the prompt
879
+ async function researchTopic(userId: string, topic: string) {
880
+ const result = await researcher.get('result', { topic }, { userId });
881
+ return result;
882
+ }
883
+
884
+ const findings = await researchTopic('user123', 'TypeScript 5.0 features');
885
+ console.log(findings?.summary);
886
+ console.log(findings?.keyFindings);
887
+ ```
888
+
889
+ ## License
890
+
891
+ GPL-3.0 © [ClickerMonkey](https://github.com/ClickerMonkey)
892
+
893
+ ## Links
894
+
895
+ - [Root README](../../README.md)
896
+ - [@aeye/core README](../core/README.md)
897
+ - [GitHub Repository](https://github.com/ClickerMonkey/aeye)
898
+ - [Issue Tracker](https://github.com/ClickerMonkey/aeye/issues)