diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
index e53ff21a..87e325b7 100644
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -1561,6 +1561,11 @@ func sinceStart(before, d time.Duration) int64 {
// attempt sends a request to one provider. call.To stays empty when the
// provider has no endpoint to send it to.
func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, model string, body []byte, call *Call) (int, string) {
+ // Normalize for the actual destination, separately on each fallback.
+ // Native ChatGPT accounts accept standalone tool outputs themselves.
+ if from == provider.Responses && (p.Account == nil || p.Account.Agent != "codex") {
+ body = orphanedToolOutputs(body)
+ }
// every request to the provider goes through its own proxy, if it has
// one (#237)
r = r.WithContext(p.Via(r.Context()))
diff --git a/internal/gateway/ir.go b/internal/gateway/ir.go
index a59d47e5..9da0be3a 100644
--- a/internal/gateway/ir.go
+++ b/internal/gateway/ir.go
@@ -43,9 +43,10 @@ type Part struct {
Args json.RawMessage // a JSON object
// tool_result
- CallID string
- IsError bool
- Images []Part // the images the tool returned beside its text
+ CallID string
+ IsError bool
+ Images []Part // the images the tool returned beside its text
+ Standalone map[string]any // native Responses notification with no call ID
// thinking
Signature string
diff --git a/internal/gateway/orphan_tool_output_test.go b/internal/gateway/orphan_tool_output_test.go
new file mode 100644
index 00000000..bb2c6ff7
--- /dev/null
+++ b/internal/gateway/orphan_tool_output_test.go
@@ -0,0 +1,175 @@
+package gateway
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// Exercise both real ingress handlers and the two Responses forwarding paths.
+// A search-capable provider relays; offering web_search to one that cannot
+// search makes the gateway translate even when its upstream is Responses.
+func TestStandaloneToolOutputResponsesRoutes(t *testing.T) {
+ for _, endpoint := range []string{"/v1/responses", CodexPath + "/responses"} {
+ for _, relay := range []bool{true, false} {
+ route := "translated"
+ if relay {
+ route = "relayed"
+ }
+ for _, tc := range []struct {
+ name, input string
+ standalone bool
+ }{
+ {"ordinary", `[{"type":"message","role":"user","content":[{"type":"input_text","text":"ordinary"}]}]`, false},
+ {"paired", `[{"type":"function_call","call_id":"call_synthetic","name":"echo","arguments":"{}"},{"type":"function_call_output","call_id":"call_synthetic","output":"paired result"}]`, false},
+ {"standalone", `[{"type":"function_call","call_id":"call_synthetic","name":"echo","arguments":"{}"},{"type":"function_call_output","call_id":"call_synthetic","output":"paired result"},{"type":"function_call_output","id":"fco_synthetic","name":"send_message_to_thread","namespace":"codex_app","output":"synthetic delivery"}]`, true},
+ {"custom_missing", `[{"type":"custom_tool_call_output","output":"synthetic delivery"}]`, true},
+ {"custom_empty", `[{"type":"custom_tool_call_output","call_id":"","output":"synthetic delivery"}]`, true},
+ {"empty_id", `[{"type":"function_call_output","call_id":"","output":"synthetic delivery"}]`, true},
+ } {
+ t.Run(endpoint+"/"+route+"/"+tc.name, func(t *testing.T) {
+ f := &fake{t: t, reply: sse(`data: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`)}
+ f.refuse = func(body []byte) (int, string) {
+ // Use the wire spelling of call_id, not Go's default field matching.
+ var sent struct {
+ Input []map[string]any `json:"input"`
+ }
+ if err := json.Unmarshal(body, &sent); err != nil {
+ t.Fatal(err)
+ }
+ for _, item := range sent.Input {
+ if item["type"] == "function_call_output" && (item["call_id"] == nil || item["call_id"] == "") {
+ return 400, `{"error":{"message":"call_id required"}}`
+ }
+ }
+ return 0, ""
+ }
+ setup(t, provider.Responses, f)
+ p, err := provider.Find("fake")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.Searches = relay
+ if err := provider.Save(*p); err != nil {
+ t.Fatal(err)
+ }
+ body := `{"model":"fake/m1","stream":true,"tools":[{"type":"web_search"}],"input":` + tc.input + `}`
+ var code int
+ var response string
+ if endpoint == CodexPath+"/responses" {
+ code, response = codexPost(t, body)
+ } else {
+ code, response = post(t, endpoint, body)
+ }
+ if code != 200 {
+ t.Fatalf("status %d: %s", code, response)
+ }
+ var sent struct {
+ Input []map[string]any `json:"input"`
+ }
+ if err := json.Unmarshal(f.got, &sent); err != nil {
+ t.Fatal(err)
+ }
+ calls, outputs := 0, 0
+ for _, item := range sent.Input {
+ switch item["type"] {
+ case "function_call":
+ calls++
+ if item["call_id"] != "call_synthetic" {
+ t.Fatalf("call changed: %v", item)
+ }
+ case "function_call_output":
+ outputs++
+ if item["call_id"] != "call_synthetic" || item["output"] != "paired result" {
+ t.Fatalf("paired result changed: %v", item)
+ }
+ }
+ }
+ if tc.name == "paired" || tc.name == "standalone" {
+ if calls != 1 || outputs != 1 {
+ t.Fatalf("lost pair: %s", f.got)
+ }
+ } else if calls != 0 || outputs != 0 {
+ t.Fatalf("invented pair: %s", f.got)
+ }
+ if tc.standalone {
+ if !strings.Contains(string(f.got), "synthetic delivery") {
+ t.Fatalf("lost delivery: %s", f.got)
+ }
+ last := sent.Input[len(sent.Input)-1]
+ if last["type"] != "message" || last["role"] != "user" {
+ t.Fatalf("not user context: %v", last)
+ }
+ } else if tc.name == "ordinary" && !strings.Contains(string(f.got), "ordinary") {
+ t.Fatalf("lost ordinary message: %s", f.got)
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestOrphanToolOutputsContentAndNoops(t *testing.T) {
+ for _, body := range []string{`not json`, `{}`, `{"input":"hello"}`, `{"input":[{"type":"function_call_output","call_id":"paired","output":"ok"}]}`, `{"input":[{"role":"user","content":"ordinary"}]}`} {
+ if got := orphanedToolOutputs([]byte(body)); string(got) != body {
+ t.Fatalf("unchanged input rewritten: %s", got)
+ }
+ }
+ body := []byte(`{"model":"fake/m1","previous_response_id":"resp_synthetic","input":[{"type":"function_call_output","output":[{"type":"text","text":"look"},{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},{"type":"function_call_output","output":""}]}`)
+ original := bytes.Clone(body)
+ fixed := orphanedToolOutputs(body)
+ if !bytes.Equal(body, original) {
+ t.Fatal("mutated caller's request buffer")
+ }
+ var q struct {
+ Previous string `json:"previous_response_id"`
+ Input []struct {
+ Content []map[string]any `json:"content"`
+ } `json:"input"`
+ }
+ if err := json.Unmarshal(fixed, &q); err != nil {
+ t.Fatal(err)
+ }
+ if q.Previous != "resp_synthetic" || len(q.Input) != 2 || len(q.Input[0].Content) != 2 {
+ t.Fatalf("lost context: %s", fixed)
+ }
+ if q.Input[0].Content[0]["text"] != "look" || q.Input[0].Content[1]["image_url"] != "data:image/png;base64,AQID" || q.Input[1].Content[0]["text"] != "Tool result received." {
+ t.Fatalf("lost output content: %s", fixed)
+ }
+ if !bytes.Equal(orphanedToolOutputs(fixed), fixed) {
+ t.Fatal("normalization is not idempotent")
+ }
+}
+
+// OpenAI's native backend supports standalone outputs; do not rewrite that
+// path merely because Codex happened to send it through openai_base_url.
+func TestCodexNativeStandaloneOutputUnchanged(t *testing.T) {
+ var got []byte
+ chatgpt(t, func(w http.ResponseWriter, r *http.Request) {
+ got, _ = io.ReadAll(r.Body)
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, sse(`data: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`))
+ })
+ body := `{"model":"gpt-5.5","stream":true,"input":[{"type":"function_call_output","name":"send_message_to_thread","namespace":"codex_app","output":"synthetic native notification"}]}`
+ code, response := codexPost(t, body)
+ if code != 200 {
+ t.Fatalf("%d %s", code, response)
+ }
+ var q struct {
+ Input []map[string]any `json:"input"`
+ }
+ if err := json.Unmarshal(got, &q); err != nil {
+ t.Fatal(err)
+ }
+ if len(q.Input) != 1 || q.Input[0]["type"] != "function_call_output" || q.Input[0]["name"] != "send_message_to_thread" || q.Input[0]["namespace"] != "codex_app" {
+ t.Fatalf("native notification changed: %s", got)
+ }
+ if _, ok := q.Input[0]["call_id"]; ok {
+ t.Fatalf("native call ID invented: %s", got)
+ }
+}
diff --git a/internal/gateway/responses.go b/internal/gateway/responses.go
index f31228f3..09cde52a 100644
--- a/internal/gateway/responses.go
+++ b/internal/gateway/responses.go
@@ -221,7 +221,8 @@ func parseResponses(body []byte) (*Request, error) {
// written to the vendor's cache again (Anthropic's system comes
// first; a Claude subscription's waiting run is found by it) (#502)
replied := false
- for _, it := range items {
+ var rawItems []json.RawMessage // decoded only for native standalone outputs
+ for i, it := range items {
if it.Role == "assistant" || strings.HasPrefix(it.Type, "function_call") || strings.HasPrefix(it.Type, "custom_tool_call") || strings.HasPrefix(it.Type, "tool_search") || it.Type == "reasoning" {
replied = true
}
@@ -276,7 +277,17 @@ func parseResponses(body []byte) (*Request, error) {
r.Messages = append(r.Messages, Message{Role: "user", Parts: []Part{{Kind: ToolResult, CallID: it.CallID, Text: searchFound(it.Tools)}}})
case it.Type == "function_call_output" || it.Type == "custom_tool_call_output":
out, images := toolOutput(it.Output)
- r.Messages = append(r.Messages, Message{Role: "user", Parts: []Part{{Kind: ToolResult, CallID: it.CallID, Text: out, Images: images}}})
+ part := Part{Kind: ToolResult, CallID: it.CallID, Text: out, Images: images}
+ if it.CallID == "" {
+ // Native account requests may still need translation to
+ // collect a streamed response for a non-streaming client.
+ // Retain the original notification, including absent IDs.
+ if rawItems == nil {
+ _ = json.Unmarshal(q.Input, &rawItems)
+ }
+ _ = json.Unmarshal(rawItems[i], &part.Standalone)
+ }
+ r.Messages = append(r.Messages, Message{Role: "user", Parts: []Part{part}})
case it.Type == "reasoning":
// the reasoning itself when the item carries it, else its
// summary (all magpie gives a client of a translated reply)
@@ -401,6 +412,89 @@ func parseResponses(body []byte) (*Request, error) {
return r, nil
}
+// orphanedToolOutputs turns a tool result with no call ID into a user message.
+// Codex uses standalone outputs for cross-thread notifications. Backends
+// requiring paired outputs still need the delivered text, without a fake call ID.
+func orphanedToolOutputs(body []byte) []byte {
+ var q map[string]json.RawMessage
+ if json.Unmarshal(body, &q) != nil {
+ return body
+ }
+ var input []json.RawMessage
+ if json.Unmarshal(q["input"], &input) != nil {
+ return body
+ }
+ changed := false
+ out := make([]json.RawMessage, 0, len(input))
+ var pending []json.RawMessage
+ inCalls, hadResults := false, false
+ flush := func() {
+ out = append(out, pending...)
+ pending = nil
+ }
+ for _, raw := range input {
+ var item struct {
+ Type string `json:"type"`
+ CallID string `json:"call_id"`
+ Output json.RawMessage `json:"output"`
+ }
+ if json.Unmarshal(raw, &item) != nil {
+ flush()
+ inCalls = false
+ out = append(out, raw)
+ continue
+ }
+ isOutput := item.Type == "function_call_output" || item.Type == "custom_tool_call_output"
+ if !isOutput || item.CallID != "" {
+ switch item.Type {
+ case "function_call", "custom_tool_call", "tool_search_call":
+ if hadResults {
+ flush()
+ }
+ inCalls, hadResults = true, false
+ case "function_call_output", "custom_tool_call_output", "tool_search_output":
+ // Keep a run of tool results directly after its calls.
+ hadResults = true
+ default:
+ flush()
+ inCalls = false
+ }
+ out = append(out, raw)
+ continue
+ }
+ text, images := toolOutput(item.Output)
+ content := []map[string]any{}
+ if text != "" {
+ content = append(content, map[string]any{"type": "input_text", "text": text})
+ }
+ for _, image := range images {
+ content = append(content, map[string]any{"type": "input_image", "image_url": dataURL(image)})
+ }
+ if len(content) == 0 {
+ content = append(content, map[string]any{"type": "input_text", "text": "Tool result received."})
+ }
+ message, _ := marshalPlain(map[string]any{"type": "message", "role": "user", "content": content})
+ if inCalls {
+ // Chat rejects a user message between tool_calls and results,
+ // including between the results of parallel calls.
+ pending = append(pending, message)
+ } else {
+ out = append(out, message)
+ }
+ changed = true
+ }
+ flush()
+ if !changed {
+ return body
+ }
+ q["input"], _ = marshalPlain(out)
+ encoded, err := marshalPlain(q)
+ if err != nil {
+ return body
+ }
+ return encoded
+}
+
// mergeTurns joins consecutive messages of the same role, since the
// Responses API splits an assistant turn into one item per part.
func mergeTurns(msgs []Message) []Message {
@@ -496,6 +590,10 @@ func buildResponses(r *Request, model, host string, rejectTemp bool) []byte {
input = append(input, map[string]any{"type": "function_call", "call_id": id, "name": p.Name, "arguments": argsString(p)})
case ToolResult:
flushMsg()
+ if p.Standalone != nil {
+ input = append(input, p.Standalone)
+ continue
+ }
var output any = p.Text
if len(p.Images) > 0 {
// an output can be a list of text and images
diff --git a/internal/gateway/standalone_review_test.go b/internal/gateway/standalone_review_test.go
new file mode 100644
index 00000000..0d51ce6a
--- /dev/null
+++ b/internal/gateway/standalone_review_test.go
@@ -0,0 +1,223 @@
+package gateway
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+func TestStandaloneChatOrdering(t *testing.T) {
+ for _, endpoint := range []string{"/v1/responses", CodexPath + "/responses"} {
+ for _, kind := range []string{"function", "custom_tool"} {
+ call := func(id string) string {
+ if kind == "function" {
+ return fmt.Sprintf(`{"type":"function_call","call_id":%q,"name":"echo","arguments":"{}"}`, id)
+ }
+ return fmt.Sprintf(`{"type":"custom_tool_call","call_id":%q,"name":"echo","input":"echo"}`, id)
+ }
+ output := func(id, text string) string {
+ return fmt.Sprintf(`{"type":%q,"call_id":%q,"output":%q}`, kind+"_call_output", id, text)
+ }
+ notice := fmt.Sprintf(`{"type":%q,"output":"notice"}`, kind+"_call_output")
+ for _, tc := range []struct{ name, input, roles string }{
+ {"before_calls", notice + "," + call("A") + "," + output("A", "result A"), "user,assistant,tool"},
+ {"before_result", call("A") + "," + notice + "," + output("A", "result A"), "assistant,tool,user"},
+ {"between_parallel_results", call("A") + "," + call("B") + "," + output("A", "result A") + "," + notice + "," + output("B", "result B"), "assistant,tool,tool,user"},
+ {"empty_id", call("A") + "," + output("", "notice") + "," + output("A", "result A"), "assistant,tool,user"},
+ } {
+ t.Run(endpoint+"/"+kind+"/"+tc.name, func(t *testing.T) {
+ f := &fake{t: t, reply: sse(`data: {"id":"ok","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}`, `data: [DONE]`)}
+ var roles []string
+ f.refuse = func(body []byte) (int, string) {
+ var q struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Calls []struct {
+ ID string `json:"id"`
+ } `json:"tool_calls"`
+ ID string `json:"tool_call_id"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(body, &q); err != nil {
+ t.Error(err)
+ return 400, `{"error":{"message":"bad JSON"}}`
+ }
+ pending := map[string]bool{}
+ for _, m := range q.Messages {
+ roles = append(roles, m.Role)
+ if len(pending) > 0 && m.Role != "tool" {
+ return 400, `{"error":{"message":"interrupted tool results"}}`
+ }
+ if m.Role == "tool" {
+ if !pending[m.ID] {
+ return 400, `{"error":{"message":"unpaired result"}}`
+ }
+ delete(pending, m.ID)
+ }
+ for _, c := range m.Calls {
+ pending[c.ID] = true
+ }
+ }
+ if len(pending) > 0 {
+ return 400, `{"error":{"message":"missing result"}}`
+ }
+ return 0, ""
+ }
+ setup(t, provider.Chat, f)
+ body := `{"model":"fake/m1","input":[` + tc.input + `]}`
+ var code int
+ var response string
+ if endpoint == "/v1/responses" {
+ code, response = post(t, endpoint, body)
+ } else {
+ code, response = codexPost(t, body)
+ }
+ if code != 200 {
+ t.Fatalf("%d: %s", code, response)
+ }
+ if got := strings.Join(roles, ","); got != tc.roles {
+ t.Fatalf("roles %s, want %s: %s", got, tc.roles, f.got)
+ }
+ for _, text := range []string{"notice", "result A"} {
+ if !bytes.Contains(f.got, []byte(text)) {
+ t.Fatalf("lost %s: %s", text, f.got)
+ }
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestStandaloneNativeAccounts(t *testing.T) {
+ for _, kind := range []string{"function_call_output", "custom_tool_call_output"} {
+ for _, id := range []string{"", `,"call_id":""`} {
+ for _, tc := range []struct {
+ name, path, model, pin, account string
+ saved bool
+ }{
+ {"direct", CodexPath + "/responses", "gpt-5.5", "", "acct-1", false},
+ {"saved", CodexPath + "/responses", "gpt-5.5", "", "acct-1", true},
+ {"fixed", CodexPath + "/responses", "gpt-5.5", "spare@example.com", "acct-2", true},
+ {"namespaced", CodexPath + "/responses", "codex/gpt-5.5", "spare@example.com", "acct-2", true},
+ {"v1_saved", "/v1/responses", "codex/gpt-5.5", "", "", true},
+ {"v1_fixed", "/v1/responses", "codex/gpt-5.5", "spare@example.com", "acct-2", true},
+ } {
+ for _, stream := range []bool{true, false} {
+ if !stream && !tc.saved {
+ continue
+ }
+ t.Run(fmt.Sprintf("%s/%s/%s/stream=%t", kind, id, tc.name, stream), func(t *testing.T) {
+ if tc.saved {
+ codexSignedIn(t, "spare@example.com")
+ } else {
+ fresh(t)
+ provider.ForgetAccounts()
+ t.Cleanup(provider.ForgetAccounts)
+ }
+ var got []byte
+ var account string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got, _ = io.ReadAll(r.Body)
+ account = r.Header.Get("chatgpt-account-id")
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, sse(`data: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`))
+ }))
+ defer up.Close()
+ old := provider.CodexBase
+ provider.CodexBase = up.URL + "/backend-api/codex"
+ defer func() { provider.CodexBase = old }()
+ body := fmt.Sprintf(`{"model":%q,"stream":%t,"input":[{"type":%q,"id":"standalone_synthetic","name":"send_message_to_thread","namespace":"codex_app","output":"native notice"%s}]}`, tc.model, stream, kind, id)
+ req := httptest.NewRequest("POST", tc.path, strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer chatgpt-token")
+ req.Header.Set("chatgpt-account-id", "acct-1")
+ req.Header.Set(AccountHeader, tc.pin)
+ rec := httptest.NewRecorder()
+ New().Handler().ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("%d %s", rec.Code, rec.Body.String())
+ }
+ if tc.account != "" && account != tc.account {
+ t.Fatalf("account %q, want %q", account, tc.account)
+ }
+ var sent struct {
+ Input []map[string]any `json:"input"`
+ }
+ json.Unmarshal(got, &sent)
+ if len(sent.Input) != 1 {
+ t.Fatalf("input: %s", got)
+ }
+ item := sent.Input[0]
+ if item["id"] != "standalone_synthetic" || item["type"] != kind || item["name"] != "send_message_to_thread" || item["namespace"] != "codex_app" || item["output"] != "native notice" {
+ t.Fatalf("native notification changed: %v", item)
+ }
+ value, present := item["call_id"]
+ if (id != "") != present || present && value != "" {
+ t.Fatalf("native ID changed: %v", item)
+ }
+ })
+ }
+ }
+ }
+ }
+}
+
+func TestStandalonePlainEncoding(t *testing.T) {
+ for _, kind := range []string{"function_call_output", "custom_tool_call_output"} {
+ for _, body := range []string{`{ "z": "&", "input": [ { "role": "user", "content": "hi" } ], "a": 1 }`, fmt.Sprintf(`{ "input": [{"type":%q,"call_id":"A","output":""}], "instructions":"&" }`, kind)} {
+ if got := orphanedToolOutputs([]byte(body)); string(got) != body {
+ t.Fatalf("no-op bytes changed: %s", got)
+ }
+ }
+ body := []byte(fmt.Sprintf(`{ "z": 1, "instructions": "&", "input": [{"type":%q,"output":"&"}], "a": 2 }`, kind))
+ got := orphanedToolOutputs(body)
+ for _, literal := range []string{"&", "&"} {
+ if !bytes.Contains(got, []byte(literal)) {
+ t.Fatalf("escaped %s: %s", literal, got)
+ }
+ }
+ if !bytes.Equal(got, orphanedToolOutputs(got)) {
+ t.Fatal("not idempotent")
+ }
+ }
+}
+
+// A failed third-party attempt must not carry its normalized body into a
+// native ChatGPT fallback for the same request.
+func TestStandaloneNativeFallback(t *testing.T) {
+ codexSignedIn(t, "spare@example.com")
+ var third, native []byte
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ if r.URL.Path == "/v1/responses" {
+ third = b
+ w.WriteHeader(429)
+ io.WriteString(w, `{"error":{"message":"rate limited"}}`)
+ return
+ }
+ native = b
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, sse(`data: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`))
+ }))
+ defer up.Close()
+ old := provider.CodexBase
+ provider.CodexBase = up.URL + "/backend-api/codex"
+ defer func() { provider.CodexBase = old }()
+ if err := provider.Save(provider.Provider{ID: "third", Name: "Third", Key: "synthetic", Models: []string{"m1"}, Responses: up.URL + "/v1", Fallback: []string{"codex/gpt-5.5"}}); err != nil {
+ t.Fatal(err)
+ }
+ code, response := post(t, "/v1/responses", `{"model":"third/m1","stream":true,"input":[{"type":"function_call_output","name":"send_message_to_thread","namespace":"codex_app","output":"notice"}]}`)
+ if code != 200 {
+ t.Fatalf("%d %s", code, response)
+ }
+ if !bytes.Contains(third, []byte(`"role":"user"`)) || !bytes.Contains(native, []byte(`"type":"function_call_output"`)) || !bytes.Contains(native, []byte(`"namespace":"codex_app"`)) {
+ t.Fatalf("third=%s native=%s", third, native)
+ }
+}
diff --git a/internal/provider/codex_request.go b/internal/provider/codex_request.go
index 1a3367cb..511b614d 100644
--- a/internal/provider/codex_request.go
+++ b/internal/provider/codex_request.go
@@ -379,6 +379,13 @@ func codexInput(input []any) []any {
if it["type"] == "item_reference" {
continue
}
+ // Codex delivers standalone notifications without a call ID. The
+ // native backend understands their name/namespace and item ID;
+ // they are not historical results whose calls have gone missing.
+ if (it["type"] == "function_call_output" || it["type"] == "custom_tool_call_output") && (it["call_id"] == nil || it["call_id"] == "") {
+ out = append(out, it)
+ continue
+ }
delete(it, "id")
switch t, _ := it["type"].(string); t {
case "function_call_output", "custom_tool_call_output", "local_shell_call_output":
diff --git a/internal/provider/codex_request_test.go b/internal/provider/codex_request_test.go
index f1806b81..61104c9a 100644
--- a/internal/provider/codex_request_test.go
+++ b/internal/provider/codex_request_test.go
@@ -204,3 +204,25 @@ func TestCodexBodyServiceTier(t *testing.T) {
t.Errorf("none became %v", v)
}
}
+
+// Standalone notifications retain the metadata the native backend recognizes;
+// paired outputs and ID-bearing historical orphans keep their existing rules.
+func TestCodexStandaloneNotifications(t *testing.T) {
+ t.Setenv("CODEX_HOME", t.TempDir())
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+ for _, kind := range []string{"function_call_output", "custom_tool_call_output"} {
+ for _, id := range []string{"", `,"call_id":""`} {
+ body := []byte(`{"model":"gpt-5.5","input":[{"type":"` + kind + `","id":"standalone","name":"send_message_to_thread","namespace":"codex_app","output":"native notice"` + id + `}]}`)
+ var before, after struct {
+ Input []map[string]any `json:"input"`
+ }
+ json.Unmarshal(body, &before)
+ json.Unmarshal(codexBody(body), &after)
+ b, _ := json.Marshal(before.Input)
+ a, _ := json.Marshal(after.Input)
+ if string(a) != string(b) {
+ t.Fatalf("standalone changed: %s -> %s", b, a)
+ }
+ }
+ }
+}