Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,710 @@
|
|||
// Package openai provides utilities to translate OpenAI Chat Completions
|
||||
// request JSON into OpenAI Responses API request JSON using gjson/sjson.
|
||||
// It supports tools, multimodal text/image inputs, and Structured Outputs.
|
||||
// The package handles the conversion of OpenAI API requests into the format
|
||||
// expected by the OpenAI Responses API, including proper mapping of messages,
|
||||
// tools, and generation parameters.
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertOpenAIRequestToCodex converts an OpenAI Chat Completions request JSON
|
||||
// into an OpenAI Responses API request JSON. The transformation follows the
|
||||
// examples defined in docs/2.md exactly, including tools, multi-turn dialog,
|
||||
// multimodal text/image handling, and Structured Outputs mapping.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model to use for the request
|
||||
// - rawJSON: The raw JSON request data from the OpenAI Chat Completions API
|
||||
// - stream: A boolean indicating if the request is for a streaming response
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The transformed request data in OpenAI Responses API format
|
||||
func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
tools := root.Get("tools")
|
||||
toolResults := tools.Array()
|
||||
// Start with empty JSON object
|
||||
out := []byte(`{"instructions":""}`)
|
||||
|
||||
// Stream must be set to true
|
||||
out, _ = sjson.SetBytes(out, "stream", stream)
|
||||
|
||||
// Codex not support temperature, top_p, top_k, max_output_tokens, so comment them
|
||||
// if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() {
|
||||
// out, _ = sjson.SetBytes(out, "temperature", v.Value())
|
||||
// }
|
||||
// if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() {
|
||||
// out, _ = sjson.SetBytes(out, "top_p", v.Value())
|
||||
// }
|
||||
// if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() {
|
||||
// out, _ = sjson.SetBytes(out, "top_k", v.Value())
|
||||
// }
|
||||
|
||||
// Map token limits
|
||||
// if v := gjson.GetBytes(rawJSON, "max_tokens"); v.Exists() {
|
||||
// out, _ = sjson.SetBytes(out, "max_output_tokens", v.Value())
|
||||
// }
|
||||
// if v := gjson.GetBytes(rawJSON, "max_completion_tokens"); v.Exists() {
|
||||
// out, _ = sjson.SetBytes(out, "max_output_tokens", v.Value())
|
||||
// }
|
||||
|
||||
// Map reasoning effort
|
||||
if v := gjson.GetBytes(rawJSON, "reasoning_effort"); v.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "reasoning.effort", v.Value())
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "reasoning.effort", "medium")
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, "parallel_tool_calls", true)
|
||||
// OpenAI documents reasoning summaries as explicit opt-in output. Leave
|
||||
// reasoning.summary to the source request's canonical summary intent instead
|
||||
// of coupling it to reasoning effort.
|
||||
out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"})
|
||||
|
||||
// Model
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
// Build request-local tool metadata and name shortening map.
|
||||
originalToolNameMap := map[string]string{}
|
||||
customToolNames := map[string]struct{}{}
|
||||
functionToolNames := map[string]struct{}{}
|
||||
{
|
||||
if tools.IsArray() && len(toolResults) > 0 {
|
||||
var names []string
|
||||
seenNames := map[string]struct{}{}
|
||||
for _, tool := range toolResults {
|
||||
var name string
|
||||
switch tool.Get("type").String() {
|
||||
case "function":
|
||||
name = tool.Get("function.name").String()
|
||||
functionToolNames[name] = struct{}{}
|
||||
case "custom":
|
||||
name = tool.Get("name").String()
|
||||
customToolNames[name] = struct{}{}
|
||||
}
|
||||
if name != "" {
|
||||
if _, seen := seenNames[name]; !seen {
|
||||
names = append(names, name)
|
||||
seenNames[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
originalToolNameMap = buildShortNameMap(names)
|
||||
}
|
||||
// A normalized function envelope cannot disambiguate declarations that share a name.
|
||||
// Preserve function behavior for such ambiguous names.
|
||||
for name := range functionToolNames {
|
||||
delete(customToolNames, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveToolCall := func(toolCall gjson.Result) (callType, name, input string, valid bool) {
|
||||
switch toolCall.Get("type").String() {
|
||||
case "custom":
|
||||
return "custom", toolCall.Get("custom.name").String(), toolCall.Get("custom.input").String(), true
|
||||
case "function":
|
||||
name = toolCall.Get("function.name").String()
|
||||
callType = "function"
|
||||
if _, custom := customToolNames[name]; custom {
|
||||
callType = "custom"
|
||||
}
|
||||
return callType, name, toolCall.Get("function.arguments").String(), true
|
||||
default:
|
||||
return "", "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// Extract system instructions from first system message (string or text object)
|
||||
messages := gjson.GetBytes(rawJSON, "messages")
|
||||
type pendingToolCall struct {
|
||||
callID string
|
||||
sourceCallID string
|
||||
callType string
|
||||
consumed bool
|
||||
}
|
||||
var pendingToolCalls []pendingToolCall
|
||||
ambiguousToolCallIDs := map[string]struct{}{}
|
||||
// if messages.IsArray() {
|
||||
// arr := messages.Array()
|
||||
// for i := 0; i < len(arr); i++ {
|
||||
// m := arr[i]
|
||||
// if m.Get("role").String() == "system" {
|
||||
// c := m.Get("content")
|
||||
// if c.Type == gjson.String {
|
||||
// out, _ = sjson.SetBytes(out, "instructions", c.String())
|
||||
// } else if c.IsObject() && c.Get("type").String() == "text" {
|
||||
// out, _ = sjson.SetBytes(out, "instructions", c.Get("text").String())
|
||||
// }
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Build input from messages, handling all message types including tool calls
|
||||
out, _ = sjson.SetRawBytes(out, "input", []byte(`[]`))
|
||||
inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int())
|
||||
if messages.IsArray() {
|
||||
arr := messages.Array()
|
||||
for i := 0; i < len(arr); i++ {
|
||||
m := arr[i]
|
||||
role := m.Get("role").String()
|
||||
|
||||
switch role {
|
||||
case "tool":
|
||||
// Handle tool response messages as top-level tool call output objects.
|
||||
toolCallID := m.Get("tool_call_id").String()
|
||||
if _, ambiguous := ambiguousToolCallIDs[toolCallID]; toolCallID != "" && ambiguous {
|
||||
continue
|
||||
}
|
||||
|
||||
pendingIndex := -1
|
||||
for index := range pendingToolCalls {
|
||||
pendingCall := &pendingToolCalls[index]
|
||||
if pendingCall.consumed {
|
||||
continue
|
||||
}
|
||||
if toolCallID == "" || pendingCall.sourceCallID == toolCallID || pendingCall.callID == toolCallID {
|
||||
pendingIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if pendingIndex < 0 {
|
||||
continue
|
||||
}
|
||||
pendingCall := &pendingToolCalls[pendingIndex]
|
||||
pendingCall.consumed = true
|
||||
toolCallID = pendingCall.callID
|
||||
outputType := "function_call_output"
|
||||
if pendingCall.callType == "custom" {
|
||||
outputType = "custom_tool_call_output"
|
||||
}
|
||||
|
||||
toolOutput := []byte(`{}`)
|
||||
toolOutput, _ = sjson.SetBytes(toolOutput, "type", outputType)
|
||||
toolOutput, _ = sjson.SetBytes(toolOutput, "call_id", toolCallID)
|
||||
toolOutput = setToolCallOutputContent(toolOutput, m.Get("content"))
|
||||
inputItems = append(inputItems, toolOutput)
|
||||
|
||||
default:
|
||||
// A new conversational message starts a new tool-call batch.
|
||||
pendingToolCalls = nil
|
||||
ambiguousToolCallIDs = map[string]struct{}{}
|
||||
|
||||
// Handle regular messages
|
||||
msg := []byte(`{}`)
|
||||
msg, _ = sjson.SetBytes(msg, "type", "message")
|
||||
if role == "system" {
|
||||
msg, _ = sjson.SetBytes(msg, "role", "developer")
|
||||
} else {
|
||||
msg, _ = sjson.SetBytes(msg, "role", role)
|
||||
}
|
||||
|
||||
contentItems := make([][]byte, 0, 4)
|
||||
|
||||
// Handle regular content
|
||||
c := m.Get("content")
|
||||
if c.Exists() && c.Type == gjson.String && c.String() != "" {
|
||||
// Single string content
|
||||
partType := "input_text"
|
||||
if role == "assistant" {
|
||||
partType = "output_text"
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", partType)
|
||||
part, _ = sjson.SetBytes(part, "text", c.String())
|
||||
contentItems = append(contentItems, part)
|
||||
} else if c.Exists() && c.IsArray() {
|
||||
items := c.Array()
|
||||
for j := 0; j < len(items); j++ {
|
||||
it := items[j]
|
||||
t := it.Get("type").String()
|
||||
switch t {
|
||||
case "text":
|
||||
partType := "input_text"
|
||||
if role == "assistant" {
|
||||
partType = "output_text"
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", partType)
|
||||
part, _ = sjson.SetBytes(part, "text", it.Get("text").String())
|
||||
contentItems = append(contentItems, part)
|
||||
case "image_url":
|
||||
// Map image inputs to input_image for Responses API
|
||||
if role == "user" {
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_image")
|
||||
if u := it.Get("image_url.url"); u.Exists() {
|
||||
part, _ = sjson.SetBytes(part, "image_url", u.String())
|
||||
}
|
||||
contentItems = append(contentItems, part)
|
||||
}
|
||||
case "file":
|
||||
if role == "user" {
|
||||
fileData := it.Get("file.file_data").String()
|
||||
filename := it.Get("file.filename").String()
|
||||
if fileData != "" {
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_file")
|
||||
part, _ = sjson.SetBytes(part, "file_data", fileData)
|
||||
if filename != "" {
|
||||
part, _ = sjson.SetBytes(part, "filename", filename)
|
||||
}
|
||||
contentItems = append(contentItems, part)
|
||||
}
|
||||
}
|
||||
case "input_audio":
|
||||
if role == "user" {
|
||||
audioData := it.Get("input_audio.data").String()
|
||||
audioFormat := it.Get("input_audio.format").String()
|
||||
if audioData != "" {
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_audio")
|
||||
part, _ = sjson.SetBytes(part, "data", audioData)
|
||||
if audioFormat != "" {
|
||||
part, _ = sjson.SetBytes(part, "format", audioFormat)
|
||||
}
|
||||
contentItems = append(contentItems, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't emit empty assistant messages when only tool_calls
|
||||
// are present — Responses API needs function_call items
|
||||
// directly, otherwise call_id matching fails (#2132).
|
||||
if role != "assistant" || len(contentItems) > 0 {
|
||||
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
|
||||
inputItems = append(inputItems, msg)
|
||||
}
|
||||
|
||||
// Handle tool calls for assistant messages as separate top-level objects
|
||||
if role == "assistant" {
|
||||
toolCalls := m.Get("tool_calls")
|
||||
if toolCalls.Exists() && toolCalls.IsArray() {
|
||||
toolCallsArr := toolCalls.Array()
|
||||
callIDCounts := map[string]int{}
|
||||
usedCallIDs := map[string]struct{}{}
|
||||
for _, tc := range toolCallsArr {
|
||||
_, _, _, valid := resolveToolCall(tc)
|
||||
callID := tc.Get("id").String()
|
||||
if valid && callID != "" {
|
||||
callIDCounts[callID]++
|
||||
usedCallIDs[callID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for callID, count := range callIDCounts {
|
||||
if count > 1 {
|
||||
ambiguousToolCallIDs[callID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for j := 0; j < len(toolCallsArr); j++ {
|
||||
tc := toolCallsArr[j]
|
||||
toolCallType, toolCallName, toolCallInput, valid := resolveToolCall(tc)
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
sourceCallID := tc.Get("id").String()
|
||||
if _, ambiguous := ambiguousToolCallIDs[sourceCallID]; sourceCallID != "" && ambiguous {
|
||||
continue
|
||||
}
|
||||
callID := sourceCallID
|
||||
if callID == "" {
|
||||
baseCallID := "call_missing_" + strconv.Itoa(i) + "_" + strconv.Itoa(j)
|
||||
callID = baseCallID
|
||||
for suffix := 1; ; suffix++ {
|
||||
if _, used := usedCallIDs[callID]; !used {
|
||||
break
|
||||
}
|
||||
callID = baseCallID + "_" + strconv.Itoa(suffix)
|
||||
}
|
||||
usedCallIDs[callID] = struct{}{}
|
||||
}
|
||||
pendingToolCalls = append(pendingToolCalls, pendingToolCall{
|
||||
callID: callID,
|
||||
sourceCallID: sourceCallID,
|
||||
callType: toolCallType,
|
||||
})
|
||||
|
||||
switch toolCallType {
|
||||
case "function":
|
||||
// Create function_call as top-level object
|
||||
funcCall := []byte(`{}`)
|
||||
funcCall, _ = sjson.SetBytes(funcCall, "type", "function_call")
|
||||
funcCall, _ = sjson.SetBytes(funcCall, "call_id", callID)
|
||||
if short, ok := originalToolNameMap[toolCallName]; ok {
|
||||
toolCallName = short
|
||||
} else {
|
||||
toolCallName = shortenNameIfNeeded(toolCallName)
|
||||
}
|
||||
funcCall, _ = sjson.SetBytes(funcCall, "name", toolCallName)
|
||||
funcCall, _ = sjson.SetBytes(funcCall, "arguments", toolCallInput)
|
||||
inputItems = append(inputItems, funcCall)
|
||||
case "custom":
|
||||
customCall := []byte(`{}`)
|
||||
customCall, _ = sjson.SetBytes(customCall, "type", "custom_tool_call")
|
||||
customCall, _ = sjson.SetBytes(customCall, "call_id", callID)
|
||||
if short, ok := originalToolNameMap[toolCallName]; ok {
|
||||
toolCallName = short
|
||||
} else {
|
||||
toolCallName = shortenNameIfNeeded(toolCallName)
|
||||
}
|
||||
customCall, _ = sjson.SetBytes(customCall, "name", toolCallName)
|
||||
customCall, _ = sjson.SetBytes(customCall, "input", toolCallInput)
|
||||
inputItems = append(inputItems, customCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
|
||||
|
||||
// Map response_format and text settings to Responses API text.format
|
||||
rf := gjson.GetBytes(rawJSON, "response_format")
|
||||
text := gjson.GetBytes(rawJSON, "text")
|
||||
if rf.Exists() {
|
||||
// Always create text object when response_format provided
|
||||
if !gjson.GetBytes(out, "text").Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "text", []byte(`{}`))
|
||||
}
|
||||
|
||||
rft := rf.Get("type").String()
|
||||
switch rft {
|
||||
case "text":
|
||||
out, _ = sjson.SetBytes(out, "text.format.type", "text")
|
||||
case "json_schema":
|
||||
js := rf.Get("json_schema")
|
||||
if js.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "text.format.type", "json_schema")
|
||||
if v := js.Get("name"); v.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "text.format.name", v.Value())
|
||||
}
|
||||
if v := js.Get("strict"); v.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "text.format.strict", v.Value())
|
||||
}
|
||||
if v := js.Get("schema"); v.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "text.format.schema", []byte(v.Raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map verbosity if provided
|
||||
if text.Exists() {
|
||||
if v := text.Get("verbosity"); v.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "text.verbosity", v.Value())
|
||||
}
|
||||
}
|
||||
} else if text.Exists() {
|
||||
// If only text.verbosity present (no response_format), map verbosity
|
||||
if v := text.Get("verbosity"); v.Exists() {
|
||||
if !gjson.GetBytes(out, "text").Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "text", []byte(`{}`))
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, "text.verbosity", v.Value())
|
||||
}
|
||||
}
|
||||
|
||||
// Map tools (flatten function fields)
|
||||
if tools.IsArray() && len(toolResults) > 0 {
|
||||
toolItems := make([][]byte, 0, len(toolResults))
|
||||
arr := toolResults
|
||||
for i := 0; i < len(arr); i++ {
|
||||
t := arr[i]
|
||||
toolType := t.Get("type").String()
|
||||
if toolType == "custom" {
|
||||
item := []byte(t.Raw)
|
||||
name := t.Get("name").String()
|
||||
if short, ok := originalToolNameMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
}
|
||||
item, _ = sjson.SetBytes(item, "name", name)
|
||||
toolItems = append(toolItems, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Pass through built-in tools (e.g. {"type":"web_search"}) directly for the Responses API.
|
||||
// Only function and custom tools need structural conversion.
|
||||
if toolType != "" && toolType != "function" && t.IsObject() {
|
||||
toolItems = append(toolItems, []byte(t.Raw))
|
||||
continue
|
||||
}
|
||||
|
||||
if toolType == "function" {
|
||||
item := []byte(`{}`)
|
||||
item, _ = sjson.SetBytes(item, "type", "function")
|
||||
fn := t.Get("function")
|
||||
if fn.Exists() {
|
||||
if v := fn.Get("name"); v.Exists() {
|
||||
name := v.String()
|
||||
if short, ok := originalToolNameMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
}
|
||||
item, _ = sjson.SetBytes(item, "name", name)
|
||||
}
|
||||
if v := fn.Get("description"); v.Exists() {
|
||||
item, _ = sjson.SetBytes(item, "description", v.Value())
|
||||
}
|
||||
if v := fn.Get("parameters"); v.Exists() {
|
||||
item, _ = sjson.SetRawBytes(item, "parameters", []byte(v.Raw))
|
||||
}
|
||||
if v := fn.Get("strict"); v.Exists() {
|
||||
item, _ = sjson.SetBytes(item, "strict", v.Value())
|
||||
}
|
||||
}
|
||||
toolItems = append(toolItems, item)
|
||||
}
|
||||
}
|
||||
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
|
||||
// Map tool_choice when present.
|
||||
// Chat Completions: "tool_choice" can be a string ("auto"/"none") or an object (e.g. {"type":"function","function":{"name":"..."}}).
|
||||
// Responses API: keep built-in tool choices as-is and flatten named choices to {"type":"...","name":"..."}.
|
||||
if tc := gjson.GetBytes(rawJSON, "tool_choice"); tc.Exists() {
|
||||
switch {
|
||||
case tc.Type == gjson.String:
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", tc.String())
|
||||
case tc.IsObject():
|
||||
tcType := tc.Get("type").String()
|
||||
if tcType == "function" || tcType == "custom" {
|
||||
name := tc.Get("name").String()
|
||||
if tcType == "function" {
|
||||
name = tc.Get("function.name").String()
|
||||
if _, custom := customToolNames[name]; custom {
|
||||
tcType = "custom"
|
||||
}
|
||||
}
|
||||
if name != "" {
|
||||
if short, ok := originalToolNameMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
}
|
||||
}
|
||||
choice := []byte(`{}`)
|
||||
choice, _ = sjson.SetBytes(choice, "type", tcType)
|
||||
if name != "" {
|
||||
choice, _ = sjson.SetBytes(choice, "name", name)
|
||||
}
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
|
||||
} else if tcType != "" {
|
||||
// Built-in tool choices (e.g. {"type":"web_search"}) are already Responses-compatible.
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(tc.Raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out, _ = sjson.SetBytes(out, "store", false)
|
||||
return out
|
||||
}
|
||||
|
||||
func setToolCallOutputContent(funcOutput []byte, content gjson.Result) []byte {
|
||||
switch {
|
||||
case content.Type == gjson.String:
|
||||
structuredContent := gjson.Parse(content.String())
|
||||
if hasToolOutputImagePart(structuredContent) {
|
||||
return setToolCallOutputContent(funcOutput, structuredContent)
|
||||
}
|
||||
funcOutput, _ = sjson.SetBytes(funcOutput, "output", content.String())
|
||||
case content.IsArray():
|
||||
outputItems := make([][]byte, 0, 4)
|
||||
for _, item := range content.Array() {
|
||||
outputItems = append(outputItems, toolOutputContentPart(item))
|
||||
}
|
||||
funcOutput, _ = sjson.SetRawBytes(funcOutput, "output", translatorcommon.JoinRawArray(outputItems))
|
||||
default:
|
||||
fallbackOutput := content.Raw
|
||||
if fallbackOutput == "" {
|
||||
fallbackOutput = content.String()
|
||||
}
|
||||
funcOutput, _ = sjson.SetBytes(funcOutput, "output", fallbackOutput)
|
||||
}
|
||||
return funcOutput
|
||||
}
|
||||
|
||||
func toolOutputContentPart(item gjson.Result) []byte {
|
||||
itemType := item.Get("type").String()
|
||||
switch itemType {
|
||||
case "text", "input_text", "output_text":
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_text")
|
||||
part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
|
||||
return part
|
||||
case "image_url", "input_image":
|
||||
imageURL := item.Get("image_url.url").String()
|
||||
fileID := item.Get("image_url.file_id").String()
|
||||
if itemType == "input_image" {
|
||||
imageURL = item.Get("image_url").String()
|
||||
fileID = item.Get("file_id").String()
|
||||
}
|
||||
if imageURL == "" && fileID == "" {
|
||||
return toolOutputFallbackPart(item)
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_image")
|
||||
if imageURL != "" {
|
||||
part, _ = sjson.SetBytes(part, "image_url", imageURL)
|
||||
}
|
||||
if fileID != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_id", fileID)
|
||||
}
|
||||
detail := item.Get("image_url.detail").String()
|
||||
if itemType == "input_image" {
|
||||
detail = item.Get("detail").String()
|
||||
}
|
||||
if detail != "" {
|
||||
part, _ = sjson.SetBytes(part, "detail", detail)
|
||||
}
|
||||
return part
|
||||
case "file":
|
||||
fileID := item.Get("file.file_id").String()
|
||||
fileData := item.Get("file.file_data").String()
|
||||
fileURL := item.Get("file.file_url").String()
|
||||
if fileID == "" && fileData == "" && fileURL == "" {
|
||||
return toolOutputFallbackPart(item)
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_file")
|
||||
if fileID != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_id", fileID)
|
||||
}
|
||||
if fileData != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_data", fileData)
|
||||
}
|
||||
if fileURL != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_url", fileURL)
|
||||
}
|
||||
if filename := item.Get("file.filename").String(); filename != "" {
|
||||
part, _ = sjson.SetBytes(part, "filename", filename)
|
||||
}
|
||||
return part
|
||||
default:
|
||||
return toolOutputFallbackPart(item)
|
||||
}
|
||||
}
|
||||
|
||||
func hasToolOutputImagePart(content gjson.Result) bool {
|
||||
if !content.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, item := range content.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "image_url":
|
||||
if item.Get("image_url.url").String() != "" || item.Get("image_url.file_id").String() != "" {
|
||||
return true
|
||||
}
|
||||
case "input_image":
|
||||
if item.Get("image_url").String() != "" || item.Get("file_id").String() != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toolOutputFallbackPart(item gjson.Result) []byte {
|
||||
text := item.Raw
|
||||
if text == "" {
|
||||
text = item.String()
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_text")
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
return part
|
||||
}
|
||||
|
||||
// shortenNameIfNeeded applies the simple shortening rule for a single name.
|
||||
// If the name length exceeds 64, it will try to preserve the "mcp__" prefix and last segment.
|
||||
// Otherwise it truncates to 64 characters.
|
||||
func shortenNameIfNeeded(name string) string {
|
||||
const limit = 64
|
||||
if len(name) <= limit {
|
||||
return name
|
||||
}
|
||||
if strings.HasPrefix(name, "mcp__") {
|
||||
// Keep prefix and last segment after '__'
|
||||
idx := strings.LastIndex(name, "__")
|
||||
if idx > 0 {
|
||||
candidate := "mcp__" + name[idx+2:]
|
||||
if len(candidate) > limit {
|
||||
return candidate[:limit]
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return name[:limit]
|
||||
}
|
||||
|
||||
// buildShortNameMap generates unique short names (<=64) for the given list of names.
|
||||
// It preserves the "mcp__" prefix with the last segment when possible and ensures uniqueness
|
||||
// by appending suffixes like "~1", "~2" if needed.
|
||||
func buildShortNameMap(names []string) map[string]string {
|
||||
const limit = 64
|
||||
used := map[string]struct{}{}
|
||||
m := map[string]string{}
|
||||
|
||||
baseCandidate := func(n string) string {
|
||||
if len(n) <= limit {
|
||||
return n
|
||||
}
|
||||
if strings.HasPrefix(n, "mcp__") {
|
||||
idx := strings.LastIndex(n, "__")
|
||||
if idx > 0 {
|
||||
cand := "mcp__" + n[idx+2:]
|
||||
if len(cand) > limit {
|
||||
cand = cand[:limit]
|
||||
}
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return n[:limit]
|
||||
}
|
||||
|
||||
makeUnique := func(cand string) string {
|
||||
if _, ok := used[cand]; !ok {
|
||||
return cand
|
||||
}
|
||||
base := cand
|
||||
for i := 1; ; i++ {
|
||||
suffix := "_" + strconv.Itoa(i)
|
||||
allowed := limit - len(suffix)
|
||||
if allowed < 0 {
|
||||
allowed = 0
|
||||
}
|
||||
tmp := base
|
||||
if len(tmp) > allowed {
|
||||
tmp = tmp[:allowed]
|
||||
}
|
||||
tmp = tmp + suffix
|
||||
if _, ok := used[tmp]; !ok {
|
||||
return tmp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range names {
|
||||
cand := baseCandidate(n)
|
||||
uniq := makeUnique(cand)
|
||||
used[uniq] = struct{}{}
|
||||
m[n] = uniq
|
||||
}
|
||||
return m
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,654 @@
|
|||
// Package openai provides response translation functionality for Codex to OpenAI API compatibility.
|
||||
// This package handles the conversion of Codex API responses into OpenAI Chat Completions-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses into the format
|
||||
// expected by OpenAI API clients. It supports both streaming and non-streaming modes,
|
||||
// handling text content, tool calls, reasoning content, and usage metadata appropriately.
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
dataTag = []byte("data:")
|
||||
)
|
||||
|
||||
type toolCallStreamState struct {
|
||||
Index int
|
||||
ArgumentsEmitted bool
|
||||
Done bool
|
||||
}
|
||||
|
||||
// ConvertCliToOpenAIParams holds parameters for response conversion.
|
||||
type ConvertCliToOpenAIParams struct {
|
||||
ResponseID string
|
||||
CreatedAt int64
|
||||
Model string
|
||||
FunctionCallIndex int
|
||||
toolCallStates map[string]*toolCallStreamState
|
||||
currentToolCall *toolCallStreamState
|
||||
LastImageHashByItemID map[string][32]byte
|
||||
}
|
||||
|
||||
// ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the
|
||||
// Codex API format to the OpenAI Chat Completions streaming format.
|
||||
// It processes various Codex event types and transforms them into OpenAI-compatible JSON responses.
|
||||
// The function handles text content, tool calls, reasoning content, and usage metadata, outputting
|
||||
// responses that match the OpenAI API format. It supports incremental updates for streaming responses.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response
|
||||
// - rawJSON: The raw JSON response from the Codex API
|
||||
// - param: A pointer to a parameter object for maintaining state between calls
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of OpenAI-compatible JSON responses
|
||||
func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &ConvertCliToOpenAIParams{
|
||||
Model: modelName,
|
||||
CreatedAt: 0,
|
||||
ResponseID: "",
|
||||
FunctionCallIndex: -1,
|
||||
toolCallStates: make(map[string]*toolCallStreamState),
|
||||
LastImageHashByItemID: make(map[string][32]byte),
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(rawJSON, dataTag) {
|
||||
return [][]byte{}
|
||||
}
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
|
||||
// Initialize the OpenAI SSE template.
|
||||
template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":null}]}`)
|
||||
|
||||
rootResult := gjson.ParseBytes(rawJSON)
|
||||
|
||||
typeResult := rootResult.Get("type")
|
||||
dataType := typeResult.String()
|
||||
if dataType == "response.created" {
|
||||
(*param).(*ConvertCliToOpenAIParams).ResponseID = rootResult.Get("response.id").String()
|
||||
(*param).(*ConvertCliToOpenAIParams).CreatedAt = rootResult.Get("response.created_at").Int()
|
||||
(*param).(*ConvertCliToOpenAIParams).Model = rootResult.Get("response.model").String()
|
||||
if (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID == nil {
|
||||
(*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID = make(map[string][32]byte)
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// Extract and set the model version.
|
||||
cachedModel := (*param).(*ConvertCliToOpenAIParams).Model
|
||||
if modelResult := gjson.GetBytes(rawJSON, "model"); modelResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "model", modelResult.String())
|
||||
} else if cachedModel != "" {
|
||||
template, _ = sjson.SetBytes(template, "model", cachedModel)
|
||||
} else if modelName != "" {
|
||||
template, _ = sjson.SetBytes(template, "model", modelName)
|
||||
}
|
||||
|
||||
template, _ = sjson.SetBytes(template, "created", (*param).(*ConvertCliToOpenAIParams).CreatedAt)
|
||||
|
||||
// Extract and set the response ID.
|
||||
template, _ = sjson.SetBytes(template, "id", (*param).(*ConvertCliToOpenAIParams).ResponseID)
|
||||
|
||||
// Extract and set usage metadata (token counts).
|
||||
if usageResult := gjson.GetBytes(rawJSON, "response.usage"); usageResult.Exists() {
|
||||
if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens", outputTokensResult.Int())
|
||||
}
|
||||
if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokensResult.Int())
|
||||
}
|
||||
if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", inputTokensResult.Int())
|
||||
}
|
||||
if cachedTokensResult := usageResult.Get("input_tokens_details.cached_tokens"); cachedTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int())
|
||||
}
|
||||
if cacheWriteTokensResult := usageResult.Get("input_tokens_details.cache_write_tokens"); cacheWriteTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", cacheWriteTokensResult.Int())
|
||||
}
|
||||
if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
|
||||
}
|
||||
}
|
||||
|
||||
if dataType == "response.reasoning_summary_text.delta" {
|
||||
if deltaResult := rootResult.Get("delta"); deltaResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", deltaResult.String())
|
||||
}
|
||||
} else if dataType == "response.reasoning_summary_text.done" {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", "\n\n")
|
||||
} else if dataType == "response.output_text.delta" {
|
||||
if deltaResult := rootResult.Get("delta"); deltaResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.content", deltaResult.String())
|
||||
}
|
||||
} else if dataType == "response.image_generation_call.partial_image" {
|
||||
itemID := rootResult.Get("item_id").String()
|
||||
b64 := rootResult.Get("partial_image_b64").String()
|
||||
if b64 == "" {
|
||||
return [][]byte{}
|
||||
}
|
||||
if itemID != "" {
|
||||
p := (*param).(*ConvertCliToOpenAIParams)
|
||||
if p.LastImageHashByItemID == nil {
|
||||
p.LastImageHashByItemID = make(map[string][32]byte)
|
||||
}
|
||||
hash := sha256.Sum256([]byte(b64))
|
||||
if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash {
|
||||
return [][]byte{}
|
||||
}
|
||||
p.LastImageHashByItemID[itemID] = hash
|
||||
}
|
||||
|
||||
outputFormat := rootResult.Get("output_format").String()
|
||||
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
|
||||
imageURL := "data:" + mimeType + ";base64," + b64
|
||||
|
||||
imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
|
||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
|
||||
}
|
||||
imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
|
||||
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
|
||||
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
|
||||
} else if dataType == "response.completed" || dataType == "response.incomplete" {
|
||||
finishReason := "stop"
|
||||
nativeFinishReason := finishReason
|
||||
if dataType == "response.incomplete" {
|
||||
nativeFinishReason = rootResult.Get("response.incomplete_details.reason").String()
|
||||
switch nativeFinishReason {
|
||||
case "max_tokens", "max_output_tokens":
|
||||
finishReason = "length"
|
||||
case "content_filter":
|
||||
finishReason = "content_filter"
|
||||
}
|
||||
} else if (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex != -1 {
|
||||
finishReason = "tool_calls"
|
||||
nativeFinishReason = finishReason
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
|
||||
template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
|
||||
} else if dataType == "response.output_item.added" {
|
||||
itemResult := rootResult.Get("item")
|
||||
if !itemResult.Exists() || !isCodexToolCallType(itemResult.Get("type").String()) {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// Increment index for this new tool call item.
|
||||
p := (*param).(*ConvertCliToOpenAIParams)
|
||||
p.FunctionCallIndex++
|
||||
state := &toolCallStreamState{Index: p.FunctionCallIndex}
|
||||
registerToolCallState(p, rootResult, itemResult, state)
|
||||
|
||||
functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String())
|
||||
|
||||
// Restore original tool name if it was shortened.
|
||||
name := itemResult.Get("name").String()
|
||||
rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
|
||||
if orig, ok := rev[name]; ok {
|
||||
name = orig
|
||||
}
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", "")
|
||||
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
|
||||
|
||||
} else if dataType == "response.function_call_arguments.delta" || dataType == "response.custom_tool_call_input.delta" {
|
||||
p := (*param).(*ConvertCliToOpenAIParams)
|
||||
state := findToolCallState(p, rootResult, gjson.Result{})
|
||||
deltaValue := rootResult.Get("delta").String()
|
||||
if state == nil || state.Done || deltaValue == "" {
|
||||
return [][]byte{}
|
||||
}
|
||||
state.ArgumentsEmitted = true
|
||||
|
||||
functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", deltaValue)
|
||||
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
|
||||
|
||||
} else if dataType == "response.function_call_arguments.done" || dataType == "response.custom_tool_call_input.done" {
|
||||
p := (*param).(*ConvertCliToOpenAIParams)
|
||||
state := findToolCallState(p, rootResult, gjson.Result{})
|
||||
if state == nil || state.Done || state.ArgumentsEmitted {
|
||||
// Arguments were already streamed via delta events; nothing to emit.
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// Fallback: no delta events were received, emit the full arguments as a single chunk.
|
||||
fullArgsField := "arguments"
|
||||
if dataType == "response.custom_tool_call_input.done" {
|
||||
fullArgsField = "input"
|
||||
}
|
||||
state.ArgumentsEmitted = true
|
||||
fullArgs := rootResult.Get(fullArgsField).String()
|
||||
if fullArgs == "" {
|
||||
return [][]byte{}
|
||||
}
|
||||
functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fullArgs)
|
||||
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
|
||||
|
||||
} else if dataType == "response.output_item.done" {
|
||||
itemResult := rootResult.Get("item")
|
||||
if !itemResult.Exists() {
|
||||
return [][]byte{}
|
||||
}
|
||||
itemType := itemResult.Get("type").String()
|
||||
if itemType == "image_generation_call" {
|
||||
itemID := itemResult.Get("id").String()
|
||||
b64 := itemResult.Get("result").String()
|
||||
if b64 == "" {
|
||||
return [][]byte{}
|
||||
}
|
||||
if itemID != "" {
|
||||
p := (*param).(*ConvertCliToOpenAIParams)
|
||||
if p.LastImageHashByItemID == nil {
|
||||
p.LastImageHashByItemID = make(map[string][32]byte)
|
||||
}
|
||||
hash := sha256.Sum256([]byte(b64))
|
||||
if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash {
|
||||
return [][]byte{}
|
||||
}
|
||||
p.LastImageHashByItemID[itemID] = hash
|
||||
}
|
||||
|
||||
outputFormat := itemResult.Get("output_format").String()
|
||||
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
|
||||
imageURL := "data:" + mimeType + ";base64," + b64
|
||||
|
||||
imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
|
||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
|
||||
}
|
||||
imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
|
||||
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
|
||||
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
|
||||
return [][]byte{template}
|
||||
}
|
||||
if !isCodexToolCallType(itemType) {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
p := (*param).(*ConvertCliToOpenAIParams)
|
||||
state := findToolCallState(p, rootResult, itemResult)
|
||||
if state != nil {
|
||||
if state.Done {
|
||||
return [][]byte{}
|
||||
}
|
||||
state.Done = true
|
||||
if state.ArgumentsEmitted {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// The tool was announced, but no argument event arrived. Emit only the
|
||||
// completed arguments so the id and name are not duplicated.
|
||||
state.ArgumentsEmitted = true
|
||||
fullArgs := codexToolCallArguments(itemResult)
|
||||
if fullArgs == "" {
|
||||
return [][]byte{}
|
||||
}
|
||||
functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fullArgs)
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
|
||||
return [][]byte{template}
|
||||
}
|
||||
|
||||
// Fallback path: model skipped output_item.added, so emit the complete tool call now.
|
||||
p.FunctionCallIndex++
|
||||
state = &toolCallStreamState{Index: p.FunctionCallIndex, ArgumentsEmitted: true, Done: true}
|
||||
registerToolCallState(p, rootResult, itemResult, state)
|
||||
|
||||
functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
|
||||
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String())
|
||||
|
||||
// Restore original tool name if it was shortened.
|
||||
name := itemResult.Get("name").String()
|
||||
rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
|
||||
if orig, ok := rev[name]; ok {
|
||||
name = orig
|
||||
}
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name)
|
||||
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", codexToolCallArguments(itemResult))
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
|
||||
|
||||
} else {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
return [][]byte{template}
|
||||
}
|
||||
|
||||
// ConvertCodexResponseToOpenAINonStream converts a non-streaming Codex response to a non-streaming OpenAI response.
|
||||
// This function processes the complete Codex response and transforms it into a single OpenAI-compatible
|
||||
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
|
||||
// the information into a single response that matches the OpenAI API format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response (unused in current implementation)
|
||||
// - rawJSON: The raw JSON response from the Codex API
|
||||
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
|
||||
func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
rootResult := gjson.ParseBytes(rawJSON)
|
||||
// Verify this is a terminal response event.
|
||||
responseType := rootResult.Get("type").String()
|
||||
if responseType != "response.completed" && responseType != "response.incomplete" {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
unixTimestamp := time.Now().Unix()
|
||||
|
||||
responseResult := rootResult.Get("response")
|
||||
|
||||
template := []byte(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
|
||||
|
||||
// Extract and set the model version.
|
||||
if modelResult := responseResult.Get("model"); modelResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "model", modelResult.String())
|
||||
}
|
||||
|
||||
// Extract and set the creation timestamp.
|
||||
if createdAtResult := responseResult.Get("created_at"); createdAtResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "created", createdAtResult.Int())
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "created", unixTimestamp)
|
||||
}
|
||||
|
||||
// Extract and set the response ID.
|
||||
if idResult := responseResult.Get("id"); idResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "id", idResult.String())
|
||||
}
|
||||
|
||||
// Extract and set usage metadata (token counts).
|
||||
if usageResult := responseResult.Get("usage"); usageResult.Exists() {
|
||||
if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens", outputTokensResult.Int())
|
||||
}
|
||||
if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokensResult.Int())
|
||||
}
|
||||
if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", inputTokensResult.Int())
|
||||
}
|
||||
if cachedTokensResult := usageResult.Get("input_tokens_details.cached_tokens"); cachedTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int())
|
||||
}
|
||||
if cacheWriteTokensResult := usageResult.Get("input_tokens_details.cache_write_tokens"); cacheWriteTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", cacheWriteTokensResult.Int())
|
||||
}
|
||||
if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
|
||||
}
|
||||
}
|
||||
|
||||
// Process the output array for content and function calls
|
||||
var toolCalls [][]byte
|
||||
var images [][]byte
|
||||
outputResult := responseResult.Get("output")
|
||||
if outputResult.IsArray() {
|
||||
outputArray := outputResult.Array()
|
||||
var contentText string
|
||||
var reasoningText string
|
||||
|
||||
for _, outputItem := range outputArray {
|
||||
outputType := outputItem.Get("type").String()
|
||||
|
||||
switch outputType {
|
||||
case "reasoning":
|
||||
// Extract reasoning content from summary
|
||||
if summaryResult := outputItem.Get("summary"); summaryResult.IsArray() {
|
||||
summaryArray := summaryResult.Array()
|
||||
for _, summaryItem := range summaryArray {
|
||||
if summaryItem.Get("type").String() == "summary_text" {
|
||||
if text := summaryItem.Get("text").String(); text != "" {
|
||||
reasoningText += text
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case "message":
|
||||
// Extract message content
|
||||
if contentResult := outputItem.Get("content"); contentResult.IsArray() {
|
||||
contentArray := contentResult.Array()
|
||||
for _, contentItem := range contentArray {
|
||||
if contentItem.Get("type").String() == "output_text" {
|
||||
if text := contentItem.Get("text").String(); text != "" {
|
||||
contentText += text
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case "function_call", "custom_tool_call":
|
||||
// Handle function and custom tool call content.
|
||||
functionCallTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
|
||||
|
||||
if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() {
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", callIdResult.String())
|
||||
}
|
||||
|
||||
if nameResult := outputItem.Get("name"); nameResult.Exists() {
|
||||
n := nameResult.String()
|
||||
rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
|
||||
if orig, ok := rev[n]; ok {
|
||||
n = orig
|
||||
}
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", n)
|
||||
}
|
||||
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", codexToolCallArguments(outputItem))
|
||||
|
||||
toolCalls = append(toolCalls, functionCallTemplate)
|
||||
case "image_generation_call":
|
||||
b64 := outputItem.Get("result").String()
|
||||
if b64 == "" {
|
||||
break
|
||||
}
|
||||
outputFormat := outputItem.Get("output_format").String()
|
||||
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
|
||||
imageURL := "data:" + mimeType + ";base64," + b64
|
||||
|
||||
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images))
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
|
||||
images = append(images, imagePayload)
|
||||
}
|
||||
}
|
||||
|
||||
// Set content and reasoning content if found
|
||||
if contentText != "" {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.message.content", contentText)
|
||||
}
|
||||
|
||||
if reasoningText != "" {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.message.reasoning_content", reasoningText)
|
||||
}
|
||||
|
||||
// Add tool calls if any
|
||||
if len(toolCalls) > 0 {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
|
||||
}
|
||||
|
||||
// Add images if any
|
||||
if len(images) > 0 {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.message.images", translatorcommon.JoinRawArray(images))
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and set the finish reason based on status.
|
||||
if statusResult := responseResult.Get("status"); statusResult.Exists() {
|
||||
status := statusResult.String()
|
||||
finishReason := ""
|
||||
nativeFinishReason := ""
|
||||
switch status {
|
||||
case "completed":
|
||||
finishReason = "stop"
|
||||
nativeFinishReason = finishReason
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
nativeFinishReason = finishReason
|
||||
}
|
||||
case "incomplete":
|
||||
nativeFinishReason = responseResult.Get("incomplete_details.reason").String()
|
||||
switch nativeFinishReason {
|
||||
case "max_tokens", "max_output_tokens":
|
||||
finishReason = "length"
|
||||
case "content_filter":
|
||||
finishReason = "content_filter"
|
||||
default:
|
||||
finishReason = "stop"
|
||||
}
|
||||
}
|
||||
if finishReason != "" {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
|
||||
template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
func registerToolCallState(p *ConvertCliToOpenAIParams, eventResult, itemResult gjson.Result, state *toolCallStreamState) {
|
||||
if p.toolCallStates == nil {
|
||||
p.toolCallStates = make(map[string]*toolCallStreamState)
|
||||
}
|
||||
if itemID := eventResult.Get("item_id").String(); itemID != "" {
|
||||
p.toolCallStates["item:"+itemID] = state
|
||||
}
|
||||
if itemID := itemResult.Get("id").String(); itemID != "" {
|
||||
p.toolCallStates["item:"+itemID] = state
|
||||
}
|
||||
if outputIndex := eventResult.Get("output_index"); outputIndex.Exists() {
|
||||
p.toolCallStates["output:"+outputIndex.Raw] = state
|
||||
}
|
||||
p.currentToolCall = state
|
||||
}
|
||||
|
||||
func findToolCallState(p *ConvertCliToOpenAIParams, eventResult, itemResult gjson.Result) *toolCallStreamState {
|
||||
if itemID := eventResult.Get("item_id").String(); itemID != "" {
|
||||
if state := p.toolCallStates["item:"+itemID]; state != nil {
|
||||
return state
|
||||
}
|
||||
}
|
||||
if itemID := itemResult.Get("id").String(); itemID != "" {
|
||||
if state := p.toolCallStates["item:"+itemID]; state != nil {
|
||||
return state
|
||||
}
|
||||
}
|
||||
if outputIndex := eventResult.Get("output_index"); outputIndex.Exists() {
|
||||
if state := p.toolCallStates["output:"+outputIndex.Raw]; state != nil {
|
||||
return state
|
||||
}
|
||||
}
|
||||
return p.currentToolCall
|
||||
}
|
||||
|
||||
func isCodexToolCallType(itemType string) bool {
|
||||
return itemType == "function_call" || itemType == "custom_tool_call"
|
||||
}
|
||||
|
||||
func codexToolCallArguments(itemResult gjson.Result) string {
|
||||
if itemResult.Get("type").String() == "custom_tool_call" {
|
||||
return itemResult.Get("input").String()
|
||||
}
|
||||
return itemResult.Get("arguments").String()
|
||||
}
|
||||
|
||||
// buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name
|
||||
// from the original OpenAI-style request JSON using the same shortening logic.
|
||||
func buildReverseMapFromOriginalOpenAI(original []byte) map[string]string {
|
||||
tools := gjson.GetBytes(original, "tools")
|
||||
rev := map[string]string{}
|
||||
if tools.IsArray() && len(tools.Array()) > 0 {
|
||||
var names []string
|
||||
seenNames := map[string]struct{}{}
|
||||
arr := tools.Array()
|
||||
for i := 0; i < len(arr); i++ {
|
||||
t := arr[i]
|
||||
var name string
|
||||
switch t.Get("type").String() {
|
||||
case "function":
|
||||
name = t.Get("function.name").String()
|
||||
case "custom":
|
||||
name = t.Get("name").String()
|
||||
}
|
||||
if name != "" {
|
||||
if _, seen := seenNames[name]; !seen {
|
||||
names = append(names, name)
|
||||
seenNames[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
m := buildShortNameMap(names)
|
||||
for orig, short := range m {
|
||||
rev[short] = orig
|
||||
}
|
||||
}
|
||||
}
|
||||
return rev
|
||||
}
|
||||
|
||||
func mimeTypeFromCodexOutputFormat(outputFormat string) string {
|
||||
if outputFormat == "" {
|
||||
return "image/png"
|
||||
}
|
||||
if strings.Contains(outputFormat, "/") {
|
||||
return outputFormat
|
||||
}
|
||||
switch strings.ToLower(outputFormat) {
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,579 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_IncompleteTerminal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
terminal := []byte(`{"type":"response.incomplete","response":{"id":"resp_1","model":"gpt-5.5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
|
||||
|
||||
var param any
|
||||
streamOut := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, append([]byte("data: "), terminal...), ¶m)
|
||||
if len(streamOut) != 1 {
|
||||
t.Fatalf("expected 1 streaming terminal chunk, got %d", len(streamOut))
|
||||
}
|
||||
if got := gjson.GetBytes(streamOut[0], "choices.0.finish_reason").String(); got != "length" {
|
||||
t.Fatalf("stream finish_reason = %q, want length; payload=%s", got, streamOut[0])
|
||||
}
|
||||
if got := gjson.GetBytes(streamOut[0], "choices.0.native_finish_reason").String(); got != "max_output_tokens" {
|
||||
t.Fatalf("stream native_finish_reason = %q, want max_output_tokens; payload=%s", got, streamOut[0])
|
||||
}
|
||||
|
||||
var toolParam any
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1","name":"lookup"}}`), &toolParam)
|
||||
toolStreamOut := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, append([]byte("data: "), terminal...), &toolParam)
|
||||
if got := gjson.GetBytes(toolStreamOut[0], "choices.0.finish_reason").String(); got != "length" {
|
||||
t.Fatalf("tool stream finish_reason = %q, want length; payload=%s", got, toolStreamOut[0])
|
||||
}
|
||||
|
||||
nonStreamOut := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, terminal, nil)
|
||||
if got := gjson.GetBytes(nonStreamOut, "choices.0.finish_reason").String(); got != "length" {
|
||||
t.Fatalf("non-stream finish_reason = %q, want length; payload=%s", got, nonStreamOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_StreamSetsModelFromResponseCreated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
modelName := "gpt-5.3-codex"
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.3-codex"}}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected no output for response.created, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"hello"}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
gotModel := gjson.GetBytes(out[0], "model").String()
|
||||
if gotModel != modelName {
|
||||
t.Fatalf("expected model %q, got %q", modelName, gotModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
modelName := "gpt-5.3-codex"
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"hello"}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
gotModel := gjson.GetBytes(out[0], "model").String()
|
||||
if gotModel != modelName {
|
||||
t.Fatalf("expected model %q, got %q", modelName, gotModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_ToolCallChunkOmitsNullContentFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_123","name":"websearch"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
|
||||
t.Fatalf("expected content to be omitted, got %s", string(out[0]))
|
||||
}
|
||||
if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
|
||||
t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
|
||||
}
|
||||
if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls").Exists() {
|
||||
t.Fatalf("expected tool_calls to exist, got %s", string(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_ToolCallArgumentsDeltaOmitsNullContentFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_123","name":"websearch"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected tool call announcement chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"query\":\"OpenAI\"}"}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
|
||||
t.Fatalf("expected content to be omitted, got %s", string(out[0]))
|
||||
}
|
||||
if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
|
||||
t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
|
||||
}
|
||||
if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").Exists() {
|
||||
t.Fatalf("expected tool call arguments delta to exist, got %s", string(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_CustomToolCallStreamDeltas(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
send := func(event string) [][]byte {
|
||||
return ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte("data: "+event), ¶m)
|
||||
}
|
||||
|
||||
out := send(`{"type":"response.output_item.added","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"unexpected input"}}`)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 announcement chunk, got %d", len(out))
|
||||
}
|
||||
toolCall := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0")
|
||||
if got := toolCall.Get("index").Int(); got != 0 {
|
||||
t.Fatalf("expected tool index 0, got %d; chunk=%s", got, out[0])
|
||||
}
|
||||
if got := toolCall.Get("id").String(); got != "call_apply" {
|
||||
t.Fatalf("expected call id call_apply, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
if got := toolCall.Get("function.name").String(); got != "ApplyPatch" {
|
||||
t.Fatalf("expected tool name ApplyPatch, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
if args := toolCall.Get("function.arguments"); !args.Exists() || args.String() != "" {
|
||||
t.Fatalf("expected empty announced arguments, got %s; chunk=%s", args.Raw, out[0])
|
||||
}
|
||||
|
||||
for _, delta := range []string{"*** Begin Patch\n", "*** End Patch"} {
|
||||
out = send(`{"type":"response.custom_tool_call_input.delta","delta":` + string(mustJSONMarshal(t, delta)) + `}`)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 arguments delta chunk, got %d", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != delta {
|
||||
t.Fatalf("expected arguments delta %q, got %q; chunk=%s", delta, got, out[0])
|
||||
}
|
||||
}
|
||||
|
||||
fullInput := "*** Begin Patch\n*** End Patch"
|
||||
out = send(`{"type":"response.custom_tool_call_input.done","input":` + string(mustJSONMarshal(t, fullInput)) + `}`)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected custom input done to be suppressed after deltas, got %d chunks", len(out))
|
||||
}
|
||||
out = send(`{"type":"response.output_item.done","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":` + string(mustJSONMarshal(t, fullInput)) + `}}`)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected output item done to be suppressed after deltas, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
out = send(`{"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 completion chunk, got %d", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.finish_reason").String(); got != "tool_calls" {
|
||||
t.Fatalf("expected finish reason tool_calls, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_EmptyCustomToolDeltaUsesDoneFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":""}}`), ¶m)
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.custom_tool_call_input.delta","item_id":"ctc_1","output_index":0,"delta":""}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected empty delta to be suppressed, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.custom_tool_call_input.done","item_id":"ctc_1","output_index":0,"input":"full patch"}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 done fallback chunk, got %d", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "full patch" {
|
||||
t.Fatalf("expected full patch arguments, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_InterleavedToolCallsKeepStateByItem(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
send := func(event string) [][]byte {
|
||||
return ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte("data: "+event), ¶m)
|
||||
}
|
||||
|
||||
out := send(`{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_lookup","name":"lookup","arguments":""}}`)
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
|
||||
t.Fatalf("expected function call index 0, got %d; chunk=%s", got, out[0])
|
||||
}
|
||||
out = send(`{"type":"response.output_item.added","output_index":1,"item":{"id":"ctc_2","type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":""}}`)
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
|
||||
t.Fatalf("expected custom call index 1, got %d; chunk=%s", got, out[0])
|
||||
}
|
||||
|
||||
out = send(`{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"{\"query\":"}`)
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
|
||||
t.Fatalf("expected interleaved function delta index 0, got %d; chunk=%s", got, out[0])
|
||||
}
|
||||
out = send(`{"type":"response.custom_tool_call_input.delta","output_index":1,"delta":""}`)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected empty custom delta to be suppressed, got %d chunks", len(out))
|
||||
}
|
||||
out = send(`{"type":"response.custom_tool_call_input.done","output_index":1,"input":"patch"}`)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected custom done fallback, got %d chunks", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
|
||||
t.Fatalf("expected output-index-routed custom fallback index 1, got %d; chunk=%s", got, out[0])
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "patch" {
|
||||
t.Fatalf("expected custom fallback arguments patch, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
|
||||
for _, event := range []string{
|
||||
`{"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":0,"arguments":"{\"query\":\"test\"}"}`,
|
||||
`{"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_lookup","name":"lookup","arguments":"{\"query\":\"test\"}"}}`,
|
||||
`{"type":"response.output_item.done","output_index":1,"item":{"id":"ctc_2","type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"patch"}}`,
|
||||
} {
|
||||
if out = send(event); len(out) != 0 {
|
||||
t.Fatalf("expected terminal tool event to avoid duplicate output, got %d chunks for %s", len(out), event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_CustomToolCallInputDoneFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":""}}`), ¶m)
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.custom_tool_call_input.done","input":"full patch"}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 fallback arguments chunk, got %d", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "full patch" {
|
||||
t.Fatalf("expected full patch arguments, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"full patch"}}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected output item done to be suppressed after input done fallback, got %d chunks", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_ToolCallOutputItemDoneFallbacks(t *testing.T) {
|
||||
t.Run("announced custom call emits arguments only", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"custom_tool_call","call_id":"call_first","name":"ApplyPatch","input":""}}`), ¶m)
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"custom_tool_call","call_id":"call_first","name":"ApplyPatch","input":"first patch"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 fallback arguments chunk, got %d", len(out))
|
||||
}
|
||||
toolCall := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0")
|
||||
if got := toolCall.Get("index").Int(); got != 0 {
|
||||
t.Fatalf("expected tool index 0, got %d; chunk=%s", got, out[0])
|
||||
}
|
||||
if toolCall.Get("id").Exists() || toolCall.Get("function.name").Exists() {
|
||||
t.Fatalf("expected arguments-only fallback, got %s", toolCall.Raw)
|
||||
}
|
||||
if got := toolCall.Get("function.arguments").String(); got != "first patch" {
|
||||
t.Fatalf("expected first patch arguments, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"custom_tool_call","call_id":"call_second","name":"ApplyPatch","input":""}}`), ¶m)
|
||||
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"custom_tool_call","call_id":"call_second","name":"ApplyPatch","input":"second patch"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 second fallback arguments chunk, got %d", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
|
||||
t.Fatalf("expected second tool index 1, got %d; chunk=%s", got, out[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unannounced custom call emits complete call", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"full patch"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 complete fallback chunk, got %d", len(out))
|
||||
}
|
||||
toolCall := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0")
|
||||
if got := toolCall.Get("id").String(); got != "call_apply" {
|
||||
t.Fatalf("expected call id call_apply, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
if got := toolCall.Get("function.name").String(); got != "ApplyPatch" {
|
||||
t.Fatalf("expected tool name ApplyPatch, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
if got := toolCall.Get("function.arguments").String(); got != "full patch" {
|
||||
t.Fatalf("expected full patch arguments, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("announced function call still falls back", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_lookup","name":"lookup","arguments":""}}`), ¶m)
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_lookup","name":"lookup","arguments":"{\"query\":\"test\"}"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 function arguments fallback chunk, got %d", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"query":"test"}` {
|
||||
t.Fatalf("expected function arguments fallback, got %q; chunk=%s", got, out[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_ToolCallStateFallsBackFromUnknownItemID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
added := ConvertCodexResponseToOpenAI(
|
||||
ctx,
|
||||
"gpt-5.6-terra",
|
||||
nil,
|
||||
nil,
|
||||
[]byte(`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"TaskCreate","arguments":""}}`),
|
||||
¶m,
|
||||
)
|
||||
if len(added) != 1 {
|
||||
t.Fatalf("added chunks = %d, want 1", len(added))
|
||||
}
|
||||
|
||||
done := ConvertCodexResponseToOpenAI(
|
||||
ctx,
|
||||
"gpt-5.6-terra",
|
||||
nil,
|
||||
nil,
|
||||
[]byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"TaskCreate","arguments":"{\"subject\":\"test\"}"}}`),
|
||||
¶m,
|
||||
)
|
||||
if len(done) != 1 {
|
||||
t.Fatalf("done chunks = %d, want 1", len(done))
|
||||
}
|
||||
|
||||
addedName := gjson.GetBytes(added[0], "choices.0.delta.tool_calls.0.function.name").String()
|
||||
doneName := gjson.GetBytes(done[0], "choices.0.delta.tool_calls.0.function.name").String()
|
||||
if got := addedName + doneName; got != "TaskCreate" {
|
||||
t.Fatalf("assembled tool name = %q, want %q", got, "TaskCreate")
|
||||
}
|
||||
|
||||
toolCall := gjson.GetBytes(done[0], "choices.0.delta.tool_calls.0")
|
||||
if toolCall.Get("id").Exists() || toolCall.Get("function.name").Exists() {
|
||||
t.Fatalf("done chunk repeated tool identity: %s", toolCall.Raw)
|
||||
}
|
||||
if got := toolCall.Get("index").Int(); got != 0 {
|
||||
t.Fatalf("done tool index = %d, want 0", got)
|
||||
}
|
||||
if got := toolCall.Get("function.arguments").String(); got != `{"subject":"test"}` {
|
||||
t.Fatalf("done arguments = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAINonStream_CustomToolCall(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.5","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"full patch"}]}}`)
|
||||
|
||||
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, raw, nil)
|
||||
toolCall := gjson.GetBytes(out, "choices.0.message.tool_calls.0")
|
||||
if got := toolCall.Get("id").String(); got != "call_apply" {
|
||||
t.Fatalf("expected call id call_apply, got %q; response=%s", got, out)
|
||||
}
|
||||
if got := toolCall.Get("function.name").String(); got != "ApplyPatch" {
|
||||
t.Fatalf("expected tool name ApplyPatch, got %q; response=%s", got, out)
|
||||
}
|
||||
if got := toolCall.Get("function.arguments").String(); got != "full patch" {
|
||||
t.Fatalf("expected full patch arguments, got %q; response=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "tool_calls" {
|
||||
t.Fatalf("expected finish reason tool_calls, got %q; response=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_StreamPartialImageEmitsDeltaImages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`)
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String()
|
||||
if gotURL != "data:image/png;base64,aGVsbG8=" {
|
||||
t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out[0]))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_StreamImageGenerationCallDoneEmitsDeltaImages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String()
|
||||
if gotURL != "data:image/jpeg;base64,Ymll" {
|
||||
t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/jpeg;base64,Ymll", gotURL, string(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_NonStreamImageGenerationCallAddsMessageImages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`)
|
||||
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil)
|
||||
|
||||
gotURL := gjson.GetBytes(out, "choices.0.message.images.0.image_url.url").String()
|
||||
if gotURL != "data:image/png;base64,aGVsbG8=" {
|
||||
t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_StreamForwardsCacheWriteTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
// Seed response.created so response.completed can reuse response metadata.
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4"}}`), ¶m)
|
||||
|
||||
chunk := []byte(`data: {"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cached_tokens":30,"cache_write_tokens":40},"output_tokens_details":{"reasoning_tokens":5}}}}`)
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
assertUsageMapping(t, out[0], 40, true)
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_StreamOmitsMissingCacheWriteTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4"}}`), ¶m)
|
||||
|
||||
chunk := []byte(`data: {"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cached_tokens":30},"output_tokens_details":{"reasoning_tokens":5}}}}`)
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
assertUsageMapping(t, out[0], 0, false)
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_StreamPreservesExplicitZeroCacheWriteTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4"}}`), ¶m)
|
||||
|
||||
chunk := []byte(`data: {"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cached_tokens":30,"cache_write_tokens":0},"output_tokens_details":{"reasoning_tokens":5}}}}`)
|
||||
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
assertUsageMapping(t, out[0], 0, true)
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_NonStreamForwardsCacheWriteTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cached_tokens":30,"cache_write_tokens":40},"output_tokens_details":{"reasoning_tokens":5}},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}}`)
|
||||
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil)
|
||||
assertUsageMapping(t, out, 40, true)
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_NonStreamOmitsMissingCacheWriteTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cached_tokens":30},"output_tokens_details":{"reasoning_tokens":5}},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}}`)
|
||||
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil)
|
||||
assertUsageMapping(t, out, 0, false)
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_NonStreamPreservesExplicitZeroCacheWriteTokens(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cached_tokens":30,"cache_write_tokens":0},"output_tokens_details":{"reasoning_tokens":5}},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}}`)
|
||||
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil)
|
||||
assertUsageMapping(t, out, 0, true)
|
||||
}
|
||||
|
||||
func mustJSONMarshal(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, errMarshal := json.Marshal(value)
|
||||
if errMarshal != nil {
|
||||
t.Fatalf("failed to marshal test JSON: %v", errMarshal)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func assertUsageMapping(t *testing.T, payload []byte, wantCachedCreation int64, expectCachedCreation bool) {
|
||||
t.Helper()
|
||||
|
||||
if got := gjson.GetBytes(payload, "usage.prompt_tokens").Int(); got != 100 {
|
||||
t.Fatalf("expected prompt_tokens=100, got %d; payload=%s", got, string(payload))
|
||||
}
|
||||
if got := gjson.GetBytes(payload, "usage.completion_tokens").Int(); got != 20 {
|
||||
t.Fatalf("expected completion_tokens=20, got %d; payload=%s", got, string(payload))
|
||||
}
|
||||
if got := gjson.GetBytes(payload, "usage.total_tokens").Int(); got != 120 {
|
||||
t.Fatalf("expected total_tokens=120, got %d; payload=%s", got, string(payload))
|
||||
}
|
||||
if got := gjson.GetBytes(payload, "usage.prompt_tokens_details.cached_tokens").Int(); got != 30 {
|
||||
t.Fatalf("expected cached_tokens=30, got %d; payload=%s", got, string(payload))
|
||||
}
|
||||
if got := gjson.GetBytes(payload, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 5 {
|
||||
t.Fatalf("expected reasoning_tokens=5, got %d; payload=%s", got, string(payload))
|
||||
}
|
||||
|
||||
gotCachedCreation := gjson.GetBytes(payload, "usage.prompt_tokens_details.cached_creation_tokens")
|
||||
if expectCachedCreation {
|
||||
if !gotCachedCreation.Exists() {
|
||||
t.Fatalf("expected cached_creation_tokens to exist, payload=%s", string(payload))
|
||||
}
|
||||
if gotCachedCreation.Int() != wantCachedCreation {
|
||||
t.Fatalf("expected cached_creation_tokens=%d, got %d; payload=%s", wantCachedCreation, gotCachedCreation.Int(), string(payload))
|
||||
}
|
||||
return
|
||||
}
|
||||
if gotCachedCreation.Exists() {
|
||||
t.Fatalf("expected cached_creation_tokens to be omitted, payload=%s", string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_NonStreamMultiMessageEmptyTrailingKeepsContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"model":"gpt-5.5","status":"completed","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15},"output":[` +
|
||||
`{"type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}]},` +
|
||||
`{"type":"message","content":[{"type":"output_text","text":"the real answer"}]},` +
|
||||
`{"type":"reasoning","summary":[{"type":"summary_text","text":"thinking again"}]},` +
|
||||
`{"type":"message","content":[{"type":"output_text","text":""}]}` +
|
||||
`]}}`)
|
||||
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, raw, nil)
|
||||
|
||||
got := gjson.GetBytes(out, "choices.0.message.content")
|
||||
if !got.Exists() || got.Type == gjson.Null {
|
||||
t.Fatalf("content was dropped to null by trailing empty message; resp=%s", string(out))
|
||||
}
|
||||
if got.String() != "the real answer" {
|
||||
t.Fatalf("expected content %q, got %q; resp=%s", "the real answer", got.String(), string(out))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenAI,
|
||||
Codex,
|
||||
ConvertOpenAIRequestToCodex,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertCodexResponseToOpenAI,
|
||||
NonStream: ConvertCodexResponseToOpenAINonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertCodexResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) {
|
||||
input := []byte(`{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}`)
|
||||
|
||||
output := ConvertCodexResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil)
|
||||
|
||||
if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" {
|
||||
t.Fatalf("role = %q, want assistant", role)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue