@tanstack/openai-base 0.4.1 → 0.6.0

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.
@@ -0,0 +1,83 @@
1
+ import { buildBaseUsage } from "@tanstack/ai";
2
+ function buildChatCompletionsUsage(usage) {
3
+ if (!usage) return void 0;
4
+ const result = buildBaseUsage({
5
+ promptTokens: usage.prompt_tokens || 0,
6
+ completionTokens: usage.completion_tokens || 0,
7
+ totalTokens: usage.total_tokens || 0
8
+ });
9
+ const completionDetails = usage.completion_tokens_details;
10
+ const completionTokensDetails = {
11
+ ...completionDetails?.reasoning_tokens ? { reasoningTokens: completionDetails.reasoning_tokens } : {},
12
+ ...completionDetails?.audio_tokens ? { audioTokens: completionDetails.audio_tokens } : {}
13
+ };
14
+ const promptDetails = usage.prompt_tokens_details;
15
+ const promptTokensDetails = {
16
+ ...promptDetails?.cached_tokens ? { cachedTokens: promptDetails.cached_tokens } : {},
17
+ ...promptDetails?.audio_tokens ? { audioTokens: promptDetails.audio_tokens } : {}
18
+ };
19
+ if (Object.keys(completionTokensDetails).length > 0) {
20
+ result.completionTokensDetails = completionTokensDetails;
21
+ }
22
+ if (Object.keys(promptTokensDetails).length > 0) {
23
+ result.promptTokensDetails = promptTokensDetails;
24
+ }
25
+ const providerUsageDetails = {
26
+ ...completionDetails?.accepted_prediction_tokens ? {
27
+ acceptedPredictionTokens: completionDetails.accepted_prediction_tokens
28
+ } : {},
29
+ ...completionDetails?.rejected_prediction_tokens ? {
30
+ rejectedPredictionTokens: completionDetails.rejected_prediction_tokens
31
+ } : {}
32
+ };
33
+ if (Object.keys(providerUsageDetails).length > 0) {
34
+ result.providerUsageDetails = providerUsageDetails;
35
+ }
36
+ return result;
37
+ }
38
+ function buildResponsesUsage(usage) {
39
+ if (!usage) return void 0;
40
+ const result = buildBaseUsage({
41
+ promptTokens: usage.input_tokens || 0,
42
+ completionTokens: usage.output_tokens || 0,
43
+ totalTokens: usage.total_tokens || 0
44
+ });
45
+ const cachedTokens = usage.input_tokens_details?.cached_tokens;
46
+ if (cachedTokens && cachedTokens > 0) {
47
+ result.promptTokensDetails = {
48
+ ...result.promptTokensDetails,
49
+ cachedTokens
50
+ };
51
+ }
52
+ const reasoningTokens = usage.output_tokens_details?.reasoning_tokens;
53
+ if (reasoningTokens && reasoningTokens > 0) {
54
+ result.completionTokensDetails = {
55
+ ...result.completionTokensDetails,
56
+ reasoningTokens
57
+ };
58
+ }
59
+ return result;
60
+ }
61
+ function buildImagesUsage(usage) {
62
+ if (!usage) return void 0;
63
+ const result = buildBaseUsage({
64
+ promptTokens: usage.input_tokens || 0,
65
+ completionTokens: usage.output_tokens || 0,
66
+ totalTokens: usage.total_tokens || 0
67
+ });
68
+ const inputDetails = usage.input_tokens_details;
69
+ const promptTokensDetails = {
70
+ ...inputDetails?.text_tokens ? { textTokens: inputDetails.text_tokens } : {},
71
+ ...inputDetails?.image_tokens ? { imageTokens: inputDetails.image_tokens } : {}
72
+ };
73
+ if (Object.keys(promptTokensDetails).length > 0) {
74
+ result.promptTokensDetails = promptTokensDetails;
75
+ }
76
+ return result;
77
+ }
78
+ export {
79
+ buildChatCompletionsUsage,
80
+ buildImagesUsage,
81
+ buildResponsesUsage
82
+ };
83
+ //# sourceMappingURL=usage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usage.js","sources":["../../src/usage.ts"],"sourcesContent":["import { buildBaseUsage } from '@tanstack/ai'\nimport type { TokenUsage } from '@tanstack/ai'\nimport type OpenAI from 'openai'\n\n/**\n * Build normalized {@link TokenUsage} from an OpenAI-compatible Chat\n * Completions `usage` object.\n *\n * Shared by every provider that routes through\n * {@link OpenAIBaseChatCompletionsTextAdapter} (OpenAI Chat Completions, Grok,\n * Groq). Surfaces cached prompt tokens and reasoning/audio detail tokens when\n * the provider reports them. Returns `undefined` when the provider reported no\n * usage object, so callers omit the field rather than fabricating zeroed totals.\n */\nexport function buildChatCompletionsUsage(\n usage: OpenAI.Chat.Completions.ChatCompletion['usage'] | undefined | null,\n): TokenUsage | undefined {\n if (!usage) return undefined\n\n const result = buildBaseUsage({\n promptTokens: usage.prompt_tokens || 0,\n completionTokens: usage.completion_tokens || 0,\n totalTokens: usage.total_tokens || 0,\n })\n\n const completionDetails = usage.completion_tokens_details\n const completionTokensDetails = {\n ...(completionDetails?.reasoning_tokens\n ? { reasoningTokens: completionDetails.reasoning_tokens }\n : {}),\n ...(completionDetails?.audio_tokens\n ? { audioTokens: completionDetails.audio_tokens }\n : {}),\n }\n\n const promptDetails = usage.prompt_tokens_details\n const promptTokensDetails = {\n ...(promptDetails?.cached_tokens\n ? { cachedTokens: promptDetails.cached_tokens }\n : {}),\n ...(promptDetails?.audio_tokens\n ? { audioTokens: promptDetails.audio_tokens }\n : {}),\n }\n\n if (Object.keys(completionTokensDetails).length > 0) {\n result.completionTokensDetails = completionTokensDetails\n }\n if (Object.keys(promptTokensDetails).length > 0) {\n result.promptTokensDetails = promptTokensDetails\n }\n\n // Predicted Outputs accepted/rejected counts have no canonical TokenUsage\n // slot but are still billed (rejected tokens included), so surface them under\n // providerUsageDetails — matching how the OpenRouter adapter exposes them.\n const providerUsageDetails = {\n ...(completionDetails?.accepted_prediction_tokens\n ? {\n acceptedPredictionTokens:\n completionDetails.accepted_prediction_tokens,\n }\n : {}),\n ...(completionDetails?.rejected_prediction_tokens\n ? {\n rejectedPredictionTokens:\n completionDetails.rejected_prediction_tokens,\n }\n : {}),\n }\n if (Object.keys(providerUsageDetails).length > 0) {\n result.providerUsageDetails = providerUsageDetails\n }\n\n return result\n}\n\n/**\n * Build normalized {@link TokenUsage} from an OpenAI Responses API\n * `ResponseUsage` object.\n *\n * Shared by every provider that routes through\n * {@link OpenAIBaseResponsesTextAdapter}. Surfaces cached prompt tokens and\n * reasoning detail tokens when present. Returns `undefined` when the provider\n * reported no usage object, so callers omit the field rather than fabricating\n * zeroed totals.\n */\nexport function buildResponsesUsage(\n usage: OpenAI.Responses.ResponseUsage | undefined | null,\n): TokenUsage | undefined {\n if (!usage) return undefined\n\n const result = buildBaseUsage({\n promptTokens: usage.input_tokens || 0,\n completionTokens: usage.output_tokens || 0,\n totalTokens: usage.total_tokens || 0,\n })\n\n // Despite the SDK types marking these required, they can be undefined at runtime.\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n const cachedTokens = usage.input_tokens_details?.cached_tokens\n if (cachedTokens && cachedTokens > 0) {\n result.promptTokensDetails = {\n ...result.promptTokensDetails,\n cachedTokens,\n }\n }\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n const reasoningTokens = usage.output_tokens_details?.reasoning_tokens\n if (reasoningTokens && reasoningTokens > 0) {\n result.completionTokensDetails = {\n ...result.completionTokensDetails,\n reasoningTokens,\n }\n }\n\n return result\n}\n\n/**\n * Build normalized {@link TokenUsage} from an OpenAI Images API `usage` object.\n *\n * Shared by every provider that generates images through the OpenAI Images SDK\n * (OpenAI, Grok). Token-billed image models (e.g. gpt-image-1) report an input\n * breakdown of text vs image tokens, which is surfaced on `promptTokensDetails`.\n * Models that don't return usage (e.g. DALL·E) yield `undefined` so callers can\n * omit the field rather than emit zeroed totals.\n */\nexport function buildImagesUsage(\n usage: OpenAI.Images.ImagesResponse['usage'] | undefined | null,\n): TokenUsage | undefined {\n if (!usage) return undefined\n\n const result = buildBaseUsage({\n promptTokens: usage.input_tokens || 0,\n completionTokens: usage.output_tokens || 0,\n totalTokens: usage.total_tokens || 0,\n })\n\n // The SDK types input_tokens_details (and its numeric fields) as required, but\n // real responses — e.g. from DALL·E or other non-token-billed models — can\n // omit them, so treat the breakdown as optional.\n const inputDetails = usage.input_tokens_details as\n | { text_tokens?: number; image_tokens?: number }\n | undefined\n const promptTokensDetails = {\n ...(inputDetails?.text_tokens\n ? { textTokens: inputDetails.text_tokens }\n : {}),\n ...(inputDetails?.image_tokens\n ? { imageTokens: inputDetails.image_tokens }\n : {}),\n }\n if (Object.keys(promptTokensDetails).length > 0) {\n result.promptTokensDetails = promptTokensDetails\n }\n\n return result\n}\n"],"names":[],"mappings":";AAcO,SAAS,0BACd,OACwB;AACxB,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,SAAS,eAAe;AAAA,IAC5B,cAAc,MAAM,iBAAiB;AAAA,IACrC,kBAAkB,MAAM,qBAAqB;AAAA,IAC7C,aAAa,MAAM,gBAAgB;AAAA,EAAA,CACpC;AAED,QAAM,oBAAoB,MAAM;AAChC,QAAM,0BAA0B;AAAA,IAC9B,GAAI,mBAAmB,mBACnB,EAAE,iBAAiB,kBAAkB,iBAAA,IACrC,CAAA;AAAA,IACJ,GAAI,mBAAmB,eACnB,EAAE,aAAa,kBAAkB,aAAA,IACjC,CAAA;AAAA,EAAC;AAGP,QAAM,gBAAgB,MAAM;AAC5B,QAAM,sBAAsB;AAAA,IAC1B,GAAI,eAAe,gBACf,EAAE,cAAc,cAAc,cAAA,IAC9B,CAAA;AAAA,IACJ,GAAI,eAAe,eACf,EAAE,aAAa,cAAc,aAAA,IAC7B,CAAA;AAAA,EAAC;AAGP,MAAI,OAAO,KAAK,uBAAuB,EAAE,SAAS,GAAG;AACnD,WAAO,0BAA0B;AAAA,EACnC;AACA,MAAI,OAAO,KAAK,mBAAmB,EAAE,SAAS,GAAG;AAC/C,WAAO,sBAAsB;AAAA,EAC/B;AAKA,QAAM,uBAAuB;AAAA,IAC3B,GAAI,mBAAmB,6BACnB;AAAA,MACE,0BACE,kBAAkB;AAAA,IAAA,IAEtB,CAAA;AAAA,IACJ,GAAI,mBAAmB,6BACnB;AAAA,MACE,0BACE,kBAAkB;AAAA,IAAA,IAEtB,CAAA;AAAA,EAAC;AAEP,MAAI,OAAO,KAAK,oBAAoB,EAAE,SAAS,GAAG;AAChD,WAAO,uBAAuB;AAAA,EAChC;AAEA,SAAO;AACT;AAYO,SAAS,oBACd,OACwB;AACxB,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,SAAS,eAAe;AAAA,IAC5B,cAAc,MAAM,gBAAgB;AAAA,IACpC,kBAAkB,MAAM,iBAAiB;AAAA,IACzC,aAAa,MAAM,gBAAgB;AAAA,EAAA,CACpC;AAID,QAAM,eAAe,MAAM,sBAAsB;AACjD,MAAI,gBAAgB,eAAe,GAAG;AACpC,WAAO,sBAAsB;AAAA,MAC3B,GAAG,OAAO;AAAA,MACV;AAAA,IAAA;AAAA,EAEJ;AAGA,QAAM,kBAAkB,MAAM,uBAAuB;AACrD,MAAI,mBAAmB,kBAAkB,GAAG;AAC1C,WAAO,0BAA0B;AAAA,MAC/B,GAAG,OAAO;AAAA,MACV;AAAA,IAAA;AAAA,EAEJ;AAEA,SAAO;AACT;AAWO,SAAS,iBACd,OACwB;AACxB,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,SAAS,eAAe;AAAA,IAC5B,cAAc,MAAM,gBAAgB;AAAA,IACpC,kBAAkB,MAAM,iBAAiB;AAAA,IACzC,aAAa,MAAM,gBAAgB;AAAA,EAAA,CACpC;AAKD,QAAM,eAAe,MAAM;AAG3B,QAAM,sBAAsB;AAAA,IAC1B,GAAI,cAAc,cACd,EAAE,YAAY,aAAa,YAAA,IAC3B,CAAA;AAAA,IACJ,GAAI,cAAc,eACd,EAAE,aAAa,aAAa,aAAA,IAC5B,CAAA;AAAA,EAAC;AAEP,MAAI,OAAO,KAAK,mBAAmB,EAAE,SAAS,GAAG;AAC/C,WAAO,sBAAsB;AAAA,EAC/B;AAEA,SAAO;AACT;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/openai-base",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Shared OpenAI SDK base adapters for TanStack AI providers using Chat Completions and Responses APIs.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -39,13 +39,13 @@
39
39
  "@tanstack/ai-utils": "0.2.1"
40
40
  },
41
41
  "peerDependencies": {
42
- "@tanstack/ai": "^0.23.0"
42
+ "@tanstack/ai": "^0.25.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@vitest/coverage-v8": "4.0.14",
46
46
  "vite": "^7.3.3",
47
47
  "zod": "^4.2.0",
48
- "@tanstack/ai": "0.23.0"
48
+ "@tanstack/ai": "0.25.0"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "vite build",
@@ -1,9 +1,13 @@
1
1
  import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
2
2
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
3
- import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
3
+ import {
4
+ toRunErrorPayload,
5
+ toRunErrorRawEvent,
6
+ } from '@tanstack/ai/adapter-internals'
4
7
  import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
5
8
  import { extractRequestOptions } from '../utils/request-options'
6
9
  import { makeStructuredOutputCompatible } from '../utils/schema-converter'
10
+ import { buildChatCompletionsUsage } from '../usage'
7
11
  import { convertToolsToChatCompletionsFormat } from './chat-completions-tool-converter'
8
12
  import type OpenAI from 'openai'
9
13
  import type {
@@ -96,6 +100,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
96
100
  error,
97
101
  `${this.name}.chatStream failed`,
98
102
  )
103
+ const rawEvent = toRunErrorRawEvent(error)
99
104
 
100
105
  // Emit RUN_STARTED if not yet emitted
101
106
  if (!aguiState.hasEmittedRunStarted) {
@@ -120,6 +125,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
120
125
  timestamp: Date.now(),
121
126
  message: errorPayload.message,
122
127
  code: errorPayload.code,
128
+ // Forward the provider's structured error body so consumers can recover
129
+ // the upstream detail the `{ message, code }` payload drops. Omitted
130
+ // when the error carried no provider body (see toRunErrorRawEvent).
131
+ ...(rawEvent !== undefined && { rawEvent }),
123
132
  error: {
124
133
  message: errorPayload.message,
125
134
  code: errorPayload.code,
@@ -498,11 +507,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
498
507
  timestamp,
499
508
  finishReason: 'stop',
500
509
  ...(lastUsage && {
501
- usage: {
502
- promptTokens: lastUsage.prompt_tokens,
503
- completionTokens: lastUsage.completion_tokens,
504
- totalTokens: lastUsage.total_tokens,
505
- },
510
+ usage: buildChatCompletionsUsage(lastUsage),
506
511
  }),
507
512
  }
508
513
  } catch (error: unknown) {
@@ -528,6 +533,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
528
533
  // `exactOptionalPropertyTypes`: AG-UI's `RunErrorEvent.code` is `string?`
529
534
  // (absent vs explicit `undefined` matter).
530
535
  const resolvedCode = isAbort ? 'aborted' : errorPayload.code
536
+ const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error)
531
537
  yield {
532
538
  type: EventType.RUN_ERROR,
533
539
  runId: aguiState.runId,
@@ -535,6 +541,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
535
541
  timestamp,
536
542
  message: errorPayload.message,
537
543
  ...(resolvedCode !== undefined && { code: resolvedCode }),
544
+ ...(rawEvent !== undefined && { rawEvent }),
538
545
  error: {
539
546
  message: errorPayload.message,
540
547
  ...(resolvedCode !== undefined && { code: resolvedCode }),
@@ -1053,11 +1060,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1053
1060
  model: lastModel || options.model,
1054
1061
  timestamp: Date.now(),
1055
1062
  ...(lastUsage && {
1056
- usage: {
1057
- promptTokens: lastUsage.prompt_tokens || 0,
1058
- completionTokens: lastUsage.completion_tokens || 0,
1059
- totalTokens: lastUsage.total_tokens || 0,
1060
- },
1063
+ usage: buildChatCompletionsUsage(lastUsage),
1061
1064
  }),
1062
1065
  finishReason,
1063
1066
  }
@@ -1069,19 +1072,22 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1069
1072
  error,
1070
1073
  `${this.name}.processStreamChunks failed`,
1071
1074
  )
1075
+ const rawEvent = toRunErrorRawEvent(error)
1072
1076
  options.logger.errors(`${this.name}.processStreamChunks fatal`, {
1073
1077
  error: errorPayload,
1074
1078
  source: `${this.name}.processStreamChunks`,
1075
1079
  })
1076
1080
 
1077
1081
  // Emit AG-UI RUN_ERROR with conditional `code` spread (see chatStream's
1078
- // catch block for the rationale).
1082
+ // catch block for the rationale). `rawEvent` carries the provider's
1083
+ // structured error body when present.
1079
1084
  yield {
1080
1085
  type: EventType.RUN_ERROR,
1081
1086
  model: options.model,
1082
1087
  timestamp: Date.now(),
1083
1088
  message: errorPayload.message,
1084
1089
  ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1090
+ ...(rawEvent !== undefined && { rawEvent }),
1085
1091
  error: {
1086
1092
  message: errorPayload.message,
1087
1093
  ...(errorPayload.code !== undefined && { code: errorPayload.code }),
@@ -1196,6 +1202,12 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1196
1202
  protected convertMessage(message: ModelMessage): ChatCompletionMessageParam {
1197
1203
  // Handle tool messages
1198
1204
  if (message.role === 'tool') {
1205
+ // The Chat Completions API has no multimodal `tool` message support
1206
+ // (unlike the Responses API's `function_call_output`). A tool that
1207
+ // returns an `Array<ContentPart>` is therefore stringified here — the
1208
+ // documented fallback for providers on the chat-completions path
1209
+ // (Groq, Ollama, Grok, OpenRouter chat). Multimodal tool results are
1210
+ // only delivered structurally via the Responses adapter.
1199
1211
  return {
1200
1212
  role: 'tool',
1201
1213
  tool_call_id: message.toolCallId || '',
@@ -1,9 +1,13 @@
1
1
  import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
2
2
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
3
- import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
3
+ import {
4
+ toRunErrorPayload,
5
+ toRunErrorRawEvent,
6
+ } from '@tanstack/ai/adapter-internals'
4
7
  import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
5
8
  import { extractRequestOptions } from '../utils/request-options'
6
9
  import { makeStructuredOutputCompatible } from '../utils/schema-converter'
10
+ import { buildResponsesUsage } from '../usage'
7
11
  import { convertToolsToResponsesFormat } from './responses-tool-converter'
8
12
  import type OpenAI from 'openai'
9
13
  import type {
@@ -13,6 +17,7 @@ import type {
13
17
  import type {
14
18
  Response,
15
19
  ResponseCreateParams,
20
+ ResponseFunctionCallOutputItem,
16
21
  ResponseInput,
17
22
  ResponseInputContent,
18
23
  ResponseStreamEvent,
@@ -119,6 +124,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
119
124
  error,
120
125
  `${this.name}.chatStream failed`,
121
126
  )
127
+ const rawEvent = toRunErrorRawEvent(error)
122
128
 
123
129
  // Emit RUN_STARTED if not yet emitted
124
130
  if (!aguiState.hasEmittedRunStarted) {
@@ -143,6 +149,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
143
149
  timestamp: Date.now(),
144
150
  message: errorPayload.message,
145
151
  code: errorPayload.code,
152
+ // Forward the provider's structured error body when present (see
153
+ // toRunErrorRawEvent); omitted otherwise.
154
+ ...(rawEvent !== undefined && { rawEvent }),
146
155
  error: {
147
156
  message: errorPayload.message,
148
157
  code: errorPayload.code,
@@ -590,11 +599,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
590
599
  timestamp,
591
600
  finishReason: 'stop',
592
601
  ...(usage && {
593
- usage: {
594
- promptTokens: usage.input_tokens,
595
- completionTokens: usage.output_tokens,
596
- totalTokens: usage.total_tokens,
597
- },
602
+ usage: buildResponsesUsage(usage),
598
603
  }),
599
604
  }
600
605
  } catch (error: unknown) {
@@ -619,6 +624,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
619
624
  // Conditional `code` spread keeps the wire shape spec-compliant under
620
625
  // `exactOptionalPropertyTypes` (see chatStream catch).
621
626
  const resolvedCode = isAbort ? 'aborted' : errorPayload.code
627
+ const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error)
622
628
  yield {
623
629
  type: EventType.RUN_ERROR,
624
630
  runId: aguiState.runId,
@@ -626,6 +632,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
626
632
  timestamp,
627
633
  message: errorPayload.message,
628
634
  ...(resolvedCode !== undefined && { code: resolvedCode }),
635
+ ...(rawEvent !== undefined && { rawEvent }),
629
636
  error: {
630
637
  message: errorPayload.message,
631
638
  ...(resolvedCode !== undefined && { code: resolvedCode }),
@@ -1501,11 +1508,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1501
1508
  threadId: aguiState.threadId,
1502
1509
  model: model || options.model,
1503
1510
  timestamp: Date.now(),
1504
- usage: {
1505
- promptTokens: chunk.response.usage?.input_tokens || 0,
1506
- completionTokens: chunk.response.usage?.output_tokens || 0,
1507
- totalTokens: chunk.response.usage?.total_tokens || 0,
1508
- },
1511
+ // Omit usage entirely when the provider reported none rather than
1512
+ // emitting fabricated zeros (also satisfies exactOptionalPropertyTypes).
1513
+ ...(chunk.response.usage && {
1514
+ usage: buildResponsesUsage(chunk.response.usage),
1515
+ }),
1509
1516
  finishReason,
1510
1517
  }
1511
1518
  runFinishedEmitted = true
@@ -1570,18 +1577,21 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1570
1577
  error,
1571
1578
  `${this.name}.processStreamChunks failed`,
1572
1579
  )
1580
+ const rawEvent = toRunErrorRawEvent(error)
1573
1581
  options.logger.errors(`${this.name}.processStreamChunks fatal`, {
1574
1582
  error: errorPayload,
1575
1583
  source: `${this.name}.processStreamChunks`,
1576
1584
  })
1577
1585
  // Emit AG-UI RUN_ERROR with conditional `code` spread (see chatStream
1578
- // catch for the rationale).
1586
+ // catch for the rationale). `rawEvent` carries the provider's structured
1587
+ // error body when present.
1579
1588
  yield {
1580
1589
  type: EventType.RUN_ERROR,
1581
1590
  model: options.model,
1582
1591
  timestamp: Date.now(),
1583
1592
  message: errorPayload.message,
1584
1593
  ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1594
+ ...(rawEvent !== undefined && { rawEvent }),
1585
1595
  error: {
1586
1596
  message: errorPayload.message,
1587
1597
  ...(errorPayload.code !== undefined && { code: errorPayload.code }),
@@ -1693,13 +1703,17 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1693
1703
  for (const message of messages) {
1694
1704
  // Handle tool messages - convert to FunctionToolCallOutput
1695
1705
  if (message.role === 'tool') {
1706
+ const toolContent = message.content
1707
+ const output: string | Array<ResponseFunctionCallOutputItem> =
1708
+ Array.isArray(toolContent)
1709
+ ? toolContent.map((part) => this.convertContentPartToInput(part))
1710
+ : typeof toolContent === 'string'
1711
+ ? toolContent
1712
+ : JSON.stringify(toolContent)
1696
1713
  result.push({
1697
1714
  type: 'function_call_output',
1698
1715
  call_id: message.toolCallId || '',
1699
- output:
1700
- typeof message.content === 'string'
1701
- ? message.content
1702
- : JSON.stringify(message.content),
1716
+ output,
1703
1717
  })
1704
1718
  continue
1705
1719
  }
package/src/index.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  export { makeStructuredOutputCompatible } from './utils/schema-converter'
2
+ export {
3
+ buildChatCompletionsUsage,
4
+ buildResponsesUsage,
5
+ buildImagesUsage,
6
+ } from './usage'
2
7
  export * from './tools/index'
3
8
  export { OpenAIBaseChatCompletionsTextAdapter } from './adapters/chat-completions-text'
4
9
  export {
package/src/usage.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { buildBaseUsage } from '@tanstack/ai'
2
+ import type { TokenUsage } from '@tanstack/ai'
3
+ import type OpenAI from 'openai'
4
+
5
+ /**
6
+ * Build normalized {@link TokenUsage} from an OpenAI-compatible Chat
7
+ * Completions `usage` object.
8
+ *
9
+ * Shared by every provider that routes through
10
+ * {@link OpenAIBaseChatCompletionsTextAdapter} (OpenAI Chat Completions, Grok,
11
+ * Groq). Surfaces cached prompt tokens and reasoning/audio detail tokens when
12
+ * the provider reports them. Returns `undefined` when the provider reported no
13
+ * usage object, so callers omit the field rather than fabricating zeroed totals.
14
+ */
15
+ export function buildChatCompletionsUsage(
16
+ usage: OpenAI.Chat.Completions.ChatCompletion['usage'] | undefined | null,
17
+ ): TokenUsage | undefined {
18
+ if (!usage) return undefined
19
+
20
+ const result = buildBaseUsage({
21
+ promptTokens: usage.prompt_tokens || 0,
22
+ completionTokens: usage.completion_tokens || 0,
23
+ totalTokens: usage.total_tokens || 0,
24
+ })
25
+
26
+ const completionDetails = usage.completion_tokens_details
27
+ const completionTokensDetails = {
28
+ ...(completionDetails?.reasoning_tokens
29
+ ? { reasoningTokens: completionDetails.reasoning_tokens }
30
+ : {}),
31
+ ...(completionDetails?.audio_tokens
32
+ ? { audioTokens: completionDetails.audio_tokens }
33
+ : {}),
34
+ }
35
+
36
+ const promptDetails = usage.prompt_tokens_details
37
+ const promptTokensDetails = {
38
+ ...(promptDetails?.cached_tokens
39
+ ? { cachedTokens: promptDetails.cached_tokens }
40
+ : {}),
41
+ ...(promptDetails?.audio_tokens
42
+ ? { audioTokens: promptDetails.audio_tokens }
43
+ : {}),
44
+ }
45
+
46
+ if (Object.keys(completionTokensDetails).length > 0) {
47
+ result.completionTokensDetails = completionTokensDetails
48
+ }
49
+ if (Object.keys(promptTokensDetails).length > 0) {
50
+ result.promptTokensDetails = promptTokensDetails
51
+ }
52
+
53
+ // Predicted Outputs accepted/rejected counts have no canonical TokenUsage
54
+ // slot but are still billed (rejected tokens included), so surface them under
55
+ // providerUsageDetails — matching how the OpenRouter adapter exposes them.
56
+ const providerUsageDetails = {
57
+ ...(completionDetails?.accepted_prediction_tokens
58
+ ? {
59
+ acceptedPredictionTokens:
60
+ completionDetails.accepted_prediction_tokens,
61
+ }
62
+ : {}),
63
+ ...(completionDetails?.rejected_prediction_tokens
64
+ ? {
65
+ rejectedPredictionTokens:
66
+ completionDetails.rejected_prediction_tokens,
67
+ }
68
+ : {}),
69
+ }
70
+ if (Object.keys(providerUsageDetails).length > 0) {
71
+ result.providerUsageDetails = providerUsageDetails
72
+ }
73
+
74
+ return result
75
+ }
76
+
77
+ /**
78
+ * Build normalized {@link TokenUsage} from an OpenAI Responses API
79
+ * `ResponseUsage` object.
80
+ *
81
+ * Shared by every provider that routes through
82
+ * {@link OpenAIBaseResponsesTextAdapter}. Surfaces cached prompt tokens and
83
+ * reasoning detail tokens when present. Returns `undefined` when the provider
84
+ * reported no usage object, so callers omit the field rather than fabricating
85
+ * zeroed totals.
86
+ */
87
+ export function buildResponsesUsage(
88
+ usage: OpenAI.Responses.ResponseUsage | undefined | null,
89
+ ): TokenUsage | undefined {
90
+ if (!usage) return undefined
91
+
92
+ const result = buildBaseUsage({
93
+ promptTokens: usage.input_tokens || 0,
94
+ completionTokens: usage.output_tokens || 0,
95
+ totalTokens: usage.total_tokens || 0,
96
+ })
97
+
98
+ // Despite the SDK types marking these required, they can be undefined at runtime.
99
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
100
+ const cachedTokens = usage.input_tokens_details?.cached_tokens
101
+ if (cachedTokens && cachedTokens > 0) {
102
+ result.promptTokensDetails = {
103
+ ...result.promptTokensDetails,
104
+ cachedTokens,
105
+ }
106
+ }
107
+
108
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
109
+ const reasoningTokens = usage.output_tokens_details?.reasoning_tokens
110
+ if (reasoningTokens && reasoningTokens > 0) {
111
+ result.completionTokensDetails = {
112
+ ...result.completionTokensDetails,
113
+ reasoningTokens,
114
+ }
115
+ }
116
+
117
+ return result
118
+ }
119
+
120
+ /**
121
+ * Build normalized {@link TokenUsage} from an OpenAI Images API `usage` object.
122
+ *
123
+ * Shared by every provider that generates images through the OpenAI Images SDK
124
+ * (OpenAI, Grok). Token-billed image models (e.g. gpt-image-1) report an input
125
+ * breakdown of text vs image tokens, which is surfaced on `promptTokensDetails`.
126
+ * Models that don't return usage (e.g. DALL·E) yield `undefined` so callers can
127
+ * omit the field rather than emit zeroed totals.
128
+ */
129
+ export function buildImagesUsage(
130
+ usage: OpenAI.Images.ImagesResponse['usage'] | undefined | null,
131
+ ): TokenUsage | undefined {
132
+ if (!usage) return undefined
133
+
134
+ const result = buildBaseUsage({
135
+ promptTokens: usage.input_tokens || 0,
136
+ completionTokens: usage.output_tokens || 0,
137
+ totalTokens: usage.total_tokens || 0,
138
+ })
139
+
140
+ // The SDK types input_tokens_details (and its numeric fields) as required, but
141
+ // real responses — e.g. from DALL·E or other non-token-billed models — can
142
+ // omit them, so treat the breakdown as optional.
143
+ const inputDetails = usage.input_tokens_details as
144
+ | { text_tokens?: number; image_tokens?: number }
145
+ | undefined
146
+ const promptTokensDetails = {
147
+ ...(inputDetails?.text_tokens
148
+ ? { textTokens: inputDetails.text_tokens }
149
+ : {}),
150
+ ...(inputDetails?.image_tokens
151
+ ? { imageTokens: inputDetails.image_tokens }
152
+ : {}),
153
+ }
154
+ if (Object.keys(promptTokensDetails).length > 0) {
155
+ result.promptTokensDetails = promptTokensDetails
156
+ }
157
+
158
+ return result
159
+ }