@tanstack/openai-base 0.9.15 → 0.10.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.
@@ -30,6 +30,13 @@ import type {
30
30
  TextOptions,
31
31
  } from '@tanstack/ai'
32
32
 
33
+ type ChatStreamState = {
34
+ runId: string
35
+ threadId: string
36
+ messageId: string
37
+ hasEmittedRunStarted: boolean
38
+ }
39
+
33
40
  /**
34
41
  * Shared implementation of the OpenAI Chat Completions API. Holds the
35
42
  * stream-accumulator + AG-UI lifecycle logic and calls the OpenAI SDK
@@ -94,98 +101,99 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
94
101
 
95
102
  yield* this.processStreamChunks(stream, options, aguiState)
96
103
  } catch (error: unknown) {
97
- // Narrow before logging: raw SDK errors can carry request metadata
98
- // (including auth headers) which we must never surface to user loggers.
99
- const errorPayload = toRunErrorPayload(
100
- error,
101
- `${this.name}.chatStream failed`,
102
- )
103
- const rawEvent = toRunErrorRawEvent(error)
104
+ yield* this.handleChatStreamError(error, options, aguiState, 'chatStream')
105
+ }
106
+ }
104
107
 
105
- // Emit RUN_STARTED if not yet emitted
106
- if (!aguiState.hasEmittedRunStarted) {
107
- aguiState.hasEmittedRunStarted = true
108
- yield {
109
- type: EventType.RUN_STARTED,
110
- runId: aguiState.runId,
111
- threadId: aguiState.threadId,
112
- model: options.model,
113
- timestamp: Date.now(),
114
- parentRunId: options.parentRunId,
115
- }
116
- }
108
+ private async *handleChatStreamError(
109
+ error: unknown,
110
+ options: TextOptions,
111
+ aguiState: ChatStreamState,
112
+ source: 'chatStream' | 'processStreamChunks',
113
+ ): AsyncIterable<StreamChunk> {
114
+ // Narrow before logging: raw SDK errors can carry request metadata
115
+ // (including auth headers) which we must never surface to user loggers.
116
+ const errorPayload = toRunErrorPayload(
117
+ error,
118
+ `${this.name}.${source} failed`,
119
+ )
120
+ const rawEvent = toRunErrorRawEvent(error)
117
121
 
118
- const rejectedToolCall = this.extractRejectedToolCall(
119
- rawEvent,
120
- errorPayload.message,
121
- )
122
- if (rejectedToolCall) {
123
- const toolCallId = generateId(this.name)
124
- yield {
125
- type: EventType.TOOL_CALL_START,
126
- toolCallId,
127
- toolCallName: rejectedToolCall.toolName,
128
- toolName: rejectedToolCall.toolName,
129
- parentMessageId: aguiState.messageId,
130
- model: options.model,
131
- timestamp: Date.now(),
132
- }
133
- yield {
134
- type: EventType.TOOL_CALL_ARGS,
135
- toolCallId,
136
- delta: rejectedToolCall.arguments,
137
- args: rejectedToolCall.arguments,
138
- model: options.model,
139
- timestamp: Date.now(),
140
- }
141
- yield {
142
- type: EventType.TOOL_CALL_END,
143
- toolCallId,
144
- toolCallName: rejectedToolCall.toolName,
145
- toolName: rejectedToolCall.toolName,
146
- ...(rejectedToolCall.input !== undefined && {
147
- input: rejectedToolCall.input,
148
- }),
149
- result: JSON.stringify({ error: rejectedToolCall.error }),
150
- state: 'output-error',
151
- model: options.model,
152
- timestamp: Date.now(),
153
- }
154
- yield {
155
- type: EventType.RUN_FINISHED,
156
- runId: aguiState.runId,
157
- threadId: aguiState.threadId,
158
- model: options.model,
159
- timestamp: Date.now(),
160
- finishReason: 'tool_calls',
161
- }
162
- return
122
+ if (!aguiState.hasEmittedRunStarted) {
123
+ aguiState.hasEmittedRunStarted = true
124
+ yield {
125
+ type: EventType.RUN_STARTED,
126
+ runId: aguiState.runId,
127
+ threadId: aguiState.threadId,
128
+ model: options.model,
129
+ timestamp: Date.now(),
130
+ parentRunId: options.parentRunId,
163
131
  }
132
+ }
164
133
 
165
- // Emit AG-UI RUN_ERROR. Conditional `code` spread keeps the wire
166
- // shape spec-compliant under `exactOptionalPropertyTypes`: AG-UI's
167
- // `RunErrorEvent.code` is `string?` (absent vs explicit `undefined`
168
- // matter), so we omit the key when there's no code.
134
+ const rejectedToolCall = this.extractRejectedToolCall(
135
+ rawEvent,
136
+ errorPayload.message,
137
+ )
138
+ if (rejectedToolCall) {
139
+ const toolCallId = generateId(this.name)
169
140
  yield {
170
- type: EventType.RUN_ERROR,
141
+ type: EventType.TOOL_CALL_START,
142
+ toolCallId,
143
+ toolCallName: rejectedToolCall.toolName,
144
+ toolName: rejectedToolCall.toolName,
145
+ parentMessageId: aguiState.messageId,
171
146
  model: options.model,
172
147
  timestamp: Date.now(),
173
- message: errorPayload.message,
174
- code: errorPayload.code,
175
- // Forward the provider's structured error body so consumers can recover
176
- // the upstream detail the `{ message, code }` payload drops. Omitted
177
- // when the error carried no provider body (see toRunErrorRawEvent).
178
- ...(rawEvent !== undefined && { rawEvent }),
179
- error: {
180
- message: errorPayload.message,
181
- code: errorPayload.code,
182
- },
183
148
  }
149
+ yield {
150
+ type: EventType.TOOL_CALL_ARGS,
151
+ toolCallId,
152
+ delta: rejectedToolCall.arguments,
153
+ args: rejectedToolCall.arguments,
154
+ model: options.model,
155
+ timestamp: Date.now(),
156
+ }
157
+ yield {
158
+ type: EventType.TOOL_CALL_END,
159
+ toolCallId,
160
+ toolCallName: rejectedToolCall.toolName,
161
+ toolName: rejectedToolCall.toolName,
162
+ ...(rejectedToolCall.input !== undefined && {
163
+ input: rejectedToolCall.input,
164
+ }),
165
+ result: JSON.stringify({ error: rejectedToolCall.error }),
166
+ state: 'output-error',
167
+ model: options.model,
168
+ timestamp: Date.now(),
169
+ }
170
+ yield {
171
+ type: EventType.RUN_FINISHED,
172
+ runId: aguiState.runId,
173
+ threadId: aguiState.threadId,
174
+ model: options.model,
175
+ timestamp: Date.now(),
176
+ finishReason: 'tool_calls',
177
+ }
178
+ return
179
+ }
184
180
 
185
- options.logger.errors(`${this.name}.chatStream fatal`, {
186
- error: errorPayload,
187
- source: `${this.name}.chatStream`,
188
- })
181
+ options.logger.errors(`${this.name}.${source} fatal`, {
182
+ error: errorPayload,
183
+ source: `${this.name}.${source}`,
184
+ })
185
+
186
+ yield {
187
+ type: EventType.RUN_ERROR,
188
+ model: options.model,
189
+ timestamp: Date.now(),
190
+ message: errorPayload.message,
191
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
192
+ ...(rawEvent !== undefined && { rawEvent }),
193
+ error: {
194
+ message: errorPayload.message,
195
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
196
+ },
189
197
  }
190
198
  }
191
199
 
@@ -680,12 +688,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
680
688
  protected async *processStreamChunks(
681
689
  stream: AsyncIterable<ChatCompletionChunk>,
682
690
  options: TextOptions,
683
- aguiState: {
684
- runId: string
685
- threadId: string
686
- messageId: string
687
- hasEmittedRunStarted: boolean
688
- },
691
+ aguiState: ChatStreamState,
689
692
  ): AsyncIterable<StreamChunk> {
690
693
  let accumulatedContent = ''
691
694
  let hasEmittedTextMessageStart = false
@@ -1136,33 +1139,12 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1136
1139
  }
1137
1140
  }
1138
1141
  } catch (error: unknown) {
1139
- // Narrow before logging: raw SDK errors can carry request metadata
1140
- // (including auth headers) which we must never surface to user loggers.
1141
- const errorPayload = toRunErrorPayload(
1142
+ yield* this.handleChatStreamError(
1142
1143
  error,
1143
- `${this.name}.processStreamChunks failed`,
1144
+ options,
1145
+ aguiState,
1146
+ 'processStreamChunks',
1144
1147
  )
1145
- const rawEvent = toRunErrorRawEvent(error)
1146
- options.logger.errors(`${this.name}.processStreamChunks fatal`, {
1147
- error: errorPayload,
1148
- source: `${this.name}.processStreamChunks`,
1149
- })
1150
-
1151
- // Emit AG-UI RUN_ERROR with conditional `code` spread (see chatStream's
1152
- // catch block for the rationale). `rawEvent` carries the provider's
1153
- // structured error body when present.
1154
- yield {
1155
- type: EventType.RUN_ERROR,
1156
- model: options.model,
1157
- timestamp: Date.now(),
1158
- message: errorPayload.message,
1159
- ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1160
- ...(rawEvent !== undefined && { rawEvent }),
1161
- error: {
1162
- message: errorPayload.message,
1163
- ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1164
- },
1165
- }
1166
1148
  }
1167
1149
  }
1168
1150
 
@@ -1399,6 +1381,17 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1399
1381
  }
1400
1382
  }
1401
1383
 
1384
+ if (part.type === 'document') {
1385
+ // Documents (PDF) are implemented on the Responses adapter, which maps
1386
+ // them to `input_file`. Model modality arrays are not endpoint-scoped,
1387
+ // so a document part can type-check for a model this adapter serves —
1388
+ // point callers at the supported path instead of a generic error.
1389
+ throw new Error(
1390
+ `${this.name} does not support document parts on the Chat Completions ` +
1391
+ `API; use the Responses adapter, which sends them as input_file.`,
1392
+ )
1393
+ }
1394
+
1402
1395
  // Unsupported content type — subclasses can override to handle more types
1403
1396
  return null
1404
1397
  }
@@ -31,6 +31,10 @@ import type {
31
31
  TextOptions,
32
32
  } from '@tanstack/ai'
33
33
 
34
+ // Base64 encoding of the '%PDF' file header — every PDF payload starts with
35
+ // these bytes, so inline document data must begin with this prefix.
36
+ const PDF_BASE64_MAGIC = 'JVBERi'
37
+
34
38
  /**
35
39
  * Provider-specific metadata that preserves the Responses API output item ID.
36
40
  *
@@ -1795,7 +1799,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1795
1799
  throw new Error(
1796
1800
  `User message for ${this.name} has no content parts. ` +
1797
1801
  `Empty user messages would produce a paid request with no input; ` +
1798
- `provide at least one text/image/audio part or omit the message.`,
1802
+ `provide at least one text/image/audio/document part or omit the message.`,
1799
1803
  )
1800
1804
  }
1801
1805
 
@@ -1811,7 +1815,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1811
1815
 
1812
1816
  /**
1813
1817
  * Converts a ContentPart to Responses API input content item.
1814
- * Handles text, image, and audio content parts.
1818
+ * Handles text, image, audio, and document (PDF) content parts.
1815
1819
  * Override this in subclasses for additional content types or provider-specific metadata.
1816
1820
  */
1817
1821
  protected convertContentPartToInput(part: ContentPart): ResponseInputContent {
@@ -1855,7 +1859,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1855
1859
  }
1856
1860
  }
1857
1861
  // Wrap raw base64 in a data URL — `input_file` rejects bare base64
1858
- // payloads (matches the image branch above which already does this).
1862
+ // payloads (matches the image branch above).
1859
1863
  // Default the MIME if missing so we never interpolate `undefined`.
1860
1864
  const audioValue = part.source.value
1861
1865
  const audioMime = part.source.mimeType || 'application/octet-stream'
@@ -1868,12 +1872,87 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1868
1872
  }
1869
1873
  }
1870
1874
 
1875
+ case 'document': {
1876
+ const documentMetadata = part.metadata as
1877
+ | { filename?: string; detail?: 'auto' | 'low' | 'high' }
1878
+ | undefined
1879
+ // Spread `detail` only when provided so the API applies its own
1880
+ // default ('auto'). The Responses API accepts 'auto' | 'low' | 'high',
1881
+ // but the pinned OpenAI SDK's `ResponseInputFile.detail` type still
1882
+ // lists only 'low' | 'high' — cast so 'auto' (a valid API value) can
1883
+ // pass through without a type error.
1884
+ const documentDetail =
1885
+ documentMetadata?.detail !== undefined
1886
+ ? { detail: documentMetadata.detail as 'low' | 'high' }
1887
+ : {}
1888
+ if (part.source.type === 'url') {
1889
+ // The Responses API fetches the PDF itself; filename and MIME
1890
+ // type are inferred from the response.
1891
+ return {
1892
+ type: 'input_file',
1893
+ file_url: part.source.value,
1894
+ ...documentDetail,
1895
+ }
1896
+ }
1897
+ // This adapter supports only PDF documents; anything else is
1898
+ // rejected. MIME types are case-insensitive and can carry
1899
+ // ;parameters (RFC 2045), so the type is normalized before comparing.
1900
+ const documentValue = part.source.value
1901
+ const documentMime = (
1902
+ (part.source.mimeType || 'application/pdf').split(';')[0] ?? ''
1903
+ )
1904
+ .trim()
1905
+ .toLowerCase()
1906
+ if (documentMime !== 'application/pdf') {
1907
+ throw new Error(
1908
+ `${this.name} document parts only support application/pdf ` +
1909
+ `(received ${documentMime})`,
1910
+ )
1911
+ }
1912
+ // A pre-wrapped data URL carries its own media type — validate it too.
1913
+ if (
1914
+ documentValue.startsWith('data:') &&
1915
+ !/^data:application\/pdf[;,]/i.test(documentValue)
1916
+ ) {
1917
+ throw new Error(
1918
+ `${this.name} document parts only support application/pdf ` +
1919
+ `(received data URL with non-PDF media type)`,
1920
+ )
1921
+ }
1922
+ // Sniff the payload so a non-PDF labeled (or unlabeled) as PDF is
1923
+ // caught locally instead of by an opaque provider 400. Only base64
1924
+ // payloads are checked; a rare `data:` URL without `;base64` is left
1925
+ // to the server.
1926
+ const documentBase64 = documentValue.startsWith('data:')
1927
+ ? /;base64,/i.test(documentValue)
1928
+ ? documentValue.slice(documentValue.indexOf(',') + 1)
1929
+ : ''
1930
+ : documentValue
1931
+ if (documentBase64 && !documentBase64.startsWith(PDF_BASE64_MAGIC)) {
1932
+ throw new Error(
1933
+ `${this.name} document parts only support application/pdf ` +
1934
+ `(inline data does not start with the %PDF header)`,
1935
+ )
1936
+ }
1937
+ // Wrap raw base64 in a data URL — `input_file` rejects bare base64
1938
+ // payloads (matches the image and audio branches above).
1939
+ const documentFileData = documentValue.startsWith('data:')
1940
+ ? documentValue
1941
+ : `data:${documentMime};base64,${documentValue}`
1942
+ return {
1943
+ type: 'input_file',
1944
+ // The Responses API requires a filename alongside PDF `file_data`.
1945
+ filename: documentMetadata?.filename || 'document.pdf',
1946
+ file_data: documentFileData,
1947
+ ...documentDetail,
1948
+ }
1949
+ }
1950
+
1871
1951
  case 'video':
1872
- case 'document':
1873
1952
  default:
1874
- // OpenAI Responses API doesn't accept native video/document parts on
1875
- // this path — surface as explicit unsupported error so callers see
1876
- // the same message regardless of which content type leaked through.
1953
+ // OpenAI Responses API doesn't accept native video parts on this
1954
+ // path — surface as explicit unsupported error so callers see the
1955
+ // same message regardless of which content type leaked through.
1877
1956
  throw new Error(`Unsupported content part type: ${part.type}`)
1878
1957
  }
1879
1958
  }