@aeye/ai 0.3.7 → 0.3.8
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 +412 -841
- package/dist/ai.d.ts +10 -4
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +7 -1
- package/dist/ai.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +17 -3
- package/dist/registry.js.map +1 -1
- package/dist/types.d.ts +9 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,114 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
> **Multi-provider AI library with intelligent model selection, type-safe context management, and comprehensive hooks system.**
|
|
4
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
|
|
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
6
|
|
|
7
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
|
+
|
|
8
14
|
const ai = AI.with<MyContext>()
|
|
9
15
|
.providers({ openai })
|
|
10
|
-
.create(/*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
ai.chat.stream(request, ctx?)
|
|
14
|
-
ai.image.generate.get(request, ctx?)
|
|
15
|
-
ai.image.
|
|
16
|
-
ai.image.
|
|
17
|
-
ai.
|
|
18
|
-
ai.
|
|
19
|
-
ai.image.analyze.stream(request, ctx?)
|
|
20
|
-
ai.transcribe.get(request, ctx?)
|
|
21
|
-
ai.transcribe.stream(request, ctx?)
|
|
22
|
-
ai.speech.get(request, ctx?)
|
|
23
|
-
ai.speech.stream(request, ctx?)
|
|
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?)
|
|
24
25
|
ai.embed.get(request, ctx?)
|
|
25
|
-
ai.
|
|
26
|
-
ai.models.list() // get(id), search(criteria), select(criteria), refresh()
|
|
27
|
-
ai.providers.openai // Provider
|
|
28
|
-
ai.hooks // Hook events to implement BYOK
|
|
29
|
-
ai.components // all prompts/tools/agents created on this ai
|
|
30
|
-
|
|
31
|
-
// Define prompts, tools, agents
|
|
32
|
-
const chatPrompt = ai.prompt({
|
|
33
|
-
name: 'chat_prompt',
|
|
34
|
-
description: 'A helpful chat assistant that answers user questions',
|
|
35
|
-
content: `You are a helpful assistant. The user {{user}} has asked: {{question}}
|
|
36
|
-
Answer their question clearly and concisely. If you need more information, use the available tools.`,
|
|
37
|
-
input: (input: { user: string; question: string }) => input,
|
|
38
|
-
schema: z.object({ answer: z.string() }),
|
|
39
|
-
refs: [relevantInfo],
|
|
40
|
-
});
|
|
26
|
+
ai.models.list() // .get(id), .search(criteria), .select(criteria), .refresh()
|
|
41
27
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Messages: {{messages}}
|
|
47
|
-
Return a summary of how well the agent performed and a rating between 0 and 100.`,
|
|
48
|
-
input: (input: { user: string; messages: string }) => input,
|
|
49
|
-
schema: z.object({ summary: z.string(), rating: z.number() }),
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// Tool
|
|
53
|
-
const relevantInfo = ai.tool({
|
|
54
|
-
name: 'relevant_info',
|
|
55
|
-
description: `Searches for info relevant to a user's question`,
|
|
56
|
-
instructions: 'Use the relevant_info tool to look up information to better answer questions',
|
|
57
|
-
schema: z.object({
|
|
58
|
-
query: z.string(),
|
|
59
|
-
}),
|
|
60
|
-
call: async ({ query }, refs, ctx) => {
|
|
61
|
-
return await DB.getRelevantInfo(query, 10);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
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 });
|
|
64
32
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// Get the chat response
|
|
72
|
-
const chatResponse = await refs[1].run({
|
|
73
|
-
user: input.user,
|
|
74
|
-
question: input.question
|
|
75
|
-
}, ctx);
|
|
76
|
-
|
|
77
|
-
// Analyze the conversation
|
|
78
|
-
const messages = JSON.stringify([
|
|
79
|
-
{ role: 'user', content: input.question },
|
|
80
|
-
{ role: 'assistant', content: chatResponse.answer }
|
|
81
|
-
]);
|
|
82
|
-
|
|
83
|
-
const analysis = await refs[2].run({
|
|
84
|
-
user: input.user,
|
|
85
|
-
messages
|
|
86
|
-
}, ctx);
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
answer: chatResponse.answer,
|
|
90
|
-
analysis: {
|
|
91
|
-
summary: analysis.summary,
|
|
92
|
-
rating: analysis.rating
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// Use the agent
|
|
99
|
-
const result = await chatAgent.run(
|
|
100
|
-
{ user: 'ClickerMonkey', question: 'What is TypeScript?' },
|
|
101
|
-
{ messages: [] }
|
|
102
|
-
);
|
|
103
|
-
console.log(result.answer);
|
|
104
|
-
console.log(`Quality rating: ${result.analysis.rating}/100`);
|
|
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?)
|
|
105
39
|
```
|
|
106
40
|
|
|
107
41
|
## Features
|
|
108
42
|
|
|
109
|
-
- **Multi-Provider Support**: Single interface for OpenAI,
|
|
43
|
+
- **Multi-Provider Support**: Single interface for OpenAI, OpenRouter, Replicate, AWS Bedrock, and custom providers
|
|
110
44
|
- **Intelligent Model Selection**: Automatic model selection based on capabilities, cost, speed, and quality
|
|
111
45
|
- **Type-Safe Context**: Strongly-typed context and metadata with compiler validation
|
|
112
|
-
- **Comprehensive APIs**: Chat, Image Generation/Analysis, Speech Synthesis
|
|
46
|
+
- **Comprehensive APIs**: Chat, Image Generation/Analysis/Editing, Speech Synthesis, Transcription, Embeddings
|
|
113
47
|
- **Lifecycle Hooks**: Intercept and modify operations at every stage
|
|
114
48
|
- **Cost Tracking**: Automatic token usage and cost calculation
|
|
115
49
|
- **Streaming Support**: Full streaming support across all compatible capabilities
|
|
@@ -129,13 +63,14 @@ console.log(`Quality rating: ${result.analysis.rating}/100`);
|
|
|
129
63
|
- [Transcribe API](#transcribe-api)
|
|
130
64
|
- [Embed API](#embed-api)
|
|
131
65
|
- [Models API](#models-api)
|
|
66
|
+
- [Tools, Prompts & Agents](#tools-prompts--agents)
|
|
132
67
|
- [Advanced Features](#advanced-features)
|
|
133
68
|
- [Custom Context & Metadata](#custom-context--metadata)
|
|
134
69
|
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
135
70
|
- [Model Selection](#model-selection)
|
|
136
71
|
- [Model Sources](#model-sources)
|
|
137
72
|
- [Custom Providers](#custom-providers)
|
|
138
|
-
- [
|
|
73
|
+
- [Extending AI Instances](#extending-ai-instances)
|
|
139
74
|
- [Examples](#examples)
|
|
140
75
|
|
|
141
76
|
## Installation
|
|
@@ -147,7 +82,10 @@ npm install @aeye/ai @aeye/core
|
|
|
147
82
|
You'll also need provider packages:
|
|
148
83
|
|
|
149
84
|
```bash
|
|
150
|
-
npm install @aeye/openai
|
|
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
|
|
151
89
|
```
|
|
152
90
|
|
|
153
91
|
## Quick Start
|
|
@@ -157,29 +95,23 @@ import { AI } from '@aeye/ai';
|
|
|
157
95
|
import { OpenAIProvider } from '@aeye/openai';
|
|
158
96
|
import { OpenRouterProvider } from '@aeye/openrouter';
|
|
159
97
|
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const openrouter = new OpenRouterProvider({ apiKey: 'abc' });
|
|
98
|
+
const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
|
|
99
|
+
const openrouter = new OpenRouterProvider({ apiKey: process.env.OPENROUTER_API_KEY! });
|
|
163
100
|
|
|
164
|
-
// Create an AI instance
|
|
165
101
|
const ai = AI.with()
|
|
166
102
|
.providers({ openai, openrouter })
|
|
167
|
-
.create(
|
|
168
|
-
defaultContext: {
|
|
169
|
-
apiVersion: 'v1'
|
|
170
|
-
}
|
|
171
|
-
});
|
|
103
|
+
.create();
|
|
172
104
|
|
|
173
105
|
// Simple chat completion
|
|
174
|
-
const response = await ai.chat.get(
|
|
175
|
-
{ role: 'user', content: 'What is TypeScript?' }
|
|
176
|
-
|
|
106
|
+
const response = await ai.chat.get({
|
|
107
|
+
messages: [{ role: 'user', content: 'What is TypeScript?' }]
|
|
108
|
+
});
|
|
177
109
|
console.log(response.content);
|
|
178
110
|
|
|
179
111
|
// Streaming
|
|
180
|
-
for await (const chunk of ai.chat.stream(
|
|
181
|
-
{ role: 'user', content: 'Write a poem' }
|
|
182
|
-
|
|
112
|
+
for await (const chunk of ai.chat.stream({
|
|
113
|
+
messages: [{ role: 'user', content: 'Write a poem' }]
|
|
114
|
+
})) {
|
|
183
115
|
if (chunk.content) {
|
|
184
116
|
process.stdout.write(chunk.content);
|
|
185
117
|
}
|
|
@@ -188,127 +120,76 @@ for await (const chunk of ai.chat.stream([
|
|
|
188
120
|
|
|
189
121
|
## Architecture
|
|
190
122
|
|
|
191
|
-
|
|
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"]
|
|
192
129
|
|
|
130
|
+
AI --> APIs
|
|
131
|
+
AI --> Registry
|
|
132
|
+
Registry --> Providers
|
|
133
|
+
APIs --> Providers
|
|
193
134
|
```
|
|
194
|
-
┌─────────────────────────────────────────────────────────┐
|
|
195
|
-
│ AI Class │
|
|
196
|
-
│ - Context Management │
|
|
197
|
-
│ - Model Registry │
|
|
198
|
-
│ - Lifecycle Hooks │
|
|
199
|
-
└─────────────────┬───────────────────────────────────────┘
|
|
200
|
-
│
|
|
201
|
-
┌────────┴─────────┐
|
|
202
|
-
│ │
|
|
203
|
-
┌────▼────┐ ┌─────▼──────┐
|
|
204
|
-
│ APIs │ │ Registry │
|
|
205
|
-
│ │ │ │
|
|
206
|
-
│ • Chat │ │ • Models │
|
|
207
|
-
│ • Image │ │ • Search │
|
|
208
|
-
│ • Speech│ │ • Select │
|
|
209
|
-
│ • Embed │ └────┬───────┘
|
|
210
|
-
└────┬────┘ │
|
|
211
|
-
│ ┌──────▼──────┐
|
|
212
|
-
│ │ Providers │
|
|
213
|
-
│ │ │
|
|
214
|
-
│ │ • OpenAI │
|
|
215
|
-
│ │ • Anthropic │
|
|
216
|
-
│ │ • Google │
|
|
217
|
-
│ │ • Custom │
|
|
218
|
-
└─────────┴─────────────┘
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
### Key Components
|
|
222
|
-
|
|
223
|
-
1. **AI Class**: Central orchestrator managing context, metadata, providers, and APIs
|
|
224
|
-
2. **APIs**: Specialized interfaces for different capabilities (chat, images, speech, etc.)
|
|
225
|
-
3. **Model Registry**: Centralized model management with selection logic
|
|
226
|
-
4. **Providers**: Pluggable implementations for different AI services
|
|
227
|
-
5. **Context System**: Type-safe context and metadata threading
|
|
228
135
|
|
|
229
136
|
## Core Concepts
|
|
230
137
|
|
|
231
138
|
### Context
|
|
232
139
|
|
|
233
|
-
Context is data passed through your entire AI operation
|
|
140
|
+
Context is data passed through your entire AI operation:
|
|
234
141
|
|
|
235
|
-
- **Default Context**: Static values provided at AI creation
|
|
236
|
-
- **Provided Context**: Async-loaded values (e.g., from database)
|
|
237
|
-
- **Required Context**: Values that must be
|
|
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
|
|
238
145
|
|
|
239
146
|
```typescript
|
|
240
147
|
interface AppContext {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
148
|
+
userId: string;
|
|
149
|
+
user?: User; // loaded by providedContext
|
|
150
|
+
db?: Database; // loaded by providedContext
|
|
244
151
|
}
|
|
245
152
|
|
|
246
153
|
const ai = AI.with<AppContext>()
|
|
247
154
|
.providers({ openai })
|
|
248
155
|
.create({
|
|
249
|
-
defaultContext: {
|
|
250
|
-
apiVersion: 'v1'
|
|
251
|
-
},
|
|
252
156
|
providedContext: async (ctx) => ({
|
|
253
|
-
user: await
|
|
254
|
-
db
|
|
255
|
-
})
|
|
157
|
+
user: await db.users.findById(ctx.userId),
|
|
158
|
+
db,
|
|
159
|
+
}),
|
|
256
160
|
});
|
|
257
161
|
|
|
258
|
-
//
|
|
259
|
-
await ai.chat.get(
|
|
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
|
+
);
|
|
260
167
|
```
|
|
261
168
|
|
|
262
169
|
### Metadata
|
|
263
170
|
|
|
264
|
-
Metadata controls model selection and
|
|
171
|
+
Metadata controls model selection and per-request configuration:
|
|
265
172
|
|
|
266
173
|
```typescript
|
|
267
174
|
interface AppMetadata {
|
|
268
175
|
priority: 'cost' | 'speed' | 'quality';
|
|
269
176
|
}
|
|
270
177
|
|
|
271
|
-
const ai = AI.with<
|
|
178
|
+
const ai = AI.with<{}, AppMetadata>()
|
|
272
179
|
.providers({ openai, openrouter })
|
|
273
180
|
.create({
|
|
274
|
-
defaultMetadata: {
|
|
275
|
-
priority: 'balanced'
|
|
276
|
-
}
|
|
181
|
+
defaultMetadata: { priority: 'cost' },
|
|
277
182
|
});
|
|
278
183
|
|
|
279
|
-
// Override per request
|
|
280
|
-
await ai.chat.get(request, {
|
|
281
|
-
metadata: {
|
|
282
|
-
model: 'gpt-4', // Specific model
|
|
283
|
-
required: ['chat', 'vision'], // Required capabilities
|
|
284
|
-
weights: { cost: 0.7, speed: 0.3 } // Selection weights
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
### Model Selection
|
|
290
|
-
|
|
291
|
-
Models are automatically selected based on:
|
|
292
|
-
|
|
293
|
-
1. **Capabilities**: Required and optional features
|
|
294
|
-
2. **Constraints**: Provider filters, context window, budget
|
|
295
|
-
3. **Scoring**: Weighted evaluation of cost, speed, accuracy, context size
|
|
296
|
-
|
|
297
|
-
```typescript
|
|
298
|
-
// Automatic selection for vision task
|
|
184
|
+
// Override per request via context
|
|
299
185
|
const response = await ai.chat.get(
|
|
300
|
-
[{
|
|
301
|
-
role: 'user',
|
|
302
|
-
content: [
|
|
303
|
-
{ type: 'text', content: 'What is in this image?' },
|
|
304
|
-
{ type: 'image', content: imageUrl }
|
|
305
|
-
]
|
|
306
|
-
}],
|
|
186
|
+
{ messages: [{ role: 'user', content: 'Hello' }] },
|
|
307
187
|
{
|
|
308
188
|
metadata: {
|
|
309
|
-
|
|
310
|
-
weights: { cost: 0.
|
|
311
|
-
|
|
189
|
+
model: 'openai/gpt-4o', // pin a specific model
|
|
190
|
+
weights: { cost: 0.2, speed: 0.3, accuracy: 0.5 },
|
|
191
|
+
priority: 'quality',
|
|
192
|
+
},
|
|
312
193
|
}
|
|
313
194
|
);
|
|
314
195
|
```
|
|
@@ -317,33 +198,31 @@ const response = await ai.chat.get(
|
|
|
317
198
|
|
|
318
199
|
### Chat API
|
|
319
200
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
#### Methods
|
|
323
|
-
|
|
324
|
-
**`chat.get(messages, context?)`**
|
|
325
|
-
|
|
326
|
-
Execute a non-streaming chat completion.
|
|
201
|
+
**`chat.get(request, ctx?)`** — Non-streaming chat completion.
|
|
327
202
|
|
|
328
203
|
```typescript
|
|
329
|
-
const response = await ai.chat.get(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
+
});
|
|
333
212
|
|
|
334
213
|
console.log(response.content);
|
|
335
|
-
console.log(response.usage);
|
|
336
|
-
console.log(response.
|
|
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' | ...
|
|
337
218
|
```
|
|
338
219
|
|
|
339
|
-
**`chat.stream(
|
|
340
|
-
|
|
341
|
-
Execute a streaming chat completion.
|
|
220
|
+
**`chat.stream(request, ctx?)`** — Streaming chat completion.
|
|
342
221
|
|
|
343
222
|
```typescript
|
|
344
|
-
for await (const chunk of ai.chat.stream(
|
|
345
|
-
{ role: 'user', content: 'Count to 10' }
|
|
346
|
-
|
|
223
|
+
for await (const chunk of ai.chat.stream({
|
|
224
|
+
messages: [{ role: 'user', content: 'Count to 10' }]
|
|
225
|
+
})) {
|
|
347
226
|
if (chunk.content) {
|
|
348
227
|
process.stdout.write(chunk.content);
|
|
349
228
|
}
|
|
@@ -353,849 +232,541 @@ for await (const chunk of ai.chat.stream([
|
|
|
353
232
|
}
|
|
354
233
|
```
|
|
355
234
|
|
|
356
|
-
|
|
235
|
+
**Vision:**
|
|
357
236
|
|
|
358
237
|
```typescript
|
|
359
|
-
|
|
360
|
-
messages:
|
|
361
|
-
maxTokens?: number;
|
|
362
|
-
temperature?: number;
|
|
363
|
-
topP?: number;
|
|
364
|
-
tools?: Tool[];
|
|
365
|
-
responseFormat?: { type: 'json_object' | 'text' };
|
|
366
|
-
// ... other options
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
type Message =
|
|
370
|
-
| { role: 'system' | 'user' | 'assistant', content: string }
|
|
371
|
-
| { role: 'user', content: ContentPart[] }; // Multimodal
|
|
372
|
-
|
|
373
|
-
type ContentPart =
|
|
374
|
-
| { type: 'text', content: string }
|
|
375
|
-
| { type: 'image', content: string }; // URL or base64
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
#### Response Format
|
|
379
|
-
|
|
380
|
-
TODO: updated
|
|
381
|
-
|
|
382
|
-
#### Examples
|
|
383
|
-
|
|
384
|
-
**Basic Conversation**
|
|
385
|
-
|
|
386
|
-
```typescript
|
|
387
|
-
const messages = [
|
|
388
|
-
{ role: 'system', content: 'You are a TypeScript expert.' },
|
|
389
|
-
{ role: 'user', content: 'Explain generics' }
|
|
390
|
-
];
|
|
391
|
-
|
|
392
|
-
const response = await ai.chat.get(messages);
|
|
393
|
-
console.log(response.content);
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
**Vision Analysis**
|
|
397
|
-
|
|
398
|
-
```typescript
|
|
399
|
-
const response = await ai.chat.get([
|
|
400
|
-
{
|
|
238
|
+
const response = await ai.chat.get({
|
|
239
|
+
messages: [{
|
|
401
240
|
role: 'user',
|
|
402
241
|
content: [
|
|
403
242
|
{ type: 'text', content: 'What is in this image?' },
|
|
404
|
-
{ type: 'image', content: 'https://example.com/image.jpg' }
|
|
405
|
-
]
|
|
406
|
-
}
|
|
407
|
-
], {
|
|
408
|
-
metadata: {
|
|
409
|
-
required: ['chat', 'vision']
|
|
410
|
-
}
|
|
243
|
+
{ type: 'image', content: 'https://example.com/image.jpg' },
|
|
244
|
+
],
|
|
245
|
+
}],
|
|
411
246
|
});
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
**Function Calling**
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
const tools = [
|
|
418
|
-
{
|
|
419
|
-
name: 'get_weather',
|
|
420
|
-
description: 'Get weather for a location',
|
|
421
|
-
parameters: {
|
|
422
|
-
type: 'object',
|
|
423
|
-
properties: {
|
|
424
|
-
location: { type: 'string' }
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
];
|
|
429
|
-
|
|
430
|
-
const response = await ai.chat.get(
|
|
431
|
-
[{ role: 'user', content: 'What is the weather in NYC?' }],
|
|
432
|
-
{
|
|
433
|
-
metadata: {
|
|
434
|
-
required: ['chat', 'tools']
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
);
|
|
438
|
-
|
|
439
|
-
if (response.toolCalls) {
|
|
440
|
-
for (const call of response.toolCalls) {
|
|
441
|
-
console.log(`Tool: ${call.function.name}`);
|
|
442
|
-
console.log(`Args: ${call.function.arguments}`);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
**Streaming with Token Counting**
|
|
448
|
-
|
|
449
|
-
TODO: update with new types
|
|
450
|
-
|
|
451
|
-
```typescript
|
|
452
|
-
let totalTokens = 0;
|
|
453
|
-
|
|
454
|
-
for await (const chunk of ai.chat.stream(messages)) {
|
|
455
|
-
if (chunk.content) {
|
|
456
|
-
process.stdout.write(chunk.content);
|
|
457
|
-
}
|
|
458
|
-
if (chunk.usage) {
|
|
459
|
-
totalTokens = chunk.usage.totalTokens;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
console.log(`\nTotal tokens: ${totalTokens}`);
|
|
247
|
+
// vision capability is detected automatically from the message content
|
|
464
248
|
```
|
|
465
249
|
|
|
466
250
|
### Image API
|
|
467
251
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
#### Sub-APIs
|
|
471
|
-
|
|
472
|
-
**`image.generate.get(request, context?)`**
|
|
473
|
-
|
|
474
|
-
Generate images from text prompts.
|
|
252
|
+
**`image.generate.get(request, ctx?)`** — Generate images from a text prompt.
|
|
475
253
|
|
|
476
254
|
```typescript
|
|
477
255
|
const response = await ai.image.generate.get({
|
|
478
256
|
prompt: 'A futuristic city at sunset',
|
|
479
257
|
n: 2,
|
|
480
258
|
size: '1024x1024',
|
|
481
|
-
quality: '
|
|
259
|
+
quality: 'high',
|
|
482
260
|
});
|
|
483
261
|
|
|
484
262
|
for (const image of response.images) {
|
|
485
|
-
console.log(image.url);
|
|
263
|
+
console.log(image.url ?? image.b64_json);
|
|
486
264
|
}
|
|
487
265
|
```
|
|
488
266
|
|
|
489
|
-
**`image.
|
|
490
|
-
|
|
491
|
-
Stream image generation progress.
|
|
267
|
+
**`image.edit.get(request, ctx?)`** — Edit an image with inpainting.
|
|
492
268
|
|
|
493
269
|
```typescript
|
|
494
|
-
|
|
495
|
-
prompt: 'A majestic mountain landscape'
|
|
496
|
-
})) {
|
|
497
|
-
if (chunk.progress) {
|
|
498
|
-
console.log(`Progress: ${chunk.progress}%`);
|
|
499
|
-
}
|
|
500
|
-
if (chunk.done && chunk.image) {
|
|
501
|
-
console.log('Image URL:', chunk.image.url);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
**`image.edit.get(request, context?)`**
|
|
507
|
-
|
|
508
|
-
Edit existing images with prompts.
|
|
270
|
+
import fs from 'fs';
|
|
509
271
|
|
|
510
|
-
```typescript
|
|
511
272
|
const response = await ai.image.edit.get({
|
|
512
|
-
image: imageBuffer,
|
|
513
|
-
mask: maskBuffer,
|
|
514
273
|
prompt: 'Add a sunset in the background',
|
|
515
|
-
|
|
274
|
+
image: fs.readFileSync('photo.png'),
|
|
275
|
+
mask: fs.readFileSync('mask.png'),
|
|
276
|
+
size: '1024x1024',
|
|
516
277
|
});
|
|
517
278
|
```
|
|
518
279
|
|
|
519
|
-
**`image.analyze.get(request,
|
|
520
|
-
|
|
521
|
-
Analyze images with vision models (uses chat models with vision capability).
|
|
280
|
+
**`image.analyze.get(request, ctx?)`** — Analyze image content (uses a vision chat model).
|
|
522
281
|
|
|
523
282
|
```typescript
|
|
524
283
|
const response = await ai.image.analyze.get({
|
|
525
284
|
prompt: 'Describe this image in detail',
|
|
526
285
|
images: ['https://example.com/photo.jpg'],
|
|
527
|
-
maxTokens: 500
|
|
528
286
|
});
|
|
529
|
-
|
|
530
287
|
console.log(response.content);
|
|
531
288
|
```
|
|
532
289
|
|
|
533
|
-
#### Examples
|
|
534
|
-
|
|
535
|
-
**Batch Generation**
|
|
536
|
-
|
|
537
|
-
```typescript
|
|
538
|
-
const response = await ai.image.generate.get({
|
|
539
|
-
prompt: 'Logo designs for a tech startup',
|
|
540
|
-
n: 4,
|
|
541
|
-
size: '512x512'
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
response.images.forEach((img, i) => {
|
|
545
|
-
console.log(`Design ${i + 1}: ${img.url}`);
|
|
546
|
-
});
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
**Style Transfer**
|
|
550
|
-
|
|
551
|
-
```typescript
|
|
552
|
-
const response = await ai.image.generate.get({
|
|
553
|
-
prompt: 'Van Gogh style starry night over modern city',
|
|
554
|
-
quality: 'hd',
|
|
555
|
-
style: 'vivid'
|
|
556
|
-
});
|
|
557
|
-
```
|
|
558
|
-
|
|
559
290
|
### Speech API
|
|
560
291
|
|
|
561
|
-
Text-to-speech synthesis.
|
|
562
|
-
|
|
563
|
-
**`speech.get(request, context?)`**
|
|
292
|
+
**`speech.get(request, ctx?)`** — Text-to-speech synthesis.
|
|
564
293
|
|
|
565
294
|
```typescript
|
|
295
|
+
import fs from 'fs';
|
|
296
|
+
import { Readable } from 'stream';
|
|
297
|
+
|
|
566
298
|
const response = await ai.speech.get({
|
|
567
|
-
text: 'Hello, this is a
|
|
299
|
+
text: 'Hello, this is a text-to-speech example.',
|
|
568
300
|
voice: 'alloy',
|
|
569
301
|
speed: 1.0,
|
|
570
|
-
responseFormat: 'mp3'
|
|
571
302
|
});
|
|
572
303
|
|
|
573
|
-
//
|
|
574
|
-
fs.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
**`speech.stream(request, context?)`**
|
|
578
|
-
|
|
579
|
-
```typescript
|
|
580
|
-
for await (const chunk of ai.speech.stream({
|
|
581
|
-
text: 'Streaming audio generation...',
|
|
582
|
-
voice: 'nova'
|
|
583
|
-
})) {
|
|
584
|
-
if (chunk.audioData) {
|
|
585
|
-
// Process audio chunks in real-time
|
|
586
|
-
audioStream.write(chunk.audioData);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
304
|
+
// Pipe the audio stream to a file
|
|
305
|
+
const fileStream = fs.createWriteStream('output.mp3');
|
|
306
|
+
Readable.fromWeb(response.audio).pipe(fileStream);
|
|
589
307
|
```
|
|
590
308
|
|
|
591
309
|
### Transcribe API
|
|
592
310
|
|
|
593
|
-
Speech-to-text transcription.
|
|
594
|
-
|
|
595
|
-
**`transcribe.get(request, context?)`**
|
|
311
|
+
**`transcribe.get(request, ctx?)`** — Speech-to-text transcription.
|
|
596
312
|
|
|
597
313
|
```typescript
|
|
598
|
-
|
|
314
|
+
import fs from 'fs';
|
|
599
315
|
|
|
600
316
|
const response = await ai.transcribe.get({
|
|
601
|
-
audio:
|
|
317
|
+
audio: fs.readFileSync('recording.mp3'),
|
|
602
318
|
language: 'en',
|
|
603
|
-
responseFormat: 'verbose_json',
|
|
604
|
-
timestampGranularities: ['word', 'segment']
|
|
605
319
|
});
|
|
606
320
|
|
|
607
321
|
console.log(response.text);
|
|
608
|
-
console.log('Words:', response.words);
|
|
609
|
-
console.log('Segments:', response.segments);
|
|
610
322
|
```
|
|
611
323
|
|
|
612
324
|
### Embed API
|
|
613
325
|
|
|
614
|
-
Generate text embeddings
|
|
615
|
-
|
|
616
|
-
**`embed.get(request, context?)`**
|
|
326
|
+
**`embed.get(request, ctx?)`** — Generate text embeddings.
|
|
617
327
|
|
|
618
328
|
```typescript
|
|
619
329
|
const response = await ai.embed.get({
|
|
620
330
|
texts: [
|
|
621
331
|
'TypeScript is a typed superset of JavaScript',
|
|
622
332
|
'Python is a high-level programming language',
|
|
623
|
-
'Rust is a systems programming language'
|
|
624
333
|
],
|
|
625
|
-
dimensions: 1536
|
|
626
334
|
});
|
|
627
335
|
|
|
628
|
-
// Get embeddings
|
|
629
336
|
response.embeddings.forEach(({ embedding, index }) => {
|
|
630
337
|
console.log(`Text ${index}: ${embedding.length} dimensions`);
|
|
631
338
|
});
|
|
632
|
-
|
|
633
|
-
// Calculate similarity
|
|
634
|
-
function cosineSimilarity(a: number[], b: number[]) {
|
|
635
|
-
const dot = a.reduce((sum, val, i) => sum + val * b[i], 0);
|
|
636
|
-
const magA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
|
|
637
|
-
const magB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
|
|
638
|
-
return dot / (magA * magB);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const sim = cosineSimilarity(
|
|
642
|
-
response.embeddings[0].embedding,
|
|
643
|
-
response.embeddings[1].embedding
|
|
644
|
-
);
|
|
645
|
-
console.log('Similarity:', sim);
|
|
646
339
|
```
|
|
647
340
|
|
|
648
341
|
### Models API
|
|
649
342
|
|
|
650
|
-
Explore and manage available models.
|
|
651
|
-
|
|
652
|
-
**`models.list()`**
|
|
653
|
-
|
|
654
343
|
```typescript
|
|
344
|
+
// List all registered models
|
|
655
345
|
const models = ai.models.list();
|
|
656
|
-
console.log(`Available models: ${models.length}`);
|
|
657
346
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
console.log(
|
|
662
|
-
console.log(
|
|
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 },
|
|
663
361
|
});
|
|
362
|
+
console.log('Best match:', results[0]?.model.id);
|
|
363
|
+
|
|
364
|
+
// Refresh model list from all sources
|
|
365
|
+
await ai.models.refresh();
|
|
664
366
|
```
|
|
665
367
|
|
|
666
|
-
|
|
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
|
|
667
373
|
|
|
668
374
|
```typescript
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
+
});
|
|
674
390
|
```
|
|
675
391
|
|
|
676
|
-
|
|
392
|
+
### Prompt
|
|
677
393
|
|
|
678
394
|
```typescript
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
+
}),
|
|
685
407
|
});
|
|
686
408
|
|
|
687
|
-
|
|
688
|
-
|
|
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
|
+
}
|
|
689
417
|
```
|
|
690
418
|
|
|
691
|
-
|
|
419
|
+
### Agent
|
|
692
420
|
|
|
693
421
|
```typescript
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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);
|
|
697
453
|
```
|
|
698
454
|
|
|
699
455
|
## Advanced Features
|
|
700
456
|
|
|
701
457
|
### Custom Context & Metadata
|
|
702
458
|
|
|
703
|
-
Define your application-specific context and metadata types:
|
|
704
|
-
|
|
705
459
|
```typescript
|
|
706
460
|
interface AppContext {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
db
|
|
710
|
-
requestId: string;
|
|
461
|
+
userId: string;
|
|
462
|
+
user?: User;
|
|
463
|
+
db?: Database;
|
|
711
464
|
}
|
|
712
465
|
|
|
713
466
|
interface AppMetadata {
|
|
467
|
+
feature: 'chat' | 'analysis';
|
|
714
468
|
priority: 'low' | 'normal' | 'high';
|
|
715
|
-
category: 'support' | 'sales' | 'internal';
|
|
716
|
-
maxCost: number;
|
|
717
469
|
}
|
|
718
470
|
|
|
719
471
|
const ai = AI.with<AppContext, AppMetadata>()
|
|
720
|
-
.providers({ openai,
|
|
472
|
+
.providers({ openai, openrouter })
|
|
721
473
|
.create({
|
|
722
|
-
defaultContext: {
|
|
723
|
-
requestId: uuid()
|
|
724
|
-
},
|
|
725
474
|
providedContext: async (ctx) => {
|
|
726
|
-
// Load from database
|
|
727
475
|
const user = await db.users.findById(ctx.userId);
|
|
728
|
-
|
|
729
|
-
return { user, organization: org, db };
|
|
476
|
+
return { user, db };
|
|
730
477
|
},
|
|
731
478
|
defaultMetadata: {
|
|
732
479
|
priority: 'normal',
|
|
733
|
-
|
|
734
|
-
}
|
|
480
|
+
},
|
|
735
481
|
});
|
|
736
482
|
|
|
737
|
-
//
|
|
738
|
-
const response = await ai.chat.get(
|
|
739
|
-
|
|
740
|
-
metadata: {
|
|
741
|
-
|
|
742
|
-
category: 'support'
|
|
743
|
-
}
|
|
744
|
-
});
|
|
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
|
+
);
|
|
745
488
|
```
|
|
746
489
|
|
|
747
490
|
### Lifecycle Hooks
|
|
748
491
|
|
|
749
|
-
Intercept and modify AI operations at key points:
|
|
750
|
-
|
|
751
492
|
```typescript
|
|
752
|
-
const ai = AI.with<AppContext
|
|
753
|
-
.providers({ openai
|
|
754
|
-
.create({
|
|
755
|
-
// ... config
|
|
756
|
-
})
|
|
493
|
+
const ai = AI.with<AppContext>()
|
|
494
|
+
.providers({ openai })
|
|
495
|
+
.create({ /* ... */ })
|
|
757
496
|
.withHooks({
|
|
758
|
-
beforeModelSelection: async (ctx, metadata) => {
|
|
759
|
-
// Adjust
|
|
760
|
-
if (ctx.user
|
|
761
|
-
return {
|
|
762
|
-
...metadata,
|
|
763
|
-
weights: { cost: 1.0, speed: 0 } // Prioritize cost
|
|
764
|
-
};
|
|
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 } };
|
|
765
501
|
}
|
|
766
502
|
return metadata;
|
|
767
503
|
},
|
|
768
504
|
|
|
769
|
-
onModelSelected: async (ctx, selected) => {
|
|
770
|
-
console.log(`Selected: ${selected.model.id}`);
|
|
771
|
-
|
|
772
|
-
// Override model for specific users
|
|
773
|
-
if (ctx.user.betaTester && selected.model.id === 'gpt-4') {
|
|
774
|
-
return {
|
|
775
|
-
...selected,
|
|
776
|
-
model: ai.models.get('gpt-4-turbo')!
|
|
777
|
-
};
|
|
778
|
-
}
|
|
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
|
|
779
508
|
},
|
|
780
509
|
|
|
781
|
-
beforeRequest: async (ctx, request, selected,
|
|
782
|
-
//
|
|
783
|
-
if (estimatedCost > ctx.user.
|
|
510
|
+
beforeRequest: async (ctx, request, selected, estimatedUsage, estimatedCost) => {
|
|
511
|
+
// Throw to cancel the request
|
|
512
|
+
if (ctx.user && estimatedCost > ctx.user.budgetRemaining) {
|
|
784
513
|
throw new Error('Insufficient budget');
|
|
785
514
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
model: selected.model.id,
|
|
791
|
-
estimatedTokens,
|
|
792
|
-
estimatedCost,
|
|
793
|
-
timestamp: new Date()
|
|
794
|
-
});
|
|
515
|
+
console.log(
|
|
516
|
+
`[${ctx.userId}] ${selected.model.id} — ` +
|
|
517
|
+
`~${estimatedUsage.text?.input ?? 0} input tokens, ~$${estimatedCost.toFixed(5)}`
|
|
518
|
+
);
|
|
795
519
|
},
|
|
796
520
|
|
|
797
521
|
afterRequest: async (ctx, request, response, responseComplete, selected, usage, cost) => {
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
successCount: 1,
|
|
809
|
-
totalTokens: usage.totalTokens,
|
|
810
|
-
totalCost: cost
|
|
811
|
-
});
|
|
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
|
+
);
|
|
812
532
|
},
|
|
813
533
|
|
|
814
|
-
onError: (
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
message,
|
|
818
|
-
error,
|
|
819
|
-
userId: ctx?.user?.id,
|
|
820
|
-
stack: error?.stack
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
// Send to monitoring
|
|
824
|
-
monitoring.captureException(error, {
|
|
825
|
-
tags: { type, userId: ctx?.user?.id }
|
|
826
|
-
});
|
|
827
|
-
}
|
|
534
|
+
onError: (errorType, message, error, ctx) => {
|
|
535
|
+
console.error(`[AI ${errorType}] ${message}`, { error, userId: ctx?.userId });
|
|
536
|
+
},
|
|
828
537
|
});
|
|
829
538
|
```
|
|
830
539
|
|
|
831
540
|
### Model Selection
|
|
832
541
|
|
|
833
|
-
Fine-tune model selection with weights and constraints:
|
|
834
|
-
|
|
835
542
|
```typescript
|
|
836
543
|
// Cost-optimized
|
|
837
|
-
await ai.chat.get(
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
speed: 0.05,
|
|
842
|
-
accuracy: 0.05
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
});
|
|
544
|
+
await ai.chat.get(
|
|
545
|
+
{ messages },
|
|
546
|
+
{ metadata: { weights: { cost: 0.9, speed: 0.05, accuracy: 0.05 } } }
|
|
547
|
+
);
|
|
846
548
|
|
|
847
|
-
//
|
|
848
|
-
await ai.chat.get(
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
speed: 0.5,
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
minContextWindow: 128000
|
|
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
|
+
}
|
|
856
557
|
}
|
|
857
|
-
|
|
558
|
+
);
|
|
858
559
|
|
|
859
560
|
// Provider-specific
|
|
860
|
-
await ai.chat.get(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
deny: ['replicate'] // Exclude Replicate
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
});
|
|
561
|
+
await ai.chat.get(
|
|
562
|
+
{ messages },
|
|
563
|
+
{ metadata: { providers: { allow: ['openai'], deny: ['replicate'] } } }
|
|
564
|
+
);
|
|
868
565
|
|
|
869
|
-
//
|
|
870
|
-
await ai.chat.get(
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
maxCostPerMillionTokens: 10.0
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
});
|
|
566
|
+
// Explicit model override
|
|
567
|
+
await ai.chat.get(
|
|
568
|
+
{ messages },
|
|
569
|
+
{ metadata: { model: 'openai/gpt-4o' } }
|
|
570
|
+
);
|
|
878
571
|
```
|
|
879
572
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
Use pre-configured profiles:
|
|
573
|
+
### Named Weight Profiles
|
|
883
574
|
|
|
884
575
|
```typescript
|
|
885
576
|
const ai = AI.with()
|
|
886
|
-
.providers({ openai,
|
|
577
|
+
.providers({ openai, openrouter })
|
|
887
578
|
.create({
|
|
888
|
-
|
|
889
|
-
costPriority:
|
|
890
|
-
balanced:
|
|
891
|
-
performance:
|
|
892
|
-
}
|
|
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
|
+
},
|
|
893
584
|
});
|
|
894
585
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
metadata: {
|
|
898
|
-
|
|
899
|
-
}
|
|
900
|
-
});
|
|
586
|
+
await ai.chat.get(
|
|
587
|
+
{ messages },
|
|
588
|
+
{ metadata: { weightProfile: 'balanced' } }
|
|
589
|
+
);
|
|
901
590
|
```
|
|
902
591
|
|
|
903
592
|
### Model Sources
|
|
904
593
|
|
|
905
|
-
Enrich model information from external registries:
|
|
906
|
-
|
|
907
594
|
```typescript
|
|
908
|
-
import {
|
|
595
|
+
import { OpenRouterModelSource } from '@aeye/openrouter';
|
|
596
|
+
|
|
597
|
+
const source = new OpenRouterModelSource({ apiKey: process.env.OPENROUTER_API_KEY });
|
|
909
598
|
|
|
910
599
|
const ai = AI.with()
|
|
911
|
-
.providers({ openai,
|
|
600
|
+
.providers({ openai, openrouter })
|
|
912
601
|
.create({
|
|
913
|
-
|
|
914
|
-
modelSources: [openRouterSource],
|
|
915
|
-
|
|
916
|
-
// Or configure it explicitly
|
|
917
|
-
fetchOpenRouterModels: {
|
|
918
|
-
enabled: true,
|
|
919
|
-
apiKey: process.env.OPENROUTER_API_KEY
|
|
920
|
-
}
|
|
602
|
+
modelSources: [source],
|
|
921
603
|
});
|
|
922
604
|
|
|
923
|
-
//
|
|
605
|
+
// Fetch and register all models from sources
|
|
924
606
|
await ai.models.refresh();
|
|
925
|
-
const models = ai.models.list();
|
|
926
607
|
```
|
|
927
608
|
|
|
928
|
-
###
|
|
929
|
-
|
|
930
|
-
You can create custom provider implementations - just implement the Provider interface.
|
|
609
|
+
### Model Overrides
|
|
931
610
|
|
|
932
|
-
|
|
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
|
+
```
|
|
933
625
|
|
|
934
|
-
###
|
|
626
|
+
### Custom Providers
|
|
935
627
|
|
|
936
|
-
|
|
628
|
+
Extend an existing provider or implement the `Provider<TConfig>` interface from `@aeye/core`:
|
|
937
629
|
|
|
938
630
|
```typescript
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
userId: string;
|
|
942
|
-
sessionId: string;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
interface MyMetadata {
|
|
946
|
-
priority: 'low' | 'high';
|
|
947
|
-
}
|
|
631
|
+
import { OpenAIProvider, OpenAIConfig } from '@aeye/openai';
|
|
632
|
+
import OpenAI from 'openai';
|
|
948
633
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
.providers({ openai })
|
|
952
|
-
.create({
|
|
953
|
-
defaultContext: {
|
|
954
|
-
sessionId: 'default' // ✓ Valid
|
|
955
|
-
// foo: 'bar' // ✗ TypeScript error
|
|
956
|
-
}
|
|
957
|
-
});
|
|
634
|
+
class MyProvider extends OpenAIProvider {
|
|
635
|
+
readonly name = 'my-provider';
|
|
958
636
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
metadata: {
|
|
965
|
-
priority: 'high' // ✓ Valid enum value
|
|
966
|
-
// priority: 'medium' // ✗ TypeScript error
|
|
637
|
+
protected createClient(config: OpenAIConfig) {
|
|
638
|
+
return new OpenAI({
|
|
639
|
+
apiKey: config.apiKey,
|
|
640
|
+
baseURL: 'https://my-api.example.com/v1',
|
|
641
|
+
});
|
|
967
642
|
}
|
|
968
|
-
}
|
|
643
|
+
}
|
|
969
644
|
```
|
|
970
645
|
|
|
971
646
|
### Extending AI Instances
|
|
972
647
|
|
|
973
|
-
|
|
648
|
+
Share providers and configuration between multiple AI instances:
|
|
974
649
|
|
|
975
650
|
```typescript
|
|
976
|
-
// Base AI instance
|
|
977
651
|
const baseAI = AI.with<BaseContext>()
|
|
978
|
-
.providers({ openai,
|
|
979
|
-
.create({ /*
|
|
652
|
+
.providers({ openai, openrouter })
|
|
653
|
+
.create({ /* base config */ });
|
|
980
654
|
|
|
981
|
-
//
|
|
982
|
-
interface
|
|
983
|
-
|
|
984
|
-
|
|
655
|
+
// Extend with additional context for a specific feature
|
|
656
|
+
interface ChatFeatureContext extends BaseContext {
|
|
657
|
+
chatId: string;
|
|
658
|
+
chatHistory: Message[];
|
|
985
659
|
}
|
|
986
660
|
|
|
987
|
-
const chatAI = baseAI.extend<
|
|
988
|
-
defaultContext: {
|
|
989
|
-
// chat and chatMessage will be provided per-request
|
|
990
|
-
},
|
|
991
|
-
modelOverrides: [
|
|
992
|
-
{
|
|
993
|
-
modelPattern: /gpt/,
|
|
994
|
-
overrides: {
|
|
995
|
-
pricing: { /* custom pricing */ }
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
]
|
|
661
|
+
const chatAI = baseAI.extend<ChatFeatureContext>({
|
|
662
|
+
defaultContext: { chatHistory: [] },
|
|
999
663
|
});
|
|
1000
664
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
chat,
|
|
1004
|
-
|
|
1005
|
-
});
|
|
665
|
+
const response = await chatAI.chat.get(
|
|
666
|
+
{ messages: [{ role: 'user', content: 'Hello' }] },
|
|
667
|
+
{ chatId: 'chat-123', chatHistory: [] }
|
|
668
|
+
);
|
|
1006
669
|
```
|
|
1007
670
|
|
|
1008
671
|
## Examples
|
|
1009
672
|
|
|
1010
|
-
### Complete Application
|
|
673
|
+
### Complete Application
|
|
1011
674
|
|
|
1012
675
|
```typescript
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
676
|
+
import { AI } from '@aeye/ai';
|
|
677
|
+
import { OpenAIProvider } from '@aeye/openai';
|
|
678
|
+
import { OpenRouterProvider } from '@aeye/openrouter';
|
|
679
|
+
import z from 'zod';
|
|
680
|
+
|
|
681
|
+
interface User {
|
|
682
|
+
id: string;
|
|
683
|
+
budgetRemaining: number;
|
|
684
|
+
totalSpent: number;
|
|
685
|
+
save: () => Promise<void>;
|
|
1020
686
|
}
|
|
1021
687
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
maxCost: number;
|
|
688
|
+
interface AppContext {
|
|
689
|
+
userId: string;
|
|
690
|
+
user?: User;
|
|
1026
691
|
}
|
|
1027
692
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
import { openai } from '@aeye/openai';
|
|
1031
|
-
import { anthropic } from '@aeye/anthropic';
|
|
693
|
+
const openai = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
|
|
694
|
+
const openrouter = new OpenRouterProvider({ apiKey: process.env.OPENROUTER_API_KEY! });
|
|
1032
695
|
|
|
1033
|
-
export const ai = AI.with<AppContext
|
|
1034
|
-
.providers({ openai,
|
|
696
|
+
export const ai = AI.with<AppContext>()
|
|
697
|
+
.providers({ openai, openrouter })
|
|
1035
698
|
.create({
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
},
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
throw new Error('Insufficient budget');
|
|
1060
|
-
}
|
|
1061
|
-
},
|
|
1062
|
-
afterRequest: async (ctx, request, response, responseComplete, selected, usage, cost) => {
|
|
1063
|
-
await trackUsage(ctx.user.id, usage, cost);
|
|
699
|
+
providedContext: async (ctx) => ({
|
|
700
|
+
user: await getUser(ctx.userId),
|
|
701
|
+
}),
|
|
702
|
+
defaultWeights: { cost: 0.5, speed: 0.3, accuracy: 0.2 },
|
|
703
|
+
hooks: {
|
|
704
|
+
beforeRequest: async (ctx, _request, selected, estimatedUsage, estimatedCost) => {
|
|
705
|
+
if (ctx.user && estimatedCost > ctx.user.budgetRemaining) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`Estimated cost $${estimatedCost.toFixed(4)} exceeds budget ` +
|
|
708
|
+
`$${ctx.user.budgetRemaining.toFixed(4)}`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
afterRequest: async (ctx, _request, _response, _complete, _selected, usage, cost) => {
|
|
713
|
+
if (ctx.user) {
|
|
714
|
+
ctx.user.budgetRemaining -= cost;
|
|
715
|
+
ctx.user.totalSpent += cost;
|
|
716
|
+
await ctx.user.save();
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
onError: (errorType, message, error, ctx) => {
|
|
720
|
+
console.error(`[AI ${errorType}] ${message}`, { userId: ctx?.userId, error });
|
|
721
|
+
},
|
|
1064
722
|
},
|
|
1065
|
-
onError: (type, message, error, ctx) => {
|
|
1066
|
-
logger.error({ type, message, error, userId: ctx?.user?.id });
|
|
1067
|
-
}
|
|
1068
723
|
});
|
|
1069
724
|
|
|
1070
|
-
//
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
)
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
{ role: 'user', content: message }
|
|
1079
|
-
],
|
|
1080
|
-
{
|
|
1081
|
-
userId,
|
|
1082
|
-
metadata: {
|
|
1083
|
-
feature: 'chat',
|
|
1084
|
-
priority: 'normal'
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
);
|
|
1088
|
-
|
|
1089
|
-
return response.content;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// features/analysis.ts
|
|
1093
|
-
export async function analyzeDocument(
|
|
1094
|
-
userId: string,
|
|
1095
|
-
document: string
|
|
1096
|
-
) {
|
|
1097
|
-
const response = await ai.chat.get(
|
|
1098
|
-
[
|
|
1099
|
-
{ role: 'system', content: 'You are a document analysis expert.' },
|
|
1100
|
-
{ role: 'user', content: `Analyze this document:\n\n${document}` }
|
|
1101
|
-
],
|
|
1102
|
-
{
|
|
1103
|
-
userId,
|
|
1104
|
-
metadata: {
|
|
1105
|
-
feature: 'analysis',
|
|
1106
|
-
priority: 'high',
|
|
1107
|
-
required: ['chat'],
|
|
1108
|
-
weights: { accuracy: 0.7, cost: 0.3 }
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
);
|
|
1112
|
-
|
|
1113
|
-
return response.content;
|
|
1114
|
-
}
|
|
1115
|
-
```
|
|
1116
|
-
|
|
1117
|
-
### Prompt Engineering with Context
|
|
1118
|
-
|
|
1119
|
-
```typescript
|
|
1120
|
-
import { ai } from './ai';
|
|
1121
|
-
|
|
1122
|
-
const summarizer = ai.prompt({
|
|
1123
|
-
name: 'summarizer',
|
|
1124
|
-
description: 'Summarize documents',
|
|
1125
|
-
|
|
1126
|
-
input: async (params: { document: string }, ctx) => {
|
|
1127
|
-
// Access context in prompt construction
|
|
1128
|
-
const userPrefs = await ctx.db.getUserPreferences(ctx.user.id);
|
|
1129
|
-
|
|
1130
|
-
return [{
|
|
1131
|
-
role: 'system',
|
|
1132
|
-
content: `Summarize documents in ${userPrefs.language}.`
|
|
1133
|
-
}, {
|
|
1134
|
-
role: 'user',
|
|
1135
|
-
content: `Summarize:\n\n${params.document}`
|
|
1136
|
-
}];
|
|
725
|
+
// Define a tool
|
|
726
|
+
const webSearch = ai.tool({
|
|
727
|
+
name: 'webSearch',
|
|
728
|
+
description: 'Search the web for recent information',
|
|
729
|
+
schema: z.object({ query: z.string() }),
|
|
730
|
+
call: async ({ query }) => {
|
|
731
|
+
// Integrate with a search API
|
|
732
|
+
return { results: [`Result for: ${query}`] };
|
|
1137
733
|
},
|
|
1138
|
-
|
|
1139
|
-
config: {
|
|
1140
|
-
maxTokens: 500,
|
|
1141
|
-
temperature: 0.3
|
|
1142
|
-
}
|
|
1143
734
|
});
|
|
1144
735
|
|
|
1145
|
-
//
|
|
1146
|
-
const
|
|
1147
|
-
{ document: longText },
|
|
1148
|
-
{ userId: '123' }
|
|
1149
|
-
);
|
|
1150
|
-
```
|
|
1151
|
-
|
|
1152
|
-
### Multi-Step Agent
|
|
1153
|
-
|
|
1154
|
-
```typescript
|
|
1155
|
-
const researchAgent = ai.agent({
|
|
736
|
+
// Define a research prompt
|
|
737
|
+
const researcher = ai.prompt({
|
|
1156
738
|
name: 'researcher',
|
|
1157
|
-
description: '
|
|
1158
|
-
|
|
1159
|
-
call: async (input: { topic: string }, refs, ctx) => {
|
|
1160
|
-
// Step 1: Generate research questions
|
|
1161
|
-
const questions = await ai.chat.get([{
|
|
1162
|
-
role: 'user',
|
|
1163
|
-
content: `Generate 3 research questions about: ${input.topic}`
|
|
1164
|
-
}], ctx);
|
|
1165
|
-
|
|
1166
|
-
// Step 2: Answer each question
|
|
1167
|
-
const answers = [];
|
|
1168
|
-
for (const question of parseQuestions(questions.content)) {
|
|
1169
|
-
const answer = await ai.chat.get([{
|
|
1170
|
-
role: 'user',
|
|
1171
|
-
content: question
|
|
1172
|
-
}], ctx);
|
|
1173
|
-
answers.push({ question, answer: answer.content });
|
|
1174
|
-
}
|
|
739
|
+
description: 'Researches a topic and provides a structured summary',
|
|
740
|
+
content: `You are a research assistant. Research the following topic and provide a clear summary.
|
|
1175
741
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
}
|
|
742
|
+
Topic: {{topic}}`,
|
|
743
|
+
input: (input: { topic: string }) => ({ topic: input.topic }),
|
|
744
|
+
tools: [webSearch],
|
|
745
|
+
schema: z.object({
|
|
746
|
+
summary: z.string(),
|
|
747
|
+
keyFindings: z.array(z.string()),
|
|
748
|
+
sources: z.array(z.string()),
|
|
749
|
+
}),
|
|
1184
750
|
});
|
|
1185
751
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
752
|
+
// Use the prompt
|
|
753
|
+
async function researchTopic(userId: string, topic: string) {
|
|
754
|
+
const result = await researcher.get('result', { topic }, { userId });
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const findings = await researchTopic('user123', 'TypeScript 5.0 features');
|
|
759
|
+
console.log(findings?.summary);
|
|
760
|
+
console.log(findings?.keyFindings);
|
|
1190
761
|
```
|
|
1191
762
|
|
|
1192
763
|
## License
|
|
1193
764
|
|
|
1194
|
-
GPL-3.0
|
|
765
|
+
GPL-3.0 © [ClickerMonkey](https://github.com/ClickerMonkey)
|
|
1195
766
|
|
|
1196
|
-
|
|
767
|
+
## Links
|
|
1197
768
|
|
|
1198
|
-
|
|
1199
|
-
-
|
|
1200
|
-
-
|
|
1201
|
-
-
|
|
769
|
+
- [Root README](../../README.md)
|
|
770
|
+
- [@aeye/core README](../core/README.md)
|
|
771
|
+
- [GitHub Repository](https://github.com/ClickerMonkey/aeye)
|
|
772
|
+
- [Issue Tracker](https://github.com/ClickerMonkey/aeye/issues)
|