Files
magpie/internal/gateway/additional_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

141 lines
6.1 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// liteRequest is a request as Codex's Responses Lite sends it: no tools and
// no instructions, its tools as the first input item (its own functions in
// the "functions" namespace), its prompt as a developer message, and a call
// made earlier under that namespace.
const liteRequest = `{"model":"fake/m1","instructions":"","stream":true,"input":[
{"type":"additional_tools","id":"at_1","role":"developer","tools":[
{"type":"tool_search","execution":"client","description":"Search tools","parameters":{"type":"object"}},
{"type":"namespace","name":"functions","description":"Default tools","tools":[
{"type":"function","name":"shell","description":"Run a command","strict":false,"parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}},
{"type":"custom","name":"apply_patch","description":"Patch files","format":{"type":"grammar","syntax":"lark","definition":"start: /.+/"}}]},
{"type":"namespace","name":"mcp__probe","description":"Probe","tools":[{"type":"function","name":"read","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":"function_call","call_id":"c1","name":"shell","namespace":"functions","arguments":"{\"cmd\":\"ls\"}"},
{"type":"function_call_output","call_id":"c1","output":"a.txt"}
]}`
// A Responses API other than OpenAI's gets Codex's Lite tools in its tools,
// not as an input item it turns the whole request away over (#350).
func TestAdditionalToolsRelayed(t *testing.T) {
f := &fake{t: t, reply: sse(
`data: {"type":"response.created","response":{"id":"r1","model":"m1"}}`,
`data: {"type":"response.completed","response":{"id":"r1","output":[],"usage":{"input_tokens":5,"output_tokens":2}}}`)}
f.refuse = func(body []byte) (int, string) {
if strings.Contains(string(body), `"additional_tools"`) {
return http.StatusUnprocessableEntity, `{"error":"Failed to deserialize the JSON body into the target type: input[0]: unknown item type \"additional_tools\""}`
}
return 0, ""
}
setup(t, provider.Responses, f)
if code, body := post(t, "/v1/responses", liteRequest); code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var got struct {
Input []map[string]any `json:"input"`
Tools []map[string]any `json:"tools"`
}
if err := json.Unmarshal(f.got, &got); err != nil {
t.Fatal(err)
}
var names []string
for _, tl := range got.Tools {
n, _ := tl["name"].(string)
names = append(names, tl["type"].(string)+":"+n)
}
// tool search as the function magpie serves it; Codex's own tools out
// of their namespace; an MCP server's namespace as it was
if want := "function:tool_search function:shell custom:apply_patch namespace:mcp__probe"; strings.Join(names, " ") != want {
t.Fatalf("tools %v, want %s\n%s", names, want, f.got)
}
if len(got.Input) != 4 || got.Input[0]["role"] != "developer" || got.Input[1]["role"] != "user" {
t.Fatalf("input: %s", f.got)
}
if call := got.Input[2]; call["name"] != "shell" || call["namespace"] != nil {
t.Fatalf("earlier call: %v", call)
}
}
// A request translated for another API offers the Lite tools as the tools
// they are, and an earlier call to one under its own name.
func TestAdditionalToolsTranslated(t *testing.T) {
r, err := parseResponses([]byte(liteRequest))
if err != nil {
t.Fatal(err)
}
var names []string
for _, tl := range r.Tools {
names = append(names, tl.Name)
}
// apply_patch, a custom tool, is offered as a function taking its input
if want := "tool_search shell apply_patch mcp__probe__read"; strings.Join(names, " ") != want {
t.Fatalf("tools %v, want %s", names, want)
}
if !r.Namespaced["apply_patch"].Custom {
t.Fatalf("apply_patch not custom: %+v", r.Namespaced)
}
if r.Namespaced["mcp__probe__read"] != (nsTool{Namespace: "mcp__probe", Name: "read"}) {
t.Fatalf("namespace lost: %+v", r.Namespaced)
}
if r.System != "You are Codex." {
t.Fatalf("system %q", r.System)
}
var call *Part
for _, m := range r.Messages {
for i, p := range m.Parts {
if p.Kind == ToolCall {
call = &m.Parts[i]
}
}
}
if call == nil || call.Name != "shell" {
t.Fatalf("earlier call: %+v", call)
}
}
// Lite tools sent beside tools already there are added once, a namespace
// given again adding the tools it didn't have; an empty additional_tools
// just goes; a request without one is left as it was.
func TestAdditionalToolsMerged(t *testing.T) {
in := `{"model":"m","tools":[{"type":"function","name":"shell","parameters":{"type":"object"}},
{"type":"namespace","name":"mcp__probe","tools":[{"type":"function","name":"write"}]}],
"input":[{"type":"additional_tools","tools":[
{"type":"namespace","name":"functions","tools":[{"type":"function","name":"shell","description":"again"}]},
{"type":"namespace","name":"mcp__probe","tools":[{"type":"function","name":"read"},{"type":"function","name":"write"}]}]},
{"type":"message","role":"user","content":"hi"}]}`
var got struct {
Tools []rTool `json:"tools"`
Input []map[string]any `json:"input"`
}
out := liftAdditionalTools([]byte(in))
if err := json.Unmarshal(out, &got); err != nil {
t.Fatal(err)
}
if len(got.Tools) != 2 || got.Tools[0].Description != "" || len(got.Tools[1].Tools) != 2 || got.Tools[1].Tools[1].Name != "read" || len(got.Input) != 1 {
t.Fatalf("merged: %s", out)
}
if again := liftAdditionalTools(out); string(again) != string(out) {
t.Fatalf("lifted twice: %s", again)
}
out = liftAdditionalTools([]byte(`{"model":"m","input":[{"type":"additional_tools","tools":[]},{"type":"message","role":"user","content":"hi"}]}`))
if strings.Contains(string(out), "additional_tools") || strings.Contains(string(out), `"tools"`) || !strings.Contains(string(out), `"content":"hi"`) {
t.Fatalf("empty: %s", out)
}
plain := `{"model":"m","input":"what are additional_tools?"}`
if out := liftAdditionalTools([]byte(plain)); string(out) != plain {
t.Fatalf("plain: %s", out)
}
}