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:
3 files changed
+172
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user