@tanstack/openai-base 0.3.3 → 0.3.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/openai-base",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Shared base adapters for OpenAI-SDK-backed providers in TanStack AI (Chat Completions and Responses)",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -33,13 +33,13 @@
33
33
  "@tanstack/ai-utils": "0.2.0"
34
34
  },
35
35
  "peerDependencies": {
36
- "@tanstack/ai": "^0.20.0"
36
+ "@tanstack/ai": "^0.21.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@vitest/coverage-v8": "4.0.14",
40
40
  "vite": "^7.3.3",
41
41
  "zod": "^4.2.0",
42
- "@tanstack/ai": "0.20.0"
42
+ "@tanstack/ai": "0.21.0"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "vite build",
@@ -46,7 +46,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
46
46
  TMessageMetadata,
47
47
  TToolCapabilities
48
48
  > {
49
- readonly kind = 'text' as const
49
+ override readonly kind = 'text' as const
50
50
  readonly name: string
51
51
  protected client: OpenAI
52
52
 
@@ -110,14 +110,20 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
110
110
  }
111
111
  }
112
112
 
113
- // Emit AG-UI RUN_ERROR
113
+ // Emit AG-UI RUN_ERROR. Conditional `code` spread keeps the wire
114
+ // shape spec-compliant under `exactOptionalPropertyTypes`: AG-UI's
115
+ // `RunErrorEvent.code` is `string?` (absent vs explicit `undefined`
116
+ // matter), so we omit the key when there's no code.
114
117
  yield {
115
118
  type: EventType.RUN_ERROR,
116
119
  model: options.model,
117
120
  timestamp: Date.now(),
118
121
  message: errorPayload.message,
119
122
  code: errorPayload.code,
120
- error: errorPayload,
123
+ error: {
124
+ message: errorPayload.message,
125
+ code: errorPayload.code,
126
+ },
121
127
  }
122
128
 
123
129
  options.logger.errors(`${this.name}.chatStream fatal`, {
@@ -518,14 +524,21 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
518
524
  `${this.name}.structuredOutputStream failed`,
519
525
  )
520
526
 
527
+ // Conditional `code` spread keeps the wire shape spec-compliant under
528
+ // `exactOptionalPropertyTypes`: AG-UI's `RunErrorEvent.code` is `string?`
529
+ // (absent vs explicit `undefined` matter).
530
+ const resolvedCode = isAbort ? 'aborted' : errorPayload.code
521
531
  yield {
522
532
  type: EventType.RUN_ERROR,
523
533
  runId: aguiState.runId,
524
534
  model: lastModel || chatOptions.model,
525
535
  timestamp,
526
536
  message: errorPayload.message,
527
- code: isAbort ? 'aborted' : errorPayload.code,
528
- error: { ...errorPayload, ...(isAbort && { code: 'aborted' }) },
537
+ ...(resolvedCode !== undefined && { code: resolvedCode }),
538
+ error: {
539
+ message: errorPayload.message,
540
+ ...(resolvedCode !== undefined && { code: resolvedCode }),
541
+ },
529
542
  }
530
543
 
531
544
  chatOptions.logger.errors(`${this.name}.structuredOutputStream fatal`, {
@@ -788,17 +801,17 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
788
801
  const index = toolCallDelta.index
789
802
 
790
803
  // Initialize or update the tool call in progress
791
- if (!toolCallsInProgress.has(index)) {
792
- toolCallsInProgress.set(index, {
804
+ let toolCall = toolCallsInProgress.get(index)
805
+ if (!toolCall) {
806
+ toolCall = {
793
807
  id: toolCallDelta.id || '',
794
808
  name: toolCallDelta.function?.name || '',
795
809
  arguments: '',
796
810
  started: false,
797
- })
811
+ }
812
+ toolCallsInProgress.set(index, toolCall)
798
813
  }
799
814
 
800
- const toolCall = toolCallsInProgress.get(index)!
801
-
802
815
  // Update with any new data from the delta
803
816
  if (toolCallDelta.id) {
804
817
  toolCall.id = toolCallDelta.id
@@ -1030,19 +1043,22 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1030
1043
  ? 'tool_calls'
1031
1044
  : (pendingFinishReason ?? 'stop')
1032
1045
 
1046
+ // Conditional `usage` spread: AG-UI's `RunFinishedEvent.usage` is
1047
+ // optional with no `| undefined`; omit the key entirely when no usage
1048
+ // arrived rather than emitting `usage: undefined`.
1033
1049
  yield {
1034
1050
  type: EventType.RUN_FINISHED,
1035
1051
  runId: aguiState.runId,
1036
1052
  threadId: aguiState.threadId,
1037
1053
  model: lastModel || options.model,
1038
1054
  timestamp: Date.now(),
1039
- usage: lastUsage
1040
- ? {
1041
- promptTokens: lastUsage.prompt_tokens || 0,
1042
- completionTokens: lastUsage.completion_tokens || 0,
1043
- totalTokens: lastUsage.total_tokens || 0,
1044
- }
1045
- : undefined,
1055
+ ...(lastUsage && {
1056
+ usage: {
1057
+ promptTokens: lastUsage.prompt_tokens || 0,
1058
+ completionTokens: lastUsage.completion_tokens || 0,
1059
+ totalTokens: lastUsage.total_tokens || 0,
1060
+ },
1061
+ }),
1046
1062
  finishReason,
1047
1063
  }
1048
1064
  }
@@ -1058,14 +1074,18 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1058
1074
  source: `${this.name}.processStreamChunks`,
1059
1075
  })
1060
1076
 
1061
- // Emit AG-UI RUN_ERROR
1077
+ // Emit AG-UI RUN_ERROR with conditional `code` spread (see chatStream's
1078
+ // catch block for the rationale).
1062
1079
  yield {
1063
1080
  type: EventType.RUN_ERROR,
1064
1081
  model: options.model,
1065
1082
  timestamp: Date.now(),
1066
1083
  message: errorPayload.message,
1067
- code: errorPayload.code,
1068
- error: errorPayload,
1084
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1085
+ error: {
1086
+ message: errorPayload.message,
1087
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1088
+ },
1069
1089
  }
1070
1090
  }
1071
1091
  }
@@ -46,7 +46,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
46
46
  TMessageMetadata,
47
47
  TToolCapabilities
48
48
  > {
49
- readonly kind = 'text' as const
49
+ override readonly kind = 'text' as const
50
50
  readonly name: string
51
51
  protected client: OpenAI
52
52
 
@@ -74,7 +74,8 @@ export abstract class OpenAIBaseResponsesTextAdapter<
74
74
  ended?: boolean
75
75
  // Set when args.done arrives before TOOL_CALL_START could fire
76
76
  // (output_item.added lacked a name). output_item.done picks these
77
- // up to emit the missing END.
77
+ // up to emit the missing END. Allow explicit `undefined` so the
78
+ // emission paths can re-clear the slot after handing it off.
78
79
  pendingArguments?: string
79
80
  }
80
81
  >()
@@ -132,14 +133,20 @@ export abstract class OpenAIBaseResponsesTextAdapter<
132
133
  }
133
134
  }
134
135
 
135
- // Emit AG-UI RUN_ERROR
136
+ // Emit AG-UI RUN_ERROR. Conditional `code` spread keeps the wire
137
+ // shape spec-compliant under `exactOptionalPropertyTypes`: AG-UI's
138
+ // `RunErrorEvent.code` is `string?` (absent vs explicit `undefined`
139
+ // matter), so we omit the key when there's no code.
136
140
  yield {
137
141
  type: EventType.RUN_ERROR,
138
142
  model: options.model,
139
143
  timestamp: Date.now(),
140
144
  message: errorPayload.message,
141
145
  code: errorPayload.code,
142
- error: errorPayload,
146
+ error: {
147
+ message: errorPayload.message,
148
+ code: errorPayload.code,
149
+ },
143
150
  }
144
151
 
145
152
  options.logger.errors(`${this.name}.chatStream fatal`, {
@@ -439,11 +446,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
439
446
  yield* openReasoning()
440
447
  // openReasoning() guarantees reasoningMessageId is set on first call;
441
448
  // TS can't see through the generator side-effect.
442
- const messageId = reasoningMessageId!
449
+ if (!reasoningMessageId) continue
443
450
  accumulatedReasoning += reasoningDelta
444
451
  yield {
445
452
  type: EventType.REASONING_MESSAGE_CONTENT,
446
- messageId,
453
+ messageId: reasoningMessageId,
447
454
  delta: reasoningDelta,
448
455
  model,
449
456
  timestamp,
@@ -499,14 +506,17 @@ export abstract class OpenAIBaseResponsesTextAdapter<
499
506
  ).response
500
507
  const message =
501
508
  response?.error?.message || 'Responses API stream failed'
509
+ const code = response?.error?.code
510
+ // Conditional `code` spread keeps the wire shape spec-compliant
511
+ // under `exactOptionalPropertyTypes` (see chatStream catch).
502
512
  yield {
503
513
  type: EventType.RUN_ERROR,
504
514
  runId: aguiState.runId,
505
515
  model,
506
516
  timestamp,
507
517
  message,
508
- code: response?.error?.code,
509
- error: { message, code: response?.error?.code },
518
+ ...(code !== undefined && { code }),
519
+ error: { message, ...(code !== undefined && { code }) },
510
520
  }
511
521
  return
512
522
  }
@@ -606,14 +616,20 @@ export abstract class OpenAIBaseResponsesTextAdapter<
606
616
  `${this.name}.structuredOutputStream failed`,
607
617
  )
608
618
 
619
+ // Conditional `code` spread keeps the wire shape spec-compliant under
620
+ // `exactOptionalPropertyTypes` (see chatStream catch).
621
+ const resolvedCode = isAbort ? 'aborted' : errorPayload.code
609
622
  yield {
610
623
  type: EventType.RUN_ERROR,
611
624
  runId: aguiState.runId,
612
625
  model,
613
626
  timestamp,
614
627
  message: errorPayload.message,
615
- code: isAbort ? 'aborted' : errorPayload.code,
616
- error: { ...errorPayload, ...(isAbort && { code: 'aborted' }) },
628
+ ...(resolvedCode !== undefined && { code: resolvedCode }),
629
+ error: {
630
+ message: errorPayload.message,
631
+ ...(resolvedCode !== undefined && { code: resolvedCode }),
632
+ },
617
633
  }
618
634
 
619
635
  chatOptions.logger.errors(`${this.name}.structuredOutputStream fatal`, {
@@ -742,7 +758,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
742
758
  name: string
743
759
  started: boolean
744
760
  ended?: boolean
745
- pendingArguments?: string
761
+ pendingArguments?: string | undefined
746
762
  }
747
763
  >,
748
764
  options: TextOptions<TProviderOptions>,
@@ -1151,25 +1167,25 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1151
1167
  if (chunk.type === 'response.output_item.added') {
1152
1168
  const item = chunk.item
1153
1169
  if (item.type === 'function_call' && item.id) {
1154
- const existing = toolCallMetadata.get(item.id)
1155
1170
  // Track the item as soon as we see it so subsequent arg deltas
1156
1171
  // aren't logged as orphans, but only emit TOOL_CALL_START when
1157
1172
  // both id AND name are populated. Emitting START with an empty
1158
1173
  // name would propagate into TOOL_CALL_END (which reads the same
1159
1174
  // metadata) and route the tool call to whatever name happens to
1160
1175
  // match `''` downstream — a silent misroute.
1161
- if (!existing) {
1162
- toolCallMetadata.set(item.id, {
1176
+ let metadata = toolCallMetadata.get(item.id)
1177
+ if (!metadata) {
1178
+ metadata = {
1163
1179
  index: chunk.output_index,
1164
1180
  name: item.name || '',
1165
1181
  started: false,
1166
- })
1167
- } else if (!existing.name && item.name) {
1182
+ }
1183
+ toolCallMetadata.set(item.id, metadata)
1184
+ } else if (!metadata.name && item.name) {
1168
1185
  // A later output_item.added for the same id finally carries
1169
1186
  // the name. Update so the gated emission below can fire.
1170
- existing.name = item.name
1187
+ metadata.name = item.name
1171
1188
  }
1172
- const metadata = toolCallMetadata.get(item.id)!
1173
1189
  if (!metadata.started && metadata.name) {
1174
1190
  yield {
1175
1191
  type: EventType.TOOL_CALL_START,
@@ -1496,15 +1512,18 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1496
1512
  }
1497
1513
 
1498
1514
  if (chunk.type === 'error') {
1515
+ // Conditional `code` spread keeps the wire shape spec-compliant
1516
+ // under `exactOptionalPropertyTypes` (see chatStream catch).
1517
+ const code = chunk.code ?? undefined
1499
1518
  yield {
1500
1519
  type: EventType.RUN_ERROR,
1501
1520
  model: model || options.model,
1502
1521
  timestamp: Date.now(),
1503
1522
  message: chunk.message,
1504
- code: chunk.code ?? undefined,
1523
+ ...(code !== undefined && { code }),
1505
1524
  error: {
1506
1525
  message: chunk.message,
1507
- code: chunk.code ?? undefined,
1526
+ ...(code !== undefined && { code }),
1508
1527
  },
1509
1528
  }
1510
1529
  // RUN_ERROR is terminal — don't let the synthetic RUN_FINISHED
@@ -1531,13 +1550,16 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1531
1550
  timestamp: Date.now(),
1532
1551
  }
1533
1552
  }
1553
+ // Omit `usage` entirely (vs `usage: undefined`) — the synthetic
1554
+ // RUN_FINISHED for truncated streams has no usage data, and AG-UI's
1555
+ // `RunFinishedEvent.usage` is optional without `| undefined` under
1556
+ // `exactOptionalPropertyTypes`.
1534
1557
  yield {
1535
1558
  type: EventType.RUN_FINISHED,
1536
1559
  runId: aguiState.runId,
1537
1560
  threadId: aguiState.threadId,
1538
1561
  model: model || options.model,
1539
1562
  timestamp: Date.now(),
1540
- usage: undefined,
1541
1563
  finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop',
1542
1564
  }
1543
1565
  }
@@ -1552,13 +1574,18 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1552
1574
  error: errorPayload,
1553
1575
  source: `${this.name}.processStreamChunks`,
1554
1576
  })
1577
+ // Emit AG-UI RUN_ERROR with conditional `code` spread (see chatStream
1578
+ // catch for the rationale).
1555
1579
  yield {
1556
1580
  type: EventType.RUN_ERROR,
1557
1581
  model: options.model,
1558
1582
  timestamp: Date.now(),
1559
1583
  message: errorPayload.message,
1560
- code: errorPayload.code,
1561
- error: errorPayload,
1584
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1585
+ error: {
1586
+ message: errorPayload.message,
1587
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1588
+ },
1562
1589
  }
1563
1590
  }
1564
1591
  }
@@ -1765,7 +1792,12 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1765
1792
  }
1766
1793
  }
1767
1794
 
1795
+ case 'video':
1796
+ case 'document':
1768
1797
  default:
1798
+ // OpenAI Responses API doesn't accept native video/document parts on
1799
+ // this path — surface as explicit unsupported error so callers see
1800
+ // the same message regardless of which content type leaked through.
1769
1801
  throw new Error(`Unsupported content part type: ${part.type}`)
1770
1802
  }
1771
1803
  }
@@ -11,11 +11,17 @@ export type CustomTool = CustomToolConfig
11
11
  */
12
12
  export function convertCustomToolToAdapterFormat(tool: Tool): CustomToolConfig {
13
13
  const metadata = tool.metadata as CustomToolConfig
14
+ // Conditional spread: the SDK's `CustomToolConfig` declares optional
15
+ // fields as `description?: string` (no `| undefined`) under
16
+ // exactOptionalPropertyTypes, so we omit absent fields rather than
17
+ // passing them through as explicit `undefined`.
14
18
  return {
15
19
  type: 'custom',
16
20
  name: metadata.name,
17
- description: metadata.description,
18
- format: metadata.format,
21
+ ...(metadata.description !== undefined && {
22
+ description: metadata.description,
23
+ }),
24
+ ...(metadata.format !== undefined && { format: metadata.format }),
19
25
  }
20
26
  }
21
27
 
@@ -22,12 +22,19 @@ export function convertFileSearchToolToAdapterFormat(
22
22
  tool: Tool,
23
23
  ): FileSearchToolConfig {
24
24
  const metadata = tool.metadata as FileSearchToolConfig
25
+ // Conditional spread: SDK's `FileSearchToolConfig` declares the
26
+ // optional fields without `| undefined`, so we omit absent values
27
+ // rather than passing them through as explicit `undefined`.
25
28
  return {
26
29
  type: 'file_search',
27
30
  vector_store_ids: metadata.vector_store_ids,
28
- max_num_results: metadata.max_num_results,
29
- ranking_options: metadata.ranking_options,
30
- filters: metadata.filters,
31
+ ...(metadata.max_num_results !== undefined && {
32
+ max_num_results: metadata.max_num_results,
33
+ }),
34
+ ...(metadata.ranking_options !== undefined && {
35
+ ranking_options: metadata.ranking_options,
36
+ }),
37
+ ...(metadata.filters !== undefined && { filters: metadata.filters }),
31
38
  }
32
39
  }
33
40
 
@@ -35,7 +35,7 @@ export * from './image-generation-tool'
35
35
  export * from './local-shell-tool'
36
36
  export * from './mcp-tool'
37
37
  export * from './shell-tool'
38
- export * from './tool-choice'
38
+ export type * from './tool-choice'
39
39
  export * from './tool-converter'
40
40
  export * from './web-search-preview-tool'
41
41
  export * from './web-search-tool'
@@ -12,5 +12,13 @@ export function extractRequestOptions(
12
12
  request: Request | RequestInit | undefined,
13
13
  ): { headers?: HeadersInit; signal?: AbortSignal | null } {
14
14
  if (!request) return {}
15
- return { headers: request.headers, signal: request.signal ?? undefined }
15
+ // Conditional spread: under exactOptionalPropertyTypes the target's
16
+ // `headers?: HeadersInit` and `signal?: AbortSignal | null` forbid an
17
+ // explicit `undefined`. Omit the keys entirely when the source values
18
+ // are absent so the OpenAI SDK sees `headers: undefined` as "not set"
19
+ // rather than a present-but-undefined value.
20
+ return {
21
+ ...(request.headers !== undefined && { headers: request.headers }),
22
+ ...(request.signal != null && { signal: request.signal }),
23
+ }
16
24
  }
@@ -15,7 +15,8 @@ export function makeStructuredOutputCompatible(
15
15
  ): Record<string, any> {
16
16
  const result = { ...schema }
17
17
  const required =
18
- originalRequired ?? (Array.isArray(result.required) ? result.required : [])
18
+ originalRequired ??
19
+ (Array.isArray(result['required']) ? result['required'] : [])
19
20
 
20
21
  if (result.type === 'object' && result.properties) {
21
22
  const properties = { ...result.properties }