diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index 0237511a..72d3da74 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -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, "/") { diff --git a/internal/gateway/codex_titles.go b/internal/gateway/codex_titles.go new file mode 100644 index 00000000..3b76ac7c --- /dev/null +++ b/internal/gateway/codex_titles.go @@ -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) +} diff --git a/internal/gateway/codex_titles_test.go b/internal/gateway/codex_titles_test.go new file mode 100644 index 00000000..771c3cb3 --- /dev/null +++ b/internal/gateway/codex_titles_test.go @@ -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) + } + } +} diff --git a/internal/gui/api.go b/internal/gui/api.go index d518e359..ca1c7a54 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -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) { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 6bab066d..fd8366ce 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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. diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index ff36dbe9..cfb50e5c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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": "¥ 人民币", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 8f68ac4b..c41f9e58 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -255,6 +255,10 @@
Codex subagents on any model
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 thread titles
+
+
Open at login
Start magpie in the tray when you log in, so agents find the gateway from the start
diff --git a/internal/gui/codex_titles_test.go b/internal/gui/codex_titles_test.go new file mode 100644 index 00000000..509abc41 --- /dev/null +++ b/internal/gui/codex_titles_test.go @@ -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) + } +} diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 1ff87222..3c0659c1 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -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, diff --git a/internal/gui/tests/codex-titles.test.cjs b/internal/gui/tests/codex-titles.test.cjs new file mode 100644 index 00000000..f1692366 --- /dev/null +++ b/internal/gui/tests/codex-titles.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 9990b735..fcef72a6 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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/) 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, "/") {