@genui-a3/create 0.1.8 → 0.1.9
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/package.json +1 -1
- package/template/.cursor/rules/example-app.mdc +9 -0
- package/template/.cursorrules +112 -0
- package/template/CLAUDE.md +112 -0
- package/template/README.md +4 -0
- package/template/app/agents/age.ts +1 -0
- package/template/app/agents/greeting.ts +1 -0
- package/template/app/lib/providers/anthropic.ts +1 -1
- package/template/docs/CUSTOM_LOGGING.md +36 -0
- package/template/docs/CUSTOM_PROVIDERS.md +642 -0
- package/template/docs/LOGGING.md +104 -0
- package/template/docs/PROVIDERS.md +217 -0
- package/template/docs/QUICK-START-EXAMPLES.md +197 -0
- package/template/docs/RESILIENCE.md +226 -0
- package/template/package.json +2 -2
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
# Creating a Custom Provider
|
|
2
|
+
|
|
3
|
+
This guide walks you through building an A3 provider for any LLM that isn't covered by the built-in Bedrock, OpenAI, or Anthropic packages.
|
|
4
|
+
By the end you'll have a working `Provider` implementation with both blocking and streaming support.
|
|
5
|
+
|
|
6
|
+
## When to Create a Custom Provider
|
|
7
|
+
|
|
8
|
+
Create a custom provider when you need to connect A3 to an LLM that doesn't have a built-in provider package — for example Google Gemini, Cohere, Mistral, or a locally-hosted model.
|
|
9
|
+
|
|
10
|
+
A provider is a thin adapter.
|
|
11
|
+
Its job is to:
|
|
12
|
+
|
|
13
|
+
1. Convert A3's provider-agnostic request format into your LLM's API format
|
|
14
|
+
1. Send the request
|
|
15
|
+
1. Convert the response back into A3's expected format (JSON string for blocking, AG-UI events for streaming)
|
|
16
|
+
|
|
17
|
+
## The Provider Interface
|
|
18
|
+
|
|
19
|
+
Every provider implements the `Provider` interface from `@genui-a3/core`:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { ZodType } from 'zod'
|
|
23
|
+
|
|
24
|
+
interface Provider {
|
|
25
|
+
/** Blocking request that returns a structured JSON response */
|
|
26
|
+
sendRequest(request: ProviderRequest): Promise<ProviderResponse>
|
|
27
|
+
|
|
28
|
+
/** Streaming request that yields AG-UI compatible events */
|
|
29
|
+
sendRequestStream<TState extends BaseState = BaseState>(
|
|
30
|
+
request: ProviderRequest,
|
|
31
|
+
): AsyncIterable<StreamEvent<TState>>
|
|
32
|
+
|
|
33
|
+
/** Human-readable name for logging */
|
|
34
|
+
readonly name: string
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Member | Description |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `sendRequest(request)` | Blocking call. Returns a `Promise<ProviderResponse>` containing the full JSON response. |
|
|
41
|
+
| `sendRequestStream(request)` | Streaming call. Returns an `AsyncIterable` (typically an `AsyncGenerator`) of AG-UI `StreamEvent`s. |
|
|
42
|
+
| `name` | A human-readable string used in log messages (e.g. `'gemini'`, `'mistral'`). |
|
|
43
|
+
|
|
44
|
+
## What Your Provider Receives
|
|
45
|
+
|
|
46
|
+
Both methods receive a `ProviderRequest`:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
interface ProviderRequest {
|
|
50
|
+
/** System prompt including agent instructions */
|
|
51
|
+
systemPrompt: string
|
|
52
|
+
/** Conversation messages */
|
|
53
|
+
messages: ProviderMessage[]
|
|
54
|
+
/** Zod schema for structured response validation */
|
|
55
|
+
responseSchema: ZodType
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ProviderMessage {
|
|
59
|
+
role: 'user' | 'assistant'
|
|
60
|
+
content: string
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Field | Description |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `systemPrompt` | The full system prompt, including the agent's instructions, base prompt, and transition context. Pass this as the system message to your LLM. |
|
|
67
|
+
| `messages` | Conversation history in chronological order. Already converted to simple `{ role, content }` pairs. |
|
|
68
|
+
| `responseSchema` | A Zod schema describing the exact JSON structure the LLM must return. Use this for validation and to generate a JSON schema for the LLM. |
|
|
69
|
+
|
|
70
|
+
### The Output Schema Structure
|
|
71
|
+
|
|
72
|
+
The `responseSchema` is a Zod object that always includes these base fields, merged with the agent's custom `outputSchema`:
|
|
73
|
+
|
|
74
|
+
| Field | Type | Description |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `chatbotMessage` | `string` | The agent's text response to the user (streamed in real-time) |
|
|
77
|
+
| `goalAchieved` | `boolean` | Whether the agent considers its goal complete |
|
|
78
|
+
| `redirectToAgent` | `string \| null` | Next agent ID for LLM-driven transitions, or `null` to stay |
|
|
79
|
+
| `conversationPayload` | `object` | Agent-specific structured data (defined by the agent's `outputSchema`) |
|
|
80
|
+
| `widgets` | `object \| undefined` | Optional widget data for UI rendering |
|
|
81
|
+
|
|
82
|
+
The LLM must return valid JSON matching this schema.
|
|
83
|
+
The `chatbotMessage` field is the text that gets streamed to the user in real-time during streaming mode.
|
|
84
|
+
|
|
85
|
+
## Implementing `sendRequest` (Blocking)
|
|
86
|
+
|
|
87
|
+
The blocking path is straightforward: call the LLM, get JSON back, validate it.
|
|
88
|
+
|
|
89
|
+
### Step-by-Step
|
|
90
|
+
|
|
91
|
+
1. **Convert the Zod schema to JSON Schema** — call `responseSchema.toJSONSchema()` (Zod v4) to get a plain JSON schema object your LLM can understand
|
|
92
|
+
1. **Format and send the request** — convert messages to your LLM's format and call its API
|
|
93
|
+
1. **Extract the JSON response** — parse the LLM's output to get a JSON object
|
|
94
|
+
1. **Return a `ProviderResponse`** — wrap the JSON string with optional usage info
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
interface ProviderResponse {
|
|
98
|
+
/** JSON string matching the response schema */
|
|
99
|
+
content: string
|
|
100
|
+
/** Optional token usage information */
|
|
101
|
+
usage?: {
|
|
102
|
+
inputTokens?: number
|
|
103
|
+
outputTokens?: number
|
|
104
|
+
totalTokens?: number
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Example
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
async sendRequest(request: ProviderRequest): Promise<ProviderResponse> {
|
|
113
|
+
// 1. Convert Zod schema to JSON Schema
|
|
114
|
+
const jsonSchema = request.responseSchema.toJSONSchema()
|
|
115
|
+
|
|
116
|
+
// 2. Call your LLM
|
|
117
|
+
const response = await myLLM.chat({
|
|
118
|
+
system: request.systemPrompt,
|
|
119
|
+
messages: request.messages.map(m => ({
|
|
120
|
+
role: m.role,
|
|
121
|
+
content: m.content,
|
|
122
|
+
})),
|
|
123
|
+
responseFormat: { type: 'json_schema', schema: jsonSchema },
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// 3. Validate the response (throws ZodError if invalid)
|
|
127
|
+
const validated = request.responseSchema.parse(JSON.parse(response.text))
|
|
128
|
+
|
|
129
|
+
// 4. Return ProviderResponse
|
|
130
|
+
return {
|
|
131
|
+
content: JSON.stringify(validated),
|
|
132
|
+
usage: {
|
|
133
|
+
inputTokens: response.usage?.input,
|
|
134
|
+
outputTokens: response.usage?.output,
|
|
135
|
+
totalTokens: response.usage?.total,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Implementing `sendRequestStream` (Streaming)
|
|
142
|
+
|
|
143
|
+
Streaming is where most of the complexity lives.
|
|
144
|
+
Your provider must yield AG-UI events that the A3 framework consumes.
|
|
145
|
+
|
|
146
|
+
### Events Your Provider Yields
|
|
147
|
+
|
|
148
|
+
Your provider is only responsible for yielding **three** event types:
|
|
149
|
+
|
|
150
|
+
| Event | When | Purpose |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `TEXT_MESSAGE_CONTENT` | Each time new text is available | Delivers text deltas to the UI in real-time |
|
|
153
|
+
| `TOOL_CALL_RESULT` | Stream completes successfully | Delivers the full, validated JSON response |
|
|
154
|
+
| `RUN_ERROR` | Any error occurs | Reports the error — **never throw from a stream** |
|
|
155
|
+
|
|
156
|
+
### Events the Framework Handles
|
|
157
|
+
|
|
158
|
+
The framework (`simpleAgentResponseStream` in `src/core/agent.ts`) wraps your events with lifecycle events automatically.
|
|
159
|
+
Do **not** yield these yourself:
|
|
160
|
+
|
|
161
|
+
- `TEXT_MESSAGE_START` / `TEXT_MESSAGE_END` — opened/closed around your `TEXT_MESSAGE_CONTENT` events
|
|
162
|
+
- `RUN_STARTED` / `RUN_FINISHED` — emitted by `chatFlow.ts` and `AGUIAgent.ts`
|
|
163
|
+
|
|
164
|
+
### Text Delta Extraction
|
|
165
|
+
|
|
166
|
+
During streaming, the LLM progressively builds a JSON object.
|
|
167
|
+
Your job is to track the `chatbotMessage` field's growth and yield only the **new** characters as deltas.
|
|
168
|
+
|
|
169
|
+
The pattern used by all built-in providers:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
function extractDelta(
|
|
173
|
+
partial: Record<string, unknown>,
|
|
174
|
+
prevLength: number,
|
|
175
|
+
): string | null {
|
|
176
|
+
const chatbotMessage = partial.chatbotMessage
|
|
177
|
+
if (typeof chatbotMessage !== 'string' || chatbotMessage.length <= prevLength) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
return chatbotMessage.slice(prevLength)
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Use it in your stream loop:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
let prevMessageLength = 0
|
|
188
|
+
|
|
189
|
+
for await (const partial of partialObjects) {
|
|
190
|
+
const delta = extractDelta(partial, prevMessageLength)
|
|
191
|
+
if (delta) {
|
|
192
|
+
prevMessageLength += delta.length
|
|
193
|
+
yield {
|
|
194
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
195
|
+
messageId: '',
|
|
196
|
+
delta,
|
|
197
|
+
agentId,
|
|
198
|
+
} as StreamEvent<TState>
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Approach 1: Vercel AI SDK (Recommended)
|
|
204
|
+
|
|
205
|
+
If your LLM has a [Vercel AI SDK provider](https://sdk.vercel.ai/providers) (e.g. `@ai-sdk/google`, `@ai-sdk/mistral`), this is the easiest path.
|
|
206
|
+
The AI SDK handles partial JSON parsing, schema conversion, and streaming internally.
|
|
207
|
+
|
|
208
|
+
The pattern (used by the OpenAI and Anthropic providers):
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { streamText, Output, jsonSchema } from 'ai'
|
|
212
|
+
import { createMyLLM } from '@ai-sdk/my-llm' // hypothetical
|
|
213
|
+
import { EventType } from '@ag-ui/client'
|
|
214
|
+
import type { StreamEvent, BaseState } from '@genui-a3/core'
|
|
215
|
+
|
|
216
|
+
async *sendRequestStream<TState extends BaseState>(
|
|
217
|
+
request: ProviderRequest,
|
|
218
|
+
): AsyncGenerator<StreamEvent<TState>> {
|
|
219
|
+
const myLLM = createMyLLM({ apiKey: '...' })
|
|
220
|
+
|
|
221
|
+
// Start the stream with structured output
|
|
222
|
+
const result = streamText({
|
|
223
|
+
model: myLLM('my-model'),
|
|
224
|
+
system: request.systemPrompt,
|
|
225
|
+
messages: request.messages,
|
|
226
|
+
output: Output.object({ schema: toJsonSchema(request.responseSchema) }),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Iterate over partial objects
|
|
230
|
+
let prevMessageLength = 0
|
|
231
|
+
for await (const partial of result.partialOutputStream) {
|
|
232
|
+
const obj = partial as Record<string, unknown>
|
|
233
|
+
const delta = extractDelta(obj, prevMessageLength)
|
|
234
|
+
if (delta) {
|
|
235
|
+
prevMessageLength += delta.length
|
|
236
|
+
yield {
|
|
237
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
238
|
+
messageId: '',
|
|
239
|
+
delta,
|
|
240
|
+
agentId: 'my-provider',
|
|
241
|
+
} as StreamEvent<TState>
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Validate and yield the final result
|
|
246
|
+
const finalObject = await result.output
|
|
247
|
+
if (finalObject === null) {
|
|
248
|
+
yield {
|
|
249
|
+
type: EventType.RUN_ERROR,
|
|
250
|
+
message: 'Stream completed with null output',
|
|
251
|
+
agentId: 'my-provider',
|
|
252
|
+
} as StreamEvent<TState>
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const validated = request.responseSchema.parse(finalObject)
|
|
257
|
+
yield {
|
|
258
|
+
type: EventType.TOOL_CALL_RESULT,
|
|
259
|
+
toolCallId: '',
|
|
260
|
+
messageId: '',
|
|
261
|
+
content: JSON.stringify(validated),
|
|
262
|
+
agentId: 'my-provider',
|
|
263
|
+
} as StreamEvent<TState>
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Approach 2: Raw SDK
|
|
268
|
+
|
|
269
|
+
If no Vercel AI SDK adapter exists for your LLM, work directly with the LLM's native streaming API.
|
|
270
|
+
You'll need to:
|
|
271
|
+
|
|
272
|
+
1. Accumulate text chunks and yield them as `TEXT_MESSAGE_CONTENT` deltas
|
|
273
|
+
1. Accumulate tool/JSON chunks into a buffer
|
|
274
|
+
1. Parse and validate the buffer when the stream ends
|
|
275
|
+
1. Yield `TOOL_CALL_RESULT` on success or `RUN_ERROR` on failure
|
|
276
|
+
|
|
277
|
+
This is the pattern the Bedrock provider uses:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
async *sendRequestStream<TState extends BaseState>(
|
|
281
|
+
request: ProviderRequest,
|
|
282
|
+
): AsyncGenerator<StreamEvent<TState>> {
|
|
283
|
+
const jsonSchema = request.responseSchema.toJSONSchema()
|
|
284
|
+
|
|
285
|
+
// Start the raw stream
|
|
286
|
+
const rawStream = await myLLM.streamChat({
|
|
287
|
+
system: request.systemPrompt,
|
|
288
|
+
messages: request.messages,
|
|
289
|
+
responseFormat: { type: 'json_schema', schema: jsonSchema },
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
let jsonBuffer = ''
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
for await (const chunk of rawStream) {
|
|
296
|
+
if (chunk.type === 'text') {
|
|
297
|
+
yield {
|
|
298
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
299
|
+
messageId: '',
|
|
300
|
+
delta: chunk.text,
|
|
301
|
+
agentId: 'my-provider',
|
|
302
|
+
} as StreamEvent<TState>
|
|
303
|
+
} else if (chunk.type === 'json') {
|
|
304
|
+
jsonBuffer += chunk.data
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Validate the accumulated JSON
|
|
309
|
+
const parsed = JSON.parse(jsonBuffer)
|
|
310
|
+
const validated = request.responseSchema.parse(parsed)
|
|
311
|
+
|
|
312
|
+
yield {
|
|
313
|
+
type: EventType.TOOL_CALL_RESULT,
|
|
314
|
+
toolCallId: '',
|
|
315
|
+
messageId: '',
|
|
316
|
+
content: JSON.stringify(validated),
|
|
317
|
+
agentId: 'my-provider',
|
|
318
|
+
} as StreamEvent<TState>
|
|
319
|
+
} catch (err) {
|
|
320
|
+
yield {
|
|
321
|
+
type: EventType.RUN_ERROR,
|
|
322
|
+
message: `Stream error: ${(err as Error).message}`,
|
|
323
|
+
agentId: 'my-provider',
|
|
324
|
+
} as StreamEvent<TState>
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Error Handling
|
|
330
|
+
|
|
331
|
+
**Never throw from `sendRequestStream`.** The framework expects the stream to yield a terminal event rather than throw.
|
|
332
|
+
|
|
333
|
+
- Wrap the entire stream body in `try/catch`
|
|
334
|
+
- In the `catch` block, yield a `RUN_ERROR` event
|
|
335
|
+
- Always yield exactly one terminal event: either `TOOL_CALL_RESULT` (success) or `RUN_ERROR` (failure)
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
async *sendRequestStream<TState extends BaseState>(
|
|
339
|
+
request: ProviderRequest,
|
|
340
|
+
): AsyncGenerator<StreamEvent<TState>> {
|
|
341
|
+
try {
|
|
342
|
+
// ... stream logic ...
|
|
343
|
+
} catch (err) {
|
|
344
|
+
yield {
|
|
345
|
+
type: EventType.RUN_ERROR,
|
|
346
|
+
message: `MyProvider stream error: ${(err as Error).message}`,
|
|
347
|
+
agentId: 'my-provider',
|
|
348
|
+
} as StreamEvent<TState>
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Model Fallback
|
|
354
|
+
|
|
355
|
+
A3 provides a shared `executeWithFallback` utility in `providers/utils/`.
|
|
356
|
+
It tries each model in order and falls back to the next if one fails.
|
|
357
|
+
|
|
358
|
+
### Blocking Fallback
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
import { executeWithFallback } from '../utils/executeWithFallback'
|
|
362
|
+
|
|
363
|
+
async sendRequest(request: ProviderRequest): Promise<ProviderResponse> {
|
|
364
|
+
return executeWithFallback(this.models, (model) =>
|
|
365
|
+
this.sendWithModel(model, request),
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Streaming Fallback (Eager First-Chunk Pattern)
|
|
371
|
+
|
|
372
|
+
For streaming, you need to detect connection/model errors **before** you start yielding events.
|
|
373
|
+
The pattern: start the stream, consume the first chunk (which forces the API call), and only then yield events.
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
// Helper: start stream and consume first chunk to trigger the API call
|
|
377
|
+
async function startStream(model: string, request: ProviderRequest) {
|
|
378
|
+
const result = streamText({
|
|
379
|
+
model: myLLM(model),
|
|
380
|
+
system: request.systemPrompt,
|
|
381
|
+
messages: request.messages,
|
|
382
|
+
output: Output.object({ schema: toJsonSchema(request.responseSchema) }),
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
const reader = result.partialOutputStream[Symbol.asyncIterator]()
|
|
386
|
+
const first = await reader.next() // Forces the API call — throws on connection error
|
|
387
|
+
|
|
388
|
+
return { result, reader, first }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// In sendRequestStream:
|
|
392
|
+
async *sendRequestStream<TState extends BaseState>(
|
|
393
|
+
request: ProviderRequest,
|
|
394
|
+
): AsyncGenerator<StreamEvent<TState>> {
|
|
395
|
+
// Fallback happens here — if primary model fails on first chunk, retry with next
|
|
396
|
+
const { result, reader, first } = await executeWithFallback(
|
|
397
|
+
this.models,
|
|
398
|
+
(model) => startStream(model, request),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
// Process the stream (first chunk already consumed)
|
|
402
|
+
yield* processMyStream<TState>(result, reader, first, 'my-provider', request.responseSchema)
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
This is the exact pattern used by the OpenAI and Anthropic providers.
|
|
407
|
+
|
|
408
|
+
## Complete Example
|
|
409
|
+
|
|
410
|
+
A full, copy-pasteable provider factory for a hypothetical LLM:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
import { EventType } from '@ag-ui/client'
|
|
414
|
+
import type {
|
|
415
|
+
Provider,
|
|
416
|
+
ProviderRequest,
|
|
417
|
+
ProviderResponse,
|
|
418
|
+
BaseState,
|
|
419
|
+
StreamEvent,
|
|
420
|
+
} from '@genui-a3/core'
|
|
421
|
+
import { executeWithFallback } from '@genui-a3/providers/utils'
|
|
422
|
+
|
|
423
|
+
// --- Hypothetical LLM SDK ---
|
|
424
|
+
import { MyLLMClient } from 'my-llm-sdk'
|
|
425
|
+
|
|
426
|
+
export interface MyProviderConfig {
|
|
427
|
+
apiKey: string
|
|
428
|
+
models: string[]
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Extracts the new portion of chatbotMessage from a partial object.
|
|
433
|
+
*/
|
|
434
|
+
function extractDelta(
|
|
435
|
+
partial: Record<string, unknown>,
|
|
436
|
+
prevLength: number,
|
|
437
|
+
): string | null {
|
|
438
|
+
const chatbotMessage = partial.chatbotMessage
|
|
439
|
+
if (typeof chatbotMessage !== 'string' || chatbotMessage.length <= prevLength) {
|
|
440
|
+
return null
|
|
441
|
+
}
|
|
442
|
+
return chatbotMessage.slice(prevLength)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function createMyProvider(config: MyProviderConfig): Provider {
|
|
446
|
+
const client = new MyLLMClient({ apiKey: config.apiKey })
|
|
447
|
+
const models = config.models
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
name: 'my-provider',
|
|
451
|
+
|
|
452
|
+
async sendRequest(request: ProviderRequest): Promise<ProviderResponse> {
|
|
453
|
+
const jsonSchema = request.responseSchema.toJSONSchema()
|
|
454
|
+
|
|
455
|
+
return executeWithFallback(models, async (model) => {
|
|
456
|
+
const response = await client.chat({
|
|
457
|
+
model,
|
|
458
|
+
system: request.systemPrompt,
|
|
459
|
+
messages: request.messages.map((m) => ({
|
|
460
|
+
role: m.role,
|
|
461
|
+
content: m.content,
|
|
462
|
+
})),
|
|
463
|
+
responseFormat: { type: 'json_schema', schema: jsonSchema },
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Validate before returning
|
|
467
|
+
const validated = request.responseSchema.parse(JSON.parse(response.text))
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
content: JSON.stringify(validated),
|
|
471
|
+
usage: {
|
|
472
|
+
inputTokens: response.usage?.inputTokens,
|
|
473
|
+
outputTokens: response.usage?.outputTokens,
|
|
474
|
+
totalTokens: (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0),
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
async *sendRequestStream<TState extends BaseState = BaseState>(
|
|
481
|
+
request: ProviderRequest,
|
|
482
|
+
): AsyncGenerator<StreamEvent<TState>> {
|
|
483
|
+
const jsonSchema = request.responseSchema.toJSONSchema()
|
|
484
|
+
const agentId = 'my-provider'
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
// Use fallback for the connection phase
|
|
488
|
+
const rawStream = await executeWithFallback(models, (model) =>
|
|
489
|
+
client.streamChat({
|
|
490
|
+
model,
|
|
491
|
+
system: request.systemPrompt,
|
|
492
|
+
messages: request.messages.map((m) => ({
|
|
493
|
+
role: m.role,
|
|
494
|
+
content: m.content,
|
|
495
|
+
})),
|
|
496
|
+
responseFormat: { type: 'json_schema', schema: jsonSchema },
|
|
497
|
+
}),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
let prevMessageLength = 0
|
|
501
|
+
let fullJson = ''
|
|
502
|
+
|
|
503
|
+
for await (const chunk of rawStream) {
|
|
504
|
+
// Yield text deltas for real-time display
|
|
505
|
+
if (chunk.type === 'text') {
|
|
506
|
+
yield {
|
|
507
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
508
|
+
messageId: '',
|
|
509
|
+
delta: chunk.text,
|
|
510
|
+
agentId,
|
|
511
|
+
} as StreamEvent<TState>
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Accumulate JSON for final validation
|
|
515
|
+
if (chunk.type === 'partial_json') {
|
|
516
|
+
const partial = chunk.data as Record<string, unknown>
|
|
517
|
+
const delta = extractDelta(partial, prevMessageLength)
|
|
518
|
+
if (delta) {
|
|
519
|
+
prevMessageLength += delta.length
|
|
520
|
+
yield {
|
|
521
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
522
|
+
messageId: '',
|
|
523
|
+
delta,
|
|
524
|
+
agentId,
|
|
525
|
+
} as StreamEvent<TState>
|
|
526
|
+
}
|
|
527
|
+
fullJson = JSON.stringify(partial)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Validate the final object
|
|
532
|
+
const parsed = JSON.parse(fullJson)
|
|
533
|
+
const validated = request.responseSchema.parse(parsed)
|
|
534
|
+
|
|
535
|
+
yield {
|
|
536
|
+
type: EventType.TOOL_CALL_RESULT,
|
|
537
|
+
toolCallId: '',
|
|
538
|
+
messageId: '',
|
|
539
|
+
content: JSON.stringify(validated),
|
|
540
|
+
agentId,
|
|
541
|
+
} as StreamEvent<TState>
|
|
542
|
+
} catch (err) {
|
|
543
|
+
yield {
|
|
544
|
+
type: EventType.RUN_ERROR,
|
|
545
|
+
message: `my-provider stream error: ${(err as Error).message}`,
|
|
546
|
+
agentId,
|
|
547
|
+
} as StreamEvent<TState>
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Using Your Provider
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
import { ChatSession, MemorySessionStore } from '@genui-a3/core'
|
|
558
|
+
import { createMyProvider } from './myProvider'
|
|
559
|
+
|
|
560
|
+
const provider = createMyProvider({
|
|
561
|
+
apiKey: process.env.MY_LLM_API_KEY!,
|
|
562
|
+
models: ['my-model-large', 'my-model-small'],
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
const session = new ChatSession({
|
|
566
|
+
sessionId: 'user-123',
|
|
567
|
+
store: new MemorySessionStore(),
|
|
568
|
+
initialAgentId: 'greeting',
|
|
569
|
+
provider,
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
// Blocking
|
|
573
|
+
const response = await session.send({ message: 'Hello!' })
|
|
574
|
+
|
|
575
|
+
// Streaming
|
|
576
|
+
for await (const event of session.send({ message: 'Hello!', stream: true })) {
|
|
577
|
+
if (event.type === 'TEXT_MESSAGE_CONTENT') {
|
|
578
|
+
process.stdout.write(event.delta)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
## Gotchas and Tips
|
|
584
|
+
|
|
585
|
+
### Message Ordering
|
|
586
|
+
|
|
587
|
+
Some LLMs require messages to start with a user turn or alternate strictly between roles.
|
|
588
|
+
The Bedrock provider handles this by prepending a `"Hi"` user message and merging consecutive same-role messages.
|
|
589
|
+
Check your LLM's requirements and preprocess accordingly.
|
|
590
|
+
|
|
591
|
+
### `messageId` and `toolCallId` Assignment
|
|
592
|
+
|
|
593
|
+
The framework handles these IDs differently depending on the event type:
|
|
594
|
+
|
|
595
|
+
- **`TEXT_MESSAGE_CONTENT`** — The framework always overwrites `messageId` with its own `crypto.randomUUID()` in `simpleAgentResponseStream`.
|
|
596
|
+
Any value you provide (including `''`) is silently ignored.
|
|
597
|
+
You can safely pass an empty string here.
|
|
598
|
+
- **`TOOL_CALL_RESULT`** — The event is yielded as-is.
|
|
599
|
+
The framework does **not** assign or overwrite IDs, so whatever `messageId` and `toolCallId` you set will pass through to downstream consumers.
|
|
600
|
+
If you need traceability or correlation on the final result event, supply your own meaningful IDs here.
|
|
601
|
+
|
|
602
|
+
Providers can supply their own IDs for any event without issue.
|
|
603
|
+
For `TOOL_CALL_RESULT` it is particularly useful to do so, since those values are preserved end-to-end.
|
|
604
|
+
|
|
605
|
+
### Schema Enforcement
|
|
606
|
+
|
|
607
|
+
Different LLMs have different levels of JSON schema support:
|
|
608
|
+
|
|
609
|
+
- **OpenAI**: Supports `response_format: { type: 'json_schema' }` with strict mode.
|
|
610
|
+
Requires `additionalProperties: false` and all properties in `required`.
|
|
611
|
+
- **Bedrock**: Uses tool-based extraction (a `structuredResponse` tool with the schema as input).
|
|
612
|
+
- **Others**: If your LLM doesn't support structured output natively, include the JSON schema in the system prompt and parse the response yourself.
|
|
613
|
+
|
|
614
|
+
Always validate with `responseSchema.parse()` regardless of the LLM's schema support — this is your safety net.
|
|
615
|
+
|
|
616
|
+
### Terminal Events
|
|
617
|
+
|
|
618
|
+
Every stream must yield exactly **one** terminal event:
|
|
619
|
+
|
|
620
|
+
- `TOOL_CALL_RESULT` on success (contains the full validated JSON)
|
|
621
|
+
- `RUN_ERROR` on failure
|
|
622
|
+
|
|
623
|
+
If a stream ends without either, the framework throws `'Stream completed without tool call data'`.
|
|
624
|
+
|
|
625
|
+
### Testing Your Provider
|
|
626
|
+
|
|
627
|
+
1. **Unit test `sendRequest`**: Mock your LLM SDK, verify the returned `content` parses as valid JSON matching the schema
|
|
628
|
+
1. **Unit test `sendRequestStream`**: Collect all yielded events, verify you get `TEXT_MESSAGE_CONTENT` events followed by exactly one `TOOL_CALL_RESULT`
|
|
629
|
+
1. **Integration test**: Wire your provider into a `ChatSession` and send a real message through the full flow
|
|
630
|
+
1. **Error paths**: Verify that API errors, invalid JSON, and schema validation failures all produce `RUN_ERROR` events (not thrown exceptions)
|
|
631
|
+
|
|
632
|
+
## Reference
|
|
633
|
+
|
|
634
|
+
| File | Description |
|
|
635
|
+
|---|---|
|
|
636
|
+
| `src/types/provider.ts` | `Provider`, `ProviderRequest`, `ProviderResponse` interfaces |
|
|
637
|
+
| `src/types/stream.ts` | `StreamEvent` type union (all AG-UI event types) |
|
|
638
|
+
| `src/core/schemas.ts` | `createFullOutputSchema` — how the output schema is built |
|
|
639
|
+
| `src/core/agent.ts` | `simpleAgentResponseStream` — how the framework consumes provider events |
|
|
640
|
+
| `providers/openai/streamProcessor.ts` | AI SDK streaming pattern (cleanest reference) |
|
|
641
|
+
| `providers/bedrock/streamProcessor.ts` | Raw SDK streaming pattern |
|
|
642
|
+
| `providers/utils/executeWithFallback.ts` | Shared model fallback utility |
|