Files
magpie/internal/gateway/codex_custom_tools_test.go
T
yetone 55e7df478f codex: a group's effort for the turn keeps Codex's reasoning.context, and Codex's custom tools (code mode's exec, apply_patch) reach a model magpie translates for (#534, #520 follow-up, azir12345: since v0.1.637 a Plus account's gpt-6.1-sol fails with 400 "X-OpenAI-Internal-Codex-Responses-Lite requires reasoning.context to be all_turns", and Codex said it had only the collaboration and wait tools, no terminal) — ChatGPT's catalog marks gpt-6.1-sol (as gpt-6-sol, gpt-6-luna, gpt-5.6-*) use_responses_lite with tool_mode code_mode_only, and Codex asks for the bare slug with its native metadata: the X-OpenAI-Internal-Codex-Responses-Lite header (add_responses_lite_header, which magpie forwards with the other x-openai-* headers), reasoning {"effort","summary","context":"all_turns"} (build_reasoning), and its tools moved into an additional_tools item, every other tool reached through exec, a custom (freeform, lark grammar) tool in the "functions" namespace, beside wait and the collaboration namespace. magpie routes the bare id to the group auto-gpt-6-1-sol, and the effort the group picks for the turn went through withEffort, whose Responses branch rebuilt reasoning as {effort, summary} and dropped context, so ChatGPT refused the Lite request; it now sets only the effort (withBodyEffort), keeping every other field of reasoning as Codex sent it. A member served by translation (Chat, Anthropic, a Responses plugin) had parseResponses drop custom tools and ignore custom_tool_call / custom_tool_call_output items, so exec vanished and the model saw only wait and the collaboration tools; a custom tool (top-level or in a namespace) is now offered as a function taking one string "input", its description its own plus a note on input and its grammar, an earlier custom_tool_call goes to the model as a call with {"input":…} and its output as the call's result, and the model's call goes back to Codex as the custom_tool_call it runs (name and namespace as Codex knows them, input from the "input" argument, else the arguments as given), streamed without function_call_arguments events and in the non-streamed reply alike. This also gives translated models Codex's freeform apply_patch, which was dropped silently before. The Lite header is still forwarded as it was; nothing showed it needs dropping. Codex's base_instructions and identity line are untouched. No request was made to ChatGPT. TestWithEffort's Lite case fails without the decide.go change ("want \"context\":\"all_turns\""); TestCodexCustomToolsTranslated (a code-mode Lite turn through a fake Chat provider: exec offered with input and its grammar, the earlier call and output shown, the model's exec call back as a custom_tool_call with its input and no arguments) and TestCodexCustomToolNamespaced (namespaced and non-JSON calls, renderResponses) cover the tools; TestAdditionalToolsTranslated, TestNamespacedToolsOffered and TestGrokPluginGetsNamespacedTools now expect the custom tools offered. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestDevinSignedInOnlyInMagpie failed once on its wait, passes alone; untouched).
2026-10-02 15:59:14 +08:00

166 lines
7.6 KiB
Go

package gateway
import (
"encoding/json"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// codeModeRequest is a turn as Codex sends it for a model its catalog puts
// in code mode on Responses Lite (gpt-6.1-sol, gpt-6-luna: tool_mode
// code_mode_only, use_responses_lite): exec, a custom tool taking
// JavaScript, through which every other tool is called, and wait, in the
// "functions" namespace; the collaboration tools in their own; an earlier
// exec call and its output.
const codeModeRequest = `{"model":"fake/m1","instructions":"","stream":true,
"reasoning":{"effort":"high","summary":"auto","context":"all_turns"},
"input":[
{"type":"additional_tools","id":"at_1","role":"developer","tools":[
{"type":"namespace","name":"functions","description":"","tools":[
{"type":"custom","name":"exec","description":"Run JavaScript.","format":{"type":"grammar","syntax":"lark","definition":"start: plain_source\nplain_source: SOURCE\nSOURCE: /[\\s\\S]+/"}},
{"type":"function","name":"wait","description":"Wait for an exec cell.","strict":false,"parameters":{"type":"object","properties":{"cell_id":{"type":"string"}}}}]},
{"type":"namespace","name":"collaboration","description":"Agents","tools":[
{"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object"}}]}]},
{"type":"message","id":"msg_1","role":"developer","content":[{"type":"input_text","text":"You are Codex."}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"list the files"}]},
{"type":"custom_tool_call","call_id":"c1","name":"exec","namespace":"functions","input":"const r = await tools.exec_command({cmd: \"ls\"});\ntext(r);"},
{"type":"custom_tool_call_output","call_id":"c1","output":"a.txt"}
]}`
// A model magpie translates for is offered Codex's custom tools as
// functions taking their input (#534: in code mode exec was dropped, and
// the model said it had only the collaboration and wait tools), sees an
// earlier call to one and its output, and its call goes back to Codex as
// the custom_tool_call Codex runs, the input as the model wrote it.
func TestCodexCustomToolsTranslated(t *testing.T) {
f := &fake{t: t, reply: sse(
`data: {"id":"c1","model":"m1","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_2","type":"function","function":{"name":"exec","arguments":""}}]}}]}`,
`data: {"id":"c1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"input\":\"text(await "}}]}}]}`,
`data: {"id":"c1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"tools.read({path: \\\"a.txt\\\"}))\"}"}}]}}]}`,
`data: {"id":"c1","choices":[{"delta":{},"finish_reason":"tool_calls"}]}`,
`data: {"id":"c1","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}`,
`data: [DONE]`)}
setup(t, provider.Chat, f)
code, body := post(t, "/v1/responses", codeModeRequest)
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
// what the model was offered and shown
var up struct {
Tools []struct {
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
} `json:"tools"`
Messages []struct {
Role string `json:"role"`
Content any `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
ToolCallID string `json:"tool_call_id"`
} `json:"messages"`
}
if err := json.Unmarshal(f.got, &up); err != nil {
t.Fatalf("%v: %s", err, f.got)
}
var names []string
for _, tl := range up.Tools {
names = append(names, tl.Function.Name)
}
if want := "exec wait collaboration__spawn_agent"; strings.Join(names, " ") != want {
t.Fatalf("tools %v, want %s\n%s", names, want, f.got)
}
exec := up.Tools[0].Function
var schema struct {
Properties map[string]struct {
Type string `json:"type"`
} `json:"properties"`
Required []string `json:"required"`
}
if json.Unmarshal(exec.Parameters, &schema) != nil || schema.Properties["input"].Type != "string" || len(schema.Required) != 1 || schema.Required[0] != "input" {
t.Fatalf("exec's parameters: %s", exec.Parameters)
}
if !strings.HasPrefix(exec.Description, "Run JavaScript.") || !strings.Contains(exec.Description, "lark grammar") || !strings.Contains(exec.Description, "plain_source: SOURCE") {
t.Fatalf("exec's description: %q", exec.Description)
}
var called, answered bool
for _, m := range up.Messages {
for _, c := range m.ToolCalls {
if c.ID == "c1" && c.Function.Name == "exec" && customInput(c.Function.Arguments) == "const r = await tools.exec_command({cmd: \"ls\"});\ntext(r);" {
called = true
}
}
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "a.txt" {
answered = true
}
}
if !called || !answered {
t.Fatalf("earlier exec call %v, its output %v: %s", called, answered, f.got)
}
// what Codex got back
var done map[string]any
for _, e := range events(body) {
switch e["type"] {
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
t.Errorf("a custom tool's call streamed as a function's: %v", e)
case "response.output_item.done":
done, _ = e["item"].(map[string]any)
}
}
if done == nil || done["type"] != "custom_tool_call" || done["name"] != "exec" || done["namespace"] != nil ||
done["call_id"] != "call_2" || done["input"] != `text(await tools.read({path: "a.txt"}))` || done["arguments"] != nil {
t.Fatalf("call item: %v\n%s", done, body)
}
}
// A custom tool in a namespace of its own is called back under it, and a
// call whose arguments aren't the input object still hands its text on.
func TestCodexCustomToolNamespaced(t *testing.T) {
r, err := parseResponses([]byte(`{"model":"m","input":"hi","tools":[
{"type":"namespace","name":"mcp__pad","tools":[{"type":"custom","name":"write","description":"Write","format":{"type":"text"}}]},
{"type":"custom","name":"apply_patch","description":"Patch files"}]}`))
if err != nil {
t.Fatal(err)
}
if len(r.Tools) != 2 || r.Tools[0].Name != "mcp__pad__write" || r.Tools[1].Name != "apply_patch" || strings.Contains(r.Tools[0].Description, "grammar") {
t.Fatalf("tools: %+v", r.Tools)
}
item := callTo(map[string]any{"type": "function_call", "call_id": "c", "arguments": `{"input":"x"}`}, "mcp__pad__write", r.Namespaced)
if item["type"] != "custom_tool_call" || item["name"] != "write" || item["namespace"] != "mcp__pad" || item["input"] != "x" {
t.Fatalf("namespaced call: %v", item)
}
item = callTo(map[string]any{"type": "function_call", "call_id": "c", "arguments": `*** Begin Patch`}, "apply_patch", r.Namespaced)
if item["type"] != "custom_tool_call" || item["input"] != "*** Begin Patch" || item["namespace"] != nil {
t.Fatalf("plain call: %v", item)
}
item = renderCall(t, r.Namespaced)
if item["type"] != "custom_tool_call" || item["input"] != "*** Begin Patch\n*** End Patch" {
t.Fatalf("rendered call: %v", item)
}
}
// renderCall is the non-streamed reply's item for a call to apply_patch.
func renderCall(t *testing.T, named map[string]nsTool) map[string]any {
t.Helper()
args, _ := json.Marshal(map[string]string{"input": "*** Begin Patch\n*** End Patch"})
out := renderResponses(Result{Parts: []Part{{Kind: ToolCall, ID: "c", Name: "apply_patch", Args: args}}}, "m", named)
var res struct {
Output []map[string]any `json:"output"`
}
if err := json.Unmarshal(out, &res); err != nil || len(res.Output) != 1 {
t.Fatalf("%v: %s", err, out)
}
return res.Output[0]
}