dsh-lcx-codex 0.4.1 → 0.4.2

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.
@@ -1,281 +1,68 @@
1
- import { fetchSseWithRetry } from './transport.js'
2
- import { mergeFeatureHeader, responsesGenerationEnvelope } from './compact-v2.js'
3
- import { responsesTools } from './dsh-responses.js'
1
+ import { mergeFeatureHeader } from './compact-v2.js'
2
+ import { resolvePiResponsesModel } from './dsh-responses.js'
3
+ import { buildResponsesBody } from './responses-request.js'
4
+ import { streamResponsesRequest } from './responses-stream.js'
4
5
 
5
- function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
6
- function fail(message, code = 'LCX_RESPONSES_UPSTREAM_ERROR') { const e = new Error(message); e.code = code; return e }
7
-
8
- function usageFrom(raw) {
9
- if (!isObject(raw)) return undefined
10
- const inputTotal = Number(raw.input_tokens ?? 0)
11
- const output = Number(raw.output_tokens ?? 0)
12
- const details = raw.input_tokens_details ?? raw.input_token_details ?? raw.prompt_tokens_details ?? {}
13
- const cacheRead = Number(details.cached_tokens ?? 0)
14
- const cacheWrite = Number(details.cache_write_tokens ?? 0)
15
- const usage = {
16
- inputTokens: Math.max(0, Number.isFinite(inputTotal) ? inputTotal - (Number.isFinite(cacheRead) ? cacheRead : 0) - (Number.isFinite(cacheWrite) ? cacheWrite : 0) : 0),
17
- outputTokens: Number.isFinite(output) && output > 0 ? output : 0,
18
- }
19
- if (Number.isFinite(cacheRead) && cacheRead > 0) usage.cacheReadTokens = cacheRead
20
- if (Number.isFinite(cacheWrite) && cacheWrite > 0) usage.cacheWriteTokens = cacheWrite
21
- const reasoning = Number(raw.output_tokens_details?.reasoning_tokens ?? raw.output_token_details?.reasoning_tokens ?? 0)
22
- if (Number.isFinite(reasoning) && reasoning > 0) usage.reasoningTokens = reasoning
23
- return usage
24
- }
25
-
26
- async function* sseEvents(response, options = {}) {
27
- if (!response?.body) throw fail('Responses replay returned no SSE body', 'LCX_INVALID_SSE')
28
- const reader = response.body.getReader()
29
- const decoder = new TextDecoder()
30
- let pending = ''
31
- let dataLines = []
32
- let bytes = 0
33
- const maxBytes = options.maxResponseBytes ?? 8 * 1024 * 1024
34
- const decodeEvent = () => {
35
- if (dataLines.length === 0) return undefined
36
- const data = dataLines.join('\n')
37
- dataLines = []
38
- if (data === '[DONE]') return undefined
39
- try { return JSON.parse(data) } catch (cause) { throw fail(`malformed Responses SSE JSON: ${cause}`, 'LCX_INVALID_SSE') }
40
- }
41
- try {
42
- while (true) {
43
- if (options.signal?.aborted) throw options.signal.reason ?? fail('request aborted', 'LCX_ABORTED')
44
- const { done, value } = await reader.read()
45
- if (done) break
46
- bytes += value.byteLength
47
- if (bytes > maxBytes) throw fail(`Responses SSE exceeds ${maxBytes} bytes`, 'LCX_RESPONSE_TOO_LARGE')
48
- pending += decoder.decode(value, { stream: true })
49
- let newline
50
- while ((newline = pending.indexOf('\n')) >= 0) {
51
- const line = pending.slice(0, newline).replace(/\r$/u, '')
52
- pending = pending.slice(newline + 1)
53
- if (line === '') {
54
- const event = decodeEvent()
55
- if (event) yield event
56
- } else if (!line.startsWith(':') && line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /u, ''))
57
- }
58
- }
59
- pending += decoder.decode()
60
- if (pending.startsWith('data:')) dataLines.push(pending.slice(5).replace(/^ /u, ''))
61
- const event = decodeEvent()
62
- if (event) yield event
63
- } finally {
64
- await reader.cancel().catch(() => undefined)
65
- }
66
- }
67
-
68
- function streamError(event) {
69
- const e = fail(`Responses stream ended with ${String(event?.type)}`)
70
- if (Number.isInteger(event?.error?.status)) e.status = event.error.status
71
- return e
72
- }
73
-
74
- function textSignature(itemId, phase) {
75
- if (typeof itemId !== 'string' || itemId.length === 0) return undefined
76
- const value = { v: 1, id: itemId }
77
- if (phase === 'commentary' || phase === 'final_answer') value.phase = phase
78
- return JSON.stringify(value)
79
- }
80
-
81
- function replayBlockFor(item, fallbackKind) {
82
- const kind = item?.kind ?? fallbackKind
83
- if (kind === 'text') {
84
- const signature = textSignature(item?.itemId, item?.phase)
85
- return { type: 'text', ...(signature ? { textSignature: signature } : {}) }
86
- }
87
- if (kind === 'reasoning') {
88
- const signature = isObject(item?.rawItem) ? JSON.stringify(item.rawItem) : undefined
89
- return { type: 'reasoning', ...(signature ? { thinkingSignature: signature } : {}) }
90
- }
91
- return { type: 'tool-call' }
92
- }
93
-
94
- function replayStopReason(reason) {
95
- if (reason?.kind === 'tool-calls') return 'toolUse'
96
- if (reason?.kind === 'max-tokens') return 'length'
97
- if (reason?.kind === 'error') return 'error'
98
- if (reason?.kind === 'aborted') return 'aborted'
99
- return 'stop'
100
- }
101
- function dshToolCallId(callId, itemId) {
102
- const call = typeof callId === 'string' && callId.length > 0 ? callId : undefined
103
- const item = typeof itemId === 'string' && itemId.length > 0 ? itemId : undefined
104
- if (call && item) return `${call}|${item}`
105
- return call ?? item ?? ''
6
+ /**
7
+ * Backward-compatible body helper retained for protocol tests and downstream imports.
8
+ * Production managed replay already carries the DSH system prompt in canonical input;
9
+ * `system` remains only as a low-level compatibility field.
10
+ */
11
+ export function replayBody({ model, modelDescriptor, input, system, tools, promptCacheKey, promptCacheRetention, cacheRetention, reasoningEffort, temperature, maxTokens }) {
12
+ const descriptor = modelDescriptor ?? resolvePiResponsesModel({
13
+ route: { provider: 'lcx', model, baseURL: '' },
14
+ model: { id: model, provider: 'lcx', baseUrl: '', api: 'openai-responses', reasoning: true, input: ['text'] },
15
+ })
16
+ const body = buildResponsesBody({
17
+ model: descriptor,
18
+ input,
19
+ instructions: system,
20
+ tools,
21
+ promptCacheKey,
22
+ promptCacheRetention,
23
+ cacheRetention: cacheRetention ?? (promptCacheKey ? (promptCacheRetention ? 'long' : 'short') : 'none'),
24
+ reasoningEffort,
25
+ temperature,
26
+ maxTokens,
27
+ })
28
+ // Opaque Native replay stays on the Remote V2 wire contract while sharing the standard builder.
29
+ body.tool_choice = 'auto'
30
+ body.parallel_tool_calls = true
31
+ return body
106
32
  }
107
33
 
108
- export function replayBody({ model, input, system, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens }) {
109
- return {
34
+ /**
35
+ * Compatibility wrapper: Native replay now uses the same builder/transport/parser as ordinary turns.
36
+ */
37
+ export async function* requestNativeReplay({ baseURL, provider, model, modelDescriptor, input, system, tools, promptCacheKey, promptCacheRetention, cacheRetention, reasoningEffort, temperature, maxTokens, grammarToolInputProperties, headers, signal, timeoutMs, maxAttempts = 1, maxResponseBytes }) {
38
+ const descriptor = modelDescriptor ?? resolvePiResponsesModel({
39
+ route: { provider, model, baseURL },
40
+ model: { id: model, provider, baseUrl: baseURL, api: 'openai-responses', reasoning: true, input: ['text'] },
41
+ })
42
+ const body = replayBody({
110
43
  model,
111
- input: structuredClone(input),
112
- stream: true,
113
- store: false,
114
- tool_choice: 'auto',
115
- parallel_tool_calls: true,
116
- ...(system !== undefined ? { instructions: system } : {}),
117
- ...(tools !== undefined ? { tools: responsesTools(tools) } : {}),
118
- ...(promptCacheKey ? { prompt_cache_key: String(promptCacheKey) } : {}),
119
- ...(promptCacheRetention ? { prompt_cache_retention: String(promptCacheRetention) } : {}),
120
- ...responsesGenerationEnvelope({ reasoningEffort, temperature, maxTokens }),
121
- }
122
- }
123
-
124
- export async function* requestNativeReplay({ baseURL, provider, model, input, system, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens, headers, signal, timeoutMs, maxAttempts, maxResponseBytes }) {
125
- const response = await fetchSseWithRetry(`${String(baseURL).replace(/\/+$/u, '')}/responses`, replayBody({ model, input, system, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens }), mergeFeatureHeader(headers), signal, timeoutMs, { maxAttempts, maxResponseBytes })
126
- const records = []
127
- const byStreamIdentity = new Map()
128
- let nextIndex = 0
129
- let terminal
130
- const identity = (kind, itemId, partIndex) => itemId === undefined ? undefined : `${kind}:${String(itemId)}:${partIndex ?? 0}`
131
- const createRecord = (kind, streamIdentity, outputIndex, partIndex) => {
132
- const existing = streamIdentity === undefined ? undefined : byStreamIdentity.get(streamIdentity)
133
- if (existing) return { record: existing, created: false }
134
- const record = { kind, index: nextIndex++, outputIndex, partIndex, streamIdentity, text: '', arguments: '', name: '', callId: undefined, itemId: undefined, terminal: undefined }
135
- records.push(record)
136
- if (streamIdentity !== undefined) byStreamIdentity.set(streamIdentity, record)
137
- return { record, created: true }
138
- }
139
- for await (const event of sseEvents(response, { signal, maxResponseBytes })) {
140
- if (!isObject(event)) continue
141
- if (event.type === 'error' || event.type === 'response.failed' || event.type === 'response.incomplete') throw streamError(event)
142
- if (event.type === 'response.output_text.delta') {
143
- const itemId = typeof event.item_id === 'string' ? event.item_id : undefined
144
- const key = identity('text', itemId, event.content_index)
145
- const allocated = createRecord('text', key ?? `text:index:${event.output_index ?? ''}:${event.content_index ?? ''}`, event.output_index, event.content_index)
146
- const record = allocated.record
147
- if (allocated.created) yield { type: 'block-start', index: record.index, blockType: 'text' }
148
- if (typeof event.delta === 'string' && event.delta) { record.text += event.delta; yield { type: 'text-delta', index: record.index, text: event.delta } }
149
- continue
150
- }
151
- if (event.type === 'response.reasoning_summary_text.delta' || event.type === 'response.reasoning_text.delta') {
152
- const itemId = typeof event.item_id === 'string' ? event.item_id : undefined
153
- const key = identity('reasoning', itemId, event.summary_index ?? event.content_index)
154
- const allocated = createRecord('reasoning', key ?? `reasoning:index:${event.output_index ?? ''}:${event.summary_index ?? event.content_index ?? ''}`, event.output_index, event.summary_index ?? event.content_index)
155
- const record = allocated.record
156
- if (allocated.created) yield { type: 'block-start', index: record.index, blockType: 'reasoning' }
157
- if (typeof event.delta === 'string' && event.delta) { record.text += event.delta; yield { type: 'reasoning-delta', index: record.index, text: event.delta } }
158
- continue
159
- }
160
- if (event.type === 'response.function_call_arguments.delta') {
161
- const itemId = typeof event.item_id === 'string' ? event.item_id : undefined
162
- const callId = typeof event.call_id === 'string' ? event.call_id : itemId ?? `call-${event.output_index ?? nextIndex}`
163
- const key = identity('tool-call', typeof event.item_id === 'string' ? event.item_id : callId, 0) ?? `tool-call:${callId}`
164
- const allocated = createRecord('tool-call', key, event.output_index, 0)
165
- const record = allocated.record
166
- record.callId = callId
167
- record.itemId = itemId
168
- if (typeof event.name === 'string') record.name = event.name
169
- if (allocated.created) yield { type: 'block-start', index: record.index, blockType: 'tool-call' }
170
- const delta = typeof event.delta === 'string' ? event.delta : ''
171
- record.arguments += delta
172
- yield { type: 'tool-call-delta', index: record.index, id: dshToolCallId(callId, itemId), ...(record.name ? { name: record.name } : {}), argumentsDelta: delta }
173
- continue
174
- }
175
- if (event.type === 'response.output_item.added' && event.item?.type === 'function_call') {
176
- const itemId = typeof event.item.id === 'string' ? event.item.id : undefined
177
- const callId = typeof event.item.call_id === 'string' ? event.item.call_id : itemId ?? `call-${event.output_index ?? nextIndex}`
178
- const key = identity('tool-call', itemId ?? callId, 0) ?? `tool-call:${callId}`
179
- const allocated = createRecord('tool-call', key, event.output_index, 0)
180
- const record = allocated.record
181
- record.callId = callId
182
- record.itemId = itemId
183
- record.name = typeof event.item.name === 'string' ? event.item.name : record.name
184
- if (allocated.created) {
185
- yield { type: 'block-start', index: record.index, blockType: 'tool-call' }
186
- if (typeof event.item.arguments === 'string' && event.item.arguments) { record.arguments = event.item.arguments; yield { type: 'tool-call-delta', index: record.index, id: dshToolCallId(callId, itemId), ...(record.name ? { name: record.name } : {}), argumentsDelta: event.item.arguments } }
187
- }
188
- continue
189
- }
190
- if (event.type === 'response.output_item.done' && event.item?.type === 'function_call') {
191
- const itemId = typeof event.item.id === 'string' ? event.item.id : undefined
192
- const callId = typeof event.item.call_id === 'string' ? event.item.call_id : itemId ?? `call-${event.output_index ?? nextIndex}`
193
- const key = identity('tool-call', itemId ?? callId, 0) ?? `tool-call:${callId}`
194
- const allocated = createRecord('tool-call', key, event.output_index, 0)
195
- const record = allocated.record
196
- record.callId = callId
197
- record.itemId = itemId
198
- record.name = typeof event.item.name === 'string' ? event.item.name : record.name
199
- const completedArguments = typeof event.item.arguments === 'string' ? event.item.arguments : record.arguments
200
- if (allocated.created) {
201
- yield { type: 'block-start', index: record.index, blockType: 'tool-call' }
202
- if (completedArguments) yield { type: 'tool-call-delta', index: record.index, id: dshToolCallId(callId, itemId), ...(record.name ? { name: record.name } : {}), argumentsDelta: completedArguments }
203
- }
204
- record.arguments = completedArguments
205
- continue
206
- }
207
- if (event.type === 'response.completed') terminal = event.response
208
- }
209
- if (!isObject(terminal)) throw fail('Responses replay ended without response.completed', 'LCX_RESPONSES_INCOMPLETE')
210
- if (terminal.status !== 'completed') throw fail('Responses replay response.completed did not carry completed status', 'LCX_RESPONSES_INCOMPLETE')
211
-
212
- const terminalItems = []
213
- for (const [outputIndex, item] of (terminal.output ?? []).entries()) {
214
- if (item?.type === 'message') {
215
- for (const [contentIndex, part] of (item.content ?? []).entries()) if (part?.type === 'output_text') terminalItems.push({ kind: 'text', outputIndex, partIndex: contentIndex, itemId: item.id, phase: item.phase, text: String(part.text ?? ''), rawItem: item })
216
- } else if (item?.type === 'reasoning') {
217
- const parts = Array.isArray(item.summary) && item.summary.length ? item.summary : item.content ?? []
218
- parts.forEach((part, index) => { if (typeof part?.text === 'string') terminalItems.push({ kind: 'reasoning', outputIndex, partIndex: index, itemId: item.id, text: part.text, rawItem: item }) })
219
- } else if (item?.type === 'function_call') {
220
- terminalItems.push({ kind: 'tool-call', outputIndex, partIndex: 0, itemId: item.id, callId: item.call_id, name: String(item.name ?? ''), arguments: String(item.arguments ?? ''), rawItem: item })
221
- }
222
- }
223
- const matched = new Set()
224
- const matchesRecord = (item) => {
225
- const stable = identity(item.kind, item.itemId ?? item.callId, item.partIndex)
226
- if (stable) {
227
- const exact = records.find((record) => !matched.has(record) && record.kind === item.kind && (record.streamIdentity === stable || (item.kind === 'tool-call' && record.callId === item.callId)))
228
- if (exact) return exact
229
- }
230
- const candidates = records.filter((record) => !matched.has(record) && record.kind === item.kind)
231
- if (candidates.length === 1) return candidates[0]
232
- const contentMatches = candidates.filter((record) => item.kind === 'tool-call'
233
- ? record.callId === item.callId || (item.arguments && record.arguments && (item.arguments.startsWith(record.arguments) || record.arguments.startsWith(item.arguments)))
234
- : item.text !== undefined && record.text && (item.text === record.text || item.text.startsWith(record.text) || record.text.startsWith(item.text)))
235
- return contentMatches.length === 1 ? contentMatches[0] : undefined
236
- }
237
- for (const item of terminalItems) {
238
- const record = matchesRecord(item)
239
- if (!record) continue
240
- matched.add(record)
241
- record.terminal = item
242
- }
243
- const replayBlocks = []
244
- for (const record of records) {
245
- const item = record.terminal
246
- replayBlocks[record.index] = replayBlockFor(item, record.kind)
247
- if (record.kind === 'text') yield { type: 'block-end', index: record.index, block: { type: 'text', text: item?.text ?? record.text } }
248
- else if (record.kind === 'reasoning') yield { type: 'block-end', index: record.index, block: { type: 'reasoning', text: item?.text ?? record.text } }
249
- else yield { type: 'block-end', index: record.index, block: { type: 'tool-call', id: dshToolCallId(item?.callId ?? record.callId, item?.itemId ?? record.itemId), name: item?.name ?? record.name, arguments: item?.arguments ?? record.arguments } }
250
- }
251
- for (const item of terminalItems) {
252
- const record = records.find((candidate) => candidate.terminal === item)
253
- if (record) continue
254
- if (item.kind === 'text' && !item.text) continue
255
- const index = nextIndex++
256
- replayBlocks[index] = replayBlockFor(item, item.kind)
257
- yield { type: 'block-start', index, blockType: item.kind === 'tool-call' ? 'tool-call' : item.kind }
258
- if (item.kind === 'text' && item.text) yield { type: 'text-delta', index, text: item.text }
259
- if (item.kind === 'reasoning' && item.text) yield { type: 'reasoning-delta', index, text: item.text }
260
- if (item.kind === 'tool-call') yield { type: 'tool-call-delta', index, id: dshToolCallId(item.callId, item.itemId), name: item.name, argumentsDelta: item.arguments }
261
- yield { type: 'block-end', index, block: item.kind === 'text' ? { type: 'text', text: item.text } : item.kind === 'reasoning' ? { type: 'reasoning', text: item.text } : { type: 'tool-call', id: dshToolCallId(item.callId, item.itemId), name: item.name, arguments: item.arguments } }
262
- }
263
- const usage = usageFrom(terminal.usage)
264
- if (usage) yield { type: 'usage', usage }
265
- const reason = terminal.status === 'incomplete'
266
- ? { kind: 'max-tokens' }
267
- : terminal.error ? { kind: 'error', failure: { message: terminal.error.message ?? 'Responses error', code: 'LCX_RESPONSES_UPSTREAM_ERROR' } }
268
- : terminalItems.some((item) => item.kind === 'tool-call') ? { kind: 'tool-calls' } : { kind: 'stop' }
269
- const replayState = typeof provider === 'string' && provider.length > 0 && typeof model === 'string' && model.length > 0
270
- ? {
271
- response: {
272
- kind: 'pi-ai', version: 2, api: 'openai-responses', provider, model,
273
- ...(typeof terminal.model === 'string' && terminal.model.length > 0 ? { responseModel: terminal.model } : {}),
274
- ...(typeof terminal.id === 'string' && terminal.id.length > 0 ? { responseId: terminal.id } : {}),
275
- stopReason: replayStopReason(reason),
276
- },
277
- blocks: Array.from({ length: nextIndex }, (_, index) => replayBlocks[index] ?? { type: records.find((record) => record.index === index)?.kind ?? 'text' }),
278
- }
279
- : undefined
280
- yield { type: 'finish', reason, ...(replayState ? { replayState } : {}) }
44
+ modelDescriptor: descriptor,
45
+ input,
46
+ system,
47
+ tools,
48
+ promptCacheKey,
49
+ promptCacheRetention,
50
+ cacheRetention,
51
+ reasoningEffort,
52
+ temperature,
53
+ maxTokens,
54
+ })
55
+ yield* streamResponsesRequest({
56
+ baseURL,
57
+ provider,
58
+ model,
59
+ piModel: descriptor,
60
+ body,
61
+ grammarToolInputProperties,
62
+ headers: mergeFeatureHeader(headers),
63
+ signal,
64
+ timeoutMs,
65
+ maxAttempts,
66
+ maxResponseBytes,
67
+ })
281
68
  }
@@ -0,0 +1,145 @@
1
+ // @ts-check
2
+
3
+ import { clampOpenAIPromptCacheKey } from '@earendil-works/pi-ai/api/openai-prompt-cache'
4
+ import { responsesTools } from './dsh-responses.js'
5
+
6
+ const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16
7
+
8
+ /** @typedef {'none' | 'short' | 'long'} CacheRetention */
9
+ /** @typedef {Record<string, unknown>} UnknownRecord */
10
+ /**
11
+ * @typedef {object} ResponsesCompat
12
+ * @property {boolean} [supportsLongCacheRetention]
13
+ * @property {boolean} [supportsExplicitPromptCacheMode]
14
+ */
15
+ /**
16
+ * @typedef {object} PiResponsesModel
17
+ * @property {string} id
18
+ * @property {string} provider
19
+ * @property {boolean} [reasoning]
20
+ * @property {Record<string, string | null>} [thinkingLevelMap]
21
+ * @property {ResponsesCompat} [compat]
22
+ */
23
+ /**
24
+ * @typedef {object} GenerationControls
25
+ * @property {unknown} [reasoningEffort]
26
+ * @property {unknown} [temperature]
27
+ * @property {unknown} [maxTokens]
28
+ */
29
+ /**
30
+ * @typedef {object} BuildResponsesBodyOptions
31
+ * @property {PiResponsesModel | string} model
32
+ * @property {unknown[]} input
33
+ * @property {string} [instructions]
34
+ * @property {unknown} [tools]
35
+ * @property {string} [sessionId]
36
+ * @property {string} [promptCacheKey]
37
+ * @property {string} [promptCacheRetention]
38
+ * @property {CacheRetention} [cacheRetention]
39
+ * @property {unknown} [reasoningEffort]
40
+ * @property {unknown} [temperature]
41
+ * @property {unknown} [maxTokens]
42
+ * @property {UnknownRecord} [samplingParams]
43
+ */
44
+
45
+ /** @param {unknown} value */
46
+ function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
47
+
48
+ /** @param {PiResponsesModel | string} model */
49
+ function modelId(model) { return typeof model === 'string' ? model : model.id }
50
+
51
+ /** @param {PiResponsesModel | string} model */
52
+ function modelRecord(model) {
53
+ return typeof model === 'string'
54
+ ? /** @type {PiResponsesModel} */ ({ id: model, provider: 'lcx', reasoning: true })
55
+ : model
56
+ }
57
+
58
+ /** @param {unknown} value */
59
+ function normalizeCacheRetention(value) {
60
+ return /** @type {CacheRetention} */ (['none', 'short', 'long'].includes(String(value)) ? value : 'short')
61
+ }
62
+
63
+ /**
64
+ * Pi-parity generation controls for OpenAI Responses.
65
+ * @param {GenerationControls & { model?: PiResponsesModel | string, includeDefaultReasoning?: boolean }} [controls]
66
+ */
67
+ export function responsesGenerationEnvelope({ model = 'unknown', reasoningEffort, temperature, maxTokens, includeDefaultReasoning = false } = {}) {
68
+ const descriptor = modelRecord(model)
69
+ /** @type {UnknownRecord} */
70
+ const result = {}
71
+ if (descriptor.reasoning !== false) {
72
+ if (reasoningEffort !== undefined && reasoningEffort !== 'off') {
73
+ const requested = String(reasoningEffort)
74
+ const wire = descriptor.thinkingLevelMap?.[requested] ?? requested
75
+ if (wire !== null) {
76
+ result.reasoning = { effort: wire, summary: 'auto' }
77
+ result.include = ['reasoning.encrypted_content']
78
+ }
79
+ } else if (includeDefaultReasoning && descriptor.provider !== 'github-copilot' && descriptor.thinkingLevelMap?.off !== null) {
80
+ result.reasoning = { effort: descriptor.thinkingLevelMap?.off ?? 'none' }
81
+ }
82
+ }
83
+ if (temperature !== undefined) {
84
+ if (!Number.isFinite(temperature)) throw Object.assign(new Error('Responses temperature must be finite'), { code: 'LCX_RESPONSES_INVALID_INPUT' })
85
+ result.temperature = Number(temperature)
86
+ }
87
+ if (maxTokens !== undefined) {
88
+ if (!Number.isSafeInteger(maxTokens) || Number(maxTokens) <= 0) throw Object.assign(new Error('Responses maxTokens must be a positive safe integer'), { code: 'LCX_RESPONSES_INVALID_INPUT' })
89
+ result.max_output_tokens = Math.max(OPENAI_RESPONSES_MIN_OUTPUT_TOKENS, Number(maxTokens))
90
+ }
91
+ return result
92
+ }
93
+
94
+ /**
95
+ * Build the shared LCX-owned request envelope while retaining Pi 0.84 Responses semantics.
96
+ * Production LCX ordinary/compact/replay construction places the DSH system prompt in canonical input.
97
+ * `instructions` remains accepted only for the exported low-level helper's backward-compatible callers.
98
+ * @param {BuildResponsesBodyOptions} options
99
+ */
100
+ export function buildResponsesBody({ model, input, instructions, tools, sessionId, promptCacheKey, promptCacheRetention, cacheRetention, reasoningEffort, temperature, maxTokens, samplingParams }) {
101
+ if (!Array.isArray(input)) throw Object.assign(new Error('Responses input must be an array'), { code: 'LCX_RESPONSES_INVALID_INPUT' })
102
+ const descriptor = modelRecord(model)
103
+ const retention = normalizeCacheRetention(cacheRetention)
104
+ const compat = descriptor.compat ?? {}
105
+ const cacheKey = retention === 'none'
106
+ ? undefined
107
+ : promptCacheKey ?? clampOpenAIPromptCacheKey(sessionId)
108
+ const longRetention = promptCacheRetention ?? (retention === 'long' && compat.supportsLongCacheRetention !== false ? '24h' : undefined)
109
+ // Pi's flag is route/model proof that prompt_cache_options is accepted; no mode keeps implicit caching.
110
+ const currentCache = retention === 'short' && compat.supportsExplicitPromptCacheMode === true
111
+ const explicitCache = retention === 'none' && compat.supportsExplicitPromptCacheMode === true
112
+ const promptCacheOptions = currentCache ? { ttl: '30m' } : explicitCache ? { mode: 'explicit' } : undefined
113
+ const nativeTools = responsesTools(tools)
114
+ /** @type {UnknownRecord} */
115
+ const body = {
116
+ model: modelId(model),
117
+ input: structuredClone(input),
118
+ stream: true,
119
+ store: false,
120
+ ...(instructions === undefined ? {} : { instructions }),
121
+ ...(nativeTools !== undefined && nativeTools.length > 0 ? { tools: nativeTools } : {}),
122
+ ...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
123
+ ...(longRetention ? { prompt_cache_retention: longRetention } : {}),
124
+ ...(promptCacheOptions ? { prompt_cache_options: promptCacheOptions } : {}),
125
+ ...responsesGenerationEnvelope({ model: descriptor, reasoningEffort, temperature, maxTokens, includeDefaultReasoning: true }),
126
+ }
127
+ if (isObject(samplingParams)) Object.assign(body, structuredClone(samplingParams))
128
+ return body
129
+ }
130
+
131
+ /**
132
+ * Compact is the standard request plus the one opaque-history transition patch.
133
+ * @param {BuildResponsesBodyOptions} options
134
+ */
135
+ export function buildCompactionResponsesBody(options) {
136
+ const body = /** @type {UnknownRecord & { input: unknown[] }} */ (buildResponsesBody(options))
137
+ if (body.input.some((item) => /** @type {UnknownRecord | undefined} */ (item)?.type === 'compaction_trigger')) {
138
+ throw Object.assign(new Error('native compaction input already contains compaction_trigger'), { code: 'LCX_COMPACT_DUPLICATE_TRIGGER' })
139
+ }
140
+ body.input = [...body.input, { type: 'compaction_trigger' }]
141
+ // Remote Compaction V2 has historically required these explicit controls.
142
+ body.tool_choice = 'auto'
143
+ body.parallel_tool_calls = true
144
+ return body
145
+ }