Skip to content
Merged
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
31 changes: 31 additions & 0 deletions internal/adapter/provider/cliproxyapi_codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/exec"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

// TokenCache caches access tokens
Expand Down Expand Up @@ -227,6 +229,11 @@ func (a *CLIProxyAPICodexAdapter) Execute(c *flow.Ctx, p *domain.Provider) error
return domain.NewProxyErrorWithMessage(err, true, fmt.Sprintf("failed to get access token: %v", err))
}

// Normalize Codex payload for upstream compatibility.
if len(requestBody) > 0 {
requestBody = sanitizeCodexPayload(requestBody)
}

// 构建 executor 请求
execReq := executor.Request{
Model: model,
Expand All @@ -246,6 +253,30 @@ func (a *CLIProxyAPICodexAdapter) Execute(c *flow.Ctx, p *domain.Provider) error
return a.executeNonStream(c, w, execReq, execOpts)
}

func sanitizeCodexPayload(body []byte) []byte {
if input := gjson.GetBytes(body, "input"); input.IsArray() {
for i, item := range input.Array() {
itemType := item.Get("type").String()
if itemType != "message" {
if item.Get("role").Exists() {
body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.role", i))
}
}
if itemType == "function_call" {
if id := item.Get("id").String(); id != "" && !strings.HasPrefix(id, "fc_") {
body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.id", i), "fc_"+id)
}
}
if itemType == "function_call_output" {
if !item.Get("output").Exists() {
body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.output", i), "")
}
}
}
}
return body
}
Comment on lines +256 to +278
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

sanitizeCodexPayloadapplyCodexRequestTuning 中的逻辑重复

sanitizeCodexPayload 的实现与 internal/adapter/provider/codex/adapter.goapplyCodexRequestTuninginput 规范化循环(Lines 575–594)逻辑完全相同。两处代码需同步维护,容易产生分歧。

建议将共用的 input 规范化逻辑提取到一个公共包(例如 internal/codexutil)或通过参数传递,由两个 adapter 复用。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapter/provider/cliproxyapi_codex/adapter.go` around lines 256 -
278, The sanitizeCodexPayload implementation duplicates the input-normalization
loop also present in applyCodexRequestTuning; extract that shared loop into a
single exported helper (e.g., NormalizeCodexInput or NormalizeInputItems) in a
new internal package (suggest internal/codexutil) and replace the body of
sanitizeCodexPayload and the input-handling section of applyCodexRequestTuning
to call that helper, preserving the same behavior for item types "message",
"function_call" (prefix id with "fc_" when missing), and "function_call_output"
(ensure empty "output" exists) and keeping use of gjson/sjson updates via byte
slices; update imports and call sites accordingly so both adapters reuse the
centralized logic.


func (a *CLIProxyAPICodexAdapter) executeNonStream(c *flow.Ctx, w http.ResponseWriter, execReq executor.Request, execOpts executor.Options) error {
ctx := context.Background()
if c.Request != nil {
Expand Down
28 changes: 28 additions & 0 deletions internal/adapter/provider/codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,37 @@ func applyCodexRequestTuning(c *flow.Ctx, body []byte) (string, []byte) {
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
if maxOut := gjson.GetBytes(body, "max_output_tokens"); maxOut.Exists() {
if !gjson.GetBytes(body, "max_tokens").Exists() {
if updated, err := sjson.SetBytes(body, "max_tokens", maxOut.Value()); err == nil {
body = updated
}
}
body, _ = sjson.DeleteBytes(body, "max_output_tokens")
}
if !gjson.GetBytes(body, "instructions").Exists() {
body, _ = sjson.SetBytes(body, "instructions", "")
}
if input := gjson.GetBytes(body, "input"); input.IsArray() {
for i, item := range input.Array() {
itemType := item.Get("type").String()
if itemType != "message" {
if item.Get("role").Exists() {
body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.role", i))
}
}
if itemType == "function_call" {
if id := item.Get("id").String(); id != "" && !strings.HasPrefix(id, "fc_") {
body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.id", i), "fc_"+id)
}
}
Comment on lines +583 to +587
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

function_call 的空 id 未添加 fc_ 前缀

function_call 条目的 id 为空字符串时,id != "" 条件不成立,不会添加 fc_ 前缀,也不会生成任何 ID。Codex API 要求 function_callid 必须以 fc_ 开头,空 ID 将导致上游返回验证错误。

如果空 id 是合法的上游输入,建议补充生成一个默认 fc_ ID:

🐛 建议修复
 if itemType == "function_call" {
-    if id := item.Get("id").String(); id != "" && !strings.HasPrefix(id, "fc_") {
-        body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.id", i), "fc_"+id)
+    id := item.Get("id").String()
+    if id == "" {
+        body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.id", i), "fc_"+uuid.NewString())
+    } else if !strings.HasPrefix(id, "fc_") {
+        body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.id", i), "fc_"+id)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapter/provider/codex/adapter.go` around lines 583 - 587, In the
function_call handling block (inside the if itemType == "function_call" branch),
always ensure the entry has an id that starts with "fc_": check
item.Get("id").String() and if it already exists but doesn't start with "fc_"
prefix, set it to "fc_"+id using sjson.SetBytes(body, fmt.Sprintf("input.%d.id",
i), ...); if the id is empty, generate a default prefixed id (e.g., "fc_"+ a
deterministic fallback such as fmt.Sprintf("%d", i) or a UUID) and set that into
body as well so no function_call is left without an fc_ prefixed id.

if itemType == "function_call_output" {
if !item.Get("output").Exists() {
body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.output", i), "")
}
}
}
}

return cacheID, body
}
Expand Down
14 changes: 13 additions & 1 deletion internal/adapter/provider/codex/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func TestApplyCodexRequestTuning(t *testing.T) {
c.Set(flow.KeyOriginalClientType, domain.ClientTypeClaude)
c.Set(flow.KeyOriginalRequestBody, []byte(`{"metadata":{"user_id":"user-123"}}`))

body := []byte(`{"model":"gpt-5","stream":false,"instructions":"x","previous_response_id":"r1","prompt_cache_retention":123,"safety_identifier":"s1"}`)
body := []byte(`{"model":"gpt-5","stream":false,"instructions":"x","previous_response_id":"r1","prompt_cache_retention":123,"safety_identifier":"s1","max_output_tokens":77,"input":[{"type":"message","role":"user","content":"hi"},{"type":"function_call","role":"assistant","name":"t","arguments":"{}"},{"role":"tool","call_id":"c1","output":"ok"}]}`)
cacheID, tuned := applyCodexRequestTuning(c, body)

if cacheID == "" {
Expand All @@ -35,6 +35,18 @@ func TestApplyCodexRequestTuning(t *testing.T) {
if gjson.GetBytes(tuned, "safety_identifier").Exists() {
t.Fatalf("expected safety_identifier to be removed")
}
if gjson.GetBytes(tuned, "max_output_tokens").Exists() {
t.Fatalf("expected max_output_tokens to be removed")
}
if gjson.GetBytes(tuned, "max_tokens").Int() != 77 {
t.Fatalf("expected max_tokens to be set from max_output_tokens")
}
if gjson.GetBytes(tuned, "input.0.role").String() != "user" {
t.Fatalf("expected role to be preserved for message input")
}
if gjson.GetBytes(tuned, "input.1.role").Exists() || gjson.GetBytes(tuned, "input.2.role").Exists() {
t.Fatalf("expected role to be removed for non-message inputs")
}
}

func TestApplyCodexHeadersFiltersSensitiveAndPreservesUA(t *testing.T) {
Expand Down
59 changes: 38 additions & 21 deletions internal/converter/claude_to_codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (c *claudeToCodexRequest) Transform(body []byte, model string, stream bool)
MaxOutputTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
Store: false,
}

shortMap := map[string]string{}
Expand Down Expand Up @@ -90,26 +91,34 @@ func (c *claudeToCodexRequest) Transform(body []byte, model string, stream bool)
} else {
name = shortenNameIfNeeded(name)
}
id, _ := m["id"].(string)
inputData := m["input"]
argJSON, _ := json.Marshal(inputData)
input = append(input, CodexInputItem{
Type: "function_call",
ID: id,
CallID: id,
Name: name,
Role: "assistant",
Arguments: string(argJSON),
})
id, _ := m["id"].(string)
callID := id
callItemID := ""
if callID != "" {
if strings.HasPrefix(callID, "fc_") {
callItemID = callID
} else {
callItemID = "fc_" + callID
}
}
inputData := m["input"]
argJSON, _ := json.Marshal(inputData)
input = append(input, CodexInputItem{
Type: "function_call",
ID: callItemID,
CallID: callID,
Name: name,
Arguments: string(argJSON),
})
continue
case "tool_result":
toolUseID, _ := m["tool_use_id"].(string)
resultContent, _ := m["content"].(string)
input = append(input, CodexInputItem{
Type: "function_call_output",
CallID: toolUseID,
Output: resultContent,
})
case "tool_result":
toolUseID, _ := m["tool_use_id"].(string)
resultContent := convertClaudeToolResultContentToString(m["content"])
input = append(input, CodexInputItem{
Type: "function_call_output",
CallID: toolUseID,
Output: resultContent,
})
continue
}
}
Expand Down Expand Up @@ -192,10 +201,18 @@ func (c *claudeToCodexResponse) Transform(body []byte) ([]byte, error) {
})
case "tool_use":
argJSON, _ := json.Marshal(block.Input)
callID := block.ID
itemID := block.ID
if callID != "" {
if strings.HasPrefix(callID, "fc_") {
callID = strings.TrimPrefix(callID, "fc_")
}
itemID = "fc_" + callID
}
codexResp.Output = append(codexResp.Output, CodexOutput{
Type: "function_call",
ID: block.ID,
CallID: block.ID,
ID: itemID,
CallID: callID,
Name: block.Name,
Arguments: string(argJSON),
Status: "completed",
Expand Down