Skip to content

Commit e0919ce

Browse files
committed
fix: normalize responses replay inputs
1 parent 2f109c4 commit e0919ce

6 files changed

Lines changed: 522 additions & 7 deletions

File tree

api/validation.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,13 @@ func ValidateInput() ValidationRule {
676676
"image_generation_call": true,
677677
"web_search_call": true,
678678
"compaction": true,
679+
"input_text": true,
680+
"input_image": true,
681+
"output_text": true,
682+
"refusal": true,
683+
"input_file": true,
684+
"computer_screenshot": true,
685+
"summary_text": true,
679686
"file": true,
680687
"image": true,
681688
}

api/validation_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,26 @@ func TestValidateResponsesAPIRequestAllowsCompactionInputType(t *testing.T) {
7575
}
7676
}
7777

78+
func TestValidateResponsesAPIRequestAllowsOfficialContentInputTypes(t *testing.T) {
79+
result := ValidateResponsesAPIRequest(
80+
[]byte(`{
81+
"model":"gpt-5.4",
82+
"input":[
83+
{"type":"input_text","text":"hello"},
84+
{"type":"input_image","image_url":"https://example.com/cat.png"},
85+
{"type":"input_file","file_id":"file_abc"},
86+
{"type":"computer_screenshot","image_url":"https://example.com/screen.png"},
87+
{"type":"summary_text","text":"summary"}
88+
]
89+
}`),
90+
[]string{"gpt-5.4"},
91+
)
92+
93+
if !result.Valid {
94+
t.Fatalf("expected official Responses content input types to be valid, got %#v", result.Errors)
95+
}
96+
}
97+
7898
func TestValidateResponsesAPIRequestMaxOutputTokensCap(t *testing.T) {
7999
tests := []struct {
80100
name string

proxy/response_cache.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,19 @@ func cacheCompletedResponse(expandedInputRaw []byte, completedData []byte) {
227227
inputItems := gjson.ParseBytes(expandedInputRaw)
228228
if inputItems.IsArray() {
229229
inputItems.ForEach(func(_, v gjson.Result) bool {
230-
items = append(items, json.RawMessage(v.Raw))
230+
if item, ok := replayableCachedInputItem(v); ok {
231+
items = append(items, item)
232+
}
231233
return true
232234
})
233235
}
234236

235-
// 添加响应 output items
237+
// 添加响应 output 中真正需要续链的工具上下文;reasoning/message 等
238+
// 服务端输出 item 带有 rs_/msg_ id,store=false 时回灌会触发 item not found。
236239
output.ForEach(func(_, v gjson.Result) bool {
237-
items = append(items, json.RawMessage(v.Raw))
240+
if item, ok := replayableCachedOutputItem(v); ok {
241+
items = append(items, item)
242+
}
238243
return true
239244
})
240245

@@ -243,6 +248,33 @@ func cacheCompletedResponse(expandedInputRaw []byte, completedData []byte) {
243248
}
244249
}
245250

251+
func replayableCachedInputItem(item gjson.Result) (json.RawMessage, bool) {
252+
return stripResponseItemID(json.RawMessage(item.Raw))
253+
}
254+
255+
func replayableCachedOutputItem(item gjson.Result) (json.RawMessage, bool) {
256+
if !isCodexToolCallContextType(item.Get("type").String()) {
257+
return nil, false
258+
}
259+
return stripResponseItemID(json.RawMessage(item.Raw))
260+
}
261+
262+
func stripResponseItemID(raw json.RawMessage) (json.RawMessage, bool) {
263+
var item map[string]any
264+
if err := json.Unmarshal(raw, &item); err != nil || item == nil {
265+
return raw, true
266+
}
267+
if _, exists := item["id"]; !exists {
268+
return raw, true
269+
}
270+
delete(item, "id")
271+
stripped, err := json.Marshal(item)
272+
if err != nil {
273+
return nil, false
274+
}
275+
return stripped, true
276+
}
277+
246278
func isCodexToolCallContextType(typ string) bool {
247279
switch typ {
248280
case "function_call",

proxy/response_cache_test.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func TestCacheCompletedResponseCachesCodexNativeToolCalls(t *testing.T) {
1616
resetResponseCacheForTest()
1717

1818
expandedInput := []byte(`[{"type":"message","role":"user","content":"find a tool"}]`)
19-
completed := []byte(`{"type":"response.completed","response":{"id":"resp_native","output":[{"type":"tool_search_call","call_id":"call_search","status":"completed"}]}}`)
19+
completed := []byte(`{"type":"response.completed","response":{"id":"resp_native","output":[{"type":"tool_search_call","id":"ts_123","call_id":"call_search","status":"completed"}]}}`)
2020

2121
cacheCompletedResponse(expandedInput, completed)
2222

@@ -27,6 +27,9 @@ func TestCacheCompletedResponseCachesCodexNativeToolCalls(t *testing.T) {
2727
if got := gjson.GetBytes(cached[1], "call_id").String(); got != "call_search" {
2828
t.Fatalf("cached call_id = %q, want call_search", got)
2929
}
30+
if got := gjson.GetBytes(cached[1], "id"); got.Exists() {
31+
t.Fatalf("cached output item id should be stripped for store=false replay, got %s", got.Raw)
32+
}
3033
}
3134

3235
func TestExpandPreviousResponseUsesCachedCodexNativeToolContext(t *testing.T) {
@@ -129,3 +132,33 @@ func TestCacheCompletedResponseDoesNotCacheNonCallIDToolCalls(t *testing.T) {
129132
})
130133
}
131134
}
135+
136+
func TestCacheCompletedResponseSkipsReasoningAndMessageOutputItems(t *testing.T) {
137+
resetResponseCacheForTest()
138+
139+
cacheCompletedResponse(
140+
[]byte(`[{"type":"message","id":"msg_input","role":"user","content":"call a tool"}]`),
141+
[]byte(`{"type":"response.completed","response":{"id":"resp_reasoning","output":[`+
142+
`{"type":"reasoning","id":"rs_0609","encrypted_content":"opaque"},`+
143+
`{"type":"message","id":"msg_output","role":"assistant","content":[{"type":"output_text","text":"thinking"}]},`+
144+
`{"type":"function_call","id":"fc_123","call_id":"call_abc","name":"lookup","arguments":"{}"}`+
145+
`]}}`),
146+
)
147+
148+
cached := getResponseCache("resp_reasoning")
149+
if len(cached) != 2 {
150+
t.Fatalf("cached items = %d, want input message + function_call only", len(cached))
151+
}
152+
if typ := gjson.GetBytes(cached[0], "type").String(); typ != "message" {
153+
t.Fatalf("cached[0].type = %q, want message", typ)
154+
}
155+
if id := gjson.GetBytes(cached[0], "id"); id.Exists() {
156+
t.Fatalf("cached input id should be stripped, got %s", id.Raw)
157+
}
158+
if typ := gjson.GetBytes(cached[1], "type").String(); typ != "function_call" {
159+
t.Fatalf("cached[1].type = %q, want function_call", typ)
160+
}
161+
if id := gjson.GetBytes(cached[1], "id"); id.Exists() {
162+
t.Fatalf("cached function_call id should be stripped, got %s", id.Raw)
163+
}
164+
}

0 commit comments

Comments
 (0)