Merge pull request #625 from stabey/pr/responses-call-items-cache-control-20260604

fix: 剥离 Codex cache_control 并完善 Responses 工具调用展示
This commit is contained in:
fawney19
2026-06-04 13:59:56 +08:00
committed by GitHub
3 changed files with 397 additions and 32 deletions
@@ -398,6 +398,23 @@ fn collect_codex_prompt_cache_control_anchors(value: &Value, anchors: &mut Vec<V
}
}
fn strip_codex_cache_control_fields(value: &mut Value) {
match value {
Value::Object(object) => {
object.remove("cache_control");
for child in object.values_mut() {
strip_codex_cache_control_fields(child);
}
}
Value::Array(items) => {
for child in items {
strip_codex_cache_control_fields(child);
}
}
_ => {}
}
}
fn extract_codex_prompt_cache_control_seed(provider_request_body: &Value) -> Option<String> {
let mut anchors = Vec::new();
collect_codex_prompt_cache_control_anchors(provider_request_body, &mut anchors);
@@ -780,6 +797,7 @@ pub fn apply_codex_openai_responses_special_body_edits(
inject_codex_default_variation_prompt(body_object);
}
strip_codex_cache_control_fields(provider_request_body);
insert_codex_prompt_cache_key(provider_request_body, prompt_cache_key);
}
@@ -1206,6 +1224,49 @@ mod tests {
assert_eq!(body_a["prompt_cache_key"], body_b["prompt_cache_key"]);
assert_ne!(body_a["prompt_cache_key"], body_c["prompt_cache_key"]);
assert!(!body_a.to_string().contains("\"cache_control\""));
assert!(!body_b.to_string().contains("\"cache_control\""));
assert!(!body_c.to_string().contains("\"cache_control\""));
}
#[test]
fn codex_responses_body_edits_strip_developer_cache_control_before_upstream() {
let mut provider_request_body = json!({
"input": [{
"type": "message",
"role": "developer",
"content": [{
"type": "input_text",
"text": "stable system brief",
"cache_control": {"type": "ephemeral"}
}]
}, {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "new turn"}]
}],
"model": "gpt-5.4"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
Some("key-a"),
);
assert!(provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty()));
assert!(!provider_request_body
.to_string()
.contains("\"cache_control\""));
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
json!("stable system brief")
);
}
#[test]
@@ -110,6 +110,178 @@ describe('Conversation stream compatibility', () => {
})
})
it('renders OpenAI Responses custom tool calls without text output', () => {
const requestBody = {
model: 'gpt-5.5',
stream: true,
input: 'Patch a file',
}
const toolInput = '*** Begin Patch\n*** Update File: demo.rs\n*** End Patch\n'
const rawSse = [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_custom_123","object":"response","model":"gpt-5.5","status":"in_progress"}}',
'',
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_123","type":"custom_tool_call","status":"in_progress","call_id":"call_123","input":"","name":"apply_patch"}}',
'',
'event: response.custom_tool_call_input.delta',
'data: {"type":"response.custom_tool_call_input.delta","output_index":0,"item_id":"ctc_123","delta":"*** Begin Patch\\n"}',
'',
'event: response.custom_tool_call_input.delta',
'data: {"type":"response.custom_tool_call_input.delta","output_index":0,"item_id":"ctc_123","delta":"*** Update File: demo.rs\\n*** End Patch\\n"}',
'',
'event: response.custom_tool_call_input.done',
`data: ${JSON.stringify({ type: 'response.custom_tool_call_input.done', output_index: 0, item_id: 'ctc_123', input: toolInput })}`,
'',
'event: response.output_item.done',
`data: ${JSON.stringify({ type: 'response.output_item.done', output_index: 0, item: { id: 'ctc_123', type: 'custom_tool_call', status: 'completed', call_id: 'call_123', input: toolInput, name: 'apply_patch' } })}`,
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_custom_123","object":"response","model":"gpt-5.5","status":"completed","output":[]}}',
'',
'data: [DONE]',
'',
].join('\n')
const parsed = parseResponse(rawSse, requestBody, 'openai:responses')
expect(parsed.messages[0]?.content[0]).toMatchObject({
type: 'tool_use',
toolName: 'apply_patch',
toolId: 'call_123',
input: toolInput,
})
const rendered = renderResponse(rawSse, requestBody, 'openai:responses')
expect(rendered.error).toBeUndefined()
expect(rendered.isStream).toBe(true)
expect(rendered.blocks).toHaveLength(1)
const firstBlock = rendered.blocks[0]
if (!firstBlock || firstBlock.type !== 'message') {
throw new Error('expected first render block to be message')
}
expect(firstBlock.content[0]).toMatchObject({
type: 'tool_use',
toolName: 'apply_patch',
toolId: 'call_123',
input: toolInput,
})
})
it('keeps OpenAI Responses custom tool calls when text output is present', () => {
const requestBody = {
model: 'gpt-5.5',
stream: true,
input: 'Explain and patch',
}
const rawSse = [
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","delta":"I will patch it."}',
'',
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"ctc_456","type":"custom_tool_call","status":"in_progress","call_id":"call_456","input":"","name":"apply_patch"}}',
'',
'event: response.custom_tool_call_input.delta',
'data: {"type":"response.custom_tool_call_input.delta","output_index":1,"item_id":"ctc_456","delta":"patch text"}',
'',
'event: response.output_item.done',
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"ctc_456","type":"custom_tool_call","status":"completed","call_id":"call_456","input":"patch text","name":"apply_patch"}}',
'',
].join('\n')
const rendered = renderResponse(rawSse, requestBody, 'openai:responses')
const firstBlock = rendered.blocks[0]
if (!firstBlock || firstBlock.type !== 'message') {
throw new Error('expected first render block to be message')
}
expect(firstBlock.content.map(block => block.type)).toEqual(['text', 'tool_use'])
expect(firstBlock.content[1]).toMatchObject({
type: 'tool_use',
toolName: 'apply_patch',
input: 'patch text',
})
})
it('renders future OpenAI Responses call items through the generic call fallback', () => {
const requestBody = {
model: 'gpt-5.5',
stream: true,
input: 'Run a command',
}
const action = { command: 'npm test', timeout_ms: 1000 }
const expectedInput = JSON.stringify(action, null, 2)
const rawSse = [
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"shell_123","type":"shell_call","status":"in_progress"}}',
'',
'event: response.output_item.done',
`data: ${JSON.stringify({ type: 'response.output_item.done', output_index: 0, item: { id: 'shell_123', type: 'shell_call', status: 'completed', action } })}`,
'',
].join('\n')
const parsed = parseResponse(rawSse, requestBody, 'openai:responses')
expect(parsed.messages[0]?.content[0]).toMatchObject({
type: 'tool_use',
toolName: 'shell_call',
toolId: 'shell_123',
input: expectedInput,
})
const rendered = renderResponse(rawSse, requestBody, 'openai:responses')
const firstBlock = rendered.blocks[0]
if (!firstBlock || firstBlock.type !== 'message') {
throw new Error('expected first render block to be message')
}
expect(firstBlock.content[0]).toMatchObject({
type: 'tool_use',
toolName: 'shell_call',
toolId: 'shell_123',
input: expectedInput,
})
})
it('keeps streamed function_call arguments when response.completed omits them', () => {
const requestBody = {
model: 'gpt-5.5',
stream: true,
input: 'What is the weather?',
}
const rawSse = [
'event: response.created',
`data: ${JSON.stringify({ type: 'response.created', response: { id: 'resp_fc_1', object: 'response', model: 'gpt-5.5', status: 'in_progress' } })}`,
'',
'event: response.output_item.added',
`data: ${JSON.stringify({ type: 'response.output_item.added', output_index: 0, item: { id: 'fc_1', type: 'function_call', status: 'in_progress', call_id: 'call_1', name: 'get_weather', arguments: '' } })}`,
'',
'event: response.function_call_arguments.delta',
`data: ${JSON.stringify({ type: 'response.function_call_arguments.delta', output_index: 0, item_id: 'fc_1', delta: '{"city":' })}`,
'',
'event: response.function_call_arguments.delta',
`data: ${JSON.stringify({ type: 'response.function_call_arguments.delta', output_index: 0, item_id: 'fc_1', delta: '"SF"}' })}`,
'',
// 最终项故意不带 arguments:解析器不应用 '{}' 冲掉已收集的增量参数
'event: response.completed',
`data: ${JSON.stringify({ type: 'response.completed', response: { id: 'resp_fc_1', object: 'response', model: 'gpt-5.5', status: 'completed', output: [{ id: 'fc_1', type: 'function_call', status: 'completed', call_id: 'call_1', name: 'get_weather' }] } })}`,
'',
'data: [DONE]',
'',
].join('\n')
const parsed = parseResponse(rawSse, requestBody, 'openai:responses')
// 命中同一 key,不重复渲染
expect(parsed.messages).toHaveLength(1)
expect(parsed.messages[0]?.content).toHaveLength(1)
expect(parsed.messages[0]?.content[0]).toMatchObject({
type: 'tool_use',
toolName: 'get_weather',
toolId: 'call_1',
input: '{"city":"SF"}',
})
})
it('renders HTML-entity encoded OpenAI tool arguments as formatted JSON', () => {
const requestBody = {
model: 'gpt-5.4',
@@ -313,11 +313,11 @@ export class OpenAIParser implements ApiFormatParser {
return createMessage(role, contentBlocks)
}
// function_call -> 工具调用
if (itemType === 'function_call') {
const toolId = String(item.call_id || item.id || '')
const toolName = String(item.name || '')
const args = String(item.arguments || '{}')
// Responses API call item -> 工具调用
if (this.isResponsesCallItemType(itemType)) {
const toolId = this.responsesCallId(item)
const toolName = this.responsesCallName(item)
const args = this.responsesCallInput(item)
return createMessage('assistant', [createToolUseBlock(toolId, toolName, args)])
}
@@ -439,6 +439,14 @@ export class OpenAIParser implements ApiFormatParser {
if (contentBlocks.length > 0) {
result.messages.push(createMessage('assistant', contentBlocks))
}
} else if (item && this.isResponsesCallItemType(item.type)) {
result.messages.push(createMessage('assistant', [
createToolUseBlock(
this.responsesCallId(item),
this.responsesCallName(item),
this.responsesCallInput(item)
),
]))
}
}
@@ -566,8 +574,39 @@ export class OpenAIParser implements ApiFormatParser {
const textParts: string[] = []
const toolCalls = new Map<string, { name: string; id: string; args: string[] }>()
let currentToolId = ''
let currentToolName = ''
const outputIndexToToolKey = new Map<number, string>()
let currentToolKey = ''
const ensureToolCall = (
key: string,
id: string,
name: string,
initialInput?: string
) => {
if (!key) return
const existing = toolCalls.get(key)
if (existing) {
if (id) existing.id = id
if (name) existing.name = name
if (initialInput) existing.args = [initialInput]
return
}
toolCalls.set(key, {
name,
id,
args: initialInput ? [initialInput] : [],
})
}
const resolveToolKey = (chunk: RawObject): string => {
const itemId = typeof chunk.item_id === 'string' ? chunk.item_id : ''
if (itemId) return itemId
const outputIndex = typeof chunk.output_index === 'number' ? chunk.output_index : null
if (outputIndex != null) {
return outputIndexToToolKey.get(outputIndex) || currentToolKey
}
return currentToolKey
}
for (const rawChunk of chunks) {
const chunk = rawChunk as RawObject
@@ -596,28 +635,55 @@ export class OpenAIParser implements ApiFormatParser {
continue
}
// 处理函数调用输出项添加: response.output_item.added
if (eventType === 'response.output_item.added') {
// 处理 Responses call 输出项添加/完成: response.output_item.added / done
if (eventType === 'response.output_item.added' || eventType === 'response.output_item.done') {
const item = chunk.item as RawObject | undefined
if (item?.type === 'function_call') {
currentToolId = String(item.call_id || item.id || '')
currentToolName = String(item.name || '')
if (currentToolId && !toolCalls.has(currentToolId)) {
toolCalls.set(currentToolId, {
name: currentToolName,
id: currentToolId,
args: [],
})
if (item && this.isResponsesCallItemType(item.type)) {
const itemId = typeof item.id === 'string' ? item.id : ''
const toolId = this.responsesCallId(item)
const key = itemId || toolId || String(chunk.output_index ?? '')
const input = eventType === 'response.output_item.done' && this.responsesCallHasInput(item)
? this.responsesCallInput(item)
: ''
ensureToolCall(key, toolId, this.responsesCallName(item), input)
currentToolKey = key
if (typeof chunk.output_index === 'number') {
outputIndexToToolKey.set(chunk.output_index, key)
}
}
continue
}
// 处理函数调用参数增量: response.function_call_arguments.delta
if (eventType === 'response.function_call_arguments.delta') {
// 处理已知 call 输入增量
if (
eventType === 'response.function_call_arguments.delta' ||
eventType === 'response.custom_tool_call_input.delta'
) {
const delta = chunk.delta
if (typeof delta === 'string' && currentToolId && toolCalls.has(currentToolId)) {
toolCalls.get(currentToolId)?.args.push(delta)
const key = resolveToolKey(chunk)
if (typeof delta === 'string' && key && toolCalls.has(key)) {
toolCalls.get(key)?.args.push(delta)
}
continue
}
if (eventType === 'response.function_call_arguments.done') {
const key = resolveToolKey(chunk)
const args = typeof chunk.arguments === 'string'
? chunk.arguments
: typeof chunk.delta === 'string'
? chunk.delta
: null
if (key && toolCalls.has(key) && args != null) {
toolCalls.get(key)!.args = [args]
}
continue
}
if (eventType === 'response.custom_tool_call_input.done') {
const key = resolveToolKey(chunk)
if (key && toolCalls.has(key) && typeof chunk.input === 'string') {
toolCalls.get(key)!.args = [chunk.input]
}
continue
}
@@ -630,17 +696,29 @@ export class OpenAIParser implements ApiFormatParser {
result.model = response.model
}
// 从 output 中提取文本(备用方案)
if (textParts.length === 0 && Array.isArray(response?.output)) {
for (const rawItem of response.output as unknown[]) {
const item = rawItem as RawObject
if (item?.type === 'message' && Array.isArray(item?.content)) {
// 从 output 中提取文本和工具调用(备用方案)
if (Array.isArray(response?.output)) {
const output = response.output as unknown[]
for (let index = 0; index < output.length; index++) {
const item = output[index] as RawObject
if (textParts.length === 0 && item?.type === 'message' && Array.isArray(item?.content)) {
for (const rawContent of item.content as unknown[]) {
const content = rawContent as RawObject
if (content?.type === 'output_text' && typeof content?.text === 'string') {
textParts.push(content.text)
}
}
} else if (this.isResponsesCallItemType(item.type)) {
const itemId = typeof item.id === 'string' ? item.id : ''
const toolId = this.responsesCallId(item)
// 与流式阶段使用同一套 key 命中同一条工具调用,避免重复渲染
const key = itemId || toolId || outputIndexToToolKey.get(index) || String(index)
// 仅在最终项确实带有输入时才覆盖,避免用 '{}' 等默认值
// 冲掉已通过增量事件收集到的参数
const input = this.responsesCallHasInput(item)
? this.responsesCallInput(item)
: ''
ensureToolCall(key, toolId, this.responsesCallName(item), input)
}
}
}
@@ -731,6 +809,49 @@ export class OpenAIParser implements ApiFormatParser {
return createMessage(role, contentBlocks)
}
private isResponsesCallItemType(itemType: unknown): boolean {
return typeof itemType === 'string' && itemType.endsWith('_call')
}
private responsesCallId(item: RawObject): string {
return String(item.call_id || item.id || '')
}
private responsesCallName(item: RawObject): string {
const name = typeof item.name === 'string' ? item.name.trim() : ''
if (name) return name
return typeof item.type === 'string' ? item.type : 'tool_call'
}
private responsesCallInputCandidate(item: RawObject): unknown {
if (item.type === 'function_call') return item.arguments
if (item.type === 'custom_tool_call') return item.input
for (const key of ['input', 'arguments', 'action', 'query', 'code', 'prompt']) {
if (item[key] != null) return item[key]
}
return undefined
}
private responsesCallInput(item: RawObject): string {
const input = this.responsesCallInputCandidate(item)
if (typeof input === 'string') return input
if (input == null) {
if (item.type === 'function_call') return '{}'
if (item.type === 'custom_tool_call') return ''
return JSON.stringify(item, null, 2)
}
return JSON.stringify(input, null, 2)
}
private responsesCallHasInput(item: RawObject): boolean {
const input = this.responsesCallInputCandidate(item)
if (input == null) {
return item.type !== 'function_call' && item.type !== 'custom_tool_call'
}
if (typeof input === 'string') return input.length > 0
return true
}
/**
* 映射角色
*/
@@ -887,12 +1008,12 @@ export class OpenAIParser implements ApiFormatParser {
return createMessageBlock(role, contentBlocks, { roleLabel: this.getRoleLabel(role) })
}
// function_call -> 工具调用
if (itemType === 'function_call') {
const toolName = String(item.name || '工具调用')
const args = this.formatJson(item.arguments)
// Responses API call item -> 工具调用
if (this.isResponsesCallItemType(itemType)) {
const toolName = this.responsesCallName(item)
const args = this.formatJson(this.responsesCallInput(item))
return createMessageBlock('assistant', [
createToolUseRenderBlock(toolName, args, String(item.call_id || item.id || '')),
createToolUseRenderBlock(toolName, args, this.responsesCallId(item)),
], { roleLabel: 'Assistant', badges: [createBadgeBlock('工具调用', 'outline')] })
}
@@ -1015,6 +1136,17 @@ export class OpenAIParser implements ApiFormatParser {
roleLabel: 'Assistant',
}))
}
} else if (this.isResponsesCallItemType(item.type)) {
blocks.push(createMessageBlock('assistant', [
createToolUseRenderBlock(
this.responsesCallName(item),
this.formatJson(this.responsesCallInput(item)),
this.responsesCallId(item)
),
], {
roleLabel: 'Assistant',
badges: [createBadgeBlock('工具调用', 'outline')],
}))
}
}