@bratsos/workflow-engine 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,587 @@
1
+ # AI Integration
2
+
3
+ Complete guide for using AIHelper for text generation, structured output, embeddings, and batch operations.
4
+
5
+ ## Topic Convention for Cost Tracking
6
+
7
+ **Topics are hierarchical strings that enable cost aggregation at different levels.**
8
+
9
+ ### The Convention
10
+
11
+ ```
12
+ workflow.{workflowRunId}.stage.{stageId}.{optional-suffix}
13
+ ```
14
+
15
+ | Topic Pattern | Use Case | Example |
16
+ |---------------|----------|---------|
17
+ | `workflow.{runId}` | Root topic for a workflow run | `workflow.abc123` |
18
+ | `workflow.{runId}.stage.{stageId}` | Specific stage | `workflow.abc123.stage.extraction` |
19
+ | `workflow.{runId}.stage.{stageId}.tool.{name}` | Tool call within a stage | `workflow.abc123.stage.extraction.tool.search` |
20
+ | `task.{taskId}` | Standalone task (not a workflow) | `task.process-document-456` |
21
+
22
+ ### How Cost Aggregation Works
23
+
24
+ The `AICallLogger.getStats(topicPrefix)` method uses **prefix matching** to aggregate costs:
25
+
26
+ ```typescript
27
+ // All AI calls are logged with their topic
28
+ ai.generateText("gemini-2.5-flash", prompt); // Logged with topic "workflow.abc123.stage.extraction"
29
+
30
+ // Later, aggregate by prefix:
31
+ const workflowStats = await aiLogger.getStats("workflow.abc123");
32
+ // Returns: all costs for that workflow run (all stages)
33
+
34
+ const stageStats = await aiLogger.getStats("workflow.abc123.stage.extraction");
35
+ // Returns: costs for just that stage
36
+
37
+ const allStats = await aiLogger.getStats("workflow");
38
+ // Returns: costs for ALL workflows
39
+ ```
40
+
41
+ ### Automatic Workflow Cost Tracking
42
+
43
+ When a workflow completes, the executor automatically calculates total cost:
44
+
45
+ ```typescript
46
+ // Inside WorkflowExecutor (automatic)
47
+ const stats = await aiLogger.getStats(`workflow.${workflowRunId}`);
48
+ await persistence.updateRun(workflowRunId, {
49
+ totalCost: stats.totalCost,
50
+ totalTokens: stats.totalInputTokens + stats.totalOutputTokens,
51
+ });
52
+ ```
53
+
54
+ **Result:** `WorkflowRun.totalCost` and `WorkflowRun.totalTokens` are populated automatically.
55
+
56
+ ### Using Topics in Stages
57
+
58
+ ```typescript
59
+ const extractStage = defineStage({
60
+ id: "extraction",
61
+ // ...
62
+ async execute(ctx) {
63
+ // Create AI helper with proper topic convention
64
+ const ai = runtime.createAIHelper(
65
+ `workflow.${ctx.workflowRunId}.stage.${ctx.stageId}`
66
+ );
67
+
68
+ // All AI calls are now tracked under this topic
69
+ const { text } = await ai.generateText("gemini-2.5-flash", prompt);
70
+
71
+ return { output: { extracted: text } };
72
+ },
73
+ });
74
+ ```
75
+
76
+ ### Querying Costs
77
+
78
+ ```typescript
79
+ // Get costs for a specific workflow run
80
+ const runStats = await aiLogger.getStats(`workflow.${workflowRunId}`);
81
+ console.log(`Workflow cost: $${runStats.totalCost.toFixed(4)}`);
82
+ console.log(`Total tokens: ${runStats.totalInputTokens + runStats.totalOutputTokens}`);
83
+ console.log(`Calls by model:`, runStats.perModel);
84
+
85
+ // Get costs for a specific stage
86
+ const stageStats = await aiLogger.getStats(`workflow.${workflowRunId}.stage.extraction`);
87
+
88
+ // Get costs across all workflows (for reporting)
89
+ const allStats = await aiLogger.getStats("workflow");
90
+ ```
91
+
92
+ ### Stats Response Type
93
+
94
+ ```typescript
95
+ interface AIHelperStats {
96
+ totalCalls: number;
97
+ totalInputTokens: number;
98
+ totalOutputTokens: number;
99
+ totalCost: number;
100
+ perModel: {
101
+ [modelKey: string]: {
102
+ calls: number;
103
+ inputTokens: number;
104
+ outputTokens: number;
105
+ cost: number;
106
+ };
107
+ };
108
+ }
109
+ ```
110
+
111
+ ## Creating an AIHelper
112
+
113
+ ```typescript
114
+ import { createAIHelper } from "@bratsos/workflow-engine";
115
+ import { createPrismaAICallLogger } from "@bratsos/workflow-engine/persistence/prisma";
116
+
117
+ // Basic usage (with topic convention)
118
+ const ai = createAIHelper(`workflow.${runId}.stage.${stageId}`, aiCallLogger);
119
+
120
+ // With log context (for batch persistence)
121
+ const logContext = {
122
+ workflowRunId: "run-123",
123
+ stageRecordId: "stage-456",
124
+ createLog: (data) => persistence.createLog(data),
125
+ };
126
+ const ai = createAIHelper("workflow.run-123", aiCallLogger, logContext);
127
+
128
+ // From runtime (preferred in stages)
129
+ const ai = runtime.createAIHelper(`workflow.${ctx.workflowRunId}.stage.${ctx.stageId}`);
130
+ ```
131
+
132
+ ## AIHelper Interface
133
+
134
+ ```typescript
135
+ interface AIHelper {
136
+ readonly topic: string;
137
+
138
+ generateText(modelKey, prompt, options?): Promise<AITextResult>;
139
+ generateObject(modelKey, prompt, schema, options?): Promise<AIObjectResult>;
140
+ embed(modelKey, text, options?): Promise<AIEmbedResult>;
141
+ streamText(modelKey, input, options?): AIStreamResult;
142
+ batch(modelKey, provider?): AIBatch;
143
+
144
+ createChild(segment, id?): AIHelper;
145
+ recordCall(params): void;
146
+ getStats(): Promise<AIHelperStats>;
147
+ }
148
+ ```
149
+
150
+ ## generateText
151
+
152
+ Generate text from a prompt.
153
+
154
+ ```typescript
155
+ const result = await ai.generateText(
156
+ "gemini-2.5-flash", // Model key
157
+ "Explain quantum computing in simple terms",
158
+ {
159
+ temperature: 0.7, // 0-2, default 0.7
160
+ maxTokens: 1000, // Max output tokens
161
+ }
162
+ );
163
+
164
+ console.log(result.text); // Generated text
165
+ console.log(result.inputTokens); // Token count
166
+ console.log(result.outputTokens);
167
+ console.log(result.cost); // Calculated cost
168
+ ```
169
+
170
+ ### Multimodal Input
171
+
172
+ ```typescript
173
+ // With images/PDFs
174
+ const result = await ai.generateText("gemini-2.5-flash", [
175
+ { type: "text", text: "What's in this image?" },
176
+ {
177
+ type: "file",
178
+ data: imageBuffer, // Buffer, Uint8Array, or base64 string
179
+ mediaType: "image/png",
180
+ filename: "image.png",
181
+ },
182
+ ]);
183
+ ```
184
+
185
+ ### With Tools
186
+
187
+ ```typescript
188
+ import { tool } from "ai";
189
+
190
+ const result = await ai.generateText("gemini-2.5-flash", "What's the weather?", {
191
+ tools: {
192
+ getWeather: tool({
193
+ description: "Get weather for a location",
194
+ parameters: z.object({ location: z.string() }),
195
+ execute: async ({ location }) => {
196
+ return { temperature: 72, condition: "sunny" };
197
+ },
198
+ }),
199
+ },
200
+ toolChoice: "auto", // or "required", "none", { type: "tool", toolName: "getWeather" }
201
+ onStepFinish: async (step) => {
202
+ console.log("Tool results:", step.toolResults);
203
+ },
204
+ });
205
+ ```
206
+
207
+ ## generateObject
208
+
209
+ Generate structured output with Zod schema validation.
210
+
211
+ ```typescript
212
+ const OutputSchema = z.object({
213
+ title: z.string(),
214
+ tags: z.array(z.string()),
215
+ sentiment: z.enum(["positive", "negative", "neutral"]),
216
+ });
217
+
218
+ const result = await ai.generateObject(
219
+ "gemini-2.5-flash",
220
+ "Analyze this article: ...",
221
+ OutputSchema,
222
+ {
223
+ temperature: 0, // Lower for structured output
224
+ maxTokens: 500,
225
+ }
226
+ );
227
+
228
+ console.log(result.object); // Typed as z.infer<typeof OutputSchema>
229
+ // { title: "...", tags: ["tech", "ai"], sentiment: "positive" }
230
+ ```
231
+
232
+ ### Multimodal with Schema
233
+
234
+ ```typescript
235
+ const result = await ai.generateObject(
236
+ "gemini-2.5-flash",
237
+ [
238
+ { type: "text", text: "Extract text from this document" },
239
+ { type: "file", data: pdfBuffer, mediaType: "application/pdf" },
240
+ ],
241
+ z.object({
242
+ title: z.string(),
243
+ content: z.string(),
244
+ pageCount: z.number(),
245
+ })
246
+ );
247
+ ```
248
+
249
+ ## embed
250
+
251
+ Generate embeddings for text.
252
+
253
+ ```typescript
254
+ // Single text
255
+ const result = await ai.embed(
256
+ "text-embedding-004",
257
+ "The quick brown fox",
258
+ {
259
+ dimensions: 768, // Output dimensions (default: 768)
260
+ taskType: "RETRIEVAL_DOCUMENT", // or "RETRIEVAL_QUERY", "SEMANTIC_SIMILARITY"
261
+ }
262
+ );
263
+
264
+ console.log(result.embedding); // number[] (768 dimensions)
265
+ console.log(result.dimensions); // 768
266
+ console.log(result.inputTokens);
267
+ console.log(result.cost);
268
+
269
+ // Multiple texts (batch)
270
+ const result = await ai.embed("text-embedding-004", [
271
+ "First document",
272
+ "Second document",
273
+ "Third document",
274
+ ]);
275
+
276
+ console.log(result.embeddings); // number[][] (3 embeddings)
277
+ console.log(result.embedding); // First embedding (convenience)
278
+ ```
279
+
280
+ ## streamText
281
+
282
+ Stream text generation.
283
+
284
+ ```typescript
285
+ const result = ai.streamText(
286
+ "gemini-2.5-flash",
287
+ { prompt: "Write a story about a robot" },
288
+ {
289
+ temperature: 0.9,
290
+ onChunk: (chunk) => process.stdout.write(chunk),
291
+ }
292
+ );
293
+
294
+ // Consume the stream
295
+ for await (const chunk of result.stream) {
296
+ console.log(chunk);
297
+ }
298
+
299
+ // Get usage after stream completes
300
+ const usage = await result.getUsage();
301
+ console.log(usage.cost);
302
+
303
+ // Or use raw AI SDK result for UI streaming
304
+ const response = result.rawResult.toUIMessageStreamResponse();
305
+ ```
306
+
307
+ ### With Messages
308
+
309
+ ```typescript
310
+ const result = ai.streamText("gemini-2.5-flash", {
311
+ system: "You are a helpful assistant.",
312
+ messages: [
313
+ { role: "user", content: "Hello!" },
314
+ { role: "assistant", content: "Hi! How can I help?" },
315
+ { role: "user", content: "Tell me a joke." },
316
+ ],
317
+ });
318
+ ```
319
+
320
+ ## batch
321
+
322
+ Submit batch operations for 50% cost savings.
323
+
324
+ ```typescript
325
+ const batch = ai.batch<OutputType>("claude-sonnet-4-20250514", "anthropic");
326
+
327
+ // Submit requests
328
+ const handle = await batch.submit([
329
+ { id: "req-1", prompt: "Summarize: ..." },
330
+ { id: "req-2", prompt: "Summarize: ...", schema: SummarySchema },
331
+ ]);
332
+
333
+ console.log(handle.id); // Batch ID
334
+ console.log(handle.status); // "pending"
335
+ console.log(handle.provider); // "anthropic"
336
+
337
+ // Check status
338
+ const status = await batch.getStatus(handle.id);
339
+ // { id: "...", status: "processing" | "completed" | "failed", provider: "anthropic" }
340
+
341
+ // Get results (when completed)
342
+ const results = await batch.getResults(handle.id);
343
+ // [
344
+ // { id: "req-1", result: "...", inputTokens: 100, outputTokens: 50, status: "succeeded" },
345
+ // { id: "req-2", result: { parsed: "object" }, inputTokens: 100, outputTokens: 50, status: "succeeded" },
346
+ // ]
347
+
348
+ // Check if already recorded (avoid duplicate logging)
349
+ const recorded = await batch.isRecorded(handle.id);
350
+ ```
351
+
352
+ ### Batch Providers
353
+
354
+ | Provider | Models | Discount |
355
+ |----------|--------|----------|
356
+ | `anthropic` | Claude models | 50% |
357
+ | `google` | Gemini models | 50% |
358
+ | `openai` | GPT models | 50% |
359
+
360
+ ```typescript
361
+ // Provider auto-detected based on model
362
+ const batch = ai.batch("claude-sonnet-4-20250514"); // Uses anthropic
363
+
364
+ // Or specify explicitly
365
+ const batch = ai.batch("gemini-2.5-flash", "google");
366
+ ```
367
+
368
+ ## Child Helpers
369
+
370
+ Create child helpers for hierarchical topic tracking.
371
+
372
+ ```typescript
373
+ const rootAI = createAIHelper("workflow.abc123", logger);
374
+
375
+ // Create child for a specific stage
376
+ const stageAI = rootAI.createChild("stage", "extraction");
377
+ // topic: "workflow.abc123.stage.extraction"
378
+
379
+ // Create child for a tool call
380
+ const toolAI = stageAI.createChild("tool", "search");
381
+ // topic: "workflow.abc123.stage.extraction.tool.search"
382
+ ```
383
+
384
+ ## Manual Recording
385
+
386
+ Record AI calls made outside the helper (e.g., direct SDK usage).
387
+
388
+ ```typescript
389
+ // Object-based API
390
+ ai.recordCall({
391
+ modelKey: "gemini-2.5-flash",
392
+ callType: "text",
393
+ prompt: "...",
394
+ response: "...",
395
+ inputTokens: 100,
396
+ outputTokens: 50,
397
+ metadata: { custom: "data" },
398
+ });
399
+
400
+ // Legacy positional API
401
+ ai.recordCall(
402
+ "gemini-2.5-flash",
403
+ "prompt text",
404
+ "response text",
405
+ { input: 100, output: 50 },
406
+ { callType: "text", isBatch: false }
407
+ );
408
+ ```
409
+
410
+ ## Statistics
411
+
412
+ Get aggregated stats for a topic prefix.
413
+
414
+ ```typescript
415
+ const stats = await ai.getStats();
416
+ // {
417
+ // totalCalls: 42,
418
+ // totalInputTokens: 50000,
419
+ // totalOutputTokens: 25000,
420
+ // totalCost: 0.15,
421
+ // perModel: {
422
+ // "gemini-2.5-flash": { calls: 30, inputTokens: 40000, outputTokens: 20000, cost: 0.10 },
423
+ // "claude-sonnet-4-20250514": { calls: 12, inputTokens: 10000, outputTokens: 5000, cost: 0.05 },
424
+ // },
425
+ // }
426
+ ```
427
+
428
+ ## Model Configuration
429
+
430
+ ### Available Models
431
+
432
+ ```typescript
433
+ import { AVAILABLE_MODELS, listModels } from "@bratsos/workflow-engine";
434
+
435
+ // List all models
436
+ const models = listModels();
437
+ // [
438
+ // { key: "gemini-2.5-flash", id: "google/gemini-2.5-flash-preview-05-20", ... },
439
+ // { key: "claude-sonnet-4-20250514", id: "anthropic/claude-sonnet-4-20250514", ... },
440
+ // ...
441
+ // ]
442
+
443
+ // Filter models
444
+ const flashModels = listModels({ provider: "google", capabilities: ["embedding"] });
445
+ ```
446
+
447
+ ### Register Custom Models
448
+
449
+ ```typescript
450
+ import { registerModels } from "@bratsos/workflow-engine";
451
+
452
+ registerModels({
453
+ "my-custom-model": {
454
+ id: "openrouter/my-model",
455
+ provider: "openrouter",
456
+ inputCostPerMillion: 0.5,
457
+ outputCostPerMillion: 1.0,
458
+ contextWindow: 128000,
459
+ maxOutput: 4096,
460
+ capabilities: ["text", "vision"],
461
+ },
462
+ });
463
+
464
+ // Now usable
465
+ const result = await ai.generateText("my-custom-model", prompt);
466
+ ```
467
+
468
+ ### Cost Calculation
469
+
470
+ ```typescript
471
+ import { calculateCost } from "@bratsos/workflow-engine";
472
+
473
+ const cost = calculateCost("gemini-2.5-flash", 1000, 500);
474
+ // {
475
+ // inputCost: 0.00015,
476
+ // outputCost: 0.0003,
477
+ // totalCost: 0.00045,
478
+ // }
479
+ ```
480
+
481
+ ## Result Types
482
+
483
+ ```typescript
484
+ interface AITextResult {
485
+ text: string;
486
+ inputTokens: number;
487
+ outputTokens: number;
488
+ cost: number;
489
+ output?: any; // Present when experimental_output is used
490
+ }
491
+
492
+ interface AIObjectResult<T> {
493
+ object: T;
494
+ inputTokens: number;
495
+ outputTokens: number;
496
+ cost: number;
497
+ }
498
+
499
+ interface AIEmbedResult {
500
+ embedding: number[]; // First embedding
501
+ embeddings: number[][]; // All embeddings
502
+ dimensions: number;
503
+ inputTokens: number;
504
+ cost: number;
505
+ }
506
+
507
+ interface AIStreamResult {
508
+ stream: AsyncIterable<string>;
509
+ getUsage(): Promise<{ inputTokens, outputTokens, cost }>;
510
+ rawResult: AISDKStreamResult;
511
+ }
512
+
513
+ interface AIBatchResult<T = string> {
514
+ id: string;
515
+ prompt: string;
516
+ result: T;
517
+ inputTokens: number;
518
+ outputTokens: number;
519
+ status: "succeeded" | "failed";
520
+ error?: string;
521
+ }
522
+ ```
523
+
524
+ ## Complete Example
525
+
526
+ ```typescript
527
+ import {
528
+ createAIHelper,
529
+ createPrismaAICallLogger,
530
+ registerModels,
531
+ } from "@bratsos/workflow-engine";
532
+ import { z } from "zod";
533
+
534
+ // Setup
535
+ const logger = createPrismaAICallLogger(prisma);
536
+ const ai = createAIHelper("document-processor", logger);
537
+
538
+ // Register a custom model if needed
539
+ registerModels({
540
+ "fast-model": {
541
+ id: "openrouter/fast-model",
542
+ provider: "openrouter",
543
+ inputCostPerMillion: 0.1,
544
+ outputCostPerMillion: 0.2,
545
+ },
546
+ });
547
+
548
+ // Processing pipeline
549
+ async function processDocument(content: string) {
550
+ // 1. Extract structure
551
+ const { object: structure } = await ai.generateObject(
552
+ "gemini-2.5-flash",
553
+ `Extract structure from:\n\n${content}`,
554
+ z.object({
555
+ title: z.string(),
556
+ sections: z.array(z.object({
557
+ heading: z.string(),
558
+ content: z.string(),
559
+ })),
560
+ })
561
+ );
562
+
563
+ // 2. Generate embeddings for each section
564
+ const embeddings = await ai.embed(
565
+ "text-embedding-004",
566
+ structure.sections.map(s => s.content)
567
+ );
568
+
569
+ // 3. Generate summary
570
+ const { text: summary } = await ai.generateText(
571
+ "gemini-2.5-flash",
572
+ `Summarize in 2 sentences:\n\n${content}`,
573
+ { maxTokens: 100 }
574
+ );
575
+
576
+ // 4. Check stats
577
+ const stats = await ai.getStats();
578
+ console.log(`Total cost: $${stats.totalCost.toFixed(4)}`);
579
+
580
+ return {
581
+ structure,
582
+ embeddings: embeddings.embeddings,
583
+ summary,
584
+ cost: stats.totalCost,
585
+ };
586
+ }
587
+ ```