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