Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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...), &param)
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"}}`), &param)
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"}`), &param)
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"}`), &param)
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"}}`), &param)
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"}}`), &param)
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\"}"}`), &param)
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), &param)
}
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":""}}`), &param)
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":""}`), &param)
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"}`), &param)
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), &param)
}
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":""}}`), &param)
out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.custom_tool_call_input.done","input":"full patch"}`), &param)
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"}}`), &param)
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":""}}`), &param)
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"}}`), &param)
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":""}}`), &param)
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"}}`), &param)
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"}}`), &param)
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":""}}`), &param)
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\"}"}}`), &param)
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":""}}`),
&param,
)
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\"}"}}`),
&param,
)
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, &param)
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, &param)
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}`), &param)
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="}}`), &param)
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"}}`), &param)
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"}}`), &param)
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, &param)
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"}}`), &param)
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, &param)
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"}}`), &param)
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, &param)
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))
}
}

View file

@ -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,
},
)
}

View file

@ -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)
}
}