Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 118 additions & 57 deletions internal/translator/codex/claude/codex_claude_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,26 @@ import (
//
// Returns:
// - []byte: The transformed request data in internal client format
func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {
func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, false)

}

// ConvertClaudeRequestToCodexWithCompat preserves assistant thinking blocks with
// empty or unknown-format signatures for configured compatibility endpoints.
func ConvertClaudeRequestToCodexWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
func ConvertClaudeRequestToCodexWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, true)
}

func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte {
func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) ([]byte, error) {
rawJSON := inputRawJSON
var drops translatorcommon.UserTurnDrops

template := []byte(`{"model":"","instructions":"","input":[]}`)

rootResult := gjson.ParseBytes(rawJSON)
toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, rawJSON)
template, _ = sjson.SetBytes(template, "model", modelName)
inputItems := translatorcommon.NewRawArrayItems(rootResult.Get("messages.#").Int())

Expand Down Expand Up @@ -124,9 +127,15 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
}
pendingToolUseIDs = nil
contentItems := make([][]byte, 0, 4)
// Counts only what this turn itself sends, not system reminders flushed beside it.
// Empty text is forwarded but does not count: it can hide an emptied attachment.
turnSendable := 0
bufferedSendable := 0

flushMessage := func() {
if len(contentItems) > 0 {
turnSendable += bufferedSendable
bufferedSendable = 0
message := []byte(`{"type":"message","role":""}`)
message, _ = sjson.SetBytes(message, "role", messageRole)
message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems))
Expand All @@ -144,18 +153,41 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
content, _ = sjson.SetBytes(content, "type", partType)
content, _ = sjson.SetBytes(content, "text", text)
contentItems = append(contentItems, content)
if text != "" {
bufferedSendable++
}
}

appendImageContent := func(dataURL string) {
content := []byte(`{"type":"input_image","image_url":""}`)
content, _ = sjson.SetBytes(content, "image_url", dataURL)
appendImageContent := func(content []byte) {
contentItems = append(contentItems, content)
bufferedSendable++
}

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

appendCommentaryContent := func(part gjson.Result) {
if messageRole != "assistant" {
return
}
text := part.Get("thinking").String()
if text == "" {
return
}
// A relayed commentary block becomes its own assistant message item
// carrying phase "commentary", mirroring the Codex output item it
// came from. The internal marker is consumed here and never
// forwarded upstream.
flushMessage()
content := []byte(`{"type":"output_text","text":""}`)
content, _ = sjson.SetBytes(content, "text", text)
message := []byte(`{"type":"message","role":"assistant","phase":"commentary"}`)
message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray([][]byte{content}))
inputItems = append(inputItems, message)
}

appendReasoningContent := func(part gjson.Result) {
Expand All @@ -164,7 +196,11 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
}

rawSignature := part.Get("signature").String()
signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, rawSignature)
if sigcompat.IsCodexCommentarySignatureMarker(rawSignature) {
appendCommentaryContent(part)
return
}
signature, ok := translatorcommon.CodexReplaySignatureForTarget(signatureTargetModel, rawSignature)
if !ok && preserveEmptyThinkingBlocks && part.Get("signature").Type == gjson.String && strings.TrimSpace(rawSignature) != "" &&
sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking) == sigcompat.SignatureProviderUnknown {
signature = rawSignature
Expand All @@ -174,13 +210,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
if preserveEmptyThinkingBlocks && strings.TrimSpace(rawSignature) == "" {
signature = rawSignature
} else {
if !codexClaudeTargetAcceptsGrokSignature(modelName) {
return
}
if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err != nil {
return
}
signature = rawSignature
return
}
}

Expand Down Expand Up @@ -210,43 +240,20 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
inputItems = append(inputItems, pendingSystemReminders...)
pendingSystemReminders = nil
}
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)
}
if imagePart, ok := claudeImageInputPart(messageContentResult.Get("source")); ok {
appendImageContent(imagePart)
} else if messageRole == "user" {
drops.Drop(contentType)
}
case "document":
case "document", "container_upload":
if len(pendingSystemReminders) > 0 {
inputItems = append(inputItems, pendingSystemReminders...)
pendingSystemReminders = nil
}
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))
if dataURL, ok := claudeDocumentDataURL(messageContentResult); ok {
appendDocumentContent(dataURL)
} else if messageRole == "user" {
drops.Drop(contentType)
}
case "tool_use":
flushMessage()
Expand Down Expand Up @@ -315,6 +322,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
}

inputItems = append(inputItems, functionCallOutputMessage)
turnSendable++
}
}
flushMessage()
Expand All @@ -330,6 +338,9 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
pendingSystemReminders = nil
}
}
if messageRole == "user" {
drops.EndTurn(turnSendable)
}
}

if len(pendingSystemReminders) > 0 {
Expand Down Expand Up @@ -393,8 +404,12 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
// Add additional configuration parameters for the Codex API.
template, _ = sjson.SetBytes(template, "parallel_tool_calls", parallelToolCalls)

// Convert thinking.budget_tokens to reasoning.effort.
// Convert Claude thinking budgets and effort to reasoning.effort.
reasoningEffort := "medium"
effort := ""
if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
switch thinkingConfig.Get("type").String() {
case "enabled":
Expand All @@ -407,10 +422,6 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
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 {
Expand All @@ -421,6 +432,8 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
reasoningEffort = effort
}
}
} else if !thinkingConfig.Exists() && effort != "" {
reasoningEffort = effort
}
template, _ = sjson.SetBytes(template, "reasoning.effort", reasoningEffort)
// OpenAI documents reasoning summaries as explicit opt-in output. Leave
Expand Down Expand Up @@ -469,12 +482,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
}
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")
return template, drops.Err()
}

func normalizeCodexServiceTier(result gjson.Result) string {
Expand Down Expand Up @@ -845,3 +853,56 @@ func codexSchemaMissesRequired(schema gjson.Result) bool {
}
return false
}

// claudeDocumentDataURL turns an inline PDF into a Codex input_file URL. Anything
// else, a file id included, is not representable and is reported by the caller.
// claudeImageInputPart maps a Claude image source onto a Responses input_image
// part. Base64 bytes become a data URL, an http(s) url passes through and a file
// id is carried as file_id. It reports false for a source with none of them.
func claudeImageInputPart(source gjson.Result) ([]byte, bool) {
if !source.Exists() {
return nil, false
}
part := []byte(`{"type":"input_image"}`)
data := source.Get("data").String()
if data == "" {
data = source.Get("base64").String()
}
switch {
case data != "":
mediaType := source.Get("media_type").String()
if mediaType == "" {
mediaType = source.Get("mime_type").String()
}
if mediaType == "" {
mediaType = "application/octet-stream"
}
part, _ = sjson.SetBytes(part, "image_url", fmt.Sprintf("data:%s;base64,%s", mediaType, data))
case source.Get("type").String() == "url" && translatorcommon.IsHTTPURL(source.Get("url").String()):
part, _ = sjson.SetBytes(part, "image_url", strings.TrimSpace(source.Get("url").String()))
case source.Get("type").String() == "file" && source.Get("file_id").String() != "":
part, _ = sjson.SetBytes(part, "file_id", source.Get("file_id").String())
default:
return nil, false
}
return part, true
}

func claudeDocumentDataURL(part gjson.Result) (string, bool) {
source := part.Get("source")
if source.Get("type").String() == "base64" {
mediaType := strings.TrimSpace(source.Get("media_type").String())
if !strings.EqualFold(mediaType, "application/pdf") {
return "", false
}
data := source.Get("data").String()
if data == "" {
data = source.Get("base64").String()
}
if data == "" {
return "", false
}
return fmt.Sprintf("data:%s;base64,%s", mediaType, data), true
}
return "", false
}
Loading
Loading