From 1cfe529f32c86101e60cc24eda17c8f2a816e09d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 17 Jul 2026 23:52:58 +0800 Subject: [PATCH] feat(executor): add ID shortening for overlong Codex input items - Implemented deterministic shortening of overlong Codex input item IDs to meet 64-character limit. - Added `SanitizeCodexInputItemIDs` helper for efficient ID shortening in request bodies. - Updated Codex executor and websocket implementations to utilize the new helper. - Introduced unit tests to validate ID length restrictions, avoidance of collisions, and deterministic behavior. Closes: #4401 --- internal/runtime/executor/codex_executor.go | 1 + .../executor/codex_executor_input_ids_test.go | 72 ++++++++++++++++ .../executor/codex_websockets_executor.go | 1 + .../codex_websockets_executor_test.go | 33 +++++++ .../runtime/executor/helps/codex_input_ids.go | 85 +++++++++++++++++++ .../executor/helps/codex_input_ids_test.go | 62 ++++++++++++++ 6 files changed, 254 insertions(+) create mode 100644 internal/runtime/executor/codex_executor_input_ids_test.go create mode 100644 internal/runtime/executor/helps/codex_input_ids.go create mode 100644 internal/runtime/executor/helps/codex_input_ids_test.go diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index c4922bec..00425377 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -1530,6 +1530,7 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form if cache.ID != "" { rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID) } + rawJSON = helps.SanitizeCodexInputItemIDs(rawJSON) var identityState codexIdentityConfuseState rawJSON, identityState = applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, rawJSON) if identityState.promptCacheKey != "" { diff --git a/internal/runtime/executor/codex_executor_input_ids_test.go b/internal/runtime/executor/codex_executor_input_ids_test.go new file mode 100644 index 00000000..15b0fe68 --- /dev/null +++ b/internal/runtime/executor/codex_executor_input_ids_test.go @@ -0,0 +1,72 @@ +package executor + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestCodexExecutorExecuteStreamShortensOverlongInputItemIDs(t *testing.T) { + longCallItemID := strings.Repeat("grok-call-item-", 6) + longOutputItemID := strings.Repeat("grok-output-item-", 6) + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + gotBody = body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{"base_url": server.URL, "api_key": "test"}} + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.4", + Payload: []byte(`{"model":"gpt-5.4","stream":true,"input":[` + + `{"type":"function_call","id":"` + longCallItemID + `","call_id":"call-1","name":"lookup","arguments":"{}"},` + + `{"type":"function_call_output","id":"` + longOutputItemID + `","call_id":"call-1","output":"ok"},` + + `{"type":"message","id":"msg-1","role":"user","content":"continue"}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Stream: true, + }) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + for range result.Chunks { + } + + for index, testCase := range []struct { + path string + originalID string + }{ + {path: "input.0.id", originalID: longCallItemID}, + {path: "input.1.id", originalID: longOutputItemID}, + } { + actual := gjson.GetBytes(gotBody, testCase.path).String() + if len([]rune(actual)) > 64 || actual == testCase.originalID { + t.Fatalf("input.%d.id was not shortened to at most 64 characters: %q", index, actual) + } + } + if got := gjson.GetBytes(gotBody, "input.0.call_id").String(); got != "call-1" { + t.Fatalf("function call_id = %q, want call-1", got) + } + if got := gjson.GetBytes(gotBody, "input.1.call_id").String(); got != "call-1" { + t.Fatalf("function call output call_id = %q, want call-1", got) + } + if got := gjson.GetBytes(gotBody, "input.2.id").String(); got != "msg-1" { + t.Fatalf("valid input item ID changed: %q", got) + } +} diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 0c81235c..275148f1 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -748,6 +748,7 @@ func buildCodexWebsocketRequestBody(body []byte) []byte { // Match codex-rs websocket v2 semantics: every request is `response.create`. // Incremental follow-up turns continue on the same websocket using // `previous_response_id` + incremental `input`, not `response.append`. + body = helps.SanitizeCodexInputItemIDs(body) wsReqBody, errSet := sjson.SetBytes(bytes.Clone(body), "type", "response.create") if errSet == nil && len(wsReqBody) > 0 { return wsReqBody diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 753259a5..42ab3b59 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -40,6 +40,39 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) } } +func TestBuildCodexWebsocketRequestBodyShortensOverlongInputItemIDs(t *testing.T) { + longCallItemID := strings.Repeat("grok-call-item-", 6) + longOutputItemID := strings.Repeat("grok-output-item-", 6) + body := []byte(`{"model":"gpt-5-codex","input":[{"type":"function_call","id":"` + longCallItemID + `","call_id":"call-1","name":"lookup"},{"type":"function_call_output","id":"` + longOutputItemID + `","call_id":"call-1","output":"ok"},{"type":"message","id":"msg-1"}]}`) + + first := buildCodexWebsocketRequestBody(body) + second := buildCodexWebsocketRequestBody(body) + + shortCallItemID := gjson.GetBytes(first, "input.0.id").String() + shortOutputItemID := gjson.GetBytes(first, "input.1.id").String() + if len([]rune(shortCallItemID)) > 64 || shortCallItemID == longCallItemID { + t.Fatalf("input.0.id was not shortened to at most 64 characters: %q", shortCallItemID) + } + if len([]rune(shortOutputItemID)) > 64 || shortOutputItemID == longOutputItemID { + t.Fatalf("input.1.id was not shortened to at most 64 characters: %q", shortOutputItemID) + } + if shortCallItemID == shortOutputItemID { + t.Fatalf("distinct long IDs produced the same shortened ID: %q", shortCallItemID) + } + if got := gjson.GetBytes(second, "input.0.id").String(); got != shortCallItemID { + t.Fatalf("input item ID shortening is not deterministic: first=%q second=%q", shortCallItemID, got) + } + if got := gjson.GetBytes(first, "input.0.call_id").String(); got != "call-1" { + t.Fatalf("function call_id = %q, want call-1", got) + } + if got := gjson.GetBytes(first, "input.1.call_id").String(); got != "call-1" { + t.Fatalf("function call output call_id = %q, want call-1", got) + } + if got := gjson.GetBytes(first, "input.2.id").String(); got != "msg-1" { + t.Fatalf("valid input item ID changed: %q", got) + } +} + func TestCodexWebsocketsExecuteResponsesLiteDoesNotInjectImageGenerationTool(t *testing.T) { upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} capturedPayload := make(chan []byte, 1) diff --git a/internal/runtime/executor/helps/codex_input_ids.go b/internal/runtime/executor/helps/codex_input_ids.go new file mode 100644 index 00000000..4f4122c8 --- /dev/null +++ b/internal/runtime/executor/helps/codex_input_ids.go @@ -0,0 +1,85 @@ +package helps + +import ( + "crypto/sha256" + "encoding/hex" + "strconv" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const codexInputItemIDLimit = 64 + +// SanitizeCodexInputItemIDs deterministically shortens overlong Responses input item IDs. +func SanitizeCodexInputItemIDs(body []byte) []byte { + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body + } + + items := input.Array() + occupied := make(map[string]struct{}, len(items)) + for _, item := range items { + itemID := item.Get("id") + if itemID.Type != gjson.String { + continue + } + id := itemID.String() + if len([]rune(id)) <= codexInputItemIDLimit { + occupied[id] = struct{}{} + } + } + + mapped := make(map[string]string, len(items)) + updated := body + for index, item := range items { + itemID := item.Get("id") + if itemID.Type != gjson.String { + continue + } + id := itemID.String() + if len([]rune(id)) <= codexInputItemIDLimit { + continue + } + + shortened, ok := mapped[id] + if !ok { + shortened = shortenCodexInputItemID(id) + for attempt := 1; ; attempt++ { + if _, exists := occupied[shortened]; !exists { + break + } + shortened = shortenCodexInputItemIDWithAttempt(id, attempt) + } + mapped[id] = shortened + occupied[shortened] = struct{}{} + } + + next, errSet := sjson.SetBytes(updated, "input."+strconv.Itoa(index)+".id", shortened) + if errSet == nil { + updated = next + } + } + return updated +} + +func shortenCodexInputItemID(id string) string { + return shortenCodexInputItemIDWithAttempt(id, 0) +} + +func shortenCodexInputItemIDWithAttempt(id string, attempt int) string { + runes := []rune(id) + if len(runes) <= codexInputItemIDLimit { + return id + } + + hashInput := id + if attempt > 0 { + hashInput += "\x00" + strconv.Itoa(attempt) + } + sum := sha256.Sum256([]byte(hashInput)) + suffix := "_" + hex.EncodeToString(sum[:8]) + prefixLength := codexInputItemIDLimit - len(suffix) + return string(runes[:prefixLength]) + suffix +} diff --git a/internal/runtime/executor/helps/codex_input_ids_test.go b/internal/runtime/executor/helps/codex_input_ids_test.go new file mode 100644 index 00000000..4d833449 --- /dev/null +++ b/internal/runtime/executor/helps/codex_input_ids_test.go @@ -0,0 +1,62 @@ +package helps + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestSanitizeCodexInputItemIDsBoundaries(t *testing.T) { + id64 := strings.Repeat("a", 64) + id65 := strings.Repeat("b", 65) + unicode65 := strings.Repeat("界", 65) + body := []byte(`{"input":[{"id":"` + id64 + `"},{"id":"` + id65 + `"},{"id":"` + unicode65 + `"}]}`) + + got := SanitizeCodexInputItemIDs(body) + + if actual := gjson.GetBytes(got, "input.0.id").String(); actual != id64 { + t.Fatalf("64-character ID changed: %q", actual) + } + for _, path := range []string{"input.1.id", "input.2.id"} { + actual := gjson.GetBytes(got, path).String() + if len([]rune(actual)) != 64 { + t.Fatalf("%s length = %d, want 64: %q", path, len([]rune(actual)), actual) + } + } +} + +func TestSanitizeCodexInputItemIDsAvoidsExistingIDCollision(t *testing.T) { + longID := strings.Repeat("grok-item-", 10) + collidingValidID := shortenCodexInputItemID(longID) + body := []byte(`{"input":[{"id":"` + longID + `"},{"id":"` + collidingValidID + `"}]}`) + + first := SanitizeCodexInputItemIDs(body) + second := SanitizeCodexInputItemIDs(body) + + shortened := gjson.GetBytes(first, "input.0.id").String() + if shortened == collidingValidID { + t.Fatalf("shortened ID collided with an existing valid ID: %q", shortened) + } + if len([]rune(shortened)) > 64 { + t.Fatalf("shortened ID length = %d, want at most 64", len([]rune(shortened))) + } + if actual := gjson.GetBytes(first, "input.1.id").String(); actual != collidingValidID { + t.Fatalf("existing valid ID changed: %q", actual) + } + if actual := gjson.GetBytes(second, "input.0.id").String(); actual != shortened { + t.Fatalf("collision resolution is not deterministic: first=%q second=%q", shortened, actual) + } +} + +func TestSanitizeCodexInputItemIDsLeavesUnsupportedPayloadsUnchanged(t *testing.T) { + for _, body := range [][]byte{ + []byte(`not-json`), + []byte(`{"input":{"id":"item-1"}}`), + []byte(`{"input":[1,{"id":2},{"id":"item-1"}]}`), + } { + if got := string(SanitizeCodexInputItemIDs(body)); got != string(body) { + t.Fatalf("payload changed: got=%q want=%q", got, body) + } + } +}