mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
Merge pull request #1053 from leehom0123/fix/openai-responses-read-image
fix(proxy): 保留 Responses 工具结果中的图片
This commit is contained in:
commit
5058efa44a
@ -554,12 +554,151 @@ describe('anthropicToOpenaiResponses', () => {
|
|||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
const result = anthropicToOpenaiResponses(req)
|
const result = anthropicToOpenaiResponses(req)
|
||||||
const fco = result.input.find((i) => i.type === 'function_call_output')
|
expect(result.input).toEqual([{
|
||||||
expect(fco).toBeDefined()
|
type: 'function_call_output',
|
||||||
if (fco && fco.type === 'function_call_output') {
|
call_id: 'tc_1',
|
||||||
expect(fco.call_id).toBe('tc_1')
|
output: 'found it',
|
||||||
expect(fco.output).toBe('found it')
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves text-only tool_result arrays as strings', () => {
|
||||||
|
const req: AnthropicRequest = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tc_2',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'first' },
|
||||||
|
{ type: 'text', text: 'second' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = anthropicToOpenaiResponses(req)
|
||||||
|
|
||||||
|
expect(result.input).toEqual([{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'tc_2',
|
||||||
|
output: 'first\nsecond',
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves image-only tool_result content', () => {
|
||||||
|
const req: AnthropicRequest = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'read_1',
|
||||||
|
content: [{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: 'image/png', data: 'AAECAwQ=' },
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = anthropicToOpenaiResponses(req)
|
||||||
|
|
||||||
|
expect(result.input).toEqual([{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'read_1',
|
||||||
|
output: [{
|
||||||
|
type: 'input_image',
|
||||||
|
image_url: 'data:image/png;base64,AAECAwQ=',
|
||||||
|
}],
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves mixed tool_result content in order', () => {
|
||||||
|
const req: AnthropicRequest = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'read_2',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'before' },
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/AA==' },
|
||||||
|
},
|
||||||
|
{ type: 'text', text: 'after' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = anthropicToOpenaiResponses(req)
|
||||||
|
|
||||||
|
expect(result.input).toEqual([{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'read_2',
|
||||||
|
output: [
|
||||||
|
{ type: 'input_text', text: 'before' },
|
||||||
|
{ type: 'input_image', image_url: 'data:image/jpeg;base64,/9j/AA==' },
|
||||||
|
{ type: 'input_text', text: 'after' },
|
||||||
|
],
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves message and tool_result item order', () => {
|
||||||
|
const req: AnthropicRequest = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'before result' },
|
||||||
|
{ type: 'tool_result', tool_use_id: 'read_3', content: 'result' },
|
||||||
|
{ type: 'text', text: 'after result' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = anthropicToOpenaiResponses(req)
|
||||||
|
|
||||||
|
expect(result.input).toEqual([
|
||||||
|
{ type: 'message', role: 'user', content: 'before result' },
|
||||||
|
{ type: 'function_call_output', call_id: 'read_3', output: 'result' },
|
||||||
|
{ type: 'message', role: 'user', content: 'after result' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves mixed text and image message content', () => {
|
||||||
|
const req: AnthropicRequest = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Describe this image' },
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: 'image/png', data: 'AAECAwQ=' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = anthropicToOpenaiResponses(req)
|
||||||
|
|
||||||
|
expect(result.input).toEqual([{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'input_text', text: 'Describe this image' },
|
||||||
|
{ type: 'input_image', image_url: 'data:image/png;base64,AAECAwQ=' },
|
||||||
|
],
|
||||||
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('thinking → reasoning', () => {
|
test('thinking → reasoning', () => {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type {
|
|||||||
AnthropicMessage,
|
AnthropicMessage,
|
||||||
OpenAIResponsesRequest,
|
OpenAIResponsesRequest,
|
||||||
OpenAIResponsesInputItem,
|
OpenAIResponsesInputItem,
|
||||||
OpenAIChatContentPart,
|
OpenAIResponsesInputContentPart,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
import { stripLeadingBillingHeader } from './billingHeader.js'
|
import { stripLeadingBillingHeader } from './billingHeader.js'
|
||||||
import { normalizeOpenAIReasoningEffort } from './effort.js'
|
import { normalizeOpenAIReasoningEffort } from './effort.js'
|
||||||
@ -116,6 +116,19 @@ export function anthropicToOpenaiResponses(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertContentBlock(
|
||||||
|
block: Extract<AnthropicContentBlock, { type: 'text' | 'image' }>,
|
||||||
|
): OpenAIResponsesInputContentPart {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
return { type: 'input_text', text: block.text }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'input_image',
|
||||||
|
image_url: `data:${block.source.media_type};base64,${block.source.data}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIResponsesInputItem[]): void {
|
function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIResponsesInputItem[]): void {
|
||||||
const content = msg.content
|
const content = msg.content
|
||||||
|
|
||||||
@ -131,27 +144,28 @@ function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIRespons
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Collect text/image parts and handle tool blocks separately
|
// Collect text/image parts and handle tool blocks separately
|
||||||
const contentParts: (string | OpenAIChatContentPart)[] = []
|
const contentParts: OpenAIResponsesInputContentPart[] = []
|
||||||
|
|
||||||
|
const flushContentParts = (): void => {
|
||||||
|
if (contentParts.length === 0) return
|
||||||
|
|
||||||
|
if (contentParts.every((part) => part.type === 'input_text')) {
|
||||||
|
const messageContent = contentParts.map((part) => part.text).join('')
|
||||||
|
if (messageContent) {
|
||||||
|
output.push({ type: 'message', role: msg.role, content: messageContent })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.push({ type: 'message', role: msg.role, content: [...contentParts] })
|
||||||
|
}
|
||||||
|
contentParts.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (block.type === 'text') {
|
if (block.type === 'text' || block.type === 'image') {
|
||||||
contentParts.push(block.text)
|
contentParts.push(convertContentBlock(block))
|
||||||
} else if (block.type === 'image') {
|
|
||||||
contentParts.push({
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` },
|
|
||||||
})
|
|
||||||
} else if (block.type === 'tool_use') {
|
} else if (block.type === 'tool_use') {
|
||||||
// Flush any accumulated content first
|
// Flush any accumulated content first
|
||||||
if (contentParts.length > 0) {
|
flushContentParts()
|
||||||
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
|
|
||||||
? contentParts[0]
|
|
||||||
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
|
|
||||||
if (flatContent) {
|
|
||||||
output.push({ type: 'message', role: msg.role, content: flatContent })
|
|
||||||
}
|
|
||||||
contentParts.length = 0
|
|
||||||
}
|
|
||||||
// Lift to function_call item
|
// Lift to function_call item
|
||||||
output.push({
|
output.push({
|
||||||
type: 'function_call',
|
type: 'function_call',
|
||||||
@ -160,30 +174,30 @@ function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIRespons
|
|||||||
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
|
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
|
||||||
})
|
})
|
||||||
} else if (block.type === 'tool_result') {
|
} else if (block.type === 'tool_result') {
|
||||||
|
// Flush any accumulated content first
|
||||||
|
flushContentParts()
|
||||||
// Lift to function_call_output item
|
// Lift to function_call_output item
|
||||||
const resultContent = typeof block.content === 'string'
|
const resultContent = typeof block.content === 'string'
|
||||||
? block.content
|
? block.content
|
||||||
: Array.isArray(block.content)
|
: Array.isArray(block.content)
|
||||||
? block.content.filter((b): b is Extract<AnthropicContentBlock, { type: 'text' }> => b.type === 'text').map((b) => b.text).join('\n')
|
? block.content.filter((part): part is Extract<AnthropicContentBlock, { type: 'text' | 'image' }> => (
|
||||||
|
part.type === 'text' || part.type === 'image'
|
||||||
|
)).map(convertContentBlock)
|
||||||
: ''
|
: ''
|
||||||
|
const resultOutput = Array.isArray(resultContent) && resultContent.every((part) => part.type === 'input_text')
|
||||||
|
? resultContent.map((part) => part.text).join('\n')
|
||||||
|
: resultContent
|
||||||
output.push({
|
output.push({
|
||||||
type: 'function_call_output',
|
type: 'function_call_output',
|
||||||
call_id: block.tool_use_id,
|
call_id: block.tool_use_id,
|
||||||
output: resultContent,
|
output: resultOutput,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Skip thinking blocks
|
// Skip thinking blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush remaining content
|
// Flush remaining content
|
||||||
if (contentParts.length > 0) {
|
flushContentParts()
|
||||||
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
|
|
||||||
? contentParts[0]
|
|
||||||
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
|
|
||||||
if (flatContent) {
|
|
||||||
output.push({ type: 'message', role: msg.role, content: flatContent })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertToolChoice(choice: unknown): unknown {
|
function convertToolChoice(choice: unknown): unknown {
|
||||||
|
|||||||
@ -117,10 +117,14 @@ export type OpenAIChatStreamChunk = {
|
|||||||
|
|
||||||
// ─── OpenAI Responses API ───────────────────────────────────
|
// ─── OpenAI Responses API ───────────────────────────────────
|
||||||
|
|
||||||
|
export type OpenAIResponsesInputContentPart =
|
||||||
|
| { type: 'input_text'; text: string }
|
||||||
|
| { type: 'input_image'; image_url: string }
|
||||||
|
|
||||||
export type OpenAIResponsesInputItem =
|
export type OpenAIResponsesInputItem =
|
||||||
| { type: 'message'; role: 'user' | 'assistant' | 'system'; content: string | OpenAIChatContentPart[] }
|
| { type: 'message'; role: 'user' | 'assistant' | 'system'; content: string | OpenAIResponsesInputContentPart[] }
|
||||||
| { type: 'function_call'; call_id: string; name: string; arguments: unknown }
|
| { type: 'function_call'; call_id: string; name: string; arguments: unknown }
|
||||||
| { type: 'function_call_output'; call_id: string; output: string }
|
| { type: 'function_call_output'; call_id: string; output: string | OpenAIResponsesInputContentPart[] }
|
||||||
|
|
||||||
export type OpenAIResponsesRequest = {
|
export type OpenAIResponsesRequest = {
|
||||||
model: string
|
model: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user