@tanstack/ai-bedrock 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +95 -0
  2. package/dist/esm/adapters/converse-text.d.ts +93 -0
  3. package/dist/esm/adapters/converse-text.js +430 -0
  4. package/dist/esm/adapters/converse-text.js.map +1 -0
  5. package/dist/esm/adapters/responses-text.d.ts +23 -0
  6. package/dist/esm/adapters/responses-text.js +22 -0
  7. package/dist/esm/adapters/responses-text.js.map +1 -0
  8. package/dist/esm/adapters/text.d.ts +30 -0
  9. package/dist/esm/adapters/text.js +38 -0
  10. package/dist/esm/adapters/text.js.map +1 -0
  11. package/dist/esm/converse/message-converter.d.ts +14 -0
  12. package/dist/esm/converse/message-converter.js +181 -0
  13. package/dist/esm/converse/message-converter.js.map +1 -0
  14. package/dist/esm/converse/provider-options.d.ts +19 -0
  15. package/dist/esm/converse/stream-processor.d.ts +46 -0
  16. package/dist/esm/converse/stream-processor.js +203 -0
  17. package/dist/esm/converse/stream-processor.js.map +1 -0
  18. package/dist/esm/converse/structured-output.d.ts +8 -0
  19. package/dist/esm/converse/structured-output.js +20 -0
  20. package/dist/esm/converse/structured-output.js.map +1 -0
  21. package/dist/esm/converse/tool-converter.d.ts +11 -0
  22. package/dist/esm/converse/tool-converter.js +21 -0
  23. package/dist/esm/converse/tool-converter.js.map +1 -0
  24. package/dist/esm/index.d.ts +29 -0
  25. package/dist/esm/index.js +62 -0
  26. package/dist/esm/index.js.map +1 -0
  27. package/dist/esm/message-types.d.ts +25 -0
  28. package/dist/esm/model-catalog.generated.d.ts +127 -0
  29. package/dist/esm/model-catalog.generated.js +90 -0
  30. package/dist/esm/model-catalog.generated.js.map +1 -0
  31. package/dist/esm/model-meta.d.ts +38 -0
  32. package/dist/esm/model-meta.js +14 -0
  33. package/dist/esm/model-meta.js.map +1 -0
  34. package/dist/esm/text/responses-provider-options.d.ts +29 -0
  35. package/dist/esm/text/text-provider-options.d.ts +36 -0
  36. package/dist/esm/utils/auth.d.ts +20 -0
  37. package/dist/esm/utils/auth.js +49 -0
  38. package/dist/esm/utils/auth.js.map +1 -0
  39. package/dist/esm/utils/client.d.ts +19 -0
  40. package/dist/esm/utils/client.js +35 -0
  41. package/dist/esm/utils/client.js.map +1 -0
  42. package/dist/esm/utils/index.d.ts +1 -0
  43. package/dist/esm/utils/openai-sigv4-fetch.d.ts +10 -0
  44. package/dist/esm/utils/openai-sigv4-fetch.js +35 -0
  45. package/dist/esm/utils/openai-sigv4-fetch.js.map +1 -0
  46. package/package.json +67 -0
  47. package/src/adapters/converse-text.ts +603 -0
  48. package/src/adapters/responses-text.ts +73 -0
  49. package/src/adapters/text.ts +97 -0
  50. package/src/converse/message-converter.ts +268 -0
  51. package/src/converse/provider-options.ts +19 -0
  52. package/src/converse/stream-processor.ts +309 -0
  53. package/src/converse/structured-output.ts +24 -0
  54. package/src/converse/tool-converter.ts +49 -0
  55. package/src/index.ts +190 -0
  56. package/src/message-types.ts +24 -0
  57. package/src/model-catalog.generated.ts +88 -0
  58. package/src/model-meta.ts +69 -0
  59. package/src/text/responses-provider-options.ts +28 -0
  60. package/src/text/text-provider-options.ts +33 -0
  61. package/src/utils/auth.ts +80 -0
  62. package/src/utils/client.ts +62 -0
  63. package/src/utils/index.ts +7 -0
  64. package/src/utils/openai-sigv4-fetch.ts +55 -0
@@ -0,0 +1,97 @@
1
+ import OpenAI from 'openai'
2
+ import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
3
+ import { withBedrockDefaults } from '../utils/client'
4
+ import type { Modality } from '@tanstack/ai'
5
+ import type { BedrockClientConfig } from '../utils/client'
6
+ import type { BedrockMessageMetadataByModality } from '../message-types'
7
+ import type {
8
+ BedrockChatModelToolCapabilitiesByName,
9
+ BedrockChatModels,
10
+ ResolveInputModalities,
11
+ ResolveProviderOptions,
12
+ } from '../model-meta'
13
+
14
+ export interface BedrockTextConfig extends BedrockClientConfig {}
15
+
16
+ export type { ExternalTextProviderOptions as BedrockTextProviderOptions } from '../text/text-provider-options'
17
+
18
+ type ResolveToolCapabilities<TModel extends string> =
19
+ TModel extends keyof BedrockChatModelToolCapabilitiesByName
20
+ ? NonNullable<BedrockChatModelToolCapabilitiesByName[TModel]>
21
+ : readonly []
22
+
23
+ /**
24
+ * Bedrock Chat Completions adapter. Drives Bedrock's OpenAI-compatible
25
+ * `/chat/completions` endpoint via the OpenAI SDK with a baseURL override
26
+ * (same pattern as ai-groq). Tool conversion, streaming, structured output,
27
+ * and the agent loop come from the base.
28
+ */
29
+ export class BedrockTextAdapter<
30
+ TModel extends BedrockChatModels,
31
+ // Constraint mirrors ai-groq and the base, which parameterises
32
+ // `TProviderOptions extends Record<string, any>`. Our default
33
+ // `ResolveProviderOptions<TModel>` resolves to the `BedrockTextProviderOptions`
34
+ // interface, which (lacking an implicit index signature) `Record<string,
35
+ // unknown>` would reject but `Record<string, any>` accepts. This `any` is
36
+ // confined to the generic constraint (the established ai-groq pattern) — no
37
+ // value/shape `as` cast is introduced.
38
+ TProviderOptions extends Record<string, any> = ResolveProviderOptions<TModel>,
39
+ TInputModalities extends ReadonlyArray<Modality> =
40
+ ResolveInputModalities<TModel>,
41
+ TToolCapabilities extends ReadonlyArray<string> =
42
+ ResolveToolCapabilities<TModel>,
43
+ > extends OpenAIBaseChatCompletionsTextAdapter<
44
+ TModel,
45
+ TProviderOptions,
46
+ TInputModalities,
47
+ BedrockMessageMetadataByModality,
48
+ TToolCapabilities
49
+ > {
50
+ override readonly kind = 'text' as const
51
+ override readonly name = 'bedrock' as const
52
+
53
+ constructor(config: BedrockTextConfig, model: TModel) {
54
+ // No `forced` -> honors config.endpoint ('runtime' default, 'mantle' allowed).
55
+ super(model, 'bedrock', new OpenAI(withBedrockDefaults(config)))
56
+ }
57
+
58
+ /**
59
+ * Surface reasoning deltas (gpt-oss / Claude reasoning) the OpenAI-compatible
60
+ * way. Base types the chunk as `unknown`; narrow with runtime guards — no
61
+ * `as` casts, no `any`.
62
+ */
63
+ protected override extractReasoning(
64
+ chunk: unknown,
65
+ ): { text: string } | undefined {
66
+ return readDeltaReasoning(chunk)
67
+ }
68
+ }
69
+
70
+ /** Cast-free narrowing of a Chat Completions chunk's reasoning delta. */
71
+ function readDeltaReasoning(chunk: unknown): { text: string } | undefined {
72
+ if (typeof chunk !== 'object' || chunk === null || !('choices' in chunk))
73
+ return undefined
74
+ if (!Array.isArray(chunk.choices)) return undefined
75
+ const choice: unknown = chunk.choices[0]
76
+ if (typeof choice !== 'object' || choice === null || !('delta' in choice))
77
+ return undefined
78
+ const delta = choice.delta
79
+ if (typeof delta !== 'object' || delta === null) return undefined
80
+ const raw =
81
+ 'reasoning' in delta && typeof delta.reasoning === 'string'
82
+ ? delta.reasoning
83
+ : 'reasoning_content' in delta &&
84
+ typeof delta.reasoning_content === 'string'
85
+ ? delta.reasoning_content
86
+ : undefined
87
+ return raw && raw.length > 0 ? { text: raw } : undefined
88
+ }
89
+
90
+ /** Chat adapter with an explicit API key (low-level; the public branching factory delegates here). */
91
+ export function createBedrockChat<TModel extends BedrockChatModels>(
92
+ model: TModel,
93
+ apiKey: string,
94
+ config?: Omit<BedrockTextConfig, 'apiKey'>,
95
+ ): BedrockTextAdapter<TModel> {
96
+ return new BedrockTextAdapter({ ...config, apiKey }, model)
97
+ }
@@ -0,0 +1,268 @@
1
+ import { normalizeSystemPrompts } from '@tanstack/ai'
2
+ import type {
3
+ ContentPart,
4
+ ContentPartDataSource,
5
+ DocumentPart,
6
+ ImagePart,
7
+ ModelMessage,
8
+ SystemPrompt,
9
+ TextPart,
10
+ } from '@tanstack/ai'
11
+ import type {
12
+ ContentBlock,
13
+ Message,
14
+ SystemContentBlock,
15
+ ToolResultContentBlock,
16
+ } from '@aws-sdk/client-bedrock-runtime'
17
+ import type { DocumentType } from '@smithy/types'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function base64ToBytes(b64: string): Uint8Array {
24
+ return new Uint8Array(Buffer.from(b64, 'base64'))
25
+ }
26
+
27
+ function imageFormat(mime: string): 'png' | 'jpeg' | 'gif' | 'webp' {
28
+ switch (mime) {
29
+ case 'image/png':
30
+ return 'png'
31
+ case 'image/jpeg':
32
+ case 'image/jpg':
33
+ return 'jpeg'
34
+ case 'image/gif':
35
+ return 'gif'
36
+ case 'image/webp':
37
+ return 'webp'
38
+ default:
39
+ throw new Error(
40
+ `Bedrock Converse: unsupported image MIME type "${mime}". Supported types: image/png, image/jpeg, image/gif, image/webp.`,
41
+ )
42
+ }
43
+ }
44
+
45
+ function documentFormat(
46
+ mime: string,
47
+ ): 'pdf' | 'csv' | 'doc' | 'docx' | 'xls' | 'xlsx' | 'html' | 'txt' | 'md' {
48
+ switch (mime) {
49
+ case 'application/pdf':
50
+ return 'pdf'
51
+ case 'text/csv':
52
+ return 'csv'
53
+ case 'application/msword':
54
+ return 'doc'
55
+ case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
56
+ return 'docx'
57
+ case 'application/vnd.ms-excel':
58
+ return 'xls'
59
+ case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
60
+ return 'xlsx'
61
+ case 'text/html':
62
+ return 'html'
63
+ case 'text/plain':
64
+ return 'txt'
65
+ case 'text/markdown':
66
+ case 'text/x-markdown':
67
+ return 'md'
68
+ default:
69
+ throw new Error(
70
+ `Bedrock Converse: unsupported document MIME type "${mime}". Supported types: pdf, csv, doc, docx, xls, xlsx, html, txt, md.`,
71
+ )
72
+ }
73
+ }
74
+
75
+ function stringContent(content: string | null | Array<ContentPart>): string {
76
+ if (content === null) return ''
77
+ if (typeof content === 'string') return content
78
+ return content
79
+ .filter((p): p is TextPart => p.type === 'text')
80
+ .map((p) => p.content)
81
+ .join('')
82
+ }
83
+
84
+ function isTextPart(p: ContentPart): p is TextPart {
85
+ return p.type === 'text'
86
+ }
87
+
88
+ function isImagePart(p: ContentPart): p is ImagePart {
89
+ return p.type === 'image'
90
+ }
91
+
92
+ function isDocumentPart(p: ContentPart): p is DocumentPart {
93
+ return p.type === 'document'
94
+ }
95
+
96
+ function isDataSource(
97
+ source: ImagePart['source'] | DocumentPart['source'],
98
+ ): source is ContentPartDataSource {
99
+ return source.type === 'data'
100
+ }
101
+
102
+ function contentPartToBlock(part: ContentPart, docIndex: number): ContentBlock {
103
+ if (isTextPart(part)) {
104
+ return { text: part.content }
105
+ }
106
+
107
+ if (isImagePart(part)) {
108
+ const { source } = part
109
+ if (!isDataSource(source)) {
110
+ throw new Error(
111
+ 'Bedrock Converse requires inline image bytes; URL image sources are not supported.',
112
+ )
113
+ }
114
+ return {
115
+ image: {
116
+ format: imageFormat(source.mimeType),
117
+ source: { bytes: base64ToBytes(source.value) },
118
+ },
119
+ }
120
+ }
121
+
122
+ if (isDocumentPart(part)) {
123
+ const { source } = part
124
+ if (!isDataSource(source)) {
125
+ throw new Error(
126
+ 'Bedrock Converse requires inline document bytes; URL document sources are not supported.',
127
+ )
128
+ }
129
+ return {
130
+ document: {
131
+ format: documentFormat(source.mimeType),
132
+ name: `document-${docIndex}`,
133
+ source: { bytes: base64ToBytes(source.value) },
134
+ },
135
+ }
136
+ }
137
+
138
+ // Fail loud for unsupported part types (audio, video, etc.)
139
+ throw new Error(
140
+ `Bedrock Converse does not support content part type "${String(part.type)}".`,
141
+ )
142
+ }
143
+
144
+ function messageToBlocks(
145
+ msg: ModelMessage,
146
+ docCounter: { value: number },
147
+ ): Array<ContentBlock> {
148
+ const blocks: Array<ContentBlock> = []
149
+
150
+ if (msg.role === 'tool') {
151
+ if (!msg.toolCallId) {
152
+ throw new Error(
153
+ 'Bedrock Converse: tool message is missing toolCallId. Every tool result must reference the tool use ID it is responding to.',
154
+ )
155
+ }
156
+ const textContent = stringContent(msg.content)
157
+ const toolResult: ToolResultContentBlock = { text: textContent }
158
+ blocks.push({
159
+ toolResult: {
160
+ toolUseId: msg.toolCallId,
161
+ content: [toolResult],
162
+ status: 'success',
163
+ },
164
+ })
165
+ return blocks
166
+ }
167
+
168
+ // Map content field to text/image/document blocks
169
+ if (typeof msg.content === 'string') {
170
+ if (msg.content !== '') {
171
+ blocks.push({ text: msg.content })
172
+ }
173
+ } else if (Array.isArray(msg.content)) {
174
+ for (const part of msg.content) {
175
+ const docIndex = isDocumentPart(part) ? ++docCounter.value : 0
176
+ blocks.push(contentPartToBlock(part, docIndex))
177
+ }
178
+ }
179
+ // null → no text blocks
180
+
181
+ // Append toolUse blocks for assistant tool calls. Malformed or non-object
182
+ // arguments come from a prior assistant turn the engine already accepted, so
183
+ // they signal a real upstream problem — throw rather than silently coercing to
184
+ // `{}` and forwarding a corrupted tool call (the adapter's catch surfaces it
185
+ // as a RUN_ERROR).
186
+ if (msg.role === 'assistant' && msg.toolCalls) {
187
+ for (const call of msg.toolCalls) {
188
+ const rawArguments = call.function.arguments || '{}'
189
+ let parsed: unknown
190
+ try {
191
+ parsed = JSON.parse(rawArguments)
192
+ } catch (error: unknown) {
193
+ throw new Error(
194
+ `Bedrock Converse: tool call "${call.function.name}" has malformed JSON arguments (${String(error)}). Raw: ${rawArguments}`,
195
+ )
196
+ }
197
+ if (
198
+ parsed === null ||
199
+ typeof parsed !== 'object' ||
200
+ Array.isArray(parsed)
201
+ ) {
202
+ throw new Error(
203
+ `Bedrock Converse: tool call "${call.function.name}" arguments must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`,
204
+ )
205
+ }
206
+ blocks.push({
207
+ toolUse: {
208
+ toolUseId: call.id,
209
+ name: call.function.name,
210
+ input: parsed as DocumentType,
211
+ },
212
+ })
213
+ }
214
+ }
215
+
216
+ return blocks
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Public API
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Convert TanStack AI messages + system prompts into the Converse API format.
225
+ *
226
+ * - System prompts are lifted into `SystemContentBlock[]`.
227
+ * - `tool` role messages are remapped to `user` role `toolResult` blocks.
228
+ * - Consecutive messages with the same Converse role are merged (Converse
229
+ * requires strict user/assistant alternation).
230
+ */
231
+ export function toConverseMessages(
232
+ messages: Array<ModelMessage>,
233
+ systemPrompts?: Array<SystemPrompt>,
234
+ ): { system: Array<SystemContentBlock>; messages: Array<Message> } {
235
+ // Build system blocks (uses normalizeSystemPrompts for runtime validation)
236
+ const system: Array<SystemContentBlock> = normalizeSystemPrompts(
237
+ systemPrompts,
238
+ ).map((p) => ({ text: p.content }))
239
+
240
+ // Convert each ModelMessage to a Converse Message, merging same-role pairs
241
+ const converseMessages: Array<Message> = []
242
+ // Global document counter: ensures every document block across all messages
243
+ // gets a unique name, preventing Bedrock ValidationException for duplicate names.
244
+ const docCounter = { value: 0 }
245
+
246
+ for (const msg of messages) {
247
+ // Map TanStack roles to Converse roles
248
+ const converseRole: 'user' | 'assistant' =
249
+ msg.role === 'assistant' ? 'assistant' : 'user'
250
+
251
+ const blocks = messageToBlocks(msg, docCounter)
252
+
253
+ // Skip messages that produce no content blocks (e.g. assistant with
254
+ // null content and no toolCalls). Pushing an empty-content message to
255
+ // Converse triggers a ValidationException.
256
+ if (blocks.length === 0) continue
257
+
258
+ const last = converseMessages[converseMessages.length - 1]
259
+ if (last && last.role === converseRole) {
260
+ // Merge into the previous message's content array
261
+ last.content = [...(last.content ?? []), ...blocks]
262
+ } else {
263
+ converseMessages.push({ role: converseRole, content: blocks })
264
+ }
265
+ }
266
+
267
+ return { system, messages: converseMessages }
268
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Provider options honored by the Bedrock **Converse** path. Deliberately
3
+ * narrower than the OpenAI-compatible `BedrockTextProviderOptions`: the Converse
4
+ * API only accepts these sampling knobs (mapped into `inferenceConfig`), so
5
+ * advertising the full Chat Completions option set would promise fields —
6
+ * `frequency_penalty`, `seed`, `logit_bias`, `tool_choice`, `reasoning_effort`,
7
+ * etc. — that Converse silently ignores. Field names mirror the Chat
8
+ * Completions shape so callers have one mental model across the three APIs.
9
+ */
10
+ export interface BedrockConverseProviderOptions {
11
+ /** Forwarded to Converse `inferenceConfig.temperature`. */
12
+ temperature?: number | null
13
+ /** Forwarded to Converse `inferenceConfig.topP`. */
14
+ top_p?: number | null
15
+ /** Forwarded to Converse `inferenceConfig.maxTokens`. */
16
+ max_completion_tokens?: number | null
17
+ /** Forwarded to Converse `inferenceConfig.stopSequences`. */
18
+ stop?: string | Array<string> | null
19
+ }
@@ -0,0 +1,309 @@
1
+ import { EventType } from '@tanstack/ai'
2
+ import type { RunFinishedEvent, StreamChunk } from '@tanstack/ai'
3
+ import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime'
4
+
5
+ /**
6
+ * Converse delivers server-side failures — throttling, request validation,
7
+ * mid-stream model faults, and service-unavailable — as in-band stream events
8
+ * rather than thrown exceptions. If they were ignored the iterator would simply
9
+ * end and the run would look like a clean, truncated success. Throw the
10
+ * underlying exception (these SDK members extend `Error`) so the adapter's
11
+ * `chatStream` / `structuredOutputStream` catch converts it into a `RUN_ERROR`.
12
+ */
13
+ export function throwIfConverseStreamError(ev: ConverseStreamOutput): void {
14
+ if ('internalServerException' in ev && ev.internalServerException) {
15
+ throw ev.internalServerException
16
+ }
17
+ if ('modelStreamErrorException' in ev && ev.modelStreamErrorException) {
18
+ throw ev.modelStreamErrorException
19
+ }
20
+ if ('validationException' in ev && ev.validationException) {
21
+ throw ev.validationException
22
+ }
23
+ if ('throttlingException' in ev && ev.throttlingException) {
24
+ throw ev.throttlingException
25
+ }
26
+ if ('serviceUnavailableException' in ev && ev.serviceUnavailableException) {
27
+ throw ev.serviceUnavailableException
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Maps a Bedrock Converse `ConverseStreamOutput` event stream to the TanStack
33
+ * AG-UI `StreamChunk` lifecycle, following `openai-base`'s `processStreamChunks`
34
+ * lifecycle shape so the activity layer / agent loop behave identically across
35
+ * providers. (Reasoning surfaces only the message-level events —
36
+ * `REASONING_MESSAGE_START/CONTENT/END` — which is all the core accumulator
37
+ * consumes; the `REASONING_START`/`REASONING_END`/`STEP_*` boundary events
38
+ * openai-base also emits are no-ops in the engine and are not reproduced here.)
39
+ *
40
+ * Lifecycle ownership matches openai-base: this processor emits the full
41
+ * success-path lifecycle itself — `RUN_STARTED` lazily before the first event,
42
+ * `TEXT_MESSAGE_*` / `TOOL_CALL_*` / `REASONING_MESSAGE_*` for content, and a
43
+ * single terminal `RUN_FINISHED` once the iterator is exhausted (so the trailing
44
+ * `metadata` usage event is folded into the finish event regardless of arrival
45
+ * order). The calling adapter only owns the catch/`RUN_ERROR` path — so any
46
+ * in-band Converse error event (see `throwIfConverseStreamError`) is thrown to
47
+ * surface there rather than ending the stream as a clean truncated success.
48
+ *
49
+ * Converse streams tool-call arguments as partial-JSON string fragments inside
50
+ * `contentBlockDelta.delta.toolUse.input`; each fragment is emitted as a
51
+ * `TOOL_CALL_ARGS` `delta`, mirroring OpenAI's `function.arguments` deltas.
52
+ *
53
+ * @param stream - The Converse event stream from `ConverseStreamCommand`.
54
+ * @param newMessageId - Factory for fresh ids — run, thread, message, and
55
+ * tool-call ids (the adapter passes `() => this.generateId()`).
56
+ * @param lifecycle - Incoming run lifecycle ids, threaded onto the emitted
57
+ * `RUN_STARTED`/`RUN_FINISHED` so the chat path matches every sibling adapter
58
+ * (openai-base reuses `options.threadId`/`parentRunId`). Defaults preserve the
59
+ * previous behaviour (fresh `threadId`, no `parentRunId`).
60
+ */
61
+ export async function* processConverseStream(
62
+ stream: AsyncIterable<ConverseStreamOutput>,
63
+ newMessageId: () => string,
64
+ lifecycle: { threadId?: string; parentRunId?: string; model?: string } = {},
65
+ ): AsyncIterable<StreamChunk> {
66
+ const runId = newMessageId()
67
+ const threadId = lifecycle.threadId ?? newMessageId()
68
+ const { parentRunId, model } = lifecycle
69
+ const messageId = newMessageId()
70
+
71
+ let hasEmittedRunStarted = false
72
+
73
+ // Text lifecycle
74
+ let accumulatedContent = ''
75
+ let hasEmittedTextMessageStart = false
76
+
77
+ // Reasoning lifecycle
78
+ let reasoningMessageId: string | undefined
79
+ let hasClosedReasoning = false
80
+
81
+ // Tool-call lifecycle, keyed by Converse contentBlockIndex. Converse opens a
82
+ // tool-use block with `contentBlockStart`, streams arg fragments via
83
+ // `contentBlockDelta`, and closes it with `contentBlockStop`.
84
+ const toolCallsByIndex = new Map<
85
+ number,
86
+ { id: string; name: string; started: boolean }
87
+ >()
88
+
89
+ // Usage + finish-reason are captured during iteration and folded into the
90
+ // single terminal RUN_FINISHED, matching openai-base's deferred-finish
91
+ // contract (usage may arrive after the finish signal).
92
+ let usage:
93
+ | { promptTokens: number; completionTokens: number; totalTokens: number }
94
+ | undefined
95
+ let finishReason: NonNullable<RunFinishedEvent['finishReason']> | undefined
96
+
97
+ // Lazily emit RUN_STARTED exactly once, before the first content event.
98
+ function* ensureRunStarted(): Generator<StreamChunk> {
99
+ if (hasEmittedRunStarted) return
100
+ hasEmittedRunStarted = true
101
+ yield {
102
+ type: EventType.RUN_STARTED,
103
+ runId,
104
+ threadId,
105
+ parentRunId,
106
+ ...(model && { model }),
107
+ timestamp: Date.now(),
108
+ }
109
+ }
110
+
111
+ // Close an open reasoning message before text/tool content begins, mirroring
112
+ // openai-base which always emits REASONING_MESSAGE_END before TEXT_MESSAGE_START.
113
+ function* closeReasoning(): Generator<StreamChunk> {
114
+ if (reasoningMessageId && !hasClosedReasoning) {
115
+ hasClosedReasoning = true
116
+ yield {
117
+ type: EventType.REASONING_MESSAGE_END,
118
+ messageId: reasoningMessageId,
119
+ timestamp: Date.now(),
120
+ }
121
+ }
122
+ }
123
+
124
+ for await (const ev of stream) {
125
+ yield* ensureRunStarted()
126
+
127
+ // Surface in-band server/throttle/validation errors instead of dropping them.
128
+ throwIfConverseStreamError(ev)
129
+
130
+ // messageStart carries only the role; no AG-UI event maps to it.
131
+ if ('messageStart' in ev) continue
132
+
133
+ if ('contentBlockStart' in ev) {
134
+ const start = ev.contentBlockStart
135
+ const toolUse = start?.start?.toolUse
136
+ if (start && toolUse) {
137
+ yield* closeReasoning()
138
+ const id = toolUse.toolUseId ?? newMessageId()
139
+ const name = toolUse.name ?? ''
140
+ const index = start.contentBlockIndex ?? 0
141
+ toolCallsByIndex.set(index, {
142
+ id,
143
+ name,
144
+ started: true,
145
+ })
146
+ yield {
147
+ type: EventType.TOOL_CALL_START,
148
+ toolCallId: id,
149
+ toolCallName: name,
150
+ toolName: name,
151
+ timestamp: Date.now(),
152
+ index,
153
+ }
154
+ }
155
+ continue
156
+ }
157
+
158
+ if ('contentBlockDelta' in ev) {
159
+ const block = ev.contentBlockDelta
160
+ const delta = block?.delta
161
+ const index = block?.contentBlockIndex ?? 0
162
+
163
+ // Tool-call argument fragments (partial JSON).
164
+ if (delta && 'toolUse' in delta && delta.toolUse?.input !== undefined) {
165
+ const toolCall = toolCallsByIndex.get(index)
166
+ if (toolCall?.started) {
167
+ yield {
168
+ type: EventType.TOOL_CALL_ARGS,
169
+ toolCallId: toolCall.id,
170
+ timestamp: Date.now(),
171
+ delta: delta.toolUse.input,
172
+ }
173
+ }
174
+ continue
175
+ }
176
+
177
+ // Reasoning content.
178
+ if (
179
+ delta &&
180
+ 'reasoningContent' in delta &&
181
+ delta.reasoningContent &&
182
+ 'text' in delta.reasoningContent &&
183
+ delta.reasoningContent.text !== undefined
184
+ ) {
185
+ if (!reasoningMessageId) {
186
+ reasoningMessageId = newMessageId()
187
+ yield {
188
+ type: EventType.REASONING_MESSAGE_START,
189
+ messageId: reasoningMessageId,
190
+ role: 'reasoning',
191
+ timestamp: Date.now(),
192
+ }
193
+ }
194
+ yield {
195
+ type: EventType.REASONING_MESSAGE_CONTENT,
196
+ messageId: reasoningMessageId,
197
+ delta: delta.reasoningContent.text,
198
+ timestamp: Date.now(),
199
+ }
200
+ continue
201
+ }
202
+
203
+ // Text content.
204
+ if (delta && 'text' in delta && delta.text !== undefined) {
205
+ yield* closeReasoning()
206
+ if (!hasEmittedTextMessageStart) {
207
+ hasEmittedTextMessageStart = true
208
+ yield {
209
+ type: EventType.TEXT_MESSAGE_START,
210
+ messageId,
211
+ role: 'assistant',
212
+ timestamp: Date.now(),
213
+ }
214
+ }
215
+ accumulatedContent += delta.text
216
+ yield {
217
+ type: EventType.TEXT_MESSAGE_CONTENT,
218
+ messageId,
219
+ delta: delta.text,
220
+ content: accumulatedContent,
221
+ timestamp: Date.now(),
222
+ }
223
+ }
224
+ continue
225
+ }
226
+
227
+ if ('contentBlockStop' in ev) {
228
+ const stopIndex = ev.contentBlockStop?.contentBlockIndex ?? 0
229
+ const toolCall = toolCallsByIndex.get(stopIndex)
230
+ if (toolCall?.started) {
231
+ yield {
232
+ type: EventType.TOOL_CALL_END,
233
+ toolCallId: toolCall.id,
234
+ toolCallName: toolCall.name,
235
+ toolName: toolCall.name,
236
+ timestamp: Date.now(),
237
+ }
238
+ toolCallsByIndex.delete(stopIndex)
239
+ }
240
+ continue
241
+ }
242
+
243
+ if ('messageStop' in ev) {
244
+ const stopReason = ev.messageStop?.stopReason
245
+ // Map Converse stopReason to AG-UI's narrower finishReason vocabulary.
246
+ finishReason =
247
+ stopReason === 'tool_use'
248
+ ? 'tool_calls'
249
+ : stopReason === 'max_tokens'
250
+ ? 'length'
251
+ : stopReason === 'content_filtered'
252
+ ? 'content_filter'
253
+ : 'stop'
254
+ continue
255
+ }
256
+
257
+ if ('metadata' in ev) {
258
+ const u = ev.metadata?.usage
259
+ if (u) {
260
+ usage = {
261
+ promptTokens: u.inputTokens ?? 0,
262
+ completionTokens: u.outputTokens ?? 0,
263
+ totalTokens: u.totalTokens ?? 0,
264
+ }
265
+ }
266
+ continue
267
+ }
268
+ }
269
+
270
+ // Stream ended (possibly without any content) — still emit RUN_STARTED so
271
+ // consumers always see a run lifecycle.
272
+ yield* ensureRunStarted()
273
+
274
+ // Drain any tool call that opened but never received contentBlockStop.
275
+ for (const [index, toolCall] of toolCallsByIndex) {
276
+ if (!toolCall.started) continue
277
+ yield {
278
+ type: EventType.TOOL_CALL_END,
279
+ toolCallId: toolCall.id,
280
+ toolCallName: toolCall.name,
281
+ toolName: toolCall.name,
282
+ timestamp: Date.now(),
283
+ }
284
+ toolCallsByIndex.delete(index)
285
+ }
286
+
287
+ // Close the text message lifecycle if it was opened.
288
+ if (hasEmittedTextMessageStart) {
289
+ yield {
290
+ type: EventType.TEXT_MESSAGE_END,
291
+ messageId,
292
+ timestamp: Date.now(),
293
+ }
294
+ }
295
+
296
+ // Close any reasoning lifecycle that text never closed.
297
+ yield* closeReasoning()
298
+
299
+ // Single terminal RUN_FINISHED. Conditional `usage` spread keeps the wire
300
+ // shape spec-compliant (AG-UI's `usage` is optional with no `| undefined`).
301
+ yield {
302
+ type: EventType.RUN_FINISHED,
303
+ runId,
304
+ threadId,
305
+ timestamp: Date.now(),
306
+ finishReason: finishReason ?? 'stop',
307
+ ...(usage && { usage }),
308
+ }
309
+ }