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
This commit is contained in:
Luis Pater
2026-07-17 23:52:58 +08:00
parent f5746a9822
commit 1cfe529f32
6 changed files with 254 additions and 0 deletions
@@ -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 != "" {
@@ -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)
}
}
@@ -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
@@ -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)
@@ -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
}
@@ -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)
}
}
}