@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.
- package/dist/esm/adapters/chat-completions-text.js +13 -11
- package/dist/esm/adapters/chat-completions-text.js.map +1 -1
- package/dist/esm/adapters/responses-text.js +18 -11
- package/dist/esm/adapters/responses-text.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/usage.d.ts +34 -0
- package/dist/esm/usage.js +83 -0
- package/dist/esm/usage.js.map +1 -0
- package/package.json +3 -3
- package/src/adapters/chat-completions-text.ts +24 -12
- package/src/adapters/responses-text.ts +30 -16
- package/src/index.ts +5 -0
- package/src/usage.ts +159 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
+
}
|