Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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); 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
112 changes: 112 additions & 0 deletions internal/gateway/codex_titles.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ 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) string {
if !isDescriptionKind(requestCallKind(h, requestSessionMetadata(h, body))) {
return ""
}
return settings.Load().CodexDescriptions
}

// 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 +123,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
78 changes: 78 additions & 0 deletions internal/gateway/codex_titles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,81 @@ func TestTitleJSONFillsTheSchema(t *testing.T) {
t.Errorf("no schema: %s", got)
}
}

func codexDescriptionRequest(t *testing.T, s *Server) *httptest.ResponseRecorder {
t.Helper()
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","maxLength":24}},"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 cha"}` || 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 TestDescriptionJSON(t *testing.T) {
body := `{"text":{"format":{"schema":{"properties":{"description":{"type":"string","maxLength":11}}}}}}`
shape := descriptionShapeOf([]byte(body))
for in, want := range map[string]string{
"A short note": `{"description":"A short not"}`,
`{"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); 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
28 changes: 27 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 @@ -18492,6 +18493,31 @@ function renderAlerts(s, keep) {
// — 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);
}

function renderCodexTitles(s) {
const models = s.titleModels || [];
const named = (id) => {
Expand Down
3 changes: 3 additions & 0 deletions internal/gui/assets/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ const I18N = {
"Full window": "完整窗口",
"Compact at {n}": "{n} 时压缩",
"Codex thread titles": "Codex 会话标题",
"Codex thread descriptions": "Codex 会话描述",
"Codex asks its own model for each thread’s description through its ChatGPT sign-in, even while the conversation is on magpie’s models": "Codex 经 ChatGPT 登录用自带模型为每个会话生成描述,即使对话用的是 magpie 的模型",
"Codex’s description requests go to this model, not to its ChatGPT sign-in; the Usage and Routing views still show them as descriptions": "Codex 的描述请求改由此模型生成,不经 ChatGPT 登录;用量和路由页仍记为描述请求",
"Codex’s own (ChatGPT)": "Codex 自带(ChatGPT)",
"as Codex sends them": "按 Codex 原样发送",
"Codex auto-review model": "Codex 自动审批模型",
Expand Down
4 changes: 4 additions & 0 deletions internal/gui/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ <h1 class="sr-only" id="pageTitle" data-t>Agents</h1>
<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" id="codexDescriptionsRow">
<div class="who"><div class="name" data-t>Codex thread descriptions</div><div class="sub" id="codexDescriptionsSub"></div></div>
<div class="val" id="codexDescriptionsPick"></div>
</div>
<div class="row pref" id="codexAutoReviewRow">
<div class="who"><div class="name" data-t>Codex auto-review model</div><div class="sub" id="codexAutoReviewSub"></div></div>
<div class="val" id="codexAutoReviewPick"></div>
Expand Down
4 changes: 4 additions & 0 deletions internal/gui/assets/purposes.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const REQUEST_KINDS = {
"purpose": "kind:review",
"name": "Review"
},
"thread_description": {
"purpose": "kind:thread_description",
"name": "Description"
},
"thread_spawn": {
"purpose": "kind:collab_spawn",
"name": "Subagent"
Expand Down
Loading
Loading