mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 18:06:50 +08:00
feat(executor, handlers): enhance websocket output restoration and compact compaction logic
- Refactored websocket output collection to restore completed events using fallback items and indexed payloads. - Updated `CodexExecutorCompact` tests to ensure no injection of `image_generation` tools and maintain compact input order. - Added new tests to validate sustained reasoning, proper output restoration, and compaction replay behavior. - Improved logic to handle incremental response appends and preserved reasoning continuations. - Enhanced test coverage for normalization, tool calls, and downstream payload restoration scenarios. Closes: #4176
This commit is contained in:
@@ -960,9 +960,6 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
|
||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||
body, _ = sjson.DeleteBytes(body, "stream")
|
||||
body = normalizeCodexInstructions(body)
|
||||
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
|
||||
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
|
||||
}
|
||||
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
|
||||
body = normalizeCodexParallelToolCallsForTools(body)
|
||||
reporter.SetTranslatedReasoningEffort(body, to.String())
|
||||
|
||||
@@ -14,18 +14,18 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCodexExecutorCompactAddsDefaultInstructions(t *testing.T) {
|
||||
func TestCodexExecutorCompactAddsDefaultInstructionsWithoutInjectingImageTool(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload string
|
||||
}{
|
||||
{
|
||||
name: "missing instructions",
|
||||
payload: `{"model":"gpt-5.4","input":"hello"}`,
|
||||
payload: `{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":"history"},{"type":"compaction_trigger"}]}`,
|
||||
},
|
||||
{
|
||||
name: "null instructions",
|
||||
payload: `{"model":"gpt-5.4","instructions":null,"input":"hello"}`,
|
||||
payload: `{"model":"gpt-5.4","instructions":null,"input":[{"type":"message","role":"user","content":"history"},{"type":"compaction_trigger"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,14 +62,15 @@ func TestCodexExecutorCompactAddsDefaultInstructions(t *testing.T) {
|
||||
if gotPath != "/responses/compact" {
|
||||
t.Fatalf("path = %q, want %q", gotPath, "/responses/compact")
|
||||
}
|
||||
if !gjson.GetBytes(gotBody, "instructions").Exists() {
|
||||
t.Fatalf("expected instructions in compact request body, got %s", string(gotBody))
|
||||
if instructions := gjson.GetBytes(gotBody, "instructions"); instructions.Type != gjson.String || instructions.String() != "" {
|
||||
t.Fatalf("instructions = %s, want empty string; body=%s", instructions.Raw, gotBody)
|
||||
}
|
||||
if gjson.GetBytes(gotBody, "instructions").Type != gjson.String {
|
||||
t.Fatalf("instructions type = %v, want string", gjson.GetBytes(gotBody, "instructions").Type)
|
||||
if gjson.GetBytes(gotBody, "tools").Exists() {
|
||||
t.Fatalf("compact request injected image_generation tool: %s", gotBody)
|
||||
}
|
||||
if gjson.GetBytes(gotBody, "instructions").String() != "" {
|
||||
t.Fatalf("instructions = %q, want empty string", gjson.GetBytes(gotBody, "instructions").String())
|
||||
input := gjson.GetBytes(gotBody, "input").Array()
|
||||
if len(input) != 2 || input[1].Get("type").String() != "compaction_trigger" {
|
||||
t.Fatalf("compact input order changed: %s", gotBody)
|
||||
}
|
||||
if string(resp.Payload) != `{"id":"resp_1","object":"response.compaction","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}` {
|
||||
t.Fatalf("payload = %s", string(resp.Payload))
|
||||
|
||||
@@ -696,8 +696,7 @@ func shouldReplaceWebsocketTranscript(rawJSON []byte, nextInput gjson.Result) bo
|
||||
case "function_call", "custom_tool_call":
|
||||
return true
|
||||
case "message":
|
||||
role := strings.TrimSpace(item.Get("role").String())
|
||||
if role == "assistant" {
|
||||
if strings.TrimSpace(item.Get("role").String()) == "assistant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1310,6 +1309,8 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
|
||||
completed := false
|
||||
completedOutput := []byte("[]")
|
||||
completedResponseID := ""
|
||||
outputItemsByIndex := make(map[int64][]byte)
|
||||
var outputItemsFallback [][]byte
|
||||
pendingToolCallIDs := make(map[string]struct{})
|
||||
downstreamSessionKey := ""
|
||||
if c != nil && c.Request != nil {
|
||||
@@ -1390,9 +1391,13 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
|
||||
|
||||
payloads := websocketJSONPayloadsFromChunk(chunk)
|
||||
for i := range payloads {
|
||||
collectResponsesWebsocketOutputItem(payloads[i], outputItemsByIndex, &outputItemsFallback)
|
||||
eventType := gjson.GetBytes(payloads[i], "type").String()
|
||||
if isResponsesWebsocketCompletionEvent(eventType) {
|
||||
payloads[i] = restoreResponsesWebsocketCompletionOutput(payloads[i], outputItemsByIndex, outputItemsFallback)
|
||||
}
|
||||
recordResponsesWebsocketToolCallsFromPayload(downstreamSessionKey, payloads[i])
|
||||
recordPendingToolCallIDsFromPayload(pendingToolCallIDs, payloads[i])
|
||||
eventType := gjson.GetBytes(payloads[i], "type").String()
|
||||
var payloadErrMsg *interfaces.ErrorMessage
|
||||
if eventType == wsEventTypeError {
|
||||
payloadErrMsg = responsesWebsocketErrorMessageFromPayload(payloads[i])
|
||||
@@ -1401,7 +1406,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
|
||||
}
|
||||
} else if isResponsesWebsocketCompletionEvent(eventType) {
|
||||
completed = true
|
||||
completedOutput = responseCompletedOutputFromPayload(payloads[i])
|
||||
completedOutput = responseCompletedOutputFromPayload(payloads[i], outputItemsByIndex, outputItemsFallback)
|
||||
completedResponseID = responseCompletedIDFromPayload(payloads[i])
|
||||
}
|
||||
markAPIResponseTimestamp(c)
|
||||
@@ -1467,12 +1472,68 @@ func shouldReleaseResponsesWebsocketPinnedAuth(errMsg *interfaces.ErrorMessage)
|
||||
return false
|
||||
}
|
||||
|
||||
func responseCompletedOutputFromPayload(payload []byte) []byte {
|
||||
func collectResponsesWebsocketOutputItem(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
|
||||
if gjson.GetBytes(payload, "type").String() != "response.output_item.done" {
|
||||
return
|
||||
}
|
||||
item := gjson.GetBytes(payload, "item")
|
||||
if !item.Exists() || !item.IsObject() {
|
||||
return
|
||||
}
|
||||
outputIndex := gjson.GetBytes(payload, "output_index")
|
||||
if outputIndex.Exists() {
|
||||
outputItemsByIndex[outputIndex.Int()] = bytes.Clone([]byte(item.Raw))
|
||||
return
|
||||
}
|
||||
*outputItemsFallback = append(*outputItemsFallback, bytes.Clone([]byte(item.Raw)))
|
||||
}
|
||||
|
||||
func restoreResponsesWebsocketCompletionOutput(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
|
||||
output := gjson.GetBytes(payload, "response.output")
|
||||
if output.Exists() && output.IsArray() {
|
||||
if output.Exists() && output.IsArray() && len(output.Array()) > 0 {
|
||||
return payload
|
||||
}
|
||||
if len(outputItemsByIndex) == 0 && len(outputItemsFallback) == 0 {
|
||||
return payload
|
||||
}
|
||||
|
||||
restored, errSet := sjson.SetRawBytes(payload, "response.output", responseCompletedOutputFromPayload(payload, outputItemsByIndex, outputItemsFallback))
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func responseCompletedOutputFromPayload(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
|
||||
output := gjson.GetBytes(payload, "response.output")
|
||||
if output.Exists() && output.IsArray() && len(output.Array()) > 0 {
|
||||
return bytes.Clone([]byte(output.Raw))
|
||||
}
|
||||
return []byte("[]")
|
||||
if len(outputItemsByIndex) == 0 && len(outputItemsFallback) == 0 {
|
||||
return []byte("[]")
|
||||
}
|
||||
|
||||
indexes := make([]int64, 0, len(outputItemsByIndex))
|
||||
for index := range outputItemsByIndex {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
sort.Slice(indexes, func(i, j int) bool {
|
||||
return indexes[i] < indexes[j]
|
||||
})
|
||||
|
||||
items := make([]json.RawMessage, 0, len(outputItemsByIndex)+len(outputItemsFallback))
|
||||
for _, index := range indexes {
|
||||
items = append(items, json.RawMessage(outputItemsByIndex[index]))
|
||||
}
|
||||
for _, item := range outputItemsFallback {
|
||||
items = append(items, json.RawMessage(item))
|
||||
}
|
||||
|
||||
marshaledOutput, errMarshal := json.Marshal(items)
|
||||
if errMarshal != nil {
|
||||
return []byte("[]")
|
||||
}
|
||||
return marshaledOutput
|
||||
}
|
||||
|
||||
func responseCompletedIDFromPayload(payload []byte) string {
|
||||
|
||||
@@ -757,7 +757,7 @@ func TestWebsocketJSONPayloadsFromPlainJSONChunk(t *testing.T) {
|
||||
func TestResponseCompletedOutputFromPayload(t *testing.T) {
|
||||
payload := []byte(`{"type":"response.completed","response":{"id":"resp-1","output":[{"type":"message","id":"out-1"}]}}`)
|
||||
|
||||
output := responseCompletedOutputFromPayload(payload)
|
||||
output := responseCompletedOutputFromPayload(payload, nil, nil)
|
||||
items := gjson.ParseBytes(output).Array()
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("output len = %d, want 1", len(items))
|
||||
@@ -767,6 +767,16 @@ func TestResponseCompletedOutputFromPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreResponsesWebsocketCompletionOutputPreservesNonEmptyOutput(t *testing.T) {
|
||||
payload := []byte(`{"type":"response.completed","response":{"id":"resp-1","output":[{"type":"message","id":"out-1"}]}}`)
|
||||
collector := map[int64][]byte{0: []byte(`{"type":"function_call","id":"call-1","call_id":"call-1"}`)}
|
||||
|
||||
restored := restoreResponsesWebsocketCompletionOutput(payload, collector, nil)
|
||||
if string(restored) != string(payload) {
|
||||
t.Fatalf("non-empty completion output was overwritten: %s", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendWebsocketEvent(t *testing.T) {
|
||||
var builder strings.Builder
|
||||
|
||||
@@ -1147,7 +1157,7 @@ func TestRecordResponsesWebsocketCustomToolCallsFromOutputItemDoneWithCache(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
|
||||
func TestForwardResponsesWebsocketRestoresAndForwardsCompletedOutput(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
serverErrCh := make(chan error, 1)
|
||||
@@ -1167,9 +1177,10 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = r
|
||||
|
||||
data := make(chan []byte, 1)
|
||||
data := make(chan []byte, 2)
|
||||
errCh := make(chan *interfaces.ErrorMessage)
|
||||
data <- []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[{\"type\":\"message\",\"id\":\"out-1\"}]}}\n\n")
|
||||
data <- []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"lookup","arguments":"{}"}}`)
|
||||
data <- []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}\n\n")
|
||||
close(data)
|
||||
close(errCh)
|
||||
|
||||
@@ -1191,16 +1202,16 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
|
||||
serverErrCh <- fmt.Errorf("unexpected websocket error message: %v", errMsg.Error)
|
||||
return
|
||||
}
|
||||
if gjson.GetBytes(completedOutput, "0.id").String() != "out-1" {
|
||||
serverErrCh <- errors.New("completed output not captured")
|
||||
if gjson.GetBytes(completedOutput, "0.id").String() != "call-1" {
|
||||
serverErrCh <- errors.New("completed output not restored")
|
||||
return
|
||||
}
|
||||
if completedResponseID != "resp-1" {
|
||||
serverErrCh <- fmt.Errorf("completed response id = %q, want resp-1", completedResponseID)
|
||||
return
|
||||
}
|
||||
if len(pendingToolCallIDs) != 0 {
|
||||
serverErrCh <- fmt.Errorf("pending tool call ids = %v, want empty", pendingToolCallIDs)
|
||||
if len(pendingToolCallIDs) != 1 || pendingToolCallIDs[0] != "call-1" {
|
||||
serverErrCh <- fmt.Errorf("pending tool call ids = %v, want [call-1]", pendingToolCallIDs)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(timelineLog.String(), "Event: websocket.response") {
|
||||
@@ -1223,9 +1234,17 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
_, outputItemPayload, errReadMessage := conn.ReadMessage()
|
||||
if errReadMessage != nil {
|
||||
t.Fatalf("read output item websocket message: %v", errReadMessage)
|
||||
}
|
||||
if got := gjson.GetBytes(outputItemPayload, "type").String(); got != "response.output_item.done" {
|
||||
t.Fatalf("output item payload type = %s, want response.output_item.done", got)
|
||||
}
|
||||
|
||||
_, payload, errReadMessage := conn.ReadMessage()
|
||||
if errReadMessage != nil {
|
||||
t.Fatalf("read websocket message: %v", errReadMessage)
|
||||
t.Fatalf("read completion websocket message: %v", errReadMessage)
|
||||
}
|
||||
if gjson.GetBytes(payload, "type").String() != wsEventTypeCompleted {
|
||||
t.Fatalf("payload type = %s, want %s", gjson.GetBytes(payload, "type").String(), wsEventTypeCompleted)
|
||||
@@ -1233,6 +1252,9 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
|
||||
if strings.Contains(string(payload), "response.done") {
|
||||
t.Fatalf("payload unexpectedly rewrote completed event: %s", payload)
|
||||
}
|
||||
if got := gjson.GetBytes(payload, "response.output.0.id").String(); got != "call-1" {
|
||||
t.Fatalf("downstream completion output id = %q, want call-1; payload=%s", got, payload)
|
||||
}
|
||||
|
||||
if errServer := <-serverErrCh; errServer != nil {
|
||||
t.Fatalf("server error: %v", errServer)
|
||||
@@ -3066,6 +3088,60 @@ func TestNormalizeSubsequentRequestCompactSkipsMerge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSubsequentRequestReasoningContinuationWithPreviousResponseID(t *testing.T) {
|
||||
lastRequest := []byte(`{"model":"gpt-5.6-terra","stream":true,"input":[{"type":"message","role":"user","id":"old-user","content":"long history"}]}`)
|
||||
lastResponseOutput := []byte(`[{"type":"function_call","id":"old-call","call_id":"old-call","name":"lookup","arguments":"{}"}]`)
|
||||
|
||||
for _, requestType := range []string{"response.create", "response.append"} {
|
||||
t.Run(requestType, func(t *testing.T) {
|
||||
raw := []byte(`{"type":"` + requestType + `","previous_response_id":"resp-1","input":[
|
||||
{"type":"reasoning","id":"reasoning-1","summary":[]},
|
||||
{"type":"function_call_output","id":"output-1","call_id":"old-call","output":"result"}
|
||||
]}`)
|
||||
|
||||
normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)
|
||||
if errMsg != nil {
|
||||
t.Fatalf("unexpected error: %v", errMsg.Error)
|
||||
}
|
||||
if got := gjson.GetBytes(normalized, "previous_response_id").String(); got != "resp-1" {
|
||||
t.Fatalf("previous_response_id = %q, want resp-1; payload=%s", got, normalized)
|
||||
}
|
||||
input := gjson.GetBytes(normalized, "input").Array()
|
||||
if len(input) != 2 || input[0].Get("id").String() != "reasoning-1" || input[1].Get("id").String() != "output-1" {
|
||||
t.Fatalf("incremental continuation was replaced or merged: %s", normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesWebsocketOutputCollectorRestoresCompletedOutput(t *testing.T) {
|
||||
outputItemsByIndex := make(map[int64][]byte)
|
||||
var outputItemsFallback [][]byte
|
||||
for _, payload := range [][]byte{
|
||||
[]byte(`{"type":"response.output_item.done","output_index":1,"item":{"type":"message","id":"reply-1","role":"assistant"}}`),
|
||||
[]byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"summary-1","summary":[]}}`),
|
||||
[]byte(`{"type":"response.output_item.done","item":{"type":"function_call","id":"call-1","call_id":"call-1"}}`),
|
||||
} {
|
||||
collectResponsesWebsocketOutputItem(payload, outputItemsByIndex, &outputItemsFallback)
|
||||
}
|
||||
|
||||
output := responseCompletedOutputFromPayload(
|
||||
[]byte(`{"type":"response.completed","response":{"id":"resp-1","output":[]}}`),
|
||||
outputItemsByIndex,
|
||||
outputItemsFallback,
|
||||
)
|
||||
items := gjson.ParseBytes(output).Array()
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("collected output len = %d, want 3: %s", len(items), output)
|
||||
}
|
||||
wantIDs := []string{"summary-1", "reply-1", "call-1"}
|
||||
for i, wantID := range wantIDs {
|
||||
if got := items[i].Get("id").String(); got != wantID {
|
||||
t.Fatalf("output[%d].id = %q, want %q: %s", i, got, wantID, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSubsequentRequestCompactMergesWhenCompactionReplayUnsupported(t *testing.T) {
|
||||
lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[
|
||||
{"type":"message","role":"user","id":"msg-1","content":"original long prompt"},
|
||||
|
||||
Reference in New Issue
Block a user