gateway: a tool search call magpie hands Codex has a tsc_ id, and one already in a session goes to OpenAI as one (Lullaby on Discord: Invalid 'input[98].id': 'fc_18da0e90a0fc07ee0000005f'. Expected an ID that begins with 'tsc', and auto-compaction failed) — a vendor's tool search, translated to Codex's tool_search_call, was given an fc_ id, as a function call is; Codex keeps it in its history and sends it back every turn, and once the conversation went to one of Codex's own models, a turn or /responses/compact, OpenAI turned the whole request away. Search calls now get tsc_ ids in the stream and the rendered reply, and a tool_search_call whose id isn't a tsc_ one is renamed (fc_X becomes tsc_X) before Codex's own /responses and /responses/compact, the rest of the body byte for byte, so a session needs no restart. Prompts and headers are unchanged. TestToolSearchCallHasTscID and TestCodexOwnModelSearchCallIDs fail without the change; TestSearchCallIDsUntouched keeps a clean request byte for byte. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

This commit is contained in:
yetone committed 2026-10-01 17:46:54 +08:00
1 parent 08be4a2907
commit 1a9c4ca6b7
3 files changed
+172 -3

No files matched your search

+52
View File
@@ -99,6 +99,7 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) {
s.serve(w, r, provider.Responses, body)
return
}
body = searchCallIDs(body)
if rest == "/responses/compact" {
break // preserve native compaction's existing passthrough
}
@@ -741,6 +742,57 @@ func codexInput(body []byte, magpieModel bool) (_ []byte, compact bool) {
return nb, compact
}
// searchCallIDs is a Responses request whose tool_search_call items have
// ids OpenAI takes. A vendor's reply that magpie, or the vendor, gave the
// call as a function_call's (fc_…) came back to Codex as Codex's
// tool_search_call with that id, and Codex hands it back on every later
// turn: OpenAI's own models and the ChatGPT backend turn the whole request
// away ("Invalid 'input[98].id': 'fc_…'. Expected an ID that begins with
// 'tsc'"), Codex's compaction with it. Such an id goes as a tsc_ one; the
// rest of the request goes byte for byte.
func searchCallIDs(body []byte) []byte {
if !bytes.Contains(body, []byte(`"tool_search_call"`)) {
return body
}
var q map[string]json.RawMessage
if json.Unmarshal(body, &q) != nil {
return body
}
var items []json.RawMessage
if json.Unmarshal(q["input"], &items) != nil {
return body
}
changed := false
for i, raw := range items {
var it map[string]json.RawMessage
if json.Unmarshal(raw, &it) != nil || string(it["type"]) != `"tool_search_call"` {
continue
}
var id string
if json.Unmarshal(it["id"], &id) != nil || id == "" || strings.HasPrefix(id, "tsc_") {
continue
}
if _, rest, ok := strings.Cut(id, "_"); ok && rest != "" {
id = "tsc_" + rest
} else {
id = "tsc_" + id
}
it["id"], _ = json.Marshal(id)
if b, err := marshalPlain(it); err == nil {
items[i], changed = b, true
}
}
if !changed {
return body
}
q["input"], _ = marshalPlain(items)
nb, err := marshalPlain(q)
if err != nil {
return body
}
return nb
}
// openaiOnly are the parts of a Responses request Codex sends only to a
// provider named "OpenAI": its built-in one (signed in, through
// openai_base_url), or CC Switch's table so named for remote compaction.
+17 -3
View File
@@ -677,6 +677,17 @@ func callTo(item map[string]any, name string, named map[string]nsTool) map[strin
return item
}
// itemPrefix is the prefix of a call item's id, by its type: OpenAI turns
// away a tool_search_call whose id isn't a tsc_ one ("Invalid
// 'input[98].id': 'fc_…'. Expected an ID that begins with 'tsc'"), and
// Codex hands the item back to it when the conversation goes there.
func itemPrefix(item map[string]any) string {
if item["type"] == "tool_search_call" {
return "tsc_"
}
return "fc_"
}
func (e *responsesEncoder) send(typ string, fields map[string]any) {
fields["type"] = typ
fields["sequence_number"] = e.seq
@@ -796,7 +807,8 @@ func (e *responsesEncoder) event(ev Event) {
}
// Open (and so close the previous item) before recording this call:
// closeItem reads the call ID from e.col.last(ToolCall).
e.openItem(ToolCall, "fc_", callTo(map[string]any{"type": "function_call", "call_id": ev.ID, "arguments": ""}, ev.Name, e.named))
item := callTo(map[string]any{"type": "function_call", "call_id": ev.ID, "arguments": ""}, ev.Name, e.named)
e.openItem(ToolCall, itemPrefix(item), item)
e.col.add(ev)
return
case KToolArgs:
@@ -850,8 +862,10 @@ func renderResponses(res Result, model string, named map[string]nsTool) []byte {
if id == "" {
id = "call_" + newID()
}
output = append(output, callTo(map[string]any{"id": "fc_" + newID(), "type": "function_call", "status": "completed",
"call_id": id, "arguments": argsString(p)}, p.Name, named))
item := callTo(map[string]any{"type": "function_call", "status": "completed",
"call_id": id, "arguments": argsString(p)}, p.Name, named)
item["id"] = itemPrefix(item) + newID()
output = append(output, item)
}
}
id := res.ID
+103
View File
@@ -0,0 +1,103 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// A search a translated vendor's model made goes back to Codex as a
// tool_search_call with a tsc_ id, the one kind OpenAI takes for it.
func TestToolSearchCallHasTscID(t *testing.T) {
req := &Request{Model: "m", Namespaced: map[string]nsTool{"tool_search": {Search: true}}}
rec := httptest.NewRecorder()
enc := encoder(provider.Responses, newSSEWriter(rec), req)
enc.event(Event{Kind: KToolStart, ID: "s2", Name: "tool_search"})
enc.event(Event{Kind: KToolArgs, Text: `{"query":"calendar"}`})
enc.event(Event{Kind: KToolStart, ID: "c1", Name: "shell"})
enc.finish()
ids := map[string]string{}
for _, ev := range events(rec.Body.String()) {
if ev["type"] == "response.output_item.done" {
it := ev["item"].(map[string]any)
ids[it["type"].(string)], _ = it["id"].(string)
}
}
if !strings.HasPrefix(ids["tool_search_call"], "tsc_") || !strings.HasPrefix(ids["function_call"], "fc_") {
t.Fatalf("streamed ids = %v", ids)
}
res := Result{Parts: []Part{{Kind: ToolCall, ID: "s3", Name: "tool_search", Args: json.RawMessage(`{"query":"mail"}`)}}}
var out struct {
Output []map[string]any `json:"output"`
}
if err := json.Unmarshal(render(provider.Responses, res, req), &out); err != nil {
t.Fatal(err)
}
if id, _ := out.Output[0]["id"].(string); len(out.Output) != 1 || !strings.HasPrefix(id, "tsc_") {
t.Fatalf("rendered = %v", out.Output)
}
}
// A conversation that searched on another vendor's model before (Lullaby
// on Discord: "Invalid 'input[98].id': 'fc_18da0e90a0fc07ee0000005f'.
// Expected an ID that begins with 'tsc'", Codex's compaction failing with
// it) goes on to OpenAI with that search's id a tsc_ one, on a turn and on
// a compaction; OpenAI's own ids and everything else go as they came.
func TestCodexOwnModelSearchCallIDs(t *testing.T) {
setup(t, provider.Chat, &fake{t: t})
var got []string
chatgpt(t, func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = append(got, string(b))
var q struct {
Input []map[string]any `json:"input"`
}
json.Unmarshal(b, &q)
for i, it := range q.Input {
if id, _ := it["id"].(string); it["type"] == "tool_search_call" && id != "" && !strings.HasPrefix(id, "tsc") {
w.WriteHeader(400)
io.WriteString(w, `{"error":{"message":"Invalid 'input[`+string(rune('0'+i))+`].id': '`+id+`'. Expected an ID that begins with 'tsc'.","type":"invalid_request_error","param":"input[`+string(rune('0'+i))+`].id","code":"invalid_value"}}`)
return
}
}
w.Header()["Content-Type"] = nil
io.WriteString(w, sse(`data: {"type":"response.completed","response":{"id":"r1"}}`))
})
input := `[{"type":"message","role":"user","content":[{"type":"input_text","text":"find mail"}]},
{"type":"tool_search_call","id":"fc_18da0e90a0fc07ee0000005f","call_id":"s1","status":"completed","execution":"client","arguments":{"query":"mail"}},
{"type":"tool_search_output","call_id":"s1","status":"completed","execution":"client","tools":[]},
{"type":"tool_search_call","id":"tsc_openai","call_id":"s2","status":"completed","execution":"client","arguments":{"query":"cal"}},
{"type":"tool_search_output","call_id":"s2","status":"completed","execution":"client","tools":[]}`
for _, path := range []string{"/responses", "/responses/compact"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", CodexPath+path, strings.NewReader(`{"model":"gpt-5.5","stream":true,"store":false,"input":`+input+`]}`))
req.Header.Set("Authorization", "Bearer chatgpt-token")
req.Header.Set("chatgpt-account-id", "acct-1")
New().Handler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body)
}
last := got[len(got)-1]
if !strings.Contains(last, `"tsc_18da0e90a0fc07ee0000005f"`) || !strings.Contains(last, `"tsc_openai"`) || !strings.Contains(last, "find mail") {
t.Errorf("%s upstream: %s", path, last)
}
}
}
// Nothing to fix leaves the request byte for byte.
func TestSearchCallIDsUntouched(t *testing.T) {
for _, b := range []string{
`{"model":"gpt-5.5","input":[{"type":"function_call","id":"fc_1","call_id":"c","name":"shell","arguments":"{}"}]}`,
`{"model":"gpt-5.5", "input":[{"type":"tool_search_call","id":"tsc_1","call_id":"s","arguments":{}},{"type":"tool_search_call","call_id":"t","arguments":{}}]}`,
} {
if got := searchCallIDs([]byte(b)); string(got) != b {
t.Errorf("%s -> %s", b, got)
}
}
}