@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.
- package/dist/esm/adapters/chat-completions-text.d.ts +9 -6
- package/dist/esm/adapters/chat-completions-text.js +71 -84
- package/dist/esm/adapters/chat-completions-text.js.map +1 -1
- package/dist/esm/adapters/responses-text.d.ts +1 -1
- package/dist/esm/adapters/responses-text.js +25 -2
- package/dist/esm/adapters/responses-text.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/chat-completions-text.ts +107 -114
- package/src/adapters/responses-text.ts +86 -7
|
@@ -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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
1140
|
-
// (including auth headers) which we must never surface to user loggers.
|
|
1141
|
-
const errorPayload = toRunErrorPayload(
|
|
1142
|
+
yield* this.handleChatStreamError(
|
|
1142
1143
|
error,
|
|
1143
|
-
|
|
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
|
|
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
|
|
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
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
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
|
}
|