mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user