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