codex: Settings decides where Codex's thread-title requests go — Codex's own ChatGPT as before, off, or a magpie model (#705, DuGuaiYA: Codex signed in with ChatGPT sends thread_title / thread_title_reconsideration requests that magpie relays through ChatGPT even when the conversation is on a magpie provider) — a new setting codexTitles ("" = as Codex sends them, the default; "off"; or a model's id such as deepseek/deepseek-v4-flash or a group) is read on /backend-api/codex/responses for a request whose x-openai-subagent or turn metadata thread_source is a title kind. Off answers at once with an empty completed response (Codex then keeps no title, quietly, as its source shows), contacts nothing, and is still recorded: a Routing trace "Titles off" and a usage row of Kind thread_title marked rejected (left out of stats). A model gets the request through serve (so it is recorded as a title request on that provider), and its answer is given back as one Responses message holding {"title":…}, since Codex's text.format schema does not reach Chat or Anthropic upstreams. Settings has a row "Codex thread titles" (zh: Codex 会话标题) with the app's model menu (Codex's own, Off, magpie's models and groups), saved on its own through POST /api/settings/codex-titles, which refuses a model magpie doesn't serve; the page's other saves keep it. TestCodexTitlesSetting (gateway) fails on the old code (off still went to ChatGPT) and checks the ledger kinds, rejected rows and trace; TestTitleJSON; TestCodexTitlesSetting (gui) for the endpoint; codex-titles.test.cjs (en/zh, webkit) fails on the old assets: no select, the picks post, the subtitle follows, no click scrolls. go vet, builds (darwin nogui, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with a real Codex TUI: no end-to-end run of Codex's title generation against magpie.

This commit is contained in:
yetone committed 2026-10-04 00:23:05 +08:00
1 parent 7806395c74
commit 65e78e459e
11 files changed
+645

No files matched your search

+9
View File
@@ -81,6 +81,15 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) {
writeError(w, provider.Responses, 400, err.Error())
return
}
// a thread's title, where Settings sends it (#705) — the request
// still on Codex's own model, through its sign-in, when it says
// nothing of them
if rest == "/responses" {
if to := codexTitlesTo(r.Header, body); to != "" {
s.codexTitle(w, r, body, to)
return
}
}
// The namespace owns the route even if a model is not in the catalog.
// Unknown providers/groups must fail locally, never fall through to OpenAI.
if strings.Contains(model, "/") {
+170
View File
@@ -0,0 +1,170 @@
package gateway
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/usage"
)
// codexTitlesTo is where settings.CodexTitles sends a request Codex makes
// for a thread's title (#705): a hidden turn of its own (thread_title,
// thread_title_reconsideration), which Codex sends on its own model —
// gpt-5.6-luna when signed in to ChatGPT — and so through its ChatGPT
// sign-in even while the conversation is on one of magpie's models. ""
// for a request that isn't one, or when the setting leaves them as Codex
// sends them; "off", or the model that writes the title.
func codexTitlesTo(h http.Header, body []byte) string {
if !isTitleKind(requestCallKind(h, requestSessionMetadata(h, body))) {
return ""
}
return settings.Load().CodexTitles
}
// codexTitle answers a request for a thread's title as settings.CodexTitles
// says: "off" here, with no title; a model's id by that model, its answer
// handed back as the {"title": …} Codex asked for. Either way the call is
// in the Usage and Routing views as the title request it is.
func (s *Server) codexTitle(w http.ResponseWriter, r *http.Request, body []byte, to string) {
if to == "off" {
s.codexTitleOff(w, r, body)
return
}
body, _ = codexInput(body, true)
rec := &recorder{header: http.Header{}, status: 200}
s.serve(rec, r, provider.Responses, withModel(body, to))
if rec.status >= 400 {
for k, vs := range rec.header {
w.Header()[k] = vs
}
w.WriteHeader(rec.status)
w.Write(rec.body.Bytes())
return
}
res, err := compactReply(rec.body.Bytes())
if err != nil {
writeError(w, provider.Responses, 502, "title: "+err.Error())
return
}
var said strings.Builder
for _, o := range res.Output {
if o.Type != "message" {
continue
}
for _, c := range o.Content {
said.WriteString(c.Text)
}
}
id := res.ID
if id == "" {
id = fmt.Sprintf("resp_magpie_%d", time.Now().UnixNano())
}
var out []any
if t := titleJSON(said.String()); t != "" {
out = append(out, map[string]any{"type": "message", "id": "msg_" + strings.TrimPrefix(id, "resp_"), "role": "assistant", "status": "completed",
"content": []any{map[string]any{"type": "output_text", "text": t, "annotations": []any{}}}})
}
writeTitleReply(w, id, out, res.Usage)
}
// codexTitleOff answers a title request with no title, sending it nowhere:
// a finished reply with nothing in it, which Codex takes as no title and
// leaves the thread named as it was (its thread_title.rs: no message, or
// no {"title": …} in it, sets none). The Routing and Usage views have it
// as the title request it was, answered by magpie and sent to no one.
func (s *Server) codexTitleOff(w http.ResponseWriter, r *http.Request, body []byte) {
start := time.Now()
usage.Saw(agentOf(r))
metadata := requestSessionMetadata(r.Header, body)
kind := requestCallKind(r.Header, metadata)
model := modelOf(body)
const why = "Codex titles are off in magpie's Settings: answered with no title, sent nowhere"
seat := Weighed{ID: "magpie", Provider: "magpie", Name: "magpie", Who: "Titles off", Kind: "provider", Model: model}
tr := s.trace.begin(Route{Time: start, Agent: agentOf(r), Session: sessionOf(r.Header), ParentSession: titleParentSession(r.Header, metadata, kind),
Kind: kind, Model: model, Provider: "magpie", Order: []Weighed{seat}, Tries: []Try{{ID: seat.ID, Model: model, Start: start}}})
writeTitleReply(w, fmt.Sprintf("resp_magpie_%d", start.UnixNano()), nil, nil)
ms := time.Since(start).Milliseconds()
s.trace.update(tr, func(t *Route) {
t.Tries[0].Done, t.Tries[0].Status, t.Tries[0].Millis, t.Tries[0].Error = true, 200, ms, why
t.Done, t.Status, t.Error, t.Millis = true, 200, why, ms
})
s.record(Call{Time: start, From: provider.Responses, To: provider.Responses, Model: model, Provider: "magpie",
Agent: agentOf(r), Kind: kind, Status: 200, Millis: ms, Error: why})
appendUsage(r, usage.Record{RouteID: tr.ID, Time: start, Agent: agentOf(r), Provider: "magpie", Model: model, Requested: model,
Millis: ms, Status: 200, Rejected: true, Error: why, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header),
Kind: kind, Endpoint: r.URL.Path})
}
// writeTitleReply streams a finished Responses reply with these output
// items, as the ChatGPT backend streams one to Codex.
func writeTitleReply(w http.ResponseWriter, id string, out []any, used json.RawMessage) {
if out == nil {
out = []any{}
}
done := map[string]any{"id": id, "object": "response", "status": "completed", "output": out}
if len(used) > 0 {
done["usage"] = used
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
w.WriteHeader(200)
evs := []map[string]any{{"type": "response.created", "response": map[string]any{"id": id, "object": "response", "status": "in_progress", "output": []any{}}}}
for i, it := range out {
evs = append(evs, map[string]any{"type": "response.output_item.added", "output_index": i, "item": it},
map[string]any{"type": "response.output_item.done", "output_index": i, "item": it})
}
evs = append(evs, map[string]any{"type": "response.completed", "response": done})
for _, ev := range evs {
b, _ := json.Marshal(ev)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev["type"], b)
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// titleJSON is a model's answer to a title request as Codex reads one:
// {"title": "…"}. Codex asks for that shape in the request's text.format,
// which a model behind another API never sees, and so may answer with the
// title alone, in quotes, or in a code fence. "" when it gave none.
func titleJSON(said string) string {
t := strings.TrimSpace(said)
if strings.HasPrefix(t, "```") {
t = strings.TrimPrefix(t, "```")
if i := strings.IndexByte(t, '\n'); i >= 0 && !strings.Contains(t[:i], "{") {
t = t[i+1:]
}
t = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(t), "```"))
}
if strings.HasPrefix(t, "{") {
var v struct {
Title *string `json:"title"`
}
if json.NewDecoder(bytes.NewReader([]byte(t))).Decode(&v) == nil && v.Title != nil {
t = *v.Title
}
}
// its first line, as a title is one
for _, line := range strings.Split(t, "\n") {
if line = strings.TrimSpace(line); line != "" {
t = line
break
}
}
if low := strings.ToLower(t); strings.HasPrefix(low, "title:") {
t = t[len("title:"):]
}
t = strings.TrimSpace(strings.Trim(strings.TrimSpace(t), "\"'`*#“”‘’"))
if t == "" || strings.HasPrefix(t, "{") {
return ""
}
b, _ := json.Marshal(map[string]string{"title": t})
return string(b)
}
+169
View File
@@ -0,0 +1,169 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/usage"
)
// codexTitleRequest is a title request as Codex's TUI sends one on its own
// model, signed in to ChatGPT: a hidden thread_title turn, its schema in
// text.format; with subagent, as the app names one in x-openai-subagent.
func codexTitleRequest(t *testing.T, s *Server, subagent string) *httptest.ResponseRecorder {
t.Helper()
body := `{"model":"gpt-5.6-luna","stream":true,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Generate a concise title. User prompt:\nfix the login bug"}]}],` +
`"text":{"format":{"type":"json_schema","name":"codex_output_schema","strict":true,"schema":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}}}}`
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer chatgpt-token")
req.Header.Set("session_id", "title-thread")
if subagent != "" {
req.Header.Set("x-openai-subagent", subagent)
} else {
req.Header.Set("x-codex-turn-metadata", `{"thread_source":"thread_title","parent_thread_id":"main-chat"}`)
}
s.Handler().ServeHTTP(rec, req)
return rec
}
// outputText is the text of the message items in a Responses stream's
// response.completed.
func outputText(t *testing.T, body string) (string, int) {
t.Helper()
for _, ev := range events(body) {
if ev["type"] != "response.completed" {
continue
}
resp, _ := ev["response"].(map[string]any)
out, _ := resp["output"].([]any)
var text strings.Builder
for _, it := range out {
m, _ := it.(map[string]any)
cs, _ := m["content"].([]any)
for _, c := range cs {
cm, _ := c.(map[string]any)
s, _ := cm["text"].(string)
text.WriteString(s)
}
}
return text.String(), len(out)
}
t.Fatalf("no response.completed in %q", body)
return "", 0
}
// Codex's title requests go where Settings says (#705): as they came to
// Codex's ChatGPT sign-in by default; turned off, answered here with no
// title and sent nowhere; or to a model of magpie's, its plain answer
// handed back as the {"title": …} Codex reads. Each stays a title request
// in the Usage and Routing views.
func TestCodexTitlesSetting(t *testing.T) {
f := &fake{t: t, reply: sse(
`data: {"id":"c1","choices":[{"index":0,"delta":{"content":"Fix login bug"}}]}`,
`data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":4}}`,
`data: [DONE]`)}
setup(t, provider.Chat, f)
chatgptCalls := 0
chatgpt(t, func(w http.ResponseWriter, r *http.Request) {
io.ReadAll(r.Body)
chatgptCalls++
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, sse(`data: {"type":"response.completed","response":{"id":"r1","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"{\"title\":\"From ChatGPT\"}"}]}],"usage":{"input_tokens":9,"output_tokens":2}}}`))
})
save := func(v string) {
t.Helper()
st := settings.Load()
st.CodexTitles = v
if err := settings.Save(st); err != nil {
t.Fatal(err)
}
}
// default: as before, to Codex's own sign-in
s := New()
rec := codexTitleRequest(t, s, "")
if text, _ := outputText(t, rec.Body.String()); chatgptCalls != 1 || f.calls != 0 || text != `{"title":"From ChatGPT"}` {
t.Fatalf("default: chatgpt %d, magpie model %d, %q", chatgptCalls, f.calls, text)
}
// off: nothing goes out, a finished reply with no title in it
save("off")
for _, sub := range []string{"", "thread_title", "thread_title_reconsideration"} {
rec = codexTitleRequest(t, s, sub)
text, n := outputText(t, rec.Body.String())
if rec.Code != 200 || n != 0 || text != "" || chatgptCalls != 1 || f.calls != 0 {
t.Fatalf("off (%q): %d %q, %d items, chatgpt %d, magpie model %d", sub, rec.Code, rec.Body.String(), n, chatgptCalls, f.calls)
}
}
// a model of magpie's: it writes the title, in the shape Codex reads
save("fake/m1")
rec = codexTitleRequest(t, s, "thread_title")
if text, _ := outputText(t, rec.Body.String()); rec.Code != 200 || text != `{"title":"Fix login bug"}` || f.calls != 1 || chatgptCalls != 1 {
t.Fatalf("routed: %d %q, magpie model %d, chatgpt %d", rec.Code, rec.Body.String(), f.calls, chatgptCalls)
}
var sent struct {
Model string `json:"model"`
}
json.Unmarshal(f.got, &sent)
if sent.Model != "m1" {
t.Errorf("the magpie model was asked for %q", sent.Model)
}
// a turn of the conversation is not a title: it goes as before
rec = httptest.NewRecorder()
req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"hi"}`))
req.Header.Set("Authorization", "Bearer chatgpt-token")
s.Handler().ServeHTTP(rec, req)
if chatgptCalls != 2 || f.calls != 1 {
t.Errorf("a plain turn: chatgpt %d, magpie model %d", chatgptCalls, f.calls)
}
// every title request is in the ledger as one: ChatGPT's, the three
// answered here, the magpie model's
var kinds []string
for _, u := range usage.Load(time.Time{}) {
kinds = append(kinds, u.Provider+":"+u.Kind)
if u.Provider == "magpie" && (!u.IsRejected() || u.Status != 200) {
t.Errorf("an answered-here title %+v", u)
}
}
want := "openai:thread_title magpie:thread_title magpie:thread_title magpie:thread_title_reconsideration fake:thread_title openai:"
if got := strings.Join(kinds, " "); got != want {
t.Errorf("ledger %s\nwant %s", got, want)
}
st := s.Trace(t.Context(), 0, 0)
titles := 0
for _, r := range st.Routes {
if isTitleKind(r.Kind) {
titles++
}
}
if titles != 5 {
t.Errorf("Routing view titles %d, want 5: %+v", titles, st.Routes)
}
}
func TestTitleJSON(t *testing.T) {
for in, want := range map[string]string{
`{"title":"Fix login bug"}`: `{"title":"Fix login bug"}`,
"```json\n{\"title\": \"Fix login\"}\n```": `{"title":"Fix login"}`,
`"Fix login bug"`: `{"title":"Fix login bug"}`,
"Title: Fix login\n\nmore words": `{"title":"Fix login"}`,
"**修复登录问题**": `{"title":"修复登录问题"}`,
"": "",
`{"name":"x"}`: "",
} {
if got := titleJSON(in); got != want {
t.Errorf("titleJSON(%q) = %q, want %q", in, got, want)
}
}
}
+36
View File
@@ -287,6 +287,8 @@ type settingsJSON struct {
// that can be named
VisionAuto string `json:"visionAuto,omitempty"`
VisionModels []modelRef `json:"visionModels"`
// the models Codex's thread titles may be sent to (CodexTitles, #705)
TitleModels []modelRef `json:"titleModels"`
// the model magpie's generate_image tool draws with when ImageGen
// names none, and those that can be named
ImageGenAuto string `json:"imageGenAuto,omitempty"`
@@ -423,6 +425,16 @@ func settingsState() settingsJSON {
s.VisionModels = append(s.VisionModels, m)
}
}
s.TitleModels = []modelRef{}
for _, e := range provider.Served() {
if e.Group != "" || e.Provider.Ready() && !e.Provider.DecideOnly() {
m := modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon}
if e.Group != "" {
m.Provider, m.PName = "", e.Group
}
s.TitleModels = append(s.TitleModels, m)
}
}
searchState(&s)
s.ImageGenAuto, s.ImageGenModels = gateway.AutoDrawer(), []modelRef{}
for _, p := range provider.All() {
@@ -762,6 +774,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
// how agents' lists name models, set on its own for the agents to be told
in.PlainNames, in.PlainOwnNames = cur.PlainNames, cur.PlainOwnNames
in.CodexAgentsV1 = cur.CodexAgentsV1
in.CodexTitles = cur.CodexTitles // set on its own (codex-titles below)
in.ChinaMirror = cur.ChinaMirror // the Plugins page's, set on its own
// which Codex accounts spend a reset by themselves, set on the Usage card
in.CodexAutoReset = cur.CodexAutoReset
@@ -900,6 +913,29 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
}
writeJSON(rw, settingsState())
})
// where Codex's requests for a thread's title go (#705): "" as Codex
// sends them, "off", or a model's id
mux.HandleFunc("POST /api/settings/codex-titles", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Model string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
v := strings.TrimSpace(in.Model)
if v != "" && v != "off" {
if _, _, ok := provider.Resolve(v); !ok {
fail(rw, fmt.Errorf("no model %s to write Codex's titles", v))
return
}
}
s := settings.Load()
s.CodexTitles = v
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
// whether a Codex account spends one of its resets by itself once its
// week is used up, the Usage card's toggle, set on its own
mux.HandleFunc("POST /api/settings/codex-auto-reset", func(rw http.ResponseWriter, r *http.Request) {
+33
View File
@@ -13680,6 +13680,7 @@ function renderTrayUsage(s, keep) {
// magpie's other models (#141)
$("#codexAgentsV1Segs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.codexAgentsV1 ? "on" : "off", (v) =>
writingPrefs(api("settings/codex-agents-v1", { on: v === "on" })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); })));
renderCodexTitles(s);
const rate = s.fx?.rate;
const currencySub = $("#currencySub");
currencySub.textContent = t("What a cost — the Usage page's, the tray panel's, the TUI's and the CLI's — is shown as; a vendor's own balance, already in its own currency, is never converted");
@@ -13880,6 +13881,38 @@ function renderAlerts(s, keep) {
.append(segs([["off", t("Off")], ["on", t("On")]], s.balanceAlert ? "on" : "off", (v) => savePrefs({ ...keep, balanceAlert: v === "on" ? s.balanceAlert || 5 : 0 })));
}
// renderCodexTitles: where Codex's requests for a thread's title go (#705)
// — as Codex sends them, to its own model through its ChatGPT sign-in; off,
// answered by magpie with no title; or to a model of magpie's — set on its
// own, as Codex's subagents are.
function renderCodexTitles(s) {
const models = s.titleModels || [];
const named = (id) => {
const m = models.find((x) => x.id === id);
return m ? `${m.name || m.id} · ${m.providerName}` : id;
};
const v = s.codexTitles || "";
$("#codexTitlesSub").textContent = v === "off" ? t("Codex’s title requests are answered by magpie with no title and sent nowhere; new threads stay unnamed")
: v ? t("Codex’s title requests go to this model, not to its ChatGPT sign-in; the Usage and Routing views still show them as titles")
: t("Codex asks its own model for each thread’s title through its ChatGPT sign-in, even while the conversation is on magpie’s models");
const b = el("button", "rt-cond on");
b.type = "button";
b.setAttribute("aria-label", t("Codex thread titles"));
if (v === "off") b.append(el("span", "", t("Off")));
else if (v) b.append(icon(models.find((x) => x.id === v)?.icon || "generic"), el("span", "", named(v)));
else b.append(icon("openai"), el("span", "", t("Codex’s own (ChatGPT)")));
const opt = (x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.provider ? x.providerName : ROUTING_GROUPS, ref: x.id });
b.onclick = (ev) => openPicker({ id: "", name: "", fields: [] }, { key: "codexTitles", label: "model", value: v, options: [
{ value: "", label: t("Codex’s own (ChatGPT)"), note: t("as Codex sends them"), icon: "openai", reset: true },
{ value: "off", label: t("Off"), note: t("no title, nothing sent"), reset: true },
...models.map(opt)],
onPick: (id) => {
if (id === v) return;
writingPrefs(api("settings/codex-titles", { model: id })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); });
} }, b, ev);
$("#codexTitlesPick").replaceChildren(b);
}
// renderImages: the model that describes images to a model that can't see
// them — the one magpie picks, one named, or none, and then such an image
// is turned away.
+7
View File
@@ -288,6 +288,13 @@ const I18N = {
"Agents’ lists put each model’s provider after its name, or not after names you set": "写给 agent 的模型列表在模型名后带上供应商,或自定义的名称不带",
"Not on names I set": "自定义名称不带供应商",
"Codex subagents on any model": "Codex 子代理可用任意模型",
"Codex thread titles": "Codex 会话标题",
"Codex’s own (ChatGPT)": "Codex 自带(ChatGPT)",
"as Codex sends them": "按 Codex 原样发送",
"no title, nothing sent": "不生成标题,不发出请求",
"Codex asks its own model for each thread’s title through its ChatGPT sign-in, even while the conversation is on magpie’s models": "Codex 用自带模型、经它的 ChatGPT 登录为每个会话生成标题,即使对话用的是 magpie 的模型",
"Codex’s title requests are answered by magpie with no title and sent nowhere; new threads stay unnamed": "Codex 的标题请求由 magpie 直接回复空标题,不发往任何地方;新会话不会自动命名",
"Codex’s title requests go to this model, not to its ChatGPT sign-in; the Usage and Routing views still show them as titles": "Codex 的标题请求改由这个模型生成,不再经它的 ChatGPT 登录;用量和路由页仍把它们记为标题请求",
"Codex’s OpenAI models hand subagents their tasks as text (multi-agent V1), so a GPT lead can spawn them on magpie’s other models. New threads only; Codex’s own multi_agent_v2 setting still wins": "Codex 的 OpenAI 模型以文本把任务交给子代理(multi-agent V1),GPT 主代理就能在 magpie 的其他模型上开子代理。只对新会话生效;Codex 自己的 multi_agent_v2 设置仍然优先",
"$ USD": "$ 美元",
"¥ CNY": "¥ 人民币",
+4
View File
@@ -255,6 +255,10 @@
<div class="who"><div class="name" data-t>Codex subagents on any model</div><div class="sub" data-t>Codex’s OpenAI models hand subagents their tasks as text (multi-agent V1), so a GPT lead can spawn them on magpie’s other models. New threads only; Codex’s own multi_agent_v2 setting still wins</div></div>
<div id="codexAgentsV1Segs"></div>
</div>
<div class="row pref" id="codexTitlesRow">
<div class="who"><div class="name" data-t>Codex thread titles</div><div class="sub" id="codexTitlesSub"></div></div>
<div class="val" id="codexTitlesPick"></div>
</div>
<div class="row pref">
<div class="who"><div class="name" data-t>Open at login</div><div class="sub" data-t>Start magpie in the tray when you log in, so agents find the gateway from the start</div></div>
<div id="loginSegs"></div>
+67
View File
@@ -0,0 +1,67 @@
package gui
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/yetone/magpie/internal/agentenv"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// Settings' Codex thread titles (#705) is set on its own: off, a model
// magpie serves (one it doesn't is refused), or back to Codex's own; the
// Settings page's other saves, which never send it, keep it; and the page
// is told the models it may name.
func TestCodexTitlesSetting(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
t.Setenv("APPDATA", filepath.Join(h, "AppData", "Roaming"))
t.Setenv("LOCALAPPDATA", filepath.Join(h, "AppData", "Local"))
for _, v := range agentenv.Vars {
t.Setenv(v, "")
}
if err := provider.Save(provider.Provider{ID: "fake", Name: "Fake", Key: "k", Models: []string{"m1"}, Chat: "http://127.0.0.1:9/v1"}); err != nil {
t.Fatal(err)
}
call := func(path, body string) (int, map[string]any) {
t.Helper()
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
code, out := call("/api/settings/codex-titles", `{"model":"off"}`)
if code != 200 || out["codexTitles"] != "off" || settings.Load().CodexTitles != "off" {
t.Fatalf("off: %d %v", code, out["codexTitles"])
}
found := false
for _, m := range out["titleModels"].([]any) {
if m.(map[string]any)["id"] == "fake/m1" {
found = true
}
}
if !found {
t.Errorf("titleModels %v has no fake/m1", out["titleModels"])
}
if code, _ := call("/api/settings", `{"theme":"dark"}`); code != 200 || settings.Load().CodexTitles != "off" {
t.Errorf("another save (%d) lost the setting: %q", code, settings.Load().CodexTitles)
}
if code, _ := call("/api/settings/codex-titles", `{"model":"nope/x"}`); code < 400 || settings.Load().CodexTitles != "off" {
t.Errorf("a model magpie doesn't serve: %d, %q", code, settings.Load().CodexTitles)
}
if code, out := call("/api/settings/codex-titles", `{"model":"fake/m1"}`); code != 200 || out["codexTitles"] != "fake/m1" {
t.Errorf("a model: %d %v", code, out["codexTitles"])
}
if code, _ := call("/api/settings/codex-titles", `{"model":""}`); code != 200 || settings.Load().CodexTitles != "" {
t.Errorf("Codex's own: %d %q", code, settings.Load().CodexTitles)
}
}
+1
View File
@@ -132,6 +132,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
PlainNames: true,
PlainOwnNames: true,
CodexAgentsV1: true,
CodexTitles: "off",
ChinaMirror: true,
CodexAutoReset: []string{"me@example.com"},
TextSize: 125,
+139
View File
@@ -0,0 +1,139 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Settings' "Codex thread titles" (#705, DuGuaiYA: Codex's thread_title
// requests go through its own ChatGPT sign-in even on magpie's models): by
// default Codex's own (ChatGPT); the app's menu (no native select) offers
// Off and magpie's models; a pick posts settings/codex-titles on its own,
// the row says what now happens, and no click scrolls the Settings page.
// English and Chinese, Chromium and WebKit; no backend, the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
function settingsPayload(over) {
return {
theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd",
dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "",
version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425",
proxyNow: "none", proxySource: "none", login: false,
visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [],
titleModels: [
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", provider: "deepseek", providerName: "DeepSeek", icon: "deepseek" },
{ id: "group/cheap", name: "cheap", provider: "", providerName: "Routing groups" },
],
fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
...over,
};
}
function server(lang, posted) {
let cur = settingsPayload({ lang });
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: cur.fx });
if (url.pathname === "/api/settings/codex-titles") {
const body = req.postDataJSON();
posted.push(["codex-titles", body]);
cur = { ...cur, codexTitles: body.model };
return json(cur);
}
if (url.pathname === "/api/settings") {
if (req.method() === "POST") posted.push(["settings", req.postDataJSON()]);
return json(cur);
}
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try {
await route.fulfill({ body: await fs.readFile(file), contentType });
} catch {
await route.fulfill({ status: 404, body: "" });
}
};
}
const want = {
en: { name: "Codex thread titles", own: "Codex’s own (ChatGPT)", off: "Off", subOwn: /through its ChatGPT sign-in/, subOff: /sent nowhere/, subModel: /go to this model/ },
zh: { name: "Codex 会话标题", own: "Codex 自带(ChatGPT)", off: "关闭", subOwn: /ChatGPT 登录/, subOff: /不发往任何地方/, subModel: /改由这个模型/ },
};
const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop);
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: Codex's title requests go where Settings says`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(5000);
const errors = [], posted = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, posted));
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-codex-titles.png`) });
}
await browser.close();
});
await page.goto("http://magpie.test/?view=settings");
const row = page.locator("#codexTitlesRow");
const pick = page.locator("#codexTitlesPick button");
await pick.waitFor();
assert.equal(await page.locator("#codexTitlesRow select").count(), 0, "no native select");
assert.equal((await row.locator(".name").textContent()).trim(), want[lang].name);
assert.equal((await pick.textContent()).trim(), want[lang].own, "Codex's own by default");
assert.match(await row.locator(".sub").textContent(), want[lang].subOwn);
// scrolled by a wheel till the row is mid-view, so a click that
// moved the page would show
const box = await page.locator("#view-settings").boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
const top = () => pick.evaluate((e) => e.getBoundingClientRect().top);
for (let i = 0; i < 40 && (await top()) > 200; i++) { await page.mouse.wheel(0, 100); await page.waitForTimeout(30); }
const before = await view(page);
assert(before > 0, "the settings list must scroll to the row");
// the app's menu: Codex's own, Off and magpie's models
await pick.click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
const items = (await page.locator("#list li:not(.group)").allTextContents()).join("|");
for (const s of [want[lang].own, want[lang].off, "DeepSeek V4 Flash", "cheap"]) assert(items.includes(s), `${s} in ${items}`);
await page.locator("#list li:not(.group)").filter({ hasText: want[lang].off }).first().click();
await page.locator("#codexTitlesPick button", { hasText: want[lang].off }).waitFor();
assert.deepEqual(posted, [["codex-titles", { model: "off" }]]);
assert.match(await row.locator(".sub").textContent(), want[lang].subOff);
await page.waitForTimeout(300);
assert.equal(await view(page), before, "picking Off scrolled the page");
// a model of magpie's
await pick.click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
await page.locator("#list li:not(.group)").filter({ hasText: "DeepSeek V4 Flash" }).first().click();
await page.locator("#codexTitlesPick button", { hasText: "DeepSeek V4 Flash" }).waitFor();
assert.deepEqual(posted.at(-1), ["codex-titles", { model: "deepseek/deepseek-v4-flash" }]);
assert.match(await row.locator(".sub").textContent(), want[lang].subModel);
// and back to Codex's own
await pick.click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
await page.locator("#list li:not(.group)").filter({ hasText: want[lang].own }).first().click();
await page.locator("#codexTitlesPick button", { hasText: want[lang].own }).waitFor();
assert.deepEqual(posted.at(-1), ["codex-titles", { model: "" }]);
await page.waitForTimeout(300);
assert.equal(await view(page), before, "the picks scrolled the page");
assert(!posted.some(([k]) => k === "settings"), "set on its own, not with the other preferences");
assert.deepEqual(errors, []);
});
}
}
+10
View File
@@ -201,6 +201,12 @@ type Settings struct {
// V2's are sealed by OpenAI's server. Codex's features.multi_agent_v2
// still wins, and a thread keeps the version it started with.
CodexAgentsV1 bool `json:"codexAgentsV1,omitempty"`
// CodexTitles is where the requests Codex makes for a thread's title
// (thread_title, thread_title_reconsideration: a hidden turn of their
// own, on Codex's own model through its ChatGPT sign-in) go (#705): ""
// as Codex sends them, "off" answered by magpie with no title and sent
// nowhere, or a model's id (provider/model, group/<id>) that writes it.
CodexTitles string `json:"codexTitles,omitempty"`
// ChinaMirror is the Plugins page's 「国内镜像」 switch: the plugin list,
// npm (the plugins' packages and what npm says of them) and Bun's
// downloads are asked of mirrors in China first, and of their official
@@ -640,6 +646,10 @@ func Save(s Settings) error {
if s.Vision != "" && s.Vision != "off" && !strings.Contains(s.Vision, "/") {
return fmt.Errorf("the vision model must be a model's id such as openai/gpt-5-mini, or off, not %q", s.Vision)
}
s.CodexTitles = strings.TrimSpace(s.CodexTitles)
if s.CodexTitles != "" && s.CodexTitles != "off" && !strings.Contains(s.CodexTitles, "/") {
return fmt.Errorf("the model for Codex's titles must be a model's id such as openai/gpt-5-mini, or off, not %q", s.CodexTitles)
}
s.Searcher = strings.TrimSpace(s.Searcher)
s.ImageGen = strings.TrimSpace(s.ImageGen)
if s.ImageGen != "" && s.ImageGen != "off" && !strings.Contains(s.ImageGen, "/") {