llm.openai_responses.js
10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
/**
* OpenAI Responses API 子类 —— POST /responses 协议,OpenAI 厂商默认适配器。
*
* 与 Chat Completions(llm.openai.js)的差异仅在:端点(/responses)、请求体
* (`input` items / `instructions` / `store` / `prompt_cache_key`)与流事件类型。
* 复用父类 OpenAIProvider 的 Bearer header(chatCompletionsHeaders)与 defaultBaseUrl
* (https://api.openai.com/v1);chat / messageToValue / 消息映射整体覆写。
*
* 网关无状态(store:false,不依赖服务端会话历史):req.messages 全量映射为 input items。
*
* 请求体字段与流事件类型以 OpenAI Responses API 当前官方文档为准:
* 请求:POST /responses,body { model, input, instructions?, stream, store, prompt_cache_key?,
* temperature?, max_output_tokens? }。
* 流事件(SSE `event: <type>\ndata: {json}`,data 内 `type` 即事件类型):
* response.output_text.delta → text 增量(取 delta)
* response.reasoning_summary_text.delta → reasoning 增量(取 delta)
* response.output_item.done(function_call) → tool_call
* response.completed → response.usage 用量
* usage 字段:input_tokens / output_tokens / total_tokens /
* input_tokens_details.cached_tokens / input_tokens_details.cache_write_tokens。
*
* 流解析沿用 LLMBase 的 iterateSse(标准 SSE `data:` 分帧,帧内 type 承载事件类型),不覆写。
*/
import { LLMError } from '../errors.js'
import { mapResponsesFailure } from '../map-error.js'
import OpenAIProvider, {
readOptionalUsageInteger,
readUsageInteger,
} from './llm.openai.js'
// 成功终态:携 usage、走成功路径。completed=完整、incomplete=受控部分结果(如 max_output_tokens
// 耗尽,语义等同 chat 的 finish_reason=length)。response.failed 是明确失败,不在此列,单独抛错。
const TERMINAL_FRAMES = new Set(['response.completed', 'response.incomplete'])
export default class OpenAIResponsesProvider extends OpenAIProvider {
/**
* @param {import('../index.js').ChatRequest} req
* @param {import('../index.js').ChatOpts} [opts]
* @returns {Promise<import('../base.js').StreamResult>}
*/
async chat(req, opts = {}) {
const model = req.model ?? this.model
return this.request({
req,
opts,
affinity_key: opts.trace_ctx?.session_id,
doFetch: (lease, { signal }) => {
const base = lease.base_url ?? this.defaultBaseUrl()
const headers = this.chatCompletionsHeaders(lease)
const { instructions, input } = this._toResponsesInput(req)
/** @type {Record<string, any>} */
const body = { model, input, stream: true, store: false }
if (instructions) body.instructions = instructions
if (opts.prompt_cache_key) body.prompt_cache_key = opts.prompt_cache_key
if (req.prompt_cache_options) body.prompt_cache_options = req.prompt_cache_options
if (req.temperature != null) body.temperature = req.temperature
if (req.max_tokens != null) body.max_output_tokens = req.max_tokens
this.applyResponseFormat(body, headers, req.response_format)
const request = this.responsesRequest(lease, req, opts, headers, body)
return this.httpFetch(`${base}/responses`, {
method: 'POST',
signal,
headers: request.headers,
body: JSON.stringify(request.body),
})
},
})
}
/**
* Responses 请求扩展点。公共端点原样返回;内部协议适配器在这里一次性改请求头和请求体。
* @protected
* @param {import('../index.js').KeyLease} lease
* @param {import('../index.js').ChatRequest} _req
* @param {import('../index.js').ChatOpts} _opts
* @param {Record<string, string>} headers
* @param {Record<string, any>} body
*/
responsesRequest(_lease, _req, _opts, headers, body) {
return { headers, body }
}
/**
* Responses API structured output 分派表:以 text.format 表达(非 Chat Completions 的 response_format)。
* json_object → text.format.type='json_object'。
* json_schema → text.format.json_schema(name+schema+strict)。
* @protected
* @returns {Record<string, (body:Record<string,any>, headers:Record<string,string>, rf:{schema?:object, name?:string}) => void>}
*/
responseFormatHandlers() {
return {
json_object: (body) => {
body.text = { ...(body.text ?? {}), format: { type: 'json_object' } }
},
json_schema: (body, _headers, rf) => {
body.text = {
...(body.text ?? {}),
format: { type: 'json_schema', name: rf.name ?? 'response', schema: rf.schema, strict: true },
}
},
}
}
/**
* messages → Responses 的 { instructions?, input }。system 累积为顶层 instructions,
* 其余转 input item(assistant→output_text、其余→input_text)。全量传入(store:false 无状态)。
* @private
* @param {import('../index.js').ChatRequest} req
* @returns {{ instructions?:string, input:Array<any> }}
*/
_toResponsesInput(req) {
/** @type {string[]} */
const systemTexts = []
/** @type {Array<any>} */
const input = []
for (const m of req.messages) {
if (m.role === 'system') {
systemTexts.push(String(m.content))
continue
}
const content = {
type: m.role === 'assistant' ? 'output_text' : 'input_text',
text: String(m.content),
}
if (req.prompt_cache_options && m.prompt_cache_breakpoint === true) {
content.prompt_cache_breakpoint = { mode: 'explicit' }
}
input.push({
role: m.role,
content: [content],
})
}
const images = this.buildMediaParts(req.images, 'image')
if (images.length && input.length) {
const last = input[input.length - 1]
for (const img of images) {
const url = img.url ?? `data:${img.mime};base64,${img.data}`
last.content.push({ type: 'input_image', image_url: url })
}
}
const out = { input }
if (systemTexts.length) out.instructions = systemTexts.join('\n')
return out
}
/**
* @param {any} frame Responses SSE data 帧(已解析 JSON),frame.type 为事件类型。
* @returns {import('../base.js').ParsedValue | null}
*/
messageToValue(frame) {
if (!frame || typeof frame !== 'object') return null
if (frame.type === 'response.output_text.delta') return { type: 'text', text: frame.delta }
if (frame.type === 'response.reasoning_summary_text.delta') {
return { type: 'reasoning', text: frame.delta }
}
if (frame.type === 'response.output_item.done' && frame.item?.type === 'function_call') {
// Responses 的 function_call 参数经 response.function_call_arguments.delta 分片,最终在 output_item.done
// 携全量 arguments 字符串。归一为 base 的分片机制:发单条 tool_call_delta(整帧即全量片段),
// 由 base 按 index 聚合 + 一次 JSON.parse 得中性 { id, name, args(object) },不在 provider 拼装 wire 形态。
return {
type: 'tool_call_delta',
index: frame.output_index ?? 0,
id: frame.item.call_id,
name: frame.item.name,
args_fragment: frame.item.arguments ?? '',
}
}
// 明确失败终态:从 response.error(code/message)构造 LLMError 抛出(沿用 azure_foundry
// 在 provider 内抛 LLMError 的既有约定)。经 base 的 _consumeStream→request 重试循环,reason
// 决定重试与否;该次调用以抛错结束,createLLM 不进成功计费路径,审计落 status='error'。
if (frame.type === 'response.failed') {
const error = frame.response?.error ?? {}
const { reason, retryable } = mapResponsesFailure(error.code)
throw new LLMError({
provider: this.provider,
reason,
retryable,
cause: error,
message: `[${this.provider}] response.failed (${error.code ?? 'unknown'}): ${error.message ?? ''}`,
detail: { type: 'provider_response', response: error },
})
}
// completed/incomplete 两终态是成功路径——incomplete(如思维链耗尽 max_output_tokens)时上游
// 已对 reasoning token 计费,漏读即漏计费,故与 completed 同走成功 + usage 路径。
//
// 终态必带 usage:Responses 规范允许 `usage: null`(见 API reference 的 incomplete 示例),
// 但零 usage 会让本次调用以 amount=0 静默跳账 + 审计 status='ok' 落地——上游已产生费用却无账可
// 对,违反「运行期不存在免费调用路径」。故缺 usage 即显式失败,绝不降级为零成本成功。
if (TERMINAL_FRAMES.has(frame.type)) {
const u = frame.response?.usage
if (!u) {
throw new LLMError({
provider: this.provider,
reason: 'unknown',
retryable: false,
message: `[${this.provider}] ${frame.type} 未携带 usage(上游已计费但本地无法计价,拒绝记为零成本成功)`,
})
}
const input_details = u.input_tokens_details
const prompt_tokens = readUsageInteger(u.input_tokens, 'input_tokens', this.provider)
const completion_tokens = readUsageInteger(u.output_tokens, 'output_tokens', this.provider)
const total_tokens = readUsageInteger(u.total_tokens, 'total_tokens', this.provider)
const cached_tokens = readOptionalUsageInteger(
input_details?.cached_tokens,
'input_tokens_details.cached_tokens',
this.provider,
)
const cache_write_tokens = readOptionalUsageInteger(
input_details?.cache_write_tokens,
'input_tokens_details.cache_write_tokens',
this.provider,
)
const reasoning_tokens = readOptionalUsageInteger(
u.output_tokens_details?.reasoning_tokens,
'output_tokens_details.reasoning_tokens',
this.provider,
)
if (cached_tokens + cache_write_tokens > prompt_tokens) {
throw new Error(
`[${this.provider}] cached_tokens + cache_write_tokens 不能大于 input_tokens`,
)
}
if (total_tokens !== prompt_tokens + completion_tokens) {
throw new Error(`[${this.provider}] total_tokens 必须等于 input_tokens + output_tokens`)
}
return [
{
type: 'metadata',
provider_request_id: typeof frame.response.id === 'string' ? frame.response.id : null,
service_tier: typeof frame.response.service_tier === 'string' ? frame.response.service_tier : null,
},
{
type: 'usage',
usage: {
prompt_tokens,
completion_tokens,
total_tokens,
cached_tokens,
cache_write_tokens,
reasoning_tokens,
},
},
]
}
return null
}
}