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,24 @@
package claude
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeRequestToCodexWithCompatPreservesEmptyThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
withoutCompat := ConvertClaudeRequestToCodex("deepseek-v4", payload, false)
if gjson.GetBytes(withoutCompat, "input.#").Int() != 0 {
t.Fatalf("default translation preserved empty-signature thinking: %s", withoutCompat)
}
withCompat := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", payload, false)
if !gjson.GetBytes(withCompat, "input.0.type").Exists() || gjson.GetBytes(withCompat, "input.0.type").String() != "reasoning" {
t.Fatalf("compat translation missing reasoning item: %s", withCompat)
}
if !gjson.GetBytes(withCompat, "input.0.encrypted_content").Exists() {
t.Fatalf("compat translation missing empty encrypted_content: %s", withCompat)
}
}

View file

@ -0,0 +1,305 @@
package claude
import (
"context"
"strings"
"testing"
"github.com/tidwall/gjson"
)
type codexClaudeContentBlock struct {
Index int64
Type string
ID string
Name string
Text string
Arguments string
}
func translateCodexClaudeChunks(t *testing.T, chunks [][]byte) [][]byte {
t.Helper()
originalRequest := []byte(`{"stream":true,"tools":[{"name":"Read"}]}`)
var state any
var outputs [][]byte
for _, chunk := range chunks {
outputs = append(outputs, ConvertCodexResponseToClaude(context.Background(), "gpt-5", originalRequest, nil, chunk, &state)...)
}
return outputs
}
func assertCodexClaudeContentBlockLifecycle(t *testing.T, outputs [][]byte) []*codexClaudeContentBlock {
t.Helper()
open := make(map[int64]*codexClaudeContentBlock)
started := make(map[int64]struct{})
blocks := make([]*codexClaudeContentBlock, 0)
messageState := 0
for _, output := range outputs {
for _, line := range strings.Split(string(output), "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
event := gjson.Parse(strings.TrimPrefix(line, "data: "))
if messageState == 2 {
t.Fatalf("event emitted after message_stop: %s", event.Raw)
}
index := event.Get("index").Int()
switch event.Get("type").String() {
case "content_block_start":
if messageState != 0 {
t.Fatalf("content block started after message terminal events: %s", event.Raw)
}
if len(open) != 0 {
t.Fatalf("content block start emitted while another block remains open: %v", open)
}
if _, exists := started[index]; exists {
t.Fatalf("content block index %d was reused", index)
}
block := &codexClaudeContentBlock{
Index: index,
Type: event.Get("content_block.type").String(),
ID: event.Get("content_block.id").String(),
Name: event.Get("content_block.name").String(),
}
open[index] = block
started[index] = struct{}{}
blocks = append(blocks, block)
case "content_block_delta":
block := open[index]
if block == nil {
t.Fatalf("content block delta targets unopened index %d", index)
}
switch event.Get("delta.type").String() {
case "input_json_delta":
block.Arguments += event.Get("delta.partial_json").String()
case "text_delta":
block.Text += event.Get("delta.text").String()
}
case "content_block_stop":
if open[index] == nil {
t.Fatalf("content block stop targets unopened index %d", index)
}
delete(open, index)
case "message_delta":
if len(open) != 0 {
t.Fatalf("message_delta emitted while content blocks remain open: %v", open)
}
if messageState != 0 {
t.Fatalf("duplicate or out-of-order message_delta: %s", event.Raw)
}
messageState = 1
case "message_stop":
if len(open) != 0 {
t.Fatalf("message_stop emitted while content blocks remain open: %v", open)
}
if messageState != 1 {
t.Fatalf("message_stop emitted before message_delta: %s", event.Raw)
}
messageState = 2
}
}
}
if len(open) != 0 {
t.Fatalf("content blocks remain open: %v", open)
}
return blocks
}
func assertParallelCodexClaudeToolCalls(t *testing.T, blocks []*codexClaudeContentBlock) {
t.Helper()
if len(blocks) != 2 {
t.Fatalf("content block count = %d, want 2", len(blocks))
}
expectedIDs := []string{"call_a", "call_b"}
expectedArguments := []string{`{"file_path":"a"}`, `{"file_path":"b"}`}
for index, block := range blocks {
if block.Index != int64(index) {
t.Fatalf("block %d index = %d, want %d", index, block.Index, index)
}
if block.Type != "tool_use" || block.Name != "Read" {
t.Fatalf("block %d = %#v, want Read tool_use", index, block)
}
if block.ID != expectedIDs[index] {
t.Fatalf("block %d ID = %q, want %q", index, block.ID, expectedIDs[index])
}
if block.Arguments != expectedArguments[index] {
t.Fatalf("block %d arguments = %q, want %q", index, block.Arguments, expectedArguments[index])
}
}
}
func TestConvertCodexResponseToClaude_StreamSerializesInterleavedNamedFunctionCalls(t *testing.T) {
tests := []struct {
name string
chunks [][]byte
}{
{
name: "first call finishes first",
chunks: [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":1}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read"},"output_index":2}`),
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"a\"}","output_index":1}`),
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"b\"}","output_index":2}`),
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":1}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":1}`),
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"b\"}","output_index":2}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"},"output_index":2}`),
},
},
{
name: "second call finishes first",
chunks: [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":1}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read"},"output_index":2}`),
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"b\"}","output_index":2}`),
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"b\"}","output_index":2}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"},"output_index":2}`),
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"a\"}","output_index":1}`),
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":1}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":1}`),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, test.chunks))
assertParallelCodexClaudeToolCalls(t, blocks)
})
}
}
func TestConvertCodexResponseToClaude_StreamDefersOtherContentUntilFunctionCallsClose(t *testing.T) {
tests := []struct {
name string
functionCall []byte
firstBlock string
secondBlock string
}{
{
name: "named active call",
functionCall: []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":0}`),
firstBlock: "tool_use",
secondBlock: "text",
},
{
name: "unnamed pending call",
functionCall: []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a"},"output_index":0}`),
firstBlock: "text",
secondBlock: "tool_use",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_mixed","model":"gpt-5"}}`),
test.functionCall,
[]byte(`data: {"type":"response.output_item.added","item":{"type":"message","status":"in_progress"},"output_index":1}`),
[]byte(`data: {"type":"response.content_part.added","part":{"type":"output_text"},"content_index":0,"output_index":1}`),
[]byte(`data: {"type":"response.output_text.delta","delta":"done","output_index":1}`),
[]byte(`data: {"type":"response.content_part.done","part":{"type":"output_text"},"content_index":0,"output_index":1}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"message","status":"completed"},"output_index":1}`),
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":0}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":0}`),
[]byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`),
}
blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks))
if len(blocks) != 2 {
t.Fatalf("content block count = %d, want 2", len(blocks))
}
if blocks[0].Index != 0 || blocks[0].Type != test.firstBlock {
t.Fatalf("unexpected first block: %#v", blocks[0])
}
if blocks[1].Index != 1 || blocks[1].Type != test.secondBlock {
t.Fatalf("unexpected second block: %#v", blocks[1])
}
for _, block := range blocks {
switch block.Type {
case "tool_use":
if block.Arguments != `{"file_path":"a"}` {
t.Fatalf("unexpected tool block: %#v", block)
}
case "text":
if block.Text != "done" {
t.Fatalf("unexpected text block: %#v", block)
}
}
}
})
}
}
func TestConvertCodexResponseToClaude_StreamDeferredTextClosesBeforeThinkingStarts(t *testing.T) {
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_mixed","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":0}`),
[]byte(`data: {"type":"response.content_part.added","part":{"type":"output_text"},"content_index":0,"output_index":1}`),
[]byte(`data: {"type":"response.output_text.delta","delta":"answer","output_index":1}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"enc_initial"},"output_index":2}`),
[]byte(`data: {"type":"response.reasoning_summary_part.added","output_index":2}`),
[]byte(`data: {"type":"response.reasoning_summary_text.delta","delta":"thought","output_index":2}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"reasoning","encrypted_content":"enc_final"},"output_index":2}`),
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":0}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":0}`),
[]byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`),
}
blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks))
if len(blocks) != 3 {
t.Fatalf("content block count = %d, want 3", len(blocks))
}
if blocks[0].Index != 0 || blocks[0].Type != "tool_use" || blocks[0].Arguments != `{"file_path":"a"}` {
t.Fatalf("unexpected tool block: %#v", blocks[0])
}
if blocks[1].Index != 1 || blocks[1].Type != "text" || blocks[1].Text != "answer" {
t.Fatalf("unexpected text block: %#v", blocks[1])
}
if blocks[2].Index != 2 || blocks[2].Type != "thinking" {
t.Fatalf("unexpected thinking block: %#v", blocks[2])
}
}
func TestConvertCodexResponseToClaude_StreamTerminalMatchesFunctionCallsByOutputIndex(t *testing.T) {
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","name":"Read"},"output_index":0}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","name":"Read"},"output_index":1}`),
[]byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","name":"Read","arguments":"{\"file_path\":\"a\"}"},{"type":"function_call","name":"Read","arguments":"{\"file_path\":\"b\"}"}]}}`),
}
blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks))
if len(blocks) != 2 {
t.Fatalf("content block count = %d, want 2", len(blocks))
}
if blocks[0].Index != 0 || blocks[0].Arguments != `{"file_path":"a"}` {
t.Fatalf("unexpected first function call: %#v", blocks[0])
}
if blocks[1].Index != 1 || blocks[1].Arguments != `{"file_path":"b"}` {
t.Fatalf("unexpected second function call: %#v", blocks[1])
}
}
func TestConvertCodexResponseToClaude_StreamTerminalHydratesInterleavedFunctionCalls(t *testing.T) {
for _, terminalType := range []string{"response.completed", "response.incomplete"} {
t.Run(terminalType, func(t *testing.T) {
terminal := `data: {"type":"` + terminalType + `","response":{"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"}]}}`
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":0}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read"},"output_index":1}`),
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":","output_index":0}`),
[]byte(terminal),
}
blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks))
assertParallelCodexClaudeToolCalls(t, blocks)
})
}
}

View file

@ -0,0 +1,622 @@
// Package claude provides request translation functionality for Claude Code API compatibility.
// It handles parsing and transforming Claude Code API requests into the internal client format,
// extracting model information, system instructions, message contents, and tool declarations.
// The package also performs JSON data cleaning and transformation to ensure compatibility
// between Claude Code API format and the internal client's expected format.
package claude
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertClaudeRequestToCodex parses and transforms a Claude Code API request into the internal client format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the internal client.
// The function performs the following transformations:
// 1. Sets up a template with the model name and empty instructions field
// 2. Processes system messages and converts them to developer input content
// 3. Transforms message contents (text, image, document, tool_use, tool_result) to appropriate formats
// 4. Converts tools declarations to the expected format
// 5. Adds additional configuration parameters for the Codex API
// 6. Maps Claude thinking configuration to Codex reasoning settings
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the Claude Code API
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
//
// Returns:
// - []byte: The transformed request data in internal client format
func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, false)
}
// ConvertClaudeRequestToCodexWithCompat preserves assistant thinking blocks with
// empty signatures for configured compatibility endpoints.
func ConvertClaudeRequestToCodexWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, true)
}
func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte {
rawJSON := inputRawJSON
template := []byte(`{"model":"","instructions":"","input":[]}`)
rootResult := gjson.ParseBytes(rawJSON)
toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
template, _ = sjson.SetBytes(template, "model", modelName)
inputItems := translatorcommon.NewRawArrayItems(rootResult.Get("messages.#").Int())
// Process system messages and convert them to input content format.
systemsResult := rootResult.Get("system")
if systemsResult.Exists() {
contentItems := make([][]byte, 0, 2)
appendSystemText := func(text string) {
if text == "" || util.IsClaudeCodeAttributionSystemText(text) {
return
}
content := []byte(`{"type":"input_text","text":""}`)
content, _ = sjson.SetBytes(content, "text", text)
contentItems = append(contentItems, content)
}
if systemsResult.Type == gjson.String {
appendSystemText(systemsResult.String())
} else if systemsResult.IsArray() {
systemResults := systemsResult.Array()
for i := 0; i < len(systemResults); i++ {
systemResult := systemResults[i]
if systemResult.Get("type").String() == "text" {
appendSystemText(systemResult.Get("text").String())
}
}
}
if len(contentItems) > 0 {
message := []byte(`{"type":"message","role":"developer"}`)
message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems))
inputItems = append(inputItems, message)
}
}
// Process messages and transform their contents to appropriate formats.
messagesResult := rootResult.Get("messages")
if messagesResult.IsArray() {
messageResults := messagesResult.Array()
for i := 0; i < len(messageResults); i++ {
messageResult := messageResults[i]
messageRole := messageResult.Get("role").String()
if messageRole == "system" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(messageResult.Get("content")); ok {
message := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`)
message, _ = sjson.SetBytes(message, "content.0.text", reminderText)
inputItems = append(inputItems, message)
}
continue
}
messageContentsResult := messageResult.Get("content")
contentItems := make([][]byte, 0, 4)
flushMessage := func() {
if len(contentItems) > 0 {
message := []byte(`{"type":"message","role":""}`)
message, _ = sjson.SetBytes(message, "role", messageRole)
message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems))
inputItems = append(inputItems, message)
contentItems = contentItems[:0]
}
}
appendTextContent := func(text string) {
partType := "input_text"
if messageRole == "assistant" {
partType = "output_text"
}
content := []byte(`{"type":"","text":""}`)
content, _ = sjson.SetBytes(content, "type", partType)
content, _ = sjson.SetBytes(content, "text", text)
contentItems = append(contentItems, content)
}
appendImageContent := func(dataURL string) {
content := []byte(`{"type":"input_image","image_url":""}`)
content, _ = sjson.SetBytes(content, "image_url", dataURL)
contentItems = append(contentItems, content)
}
appendDocumentContent := func(dataURL string) {
content := []byte(`{"type":"input_file","file_data":"","filename":"document.pdf"}`)
content, _ = sjson.SetBytes(content, "file_data", dataURL)
contentItems = append(contentItems, content)
}
appendReasoningContent := func(part gjson.Result) {
if messageRole != "assistant" {
return
}
rawSignature := part.Get("signature").String()
signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, rawSignature)
if !ok {
if preserveEmptyThinkingBlocks && strings.TrimSpace(rawSignature) == "" {
signature = rawSignature
} else {
if !codexClaudeTargetAcceptsGrokSignature(modelName) {
return
}
if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err != nil {
return
}
signature = rawSignature
}
}
flushMessage()
reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`)
reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature)
inputItems = append(inputItems, reasoningItem)
}
if messageContentsResult.IsArray() {
messageContentResults := messageContentsResult.Array()
for j := 0; j < len(messageContentResults); j++ {
messageContentResult := messageContentResults[j]
contentType := messageContentResult.Get("type").String()
switch contentType {
case "text":
appendTextContent(messageContentResult.Get("text").String())
case "thinking":
appendReasoningContent(messageContentResult)
case "image":
sourceResult := messageContentResult.Get("source")
if sourceResult.Exists() {
data := sourceResult.Get("data").String()
if data == "" {
data = sourceResult.Get("base64").String()
}
if data != "" {
mediaType := sourceResult.Get("media_type").String()
if mediaType == "" {
mediaType = sourceResult.Get("mime_type").String()
}
if mediaType == "" {
mediaType = "application/octet-stream"
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
appendImageContent(dataURL)
}
}
case "document":
sourceResult := messageContentResult.Get("source")
if sourceResult.Get("type").String() != "base64" {
continue
}
mediaType := strings.TrimSpace(sourceResult.Get("media_type").String())
if !strings.EqualFold(mediaType, "application/pdf") {
continue
}
data := sourceResult.Get("data").String()
if data == "" {
data = sourceResult.Get("base64").String()
}
if data != "" {
appendDocumentContent(fmt.Sprintf("data:%s;base64,%s", mediaType, data))
}
case "tool_use":
flushMessage()
functionCallMessage := []byte(`{"type":"function_call"}`)
functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("id").String()))
{
name := messageContentResult.Get("name").String()
if short, ok := toolNameMap[name]; ok {
name = short
} else {
name = shortenNameIfNeeded(name)
}
functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "name", name)
}
functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "arguments", messageContentResult.Get("input").Raw)
inputItems = append(inputItems, functionCallMessage)
case "tool_result":
flushMessage()
functionCallOutputMessage := []byte(`{"type":"function_call_output"}`)
functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("tool_use_id").String()))
contentResult := messageContentResult.Get("content")
if contentResult.IsArray() {
contentResults := contentResult.Array()
toolResultContentItems := make([][]byte, 0, len(contentResults))
for k := 0; k < len(contentResults); k++ {
toolResultContentType := contentResults[k].Get("type").String()
if toolResultContentType == "image" {
sourceResult := contentResults[k].Get("source")
if sourceResult.Exists() {
data := sourceResult.Get("data").String()
if data == "" {
data = sourceResult.Get("base64").String()
}
if data != "" {
mediaType := sourceResult.Get("media_type").String()
if mediaType == "" {
mediaType = sourceResult.Get("mime_type").String()
}
if mediaType == "" {
mediaType = "application/octet-stream"
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
toolResultContent := []byte(`{"type":"input_image","image_url":""}`)
toolResultContent, _ = sjson.SetBytes(toolResultContent, "image_url", dataURL)
toolResultContentItems = append(toolResultContentItems, toolResultContent)
}
}
} else if toolResultContentType == "text" {
toolResultContent := []byte(`{"type":"input_text","text":""}`)
toolResultContent, _ = sjson.SetBytes(toolResultContent, "text", contentResults[k].Get("text").String())
toolResultContentItems = append(toolResultContentItems, toolResultContent)
}
}
if len(toolResultContentItems) > 0 {
functionCallOutputMessage, _ = sjson.SetRawBytes(functionCallOutputMessage, "output", translatorcommon.JoinRawArray(toolResultContentItems))
} else {
functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
}
} else {
functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
}
inputItems = append(inputItems, functionCallOutputMessage)
}
}
flushMessage()
} else if messageContentsResult.Type == gjson.String {
appendTextContent(messageContentsResult.String())
flushMessage()
}
}
}
// Convert tools declarations to the expected format for the Codex API.
toolsResult := rootResult.Get("tools")
var toolItems [][]byte
if toolsResult.IsArray() {
webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult)
template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames))
toolResults := toolsResult.Array()
toolItems = make([][]byte, 0, len(toolResults))
for i := 0; i < len(toolResults); i++ {
toolResult := toolResults[i]
// Special handling: map Claude web search tool to Codex web_search
if isClaudeWebSearchToolType(toolResult.Get("type").String()) {
toolItems = append(toolItems, convertClaudeWebSearchToolToCodex(toolResult))
continue
}
tool := []byte(toolResult.Raw)
if toolResult.Get("type").Type != gjson.String || toolResult.Get("type").String() != "function" {
tool, _ = sjson.SetBytes(tool, "type", "function")
}
// Apply shortened name if needed
if v := toolResult.Get("name"); v.Exists() {
originalName := v.String()
name := originalName
if short, ok := toolNameMap[name]; ok {
name = short
} else {
name = shortenNameIfNeeded(name)
}
if v.Type != gjson.String || name != originalName {
tool, _ = sjson.SetBytes(tool, "name", name)
}
}
tool, _ = sjson.SetRawBytes(tool, "parameters", []byte(normalizeToolParameters(toolResult.Get("input_schema").Raw)))
for _, path := range []string{"input_schema", "parameters.$schema", "cache_control", "defer_loading"} {
if gjson.GetBytes(tool, path).Exists() {
tool, _ = sjson.DeleteBytes(tool, path)
}
}
if gjson.GetBytes(tool, "strict").Type != gjson.False {
tool, _ = sjson.SetBytes(tool, "strict", false)
}
toolItems = append(toolItems, tool)
}
}
// Default to parallel tool calls unless tool_choice explicitly disables them.
parallelToolCalls := true
if disableParallelToolUse := rootResult.Get("tool_choice.disable_parallel_tool_use"); disableParallelToolUse.Exists() {
parallelToolCalls = !disableParallelToolUse.Bool()
}
// Add additional configuration parameters for the Codex API.
template, _ = sjson.SetBytes(template, "parallel_tool_calls", parallelToolCalls)
// Convert thinking.budget_tokens to reasoning.effort.
reasoningEffort := "medium"
if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
switch thinkingConfig.Get("type").String() {
case "enabled":
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int())
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
reasoningEffort = effort
}
}
case "adaptive", "auto":
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
// Pass through directly; ApplyThinking handles clamping to target model's levels.
effort := ""
if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
reasoningEffort = effort
} else {
reasoningEffort = string(thinking.LevelXHigh)
}
case "disabled":
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
reasoningEffort = effort
}
}
}
template, _ = sjson.SetBytes(template, "reasoning.effort", reasoningEffort)
// 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.
serviceTier := normalizeCodexServiceTier(rootResult.Get("service_tier"))
if speed := rootResult.Get("speed"); speed.Type == gjson.String && speed.String() == "fast" {
serviceTier = "priority"
}
if serviceTier != "" {
template, _ = sjson.SetBytes(template, "service_tier", serviceTier)
}
template, _ = sjson.SetBytes(template, "stream", true)
template, _ = sjson.SetBytes(template, "store", false)
template, _ = sjson.SetBytes(template, "include", []string{"reasoning.encrypted_content"})
if toolsResult.IsArray() {
template, _ = sjson.SetRawBytes(template, "tools", translatorcommon.JoinRawArray(toolItems))
}
template = translatorcommon.SetRawArrayItems(template, "input", inputItems)
return template
}
func codexClaudeTargetAcceptsGrokSignature(modelName string) bool {
baseModel := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName))
return strings.Contains(baseModel, "grok")
}
func normalizeCodexServiceTier(result gjson.Result) string {
if !result.Exists() || result.Type != gjson.String {
return ""
}
switch strings.ToLower(strings.TrimSpace(result.String())) {
case "fast", "priority":
return "priority"
default:
return ""
}
}
// shortenCodexCallIDIfNeeded keeps Claude tool IDs within the OpenAI Responses
// API call_id limit while preserving a stable, low-collision mapping.
func shortenCodexCallIDIfNeeded(id string) string {
const limit = 64
if len(id) <= limit {
return id
}
sum := sha256.Sum256([]byte(id))
suffix := "_" + hex.EncodeToString(sum[:8])
prefixLen := limit - len(suffix)
if prefixLen <= 0 {
return suffix[len(suffix)-limit:]
}
return id[:prefixLen] + suffix
}
func isClaudeWebSearchToolType(toolType string) bool {
return toolType == "web_search_20250305" || toolType == "web_search_20260209"
}
func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} {
names := map[string]struct{}{}
if !tools.IsArray() {
return names
}
tools.ForEach(func(_, tool gjson.Result) bool {
toolType := tool.Get("type").String()
if !isClaudeWebSearchToolType(toolType) {
return true
}
if name := tool.Get("name").String(); name != "" {
names[name] = struct{}{}
}
return true
})
return names
}
func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte {
if !toolChoice.Exists() || toolChoice.Type == gjson.Null {
return []byte(`"auto"`)
}
choiceType := toolChoice.Get("type").String()
if choiceType == "" && toolChoice.Type == gjson.String {
choiceType = toolChoice.String()
}
switch choiceType {
case "auto", "":
return []byte(`"auto"`)
case "any":
return []byte(`"required"`)
case "none":
return []byte(`"none"`)
case "tool":
name := toolChoice.Get("name").String()
if _, ok := webSearchToolNames[name]; ok {
return []byte(`{"type":"web_search"}`)
}
if short, ok := toolNameMap[name]; ok {
name = short
} else {
name = shortenNameIfNeeded(name)
}
if name == "" {
return []byte(`"auto"`)
}
choice := []byte(`{"type":"function","name":""}`)
choice, _ = sjson.SetBytes(choice, "name", name)
return choice
default:
return []byte(`"auto"`)
}
}
func convertClaudeWebSearchToolToCodex(tool gjson.Result) []byte {
out := []byte(`{"type":"web_search"}`)
if allowedDomains := tool.Get("allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
out, _ = sjson.SetRawBytes(out, "filters.allowed_domains", []byte(allowedDomains.Raw))
}
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
out, _ = sjson.SetRawBytes(out, "user_location", []byte(userLocation.Raw))
}
return out
}
// shortenNameIfNeeded applies a simple shortening rule for a single name.
func shortenNameIfNeeded(name string) string {
const limit = 64
if len(name) <= limit {
return name
}
if strings.HasPrefix(name, "mcp__") {
idx := strings.LastIndex(name, "__")
if idx > 0 {
cand := "mcp__" + name[idx+2:]
if len(cand) > limit {
return cand[:limit]
}
return cand
}
}
return name[:limit]
}
// buildShortNameMap ensures uniqueness of shortened names within a request.
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
}
// buildReverseMapFromClaudeOriginalToShort builds original->short map, used to map tool_use names to short.
func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string {
tools := gjson.GetBytes(original, "tools")
m := map[string]string{}
if !tools.IsArray() {
return m
}
var names []string
arr := tools.Array()
for i := 0; i < len(arr); i++ {
n := arr[i].Get("name").String()
if n != "" {
names = append(names, n)
}
}
if len(names) > 0 {
m = buildShortNameMap(names)
}
return m
}
// normalizeToolParameters ensures object schemas contain at least an empty properties map.
func normalizeToolParameters(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" || !gjson.Valid(raw) {
return `{"type":"object","properties":{}}`
}
result := gjson.Parse(raw)
schema := []byte(raw)
schemaType := result.Get("type").String()
if schemaType == "" {
schema, _ = sjson.SetBytes(schema, "type", "object")
schemaType = "object"
}
if schemaType == "object" && !result.Get("properties").Exists() {
schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`))
}
return string(schema)
}

View file

@ -0,0 +1,72 @@
package claude
import (
"strconv"
"strings"
"testing"
"github.com/tidwall/gjson"
)
func BenchmarkConvertClaudeRequestToCodexLargeHistory(b *testing.B) {
for _, turns := range []int{16, 64} {
b.Run(strconv.Itoa(turns)+"_turns", func(b *testing.B) {
request := largeClaudeRequest(turns, 32, 8*1024)
if !gjson.ValidBytes(request) {
b.Fatal("benchmark generated an invalid Claude request")
}
if result := ConvertClaudeRequestToCodex("gpt-5.4", request, false); !gjson.ValidBytes(result) {
b.Fatal("translator generated invalid Codex JSON")
}
b.ReportAllocs()
b.SetBytes(int64(len(request)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
ConvertClaudeRequestToCodex("gpt-5.4", request, false)
}
})
}
}
func largeClaudeRequest(turns, toolCount, payloadSize int) []byte {
payload := strings.Repeat("x", payloadSize)
var request strings.Builder
request.Grow((turns + toolCount) * payloadSize)
request.WriteString(`{"model":"claude-test","system":[{"type":"text","text":"`)
request.WriteString(payload)
request.WriteString(`"}],"messages":[`)
for i := 0; i < turns; i++ {
if i > 0 {
request.WriteByte(',')
}
request.WriteString(`{"role":"assistant","content":[{"type":"text","text":"`)
request.WriteString(payload)
request.WriteString(`"},{"type":"tool_use","id":"toolu_`)
request.WriteString(strconv.Itoa(i))
request.WriteString(`","name":"tool_`)
request.WriteString(strconv.Itoa(i % toolCount))
request.WriteString(`","input":{"value":"`)
request.WriteString(payload)
request.WriteString(`"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_`)
request.WriteString(strconv.Itoa(i))
request.WriteString(`","content":[{"type":"text","text":"`)
request.WriteString(payload)
request.WriteString(`"}]}]}`)
}
request.WriteString(`],"tools":[`)
for i := 0; i < toolCount; i++ {
if i > 0 {
request.WriteByte(',')
}
request.WriteString(`{"name":"tool_`)
request.WriteString(strconv.Itoa(i))
request.WriteString(`","description":"`)
request.WriteString(payload)
request.WriteString(`","input_schema":{"type":"object","properties":{"value":{"type":"string"}}}}`)
}
request.WriteString(`]}`)
return []byte(request.String())
}

View file

@ -0,0 +1,712 @@
package claude
import (
"encoding/base64"
"strings"
"testing"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantHasDeveloper bool
wantTexts []string
}{
{
name: "No system field",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: false,
},
{
name: "Empty string system field",
inputJSON: `{
"model": "claude-3-opus",
"system": "",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: false,
},
{
name: "String system field",
inputJSON: `{
"model": "claude-3-opus",
"system": "Be helpful",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: true,
wantTexts: []string{"Be helpful"},
},
{
name: "Message system role does not become developer",
inputJSON: `{
"model": "claude-3-opus",
"messages": [
{"role": "system", "content": "Follow the project instructions"},
{"role": "user", "content": "hello"}
]
}`,
wantHasDeveloper: false,
},
{
name: "Array system field with filtered billing header",
inputJSON: `{
"model": "claude-3-opus",
"system": [
{"type": "text", "text": "x-anthropic-billing-header: tenant-123"},
{"type": "text", "text": "Block 1"},
{"type": "text", "text": "Block 2"}
],
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: true,
wantTexts: []string{"Block 1", "Block 2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
resultJSON := gjson.ParseBytes(result)
inputs := resultJSON.Get("input").Array()
hasDeveloper := len(inputs) > 0 && inputs[0].Get("role").String() == "developer"
if hasDeveloper != tt.wantHasDeveloper {
t.Fatalf("got hasDeveloper = %v, want %v. Output: %s", hasDeveloper, tt.wantHasDeveloper, resultJSON.Get("input").Raw)
}
if !tt.wantHasDeveloper {
return
}
content := inputs[0].Get("content").Array()
if len(content) != len(tt.wantTexts) {
t.Fatalf("got %d system content items, want %d. Content: %s", len(content), len(tt.wantTexts), inputs[0].Get("content").Raw)
}
for i, wantText := range tt.wantTexts {
if gotType := content[i].Get("type").String(); gotType != "input_text" {
t.Fatalf("content[%d] type = %q, want %q", i, gotType, "input_text")
}
if gotText := content[i].Get("text").String(); gotText != wantText {
t.Fatalf("content[%d] text = %q, want %q", i, gotText, wantText)
}
}
})
}
}
func TestConvertClaudeRequestToCodex_MessageSystemRoleWrapsAsUserReminder(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"system": [{"type": "text", "text": "Top-level rules"}],
"messages": [
{"role": "user", "content": "hello"},
{"role": "system", "content": "Follow the project instructions"},
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]},
{"role": "system", "content": [{"type": "text", "text": "Use the current repo"}]}
]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
inputs := gjson.GetBytes(result, "input").Array()
if len(inputs) != 5 {
t.Fatalf("got %d input items, want 5: %s", len(inputs), gjson.GetBytes(result, "input").Raw)
}
if got := inputs[0].Get("role").String(); got != "developer" {
t.Fatalf("top-level system role = %q, want developer", got)
}
if got := inputs[2].Get("role").String(); got != "user" {
t.Fatalf("message-level system role = %q, want user", got)
}
if got := inputs[2].Get("content.0.text").String(); got != "<system-reminder>\nFollow the project instructions\n</system-reminder>" {
t.Fatalf("unexpected first reminder text: %q", got)
}
if got := inputs[4].Get("role").String(); got != "user" {
t.Fatalf("array message-level system role = %q, want user", got)
}
if got := inputs[4].Get("content.0.text").String(); got != "<system-reminder>\nUse the current repo\n</system-reminder>" {
t.Fatalf("unexpected second reminder text: %q", got)
}
}
func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantParallelToolCalls bool
}{
{
name: "Default to true when tool_choice.disable_parallel_tool_use is absent",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantParallelToolCalls: true,
},
{
name: "Disable parallel tool calls when client opts out",
inputJSON: `{
"model": "claude-3-opus",
"tool_choice": {"disable_parallel_tool_use": true},
"messages": [{"role": "user", "content": "hello"}]
}`,
wantParallelToolCalls: false,
},
{
name: "Keep parallel tool calls enabled when client explicitly allows them",
inputJSON: `{
"model": "claude-3-opus",
"tool_choice": {"disable_parallel_tool_use": false},
"messages": [{"role": "user", "content": "hello"}]
}`,
wantParallelToolCalls: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("parallel_tool_calls").Bool(); got != tt.wantParallelToolCalls {
t.Fatalf("parallel_tool_calls = %v, want %v. Output: %s", got, tt.wantParallelToolCalls, string(result))
}
})
}
}
func TestConvertClaudeRequestToCodex_ServiceTier(t *testing.T) {
tests := []struct {
name string
serviceTierJSON string
speedJSON string
want string
wantExists bool
}{
{
name: "Priority passes through",
serviceTierJSON: `"priority"`,
want: "priority",
wantExists: true,
},
{
name: "Fast tier normalizes to priority",
serviceTierJSON: `"fast"`,
want: "priority",
wantExists: true,
},
{
name: "Unsupported tier is omitted",
serviceTierJSON: `"default"`,
},
{
name: "Non-string tier is omitted",
serviceTierJSON: `true`,
},
{
name: "Fast speed maps to priority",
speedJSON: `"fast"`,
want: "priority",
wantExists: true,
},
{
name: "Standard speed is omitted",
speedJSON: `"standard"`,
},
{
name: "Non-string speed is omitted",
speedJSON: `true`,
},
{
name: "Fast speed overrides unsupported Anthropic tier",
serviceTierJSON: `"auto"`,
speedJSON: `"fast"`,
want: "priority",
wantExists: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "Reply with OK"}]
}`)
if tt.serviceTierJSON != "" {
inputJSON, _ = sjson.SetRawBytes(inputJSON, "service_tier", []byte(tt.serviceTierJSON))
}
if tt.speedJSON != "" {
inputJSON, _ = sjson.SetRawBytes(inputJSON, "speed", []byte(tt.speedJSON))
}
result := ConvertClaudeRequestToCodex("gpt-5.4", inputJSON, false)
serviceTierResult := gjson.GetBytes(result, "service_tier")
if serviceTierResult.Exists() != tt.wantExists {
t.Fatalf("service_tier exists = %v, want %v. Output: %s", serviceTierResult.Exists(), tt.wantExists, string(result))
}
if !tt.wantExists {
return
}
if got := serviceTierResult.String(); got != tt.want {
t.Fatalf("service_tier = %q, want %q. Output: %s", got, tt.want, string(result))
}
})
}
}
func TestConvertClaudeRequestToCodex_ShortenLongToolUseIDs(t *testing.T) {
longID := "toolu_" + strings.Repeat("a", 62)
if len(longID) <= 64 {
t.Fatalf("test setup error: longID length = %d, want > 64", len(longID))
}
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{"role": "user", "content": [{"type":"text","text":"run pwd"}]},
{"role": "assistant", "content": [
{"type":"tool_use","id":"` + longID + `","name":"Bash","input":{"cmd":"pwd"}}
]},
{"role": "user", "content": [
{"type":"tool_result","tool_use_id":"` + longID + `","content":"ok"}
]}
]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
inputs := gjson.GetBytes(result, "input").Array()
var callID string
var outputCallID string
for _, item := range inputs {
switch item.Get("type").String() {
case "function_call":
callID = item.Get("call_id").String()
case "function_call_output":
outputCallID = item.Get("call_id").String()
}
}
if callID == "" {
t.Fatalf("missing function_call item. Output: %s", string(result))
}
if outputCallID == "" {
t.Fatalf("missing function_call_output item. Output: %s", string(result))
}
if callID != outputCallID {
t.Fatalf("call_id mismatch: function_call=%q function_call_output=%q. Output: %s", callID, outputCallID, string(result))
}
if len(callID) > 64 {
t.Fatalf("call_id length = %d, want <= 64: %q", len(callID), callID)
}
if callID == longID {
t.Fatalf("long call_id was not shortened: %q", callID)
}
}
func TestConvertClaudeRequestToCodex_ToolChoiceModeMapping(t *testing.T) {
tests := []struct {
name string
claudeToolChoice string
wantCodexToolChoice string
}{
{
name: "Any requires at least one tool",
claudeToolChoice: `{"type":"any"}`,
wantCodexToolChoice: "required",
},
{
name: "None disables tools",
claudeToolChoice: `{"type":"none"}`,
wantCodexToolChoice: "none",
},
{
name: "Auto stays auto",
claudeToolChoice: `{"type":"auto"}`,
wantCodexToolChoice: "auto",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"tools": [
{"name": "lookup", "description": "Lookup", "input_schema": {"type":"object","properties":{}}}
],
"tool_choice": ` + tt.claudeToolChoice + `,
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tool_choice").String(); got != tt.wantCodexToolChoice {
t.Fatalf("tool_choice = %q, want %q. Output: %s", got, tt.wantCodexToolChoice, string(result))
}
})
}
}
func TestConvertClaudeRequestToCodex_ToolChoiceSpecificFunctionUsesConvertedName(t *testing.T) {
longName := "mcp__server_with_a_very_long_name_that_exceeds_sixty_four_characters__search"
inputJSON := `{
"model": "claude-3-opus",
"tools": [
{"name": "` + longName + `", "description": "Search", "input_schema": {"type":"object","properties":{}}}
],
"tool_choice": {"type":"tool","name":"` + longName + `"},
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
}
toolName := resultJSON.Get("tools.0.name").String()
choiceName := resultJSON.Get("tool_choice.name").String()
if choiceName != toolName {
t.Fatalf("tool_choice.name = %q, want converted tool name %q. Output: %s", choiceName, toolName, string(result))
}
if choiceName == longName {
t.Fatalf("tool_choice.name should use shortened Codex tool name. Output: %s", string(result))
}
}
func TestConvertClaudeRequestToCodex_WebSearchToolMapping(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"tools": [
{
"type": "web_search_20260209",
"name": "web_search",
"allowed_domains": ["example.com"],
"blocked_domains": ["blocked.example"],
"user_location": {
"type": "approximate",
"city": "Beijing",
"country": "CN",
"timezone": "Asia/Shanghai"
}
}
],
"tool_choice": {"type":"tool","name":"web_search"},
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tools.0.type").String(); got != "web_search" {
t.Fatalf("tools.0.type = %q, want web_search. Output: %s", got, string(result))
}
if got := resultJSON.Get("tools.0.filters.allowed_domains.0").String(); got != "example.com" {
t.Fatalf("tools.0.filters.allowed_domains.0 = %q, want example.com. Output: %s", got, string(result))
}
if resultJSON.Get("tools.0.blocked_domains").Exists() {
t.Fatalf("tools.0.blocked_domains should not be forwarded to Codex. Output: %s", string(result))
}
if got := resultJSON.Get("tools.0.user_location.city").String(); got != "Beijing" {
t.Fatalf("tools.0.user_location.city = %q, want Beijing. Output: %s", got, string(result))
}
if got := resultJSON.Get("tool_choice.type").String(); got != "web_search" {
t.Fatalf("tool_choice.type = %q, want web_search. Output: %s", got, string(result))
}
}
func TestConvertClaudeRequestToCodex_WebSearchToolChoiceUsesDeclaredTypedToolName(t *testing.T) {
inputJSON := `{
"model": "claude-opus-4-7",
"tools": [
{"type": "web_search_20250305", "name": "browser_search"},
{"name": "web_search", "description": "Local search", "input_schema": {"type":"object","properties":{}}}
],
"tool_choice": {"type":"tool","name":"web_search"},
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
}
if got := resultJSON.Get("tool_choice.name").String(); got != "web_search" {
t.Fatalf("tool_choice.name = %q, want web_search. Output: %s", got, string(result))
}
}
func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) {
signature := validCodexReasoningSignature()
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "visible summary must not be replayed",
"signature": "` + signature + `"
},
{
"type": "text",
"text": "visible answer"
}
]
},
{
"role": "user",
"content": "continue"
}
]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
inputs := resultJSON.Get("input").Array()
if len(inputs) != 3 {
t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result))
}
reasoning := inputs[0]
if got := reasoning.Get("type").String(); got != "reasoning" {
t.Fatalf("first input type = %q, want reasoning. Output: %s", got, string(result))
}
if got := reasoning.Get("encrypted_content").String(); got != signature {
t.Fatalf("encrypted_content = %q, want %q", got, signature)
}
if got := reasoning.Get("summary").Raw; got != "[]" {
t.Fatalf("summary = %s, want []", got)
}
if got := reasoning.Get("content").Raw; got != "null" {
t.Fatalf("content = %s, want null", got)
}
assistantMessage := inputs[1]
if got := assistantMessage.Get("role").String(); got != "assistant" {
t.Fatalf("second input role = %q, want assistant. Output: %s", got, string(result))
}
if got := assistantMessage.Get("content.0.type").String(); got != "output_text" {
t.Fatalf("assistant content type = %q, want output_text", got)
}
if got := assistantMessage.Get("content.0.text").String(); got != "visible answer" {
t.Fatalf("assistant text = %q, want visible answer", got)
}
if strings.Contains(string(result), "visible summary must not be replayed") {
t.Fatalf("thinking text should not be replayed into Codex input. Output: %s", string(result))
}
}
func TestConvertClaudeRequestToCodex_PreservesBase64PDFDocumentContent(t *testing.T) {
inputJSON := `{
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "before"},
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"}},
{"type": "text", "text": "after"}
]
}]
}`
result := ConvertClaudeRequestToCodex("gpt-5.6-sol", []byte(inputJSON), false)
content := gjson.GetBytes(result, "input.0.content").Array()
if len(content) != 3 {
t.Fatalf("got %d content items, want 3. Output: %s", len(content), result)
}
wantTypes := []string{"input_text", "input_file", "input_text"}
for i, wantType := range wantTypes {
if got := content[i].Get("type").String(); got != wantType {
t.Fatalf("content[%d].type = %q, want %q. Output: %s", i, got, wantType, result)
}
}
if got := content[0].Get("text").String(); got != "before" {
t.Fatalf("content[0].text = %q, want %q", got, "before")
}
if got := content[1].Get("file_data").String(); got != "data:application/pdf;base64,JVBERi0xLjQK" {
t.Fatalf("content[1].file_data = %q, want PDF data URL", got)
}
if got := content[1].Get("filename").String(); got != "document.pdf" {
t.Fatalf("content[1].filename = %q, want %q", got, "document.pdf")
}
if got := content[2].Get("text").String(); got != "after" {
t.Fatalf("content[2].text = %q, want %q", got, "after")
}
}
func TestConvertClaudeRequestToCodex_PreservesContentOrderAcrossToolAndReasoningItems(t *testing.T) {
signature := validCodexReasoningSignature()
inputJSON := `{
"system": "system rules",
"messages": [
{"role":"assistant","content":[
{"type":"text","text":"before reasoning"},
{"type":"thinking","signature":"` + signature + `"},
{"type":"text","text":"before tool"},
{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"query":"test"}},
{"type":"text","text":"after tool"}
]},
{"role":"user","content":[
{"type":"tool_result","tool_use_id":"toolu_1","content":[
{"type":"text","text":"tool output"},
{"type":"image","source":{"media_type":"image/png","data":"aW1hZ2U="}}
]},
{"type":"text","text":"continue"}
]}
],
"tools": [{"name":"lookup","input_schema":{"type":"object"}}]
}`
result := ConvertClaudeRequestToCodex("gpt-5.4", []byte(inputJSON), false)
inputs := gjson.GetBytes(result, "input").Array()
if len(inputs) != 8 {
t.Fatalf("got %d input items, want 8. Output: %s", len(inputs), result)
}
wantTypes := []string{"message", "message", "reasoning", "message", "function_call", "message", "function_call_output", "message"}
for i := 0; i < len(wantTypes); i++ {
if got := inputs[i].Get("type").String(); got != wantTypes[i] {
t.Fatalf("input[%d].type = %q, want %q. Output: %s", i, got, wantTypes[i], result)
}
}
if got := inputs[1].Get("content.0.text").String(); got != "before reasoning" {
t.Fatalf("input[1] text = %q, want before reasoning", got)
}
if got := inputs[3].Get("content.0.text").String(); got != "before tool" {
t.Fatalf("input[3] text = %q, want before tool", got)
}
if got := inputs[5].Get("content.0.text").String(); got != "after tool" {
t.Fatalf("input[5] text = %q, want after tool", got)
}
if got := inputs[6].Get("output.0.type").String(); got != "input_text" {
t.Fatalf("tool result output.0.type = %q, want input_text", got)
}
if got := inputs[6].Get("output.1.image_url").String(); got != "data:image/png;base64,aW1hZ2U=" {
t.Fatalf("tool result image_url = %q, want data URL", got)
}
if got := inputs[7].Get("content.0.text").String(); got != "continue" {
t.Fatalf("input[7] text = %q, want continue", got)
}
}
func TestConvertClaudeRequestToCodex_AssistantGrokSignatureToReasoningItem(t *testing.T) {
signature := "HmlYdr2aCAqCYP/m9mr8PS6KOsdMs72FGDigmydR+Jsmuv8KX97yWPlbOwmXJgWn0CbHaCacdQD3+n5EvpgLfPNmafS3kdICBjRuDf4bzHy7uBiUhNVhqPtp/ee1y9q4imPE4LYgD1VZ4J+bp9mTeqA1+nC9Oue58CiNEMV9SVaGenCD+aBnVuSTzQhD32Y+68i6HLJW0Dx6ifaRfb8hxYtA/sPM+/FTvAMW11nRho5a2BBSkpnzfqqAz/e/vGJ77/bygpXM823QA9wL9i0X"
payload := []byte(`{"model":"grok-4.5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
out := ConvertClaudeRequestToCodex("grok-4.5", payload, false)
reasoning := gjson.GetBytes(out, "input.0")
if reasoning.Get("type").String() != "reasoning" {
t.Fatalf("input.0 type = %q, want reasoning; output=%s", reasoning.Get("type").String(), out)
}
if got := reasoning.Get("encrypted_content").String(); got != signature {
t.Fatalf("encrypted_content = %q, want Grok signature", got)
}
}
func TestConvertClaudeRequestToCodex_IgnoresGrokSignatureForNonGrokTargets(t *testing.T) {
signature := "HmlYdr2aCAqCYP/m9mr8PS6KOsdMs72FGDigmydR+Jsmuv8KX97yWPlbOwmXJgWn0CbHaCacdQD3+n5EvpgLfPNmafS3kdICBjRuDf4bzHy7uBiUhNVhqPtp/ee1y9q4imPE4LYgD1VZ4J+bp9mTeqA1+nC9Oue58CiNEMV9SVaGenCD+aBnVuSTzQhD32Y+68i6HLJW0Dx6ifaRfb8hxYtA/sPM+/FTvAMW11nRho5a2BBSkpnzfqqAz/e/vGJ77/bygpXM823QA9wL9i0X"
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
for _, modelName := range []string{"gpt-5.4", "claude-sonnet-4-6"} {
t.Run(modelName, func(t *testing.T) {
out := ConvertClaudeRequestToCodex(modelName, payload, false)
if got := countRequestInputItemsByType(out, "reasoning"); got != 0 {
t.Fatalf("got %d reasoning items for non-Grok target, want 0; output=%s", got, out)
}
})
}
}
func TestConvertClaudeRequestToCodex_IgnoresNonCodexThinkingSignatures(t *testing.T) {
tests := []struct {
name string
inputJSON string
}{
{
name: "Ignore user thinking even with Codex-shaped signature",
inputJSON: `{
"model": "claude-3-opus",
"messages": [
{
"role": "user",
"content": [
{
"type": "thinking",
"thinking": "user supplied thinking",
"signature": "` + validCodexReasoningSignature() + `"
},
{
"type": "text",
"text": "hello"
}
]
}
]
}`,
},
{
name: "Ignore Anthropic native signature",
inputJSON: `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "anthropic thinking",
"signature": "Eo8Canthropic-state"
},
{
"type": "text",
"text": "visible answer"
}
]
}
]
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
if got := countRequestInputItemsByType(result, "reasoning"); got != 0 {
t.Fatalf("got %d reasoning items, want 0. Output: %s", got, string(result))
}
})
}
}
func countRequestInputItemsByType(result []byte, itemType string) int {
count := 0
gjson.GetBytes(result, "input").ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == itemType {
count++
}
return true
})
return count
}
func validCodexReasoningSignature() string {
raw := make([]byte, 1+8+16+16+32)
raw[0] = 0x80
raw[8] = 1
return base64.URLEncoding.EncodeToString(raw)
}

View file

@ -0,0 +1,926 @@
// Package claude provides response translation functionality for Codex to Claude Code API compatibility.
// This package handles the conversion of Codex API responses into Claude Code-compatible
// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages
// different response types including text content, thinking processes, and function calls.
// The translation ensures proper sequencing of SSE events and maintains state across
// multiple response chunks to provide a seamless streaming experience.
package claude
import (
"bytes"
"context"
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var (
dataTag = []byte("data:")
)
// codexThinkingSummaryPartSeparator joins consecutive reasoning summary parts inside
// the single thinking block that represents one Codex reasoning item.
const codexThinkingSummaryPartSeparator = "\n\n"
// ConvertCodexResponseToClaudeParams holds parameters for response conversion.
type ConvertCodexResponseToClaudeParams struct {
HasEmittedToolUse bool
BlockIndex int
HasTextDelta bool
TextBlockOpen bool
ThinkingBlockOpen bool
ThinkingSignature string
ThinkingSummarySeen bool
WebSearchToolUseIDs map[string]struct{}
WebSearchToolResultIDs map[string]struct{}
LastWebSearchToolUseID string
FunctionCalls map[string]*codexFunctionCallStream
FunctionCallQueue []*codexFunctionCallStream
ActiveFunctionCall *codexFunctionCallStream
LastFunctionCall *codexFunctionCallStream
DeferredStreamEvents [][]byte
}
type codexFunctionCallStream struct {
CallID string
Name string
BlockIndex int
Arguments string
EmittedArgumentsLength int
HasReceivedArgumentsDelta bool
EmitInitialEmptyDelta bool
Started bool
Done bool
Closed bool
}
// ConvertCodexResponseToClaude performs sophisticated streaming response format conversion.
// This function implements a complex state machine that translates Codex API responses
// into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types
// and handles state transitions between content blocks, thinking processes, and function calls.
//
// Response type states: 0=none, 1=content, 2=thinking, 3=function
// The function maintains state across multiple calls to ensure proper SSE event sequencing.
//
// 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 maintaining state between calls
//
// Returns:
// - [][]byte: A slice of Claude Code-compatible JSON responses
func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertCodexResponseToClaudeParams{
BlockIndex: 0,
}
}
if !bytes.HasPrefix(rawJSON, dataTag) {
return [][]byte{}
}
streamEventRawJSON := bytes.Clone(rawJSON)
rawJSON = bytes.TrimSpace(rawJSON[5:])
output := make([]byte, 0, 512)
rootResult := gjson.ParseBytes(rawJSON)
params := (*param).(*ConvertCodexResponseToClaudeParams)
typeResult := rootResult.Get("type")
typeStr := typeResult.String()
if params.ActiveFunctionCall != nil && shouldDeferCodexStreamEvent(typeStr, rootResult) {
params.DeferredStreamEvents = append(params.DeferredStreamEvents, streamEventRawJSON)
return [][]byte{}
}
var template []byte
switch typeStr {
case "error":
output = append(output, codexStreamErrorToClaudeError(rootResult)...)
case "response.created":
template = []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0},"content":[],"stop_reason":null}}`)
template, _ = sjson.SetBytes(template, "message.model", rootResult.Get("response.model").String())
template, _ = sjson.SetBytes(template, "message.id", rootResult.Get("response.id").String())
output = translatorcommon.AppendSSEEventBytes(output, "message_start", template, 2)
case "response.reasoning_summary_part.added":
output = append(output, stopCodexTextBlock(params)...)
// Codex splits a single reasoning item into several summary parts, but only
// output_item.done carries that item's final encrypted_content. Keep one
// thinking block open for the whole item and separate the parts with a blank
// line, so the only signature ever emitted is the final one.
if params.ThinkingBlockOpen {
output = append(output, appendCodexThinkingDelta(params, codexThinkingSummaryPartSeparator)...)
} else {
output = append(output, startCodexThinkingBlock(params)...)
}
params.ThinkingSummarySeen = true
case "response.reasoning_summary_text.delta":
output = append(output, stopCodexTextBlock(params)...)
output = append(output, startCodexThinkingBlock(params)...)
output = append(output, appendCodexThinkingDelta(params, rootResult.Get("delta").String())...)
case "response.reasoning_summary_part.done":
// Intentionally does not close the thinking block: it stays open until
// output_item.done delivers the reasoning item's final encrypted_content.
case "response.content_part.added":
output = append(output, finalizeCodexThinkingBlock(params)...)
if rootResult.Get("part.type").String() == "output_text" {
output = append(output, startCodexTextBlock(params)...)
}
case "response.output_text.delta":
params.HasTextDelta = true
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, startCodexTextBlock(params)...)
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String())
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
case "response.content_part.done":
if rootResult.Get("part.type").String() == "output_text" {
output = append(output, stopCodexTextBlock(params)...)
}
case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress":
// Wait for populated web_search_call items on output_item.done.
case "response.completed", "response.incomplete":
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
responseData := rootResult.Get("response")
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
output = appendCodexFunctionCallsFromTerminal(output, params, originalRequestRawJSON, responseData)
output = appendDeferredCodexStreamEvents(output, originalRequestRawJSON, param)
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasEmittedToolUse))
template = setClaudeStopSequence(template, "delta.stop_sequence", responseData)
inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage"))
template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens)
template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens)
if cachedTokens > 0 {
template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", cachedTokens)
}
output = translatorcommon.AppendSSEEventBytes(output, "message_delta", template, 2)
output = translatorcommon.AppendSSEEventBytes(output, "message_stop", []byte(`{"type":"message_stop"}`), 2)
case "response.output_item.added":
itemResult := rootResult.Get("item")
itemType := itemResult.Get("type").String()
switch itemType {
case "function_call":
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
call := recordCodexFunctionCall(params, rootResult, itemResult)
updateCodexFunctionCallIdentity(params, call, rootResult, itemResult)
if call.Name != "" {
call.EmitInitialEmptyDelta = true
}
output = appendCodexFunctionCallQueue(output, params, originalRequestRawJSON)
case "reasoning":
output = append(output, stopCodexTextBlock(params)...)
// A previous reasoning item that never reported output_item.done must not
// leak its still-open block into this one.
output = append(output, finalizeCodexThinkingBlock(params)...)
params.ThinkingSummarySeen = false
// Kept only as a fallback for streams whose output_item.done omits
// encrypted_content; it is a pre-content snapshot, never the final value.
params.ThinkingSignature = itemResult.Get("encrypted_content").String()
case "web_search_call":
// Defer server_tool_use until output_item.done carries action/query.
}
case "response.output_item.done":
itemResult := rootResult.Get("item")
itemType := itemResult.Get("type").String()
switch itemType {
case "message":
if params.HasTextDelta {
return [][]byte{output}
}
contentResult := itemResult.Get("content")
if !contentResult.Exists() || !contentResult.IsArray() {
return [][]byte{output}
}
var textBuilder strings.Builder
contentResult.ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() != "output_text" {
return true
}
if txt := part.Get("text").String(); txt != "" {
textBuilder.WriteString(txt)
}
return true
})
text := textBuilder.String()
if text == "" {
return [][]byte{output}
}
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, startCodexTextBlock(params)...)
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "delta.text", text)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
output = append(output, stopCodexTextBlock(params)...)
params.HasTextDelta = true
case "function_call":
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
call := codexFunctionCallForEvent(params, rootResult, itemResult)
if call == nil {
call = recordCodexFunctionCall(params, rootResult, itemResult)
}
updateCodexFunctionCallIdentity(params, call, rootResult, itemResult)
updateCodexFunctionCallArguments(call, itemResult.Get("arguments").String(), false)
call.Done = true
output = appendCodexFunctionCallQueue(output, params, originalRequestRawJSON)
case "reasoning":
output = append(output, stopCodexTextBlock(params)...)
if signature := itemResult.Get("encrypted_content").String(); signature != "" {
params.ThinkingSignature = signature
}
if params.ThinkingSummarySeen {
output = append(output, finalizeCodexThinkingBlock(params)...)
} else {
output = append(output, finalizeCodexSignatureOnlyThinkingBlock(params)...)
}
params.ThinkingSignature = ""
params.ThinkingSummarySeen = false
case "web_search_call":
output = appendCodexWebSearchToolResult(output, params, rootResult, itemResult)
}
case "response.function_call_arguments.delta":
call := codexFunctionCallForEvent(params, rootResult, gjson.Result{})
if call == nil {
call = recordCodexFunctionCall(params, rootResult, gjson.Result{})
}
updateCodexFunctionCallArguments(call, rootResult.Get("delta").String(), true)
output = appendCodexFunctionCallBufferedArguments(output, params, call)
case "response.function_call_arguments.done":
call := codexFunctionCallForEvent(params, rootResult, gjson.Result{})
if call == nil {
call = recordCodexFunctionCall(params, rootResult, gjson.Result{})
}
updateCodexFunctionCallArguments(call, rootResult.Get("arguments").String(), false)
output = appendCodexFunctionCallBufferedArguments(output, params, call)
}
if len(params.FunctionCallQueue) == 0 {
output = appendDeferredCodexStreamEvents(output, originalRequestRawJSON, param)
}
return [][]byte{output}
}
func shouldDeferCodexStreamEvent(typeStr string, rootResult gjson.Result) bool {
switch typeStr {
case "error", "response.completed", "response.incomplete", "response.function_call_arguments.delta", "response.function_call_arguments.done":
return false
case "response.output_item.added", "response.output_item.done":
return rootResult.Get("item.type").String() != "function_call"
default:
return true
}
}
func appendDeferredCodexStreamEvents(output []byte, originalRequestRawJSON []byte, param *any) []byte {
if param == nil || *param == nil {
return output
}
params := (*param).(*ConvertCodexResponseToClaudeParams)
if len(params.DeferredStreamEvents) == 0 {
return output
}
events := params.DeferredStreamEvents
params.DeferredStreamEvents = nil
for _, event := range events {
translated := ConvertCodexResponseToClaude(context.Background(), "", originalRequestRawJSON, nil, event, param)
for _, chunk := range translated {
output = append(output, chunk...)
}
}
return output
}
func codexStreamErrorToClaudeError(rootResult gjson.Result) []byte {
errorResult := rootResult.Get("error")
errType := strings.TrimSpace(errorResult.Get("type").String())
if errType == "" {
errType = strings.TrimSpace(rootResult.Get("error_type").String())
}
if errType == "" {
errType = "api_error"
}
code := strings.TrimSpace(errorResult.Get("code").String())
message := strings.TrimSpace(errorResult.Get("message").String())
if message == "" {
message = strings.TrimSpace(rootResult.Get("message").String())
}
if message == "" {
message = code
}
if message == "" {
message = errType
}
if code == "cyber_policy" || errType == "invalid_request" {
errType = "invalid_request_error"
}
out := []byte(`{"type":"error","error":{"type":"api_error","message":""}}`)
out, _ = sjson.SetBytes(out, "error.type", errType)
out, _ = sjson.SetBytes(out, "error.message", message)
return translatorcommon.AppendSSEEventBytes(nil, "error", out, 2)
}
// ConvertCodexResponseToClaudeNonStream converts a non-streaming Codex response to a non-streaming Claude Code response.
// This function processes the complete Codex response and transforms it into a single Claude Code-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 Claude Code API format.
func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) []byte {
revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
rootResult := gjson.ParseBytes(rawJSON)
typeStr := rootResult.Get("type").String()
if typeStr != "response.completed" && typeStr != "response.incomplete" {
return []byte{}
}
responseData := rootResult.Get("response")
if !responseData.Exists() {
return []byte{}
}
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
out, _ = sjson.SetBytes(out, "id", responseData.Get("id").String())
out, _ = sjson.SetBytes(out, "model", responseData.Get("model").String())
inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage"))
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
if cachedTokens > 0 {
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
}
hasToolCall := false
webSearchSeen := make(map[string]struct{})
var contentBlocks [][]byte
if output := responseData.Get("output"); output.Exists() && output.IsArray() {
output.ForEach(func(_, item gjson.Result) bool {
switch item.Get("type").String() {
case "reasoning":
thinkingBuilder := strings.Builder{}
signature := item.Get("encrypted_content").String()
if summary := item.Get("summary"); summary.Exists() {
if summary.IsArray() {
summary.ForEach(func(_, part gjson.Result) bool {
if txt := part.Get("text"); txt.Exists() {
thinkingBuilder.WriteString(txt.String())
} else {
thinkingBuilder.WriteString(part.String())
}
return true
})
} else {
thinkingBuilder.WriteString(summary.String())
}
}
if thinkingBuilder.Len() == 0 {
if content := item.Get("content"); content.Exists() {
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
if txt := part.Get("text"); txt.Exists() {
thinkingBuilder.WriteString(txt.String())
} else {
thinkingBuilder.WriteString(part.String())
}
return true
})
} else {
thinkingBuilder.WriteString(content.String())
}
}
}
if thinkingBuilder.Len() > 0 || signature != "" {
block := []byte(`{"type":"thinking","thinking":""}`)
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
if signature != "" {
block, _ = sjson.SetBytes(block, "signature", signature)
}
contentBlocks = append(contentBlocks, block)
}
case "message":
if content := item.Get("content"); content.Exists() {
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() == "output_text" {
text := part.Get("text").String()
if text != "" {
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", text)
contentBlocks = append(contentBlocks, block)
}
}
return true
})
} else {
text := content.String()
if text != "" {
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", text)
contentBlocks = append(contentBlocks, block)
}
}
}
case "web_search_call":
contentBlocks = appendCodexWebSearchNonStreamBlocks(contentBlocks, item, webSearchSeen)
case "function_call":
hasToolCall = true
name := item.Get("name").String()
if original, ok := revNames[name]; ok {
name = original
}
toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolBlock, _ = sjson.SetBytes(toolBlock, "id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(item.Get("call_id").String())))
toolBlock, _ = sjson.SetBytes(toolBlock, "name", name)
inputRaw := "{}"
if argsStr := item.Get("arguments").String(); argsStr != "" && gjson.Valid(argsStr) {
argsJSON := gjson.Parse(argsStr)
if argsJSON.IsObject() {
inputRaw = argsJSON.Raw
}
}
toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw))
contentBlocks = append(contentBlocks, toolBlock)
}
return true
})
}
if len(contentBlocks) > 0 {
out = translatorcommon.SetRawArrayItems(out, "content", contentBlocks)
}
out, _ = sjson.SetBytes(out, "stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), hasToolCall))
out = setClaudeStopSequence(out, "stop_sequence", responseData)
return out
}
func codexStopReason(responseData gjson.Result) string {
if stopReason := responseData.Get("stop_reason"); stopReason.Exists() && stopReason.String() != "" {
if stopReason.String() == "stop" && codexStopSequence(responseData).String() != "" {
return "stop_sequence"
}
return stopReason.String()
}
if reason := responseData.Get("incomplete_details.reason"); reason.Exists() && reason.String() != "" {
return reason.String()
}
if codexStopSequence(responseData).String() != "" {
return "stop_sequence"
}
return ""
}
func mapCodexStopReasonToClaude(stopReason string, hasToolCall bool) string {
if hasToolCall {
return "tool_use"
}
switch stopReason {
case "", "stop", "completed":
return "end_turn"
case "max_tokens", "max_output_tokens":
return "max_tokens"
case "tool_use", "tool_calls", "function_call":
return "end_turn"
case "end_turn", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded":
return stopReason
case "content_filter":
return "refusal"
default:
return "end_turn"
}
}
func codexStopSequence(responseData gjson.Result) gjson.Result {
return responseData.Get("stop_sequence")
}
func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) []byte {
if stopSequence := codexStopSequence(responseData); stopSequence.Exists() && stopSequence.String() != "" {
out, _ = sjson.SetRawBytes(out, path, []byte(stopSequence.Raw))
}
return out
}
func codexFunctionCallID(itemResult gjson.Result) string {
return itemResult.Get("call_id").String()
}
func codexFunctionCallKeys(rootResult, itemResult gjson.Result) []string {
keys := make([]string, 0, 5)
if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() {
keys = appendUniqueCodexFunctionCallKey(keys, "output:"+outputIndex.Raw)
}
if callID := codexFunctionCallID(itemResult); callID != "" {
keys = appendUniqueCodexFunctionCallKey(keys, "call:"+callID)
}
if callID := rootResult.Get("call_id").String(); callID != "" {
keys = appendUniqueCodexFunctionCallKey(keys, "call:"+callID)
}
if itemID := itemResult.Get("id").String(); itemID != "" {
keys = appendUniqueCodexFunctionCallKey(keys, "item:"+itemID)
}
if itemID := rootResult.Get("item_id").String(); itemID != "" {
keys = appendUniqueCodexFunctionCallKey(keys, "item:"+itemID)
}
return keys
}
func appendUniqueCodexFunctionCallKey(keys []string, key string) []string {
if key == "" {
return keys
}
for _, existing := range keys {
if existing == key {
return keys
}
}
return append(keys, key)
}
func codexFunctionCallForKeys(params *ConvertCodexResponseToClaudeParams, keys []string) *codexFunctionCallStream {
if params == nil || params.FunctionCalls == nil {
return nil
}
for _, key := range keys {
if call := params.FunctionCalls[key]; call != nil {
return call
}
}
return nil
}
func codexFunctionCallForEvent(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) *codexFunctionCallStream {
keys := codexFunctionCallKeys(rootResult, itemResult)
if len(keys) > 0 {
return codexFunctionCallForKeys(params, keys)
}
if params == nil {
return nil
}
return params.LastFunctionCall
}
func recordCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) *codexFunctionCallStream {
keys := codexFunctionCallKeys(rootResult, itemResult)
call := codexFunctionCallForKeys(params, keys)
if call == nil {
call = &codexFunctionCallStream{BlockIndex: -1}
params.FunctionCallQueue = append(params.FunctionCallQueue, call)
}
addCodexFunctionCallAliases(params, call, keys)
params.LastFunctionCall = call
return call
}
func addCodexFunctionCallAliases(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream, keys []string) {
if params == nil || call == nil {
return
}
if params.FunctionCalls == nil {
params.FunctionCalls = map[string]*codexFunctionCallStream{}
}
for _, key := range keys {
params.FunctionCalls[key] = call
}
}
func updateCodexFunctionCallIdentity(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream, rootResult, itemResult gjson.Result) {
if call == nil {
return
}
if callID := codexFunctionCallID(itemResult); callID != "" {
call.CallID = callID
}
if name := itemResult.Get("name").String(); name != "" {
call.Name = name
}
addCodexFunctionCallAliases(params, call, codexFunctionCallKeys(rootResult, itemResult))
}
func updateCodexFunctionCallArguments(call *codexFunctionCallStream, arguments string, delta bool) {
if call == nil || arguments == "" {
return
}
if delta {
call.Arguments += arguments
call.HasReceivedArgumentsDelta = true
return
}
if !call.HasReceivedArgumentsDelta {
call.Arguments = arguments
return
}
if strings.HasPrefix(arguments, call.Arguments) {
call.Arguments = arguments
}
}
func appendCodexFunctionCallStart(output []byte, originalRequestRawJSON []byte, callID, name string, blockIndex int) []byte {
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
template, _ = sjson.SetBytes(template, "index", blockIndex)
template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(callID)))
template, _ = sjson.SetBytes(template, "content_block.name", resolveCodexClaudeToolUseName(originalRequestRawJSON, name))
return translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
}
func appendCodexFunctionCallArgumentDelta(output []byte, partialJSON string, blockIndex int) []byte {
template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
template, _ = sjson.SetBytes(template, "index", blockIndex)
template, _ = sjson.SetBytes(template, "delta.partial_json", partialJSON)
return translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
}
func appendCodexFunctionCallStop(output []byte, blockIndex int) []byte {
template := []byte(`{"type":"content_block_stop","index":0}`)
template, _ = sjson.SetBytes(template, "index", blockIndex)
return translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
}
func appendCodexFunctionCallBufferedArguments(output []byte, params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream) []byte {
if params == nil || call == nil || params.ActiveFunctionCall != call || !call.Started || call.Closed {
return output
}
if call.EmittedArgumentsLength >= len(call.Arguments) {
return output
}
output = appendCodexFunctionCallArgumentDelta(output, call.Arguments[call.EmittedArgumentsLength:], call.BlockIndex)
call.EmittedArgumentsLength = len(call.Arguments)
return output
}
func appendCodexFunctionCallQueue(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte) []byte {
if params == nil {
return output
}
for {
if active := params.ActiveFunctionCall; active != nil {
output = appendCodexFunctionCallBufferedArguments(output, params, active)
if !active.Done {
return output
}
output = appendCodexFunctionCallStop(output, active.BlockIndex)
if params.BlockIndex <= active.BlockIndex {
params.BlockIndex = active.BlockIndex + 1
}
active.Closed = true
params.ActiveFunctionCall = nil
removeCodexFunctionCallFromQueue(params, active)
}
for len(params.FunctionCallQueue) > 0 && params.FunctionCallQueue[0].Closed {
params.FunctionCallQueue = params.FunctionCallQueue[1:]
}
if len(params.FunctionCallQueue) == 0 {
return output
}
call := params.FunctionCallQueue[0]
if call.Name == "" {
return output
}
call.BlockIndex = params.BlockIndex
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, call.CallID, call.Name, call.BlockIndex)
if call.EmitInitialEmptyDelta {
output = appendCodexFunctionCallArgumentDelta(output, "", call.BlockIndex)
}
call.Started = true
params.ActiveFunctionCall = call
params.HasEmittedToolUse = true
output = appendCodexFunctionCallBufferedArguments(output, params, call)
}
}
func removeCodexFunctionCallFromQueue(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream) {
if params == nil || call == nil {
return
}
for index, queued := range params.FunctionCallQueue {
if queued != call {
continue
}
params.FunctionCallQueue = append(params.FunctionCallQueue[:index], params.FunctionCallQueue[index+1:]...)
return
}
}
func appendCodexFunctionCallsFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, responseData gjson.Result) []byte {
if params == nil {
return output
}
responseData.Get("output").ForEach(func(index, item gjson.Result) bool {
if item.Get("type").String() != "function_call" {
return true
}
keys := codexFunctionCallKeys(gjson.Result{}, item)
if itemOutputIndex := item.Get("output_index"); itemOutputIndex.Exists() {
keys = appendUniqueCodexFunctionCallKey(keys, "output:"+itemOutputIndex.Raw)
}
if index.Exists() {
keys = appendUniqueCodexFunctionCallKey(keys, "output:"+index.String())
}
call := codexFunctionCallForKeys(params, keys)
if call == nil {
call = &codexFunctionCallStream{BlockIndex: -1}
params.FunctionCallQueue = append(params.FunctionCallQueue, call)
}
addCodexFunctionCallAliases(params, call, keys)
updateCodexFunctionCallIdentity(params, call, gjson.Result{}, item)
updateCodexFunctionCallArguments(call, item.Get("arguments").String(), false)
call.Done = true
return true
})
queuedCalls := params.FunctionCallQueue[:0]
for _, call := range params.FunctionCallQueue {
if call.Closed {
continue
}
if call.Name == "" {
call.Closed = true
continue
}
call.Done = true
queuedCalls = append(queuedCalls, call)
}
params.FunctionCallQueue = queuedCalls
output = appendCodexFunctionCallQueue(output, params, originalRequestRawJSON)
clearCodexFunctionCalls(params)
return output
}
func clearCodexFunctionCalls(params *ConvertCodexResponseToClaudeParams) {
if params == nil {
return
}
clear(params.FunctionCalls)
params.FunctionCallQueue = nil
params.ActiveFunctionCall = nil
params.LastFunctionCall = nil
}
func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) string {
rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
if orig, ok := rev[name]; ok {
return orig
}
return name
}
func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) {
if !usage.Exists() || usage.Type == gjson.Null {
return 0, 0, 0
}
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
cachedTokens := usage.Get("input_tokens_details.cached_tokens").Int()
if cachedTokens > 0 {
if inputTokens >= cachedTokens {
inputTokens -= cachedTokens
} else {
inputTokens = 0
}
}
return inputTokens, outputTokens, cachedTokens
}
// buildReverseMapFromClaudeOriginalShortToOriginal builds a map[short]original from original Claude request tools.
func buildReverseMapFromClaudeOriginalShortToOriginal(original []byte) map[string]string {
tools := gjson.GetBytes(original, "tools")
rev := map[string]string{}
if !tools.IsArray() {
return rev
}
var names []string
arr := tools.Array()
for i := 0; i < len(arr); i++ {
n := arr[i].Get("name").String()
if n != "" {
names = append(names, n)
}
}
if len(names) > 0 {
m := buildShortNameMap(names)
for orig, short := range m {
rev[short] = orig
}
}
return rev
}
func ClaudeTokenCount(_ context.Context, count int64) []byte {
return translatorcommon.ClaudeInputTokensJSON(count)
}
func startCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
if params.TextBlockOpen {
return nil
}
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
params.TextBlockOpen = true
return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
}
func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
if !params.TextBlockOpen {
return nil
}
template := []byte(`{"type":"content_block_stop","index":0}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
params.TextBlockOpen = false
params.BlockIndex++
return translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", template, 2)
}
func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
if params.ThinkingBlockOpen {
return nil
}
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
params.ThinkingBlockOpen = true
return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
}
// appendCodexThinkingDelta emits a thinking_delta for the currently open thinking block.
func appendCodexThinkingDelta(params *ConvertCodexResponseToClaudeParams, text string) []byte {
if text == "" {
return nil
}
template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "delta.thinking", text)
return translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", template, 2)
}
func finalizeCodexSignatureOnlyThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
if params.ThinkingSignature == "" {
return nil
}
output := startCodexThinkingBlock(params)
output = append(output, finalizeCodexThinkingBlock(params)...)
return output
}
func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
if !params.ThinkingBlockOpen {
return nil
}
output := make([]byte, 0, 256)
if params.ThinkingSignature != "" {
signatureDelta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":""}}`)
signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", params.BlockIndex)
signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", params.ThinkingSignature)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", signatureDelta, 2)
}
contentBlockStop := []byte(`{"type":"content_block_stop","index":0}`)
contentBlockStop, _ = sjson.SetBytes(contentBlockStop, "index", params.BlockIndex)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", contentBlockStop, 2)
params.BlockIndex++
params.ThinkingBlockOpen = false
return output
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte {
toolUseID := codexWebSearchToolUseID(params, root, item)
if toolUseID == "" {
return output
}
if params.WebSearchToolUseIDs == nil {
params.WebSearchToolUseIDs = make(map[string]struct{})
}
query := codexWebSearchQuery(root, item)
alreadyStarted := false
if _, ok := params.WebSearchToolUseIDs[toolUseID]; ok {
alreadyStarted = true
if query == "" {
return output
}
}
if !alreadyStarted {
output = append(output, stopCodexTextBlock(params)...)
output = append(output, finalizeCodexThinkingBlock(params)...)
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"","name":"web_search","input":{}}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "content_block.id", toolUseID)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
}
if query != "" {
partialJSON, _ := json.Marshal(map[string]string{"query": query})
delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
delta, _ = sjson.SetBytes(delta, "index", params.BlockIndex)
delta, _ = sjson.SetBytes(delta, "delta.partial_json", string(partialJSON))
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", delta, 2)
}
if !alreadyStarted {
stop := []byte(`{"type":"content_block_stop","index":0}`)
stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2)
params.WebSearchToolUseIDs[toolUseID] = struct{}{}
params.BlockIndex++
}
return output
}
func appendCodexWebSearchToolResult(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte {
toolUseID := codexWebSearchToolUseID(params, root, item)
if toolUseID == "" {
return output
}
output = appendCodexWebSearchServerToolUse(output, params, root, item)
if params.WebSearchToolResultIDs == nil {
params.WebSearchToolResultIDs = make(map[string]struct{})
}
if _, ok := params.WebSearchToolResultIDs[toolUseID]; ok {
return output
}
if codexWebSearchQuery(root, item) == "" && len(codexWebSearchResultContent(root, item)) == 0 && item.Get("action").Exists() == false {
return output
}
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"web_search_tool_result","tool_use_id":"","content":[]}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "content_block.tool_use_id", toolUseID)
if content := codexWebSearchResultContent(root, item); len(content) > 0 {
template, _ = sjson.SetRawBytes(template, "content_block.content", content)
}
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
stop := []byte(`{"type":"content_block_stop","index":0}`)
stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2)
params.WebSearchToolResultIDs[toolUseID] = struct{}{}
params.BlockIndex++
if toolUseID == params.LastWebSearchToolUseID {
params.LastWebSearchToolUseID = ""
}
return output
}
func codexWebSearchToolUseID(params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) string {
for _, path := range []string{"id", "output_item_id", "call_id"} {
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
return value
}
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
return value
}
}
if params.LastWebSearchToolUseID != "" {
return params.LastWebSearchToolUseID
}
for _, path := range []string{"item_id"} {
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
return value
}
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
return value
}
}
id := fmt.Sprintf("web_search_%d", params.BlockIndex)
params.LastWebSearchToolUseID = id
return id
}
func codexWebSearchQuery(root, item gjson.Result) string {
for _, path := range []string{"action.query", "query", "input.query"} {
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
return value
}
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
return value
}
}
return ""
}
func codexWebSearchResultContent(root, item gjson.Result) []byte {
results := item.Get("results")
if !results.IsArray() {
results = root.Get("results")
}
if !results.IsArray() {
return nil
}
var resultBlocks [][]byte
results.ForEach(func(_, result gjson.Result) bool {
url := strings.TrimSpace(result.Get("url").String())
if url == "" {
return true
}
block := []byte(`{"type":"web_search_result","title":"","url":"","page_age":null}`)
block, _ = sjson.SetBytes(block, "url", url)
title := strings.TrimSpace(result.Get("title").String())
if title == "" {
title = url
}
block, _ = sjson.SetBytes(block, "title", title)
resultBlocks = append(resultBlocks, block)
return true
})
if len(resultBlocks) == 0 {
return []byte(`[]`)
}
return translatorcommon.JoinRawArray(resultBlocks)
}
func appendCodexWebSearchNonStreamBlocks(contentBlocks [][]byte, item gjson.Result, seen map[string]struct{}) [][]byte {
id := strings.TrimSpace(item.Get("id").String())
if id == "" {
return contentBlocks
}
if seen == nil {
seen = make(map[string]struct{})
}
if _, ok := seen[id]; ok {
return contentBlocks
}
emptyRoot := gjson.Result{}
query := codexWebSearchQuery(emptyRoot, item)
resultContent := codexWebSearchResultContent(emptyRoot, item)
if query == "" && len(resultContent) == 0 {
return contentBlocks
}
useBlock := []byte(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
useBlock, _ = sjson.SetBytes(useBlock, "id", id)
if query != "" {
input, _ := json.Marshal(map[string]string{"query": query})
useBlock, _ = sjson.SetRawBytes(useBlock, "input", input)
}
contentBlocks = append(contentBlocks, useBlock)
resultBlock := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
resultBlock, _ = sjson.SetBytes(resultBlock, "tool_use_id", id)
if len(resultContent) > 0 {
resultBlock, _ = sjson.SetRawBytes(resultBlock, "content", resultContent)
}
contentBlocks = append(contentBlocks, resultBlock)
seen[id] = struct{}{}
return contentBlocks
}
func appendCodexWebSearchNonStreamContent(out []byte, item gjson.Result, seen map[string]struct{}) []byte {
blocks := appendCodexWebSearchNonStreamBlocks(nil, item, seen)
for _, block := range blocks {
out, _ = sjson.SetRawBytes(out, "content.-1", block)
}
return out
}

View file

@ -0,0 +1,20 @@
package claude
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(
Claude,
Codex,
ConvertClaudeRequestToCodex,
interfaces.TranslateResponse{
Stream: ConvertCodexResponseToClaude,
NonStream: ConvertCodexResponseToClaudeNonStream,
TokenCount: ClaudeTokenCount,
},
)
}

View file

@ -0,0 +1,18 @@
package claude
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeRequestToCodexNormalizesNonStringToolName(t *testing.T) {
input := []byte(`{"messages":[],"tools":[{"name":123,"input_schema":{"type":"object"}}]}`)
output := ConvertClaudeRequestToCodex("gpt-test", input, false)
name := gjson.GetBytes(output, "tools.0.name")
if name.Type != gjson.String || name.String() != "123" {
t.Fatalf("tools.0.name = %s, want string 123", name.Raw)
}
}

View file

@ -0,0 +1,584 @@
// Package gemini provides request translation functionality for Codex to Gemini API compatibility.
// It handles parsing and transforming Codex API requests into Gemini API format,
// extracting model information, system instructions, message contents, and tool declarations.
// The package performs JSON data transformation to ensure compatibility
// between Codex API format and Gemini API's expected format.
package gemini
import (
"fmt"
"strconv"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertGeminiRequestToCodex parses and transforms a Gemini API request into Codex API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Codex API.
// The function performs comprehensive transformation including:
// 1. Model name mapping and generation configuration extraction
// 2. System instruction conversion to Codex format
// 3. Message content conversion with proper role mapping
// 4. Tool call and tool result handling with FIFO queue for ID matching
// 5. Tool declaration and tool choice configuration mapping
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the Gemini API
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
//
// Returns:
// - []byte: The transformed request data in Codex API format
func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
// Base template
out := []byte(`{"model":"","instructions":"","input":[]}`)
root := gjson.ParseBytes(rawJSON)
inputItems := translatorcommon.NewRawArrayItems(root.Get("contents.#").Int())
// Pre-compute tool name shortening map from declared functionDeclarations
shortMap := map[string]string{}
if tools := root.Get("tools"); tools.IsArray() {
var names []string
tarr := tools.Array()
for i := 0; i < len(tarr); i++ {
fns := tarr[i].Get("functionDeclarations")
if !fns.IsArray() {
continue
}
for _, fn := range fns.Array() {
if v := fn.Get("name"); v.Exists() {
names = append(names, v.String())
}
}
}
if len(names) > 0 {
shortMap = buildShortNameMap(names)
}
}
// helper for generating paired call IDs in the form: call_gemini_<seq>
// Gemini uses sequential pairing across possibly multiple in-flight
// functionCalls, so we keep a FIFO queue of generated call IDs and
// consume them in order when functionResponses arrive.
var pendingCallIDs []string
callCounter := 0
getGeminiCallID := func(value gjson.Result) string {
if callID := strings.TrimSpace(value.Get("id").String()); callID != "" {
return callID
}
return strings.TrimSpace(value.Get("call_id").String())
}
removePendingCallID := func(ids []string, callID string) []string {
if callID == "" {
return ids
}
for idx, pendingID := range ids {
if pendingID == callID {
return append(ids[:idx], ids[idx+1:]...)
}
}
return ids
}
// Model
out, _ = sjson.SetBytes(out, "model", modelName)
if serviceTier := normalizeGeminiCodexServiceTier(root.Get("service_tier")); serviceTier != "" {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier)
}
// System instruction -> as a user message with input_text parts
sysParts := root.Get("system_instruction.parts")
if !sysParts.Exists() {
sysParts = root.Get("systemInstruction.parts")
}
if sysParts.IsArray() {
contentItems := make([][]byte, 0, 2)
arr := sysParts.Array()
for i := 0; i < len(arr); i++ {
p := arr[i]
if translatorcommon.IsGeminiThoughtPart(p) {
continue
}
if t := p.Get("text"); t.Exists() {
part := []byte(`{}`)
part, _ = sjson.SetBytes(part, "type", "input_text")
part, _ = sjson.SetBytes(part, "text", t.String())
contentItems = append(contentItems, part)
}
}
if len(contentItems) > 0 {
msg := []byte(`{"type":"message","role":"developer","content":[]}`)
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
inputItems = append(inputItems, msg)
}
}
// Contents -> messages and function calls/results
contents := root.Get("contents")
if contents.IsArray() {
items := contents.Array()
for i := 0; i < len(items); i++ {
item := items[i]
role := item.Get("role").String()
if role == "model" {
role = "assistant"
}
parts := item.Get("parts")
if !parts.IsArray() {
continue
}
parr := parts.Array()
for j := 0; j < len(parr); j++ {
p := parr[j]
if translatorcommon.IsGeminiThoughtPart(p) {
continue
}
// text part
if t := p.Get("text"); t.Exists() {
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
part := []byte(`{}`)
part, _ = sjson.SetBytes(part, "type", partType)
part, _ = sjson.SetBytes(part, "text", t.String())
inputItems = append(inputItems, codexMessageWithPart(role, part))
continue
}
if contentPart, ok := codexContentPartFromGeminiInlineData(p); ok {
inputItems = append(inputItems, codexMessageWithPart(role, contentPart))
continue
}
if contentPart, ok := codexContentPartFromGeminiFileData(p); ok {
inputItems = append(inputItems, codexMessageWithPart(role, contentPart))
continue
}
// function call from model
if fc := p.Get("functionCall"); fc.Exists() {
fn := []byte(`{"type":"function_call"}`)
if name := fc.Get("name"); name.Exists() {
n := name.String()
if short, ok := shortMap[n]; ok {
n = short
} else {
n = shortenNameIfNeeded(n)
}
fn, _ = sjson.SetBytes(fn, "name", n)
}
if args := fc.Get("args"); args.Exists() {
fn, _ = sjson.SetBytes(fn, "arguments", args.Raw)
}
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
id := getGeminiCallID(fc)
if id == "" {
callCounter++
id = fmt.Sprintf("call_gemini_%016d", callCounter)
}
fn, _ = sjson.SetBytes(fn, "call_id", id)
pendingCallIDs = append(pendingCallIDs, id)
inputItems = append(inputItems, fn)
continue
}
// function response from user
if fr := p.Get("functionResponse"); fr.Exists() {
fno := []byte(`{"type":"function_call_output"}`)
// Prefer a string result if present; otherwise embed the raw response as a string
if res := fr.Get("response.result"); res.Exists() {
fno, _ = sjson.SetBytes(fno, "output", res.String())
} else if resp := fr.Get("response"); resp.Exists() {
fno, _ = sjson.SetBytes(fno, "output", resp.Raw)
}
// fno, _ = sjson.SetBytes(fno, "call_id", "call_W6nRJzFXyPM2LFBbfo98qAbq")
// attach the oldest queued call_id to pair the response
// with its call. If the queue is empty, generate a new id.
var id string
if customID := getGeminiCallID(fr); customID != "" {
id = customID
pendingCallIDs = removePendingCallID(pendingCallIDs, id)
} else if len(pendingCallIDs) > 0 {
id = pendingCallIDs[0]
// pop the first element
pendingCallIDs = pendingCallIDs[1:]
} else {
callCounter++
id = fmt.Sprintf("call_gemini_%016d", callCounter)
}
fno, _ = sjson.SetBytes(fno, "call_id", id)
inputItems = append(inputItems, fno)
continue
}
}
}
}
out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
// Tools mapping: Gemini functionDeclarations -> Codex tools
tools := root.Get("tools")
if tools.IsArray() {
var toolItems [][]byte
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
tarr := tools.Array()
for i := 0; i < len(tarr); i++ {
td := tarr[i]
fns := td.Get("functionDeclarations")
if !fns.IsArray() {
continue
}
farr := fns.Array()
for j := 0; j < len(farr); j++ {
fn := farr[j]
tool := []byte(`{}`)
tool, _ = sjson.SetBytes(tool, "type", "function")
if v := fn.Get("name"); v.Exists() {
name := v.String()
if short, ok := shortMap[name]; ok {
name = short
} else {
name = shortenNameIfNeeded(name)
}
tool, _ = sjson.SetBytes(tool, "name", name)
}
if v := fn.Get("description"); v.Exists() {
tool, _ = sjson.SetBytes(tool, "description", v.String())
}
if prm := fn.Get("parameters"); prm.Exists() {
cleaned := cleanGeminiCodexToolParameters(prm)
tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned)
} else if prm = fn.Get("parametersJsonSchema"); prm.Exists() {
cleaned := cleanGeminiCodexToolParameters(prm)
tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned)
}
tool, _ = sjson.SetBytes(tool, "strict", false)
toolItems = append(toolItems, tool)
}
}
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
// Fixed flags aligning with Codex expectations
out, _ = sjson.SetBytes(out, "parallel_tool_calls", true)
out = setCodexToolChoiceFromGeminiToolConfig(out, root.Get("toolConfig.functionCallingConfig"))
// Convert Gemini thinkingConfig to Codex reasoning.effort.
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
effortSet := false
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
thinkingLevel := genConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = genConfig.Get("thinking_level")
}
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
effortSet = true
}
} else if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
thinkingLevel := thinkingConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = thinkingConfig.Get("thinking_level")
}
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
effortSet = true
}
} else {
thinkingBudget := thinkingConfig.Get("thinkingBudget")
if !thinkingBudget.Exists() {
thinkingBudget = thinkingConfig.Get("thinking_budget")
}
if thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
effortSet = true
}
}
}
}
}
if !effortSet {
// No thinking config, set default effort
out, _ = sjson.SetBytes(out, "reasoning.effort", "medium")
}
// 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, "stream", true)
out, _ = sjson.SetBytes(out, "store", false)
out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"})
var pathsToLower []string
toolsResult := gjson.GetBytes(out, "tools")
util.Walk(toolsResult, "", "type", &pathsToLower)
for _, p := range pathsToLower {
fullPath := fmt.Sprintf("tools.%s", p)
typeValue := gjson.GetBytes(out, fullPath)
if typeValue.Type != gjson.String {
continue
}
normalizedType := strings.ToLower(typeValue.String())
if normalizedType == typeValue.String() {
continue
}
out, _ = sjson.SetBytes(out, fullPath, normalizedType)
}
return out
}
func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gjson.Result) []byte {
if !functionCallingConfig.Exists() {
return out
}
mode := functionCallingConfig.Get("mode").String()
switch mode {
case "NONE":
out, _ = sjson.SetBytes(out, "tool_choice", "none")
case "AUTO":
current := gjson.GetBytes(out, "tool_choice")
if current.Type != gjson.String || current.String() != "auto" {
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
}
case "ANY":
allowedNames := functionCallingConfig.Get("allowedFunctionNames")
allowedNameItems := allowedNames.Array()
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
choice := []byte(`{"type":"function","name":""}`)
choice, _ = sjson.SetBytes(choice, "name", shortenNameIfNeeded(allowedNameItems[0].String()))
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
} else {
out, _ = sjson.SetBytes(out, "tool_choice", "required")
}
}
return out
}
func cleanGeminiCodexToolParameters(parameters gjson.Result) []byte {
cleaned := []byte(parameters.Raw)
if parameters.Get("$schema").Exists() {
cleaned, _ = sjson.DeleteBytes(cleaned, "$schema")
}
if additionalProperties := parameters.Get("additionalProperties"); additionalProperties.Type != gjson.False {
cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
}
return cleaned
}
func codexMessageWithPart(role string, part []byte) []byte {
msg := []byte(`{"type":"message","role":"","content":[]}`)
msg, _ = sjson.SetBytes(msg, "role", role)
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{part}))
return msg
}
func normalizeGeminiCodexServiceTier(serviceTier gjson.Result) string {
if !serviceTier.Exists() || serviceTier.Type != gjson.String {
return ""
}
switch strings.ToLower(strings.TrimSpace(serviceTier.String())) {
case "priority", "fast":
return "priority"
}
return ""
}
func codexContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) {
inlineData := part.Get("inlineData")
if !inlineData.Exists() {
inlineData = part.Get("inline_data")
}
if !inlineData.Exists() {
return nil, false
}
mimeType := inlineData.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineData.Get("mime_type").String()
}
data := inlineData.Get("data").String()
if mimeType == "" || data == "" {
return nil, false
}
lowerMimeType := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
contentPart := []byte(`{"type":"input_image","image_url":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data))
return contentPart, true
case strings.HasPrefix(lowerMimeType, "audio/"):
contentPart := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "input_audio.data", data)
contentPart, _ = sjson.SetBytes(contentPart, "input_audio.format", codexInputAudioFormatFromMIME(mimeType))
return contentPart, true
default:
contentPart := []byte(`{"type":"input_file","file_data":"","filename":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "file_data", data)
contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType))
return contentPart, true
}
}
func codexContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) {
fileData := part.Get("fileData")
if !fileData.Exists() {
fileData = part.Get("file_data")
}
if !fileData.Exists() {
return nil, false
}
fileURI := fileData.Get("fileUri").String()
if fileURI == "" {
fileURI = fileData.Get("file_uri").String()
}
if fileURI == "" {
return nil, false
}
mimeType := fileData.Get("mimeType").String()
if mimeType == "" {
mimeType = fileData.Get("mime_type").String()
}
lowerMimeType := strings.ToLower(mimeType)
if strings.HasPrefix(lowerMimeType, "image/") {
contentPart := []byte(`{"type":"input_image","image_url":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "image_url", fileURI)
return contentPart, true
}
if strings.HasPrefix(lowerMimeType, "video/") || strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") {
contentPart := []byte(`{"type":"input_file","file_url":"","filename":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "file_url", fileURI)
contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType))
return contentPart, true
}
fileInfo := "File: " + fileURI
if mimeType != "" {
fileInfo += " (Type: " + mimeType + ")"
}
contentPart := []byte(`{"type":"input_text","text":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo)
return contentPart, true
}
func codexInputAudioFormatFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "audio/wav", "audio/wave", "audio/x-wav":
return "wav"
case "audio/flac":
return "flac"
case "audio/opus", "audio/ogg":
return "opus"
case "audio/pcm", "audio/l16":
return "pcm16"
default:
return "mp3"
}
}
func codexFileNameFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "application/pdf":
return "document.pdf"
case "text/plain":
return "document.txt"
case "text/csv":
return "document.csv"
case "application/json":
return "document.json"
case "application/xml", "text/xml":
return "document.xml"
default:
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") {
return "video"
}
return "document"
}
}
// shortenNameIfNeeded applies the simple shortening rule for a single name.
func shortenNameIfNeeded(name string) string {
const limit = 64
if len(name) <= limit {
return name
}
if strings.HasPrefix(name, "mcp__") {
idx := strings.LastIndex(name, "__")
if idx > 0 {
cand := "mcp__" + name[idx+2:]
if len(cand) > limit {
return cand[:limit]
}
return cand
}
}
return name[:limit]
}
// buildShortNameMap ensures uniqueness of shortened names within a request.
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,170 @@
package gemini
import (
"fmt"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) {
tests := []struct {
name string
callField string
responseField string
want string
}{
{
name: "id",
callField: `"id":"call_gateway_id"`,
responseField: `"id":"call_gateway_id"`,
want: "call_gateway_id",
},
{
name: "call_id",
callField: `"call_id":"call_gateway_call_id"`,
responseField: `"call_id":"call_gateway_call_id"`,
want: "call_gateway_call_id",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := []byte(fmt.Sprintf(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}}
]
}
]
}`, tt.callField, tt.responseField))
out := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
gotCallID := gjson.GetBytes(out, "input.0.call_id").String()
if gotCallID != tt.want {
t.Fatalf("expected function_call call_id %q, got %q; output=%s", tt.want, gotCallID, string(out))
}
gotOutputID := gjson.GetBytes(out, "input.1.call_id").String()
if gotOutputID != tt.want {
t.Fatalf("expected function_call_output call_id %q, got %q; output=%s", tt.want, gotOutputID, string(out))
}
})
}
}
func TestConvertGeminiRequestToCodex_AcceptsInlineData(t *testing.T) {
out := ConvertGeminiRequestToCodex("gpt-5.1-codex", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_image" {
t.Fatalf("content type = %q, want input_image. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image_url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToCodex_SplitsNonImageInlineDataByMIME(t *testing.T) {
out := ConvertGeminiRequestToCodex("gpt-5.1-codex", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.content.0.type").String(); got != "input_file" {
t.Fatalf("video content type = %q, want input_file. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.2.content.0.type").String(); got != "input_file" {
t.Fatalf("document content type = %q, want input_file. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToCodex_DropsHiddenThoughtParts(t *testing.T) {
t.Run("thought-only turn", func(t *testing.T) {
out := ConvertGeminiRequestToCodex("codex-test", []byte(`{
"contents":[
{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"}]},
{"role":"user","parts":[{"text":"continue"}]}
]
}`), false)
input := gjson.GetBytes(out, "input").Array()
if len(input) != 1 || input[0].Get("role").String() != "user" || input[0].Get("content.0.text").String() != "continue" {
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
}
})
t.Run("mixed turn", func(t *testing.T) {
out := ConvertGeminiRequestToCodex("codex-test", []byte(`{
"contents":[{"role":"model","parts":[
{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"},
{"text":"visible answer"}
]}]
}`), false)
input := gjson.GetBytes(out, "input").Array()
if len(input) != 1 || input[0].Get("content.0.type").String() != "output_text" || input[0].Get("content.0.text").String() != "visible answer" {
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
}
})
}
func TestConvertGeminiRequestToCodex_DeterministicCallIDs(t *testing.T) {
raw := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "first_tool", "args": {"q": "one"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "first_tool", "response": {"result": "ok1"}}}
]
},
{
"role": "model",
"parts": [
{"functionCall": {"name": "second_tool", "args": {"q": "two"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "second_tool", "response": {"result": "ok2"}}}
]
}
]
}`)
out1 := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
out2 := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
if string(out1) != string(out2) {
t.Fatalf("expected deterministic output across multiple conversions, got different outputs:\nout1=%s\nout2=%s", string(out1), string(out2))
}
wantID1 := "call_gemini_0000000000000001"
wantID2 := "call_gemini_0000000000000002"
gotCall1 := gjson.GetBytes(out1, "input.0.call_id").String()
gotResp1 := gjson.GetBytes(out1, "input.1.call_id").String()
gotCall2 := gjson.GetBytes(out1, "input.2.call_id").String()
gotResp2 := gjson.GetBytes(out1, "input.3.call_id").String()
if gotCall1 != wantID1 || gotResp1 != wantID1 {
t.Fatalf("expected first tool pair to have id %q, got call=%q, resp=%q", wantID1, gotCall1, gotResp1)
}
if gotCall2 != wantID2 || gotResp2 != wantID2 {
t.Fatalf("expected second tool pair to have id %q, got call=%q, resp=%q", wantID2, gotCall2, gotResp2)
}
}

View file

@ -0,0 +1,461 @@
// Package gemini provides response translation functionality for Codex to Gemini API compatibility.
// This package handles the conversion of Codex API responses into Gemini-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by Gemini API clients.
package gemini
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:")
)
// ConvertCodexResponseToGeminiParams holds parameters for response conversion.
type ConvertCodexResponseToGeminiParams struct {
Model string
CreatedAt int64
ResponseID string
LastStorageOutput []byte
HasOutputTextDelta bool
LastImageHashByID map[string][32]byte
}
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
// This function processes various Codex event types and transforms them into Gemini-compatible JSON responses.
// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.
// The function maintains state across multiple calls to ensure proper response sequencing.
//
// 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 Gemini-compatible JSON responses
func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertCodexResponseToGeminiParams{
Model: modelName,
CreatedAt: 0,
ResponseID: "",
LastStorageOutput: nil,
HasOutputTextDelta: false,
LastImageHashByID: make(map[string][32]byte),
}
}
if !bytes.HasPrefix(rawJSON, dataTag) {
return [][]byte{}
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
rootResult := gjson.ParseBytes(rawJSON)
typeResult := rootResult.Get("type")
typeStr := typeResult.String()
params := (*param).(*ConvertCodexResponseToGeminiParams)
// Base Gemini response template
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`)
{
template, _ = sjson.SetBytes(template, "modelVersion", params.Model)
createdAtResult := rootResult.Get("response.created_at")
if createdAtResult.Exists() {
params.CreatedAt = createdAtResult.Int()
template, _ = sjson.SetBytes(template, "createTime", time.Unix(params.CreatedAt, 0).Format(time.RFC3339Nano))
}
template, _ = sjson.SetBytes(template, "responseId", params.ResponseID)
}
if typeStr == "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 != "" {
if params.LastImageHashByID == nil {
params.LastImageHashByID = make(map[string][32]byte)
}
hash := sha256.Sum256([]byte(b64))
if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
return [][]byte{}
}
params.LastImageHashByID[itemID] = hash
}
outputFormat := rootResult.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
return [][]byte{template}
}
// Handle function call completion
if typeStr == "response.output_item.done" {
itemResult := rootResult.Get("item")
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 != "" {
if params.LastImageHashByID == nil {
params.LastImageHashByID = make(map[string][32]byte)
}
hash := sha256.Sum256([]byte(b64))
if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
return [][]byte{}
}
params.LastImageHashByID[itemID] = hash
}
outputFormat := itemResult.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
return [][]byte{template}
}
if itemType == "function_call" {
// Create function call part
functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
{
// Restore original tool name if shortened
n := itemResult.Get("name").String()
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
if orig, ok := rev[n]; ok {
n = orig
}
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", n)
}
// Parse and set arguments
argsStr := itemResult.Get("arguments").String()
if argsStr != "" {
argsResult := gjson.Parse(argsStr)
if argsResult.IsObject() {
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
}
}
functionCall = setGeminiFunctionCallID(functionCall, itemResult)
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{functionCall})
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
params.LastStorageOutput = append([]byte(nil), template...)
// Use this return to storage message
return [][]byte{}
}
}
if typeStr == "response.created" { // Handle response creation - set model and response ID
template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String())
template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String())
params.ResponseID = rootResult.Get("response.id").String()
} else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta
part := []byte(`{"thought":true,"text":""}`)
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
params.HasOutputTextDelta = true
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
} else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received
itemResult := rootResult.Get("item")
if itemResult.Get("type").String() != "message" || params.HasOutputTextDelta {
return [][]byte{}
}
contentResult := itemResult.Get("content")
if !contentResult.Exists() || !contentResult.IsArray() {
return [][]byte{}
}
wroteText := false
contentResult.ForEach(func(_, partResult gjson.Result) bool {
if partResult.Get("type").String() != "output_text" {
return true
}
text := partResult.Get("text").String()
if text == "" {
return true
}
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
wroteText = true
return true
})
if wroteText {
params.HasOutputTextDelta = true
return [][]byte{template}
}
return [][]byte{}
} else if typeStr == "response.completed" || typeStr == "response.incomplete" { // Handle response completion with usage metadata
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
totalTokens := rootResult.Get("response.usage.input_tokens").Int() + rootResult.Get("response.usage.output_tokens").Int()
template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", totalTokens)
if typeStr == "response.incomplete" {
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", codexGeminiIncompleteFinishReason(rootResult.Get("response.incomplete_details.reason").String()))
}
} else {
return [][]byte{}
}
if len(params.LastStorageOutput) > 0 {
stored := append([]byte(nil), params.LastStorageOutput...)
params.LastStorageOutput = nil
return [][]byte{stored, template}
}
return [][]byte{template}
}
// ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response.
// This function processes the complete Codex response and transforms it into a single Gemini-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 Gemini 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
// - 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: A Gemini-compatible JSON response containing all message content and metadata
func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName 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{}
}
// Base Gemini response template for non-streaming
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
// Set model version
template, _ = sjson.SetBytes(template, "modelVersion", modelName)
// Set response metadata from the completed response
responseData := rootResult.Get("response")
if responseData.Exists() {
if responseType == "response.incomplete" {
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", codexGeminiIncompleteFinishReason(responseData.Get("incomplete_details.reason").String()))
}
// Set response ID
if responseId := responseData.Get("id"); responseId.Exists() {
template, _ = sjson.SetBytes(template, "responseId", responseId.String())
}
// Set creation time
if createdAt := responseData.Get("created_at"); createdAt.Exists() {
template, _ = sjson.SetBytes(template, "createTime", time.Unix(createdAt.Int(), 0).Format(time.RFC3339Nano))
}
// Set usage metadata
if usage := responseData.Get("usage"); usage.Exists() {
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
totalTokens := inputTokens + outputTokens
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", inputTokens)
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", outputTokens)
template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", totalTokens)
}
// Process output content to build parts array
var parts [][]byte
var pendingFunctionCalls [][]byte
flushPendingFunctionCalls := func() {
if len(pendingFunctionCalls) == 0 {
return
}
// Add all pending function calls as individual parts
// This maintains the original Gemini API format while ensuring consecutive calls are grouped together
parts = append(parts, pendingFunctionCalls...)
pendingFunctionCalls = nil
}
if output := responseData.Get("output"); output.Exists() && output.IsArray() {
output.ForEach(func(key, value gjson.Result) bool {
itemType := value.Get("type").String()
switch itemType {
case "reasoning":
// Flush any pending function calls before adding non-function content
flushPendingFunctionCalls()
// Add thinking content
if content := value.Get("content"); content.Exists() {
part := []byte(`{"text":"","thought":true}`)
part, _ = sjson.SetBytes(part, "text", content.String())
parts = append(parts, part)
}
case "message":
// Flush any pending function calls before adding non-function content
flushPendingFunctionCalls()
// Add regular text content
if content := value.Get("content"); content.Exists() && content.IsArray() {
content.ForEach(func(_, contentItem gjson.Result) bool {
if contentItem.Get("type").String() == "output_text" {
if text := contentItem.Get("text"); text.Exists() {
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", text.String())
parts = append(parts, part)
}
}
return true
})
}
case "image_generation_call":
flushPendingFunctionCalls()
b64 := value.Get("result").String()
if b64 == "" {
break
}
outputFormat := value.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
parts = append(parts, part)
case "function_call":
// Collect function call for potential merging with consecutive ones
functionCall := []byte(`{"functionCall":{"args":{},"name":""}}`)
{
n := value.Get("name").String()
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
if orig, ok := rev[n]; ok {
n = orig
}
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", n)
}
// Parse and set arguments
if argsStr := value.Get("arguments").String(); argsStr != "" {
argsResult := gjson.Parse(argsStr)
if argsResult.IsObject() {
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
}
}
functionCall = setGeminiFunctionCallID(functionCall, value)
pendingFunctionCalls = append(pendingFunctionCalls, functionCall)
}
return true
})
// Handle any remaining pending function calls at the end
flushPendingFunctionCalls()
if len(parts) > 0 {
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts", translatorcommon.JoinRawArray(parts))
}
}
}
return template
}
// buildReverseMapFromGeminiOriginal builds a map[short]original from original Gemini request tools.
func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
tools := gjson.GetBytes(original, "tools")
rev := map[string]string{}
if !tools.IsArray() {
return rev
}
var names []string
tarr := tools.Array()
for i := 0; i < len(tarr); i++ {
fns := tarr[i].Get("functionDeclarations")
if !fns.IsArray() {
continue
}
for _, fn := range fns.Array() {
if v := fn.Get("name"); v.Exists() {
names = append(names, v.String())
}
}
}
if len(names) > 0 {
m := buildShortNameMap(names)
for orig, short := range m {
rev[short] = orig
}
}
return rev
}
func setGeminiFunctionCallID(functionCall []byte, item gjson.Result) []byte {
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", callID)
return functionCall
}
if id := strings.TrimSpace(item.Get("id").String()); id != "" {
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", id)
}
return functionCall
}
func codexGeminiIncompleteFinishReason(reason string) string {
switch reason {
case "max_tokens", "max_output_tokens":
return "MAX_TOKENS"
case "content_filter":
return "SAFETY"
default:
return "OTHER"
}
}
func GeminiTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.GeminiTokenCountJSON(count)
}
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,170 @@
package gemini
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertCodexResponseToGemini_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 := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", 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], "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
t.Fatalf("stream finishReason = %q, want MAX_TOKENS; payload=%s", got, streamOut[0])
}
nonStreamOut := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, terminal, nil)
if got := gjson.GetBytes(nonStreamOut, "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
t.Fatalf("non-stream finishReason = %q, want MAX_TOKENS; payload=%s", got, nonStreamOut)
}
}
func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)
var param any
chunks := [][]byte{
[]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"),
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
}
var outputs [][]byte
for _, chunk := range chunks {
outputs = append(outputs, ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, &param)...)
}
found := false
for _, out := range outputs {
if gjson.GetBytes(out, "candidates.0.content.parts.0.text").String() == "ok" {
found = true
break
}
}
if !found {
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
}
}
func TestConvertCodexResponseToGemini_StreamPartialImageEmitsInlineData(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)
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 := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, &param)
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
if got != "aGVsbG8=" {
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out[0]))
}
gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
if gotMime != "image/png" {
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out[0]))
}
out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, &param)
if len(out) != 0 {
t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out))
}
}
func TestConvertCodexResponseToGemini_StreamImageGenerationCallDoneEmitsInlineData(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)
var param any
out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, 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 = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, 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 = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, 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))
}
got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
if got != "Ymll" {
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "Ymll", got, string(out[0]))
}
gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
if gotMime != "image/jpeg" {
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/jpeg", gotMime, string(out[0]))
}
}
func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineDataPart(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`)
out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
got := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.data").String()
if got != "aGVsbG8=" {
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out))
}
gotMime := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.mimeType").String()
if gotMime != "image/png" {
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out))
}
}
func TestConvertCodexResponseToGemini_StreamPreservesFunctionCallID(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)
var param any
out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}}`), &param)
if len(out) != 0 {
t.Fatalf("expected function call output to be buffered, got %d chunks", len(out))
}
out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`), &param)
if len(out) == 0 {
t.Fatal("expected buffered function call to be emitted on completion")
}
got := ""
for _, chunk := range out {
if value := gjson.GetBytes(chunk, "candidates.0.content.parts.0.functionCall.id").String(); value != "" {
got = value
break
}
}
if got != "call_gateway" {
t.Fatalf("expected functionCall.id %q, got %q; chunks=%q", "call_gateway", got, out)
}
}
func TestConvertCodexResponseToGeminiNonStreamPreservesFunctionCallID(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}]}}`)
out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
if got != "call_gateway" {
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "call_gateway", got, string(out))
}
}

View file

@ -0,0 +1,20 @@
package gemini
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(
Gemini,
Codex,
ConvertGeminiRequestToCodex,
interfaces.TranslateResponse{
Stream: ConvertCodexResponseToGemini,
NonStream: ConvertCodexResponseToGeminiNonStream,
TokenCount: GeminiTokenCount,
},
)
}

View file

@ -0,0 +1,41 @@
package gemini
import (
"testing"
"github.com/tidwall/gjson"
)
func TestCleanGeminiCodexToolParametersPreservesCanonicalSchema(t *testing.T) {
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false}`)
output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input))
if string(output) != string(input) {
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
}
}
func TestSetCodexToolChoiceFromGeminiToolConfigReusesAutoChoice(t *testing.T) {
input := []byte(`{"tool_choice":"auto","input":[]}`)
config := gjson.Parse(`{"mode":"AUTO"}`)
output := setCodexToolChoiceFromGeminiToolConfig(input, config)
if &output[0] != &input[0] {
t.Fatal("AUTO tool choice caused a payload copy")
}
}
func TestCleanGeminiCodexToolParametersNormalizesSchema(t *testing.T) {
input := []byte(`{"type":"object","$schema":"draft","additionalProperties":true}`)
output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input))
if gjson.GetBytes(output, "$schema").Exists() {
t.Fatal("$schema should be removed")
}
if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False {
t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw)
}
}

View file

@ -0,0 +1,19 @@
package interactions
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(
Interactions,
Codex,
ConvertInteractionsRequestToCodex,
interfaces.TranslateResponse{
Stream: ConvertCodexResponseToInteractions,
NonStream: ConvertCodexResponseToInteractionsNonStream,
},
)
}

View file

@ -0,0 +1,727 @@
package interactions
import (
"encoding/json"
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","instructions":"","input":[]}`)
out, _ = sjson.SetBytes(out, "model", modelName)
if stream || root.Get("stream").Bool() {
out, _ = sjson.SetBytes(out, "stream", true)
}
out = copyInteractionsSystemToCodex(out, root)
out = copyInteractionsGenerationConfigToCodex(out, root)
inputItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int())
appendInteractionsInputToCodex(&inputItems, root.Get("input"))
out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
out = copyInteractionsToolsToCodex(out, root)
out = copyInteractionsCodexTopLevel(out, root)
return out
}
func copyInteractionsSystemToCodex(out []byte, root gjson.Result) []byte {
systemInstruction := root.Get("system_instruction")
if !systemInstruction.Exists() {
systemInstruction = root.Get("systemInstruction")
}
if !systemInstruction.Exists() {
return out
}
if systemInstruction.Type == gjson.String {
out, _ = sjson.SetBytes(out, "instructions", systemInstruction.String())
return out
}
if text := systemInstruction.Get("text"); text.Exists() && text.Type == gjson.String {
out, _ = sjson.SetBytes(out, "instructions", text.String())
return out
}
if parts := systemInstruction.Get("parts"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
text := part.Get("text").String()
if text == "" {
return true
}
if builder.Len() > 0 {
builder.WriteByte('\n')
}
builder.WriteString(text)
return true
})
if builder.Len() > 0 {
out, _ = sjson.SetBytes(out, "instructions", builder.String())
}
}
return out
}
func copyInteractionsGenerationConfigToCodex(out []byte, root gjson.Result) []byte {
cfg := root.Get("generation_config")
if !cfg.Exists() {
cfg = root.Get("generationConfig")
}
if !cfg.Exists() {
if reasoning := root.Get("reasoning"); reasoning.Exists() {
out, _ = sjson.SetRawBytes(out, "reasoning", []byte(reasoning.Raw))
}
return out
}
if reasoning := cfg.Get("reasoning"); reasoning.Exists() {
out, _ = sjson.SetRawBytes(out, "reasoning", []byte(reasoning.Raw))
}
if effort := interactionsCodexReasoningEffort(cfg); effort != "" {
out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
}
if summary := interactionsCodexReasoningSummary(cfg); summary != "" {
out, _ = sjson.SetBytes(out, "reasoning.summary", summary)
}
copyRawPaths := map[string]string{
"max_output_tokens": "max_output_tokens",
"maxOutputTokens": "max_output_tokens",
"max_tokens": "max_output_tokens",
"temperature": "temperature",
"top_p": "top_p",
"topP": "top_p",
"presence_penalty": "presence_penalty",
"presencePenalty": "presence_penalty",
"frequency_penalty": "frequency_penalty",
"frequencyPenalty": "frequency_penalty",
"parallel_tool_calls": "parallel_tool_calls",
"parallelToolCalls": "parallel_tool_calls",
"response_format": "response_format",
"responseFormat": "response_format",
"text": "text",
"verbosity": "text.verbosity",
"truncation": "truncation",
"tool_choice": "tool_choice",
"toolChoice": "tool_choice",
"service_tier": "service_tier",
"serviceTier": "service_tier",
}
for sourcePath, targetPath := range copyRawPaths {
if value := cfg.Get(sourcePath); value.Exists() {
out, _ = sjson.SetRawBytes(out, targetPath, []byte(value.Raw))
}
}
return out
}
func interactionsCodexReasoningEffort(cfg gjson.Result) string {
for _, path := range []string{
"thinking_level",
"thinkingLevel",
"thinking_config.thinking_level",
"thinking_config.thinkingLevel",
"thinkingConfig.thinking_level",
"thinkingConfig.thinkingLevel",
"reasoning.effort",
} {
if value := cfg.Get(path); value.Exists() {
effort := strings.ToLower(strings.TrimSpace(value.String()))
if effort != "" {
return effort
}
}
}
for _, path := range []string{
"thinking_budget",
"thinkingBudget",
"thinking_config.thinking_budget",
"thinking_config.thinkingBudget",
"thinkingConfig.thinking_budget",
"thinkingConfig.thinkingBudget",
} {
if value := cfg.Get(path); value.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(value.Int())); ok {
return effort
}
}
}
return ""
}
func interactionsCodexReasoningSummary(cfg gjson.Result) string {
for _, path := range []string{
"thinking_summaries",
"thinkingSummaries",
"reasoning.summary",
} {
if value := cfg.Get(path); value.Type == gjson.String {
summary := strings.ToLower(strings.TrimSpace(value.String()))
switch summary {
case "auto", "none":
return summary
}
}
}
for _, path := range []string{
"include_thoughts",
"includeThoughts",
"thinking_config.include_thoughts",
"thinking_config.includeThoughts",
"thinkingConfig.include_thoughts",
"thinkingConfig.includeThoughts",
} {
switch value := cfg.Get(path); value.Type {
case gjson.True:
return "auto"
case gjson.False:
return "none"
}
}
return ""
}
func appendInteractionsInputToCodex(items *[][]byte, input gjson.Result) {
if !input.Exists() {
return
}
if input.Type == gjson.String {
appendInteractionsTextToCodex(items, "user", input.String())
return
}
if input.IsArray() {
input.ForEach(func(_, step gjson.Result) bool {
appendInteractionsStepToCodex(items, step, "user")
return true
})
return
}
if steps := input.Get("steps"); steps.Exists() && steps.IsArray() {
defaultRole := interactionsCodexDefaultRole(input.Get("role").String(), "user")
steps.ForEach(func(_, step gjson.Result) bool {
appendInteractionsStepToCodex(items, step, defaultRole)
return true
})
return
}
appendInteractionsStepToCodex(items, input, "user")
}
func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRole string) {
if step.Type == gjson.String {
appendInteractionsTextToCodex(items, defaultRole, step.String())
return
}
if steps := step.Get("steps"); steps.Exists() && steps.IsArray() {
role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole)
steps.ForEach(func(_, nested gjson.Result) bool {
appendInteractionsStepToCodex(items, nested, role)
return true
})
return
}
stepType := strings.ToLower(strings.TrimSpace(step.Get("type").String()))
switch stepType {
case "function_call":
appendInteractionsFunctionCallToCodex(items, step)
case "function_result", "function_call_output":
appendInteractionsFunctionResultToCodex(items, step)
case "model_output", "assistant":
appendInteractionsContentToCodexItem(items, step.Get("content"), "assistant")
case "thought", "reasoning":
appendInteractionsThoughtToCodex(items, step)
case "user_input", "message", "":
role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole)
if content := step.Get("content"); content.Exists() {
appendInteractionsContentToCodexItem(items, content, role)
} else if text := step.Get("text"); text.Exists() {
appendInteractionsTextToCodex(items, role, text.String())
}
default:
role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole)
if content := step.Get("content"); content.Exists() {
appendInteractionsContentToCodexItem(items, content, role)
} else if text := step.Get("text"); text.Exists() {
appendInteractionsTextToCodex(items, role, text.String())
}
}
}
func appendInteractionsContentToCodexItem(items *[][]byte, content gjson.Result, role string) {
if !content.Exists() {
return
}
if content.Type == gjson.String {
appendInteractionsTextToCodex(items, role, content.String())
return
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
if item := interactionsCodexMessagePart(part, role); len(item) > 0 {
appendInteractionsMessagePartToCodex(items, role, item)
}
return true
})
return
}
if content.IsObject() {
if item := interactionsCodexMessagePart(content, role); len(item) > 0 {
appendInteractionsMessagePartToCodex(items, role, item)
}
}
}
func appendInteractionsFunctionCallToCodex(items *[][]byte, step gjson.Result) {
item := []byte(`{"type":"function_call"}`)
if name := step.Get("name"); name.Exists() {
item, _ = sjson.SetBytes(item, "name", shortenCodexToolNameIfNeeded(name.String()))
}
if callID := interactionsCodexCallID(step); callID != "" {
item, _ = sjson.SetBytes(item, "call_id", callID)
}
if args := step.Get("arguments"); args.Exists() {
item, _ = sjson.SetBytes(item, "arguments", interactionsCodexJSONString(args))
} else if args := step.Get("args"); args.Exists() {
item, _ = sjson.SetBytes(item, "arguments", interactionsCodexJSONString(args))
}
*items = append(*items, item)
}
func appendInteractionsFunctionResultToCodex(items *[][]byte, step gjson.Result) {
item := []byte(`{"type":"function_call_output"}`)
if callID := interactionsCodexCallID(step); callID != "" {
item, _ = sjson.SetBytes(item, "call_id", callID)
}
if result := step.Get("result"); result.Exists() {
item, _ = sjson.SetBytes(item, "output", interactionsCodexOutputString(result))
} else if output := step.Get("output"); output.Exists() {
item, _ = sjson.SetBytes(item, "output", interactionsCodexOutputString(output))
}
*items = append(*items, item)
}
func copyInteractionsToolsToCodex(out []byte, root gjson.Result) []byte {
tools := root.Get("tools")
if !tools.Exists() {
return out
}
if !tools.IsArray() {
out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
return out
}
normalized := make([]map[string]any, 0)
tools.ForEach(func(_, tool gjson.Result) bool {
if decls := tool.Get("function_declarations"); decls.Exists() {
appendCodexToolDeclarations(&normalized, decls)
return true
}
if decls := tool.Get("functionDeclarations"); decls.Exists() {
appendCodexToolDeclarations(&normalized, decls)
return true
}
if name := tool.Get("name"); name.Exists() {
normalized = append(normalized, codexToolFromDeclaration(tool))
}
return true
})
if len(normalized) == 0 {
out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
return out
}
raw, errMarshal := json.Marshal(normalized)
if errMarshal != nil {
out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
return out
}
out, _ = sjson.SetRawBytes(out, "tools", raw)
if !gjson.GetBytes(out, "tool_choice").Exists() {
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
}
return out
}
func copyInteractionsCodexTopLevel(out []byte, root gjson.Result) []byte {
if serviceTier := normalizeInteractionsCodexServiceTier(root.Get("service_tier")); serviceTier != "" {
current := gjson.GetBytes(out, "service_tier")
if current.Type != gjson.String || current.String() != serviceTier {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier)
}
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
out = setInteractionsCodexRawIfDifferent(out, "tool_choice", toolChoice)
}
for _, path := range []string{"parallel_tool_calls", "store", "metadata", "include", "truncation"} {
if value := root.Get(path); value.Exists() {
out = setInteractionsCodexRawIfDifferent(out, path, value)
}
}
return out
}
func setInteractionsCodexRawIfDifferent(out []byte, path string, value gjson.Result) []byte {
current := gjson.GetBytes(out, path)
if current.Exists() && current.Raw == value.Raw {
return out
}
updated, errSet := sjson.SetRawBytes(out, path, []byte(value.Raw))
if errSet != nil {
return out
}
return updated
}
func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result) {
text := interactionsCodexContentText(step.Get("content"))
if text == "" {
text = step.Get("text").String()
}
item := []byte(`{"type":"reasoning"}`)
if text != "" {
item, _ = sjson.SetBytes(item, "content", text)
}
if id := step.Get("id"); id.Exists() {
item, _ = sjson.SetBytes(item, "id", id.String())
}
*items = append(*items, item)
}
func appendInteractionsTextToCodex(items *[][]byte, role, text string) {
part := []byte(`{"type":"","text":""}`)
if role == "assistant" {
part, _ = sjson.SetBytes(part, "type", "output_text")
} else {
part, _ = sjson.SetBytes(part, "type", "input_text")
}
part, _ = sjson.SetBytes(part, "text", text)
appendInteractionsMessagePartToCodex(items, role, part)
}
func appendInteractionsMessagePartToCodex(items *[][]byte, role string, part []byte) {
message := []byte(`{"type":"message","role":"","content":[]}`)
message, _ = sjson.SetBytes(message, "role", role)
message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray([][]byte{part}))
*items = append(*items, message)
}
func interactionsCodexMessagePart(part gjson.Result, role string) []byte {
if text := part.Get("text"); text.Exists() {
item := []byte(`{"type":"","text":""}`)
if role == "assistant" {
item, _ = sjson.SetBytes(item, "type", "output_text")
} else {
item, _ = sjson.SetBytes(item, "type", "input_text")
}
item, _ = sjson.SetBytes(item, "text", text.String())
return item
}
partType := strings.ToLower(strings.TrimSpace(part.Get("type").String()))
switch partType {
case "text", "":
return nil
case "image":
return interactionsCodexImagePart(part)
case "image_url":
item := []byte(`{"type":"input_image","image_url":""}`)
item, _ = sjson.SetBytes(item, "image_url", part.Get("image_url.url").String())
return item
case "audio":
return interactionsCodexAudioPart(part)
case "input_audio":
item := []byte(`{"type":"input_audio","input_audio":{}}`)
if audio := part.Get("input_audio"); audio.Exists() {
item, _ = sjson.SetRawBytes(item, "input_audio", []byte(audio.Raw))
}
return item
case "video", "document", "file":
return interactionsCodexFilePart(part)
default:
if inline := part.Get("inline_data"); inline.Exists() {
return interactionsCodexInlinePart(inline)
}
if inline := part.Get("inlineData"); inline.Exists() {
return interactionsCodexInlinePart(inline)
}
if file := part.Get("file_data"); file.Exists() {
return interactionsCodexFileDataPart(file)
}
if file := part.Get("fileData"); file.Exists() {
return interactionsCodexFileDataPart(file)
}
}
return nil
}
func interactionsCodexImagePart(part gjson.Result) []byte {
if url := part.Get("url"); url.Exists() {
item := []byte(`{"type":"input_image","image_url":""}`)
item, _ = sjson.SetBytes(item, "image_url", url.String())
return item
}
if fileURI := firstString(part, "file_uri", "fileUri"); fileURI != "" {
item := []byte(`{"type":"input_image","image_url":""}`)
item, _ = sjson.SetBytes(item, "image_url", fileURI)
return item
}
mimeType := firstString(part, "mime_type", "mimeType")
data := part.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
item := []byte(`{"type":"input_image","image_url":""}`)
item, _ = sjson.SetBytes(item, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data))
return item
}
func interactionsCodexAudioPart(part gjson.Result) []byte {
mimeType := firstString(part, "mime_type", "mimeType")
data := part.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
item := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
item, _ = sjson.SetBytes(item, "input_audio.data", data)
item, _ = sjson.SetBytes(item, "input_audio.format", codexInputAudioFormatFromMIME(mimeType))
return item
}
func interactionsCodexFilePart(part gjson.Result) []byte {
if fileData := part.Get("file.file_data").String(); fileData != "" {
item := []byte(`{"type":"input_file","file_data":"","filename":""}`)
item, _ = sjson.SetBytes(item, "file_data", fileData)
item, _ = sjson.SetBytes(item, "filename", part.Get("file.filename").String())
return item
}
mimeType := firstString(part, "mime_type", "mimeType")
if fileURI := firstString(part, "file_uri", "fileUri", "url"); fileURI != "" {
item := []byte(`{"type":"input_file","file_url":"","filename":""}`)
item, _ = sjson.SetBytes(item, "file_url", fileURI)
item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType))
return item
}
data := part.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
item := []byte(`{"type":"input_file","file_data":"","filename":""}`)
item, _ = sjson.SetBytes(item, "file_data", data)
item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType))
return item
}
func interactionsCodexInlinePart(inline gjson.Result) []byte {
mimeType := firstString(inline, "mime_type", "mimeType")
data := inline.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
switch {
case strings.HasPrefix(strings.ToLower(mimeType), "image/"):
return interactionsCodexImagePart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
case strings.HasPrefix(strings.ToLower(mimeType), "audio/"):
return interactionsCodexAudioPart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
default:
return interactionsCodexFilePart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
func interactionsCodexFileDataPart(fileData gjson.Result) []byte {
mimeType := firstString(fileData, "mime_type", "mimeType")
fileURI := firstString(fileData, "file_uri", "fileUri")
if fileURI == "" {
return nil
}
if strings.HasPrefix(strings.ToLower(mimeType), "image/") {
item := []byte(`{"type":"input_image","image_url":""}`)
item, _ = sjson.SetBytes(item, "image_url", fileURI)
return item
}
item := []byte(`{"type":"input_file","file_url":"","filename":""}`)
item, _ = sjson.SetBytes(item, "file_url", fileURI)
item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType))
return item
}
func appendCodexToolDeclarations(normalized *[]map[string]any, declarations gjson.Result) {
if !declarations.IsArray() {
return
}
declarations.ForEach(func(_, declaration gjson.Result) bool {
if declaration.Get("name").Exists() {
*normalized = append(*normalized, codexToolFromDeclaration(declaration))
}
return true
})
}
func codexToolFromDeclaration(declaration gjson.Result) map[string]any {
tool := map[string]any{
"type": "function",
"name": shortenCodexToolNameIfNeeded(declaration.Get("name").String()),
"strict": false,
}
if desc := declaration.Get("description"); desc.Exists() {
tool["description"] = desc.String()
}
if params := declaration.Get("parameters"); params.Exists() {
tool["parameters"] = cleanedCodexToolParameters(params)
} else if params := declaration.Get("parametersJsonSchema"); params.Exists() {
tool["parameters"] = cleanedCodexToolParameters(params)
} else if params := declaration.Get("parameters_json_schema"); params.Exists() {
tool["parameters"] = cleanedCodexToolParameters(params)
}
return tool
}
func cleanedCodexToolParameters(params gjson.Result) json.RawMessage {
cleaned := []byte(params.Raw)
if params.Get("$schema").Exists() {
cleaned, _ = sjson.DeleteBytes(cleaned, "$schema")
}
if params.Get("additionalProperties").Type != gjson.False {
cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
}
return json.RawMessage(cleaned)
}
func interactionsCodexContentText(content gjson.Result) string {
if !content.Exists() {
return ""
}
if content.Type == gjson.String {
return content.String()
}
if content.IsObject() {
return content.Get("text").String()
}
if content.IsArray() {
var builder strings.Builder
content.ForEach(func(_, part gjson.Result) bool {
text := part.Get("text").String()
if text == "" {
return true
}
if builder.Len() > 0 {
builder.WriteByte('\n')
}
builder.WriteString(text)
return true
})
return builder.String()
}
return ""
}
func interactionsCodexCallID(step gjson.Result) string {
if callID := strings.TrimSpace(step.Get("call_id").String()); callID != "" {
return callID
}
return strings.TrimSpace(step.Get("id").String())
}
func interactionsCodexJSONString(value gjson.Result) string {
if value.Type == gjson.String {
return value.String()
}
if value.Exists() {
return value.Raw
}
return "{}"
}
func interactionsCodexOutputString(value gjson.Result) string {
if value.Type == gjson.String {
return value.String()
}
if value.Exists() {
return value.Raw
}
return ""
}
func interactionsCodexDefaultRole(role, fallback string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case "model", "assistant":
return "assistant"
case "developer", "system":
return "developer"
case "user":
return "user"
}
if fallback == "assistant" || fallback == "developer" {
return fallback
}
return "user"
}
func normalizeInteractionsCodexServiceTier(serviceTier gjson.Result) string {
if !serviceTier.Exists() || serviceTier.Type != gjson.String {
return ""
}
switch strings.ToLower(strings.TrimSpace(serviceTier.String())) {
case "priority", "fast":
return "priority"
}
return ""
}
func codexInputAudioFormatFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "audio/wav", "audio/wave", "audio/x-wav":
return "wav"
case "audio/flac":
return "flac"
case "audio/opus", "audio/ogg":
return "opus"
case "audio/pcm", "audio/l16":
return "pcm16"
default:
return "mp3"
}
}
func codexFileNameFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "application/pdf":
return "document.pdf"
case "text/plain":
return "document.txt"
case "text/csv":
return "document.csv"
case "application/json":
return "document.json"
case "application/xml", "text/xml":
return "document.xml"
default:
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") {
return "video"
}
return "document"
}
}
func shortenCodexToolNameIfNeeded(name string) string {
const limit = 64
if len(name) <= limit {
return name
}
if strings.HasPrefix(name, "mcp__") {
idx := strings.LastIndex(name, "__")
if idx > 0 {
candidate := "mcp__" + name[idx+2:]
if len(candidate) > limit {
return candidate[:limit]
}
return candidate
}
}
return name[:limit]
}
func firstString(root gjson.Result, paths ...string) string {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value.String()
}
}
return ""
}

View file

@ -0,0 +1,595 @@
package interactions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type codexToInteractionsStreamState struct {
Started bool
Completed bool
Done bool
ActiveStepOpen bool
ActiveStepType string
ActiveStepIndex int
StepIndex int
ID string
Model string
CreatedAt int64
HasOutputText bool
FunctionCallName string
FunctionCallID string
}
func ConvertCodexResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &codexToInteractionsStreamState{
ID: fmt.Sprintf("interaction_%d", time.Now().UnixNano()),
Model: modelName,
}
}
st := (*param).(*codexToInteractionsStreamState)
payload := codexStreamPayload(rawJSON)
if bytes.Equal(payload, []byte("[DONE]")) {
out := appendCodexInteractionsStepStop(nil, st)
if !st.Completed {
out = appendCodexInteractionsCompleted(out, st, gjson.Result{})
}
return appendCodexInteractionsDone(out, st)
}
if len(payload) == 0 {
return nil
}
root := gjson.ParseBytes(payload)
switch root.Get("type").String() {
case "response.created":
return appendCodexInteractionsCreated(nil, st, root.Get("response"))
case "response.output_item.added":
return codexOutputItemAddedToInteractions(st, root)
case "response.output_text.delta":
return codexOutputTextDeltaToInteractions(st, root)
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
return codexReasoningDeltaToInteractions(st, root)
case "response.function_call_arguments.delta":
return codexFunctionArgumentsDeltaToInteractions(st, root)
case "response.output_item.done":
return codexOutputItemDoneToInteractions(st, root.Get("item"))
case "response.completed", "response.incomplete":
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
out = appendCodexInteractionsStepStop(out, st)
out = appendCodexInteractionsCompleted(out, st, root.Get("response"))
return appendCodexInteractionsDone(out, st)
default:
return nil
}
}
func ConvertCodexResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
response := root.Get("response")
if !response.Exists() {
response = root
}
out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
if status := response.Get("status").String(); status != "" {
out, _ = sjson.SetBytes(out, "status", status)
}
id := response.Get("id").String()
if id == "" {
id = fmt.Sprintf("interaction_%d", time.Now().UnixNano())
}
out, _ = sjson.SetBytes(out, "id", id)
if model := response.Get("model").String(); model != "" {
out, _ = sjson.SetBytes(out, "model", model)
} else {
out, _ = sjson.SetBytes(out, "model", modelName)
}
var steps [][]byte
response.Get("output").ForEach(func(_, item gjson.Result) bool {
switch item.Get("type").String() {
case "message":
if step := buildCodexMessageItemToInteractions(item); len(step) > 0 {
steps = append(steps, step)
}
case "reasoning":
if step := buildCodexReasoningItemToInteractions(item); len(step) > 0 {
steps = append(steps, step)
}
case "function_call", "tool_call":
if step := buildCodexFunctionCallItemToInteractions(item); len(step) > 0 {
steps = append(steps, step)
}
case "image_generation_call":
if step := buildCodexImageItemToInteractions(item); len(step) > 0 {
steps = append(steps, step)
}
}
return true
})
if len(steps) > 0 {
out = translatorcommon.SetRawArrayItems(out, "steps", steps)
}
out = setCodexInteractionsUsage(out, "usage", response.Get("usage"), false)
return out
}
func codexStreamPayload(rawJSON []byte) []byte {
rawJSON = bytes.TrimSpace(rawJSON)
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[len("data:"):])
}
return rawJSON
}
func codexStreamEventType(rawJSON []byte) string {
payload := codexStreamPayload(rawJSON)
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
return ""
}
return gjson.GetBytes(payload, "type").String()
}
func appendCodexInteractionsCreated(out [][]byte, st *codexToInteractionsStreamState, response gjson.Result) [][]byte {
if st.Started {
return out
}
if id := response.Get("id").String(); id != "" {
st.ID = id
}
if model := response.Get("model").String(); model != "" {
st.Model = model
}
if createdAt := response.Get("created_at"); createdAt.Exists() {
st.CreatedAt = createdAt.Int()
}
created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
created, _ = sjson.SetBytes(created, "interaction.model", st.Model)
out = append(out, translatorcommon.SSEEventData("interaction.created", created))
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
st.Started = true
return out
}
func appendCodexInteractionsCompleted(out [][]byte, st *codexToInteractionsStreamState, response gjson.Result) [][]byte {
if st.Completed {
return out
}
created := time.Now().UTC()
if st.CreatedAt > 0 {
created = time.Unix(st.CreatedAt, 0).UTC()
}
completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID)
completed, _ = sjson.SetBytes(completed, "interaction.created", created.Format(time.RFC3339))
completed, _ = sjson.SetBytes(completed, "interaction.updated", time.Now().UTC().Format(time.RFC3339))
completed, _ = sjson.SetBytes(completed, "interaction.model", st.Model)
if status := response.Get("status").String(); status != "" {
completed, _ = sjson.SetBytes(completed, "interaction.status", status)
}
completed = setCodexInteractionsUsage(completed, "interaction.usage", response.Get("usage"), true)
out = append(out, translatorcommon.SSEEventData("interaction.completed", completed))
st.Completed = true
return out
}
func appendCodexInteractionsDone(out [][]byte, st *codexToInteractionsStreamState) [][]byte {
if st.Done {
return out
}
out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]")))
st.Done = true
return out
}
func codexOutputItemAddedToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
item := root.Get("item")
switch item.Get("type").String() {
case "message":
return ensureCodexInteractionsStep(out, st, "model_output", item)
case "reasoning":
return ensureCodexInteractionsStep(out, st, "thought", item)
case "function_call", "tool_call":
st.FunctionCallName = item.Get("name").String()
st.FunctionCallID = codexItemCallID(item)
return ensureCodexInteractionsStep(out, st, "function_call", item)
}
return out
}
func codexOutputTextDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
out = ensureCodexInteractionsStep(out, st, "model_output", gjson.Result{})
delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.text", root.Get("delta").String())
st.HasOutputText = true
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
func codexReasoningDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
out = ensureCodexInteractionsStep(out, st, "thought", gjson.Result{})
delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.content.text", root.Get("delta").String())
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
func codexFunctionArgumentsDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
out = ensureCodexInteractionsStep(out, st, "function_call", root.Get("item"))
delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.arguments", root.Get("delta").String())
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
func codexOutputItemDoneToInteractions(st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, gjson.Result{})
switch item.Get("type").String() {
case "message":
if st.HasOutputText {
return appendCodexInteractionsStepStop(out, st)
}
out = appendCodexMessageItemToInteractionsStream(out, st, item)
return appendCodexInteractionsStepStop(out, st)
case "reasoning":
out = appendCodexReasoningItemToInteractionsStream(out, st, item)
return appendCodexInteractionsStepStop(out, st)
case "function_call", "tool_call":
out = appendCodexFunctionCallItemToInteractionsStream(out, st, item)
return appendCodexInteractionsStepStop(out, st)
case "image_generation_call":
out = appendCodexImageItemToInteractionsStream(out, st, item)
return appendCodexInteractionsStepStop(out, st)
}
return out
}
func ensureCodexInteractionsStep(out [][]byte, st *codexToInteractionsStreamState, stepType string, item gjson.Result) [][]byte {
if st.ActiveStepOpen && st.ActiveStepType == stepType {
return out
}
out = appendCodexInteractionsStepStop(out, st)
return appendCodexInteractionsStepStart(out, st, stepType, item)
}
func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStreamState, stepType string, item gjson.Result) [][]byte {
st.ActiveStepIndex = st.StepIndex
st.StepIndex++
st.ActiveStepOpen = true
st.ActiveStepType = stepType
stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex)
stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType)
if stepType == "function_call" {
name := item.Get("name").String()
if name == "" {
name = st.FunctionCallName
}
callID := codexItemCallID(item)
if callID == "" {
callID = st.FunctionCallID
}
if callID == "" {
callID = fmt.Sprintf("step_%d", time.Now().UnixNano())
}
stepStart, _ = sjson.SetBytes(stepStart, "step.id", callID)
stepStart, _ = sjson.SetBytes(stepStart, "step.call_id", callID)
stepStart, _ = sjson.SetBytes(stepStart, "step.name", name)
stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`))
}
return append(out, translatorcommon.SSEEventData("step.start", stepStart))
}
func appendCodexInteractionsStepStop(out [][]byte, st *codexToInteractionsStreamState) [][]byte {
if !st.ActiveStepOpen {
return out
}
stepStop := []byte(`{"index":0,"event_type":"step.stop"}`)
stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", stepStop))
st.ActiveStepOpen = false
st.ActiveStepType = ""
return out
}
func buildCodexMessageItemToInteractions(item gjson.Result) []byte {
var contents [][]byte
item.Get("content").ForEach(func(_, content gjson.Result) bool {
if contentItem := codexContentToInteractionsContent(content); len(contentItem) > 0 {
contents = append(contents, contentItem)
}
return true
})
if len(contents) == 0 {
return nil
}
step := []byte(`{"type":"model_output","content":[]}`)
return translatorcommon.SetRawArrayItems(step, "content", contents)
}
func buildCodexReasoningItemToInteractions(item gjson.Result) []byte {
text := codexReasoningText(item)
if text == "" {
return nil
}
step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`)
step, _ = sjson.SetBytes(step, "content.0.text", text)
return step
}
func buildCodexFunctionCallItemToInteractions(item gjson.Result) []byte {
step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
step, _ = sjson.SetBytes(step, "name", item.Get("name").String())
if callID := codexItemCallID(item); callID != "" {
step, _ = sjson.SetBytes(step, "call_id", callID)
}
if args := codexArgumentsJSON(item.Get("arguments")); len(args) > 0 {
step, _ = sjson.SetRawBytes(step, "arguments", args)
}
return step
}
func buildCodexImageItemToInteractions(item gjson.Result) []byte {
result := item.Get("result").String()
if result == "" {
return nil
}
step := []byte(`{"type":"model_output","content":[{"type":"image","mime_type":"","data":""}]}`)
step, _ = sjson.SetBytes(step, "content.0.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String()))
step, _ = sjson.SetBytes(step, "content.0.data", result)
return step
}
func appendCodexMessageItemToInteractions(out []byte, item gjson.Result) []byte {
if step := buildCodexMessageItemToInteractions(item); len(step) > 0 {
out, _ = sjson.SetRawBytes(out, "steps.-1", step)
}
return out
}
func appendCodexReasoningItemToInteractions(out []byte, item gjson.Result) []byte {
if step := buildCodexReasoningItemToInteractions(item); len(step) > 0 {
out, _ = sjson.SetRawBytes(out, "steps.-1", step)
}
return out
}
func appendCodexFunctionCallItemToInteractions(out []byte, item gjson.Result) []byte {
if step := buildCodexFunctionCallItemToInteractions(item); len(step) > 0 {
out, _ = sjson.SetRawBytes(out, "steps.-1", step)
}
return out
}
func appendCodexImageItemToInteractions(out []byte, item gjson.Result) []byte {
if step := buildCodexImageItemToInteractions(item); len(step) > 0 {
out, _ = sjson.SetRawBytes(out, "steps.-1", step)
}
return out
}
func appendCodexMessageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
item.Get("content").ForEach(func(_, content gjson.Result) bool {
if text := codexContentText(content); text != "" {
out = ensureCodexInteractionsStep(out, st, "model_output", item)
delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.text", text)
out = append(out, translatorcommon.SSEEventData("step.delta", delta))
}
return true
})
return out
}
func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
text := codexReasoningText(item)
if text == "" {
return out
}
out = ensureCodexInteractionsStep(out, st, "thought", item)
delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.content.text", text)
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
func appendCodexFunctionCallItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
out = ensureCodexInteractionsStep(out, st, "function_call", item)
delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.arguments", item.Get("arguments").String())
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
func appendCodexImageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
result := item.Get("result").String()
if result == "" {
return out
}
out = ensureCodexInteractionsStep(out, st, "model_output", item)
delta := []byte(`{"index":0,"delta":{"content":{"type":"image","mime_type":"","data":""},"type":"content"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.content.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String()))
delta, _ = sjson.SetBytes(delta, "delta.content.data", result)
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
func codexContentToInteractionsContent(content gjson.Result) []byte {
if text := codexContentText(content); text != "" {
item := []byte(`{"type":"text","text":""}`)
item, _ = sjson.SetBytes(item, "text", text)
return item
}
return nil
}
func codexContentText(content gjson.Result) string {
for _, path := range []string{"text", "content"} {
if value := content.Get(path); value.Exists() && value.Type == gjson.String {
return value.String()
}
}
return ""
}
func codexReasoningText(item gjson.Result) string {
if content := item.Get("content"); content.Exists() {
if content.Type == gjson.String {
return content.String()
}
if content.IsArray() {
var builder strings.Builder
content.ForEach(func(_, part gjson.Result) bool {
text := codexContentText(part)
if text == "" {
text = part.Get("summary_text").String()
}
if text == "" {
return true
}
if builder.Len() > 0 {
builder.WriteByte('\n')
}
builder.WriteString(text)
return true
})
return builder.String()
}
}
if summary := item.Get("summary"); summary.Exists() {
if summary.Type == gjson.String {
return summary.String()
}
if summary.IsArray() {
var builder strings.Builder
summary.ForEach(func(_, part gjson.Result) bool {
text := codexContentText(part)
if text == "" {
return true
}
if builder.Len() > 0 {
builder.WriteByte('\n')
}
builder.WriteString(text)
return true
})
return builder.String()
}
}
return ""
}
func codexItemCallID(item gjson.Result) string {
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
return callID
}
return strings.TrimSpace(item.Get("id").String())
}
func codexArgumentsJSON(arguments gjson.Result) []byte {
if !arguments.Exists() {
return nil
}
if arguments.Type == gjson.String {
parsed := gjson.Parse(arguments.String())
if parsed.Exists() && parsed.IsObject() {
return []byte(arguments.String())
}
return []byte(`{}`)
}
if arguments.IsObject() {
return []byte(arguments.Raw)
}
return nil
}
func setCodexInteractionsUsage(out []byte, path string, usage gjson.Result, stream bool) []byte {
if !usage.Exists() {
return out
}
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
if inputTokens == 0 {
inputTokens = usage.Get("prompt_tokens").Int()
}
if outputTokens == 0 {
outputTokens = usage.Get("completion_tokens").Int()
}
totalTokens := usage.Get("total_tokens").Int()
if totalTokens == 0 {
totalTokens = inputTokens + outputTokens
}
reasoningTokens := usage.Get("output_tokens_details.reasoning_tokens").Int()
if reasoningTokens == 0 {
reasoningTokens = usage.Get("reasoning_tokens").Int()
}
cachedTokens := usage.Get("input_tokens_details.cached_tokens").Int()
if cachedTokens == 0 {
cachedTokens = usage.Get("cached_tokens").Int()
}
if stream {
out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens)
out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens)))
out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens)
out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens)
out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0)
out, _ = sjson.SetBytes(out, path+".total_thought_tokens", reasoningTokens)
return out
}
out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens)
out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens)
out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
if reasoningTokens > 0 {
out, _ = sjson.SetBytes(out, path+".reasoning_tokens", reasoningTokens)
}
if cachedTokens > 0 {
out, _ = sjson.SetBytes(out, path+".cached_tokens", cachedTokens)
}
return out
}
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,220 @@
package interactions
import (
"bytes"
"context"
"strings"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToCodexWithToolMessagesDirect(t *testing.T) {
out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","content":[{"type":"text","text":"thinking"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}`), false)
if got := gjson.GetBytes(out, "instructions").String(); got != "be brief" {
t.Fatalf("instructions = %q, want be brief. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("input.0.content.0.text = %q, want hi. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.type").String(); got != "reasoning" {
t.Fatalf("input.1.type = %q, want reasoning. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_call" {
t.Fatalf("input.2.type = %q, want function_call. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.2.call_id").String(); got != "call_1" {
t.Fatalf("function_call call_id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.3.type").String(); got != "function_call_output" {
t.Fatalf("input.3.type = %q, want function_call_output. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "contents").Exists() || gjson.GetBytes(out, "systemInstruction").Exists() {
t.Fatalf("Codex request must not use foreign request shape. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToCodexPreservesNonImageMediaContent(t *testing.T) {
out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","input":[{"type":"model_output","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.role").String(); got != "assistant" {
t.Fatalf("input.0.role = %q, want assistant. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.content.0.type").String(); got != "input_file" {
t.Fatalf("video content type = %q, want input_file. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.2.content.0.type").String(); got != "input_file" {
t.Fatalf("document content type = %q, want input_file. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToCodexPreservesTopLevelThinkingLevel(t *testing.T) {
out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","generation_config":{"thinking_level":"high"},"input":"hi"}`), true)
if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" {
t.Fatalf("reasoning.effort = %q, want high. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToCodexUsesBodyStream(t *testing.T) {
out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","stream":true,"input":"hi"}`), false)
if got := gjson.GetBytes(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToCodexFunctionDeclarations(t *testing.T) {
out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","input":"hi","tools":[{"function_declarations":[{"name":"lookup","description":"Lookup data","parameters":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","properties":{"q":{"type":"string"}}}}]}]}`), false)
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "tools.0.parameters.$schema").Exists() {
t.Fatalf("tool parameters should not keep $schema. Output: %s", string(out))
}
}
func TestConvertCodexResponseToInteractionsIncompleteTerminal(t *testing.T) {
raw := []byte(`{"type":"response.incomplete","response":{"id":"resp_1","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
nonStreamOut := ConvertCodexResponseToInteractionsNonStream(context.Background(), "codex-test", nil, nil, raw, nil)
if got := gjson.GetBytes(nonStreamOut, "status").String(); got != "incomplete" {
t.Fatalf("non-stream status = %q, want incomplete. Output: %s", got, nonStreamOut)
}
var param any
streamOut := ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, append([]byte("data: "), raw...), &param)
payload := findCodexInteractionsEventPayload(streamOut, "interaction.completed")
if len(payload) == 0 {
t.Fatalf("stream incomplete event did not terminate interaction: %q", streamOut)
}
if got := gjson.GetBytes(payload, "interaction.status").String(); got != "incomplete" {
t.Fatalf("stream status = %q, want incomplete. Payload: %s", got, payload)
}
}
func TestConvertCodexResponseToInteractionsNonStream(t *testing.T) {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"reasoning","content":"thinking"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}]}}`)
out := ConvertCodexResponseToInteractionsNonStream(context.Background(), "codex-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.1.type").String(); got != "thought" {
t.Fatalf("steps.1.type = %q, want thought. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.2.type").String(); got != "function_call" {
t.Fatalf("steps.2.type = %q, want function_call. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
}
func TestConvertCodexResponseToInteractionsStream(t *testing.T) {
var param any
events := ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"ok"}`), &param)
payload := findCodexInteractionsEventPayload(events, "step.delta")
if len(payload) == 0 {
t.Fatalf("step.delta event not found: %q", events)
}
if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" {
t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload))
}
}
func TestConvertCodexResponseToInteractionsStreamFunctionCallStartHasCallID(t *testing.T) {
var param any
events := ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}}`), &param)
payload := findCodexInteractionsEventPayload(events, "step.start")
if got := gjson.GetBytes(payload, "step.call_id").String(); got != "call_1" {
t.Fatalf("step.call_id = %q, want call_1. Payload: %s", got, string(payload))
}
}
func TestConvertCodexResponseToInteractionsStreamCompletesAfterSteps(t *testing.T) {
var param any
var events [][]byte
for _, chunk := range [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"codex-test"}}`),
[]byte(`data: {"type":"response.output_text.delta","delta":"我将调用工具。"}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"weather\"}"},"output_index":1}`),
[]byte(`data: {"type":"response.completed","response":{"id":"resp_1","output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`),
} {
events = append(events, ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, chunk, &param)...)
}
got := strings.Join(codexInteractionsEventNames(events), ",")
want := "interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed,done"
if got != want {
t.Fatalf("events = %s, want %s", got, want)
}
completed := findCodexInteractionsEventPayload(events, "interaction.completed")
if gotTokens := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
t.Fatalf("total_tokens = %d, want 3. Payload: %s", gotTokens, string(completed))
}
}
func findCodexInteractionsEventPayload(events [][]byte, eventType string) []byte {
prefix := []byte("data:")
for _, event := range events {
eventName := codexInteractionsFrameEventName(event)
for _, line := range bytes.Split(event, []byte("\n")) {
line = bytes.TrimSpace(line)
if !bytes.HasPrefix(line, prefix) {
continue
}
payload := bytes.TrimSpace(line[len(prefix):])
if codexInteractionsEventName(eventName, payload) == eventType {
return payload
}
}
}
return nil
}
func codexInteractionsEventNames(events [][]byte) []string {
names := make([]string, 0, len(events))
for _, event := range events {
eventName := codexInteractionsFrameEventName(event)
for _, line := range bytes.Split(event, []byte("\n")) {
line = bytes.TrimSpace(line)
if !bytes.HasPrefix(line, []byte("data:")) {
continue
}
payload := bytes.TrimSpace(line[len("data:"):])
if name := codexInteractionsEventName(eventName, payload); name != "" {
names = append(names, name)
}
}
}
return names
}
func codexInteractionsEventName(eventName string, payload []byte) string {
if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
return eventType
}
if eventType := gjson.GetBytes(payload, "type").String(); eventType != "" {
return eventType
}
return eventName
}
func codexInteractionsFrameEventName(event []byte) string {
for _, line := range bytes.Split(event, []byte("\n")) {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("event:")) {
return strings.TrimSpace(string(line[len("event:"):]))
}
}
return ""
}

View file

@ -0,0 +1,28 @@
package interactions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestCleanedCodexToolParametersPreservesCanonicalSchema(t *testing.T) {
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false}`)
output := []byte(cleanedCodexToolParameters(gjson.ParseBytes(input)))
if string(output) != string(input) {
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
}
}
func TestSetInteractionsCodexRawIfDifferentReusesMatchingValue(t *testing.T) {
input := []byte(`{"tool_choice":"auto","input":[]}`)
value := gjson.Parse(`"auto"`)
output := setInteractionsCodexRawIfDifferent(input, "tool_choice", value)
if &output[0] != &input[0] {
t.Fatal("matching raw value caused a payload copy")
}
}

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

View file

@ -0,0 +1,312 @@
package responses
import (
"bytes"
"encoding/json"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
inputResult := util.GetGJSONBytesNoCopy(rawJSON, "input")
if inputResult.Type == gjson.String {
input, _ := sjson.SetBytes([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`), "0.content.0.text", inputResult.String())
rawJSON, _ = sjson.SetRawBytes(rawJSON, "input", input)
inputResult = util.GetGJSONBytesNoCopy(rawJSON, "input")
}
rawJSON = setCodexRequiredBool(rawJSON, "stream", true)
rawJSON = setCodexRequiredBool(rawJSON, "store", false)
rawJSON = setCodexRequiredBool(rawJSON, "parallel_tool_calls", true)
rawJSON = setCodexRequiredInclude(rawJSON)
// Codex Responses rejects token limit fields, so strip them out before forwarding.
rawJSON = deleteCodexRequestFields(rawJSON, "max_output_tokens", "max_completion_tokens", "temperature", "top_p")
if serviceTier := gjson.GetBytes(rawJSON, "service_tier"); serviceTier.Exists() && serviceTier.String() != "priority" {
rawJSON = deleteCodexRequestFields(rawJSON, "service_tier")
}
rawJSON = deleteCodexRequestFields(rawJSON, "truncation", "prompt_cache_options", "prompt_cache_retention")
rawJSON = stripCodexResponsesCacheBreakpoints(rawJSON)
rawJSON = applyResponsesCompactionCompatibility(rawJSON)
// Delete the user field as it is not supported by the Codex upstream.
rawJSON = deleteCodexRequestFields(rawJSON, "user")
// Convert role "system" to "developer" in input array to comply with Codex API requirements.
rawJSON = convertSystemRoleToDeveloper(rawJSON)
rawJSON = normalizeCodexBuiltinTools(rawJSON)
return rawJSON
}
func setCodexRequiredBool(rawJSON []byte, path string, value bool) []byte {
current := gjson.GetBytes(rawJSON, path)
if value && current.Type == gjson.True || !value && current.Type == gjson.False {
return rawJSON
}
updated, errSet := sjson.SetBytes(rawJSON, path, value)
if errSet != nil {
return rawJSON
}
return updated
}
func setCodexRequiredInclude(rawJSON []byte) []byte {
current := gjson.GetBytes(rawJSON, "include")
values := current.Array()
if current.IsArray() && len(values) == 1 && values[0].Type == gjson.String && values[0].String() == "reasoning.encrypted_content" {
return rawJSON
}
updated, errSet := sjson.SetRawBytes(rawJSON, "include", []byte(`["reasoning.encrypted_content"]`))
if errSet != nil {
return rawJSON
}
return updated
}
func deleteCodexRequestFields(rawJSON []byte, paths ...string) []byte {
for _, path := range paths {
if !gjson.GetBytes(rawJSON, path).Exists() {
continue
}
updated, errDelete := sjson.DeleteBytes(rawJSON, path)
if errDelete == nil {
rawJSON = updated
}
}
return rawJSON
}
// stripCodexResponsesCacheBreakpoints removes any "prompt_cache_breakpoint" hint
// attached to individual input[].content[] items. Some clients (e.g. GitHub
// Copilot CLI) attach this field per content item when targeting the OpenAI
// Responses format. Codex Responses rejects it outright:
// {"error":{"message":"prompt_cache_breakpoint is not supported on this model", ...}}.
// The top-level prompt_cache_options strip above does not cover this nested case.
func stripCodexResponsesCacheBreakpoints(rawJSON []byte) []byte {
if !bytes.Contains(rawJSON, []byte(`"prompt_cache_breakpoint"`)) {
return rawJSON
}
input := util.GetGJSONBytesNoCopy(rawJSON, "input")
if !input.IsArray() {
return rawJSON
}
inputItems := input.Array()
if len(inputItems) == 0 {
return rawJSON
}
changed := false
rebuiltInput := make([][]byte, 0, len(inputItems))
for _, item := range inputItems {
itemRaw := []byte(item.Raw)
content := item.Get("content")
if content.IsArray() {
updatedContent, contentChanged := stripPromptCacheBreakpointFromContent(content)
if contentChanged {
if updatedItem, errSet := sjson.SetRawBytes(itemRaw, "content", updatedContent); errSet == nil {
itemRaw = updatedItem
changed = true
}
}
}
rebuiltInput = append(rebuiltInput, itemRaw)
}
if !changed {
return rawJSON
}
updated, errSet := sjson.SetRawBytes(rawJSON, "input", translatorcommon.JoinRawArray(rebuiltInput))
if errSet != nil {
return rawJSON
}
return updated
}
// stripPromptCacheBreakpointFromContent removes "prompt_cache_breakpoint" from each
// content part that carries it and reports whether anything changed.
func stripPromptCacheBreakpointFromContent(content gjson.Result) ([]byte, bool) {
parts := content.Array()
hasBreakpoint := false
for _, part := range parts {
if part.Get("prompt_cache_breakpoint").Exists() {
hasBreakpoint = true
break
}
}
if !hasBreakpoint {
return nil, false
}
changed := false
rebuiltParts := make([][]byte, 0, len(parts))
for _, part := range parts {
partRaw := []byte(part.Raw)
if part.Get("prompt_cache_breakpoint").Exists() {
if updated, errDelete := sjson.DeleteBytes(partRaw, "prompt_cache_breakpoint"); errDelete == nil {
partRaw = updated
changed = true
}
}
rebuiltParts = append(rebuiltParts, partRaw)
}
if !changed {
return nil, false
}
return translatorcommon.JoinRawArray(rebuiltParts), true
}
// applyResponsesCompactionCompatibility handles OpenAI Responses context_management.compaction
// for Codex upstream compatibility.
//
// Codex /responses currently rejects context_management with:
// {"detail":"Unsupported parameter: context_management"}.
//
// Compatibility strategy:
// 1) Remove context_management before forwarding to Codex upstream.
func applyResponsesCompactionCompatibility(rawJSON []byte) []byte {
if !gjson.GetBytes(rawJSON, "context_management").Exists() {
return rawJSON
}
rawJSON, _ = sjson.DeleteBytes(rawJSON, "context_management")
return rawJSON
}
// convertSystemRoleToDeveloper traverses the input array and converts any message items
// with role "system" to role "developer". This is necessary because Codex API does not
// accept "system" role in the input array.
func convertSystemRoleToDeveloper(rawJSON []byte) []byte {
return convertSystemRoleToDeveloperWithInput(rawJSON, util.GetGJSONBytesNoCopy(rawJSON, "input"))
}
func convertSystemRoleToDeveloperWithInput(rawJSON []byte, inputResult gjson.Result) []byte {
if !inputResult.IsArray() {
return rawJSON
}
inputItems := inputResult.Array()
if len(inputItems) == 0 {
return rawJSON
}
hasSystemRole := false
for _, item := range inputItems {
if item.IsObject() && item.Get("role").String() == "system" {
hasSystemRole = true
break
}
}
if !hasSystemRole {
return rawJSON
}
changed := false
rebuiltInput := make([]json.RawMessage, 0, len(inputItems))
for _, item := range inputItems {
itemRaw := []byte(item.Raw)
if item.IsObject() && item.Get("role").String() == "system" {
updatedItem, errSetItem := sjson.SetRawBytes(itemRaw, "role", []byte(`"developer"`))
if errSetItem != nil {
return rawJSON
}
itemRaw = updatedItem
changed = true
}
rebuiltInput = append(rebuiltInput, json.RawMessage(itemRaw))
}
if !changed {
return rawJSON
}
inputRaw, errMarshalInput := json.Marshal(rebuiltInput)
if errMarshalInput != nil {
return rawJSON
}
updated, errSetInput := sjson.SetRawBytes(rawJSON, "input", inputRaw)
if errSetInput != nil {
return rawJSON
}
return updated
}
// normalizeCodexBuiltinTools rewrites legacy/preview built-in tool variants to the
// stable names expected by the current Codex upstream.
func normalizeCodexBuiltinTools(rawJSON []byte) []byte {
result := normalizeCodexBuiltinToolArray(rawJSON, "tools")
result = normalizeCodexBuiltinToolAtPath(result, "tool_choice.type")
return normalizeCodexBuiltinToolArray(result, "tool_choice.tools")
}
func normalizeCodexBuiltinToolArray(rawJSON []byte, path string) []byte {
tools := gjson.GetBytes(rawJSON, path)
if !tools.IsArray() {
return rawJSON
}
changed := false
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
item := []byte(tool.Raw)
currentType := tool.Get("type").String()
normalizedType := normalizeCodexBuiltinToolType(currentType)
if normalizedType != "" {
updated, errSetType := sjson.SetBytes(item, "type", normalizedType)
if errSetType == nil {
item = updated
changed = true
log.Debugf("codex responses: normalized builtin tool type at %s.%d.type from %q to %q", path, len(toolItems), currentType, normalizedType)
}
}
toolItems = append(toolItems, item)
return true
})
if !changed {
return rawJSON
}
updated, errSetTools := sjson.SetRawBytes(rawJSON, path, translatorcommon.JoinRawArray(toolItems))
if errSetTools != nil {
return rawJSON
}
return updated
}
func normalizeCodexBuiltinToolAtPath(rawJSON []byte, path string) []byte {
currentType := gjson.GetBytes(rawJSON, path).String()
normalizedType := normalizeCodexBuiltinToolType(currentType)
if normalizedType == "" {
return rawJSON
}
updated, err := sjson.SetBytes(rawJSON, path, normalizedType)
if err != nil {
return rawJSON
}
log.Debugf("codex responses: normalized builtin tool type at %s from %q to %q", path, currentType, normalizedType)
return updated
}
// normalizeCodexBuiltinToolType centralizes the current known Codex Responses
// built-in tool alias compatibility. If Codex introduces more legacy aliases,
// extend this helper instead of adding path-specific rewrite logic elsewhere.
func normalizeCodexBuiltinToolType(toolType string) string {
switch toolType {
case "web_search_preview", "web_search_preview_2025_03_11":
return "web_search"
default:
return ""
}
}

View file

@ -0,0 +1,680 @@
package responses
import (
"fmt"
"strconv"
"strings"
"testing"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var benchmarkConvertSystemRoleOutput []byte
var benchmarkConvertNormalizedOutput []byte
// TestConvertSystemRoleToDeveloper_BasicConversion tests the basic system -> developer role conversion
func TestConvertSystemRoleToDeveloper_BasicConversion(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "You are a pirate."}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Say hello."}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that system role was converted to developer
firstItemRole := gjson.Get(outputStr, "input.0.role")
if firstItemRole.String() != "developer" {
t.Errorf("Expected role 'developer', got '%s'", firstItemRole.String())
}
// Check that user role remains unchanged
secondItemRole := gjson.Get(outputStr, "input.1.role")
if secondItemRole.String() != "user" {
t.Errorf("Expected role 'user', got '%s'", secondItemRole.String())
}
// Check content is preserved
firstItemContent := gjson.Get(outputStr, "input.0.content.0.text")
if firstItemContent.String() != "You are a pirate." {
t.Errorf("Expected content 'You are a pirate.', got '%s'", firstItemContent.String())
}
}
// TestConvertSystemRoleToDeveloper_MultipleSystemMessages tests conversion with multiple system messages
func TestConvertSystemRoleToDeveloper_MultipleSystemMessages(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "You are helpful."}]
},
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "Be concise."}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that both system roles were converted
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected first role 'developer', got '%s'", firstRole.String())
}
secondRole := gjson.Get(outputStr, "input.1.role")
if secondRole.String() != "developer" {
t.Errorf("Expected second role 'developer', got '%s'", secondRole.String())
}
// Check that user role is unchanged
thirdRole := gjson.Get(outputStr, "input.2.role")
if thirdRole.String() != "user" {
t.Errorf("Expected third role 'user', got '%s'", thirdRole.String())
}
}
// TestConvertSystemRoleToDeveloper_NoSystemMessages tests that requests without system messages are unchanged
func TestConvertSystemRoleToDeveloper_NoSystemMessages(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hi there!"}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that user and assistant roles are unchanged
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "user" {
t.Errorf("Expected role 'user', got '%s'", firstRole.String())
}
secondRole := gjson.Get(outputStr, "input.1.role")
if secondRole.String() != "assistant" {
t.Errorf("Expected role 'assistant', got '%s'", secondRole.String())
}
}
// TestConvertSystemRoleToDeveloper_EmptyInput tests that empty input arrays are handled correctly
func TestConvertSystemRoleToDeveloper_EmptyInput(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": []
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that input is still an empty array
inputArray := gjson.Get(outputStr, "input")
if !inputArray.IsArray() {
t.Error("Input should still be an array")
}
if len(inputArray.Array()) != 0 {
t.Errorf("Expected empty array, got %d items", len(inputArray.Array()))
}
}
// TestConvertSystemRoleToDeveloper_NoInputField tests that requests without input field are unchanged
func TestConvertSystemRoleToDeveloper_NoInputField(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"stream": false
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that other fields are still set correctly
stream := gjson.Get(outputStr, "stream")
if !stream.Bool() {
t.Error("Stream should be set to true by conversion")
}
store := gjson.Get(outputStr, "store")
if store.Bool() {
t.Error("Store should be set to false by conversion")
}
}
// TestConvertOpenAIResponsesRequestToCodex_OriginalIssue tests the exact issue reported by the user
func TestConvertOpenAIResponsesRequestToCodex_OriginalIssue(t *testing.T) {
// This is the exact input that was failing with "System messages are not allowed"
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": "You are a pirate. Always respond in pirate speak."
},
{
"type": "message",
"role": "user",
"content": "Say hello."
}
],
"stream": false
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Verify system role was converted to developer
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected role 'developer', got '%s'", firstRole.String())
}
// Verify stream was set to true (as required by Codex)
stream := gjson.Get(outputStr, "stream")
if !stream.Bool() {
t.Error("Stream should be set to true")
}
// Verify other required fields for Codex
store := gjson.Get(outputStr, "store")
if store.Bool() {
t.Error("Store should be false")
}
parallelCalls := gjson.Get(outputStr, "parallel_tool_calls")
if !parallelCalls.Bool() {
t.Error("parallel_tool_calls should be true")
}
include := gjson.Get(outputStr, "include")
if !include.IsArray() || len(include.Array()) != 1 {
t.Error("include should be an array with one element")
} else if include.Array()[0].String() != "reasoning.encrypted_content" {
t.Errorf("Expected include[0] to be 'reasoning.encrypted_content', got '%s'", include.Array()[0].String())
}
}
func TestConvertOpenAIResponsesRequestToCodexReusesNormalizedPayload(t *testing.T) {
inputJSON := []byte(`{"model":"gpt-5.6","stream":true,"store":false,"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"service_tier":"priority","input":[{"type":"message","role":"user","content":"hello"}]}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
if &output[0] != &inputJSON[0] {
t.Fatal("normalized request payload was copied")
}
if string(output) != string(inputJSON) {
t.Fatalf("normalized request changed:\n got: %s\nwant: %s", output, inputJSON)
}
}
func TestConvertOpenAIResponsesRequestToCodexNormalizesRequiredFields(t *testing.T) {
inputJSON := []byte(`{
"model":"gpt-5.6",
"stream":"true",
"store":true,
"parallel_tool_calls":false,
"include":["file_search_call.results","reasoning.encrypted_content"],
"max_output_tokens":4096,
"max_completion_tokens":4096,
"temperature":0.2,
"top_p":0.9,
"service_tier":"standard",
"truncation":"auto",
"prompt_cache_options":{"mode":"implicit"},
"prompt_cache_retention":"24h",
"user":"request-owner",
"input":[{"type":"message","role":"system","content":"hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
if stream := gjson.GetBytes(output, "stream"); stream.Type != gjson.True {
t.Fatalf("stream = %s, want true", stream.Raw)
}
if store := gjson.GetBytes(output, "store"); store.Type != gjson.False {
t.Fatalf("store = %s, want false", store.Raw)
}
if parallel := gjson.GetBytes(output, "parallel_tool_calls"); parallel.Type != gjson.True {
t.Fatalf("parallel_tool_calls = %s, want true", parallel.Raw)
}
include := gjson.GetBytes(output, "include").Array()
if len(include) != 1 || include[0].Type != gjson.String || include[0].String() != "reasoning.encrypted_content" {
t.Fatalf("include = %s, want reasoning.encrypted_content only", gjson.GetBytes(output, "include").Raw)
}
if role := gjson.GetBytes(output, "input.0.role").String(); role != "developer" {
t.Fatalf("input.0.role = %q, want developer", role)
}
for _, path := range []string{
"max_output_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"service_tier",
"truncation",
"prompt_cache_options",
"prompt_cache_retention",
"user",
} {
if gjson.GetBytes(output, path).Exists() {
t.Fatalf("%s should be removed: %s", path, output)
}
}
}
func TestConvertOpenAIResponsesRequestToCodex_FiltersPromptCacheRetention(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.6-terra",
"prompt_cache_retention": "24h",
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "hello"
}
]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6-terra", inputJSON, true)
if gjson.GetBytes(output, "prompt_cache_retention").Exists() {
t.Fatalf("prompt_cache_retention should be removed: %s", string(output))
}
}
// TestConvertSystemRoleToDeveloper_AssistantRole tests that assistant role is preserved
func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "You are helpful."}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hi!"}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check system -> developer
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected first role 'developer', got '%s'", firstRole.String())
}
// Check user unchanged
secondRole := gjson.Get(outputStr, "input.1.role")
if secondRole.String() != "user" {
t.Errorf("Expected second role 'user', got '%s'", secondRole.String())
}
// Check assistant unchanged
thirdRole := gjson.Get(outputStr, "input.2.role")
if thirdRole.String() != "assistant" {
t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String())
}
}
func TestConvertOpenAIResponsesRequestToCodex_NormalizesWebSearchPreview(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.4-mini",
"input": "find latest OpenAI model news",
"tools": [
{"type": "web_search_preview_2025_03_11"}
],
"tool_choice": {
"type": "allowed_tools",
"tools": [
{"type": "web_search_preview"},
{"type": "web_search_preview_2025_03_11"}
]
}
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.4-mini", inputJSON, false)
if got := gjson.GetBytes(output, "tools.0.type").String(); got != "web_search" {
t.Fatalf("tools.0.type = %q, want %q: %s", got, "web_search", string(output))
}
if got := gjson.GetBytes(output, "tool_choice.type").String(); got != "allowed_tools" {
t.Fatalf("tool_choice.type = %q, want %q: %s", got, "allowed_tools", string(output))
}
if got := gjson.GetBytes(output, "tool_choice.tools.0.type").String(); got != "web_search" {
t.Fatalf("tool_choice.tools.0.type = %q, want %q: %s", got, "web_search", string(output))
}
if got := gjson.GetBytes(output, "tool_choice.tools.1.type").String(); got != "web_search" {
t.Fatalf("tool_choice.tools.1.type = %q, want %q: %s", got, "web_search", string(output))
}
}
func TestConvertOpenAIResponsesRequestToCodex_NormalizesTopLevelToolChoicePreviewAlias(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.4-mini",
"input": "find latest OpenAI model news",
"tool_choice": {"type": "web_search_preview_2025_03_11"}
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.4-mini", inputJSON, false)
if got := gjson.GetBytes(output, "tool_choice.type").String(); got != "web_search" {
t.Fatalf("tool_choice.type = %q, want %q: %s", got, "web_search", string(output))
}
}
func TestUserFieldDeletion(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"user": "test-user",
"input": [{"role": "user", "content": "Hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Verify user field is deleted
userField := gjson.Get(outputStr, "user")
if userField.Exists() {
t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)
}
}
func TestContextManagementCompactionCompatibility(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"context_management": [
{
"type": "compaction",
"compact_threshold": 12000
}
],
"input": [{"role":"user","content":"hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
if gjson.Get(outputStr, "context_management").Exists() {
t.Fatalf("context_management should be removed for Codex compatibility")
}
if gjson.Get(outputStr, "truncation").Exists() {
t.Fatalf("truncation should be removed for Codex compatibility")
}
}
func TestTruncationRemovedForCodexCompatibility(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"truncation": "disabled",
"input": [{"role":"user","content":"hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
if gjson.Get(outputStr, "truncation").Exists() {
t.Fatalf("truncation should be removed for Codex compatibility")
}
}
func TestStripCodexResponsesCacheBreakpoints(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "Hello world",
"prompt_cache_breakpoint": {"mode": "explicit"}
},
{
"type": "input_text",
"text": "Second part"
}
]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
if gjson.Get(outputStr, "input.0.content.0.text").String() != "Hello world" {
t.Fatalf("text content should be preserved")
}
if gjson.Get(outputStr, "input.0.content.1.text").String() != "Second part" {
t.Fatalf("second content part should be preserved")
}
}
func TestStripCodexResponsesCacheBreakpoints_WithSystemRole(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [
{
"type": "input_text",
"text": "System prompt",
"prompt_cache_breakpoint": {"mode": "explicit"}
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "User query",
"prompt_cache_breakpoint": {"mode": "explicit"}
}
]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check system role is converted to developer
if gjson.Get(outputStr, "input.0.role").String() != "developer" {
t.Fatalf("expected role 'developer', got %q", gjson.Get(outputStr, "input.0.role").String())
}
// Check prompt_cache_breakpoint is completely removed from payload
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
if gjson.Get(outputStr, "input.0.content.0.text").String() != "System prompt" {
t.Fatalf("expected system prompt text preserved, got %q", gjson.Get(outputStr, "input.0.content.0.text").String())
}
if gjson.Get(outputStr, "input.1.content.0.text").String() != "User query" {
t.Fatalf("expected user query text preserved, got %q", gjson.Get(outputStr, "input.1.content.0.text").String())
}
}
func BenchmarkConvertSystemRoleToDeveloperLargeInput(b *testing.B) {
cases := []struct {
name string
inputJSON []byte
}{
{
name: "200_input_1_system",
inputJSON: makeLargeResponsesInputForBenchmark(200, 200),
},
{
name: "200_input_2_system",
inputJSON: makeLargeResponsesInputForBenchmark(200, 100),
},
{
name: "2000_input_20_system",
inputJSON: makeLargeResponsesInputForBenchmark(2000, 100),
},
}
benchmarks := []struct {
name string
fn func([]byte) []byte
}{
{
name: "previous_root_path_rewrite",
fn: convertSystemRoleToDeveloperPreviousRootPathRewriteForBenchmark,
},
{
name: "current_rebuilt_input_json_marshal",
fn: convertSystemRoleToDeveloper,
},
}
for _, testCase := range cases {
for _, benchmark := range benchmarks {
b.Run(testCase.name+"/"+benchmark.name, func(b *testing.B) {
output := benchmark.fn(testCase.inputJSON)
if got := gjson.GetBytes(output, "input.0.role").String(); got != "developer" {
b.Fatalf("input.0.role = %q, want %q", got, "developer")
}
if got := gjson.GetBytes(output, "input.1.role").String(); got != "user" {
b.Fatalf("input.1.role = %q, want %q", got, "user")
}
b.ReportAllocs()
b.SetBytes(int64(len(testCase.inputJSON)))
b.ResetTimer()
var benchmarkOutput []byte
for i := 0; i < b.N; i++ {
benchmarkOutput = benchmark.fn(testCase.inputJSON)
}
benchmarkConvertSystemRoleOutput = benchmarkOutput
})
}
}
}
func BenchmarkConvertOpenAIResponsesRequestToCodexNormalizedPayload(b *testing.B) {
cases := []struct {
name string
inputJSON []byte
}{
{name: "1KiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(1 << 10)},
{name: "1MiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(1 << 20)},
{name: "8MiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(8 << 20)},
}
for _, testCase := range cases {
b.Run(testCase.name, func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(testCase.inputJSON)))
b.ResetTimer()
var output []byte
for b.Loop() {
output = ConvertOpenAIResponsesRequestToCodex("gpt-5.6", testCase.inputJSON, true)
}
benchmarkConvertNormalizedOutput = output
})
}
}
func makeNormalizedResponsesRequestForBenchmark(contentBytes int) []byte {
var builder strings.Builder
builder.Grow(contentBytes + 256)
builder.WriteString(`{"model":"gpt-5.6","stream":true,"store":false,"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"input":[{"type":"message","role":"user","content":"`)
builder.WriteString(strings.Repeat("x", contentBytes))
builder.WriteString(`"}]}`)
return []byte(builder.String())
}
func makeLargeResponsesInputForBenchmark(inputCount int, systemEvery int) []byte {
var builder strings.Builder
builder.Grow(inputCount * 96)
builder.WriteString(`{"model":"gpt-5.2","input":[`)
for i := 0; i < inputCount; i++ {
if i > 0 {
builder.WriteByte(',')
}
role := "user"
if i%systemEvery == 0 {
role = "system"
}
builder.WriteString(`{"type":"message","role":"`)
builder.WriteString(role)
builder.WriteString(`","content":[{"type":"input_text","text":"message `)
builder.WriteString(strconv.Itoa(i))
builder.WriteString(`"}]}`)
}
builder.WriteString(`]}`)
return []byte(builder.String())
}
func convertSystemRoleToDeveloperPreviousRootPathRewriteForBenchmark(rawJSON []byte) []byte {
inputResult := gjson.GetBytes(rawJSON, "input")
if !inputResult.IsArray() {
return rawJSON
}
inputArray := inputResult.Array()
result := rawJSON
for i := 0; i < len(inputArray); i++ {
rolePath := fmt.Sprintf("input.%d.role", i)
if gjson.GetBytes(result, rolePath).String() == "system" {
result, _ = sjson.SetBytes(result, rolePath, "developer")
}
}
return result
}

View file

@ -0,0 +1,62 @@
package responses
import (
"bytes"
"context"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertCodexResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
// to OpenAI Responses SSE events (response.*).
func ConvertCodexResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) [][]byte {
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
rawJSON = setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON)
out := make([]byte, 0, len(rawJSON)+len("data: "))
out = append(out, []byte("data: ")...)
out = append(out, rawJSON...)
return [][]byte{out}
}
return [][]byte{setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON)}
}
func setResponsesModel(rawJSON []byte, modelName string, originalRequestRawJSON, requestRawJSON []byte) []byte {
eventType := gjson.GetBytes(rawJSON, "type").String()
if eventType != "response.created" && eventType != "response.in_progress" {
return rawJSON
}
if gjson.GetBytes(rawJSON, "response.model").Exists() {
return rawJSON
}
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" {
requestModelName = modelName
}
if requestModelName == "" {
return rawJSON
}
updated, errSet := sjson.SetBytes(rawJSON, "response.model", requestModelName)
if errSet != nil {
return rawJSON
}
return updated
}
// ConvertCodexResponseToOpenAIResponsesNonStream builds a single Responses JSON
// from a non-streaming OpenAI Chat Completions response.
func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, _, _, 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{}
}
responseResult := rootResult.Get("response")
return []byte(responseResult.Raw)
}

View file

@ -0,0 +1,38 @@
package responses
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertCodexResponseToOpenAIResponses_CreatedIncludesOriginalRequestModel(t *testing.T) {
request := []byte(`{"model":"original-codex-model"}`)
translatedRequest := []byte(`{"model":"translated-codex-model"}`)
for eventName, raw := range map[string][]byte{
"response.created": []byte(`data: {"type":"response.created","response":{"id":"resp_1"}}`),
"response.in_progress": []byte(`data: {"type":"response.in_progress","response":{"id":"resp_1"}}`),
} {
outputs := ConvertCodexResponseToOpenAIResponses(context.Background(), "fallback-model", request, translatedRequest, raw, nil)
if len(outputs) != 1 {
t.Fatalf("%s outputs = %d, want 1", eventName, len(outputs))
}
if got := gjson.GetBytes(outputs[0], "response.model").String(); got != "original-codex-model" {
t.Fatalf("%s models = %q, want original-codex-model; payload=%s", eventName, got, outputs[0])
}
}
}
func TestConvertCodexResponseToOpenAIResponsesNonStreamIncomplete(t *testing.T) {
raw := []byte(`{"type":"response.incomplete","response":{"id":"resp_1","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
out := ConvertCodexResponseToOpenAIResponsesNonStream(context.Background(), "gpt-5.5", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete; payload=%s", got, out)
}
if got := gjson.GetBytes(out, "incomplete_details.reason").String(); got != "max_output_tokens" {
t.Fatalf("incomplete reason = %q, want max_output_tokens; payload=%s", got, out)
}
}

View file

@ -0,0 +1,19 @@
package responses
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(
OpenaiResponse,
Codex,
ConvertOpenAIResponsesRequestToCodex,
interfaces.TranslateResponse{
Stream: ConvertCodexResponseToOpenAIResponses,
NonStream: ConvertCodexResponseToOpenAIResponsesNonStream,
},
)
}