gateway: a call Codex makes for itself says so on the Routing page, and goes to a pool account with what Codex says about it (碳碳双键: 为何 codex 桌面选的 5.6sol,但是请求记录…显示的全是 gpt-6-luna) — magpie changes no model: Codex sends its own calls on Luna (the guardian review of an approval, x-openai-subagent: guardian; a thread's title; memories) and, on Luna Reserve once the plan's allowance is used up, every turn with x-openai-codex-luna-reserve. Relayed to Codex's own sign-in these headers went on as they came, but a turn the pool of Codex's accounts served was signed afresh and went without them. Now x-openai-*, x-codex-* (turn metadata, installation, window, parent thread), session_id, conversation_id, x-client-request-id and version go on with it too; never a sign-in (Authorization, chatgpt-account-id, anything with authorization in its name) nor Codex's device attestation, the pool account's own going in its place, nor what holds for Codex's own account alone (x-openai-codex-luna-reserve, as another account's allowance may be left, and x-codex-turn-state, the backend's routing of the turn for that account). The trace, the Recent calls and the usage log keep the call's kind (kind, from x-openai-subagent; memgen; luna_reserve), and the Requests list, a replay and the request's story show it as a grey tag by the model — Approval check / 审批判定, Title / 标题, Memory / 记忆, Subagent / 子代理, Review / 审查, Compaction / 压缩, Luna Reserve / Luna 储备, an unknown one as Codex names it — the story saying Codex made the call itself and picks its model. The Requests list's model column fits its content (up to 36%) so model and tag keep their room, the tag giving way first. Tested with a fake ChatGPT backend on the pool path (acct-1 used up, acct-2 answering: both get the headers and neither the client's sign-in; fails without the change), callKind/codexHeader cases, and a new internal/gui/tests/routing-kind.test.cjs in Chromium and WebKit, en and zh (tags, the model shown whole, the story, a click leaving the row where it is); the other GUI tests, go build -tags nogui, GOOS=windows go build and go test -tags nogui pass
This commit is contained in:
10 files changed
+323
-12
No files matched your search
@@ -148,3 +148,104 @@ func TestResetsIn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A turn served by the pool of Codex's accounts goes on with what Codex
|
||||
// says about it — a guardian review, the turn's metadata, Luna Reserve —
|
||||
// as it would to Codex's own sign-in, signed by the pool's account and
|
||||
// never by the client's; the Routing view and the usage log say it was a
|
||||
// guardian review (碳碳双键: 为何 codex 桌面选的 5.6sol,但是请求记录…显示
|
||||
// 的全是 gpt-6-luna).
|
||||
func TestCodexPoolForwardsCodexHeaders(t *testing.T) {
|
||||
codexSignedIn(t, "spare@example.com")
|
||||
var heads []http.Header
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.ReadAll(r.Body)
|
||||
heads = append(heads, r.Header.Clone())
|
||||
if r.Header.Get("chatgpt-account-id") == "acct-1" {
|
||||
w.WriteHeader(429)
|
||||
io.WriteString(w, `{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","resets_in_seconds":7200}}`)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, sse(
|
||||
`data: {"type":"response.created","response":{"id":"r1","model":"gpt-5.5"}}`,
|
||||
`data: {"type":"response.output_text.delta","delta":"pong"}`,
|
||||
`data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":7,"output_tokens":1}}}`))
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
was := provider.CodexBase
|
||||
provider.CodexBase = up.URL + "/backend-api/codex"
|
||||
t.Cleanup(func() { provider.CodexBase = was })
|
||||
|
||||
s := New()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"ping"}`))
|
||||
req.Header.Set("Authorization", "Bearer chatgpt-token")
|
||||
req.Header.Set("chatgpt-account-id", "acct-1")
|
||||
req.Header.Set("User-Agent", "codex_cli_rs/0.155.1")
|
||||
req.Header.Set("x-openai-subagent", "guardian")
|
||||
req.Header.Set("x-codex-turn-metadata", `{"turn_id":"t1"}`)
|
||||
req.Header.Set("x-openai-codex-luna-reserve", "true")
|
||||
req.Header.Set("x-codex-installation-id", "inst-1")
|
||||
req.Header.Set("x-codex-turn-state", "acct-1-state")
|
||||
req.Header.Set("x-oai-attestation", "device-proof")
|
||||
req.Header.Set("x-openai-actor-authorization", "Bearer someone")
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "pong") {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(heads) != 2 {
|
||||
t.Fatalf("%d requests upstream", len(heads))
|
||||
}
|
||||
for i, h := range heads {
|
||||
for k, want := range map[string]string{
|
||||
"x-openai-subagent": "guardian",
|
||||
"x-codex-turn-metadata": `{"turn_id":"t1"}`,
|
||||
"x-codex-installation-id": "inst-1",
|
||||
"chatgpt-account-id": "acct-" + string(rune('1'+i)),
|
||||
} {
|
||||
if got := h.Get(k); got != want {
|
||||
t.Errorf("request %d: %s %q, want %q", i, k, got, want)
|
||||
}
|
||||
}
|
||||
if h.Get("x-openai-codex-luna-reserve") != "" || h.Get("x-codex-turn-state") != "" {
|
||||
t.Errorf("request %d carries what holds for Codex's own account: %v", i, h)
|
||||
}
|
||||
if h.Get("Authorization") == "Bearer chatgpt-token" || h.Get("x-oai-attestation") != "" || h.Get("x-openai-actor-authorization") != "" {
|
||||
t.Errorf("request %d carries the client's sign-in: %v", i, h)
|
||||
}
|
||||
}
|
||||
if r := s.trace.routes[len(s.trace.routes)-1]; r.Kind != "guardian" {
|
||||
t.Errorf("route kind %q", r.Kind)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(filepath.Dir(provider.Path()), "usage.jsonl"))
|
||||
if !strings.Contains(string(b), `"kind":"guardian"`) {
|
||||
t.Errorf("usage log: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallKind(t *testing.T) {
|
||||
for want, h := range map[string]map[string]string{
|
||||
"": {},
|
||||
"guardian": {"x-openai-subagent": " guardian ", "x-openai-codex-luna-reserve": "true"},
|
||||
"thread_title": {"x-openai-subagent": "thread_title"},
|
||||
"memgen": {"x-openai-memgen-request": "1"},
|
||||
"luna_reserve": {"x-openai-codex-luna-reserve": "true"},
|
||||
} {
|
||||
hh := http.Header{}
|
||||
for k, v := range h {
|
||||
hh.Set(k, v)
|
||||
}
|
||||
if got := callKind(hh); got != want {
|
||||
t.Errorf("%v: %q, want %q", h, got, want)
|
||||
}
|
||||
}
|
||||
for k, want := range map[string]bool{
|
||||
"x-openai-subagent": true, "X-Codex-Turn-Metadata": true, "X-Codex-Turn-State": false, "x-openai-codex-luna-reserve": false, "session_id": true, "Session_id": true,
|
||||
"Authorization": false, "chatgpt-account-id": false, "x-oai-attestation": false,
|
||||
"x-openai-actor-authorization": false, "User-Agent": false, "Cookie": false,
|
||||
} {
|
||||
if codexHeader(k) != want {
|
||||
t.Errorf("codexHeader(%q) = %v", k, !want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,7 +224,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
|
||||
}
|
||||
model := modelOf(body)
|
||||
seat := Weighed{ID: "codex", Provider: "openai", Name: "OpenAI", Icon: "openai", Who: who, Kind: "account", Agent: "codex", Model: model}
|
||||
tr = s.trace.begin(Route{Time: start, Agent: agentOf(r), Model: model, Provider: "openai",
|
||||
tr = s.trace.begin(Route{Time: start, Agent: agentOf(r), Kind: callKind(r.Header), Model: model, Provider: "openai",
|
||||
Order: []Weighed{seat}, Tries: []Try{{ID: seat.ID, Model: model, Start: start}}})
|
||||
end = func(status int, msg string, tokens, out int) {
|
||||
ms := time.Since(start).Milliseconds()
|
||||
@@ -321,7 +321,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
|
||||
return
|
||||
}
|
||||
call := Call{Time: start, From: provider.Responses, To: provider.Responses, Model: modelOf(body),
|
||||
Provider: "openai", Agent: agentOf(r), Status: res.StatusCode,
|
||||
Provider: "openai", Agent: agentOf(r), Kind: callKind(r.Header), Status: res.StatusCode,
|
||||
Millis: time.Since(start).Milliseconds()}
|
||||
call.TTFT, call.FirstText = first.ms()
|
||||
var uu Usage
|
||||
@@ -334,7 +334,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
|
||||
s.record(call)
|
||||
usage.Append(usage.Record{Time: start, Agent: call.Agent, Provider: call.Provider, Host: provider.HostOf(base), Model: call.Model,
|
||||
Input: uu.Input, Output: uu.Output, CacheRead: uu.CacheRead, CacheWrite: uu.CacheWrite,
|
||||
Reasoning: uu.Reasoning, Millis: call.Millis, TTFT: call.TTFT, FirstText: call.FirstText, Status: call.Status, Session: sessionOf(r.Header)})
|
||||
Reasoning: uu.Reasoning, Millis: call.Millis, TTFT: call.TTFT, FirstText: call.FirstText, Status: call.Status, Session: sessionOf(r.Header), Kind: call.Kind})
|
||||
}
|
||||
|
||||
// unreadableItem is the item OpenAI's refusal names: sealed content it
|
||||
@@ -418,6 +418,48 @@ func hopHeader(k string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// codexHeader is one of the headers Codex tells the ChatGPT backend about
|
||||
// a request by, which go on with it when a pool account signs it as they
|
||||
// do when Codex's own sign-in does (codexUpstream): x-openai-subagent (a
|
||||
// guardian review, a thread's title, memories, a compaction…), the turn's
|
||||
// x-codex-turn-metadata, its installation, window and parent thread, and
|
||||
// the session. Never a sign-in: the account's own goes in its place, and
|
||||
// Codex's device attestation is its own sign-in's. Nor what holds for
|
||||
// Codex's own account alone: x-openai-codex-luna-reserve, which says its
|
||||
// plan's allowance is used up (another account's may not be), and
|
||||
// x-codex-turn-state, the backend's routing of the turn for that account.
|
||||
func codexHeader(k string) bool {
|
||||
k = strings.ToLower(k)
|
||||
if strings.Contains(k, "authorization") || k == "x-oai-attestation" ||
|
||||
k == "x-openai-codex-luna-reserve" || k == "x-codex-turn-state" {
|
||||
return false
|
||||
}
|
||||
switch k {
|
||||
case "session_id", "conversation_id", "x-client-request-id", "version":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(k, "x-openai-") || strings.HasPrefix(k, "x-codex-")
|
||||
}
|
||||
|
||||
// callKind is what an agent made a call for when it isn't a turn of the
|
||||
// conversation, as Codex names it in x-openai-subagent: "guardian" (auto
|
||||
// review of an approval), "review", "compact", "memory_consolidation",
|
||||
// "thread_title", "collab_spawn"… A turn Codex sends on Luna Reserve, once
|
||||
// the plan's own allowance is used up, is "luna_reserve".
|
||||
func callKind(h http.Header) string {
|
||||
v := strings.TrimSpace(h.Get("x-openai-subagent"))
|
||||
if v == "" && h.Get("x-openai-memgen-request") != "" {
|
||||
v = "memgen"
|
||||
}
|
||||
if v == "" && h.Get("x-openai-codex-luna-reserve") != "" {
|
||||
v = "luna_reserve"
|
||||
}
|
||||
if len(v) > 40 {
|
||||
v = v[:40]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func copyHeaders(dst, src http.Header) {
|
||||
for k, vs := range src {
|
||||
if !hopHeader(k) && http.CanonicalHeaderKey(k) != "Host" {
|
||||
|
||||
@@ -153,8 +153,11 @@ func Serving() (running, window bool) {
|
||||
|
||||
// Call is one request the gateway handled, for the status views.
|
||||
type Call struct {
|
||||
Time time.Time `json:"time"`
|
||||
Agent string `json:"agent"` // who called, from the client's User-Agent
|
||||
Time time.Time `json:"time"`
|
||||
Agent string `json:"agent"` // who called, from the client's User-Agent
|
||||
// Kind: what the agent made the call for, when it isn't its turn —
|
||||
// a Codex subagent's (callKind) — "" for a turn
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
From provider.Protocol `json:"from"`
|
||||
@@ -643,7 +646,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
requestBody, requestTruncated := captureRequestBody(body)
|
||||
capture := &captureResponseWriter{ResponseWriter: w}
|
||||
w = capture
|
||||
call := Call{Time: start, From: from, Model: unprefixed(modelOf(body)), Agent: agentOf(r),
|
||||
call := Call{Time: start, From: from, Model: unprefixed(modelOf(body)), Agent: agentOf(r), Kind: callKind(r.Header),
|
||||
RequestBody: requestBody, RequestTruncated: requestTruncated}
|
||||
usage.Saw(call.Agent)
|
||||
finishCapture := func() {
|
||||
@@ -873,7 +876,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
if len(cands) == 1 {
|
||||
shown = nil // nobody else to stay away from
|
||||
}
|
||||
tr := s.trace.begin(Route{Time: start, Agent: call.Agent, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, Affinity: shown, Order: pl.order, Left: pl.left})
|
||||
tr := s.trace.begin(Route{Time: start, Agent: call.Agent, Kind: call.Kind, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, Affinity: shown, Order: pl.order, Left: pl.left})
|
||||
var skipped []string
|
||||
sent := "" // the reasoning the last try's model was asked for
|
||||
where := "" // the last try's provider.Where, for the usage
|
||||
@@ -1047,7 +1050,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
usage.Append(usage.Record{Time: start, Agent: call.Agent, Provider: call.Provider, Host: where, Model: model,
|
||||
Input: call.Usage.Input, Output: call.Usage.Output, CacheRead: call.Usage.CacheRead,
|
||||
CacheWrite: call.Usage.CacheWrite, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: call.Millis, Status: call.Status,
|
||||
TTFT: call.TTFT, FirstText: call.FirstText, Session: sessionOf(r.Header)})
|
||||
TTFT: call.TTFT, FirstText: call.FirstText, Session: sessionOf(r.Header), Kind: call.Kind})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1184,6 +1187,16 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid
|
||||
if p.IsOpenCode() {
|
||||
req.Header.Set("x-opencode-session", conversationID(in, body))
|
||||
}
|
||||
if p.Account != nil && p.Account.Agent == "codex" {
|
||||
// what Codex says about the request goes on as codexUpstream
|
||||
// relays it — a subagent's kind, the turn's metadata, a turn on
|
||||
// Luna Reserve — the account signing it being the pool's
|
||||
for k, vs := range in {
|
||||
if codexHeader(k) {
|
||||
req.Header[k] = slices.Clone(vs)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.Sign(ctx, req, to, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Route struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
Agent string `json:"agent"`
|
||||
Kind string `json:"kind,omitempty"` // what the call is for, as Call's
|
||||
Model string `json:"model"` // as the agent asked
|
||||
Effort string `json:"effort,omitempty"` // the reasoning the agent asked for; "" for none
|
||||
Provider string `json:"provider"` // the provider the model resolved to
|
||||
|
||||
@@ -1066,6 +1066,15 @@ const I18N = {
|
||||
"the others go in their order.": "其余按顺序排。",
|
||||
"In order: with {rested} resting after a failure, {who} is the first that can answer.": "按顺序:{rested} 失败后正在休息,{who} 是第一个能答的。",
|
||||
"{agent} asked for {model}": "{agent} 请求 {model}",
|
||||
"Approval check": "审批判定",
|
||||
"Review": "审查",
|
||||
"Compaction": "压缩",
|
||||
"Memory": "记忆",
|
||||
"Title": "标题",
|
||||
"Subagent": "子代理",
|
||||
"Luna Reserve": "Luna 储备",
|
||||
"{agent} sent this turn on Luna Reserve, which it turns to once the plan's own allowance is used up; it picks the model itself.": "{agent} 用 Luna 储备发出了这一轮:套餐自己的额度用完后它会改用 Luna 储备,模型由它自己决定。",
|
||||
"{agent} made this call itself ({kind}), not as a turn of the conversation, and picks its model itself.": "这是 {agent} 自己发起的调用({kind}),不是对话中的一轮,模型由它自己决定。",
|
||||
"Nothing was tried: {error}": "没有尝试任何账号:{error}",
|
||||
"Requests": "请求",
|
||||
"{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}": "{agent} 请求 {model}:由 {name} 提供,向厂商请求的模型是 {sent}",
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
.rt-cols { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 16px; }
|
||||
@container (min-width: 860px) { .rt-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } }
|
||||
.rt-col { min-width: 0; }
|
||||
.rt-reqs { padding: 4px; max-height: 420px; overflow-y: auto; display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto; }
|
||||
.rt-reqs { padding: 4px; max-height: 420px; overflow-y: auto; display: grid; grid-template-columns: auto fit-content(36%) minmax(0, 1fr) auto; }
|
||||
.rt-req { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; column-gap: 12px; padding: 6px 8px; font: inherit; font-size: 12px; text-align: left; color: var(--fg-2); background: none; border: 1px solid transparent; border-radius: 8px; cursor: pointer; }
|
||||
.rt-req:hover { background: var(--card-2); }
|
||||
.rt-req[aria-pressed="true"] { background: var(--card-2); border-color: var(--line); color: var(--fg); }
|
||||
@@ -168,6 +168,12 @@
|
||||
.rt-req .to .ef { flex: none; overflow: visible; margin-left: -2px; color: var(--muted); }
|
||||
.rt-req .ef::before { content: "· "; }
|
||||
.rt-req .to .ef.picked { color: var(--fg-2); }
|
||||
/* what a call was for (a Codex guardian review, a title…): a quiet grey tag by the model */
|
||||
.rt-req .asked .kind, .rp-req .kind, .rt-steps li .kind { flex: none; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--card-2); border: 1px solid var(--line); white-space: nowrap; }
|
||||
.rt-steps li .kind { display: inline-block; vertical-align: 1px; }
|
||||
/* in a narrow row the model keeps its room first: the tag gives way, to an ellipsis */
|
||||
.rt-req .asked .kind { flex: 0 100 auto; min-width: 3.2em; max-width: 9em; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rt-req .asked.kinded .m { flex: none; max-width: calc(100% - 3.2em - 40px); }
|
||||
.rt-req .meta { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
|
||||
@container (max-width: 520px) { .rt-reqs { grid-template-columns: auto minmax(0, 1fr); } .rt-req { row-gap: 2px; } .rt-req .to { grid-column: 2; } .rt-req .meta { grid-column: 2; text-align: left; } }
|
||||
.rt-acts { padding: 4px 0; }
|
||||
|
||||
@@ -903,6 +903,7 @@
|
||||
: main && main.model !== r.model
|
||||
? t("{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}", { agent: agentName(r.agent), model: r.model, name: main.name, sent: main.model })
|
||||
: t("{agent} asked for {model}", { agent: agentName(r.agent), model: r.model }) + " → " + (main?.name || r.provider), ""]);
|
||||
if (r.kind) items.push([kindWhy(r), "aside kind"]);
|
||||
items.push([affWhy(r, true) || ruleWhy(r, true) || firstWhy(r), "why"]);
|
||||
for (const s of nestedWhy(r)) items.push([s, "why"]);
|
||||
for (const a of asides(r)) items.push([a, "aside"]);
|
||||
@@ -912,7 +913,11 @@
|
||||
if (tr.done && tr.status >= 400 && tr.error) items.push([t("It said: {error}", { error: tr.error.length > 600 ? tr.error.slice(0, 600) + "…" : tr.error }), "aside said"]);
|
||||
});
|
||||
if (r.done && !r.tries.length) items.push([t("Nothing was tried: {error}", { error: r.error || r.status }), "bad"]);
|
||||
steps.replaceChildren(...items.map(([s, c]) => el("li", c, s)));
|
||||
steps.replaceChildren(...items.map(([s, c]) => {
|
||||
const li = el("li", c, s);
|
||||
if (c === "aside kind") li.prepend(kindTag(r), " ");
|
||||
return li;
|
||||
}));
|
||||
}
|
||||
|
||||
// pick sets the stage to a past request, or back to live with the newest.
|
||||
@@ -928,6 +933,30 @@
|
||||
say(affWhy(r, true) || ruleWhy(r, true) || firstWhy(r));
|
||||
}
|
||||
|
||||
// what a call was for when it isn't a turn of the conversation, as
|
||||
// Codex names it (x-openai-subagent): its own guardian review of an
|
||||
// approval, a thread's title, memories… — each on the model Codex picks
|
||||
// for it, so a list of Luna calls under a Sol composer reads as it is
|
||||
const KIND = {
|
||||
guardian: "Approval check", auto_review: "Approval check", guardian_review: "Approval check",
|
||||
review: "Review", compact: "Compaction",
|
||||
memory_consolidation: "Memory", memgen: "Memory", memory: "Memory",
|
||||
thread_title: "Title", title: "Title",
|
||||
collab_spawn: "Subagent", thread_spawn: "Subagent", agent_job: "Subagent",
|
||||
luna_reserve: "Luna Reserve",
|
||||
};
|
||||
const kindName = (k) => KIND[k] ? t(KIND[k]) : k;
|
||||
function kindTag(r) {
|
||||
const k = el("span", "kind", kindName(r.kind));
|
||||
k.title = kindWhy(r);
|
||||
return k;
|
||||
}
|
||||
function kindWhy(r) {
|
||||
const agent = agentName(r.agent);
|
||||
if (r.kind === "luna_reserve") return t("{agent} sent this turn on Luna Reserve, which it turns to once the plan's own allowance is used up; it picks the model itself.", { agent });
|
||||
return t("{agent} made this call itself ({kind}), not as a turn of the conversation, and picks its model itself.", { agent, kind: kindName(r.kind) });
|
||||
}
|
||||
|
||||
// who answered a request, or what its agent got
|
||||
function outcome(r) {
|
||||
if (!r.done) {
|
||||
@@ -1021,6 +1050,7 @@
|
||||
const sw = el("i", "ag");
|
||||
sw.style.setProperty("--agent", hueOf(r.agent));
|
||||
asked.append(sw, icon(ag?.icon || "generic"), el("span", "m", r.model));
|
||||
if (r.kind) { asked.classList.add("kinded"); asked.append(kindTag(r)); }
|
||||
// the reasoning the model was sent at — the turn's pick, or the
|
||||
// agent's fitted to the model's levels; what the agent asked for is
|
||||
// in its title and the request's story
|
||||
@@ -1403,7 +1433,7 @@
|
||||
sw.style.setProperty("--agent", hueOf(r.agent));
|
||||
const [said] = outcome(r);
|
||||
row.append(el("span", "rp-n", t("request {i} of {n}", { i: g.n, n: p.plan.length })), sw,
|
||||
el("b", "", agentName(r.agent)), el("code", "mdl", r.model), el("span", "rp-to", "→ " + said),
|
||||
el("b", "", agentName(r.agent)), el("code", "mdl", r.model), ...(r.kind ? [kindTag(r)] : []), el("span", "rp-to", "→ " + said),
|
||||
el("span", "rp-at", t("sent {time}", { time: new Date(r.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) })));
|
||||
row.onclick = () => { cur = r; renderLog(); };
|
||||
return row;
|
||||
|
||||
@@ -23,10 +23,15 @@ was clicked where it is on every frame.
|
||||
scrolled: its fields hide, the head keeps the base URL and a copy button, the
|
||||
head stays where it was, and the fold is remembered across a reload.
|
||||
|
||||
`routing-kind.test.cjs` lists calls Codex makes for itself (a guardian review,
|
||||
a title, memories, a turn on Luna Reserve, a kind it does not know yet): each
|
||||
has a grey tag by its model in the Requests list, in English and Chinese, the
|
||||
model keeping its room first, and the request's story says what it was.
|
||||
|
||||
With Node.js and Playwright available:
|
||||
|
||||
```sh
|
||||
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs
|
||||
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs
|
||||
```
|
||||
|
||||
If Playwright is installed outside the repository, set `NODE_PATH` to the
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A call Codex makes for itself — its guardian review of an approval, a
|
||||
// thread's title, memories — or a turn on Luna Reserve, carries a grey tag
|
||||
// by the model in the Routing page's Requests list and in the request's
|
||||
// story, so a list of Luna calls under a Sol composer reads as it is
|
||||
// (碳碳双键: 为何 codex 桌面选的 5.6sol,但是请求记录…显示的全是 gpt-6-luna).
|
||||
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");
|
||||
const now = new Date();
|
||||
const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-");
|
||||
const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString();
|
||||
const seat = { id: "codex", provider: "openai", name: "OpenAI", who: "Codex's own sign-in", kind: "account", agent: "codex", model: "gpt-6-luna" };
|
||||
const kinds = ["guardian", "thread_title", "memory_consolidation", "luna_reserve", "something_new", ""];
|
||||
const routes = kinds.map((kind, i) => ({
|
||||
id: 100 - i, seq: 100 - i, time: at(i), agent: "codex", model: "gpt-6-luna", provider: "openai", ...(kind ? { kind } : {}),
|
||||
order: [seat], tries: [{ id: seat.id, model: "gpt-6-luna", start: at(i), done: true, status: 200, ms: 900 }],
|
||||
done: true, status: 200, ms: 900, tokens: 1200,
|
||||
}));
|
||||
|
||||
function serve(lang) {
|
||||
const state = { agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().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:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json(state);
|
||||
if (url.pathname === "/api/gateway/trace") {
|
||||
if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
|
||||
return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
|
||||
}
|
||||
if (url.pathname === "/api/gateway/history") {
|
||||
const d = url.searchParams.get("day");
|
||||
return json({ cut: false, days: [{ day, requests: routes.length }], routes: d ? routes : [] });
|
||||
}
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
|
||||
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)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const want = {
|
||||
en: ["Approval check", "Title", "Memory", "Luna Reserve", "something_new"],
|
||||
zh: ["审批判定", "标题", "记忆", "Luna 储备", "something_new"],
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
test(`${engine} ${lang}: a call Codex makes for itself is tagged by its model`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const context = await browser.newContext({ viewport: { width: 1100, height: 760 }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang));
|
||||
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}-kind.png`), fullPage: true });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
await page.goto("http://magpie.test/?view=routing");
|
||||
await page.locator(".rt-day").nth(1).click();
|
||||
await page.locator(".rt-req").nth(kinds.length - 1).waitFor();
|
||||
|
||||
const tags = await page.locator(".rt-req").evaluateAll((rows) => rows.map((r) => r.querySelector(".asked .kind")?.textContent || ""));
|
||||
assert.deepEqual(tags, [...want[lang], ""]);
|
||||
// beside the model, not in its place, the model keeping its room first
|
||||
const look = await page.locator(".rt-req .asked .kind").first().evaluate((e) => {
|
||||
const m = e.parentElement.querySelector(".m");
|
||||
return { after: m.getBoundingClientRect().right <= e.getBoundingClientRect().left + 0.5, shown: m.clientWidth >= m.scrollWidth, model: m.textContent };
|
||||
});
|
||||
assert(look.after && look.shown && look.model === "gpt-6-luna", JSON.stringify(look));
|
||||
|
||||
// the story says what it was, and the click leaves the row where it is
|
||||
const row = page.locator(".rt-req").nth(0);
|
||||
const was = await row.evaluate((e) => e.getBoundingClientRect().top);
|
||||
await row.click();
|
||||
await page.waitForTimeout(600);
|
||||
assert(Math.abs((await row.evaluate((e) => e.getBoundingClientRect().top)) - was) <= 1, "picking the request moved the page");
|
||||
const story = page.locator(".rt-steps li.kind");
|
||||
assert.equal(await story.locator(".kind").textContent(), want[lang][0]);
|
||||
assert.match(await story.textContent(), lang === "zh" ? /不是对话中的一轮/ : /not as a turn of the conversation/);
|
||||
// a turn has no tag in its story
|
||||
await page.locator(".rt-req").nth(kinds.length - 1).click();
|
||||
await page.waitForTimeout(300);
|
||||
assert.equal(await page.locator(".rt-steps li.kind").count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ type Record struct {
|
||||
// it (X-Magpie-Session, or the session header Claude Code, Codex or
|
||||
// OpenCode sends): several sessions on one model told apart
|
||||
Session string `json:"session,omitempty"`
|
||||
// Kind is what the agent made the call for when it isn't a turn of
|
||||
// the conversation: a Codex subagent's (review, compact, guardian…)
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// Path is the log file: ~/.config/magpie/usage.jsonl (XDG-aware).
|
||||
|
||||
Reference in new issue
Block a user