@tanstack/openai-base 0.10.15 → 0.11.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.js +23 -2
- package/dist/esm/adapters/chat-completions-text.js.map +1 -1
- package/dist/esm/adapters/responses-text.d.ts +6 -1
- package/dist/esm/adapters/responses-text.js +110 -5
- package/dist/esm/adapters/responses-text.js.map +1 -1
- package/dist/esm/adapters/responses-user-tools.d.ts +42 -0
- package/dist/esm/adapters/responses-user-tools.js +241 -0
- package/dist/esm/adapters/responses-user-tools.js.map +1 -0
- package/package.json +3 -3
- package/src/adapters/chat-completions-text.ts +53 -2
- package/src/adapters/responses-text.ts +197 -11
- package/src/adapters/responses-user-tools.ts +355 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
EventType,
|
|
3
|
+
fileReferenceFor,
|
|
4
|
+
isFileSource,
|
|
5
|
+
normalizeSystemPrompts,
|
|
6
|
+
unsupportedFileSourceError,
|
|
7
|
+
} from '@tanstack/ai'
|
|
2
8
|
import { BaseTextAdapter } from '@tanstack/ai/adapters'
|
|
3
9
|
import {
|
|
4
10
|
toRunErrorPayload,
|
|
@@ -11,6 +17,13 @@ import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
|
|
|
11
17
|
import type { StructuredOutputCompatibility } from '../utils/schema-converter'
|
|
12
18
|
import { buildResponsesUsage } from '../usage'
|
|
13
19
|
import { convertToolsToResponsesFormat } from './responses-tool-converter'
|
|
20
|
+
import {
|
|
21
|
+
hostedShellCallIds,
|
|
22
|
+
readUserExecutedCall,
|
|
23
|
+
userToolRequestItem,
|
|
24
|
+
userToolResultItem,
|
|
25
|
+
} from './responses-user-tools'
|
|
26
|
+
import type { OpenAIUserToolName } from './responses-user-tools'
|
|
14
27
|
import type OpenAI from 'openai'
|
|
15
28
|
import type {
|
|
16
29
|
StructuredOutputOptions,
|
|
@@ -98,7 +111,11 @@ function readReasoningItem(
|
|
|
98
111
|
* item ID here so stateless follow-up requests can replay both values.
|
|
99
112
|
*/
|
|
100
113
|
export interface OpenAIResponsesToolCallMetadata {
|
|
101
|
-
itemId
|
|
114
|
+
itemId?: string
|
|
115
|
+
/** Set for shell, local_shell, and apply_patch calls the app must run. */
|
|
116
|
+
openaiUserTool?: OpenAIUserToolName
|
|
117
|
+
/** Shell `action.max_output_length`, echoed on `shell_call_output`. */
|
|
118
|
+
maxOutputLength?: number | null
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
interface StreamedFunctionCallMetadata {
|
|
@@ -979,6 +996,56 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
979
996
|
accumulatedReasoning = ''
|
|
980
997
|
}
|
|
981
998
|
|
|
999
|
+
const userToolChunks = (
|
|
1000
|
+
item: unknown,
|
|
1001
|
+
outputIndex: number,
|
|
1002
|
+
bareShell: boolean,
|
|
1003
|
+
): Array<AdapterYieldChunk> => {
|
|
1004
|
+
const call = readUserExecutedCall(item, { bareShell })
|
|
1005
|
+
if (!call || toolCallMetadata.get(call.callId)?.ended) return []
|
|
1006
|
+
const tracked = toolCallMetadata.get(call.callId) ?? {
|
|
1007
|
+
callId: call.callId,
|
|
1008
|
+
index: outputIndex,
|
|
1009
|
+
name: call.name,
|
|
1010
|
+
started: false,
|
|
1011
|
+
}
|
|
1012
|
+
toolCallMetadata.set(call.callId, tracked)
|
|
1013
|
+
tracked.started = true
|
|
1014
|
+
tracked.ended = true
|
|
1015
|
+
tracked.name = call.name
|
|
1016
|
+
const timestamp = Date.now()
|
|
1017
|
+
const modelName = model || options.model
|
|
1018
|
+
const metadata: OpenAIResponsesToolCallMetadata = {
|
|
1019
|
+
openaiUserTool: call.name,
|
|
1020
|
+
...(call.itemId ? { itemId: call.itemId } : {}),
|
|
1021
|
+
...(call.maxOutputLength !== undefined
|
|
1022
|
+
? { maxOutputLength: call.maxOutputLength }
|
|
1023
|
+
: {}),
|
|
1024
|
+
}
|
|
1025
|
+
return [
|
|
1026
|
+
{
|
|
1027
|
+
type: EventType.TOOL_CALL_START,
|
|
1028
|
+
toolCallId: call.callId,
|
|
1029
|
+
toolCallName: call.name,
|
|
1030
|
+
toolName: call.name,
|
|
1031
|
+
parentMessageId: aguiState.messageId,
|
|
1032
|
+
model: modelName,
|
|
1033
|
+
timestamp,
|
|
1034
|
+
index: outputIndex,
|
|
1035
|
+
metadata,
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
type: EventType.TOOL_CALL_END,
|
|
1039
|
+
toolCallId: call.callId,
|
|
1040
|
+
toolCallName: call.name,
|
|
1041
|
+
toolName: call.name,
|
|
1042
|
+
model: modelName,
|
|
1043
|
+
timestamp,
|
|
1044
|
+
input: call.input,
|
|
1045
|
+
},
|
|
1046
|
+
]
|
|
1047
|
+
}
|
|
1048
|
+
|
|
982
1049
|
const emitReasoningDelta = function* (
|
|
983
1050
|
text: string,
|
|
984
1051
|
): Generator<AdapterYieldChunk> {
|
|
@@ -1341,6 +1408,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1341
1408
|
metadata.started = true
|
|
1342
1409
|
}
|
|
1343
1410
|
}
|
|
1411
|
+
yield* userToolChunks(item, chunk.output_index, false)
|
|
1344
1412
|
}
|
|
1345
1413
|
|
|
1346
1414
|
// Handle function call arguments delta (streaming). Drop the
|
|
@@ -1545,6 +1613,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1545
1613
|
metadata.pendingArguments = undefined
|
|
1546
1614
|
}
|
|
1547
1615
|
}
|
|
1616
|
+
yield* userToolChunks(item, chunk.output_index, false)
|
|
1548
1617
|
}
|
|
1549
1618
|
|
|
1550
1619
|
if (chunk.type === 'response.completed') {
|
|
@@ -1621,11 +1690,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1621
1690
|
// be silently dropped from the AG-UI stream while `hasFunctionCalls`
|
|
1622
1691
|
// below still routes the run's finishReason to 'tool_calls' —
|
|
1623
1692
|
// leaving consumers waiting for tool results they never saw start.
|
|
1624
|
-
for (const item of chunk.response.output) {
|
|
1693
|
+
for (const [outputIndex, item] of chunk.response.output.entries()) {
|
|
1625
1694
|
if (item.type !== 'function_call' || !item.id) continue
|
|
1626
1695
|
const metadata = toolCallMetadata.get(item.id) ?? {
|
|
1627
1696
|
callId: item.call_id || item.id,
|
|
1628
|
-
index:
|
|
1697
|
+
index: outputIndex,
|
|
1629
1698
|
name: item.name || '',
|
|
1630
1699
|
started: false,
|
|
1631
1700
|
}
|
|
@@ -1697,6 +1766,19 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1697
1766
|
}
|
|
1698
1767
|
}
|
|
1699
1768
|
|
|
1769
|
+
const shellOutputs = hostedShellCallIds(chunk.response.output)
|
|
1770
|
+
for (const [outputIndex, item] of chunk.response.output.entries()) {
|
|
1771
|
+
if (
|
|
1772
|
+
isRecord(item) &&
|
|
1773
|
+
item.type === 'shell_call' &&
|
|
1774
|
+
typeof item.call_id === 'string' &&
|
|
1775
|
+
shellOutputs.has(item.call_id)
|
|
1776
|
+
) {
|
|
1777
|
+
continue
|
|
1778
|
+
}
|
|
1779
|
+
yield* userToolChunks(item, outputIndex, true)
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1700
1782
|
yield* closeReasoning()
|
|
1701
1783
|
// Emit TEXT_MESSAGE_END if we had text content
|
|
1702
1784
|
if (hasEmittedTextMessageStart) {
|
|
@@ -1716,10 +1798,18 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1716
1798
|
// The Responses API's incomplete_details.reason ('max_output_tokens'
|
|
1717
1799
|
// | 'content_filter') maps to the AG-UI finishReason vocabulary:
|
|
1718
1800
|
// max_output_tokens → 'length', content_filter → 'content_filter'.
|
|
1719
|
-
const hasFunctionCalls = chunk.response.output.some(
|
|
1720
|
-
(item
|
|
1721
|
-
|
|
1722
|
-
|
|
1801
|
+
const hasFunctionCalls = chunk.response.output.some((item) => {
|
|
1802
|
+
if (!isRecord(item)) return false
|
|
1803
|
+
if (item.type === 'function_call') return true
|
|
1804
|
+
if (
|
|
1805
|
+
item.type === 'shell_call' &&
|
|
1806
|
+
typeof item.call_id === 'string' &&
|
|
1807
|
+
shellOutputs.has(item.call_id)
|
|
1808
|
+
) {
|
|
1809
|
+
return false
|
|
1810
|
+
}
|
|
1811
|
+
return readUserExecutedCall(item, { bareShell: true }) !== null
|
|
1812
|
+
})
|
|
1723
1813
|
const incompleteReason = chunk.response.incomplete_details?.reason
|
|
1724
1814
|
const finishReason:
|
|
1725
1815
|
| 'tool_calls'
|
|
@@ -1923,10 +2013,33 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1923
2013
|
messages: Array<ModelMessage>,
|
|
1924
2014
|
): ResponseInput {
|
|
1925
2015
|
const result: ResponseInput = []
|
|
2016
|
+
// A reasoning item id may only appear once in `input`; replaying the same
|
|
2017
|
+
// id twice fails with "Duplicate item found with id rs_...". Providers have
|
|
2018
|
+
// been observed minting one id for two separate reasoning items, and a
|
|
2019
|
+
// stored transcript can end up carrying it on two assistant messages.
|
|
2020
|
+
const seenReasoningIds = new Set<string>()
|
|
2021
|
+
const userToolCalls = new Map<
|
|
2022
|
+
string,
|
|
2023
|
+
{
|
|
2024
|
+
id: string
|
|
2025
|
+
function: { name: string; arguments: string }
|
|
2026
|
+
metadata?: unknown
|
|
2027
|
+
}
|
|
2028
|
+
>()
|
|
1926
2029
|
|
|
1927
2030
|
for (const message of messages) {
|
|
1928
2031
|
// Handle tool messages - convert to FunctionToolCallOutput
|
|
1929
2032
|
if (message.role === 'tool') {
|
|
2033
|
+
const owner = message.toolCallId
|
|
2034
|
+
? userToolCalls.get(message.toolCallId)
|
|
2035
|
+
: undefined
|
|
2036
|
+
const userOutput = owner
|
|
2037
|
+
? userToolResultItem(owner, message.content)
|
|
2038
|
+
: null
|
|
2039
|
+
if (userOutput) {
|
|
2040
|
+
result.push(userOutput)
|
|
2041
|
+
continue
|
|
2042
|
+
}
|
|
1930
2043
|
const toolContent = message.content
|
|
1931
2044
|
const output: string | Array<ResponseFunctionCallOutputItem> =
|
|
1932
2045
|
Array.isArray(toolContent)
|
|
@@ -1944,11 +2057,21 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1944
2057
|
|
|
1945
2058
|
// Handle assistant messages
|
|
1946
2059
|
if (message.role === 'assistant') {
|
|
2060
|
+
// Every reasoning item this message could contribute, and how many of
|
|
2061
|
+
// them actually made it into `input` after de-duplication. Both counts
|
|
2062
|
+
// are needed below to decide whether the function calls can still be
|
|
2063
|
+
// paired with their reasoning.
|
|
2064
|
+
let reasoningCandidates = 0
|
|
2065
|
+
let emittedReasoning = 0
|
|
1947
2066
|
if (message.thinking) {
|
|
1948
2067
|
for (const thinking of message.thinking) {
|
|
1949
2068
|
if (!thinking.signature) continue
|
|
1950
2069
|
const packed = unpackResponsesReasoningSignature(thinking.signature)
|
|
1951
2070
|
if (!packed?.id) continue
|
|
2071
|
+
reasoningCandidates++
|
|
2072
|
+
if (seenReasoningIds.has(packed.id)) continue
|
|
2073
|
+
seenReasoningIds.add(packed.id)
|
|
2074
|
+
emittedReasoning++
|
|
1952
2075
|
result.push({
|
|
1953
2076
|
type: 'reasoning',
|
|
1954
2077
|
id: packed.id,
|
|
@@ -1962,15 +2085,50 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1962
2085
|
}
|
|
1963
2086
|
}
|
|
1964
2087
|
|
|
2088
|
+
// The Responses API requires a persisted `function_call` item to sit
|
|
2089
|
+
// directly after the reasoning item that produced it. A ModelMessage
|
|
2090
|
+
// stores reasoning and tool calls as two flat arrays, so their original
|
|
2091
|
+
// interleaving is gone: a message holding two or more reasoning items
|
|
2092
|
+
// replays as reasoning A, reasoning B, call A, call B, and the request
|
|
2093
|
+
// is rejected with "Item 'fc_...' of type 'function_call' was provided
|
|
2094
|
+
// without its required 'reasoning' item: 'rs_...'". The same happens
|
|
2095
|
+
// when this message's reasoning was dropped above as a duplicate.
|
|
2096
|
+
//
|
|
2097
|
+
// Sending the calls without their item id makes them fresh items that
|
|
2098
|
+
// the API does not try to pair, which replays correctly. `call_id` is
|
|
2099
|
+
// untouched, so the matching `function_call_output` still resolves.
|
|
2100
|
+
// One reasoning item (or none at all) always lands adjacent to its
|
|
2101
|
+
// calls, so those keep their ids and their prompt-cache hits. If any
|
|
2102
|
+
// reasoning was dropped as a duplicate, a call may belong to it, so
|
|
2103
|
+
// the ids go too.
|
|
2104
|
+
const canPairReasoning =
|
|
2105
|
+
reasoningCandidates === 0 ||
|
|
2106
|
+
(reasoningCandidates === 1 && emittedReasoning === 1)
|
|
2107
|
+
|
|
1965
2108
|
// If the assistant message has tool calls, add them as FunctionToolCall objects
|
|
1966
2109
|
// Responses API expects arguments as a string (JSON string)
|
|
1967
2110
|
if (message.toolCalls && message.toolCalls.length > 0) {
|
|
1968
2111
|
for (const toolCall of message.toolCalls) {
|
|
1969
|
-
// Keep arguments as string for Responses API
|
|
1970
2112
|
const argumentsString =
|
|
1971
2113
|
typeof toolCall.function.arguments === 'string'
|
|
1972
2114
|
? toolCall.function.arguments
|
|
1973
2115
|
: JSON.stringify(toolCall.function.arguments)
|
|
2116
|
+
const replayCall = {
|
|
2117
|
+
id: toolCall.id,
|
|
2118
|
+
function: {
|
|
2119
|
+
name: toolCall.function.name,
|
|
2120
|
+
arguments: argumentsString,
|
|
2121
|
+
},
|
|
2122
|
+
...(toolCall.metadata !== undefined
|
|
2123
|
+
? { metadata: toolCall.metadata }
|
|
2124
|
+
: {}),
|
|
2125
|
+
}
|
|
2126
|
+
const userItem = userToolRequestItem(replayCall)
|
|
2127
|
+
if (userItem) {
|
|
2128
|
+
userToolCalls.set(toolCall.id, replayCall)
|
|
2129
|
+
result.push(userItem)
|
|
2130
|
+
continue
|
|
2131
|
+
}
|
|
1974
2132
|
const itemId = (
|
|
1975
2133
|
toolCall.metadata as OpenAIResponsesToolCallMetadata | undefined
|
|
1976
2134
|
)?.itemId
|
|
@@ -1978,7 +2136,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1978
2136
|
result.push({
|
|
1979
2137
|
type: 'function_call',
|
|
1980
2138
|
call_id: toolCall.id,
|
|
1981
|
-
...(itemId && { id: itemId }),
|
|
2139
|
+
...(itemId && canPairReasoning && { id: itemId }),
|
|
1982
2140
|
name: toolCall.function.name,
|
|
1983
2141
|
arguments: argumentsString,
|
|
1984
2142
|
})
|
|
@@ -2046,6 +2204,16 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
2046
2204
|
const imageMetadata = part.metadata as
|
|
2047
2205
|
| { detail?: 'auto' | 'low' | 'high' }
|
|
2048
2206
|
| undefined
|
|
2207
|
+
if (isFileSource(part.source)) {
|
|
2208
|
+
if (this.supportsFileSources !== true) {
|
|
2209
|
+
throw unsupportedFileSourceError(this.name)
|
|
2210
|
+
}
|
|
2211
|
+
return {
|
|
2212
|
+
type: 'input_image',
|
|
2213
|
+
file_id: fileReferenceFor(part.source, this.name),
|
|
2214
|
+
detail: imageMetadata?.detail || 'auto',
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2049
2217
|
if (part.source.type === 'url') {
|
|
2050
2218
|
return {
|
|
2051
2219
|
type: 'input_image',
|
|
@@ -2069,6 +2237,15 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
2069
2237
|
}
|
|
2070
2238
|
}
|
|
2071
2239
|
case 'audio': {
|
|
2240
|
+
if (isFileSource(part.source)) {
|
|
2241
|
+
if (this.supportsFileSources !== true) {
|
|
2242
|
+
throw unsupportedFileSourceError(this.name)
|
|
2243
|
+
}
|
|
2244
|
+
return {
|
|
2245
|
+
type: 'input_file',
|
|
2246
|
+
file_id: fileReferenceFor(part.source, this.name),
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2072
2249
|
if (part.source.type === 'url') {
|
|
2073
2250
|
return {
|
|
2074
2251
|
type: 'input_file',
|
|
@@ -2102,6 +2279,16 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
2102
2279
|
documentMetadata?.detail !== undefined
|
|
2103
2280
|
? { detail: documentMetadata.detail as 'low' | 'high' }
|
|
2104
2281
|
: {}
|
|
2282
|
+
if (isFileSource(part.source)) {
|
|
2283
|
+
if (this.supportsFileSources !== true) {
|
|
2284
|
+
throw unsupportedFileSourceError(this.name)
|
|
2285
|
+
}
|
|
2286
|
+
return {
|
|
2287
|
+
type: 'input_file',
|
|
2288
|
+
file_id: fileReferenceFor(part.source, this.name),
|
|
2289
|
+
...documentDetail,
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2105
2292
|
if (part.source.type === 'url') {
|
|
2106
2293
|
// The Responses API fetches the PDF itself; filename and MIME
|
|
2107
2294
|
// type are inferred from the response.
|
|
@@ -2164,7 +2351,6 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
2164
2351
|
...documentDetail,
|
|
2165
2352
|
}
|
|
2166
2353
|
}
|
|
2167
|
-
|
|
2168
2354
|
case 'video':
|
|
2169
2355
|
default:
|
|
2170
2356
|
// OpenAI Responses API doesn't accept native video parts on this
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { ResponseInputItem } from 'openai/resources/responses/responses'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OpenAI Responses tools the app must run.
|
|
5
|
+
* A container `shell` runs on the provider. `apply_patch`, `local_shell`,
|
|
6
|
+
* and `shell` with `environment.type: "local"` (or no environment) do not.
|
|
7
|
+
*/
|
|
8
|
+
export type OpenAIUserToolName = 'shell' | 'apply_patch' | 'local_shell'
|
|
9
|
+
|
|
10
|
+
export interface OpenAIUserExecutedCall {
|
|
11
|
+
name: OpenAIUserToolName
|
|
12
|
+
callId: string
|
|
13
|
+
itemId?: string
|
|
14
|
+
input: Record<string, unknown>
|
|
15
|
+
maxOutputLength?: number | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ToolCallLike {
|
|
19
|
+
id: string
|
|
20
|
+
function: { name: string; arguments: string }
|
|
21
|
+
metadata?: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stringList(value: unknown): Array<string> | null {
|
|
29
|
+
if (
|
|
30
|
+
!Array.isArray(value) ||
|
|
31
|
+
!value.every((entry) => typeof entry === 'string')
|
|
32
|
+
) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stringEnv(value: unknown): Record<string, string> {
|
|
39
|
+
if (!isRecord(value)) return {}
|
|
40
|
+
const env: Record<string, string> = {}
|
|
41
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
42
|
+
if (typeof entry === 'string') env[key] = entry
|
|
43
|
+
}
|
|
44
|
+
return env
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nullableNumber(value: unknown): number | null {
|
|
48
|
+
return typeof value === 'number' || value === null ? value : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hosted shell calls already include a `shell_call_output` in the same
|
|
53
|
+
* response. Those call ids must not pause the app for another run.
|
|
54
|
+
*/
|
|
55
|
+
export function hostedShellCallIds(
|
|
56
|
+
output: ReadonlyArray<unknown>,
|
|
57
|
+
): Set<string> {
|
|
58
|
+
const ids = new Set<string>()
|
|
59
|
+
for (const item of output) {
|
|
60
|
+
if (
|
|
61
|
+
isRecord(item) &&
|
|
62
|
+
item.type === 'shell_call_output' &&
|
|
63
|
+
typeof item.call_id === 'string'
|
|
64
|
+
) {
|
|
65
|
+
ids.add(item.call_id)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return ids
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readUserToolName(metadata: unknown): OpenAIUserToolName | null {
|
|
72
|
+
if (!isRecord(metadata)) return null
|
|
73
|
+
const name = metadata.openaiUserTool
|
|
74
|
+
if (name === 'shell' || name === 'apply_patch' || name === 'local_shell') {
|
|
75
|
+
return name
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readItemId(metadata: unknown): string | undefined {
|
|
81
|
+
if (!isRecord(metadata)) return undefined
|
|
82
|
+
return typeof metadata.itemId === 'string' && metadata.itemId.length > 0
|
|
83
|
+
? metadata.itemId
|
|
84
|
+
: undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readMaxOutputLength(metadata: unknown): number | null | undefined {
|
|
88
|
+
if (!isRecord(metadata)) return undefined
|
|
89
|
+
const value = metadata.maxOutputLength
|
|
90
|
+
return typeof value === 'number' || value === null ? value : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applyPatchOperation(
|
|
94
|
+
value: unknown,
|
|
95
|
+
):
|
|
96
|
+
| { type: 'create_file'; path: string; diff: string }
|
|
97
|
+
| { type: 'update_file'; path: string; diff: string }
|
|
98
|
+
| { type: 'delete_file'; path: string }
|
|
99
|
+
| null {
|
|
100
|
+
if (!isRecord(value) || typeof value.path !== 'string') return null
|
|
101
|
+
if (value.type === 'delete_file') {
|
|
102
|
+
return { type: 'delete_file', path: value.path }
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
(value.type === 'create_file' || value.type === 'update_file') &&
|
|
106
|
+
typeof value.diff === 'string'
|
|
107
|
+
) {
|
|
108
|
+
return { type: value.type, path: value.path, diff: value.diff }
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read a user-run Responses output item.
|
|
115
|
+
* `bareShell` is true only once the full response is known. A shell call
|
|
116
|
+
* with no environment waits for that pass, so a hosted call that later
|
|
117
|
+
* carries `shell_call_output` is not asked of the app.
|
|
118
|
+
*/
|
|
119
|
+
export function readUserExecutedCall(
|
|
120
|
+
item: unknown,
|
|
121
|
+
options: { bareShell: boolean },
|
|
122
|
+
): OpenAIUserExecutedCall | null {
|
|
123
|
+
if (!isRecord(item) || typeof item.type !== 'string') return null
|
|
124
|
+
const callId = typeof item.call_id === 'string' ? item.call_id : ''
|
|
125
|
+
const itemId = typeof item.id === 'string' ? item.id : undefined
|
|
126
|
+
|
|
127
|
+
if (item.type === 'apply_patch_call') {
|
|
128
|
+
const operation = applyPatchOperation(item.operation)
|
|
129
|
+
if (!callId || !operation) return null
|
|
130
|
+
return {
|
|
131
|
+
name: 'apply_patch',
|
|
132
|
+
callId,
|
|
133
|
+
...(itemId ? { itemId } : {}),
|
|
134
|
+
input: { operation },
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (item.type === 'local_shell_call') {
|
|
139
|
+
if (!isRecord(item.action)) return null
|
|
140
|
+
const command = stringList(item.action.command)
|
|
141
|
+
if (!callId || !command || command.length === 0) return null
|
|
142
|
+
return {
|
|
143
|
+
name: 'local_shell',
|
|
144
|
+
callId,
|
|
145
|
+
...(itemId ? { itemId } : {}),
|
|
146
|
+
input: item.action,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (item.type !== 'shell_call') return null
|
|
151
|
+
const environment = item.environment
|
|
152
|
+
if (isRecord(environment)) {
|
|
153
|
+
if (environment.type !== 'local') return null
|
|
154
|
+
} else if (!options.bareShell) {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
if (!callId || !isRecord(item.action)) return null
|
|
158
|
+
const commands = stringList(item.action.commands)
|
|
159
|
+
if (!commands || commands.length === 0) return null
|
|
160
|
+
const maxOutputLength = nullableNumber(item.action.max_output_length)
|
|
161
|
+
return {
|
|
162
|
+
name: 'shell',
|
|
163
|
+
callId,
|
|
164
|
+
...(itemId ? { itemId } : {}),
|
|
165
|
+
input: {
|
|
166
|
+
commands,
|
|
167
|
+
max_output_length: maxOutputLength,
|
|
168
|
+
timeout_ms: nullableNumber(item.action.timeout_ms),
|
|
169
|
+
},
|
|
170
|
+
maxOutputLength,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseArguments(argumentsString: string): Record<string, unknown> {
|
|
175
|
+
try {
|
|
176
|
+
const parsed: unknown = JSON.parse(argumentsString)
|
|
177
|
+
return isRecord(parsed) ? parsed : {}
|
|
178
|
+
} catch {
|
|
179
|
+
return {}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseContent(content: unknown): unknown {
|
|
184
|
+
if (typeof content !== 'string') return content
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(content) as unknown
|
|
187
|
+
} catch {
|
|
188
|
+
return content
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Replay a user-run tool call as the Responses input item OpenAI expects. */
|
|
193
|
+
export function userToolRequestItem(
|
|
194
|
+
toolCall: ToolCallLike,
|
|
195
|
+
): ResponseInputItem | null {
|
|
196
|
+
const name = readUserToolName(toolCall.metadata)
|
|
197
|
+
if (!name) return null
|
|
198
|
+
const args = parseArguments(toolCall.function.arguments)
|
|
199
|
+
const itemId = readItemId(toolCall.metadata)
|
|
200
|
+
|
|
201
|
+
if (name === 'apply_patch') {
|
|
202
|
+
const operation = applyPatchOperation(args.operation)
|
|
203
|
+
if (!operation) return null
|
|
204
|
+
return {
|
|
205
|
+
type: 'apply_patch_call',
|
|
206
|
+
call_id: toolCall.id,
|
|
207
|
+
status: 'completed',
|
|
208
|
+
operation,
|
|
209
|
+
...(itemId ? { id: itemId } : {}),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (name === 'local_shell') {
|
|
214
|
+
const command = stringList(args.command)
|
|
215
|
+
if (!command) return null
|
|
216
|
+
return {
|
|
217
|
+
type: 'local_shell_call',
|
|
218
|
+
id: itemId ?? toolCall.id,
|
|
219
|
+
call_id: toolCall.id,
|
|
220
|
+
status: 'completed',
|
|
221
|
+
action: {
|
|
222
|
+
type: 'exec',
|
|
223
|
+
command,
|
|
224
|
+
env: stringEnv(args.env),
|
|
225
|
+
timeout_ms: nullableNumber(args.timeout_ms),
|
|
226
|
+
...(typeof args.user === 'string' || args.user === null
|
|
227
|
+
? { user: args.user }
|
|
228
|
+
: {}),
|
|
229
|
+
...(typeof args.working_directory === 'string' ||
|
|
230
|
+
args.working_directory === null
|
|
231
|
+
? { working_directory: args.working_directory }
|
|
232
|
+
: {}),
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const commands = stringList(args.commands)
|
|
238
|
+
if (!commands) return null
|
|
239
|
+
return {
|
|
240
|
+
type: 'shell_call',
|
|
241
|
+
call_id: toolCall.id,
|
|
242
|
+
status: 'completed',
|
|
243
|
+
action: {
|
|
244
|
+
commands,
|
|
245
|
+
max_output_length: nullableNumber(args.max_output_length),
|
|
246
|
+
timeout_ms: nullableNumber(args.timeout_ms),
|
|
247
|
+
},
|
|
248
|
+
...(itemId ? { id: itemId } : {}),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
type ShellOutcome = { type: 'exit'; exit_code: number } | { type: 'timeout' }
|
|
253
|
+
|
|
254
|
+
function shellOutcome(value: unknown): ShellOutcome {
|
|
255
|
+
if (isRecord(value) && value.type === 'timeout') return { type: 'timeout' }
|
|
256
|
+
const exitCode =
|
|
257
|
+
isRecord(value) && typeof value.exit_code === 'number' ? value.exit_code : 0
|
|
258
|
+
return { type: 'exit', exit_code: exitCode }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function shellEntry(value: unknown): {
|
|
262
|
+
stdout: string
|
|
263
|
+
stderr: string
|
|
264
|
+
outcome: ShellOutcome
|
|
265
|
+
} {
|
|
266
|
+
if (typeof value === 'string') {
|
|
267
|
+
return {
|
|
268
|
+
stdout: value,
|
|
269
|
+
stderr: '',
|
|
270
|
+
outcome: { type: 'exit', exit_code: 0 },
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const record = isRecord(value) ? value : {}
|
|
274
|
+
return {
|
|
275
|
+
stdout: typeof record.stdout === 'string' ? record.stdout : '',
|
|
276
|
+
stderr: typeof record.stderr === 'string' ? record.stderr : '',
|
|
277
|
+
outcome: shellOutcome(record.outcome),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function shellOutputList(content: unknown): Array<{
|
|
282
|
+
stdout: string
|
|
283
|
+
stderr: string
|
|
284
|
+
outcome: ShellOutcome
|
|
285
|
+
}> {
|
|
286
|
+
const parsed = parseContent(content)
|
|
287
|
+
if (isRecord(parsed) && Array.isArray(parsed.output)) {
|
|
288
|
+
return parsed.output.map((entry) => shellEntry(entry))
|
|
289
|
+
}
|
|
290
|
+
if (isRecord(parsed) && ('stdout' in parsed || 'outcome' in parsed)) {
|
|
291
|
+
return [shellEntry(parsed)]
|
|
292
|
+
}
|
|
293
|
+
if (typeof parsed === 'string') return [shellEntry(parsed)]
|
|
294
|
+
return [shellEntry(JSON.stringify(parsed ?? ''))]
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Replay the app's tool result as the matching Responses output item. */
|
|
298
|
+
export function userToolResultItem(
|
|
299
|
+
toolCall: ToolCallLike,
|
|
300
|
+
content: unknown,
|
|
301
|
+
): ResponseInputItem | null {
|
|
302
|
+
const name = readUserToolName(toolCall.metadata)
|
|
303
|
+
if (!name) return null
|
|
304
|
+
const parsed = parseContent(content)
|
|
305
|
+
|
|
306
|
+
if (name === 'apply_patch') {
|
|
307
|
+
const record = isRecord(parsed) ? parsed : {}
|
|
308
|
+
const failed =
|
|
309
|
+
record.status === 'failed' ||
|
|
310
|
+
(record.status !== 'completed' && typeof record.error === 'string')
|
|
311
|
+
const output =
|
|
312
|
+
typeof record.output === 'string'
|
|
313
|
+
? record.output
|
|
314
|
+
: typeof record.error === 'string'
|
|
315
|
+
? record.error
|
|
316
|
+
: typeof parsed === 'string'
|
|
317
|
+
? parsed
|
|
318
|
+
: undefined
|
|
319
|
+
return {
|
|
320
|
+
type: 'apply_patch_call_output',
|
|
321
|
+
call_id: toolCall.id,
|
|
322
|
+
status: failed ? 'failed' : 'completed',
|
|
323
|
+
...(output !== undefined ? { output } : {}),
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (name === 'local_shell') {
|
|
328
|
+
const output =
|
|
329
|
+
typeof parsed === 'string'
|
|
330
|
+
? parsed
|
|
331
|
+
: isRecord(parsed) && typeof parsed.output === 'string'
|
|
332
|
+
? parsed.output
|
|
333
|
+
: JSON.stringify(parsed ?? '')
|
|
334
|
+
return {
|
|
335
|
+
type: 'local_shell_call_output',
|
|
336
|
+
id: toolCall.id,
|
|
337
|
+
output,
|
|
338
|
+
status: 'completed',
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const record = isRecord(parsed) ? parsed : {}
|
|
343
|
+
const fromResult = nullableNumber(record.max_output_length)
|
|
344
|
+
const fromCall = readMaxOutputLength(toolCall.metadata)
|
|
345
|
+
const maxOutputLength =
|
|
346
|
+
record.max_output_length !== undefined ? fromResult : fromCall
|
|
347
|
+
return {
|
|
348
|
+
type: 'shell_call_output',
|
|
349
|
+
call_id: toolCall.id,
|
|
350
|
+
output: shellOutputList(content),
|
|
351
|
+
...(maxOutputLength !== undefined
|
|
352
|
+
? { max_output_length: maxOutputLength }
|
|
353
|
+
: {}),
|
|
354
|
+
}
|
|
355
|
+
}
|