Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1265,7 +1265,8 @@ expose it publicly without gateway-key authentication.
Such a page is in **gateway mode**: it shows Providers, Gateway, Routing,
Usage, Plugins and Settings, with no Agents, Sessions or Library tab, and
Settings leaves out what is written into this machine's agents (provider in
model names, Codex subagents, long conversations, Codex thread titles) and
model names, Codex subagents, long conversations, Codex thread titles,
Codex thread descriptions) and
the desktop's alerts and tray. `magpie web` is in it by itself when it finds
no agents on its machine, as in the container, and with
`magpie web --gateway`. *Settings › General › Gateway mode* picks Automatic,
Expand Down
4 changes: 4 additions & 0 deletions internal/gateway/codex_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) {
s.codexTitle(w, r, body, to)
return
}
if to := codexDescriptionsTo(r.Header, body, false); to != "" {
s.codexDescription(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.
Expand Down
120 changes: 120 additions & 0 deletions internal/gateway/codex_titles.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ func codexTitlesTo(h http.Header, body []byte, ours bool) string {
return ""
}

// codexDescriptionsTo is where a request Codex makes for a thread's
// description goes. "" leaves the request as Codex sent it; a model's id
// writes it. A description is not a title: Codex asks for {"description"}
// alone, which the title wrapper cannot answer.
func codexDescriptionsTo(h http.Header, body []byte, ours bool) string {
if !isDescriptionKind(requestCallKind(h, requestSessionMetadata(h, body))) {
return ""
}
if to := settings.Load().CodexDescriptions; to != "" {
return to
}
// Codex on a magpie model gets no schema upstream (#743), so an empty
// setting still wraps the model it asked for, as titles do.
if m := modelOf(body); m != "" && (ours || strings.Contains(m, "/")) {
return m
}
return ""
}

// titleCheckKey holds, in a request's context, what serve asks of the
// reply it relayed before it records the call: the reason it fails its
// caller although the vendor answered, or "".
Expand Down Expand Up @@ -112,6 +131,107 @@ func (s *Server) codexTitle(w http.ResponseWriter, r *http.Request, body []byte,
writeTitleReply(w, id, out, res.Usage)
}

// codexDescription answers a thread_description request with the model
// CodexDescriptions names. The model's text comes back as the
// {"description": ...} its schema asks for; a reply without one fails as a
// description, not as a title.
func (s *Server) codexDescription(w http.ResponseWriter, r *http.Request, body []byte, to string) {
shape := descriptionShapeOf(body)
body, _ = codexInput(body, true)
rec := &recorder{header: http.Header{}, status: 200}
check := func(reply string) string {
if res, err := compactReply([]byte(reply)); err == nil && descriptionJSON(messageText(res), shape) == "" {
return noDescription(messageText(res))
}
return ""
}
ctx := context.WithValue(r.Context(), titleCheckKey{}, check)
ctx = magpieChose(ctx)
s.serve(rec, r.WithContext(ctx), 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, "description: "+err.Error())
return
}
id := res.ID
if id == "" {
id = fmt.Sprintf("resp_magpie_%d", time.Now().UnixNano())
}
var out []any
if d := descriptionJSON(messageText(res), shape); d != "" {
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": d, "annotations": []any{}}}})
}
writeTitleReply(w, id, out, res.Usage)
}

func noDescription(said string) string {
said = strings.Join(strings.Fields(said), " ")
if said == "" {
return "description: the model answered with no text, so Codex got no description"
}
if r := []rune(said); len(r) > 80 {
said = string(r[:80]) + "…"
}
return "description: no description in the model's answer, so Codex got none: " + said
}

// descriptionShape is the {"description"} object a description request asks
// for. Its max length is all magpie needs: the reply contains that one field.
type descriptionShape struct {
MaxLength int
}

func descriptionShapeOf(body []byte) descriptionShape {
shape := titleShapeOf(body)
return descriptionShape{MaxLength: shape.Properties["description"].MaxLength}
}

// descriptionJSON is a model's answer as {"description": "..."}, cut to the
// schema's length. "" when the answer gave none.
func descriptionJSON(said string, shape descriptionShape) string {
t := strings.TrimSpace(said)
if rest, ok := strings.CutPrefix(t, "<think>"); ok {
if _, after, ok := strings.Cut(rest, "</think>"); ok {
t = strings.TrimSpace(after)
}
}
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, "{") {
fields := map[string]any{}
if json.NewDecoder(bytes.NewReader([]byte(t))).Decode(&fields) == nil {
if v, ok := fields["description"].(string); ok {
t = v
}
}
}
t = strings.TrimSpace(strings.Trim(strings.TrimSpace(t), "\"`'"))
if t == "" || strings.HasPrefix(t, "{") {
return ""
}
if n := shape.MaxLength; n > 0 {
if r := []rune(t); len(r) > n {
t = strings.TrimSpace(string(r[:n]))
}
}
b, _ := json.Marshal(map[string]string{"description": t})
return string(b)
}

// messageText is the text of a reply's messages, its reasoning left out.
func messageText(res compactResult) string {
var said strings.Builder
Expand Down
124 changes: 124 additions & 0 deletions internal/gateway/codex_titles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,127 @@ func TestTitleJSONFillsTheSchema(t *testing.T) {
t.Errorf("no schema: %s", got)
}
}

func codexDescriptionRequest(t *testing.T, s *Server) *httptest.ResponseRecorder {
t.Helper()
// ChatGPT.app's thread_description schema: description is required and has
// a minimum length, but no maximum.
body := `{"model":"gpt-6-luna","stream":true,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Describe the thread"}]}],` +
`"text":{"format":{"type":"json_schema","strict":true,"schema":{"type":"object","properties":{"description":{"type":"string","minLength":1}},"required":["description"],"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", "description-thread")
req.Header.Set("x-codex-turn-metadata", `{"thread_source":"thread_description"}`)
s.Handler().ServeHTTP(rec, req)
return rec
}

func TestCodexDescriptionsSetting(t *testing.T) {
f := &fake{t: t, reply: sse(
`data: {"id":"c1","choices":[{"index":0,"delta":{"content":"Reviewed the routing change and verified it"}}]}`,
`data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":8}}`,
`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":"{\"description\":\"From ChatGPT\"}"}]}],"usage":{"input_tokens":9,"output_tokens":2}}}`))
})
save := func(v string) {
t.Helper()
st := settings.Load()
st.CodexDescriptions = v
if err := settings.Save(st); err != nil {
t.Fatal(err)
}
}

s := New()
rec := codexDescriptionRequest(t, s)
if text, _ := outputText(t, rec.Body.String()); chatgptCalls != 1 || f.calls != 0 || text != `{"description":"From ChatGPT"}` {
t.Fatalf("default: chatgpt %d, magpie model %d, %q", chatgptCalls, f.calls, text)
}

save("fake/m1")
rec = codexDescriptionRequest(t, s)
if text, _ := outputText(t, rec.Body.String()); rec.Code != 200 || text != `{"description":"Reviewed the routing change and verified it"}` || 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)
}
var kinds []string
for _, u := range usage.Load(time.Time{}) {
kinds = append(kinds, u.Provider+":"+u.Kind)
}
want := "openai:thread_description fake:thread_description"
if got := strings.Join(kinds, " "); got != want {
t.Errorf("ledger %s\nwant %s", got, want)
}
}

func TestCodexDescriptionFallbackOnMagpie(t *testing.T) {
f := &fake{t: t, reply: sse(
`data: {"id":"c1","choices":[{"index":0,"delta":{"content":"Plain description"}}]}`,
`data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2}}`,
`data: [DONE]`)}
setup(t, provider.Chat, f)
s := New()
body := `{"model":"fake/m1","stream":true,"input":"Describe the thread",` +
`"text":{"format":{"type":"json_schema","schema":{"type":"object","properties":{"description":{"type":"string","minLength":1}},"required":["description"]}}}}`
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/responses", strings.NewReader(body))
req.Header.Set("x-codex-turn-metadata", `{"thread_source":"thread_description"}`)
s.Handler().ServeHTTP(rec, req)
if text, _ := outputText(t, rec.Body.String()); rec.Code != 200 || text != `{"description":"Plain description"}` || f.calls != 1 {
t.Fatalf("fallback: %d %q, calls %d", rec.Code, rec.Body.String(), f.calls)
}
}

func TestNoDescription(t *testing.T) {
f := &fake{t: t, reply: sse(
`data: {"id":"c1","choices":[{"index":0,"delta":{"content":" "}}]}`,
`data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":1}}`,
`data: [DONE]`)}
setup(t, provider.Chat, f)
st := settings.Load()
st.CodexDescriptions = "fake/m1"
if err := settings.Save(st); err != nil {
t.Fatal(err)
}
s := New()
rec := codexDescriptionRequest(t, s)
text, n := outputText(t, rec.Body.String())
if rec.Code != 200 || n != 0 || text != "" {
t.Fatalf("empty reply: %d %q, %d items", rec.Code, rec.Body.String(), n)
}
var last usage.Record
for _, u := range usage.Load(time.Time{}) {
last = u
}
if last.Kind != "thread_description" || !strings.HasPrefix(last.Error, "description: ") || !last.Failed() {
t.Fatalf("unusable reply was not flagged: %+v", last)
}
}

func TestDescriptionJSON(t *testing.T) {
body := `{"text":{"format":{"schema":{"properties":{"description":{"type":"string","minLength":1}}}}}}`
shape := descriptionShapeOf([]byte(body))
for in, want := range map[string]string{
"A short note": `{"description":"A short note"}`,
`{"description":"From JSON"}`: `{"description":"From JSON"}`,
"```json\n{\"description\":\"Fenced\"}\n```": `{"description":"Fenced"}`,
"": "",
} {
if got := descriptionJSON(in, shape); got != want {
t.Errorf("descriptionJSON(%q) = %s, want %s", in, got, want)
}
}
}
4 changes: 4 additions & 0 deletions internal/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,10 @@ func (s *Server) handle(from provider.Protocol) http.HandlerFunc {
s.codexTitle(w, r, body, to)
return
}
if to := codexDescriptionsTo(r.Header, body, true); to != "" {
s.codexDescription(w, r, body, to)
return
}
}
s.serveAgent(w, r, from, body)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/gateway/session_parent.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ func isTitleKind(kind string) bool {
return kind == "thread_title" || kind == "thread_title_reconsideration" || kind == "title_generation"
}

func isDescriptionKind(kind string) bool {
return kind == "thread_description"
}

// Codex projects turn metadata into headers, but its canonical transport is
// client_metadata in the Responses body. Read only identity fields; never keep
// prompts or the rest of the metadata in the trace.
Expand Down
26 changes: 25 additions & 1 deletion internal/gui/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
in.FullContext = cur.FullContext // set on its own (full-context below)
in.CompactAt = cur.CompactAt // and so is the threshold

in.CodexTitles = cur.CodexTitles // set on its own (codex-titles below)
in.CodexTitles = cur.CodexTitles // set on its own (codex-titles below)
in.CodexDescriptions = cur.CodexDescriptions // set on its own (codex-descriptions below)
// and so is the model Codex's auto-review runs on (codex-auto-review)
in.CodexAutoReview = cur.CodexAutoReview
in.ChinaMirror = cur.ChinaMirror // the Plugins page's, set on its own
Expand Down Expand Up @@ -1184,6 +1185,29 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
}
writeJSON(rw, settingsState())
})
// where Codex's request for a thread's description goes: "" as Codex
// sends it, or a model's id. It is separate from the title setting.
mux.HandleFunc("POST /api/settings/codex-descriptions", 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 != "" {
if _, _, ok := provider.Resolve(v); !ok {
fail(rw, fmt.Errorf("no model %s to write Codex's descriptions", v))
return
}
}
s := settings.Load()
s.CodexDescriptions = v
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
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) {
Expand Down
32 changes: 31 additions & 1 deletion internal/gui/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17738,7 +17738,7 @@ function renderSettings() {
// gateway mode leaves out what is written into this computer's agents'
// files, and the alerts its desktop would show
for (const r of [$("#plainNamesSegs").parentElement, $("#codexAgentsV1Segs").parentElement, $("#fullContextSegs").parentElement,
$("#codexTitlesRow"), $("#codexAutoReviewRow"), $("#usageAlertRow"), $("#balanceAlertRow"), $("#resetReminderRow")]) r.hidden = gatewayMode;
$("#codexTitlesRow"), $("#codexDescriptionsRow"), $("#codexAutoReviewRow"), $("#usageAlertRow"), $("#balanceAlertRow"), $("#resetReminderRow")]) r.hidden = gatewayMode;
// the parts' tabs, one gone whose rows are all hidden here
setSetTab(setTab);
}
Expand Down Expand Up @@ -18223,6 +18223,7 @@ function renderTrayUsage(s, keep) {
compactRow.append(compactSegs, compactNum);
$("#fullContextSegs").replaceChildren(compactRow);
renderCodexTitles(s);
renderCodexDescriptions(s);
renderCodexAutoReview(s);
renderCodexMemories();
const rate = s.fx?.rate;
Expand Down Expand Up @@ -18488,6 +18489,35 @@ function renderAlerts(s, keep) {
$("#resetReminderSegs").replaceChildren(segs(opts, String(r), (v) => savePrefs({ ...keep, resetReminder: Number(v) })));
}

// 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 renderCodexDescriptions(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.codexDescriptions || "";
$("#codexDescriptionsSub").textContent = v ? t("Codex’s description requests go to this model, not to its ChatGPT sign-in; the Usage and Routing views still show them as descriptions")
: t("Codex asks its own model for each thread’s description 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 descriptions"));
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: "codexDescriptions", label: "model", value: v, options: [
{ value: "", label: t("Codex’s own (ChatGPT)"), note: t("as Codex sends them"), icon: "openai", reset: true },
...models.map(opt)],
onPick: (id) => {
if (id === v) return;
writingPrefs(api("settings/codex-descriptions", { model: id })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); });
} }, b, ev);
$("#codexDescriptionsPick").replaceChildren(b);
}

// 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
Expand Down
Loading
Loading