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:
1 parent
7806395c74
commit
65e78e459e
11 files changed
+645
No files matched your search
@@ -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, "/") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "¥ 人民币",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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, "/") {
|
||||
|
||||
Reference in new issue
Block a user