* provider: a Codex account at a spend cap or past its overage with credits left keeps Codex signed in, as the Codex app's composer still sends for it #966 held such an account (and one whose limit_reached alone was true), following the readiness check Tht in ChatGPT.app 26.930. Tht has one caller, the work-credit-restriction-recovery poller; it isn't the composer's gate. The composer's submit is disabled only by hP (search "n.rate_limit?.allowed!==!1||Jpe(n)||SIe(") and hardBlocked (search "hardBlocked:r.rate_limit?.allowed===!1"): both read rate_limit.allowed alone and let credits (has_credits or unlimited) through before any spend cap or overage. So the app sends for that account, and holding it gains nothing: CodexUsedUp only moves Codex to magpie as provider while Codex is on a magpie model, whose turns already reach magpie through openai_base_url. It only takes the app out of ChatGPT (durable threads, remote control), the loss #966 set out to stop. codexHeld is back to that gate: held when allowed is false, no credits, and not a workspace within its overage (no spend cap, an ordinary reason). Its comment names the build, both gates and why Tht isn't one, each with a string to search for. The spend-cap and overage subcases now expect not held; new subcases pin allowed as the only trigger. Five subcases fail on the merged codexHeld. agent-wiring.md and reference.md say the same. Follow-up to #966. * provider: a Sci, Law, FinServ or Enterprise trial/view-only workspace within its overage keeps Codex signed in codexHeld lets a workspace with no credits through while it is within its overage, as the Codex app's composer does, but the workspace list came from codex-rs's PlanType alone. The app's own plan_type switch for its usage banner (ChatGPT.app 26.930.61225, app-initial) also names sci, law, finserv, enterprise_cbp_trial and enterprise_cbp_view_only, so such an account read held and magpie took Codex off its ChatGPT sign-in while the app still sent. They are added; the comment cites that switch. guest there is a personal plan and stays out, and a plan not listed still counts as no workspace (held). Verification: "Law without credits, within its overage" in TestCodexUsedUpIsWhatTheAppHolds fails without the list change (CodexUsedUp = true, want false) and passes with it. * provider: a held Codex reading with no window at 100% stays held through a failed read A reading kept for when /wham/usage fails dropped Held whenever none of its windows read 100%. That is right for a hold that came with a used-up window which has since started again, but a hold with no window used up (the backend says not allowed, no credits, every window under 100%) has no reset to wait for: the first 503 after it read not held and put Codex back on its ChatGPT sign-in for an account its app sends nothing for. Held is now cleared only when the saved reading had a window used up and none is after the passed resets are applied; any other hold stands till a fresh read. Verification: TestCodexHeldWithNoWindowUpKeptTillAFreshRead (the real Pro reply, credits spent, its window at 40%: a good read, then a 503) fails without the change ("CodexUsedUp = false on the kept held reading, no window used up") and passes with it. TestCodexHeldKeptOnlyTillTheReset still passes; both share their setup now. -race -count=10 passes. * agent, gui, docs: Codex's sign-in notes say magpie steps in while the Codex app blocks the account, not whenever it is used up #981's sign-in notes said magpie becomes Codex's provider "while its account is used up". Since codexHeld, a ChatGPT account at 100% with credits left keeps Codex signed in; magpie steps in only while the Codex app blocks the account (out of allowance, no credits left), and the notes now say that, Always ChatGPT's to match, in English, Chinese, Japanese and German. agent-wiring.md's sign-in line and reference.md's Codex paragraph say when the app holds an account as codexHeld reads it: OpenAI no longer allows it and it has no credits left, a workspace still within its overage excepted; at 100% with credits Codex stays signed in. Verification: codex-signin-always (Chromium and WebKit, en/zh/ja/de, 1100 and 440px) 8/8, i18n-keys 1/1, gui-ja 3/3, gui-de 3/3. git grep finds the old English notes nowhere under internal/gui/tests or internal/gui/assets.
231 lines
9.0 KiB
Go
231 lines
9.0 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The account Codex is signed in to is out (CodexUsedUp, which makes magpie
|
|
// Codex's provider and the Codex app lose its ChatGPT sign-in) only when the
|
|
// Codex app itself holds it: the backend says it isn't allowed and it has no
|
|
// credits to go on with, a workspace one past its overage too. A window at
|
|
// 100% alone isn't: a Pro account with credits read 100% on its week and
|
|
// "allowed": false, and the app kept sending, the backend answering 200,
|
|
// while magpie had taken Codex out of ChatGPT ("Sign in to ChatGPT to start
|
|
// a durable thread"). The two fixtures are real /wham/usage replies, ids
|
|
// replaced.
|
|
func TestCodexUsedUpIsWhatTheAppHolds(t *testing.T) {
|
|
read := func(name string) map[string]any {
|
|
t.Helper()
|
|
b, err := os.ReadFile("testdata/" + name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
with := func(m map[string]any, edit func(m map[string]any)) map[string]any {
|
|
edit(m)
|
|
return m
|
|
}
|
|
credits := func(m map[string]any) map[string]any { return m["credits"].(map[string]any) }
|
|
rateLimit := func(m map[string]any) map[string]any { return m["rate_limit"].(map[string]any) }
|
|
// a workspace on no credits that the app sends for: within its overage,
|
|
// not at a spend cap, held for its allowance alone
|
|
within := func(plan string, edit func(m map[string]any)) map[string]any {
|
|
return with(read("codex_usage_team_spend_cap.json"), func(m map[string]any) {
|
|
m["plan_type"] = plan
|
|
credits(m)["has_credits"] = false
|
|
m["spend_control"] = map[string]any{"reached": false, "individual_limit": nil}
|
|
m["rate_limit_reached_type"] = map[string]any{"type": "rate_limit_reached", "details": nil}
|
|
edit(m)
|
|
})
|
|
}
|
|
as := func(m map[string]any) {}
|
|
for _, c := range []struct {
|
|
name string
|
|
body map[string]any
|
|
want bool
|
|
}{
|
|
{"Pro at 100% on its week, with credits", read("codex_usage_pro_credits.json"), false},
|
|
{"Team at its spend cap, with credits", read("codex_usage_team_spend_cap.json"), false},
|
|
{"Pro at 100%, credits spent", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
credits(m)["has_credits"], credits(m)["balance"] = false, "0"
|
|
}), true},
|
|
{"Pro at 100%, credits unlimited", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
credits(m)["has_credits"], credits(m)["unlimited"] = false, true
|
|
}), false},
|
|
{"Pro at 100%, no credits said", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
delete(m, "credits")
|
|
}), true},
|
|
// the app reads the spend cap and the overage only for a workspace
|
|
// with no credits: credits carry a Pro account on past both
|
|
{"Pro with credits, past its overage", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
credits(m)["overage_limit_reached"] = true
|
|
}), false},
|
|
{"Pro with credits, at a spend cap", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
m["spend_control"].(map[string]any)["reached"] = true
|
|
}), false},
|
|
// only allowed false holds it: limit_reached alone doesn't
|
|
{"limit reached but allowed, no credits", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
delete(m, "credits")
|
|
rateLimit(m)["limit_reached"], rateLimit(m)["allowed"] = true, true
|
|
}), false},
|
|
{"limit reached, allowed not said, no credits", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
delete(m, "credits")
|
|
rateLimit(m)["limit_reached"] = true
|
|
delete(rateLimit(m), "allowed")
|
|
}), false},
|
|
{"not allowed, limit not reached, no credits", with(read("codex_usage_pro_credits.json"), func(m map[string]any) {
|
|
delete(m, "credits")
|
|
rateLimit(m)["limit_reached"], rateLimit(m)["allowed"] = false, false
|
|
}), true},
|
|
{"Business without credits, within its overage", within("business", as), false},
|
|
{"K12 without credits, within its overage", within("k12", as), false},
|
|
{"Law without credits, within its overage", within("law", as), false},
|
|
{"Sci without credits, within its overage", within("sci", as), false},
|
|
{"FinServ without credits, within its overage", within("finserv", as), false},
|
|
{"Enterprise trial without credits, within its overage", within("enterprise_cbp_trial", as), false},
|
|
{"Enterprise view-only without credits, within its overage", within("enterprise_cbp_view_only", as), false},
|
|
{"Pro without credits, its overage open", within("pro", as), true},
|
|
// the app's reserve experiment holds these on no credits, overage
|
|
// or not, and magpie can't see whether it is on
|
|
{"Team without credits, within its overage", within("team", as), true},
|
|
{"Business at its spend cap", within("business", func(m map[string]any) {
|
|
m["spend_control"].(map[string]any)["reached"] = true
|
|
}), true},
|
|
{"Business at its owner's limit", within("business", func(m map[string]any) {
|
|
m["rate_limit_reached_type"].(map[string]any)["type"] = "workspace_owner_usage_limit_reached"
|
|
}), true},
|
|
{"Business past its overage", within("business", func(m map[string]any) {
|
|
credits(m)["overage_limit_reached"] = true
|
|
}), true},
|
|
{"Business, overage not said", within("business", func(m map[string]any) {
|
|
delete(credits(m), "overage_limit_reached")
|
|
}), true},
|
|
{"Team without credits, at its owner's limit", with(read("codex_usage_team_spend_cap.json"), func(m map[string]any) {
|
|
credits(m)["has_credits"] = false
|
|
}), true},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
signIn(t) // me@example.com, acct-1, the account Codex is on
|
|
rememberLogins(true)
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(c.body)
|
|
}))
|
|
defer fake.Close()
|
|
old := CodexBase
|
|
CodexBase = fake.URL + "/backend-api/codex"
|
|
t.Cleanup(func() { CodexBase = old })
|
|
loginUsageCache.Lock()
|
|
loginUsageCache.m = nil
|
|
loginUsageCache.Unlock()
|
|
if got := CodexUsedUp(context.Background()); got != c.want {
|
|
t.Fatalf("CodexUsedUp = %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A held reading kept for when /wham/usage fails stops saying held once
|
|
// the window that held it has started again, as its usage does: else a
|
|
// 503 after the reset kept Codex on magpie, out of ChatGPT, with room.
|
|
func TestCodexHeldKeptOnlyTillTheReset(t *testing.T) {
|
|
body := heldUsage(t)
|
|
body["rate_limit"].(map[string]any)["primary_window"].(map[string]any)["reset_at"] = time.Now().Add(time.Hour).Unix()
|
|
read, down := servedUsage(t, body)
|
|
if !read() {
|
|
t.Fatal("CodexUsedUp = false on a held reading")
|
|
}
|
|
down.Store(true)
|
|
if !read() {
|
|
t.Fatal("CodexUsedUp = false on the kept reading before its reset")
|
|
}
|
|
lastQuotas.Lock()
|
|
past := time.Now().Add(-time.Minute)
|
|
for k, e := range lastQuotas.m {
|
|
for i := range e.Q.Windows {
|
|
e.Q.Windows[i].ResetsAt = &past
|
|
}
|
|
lastQuotas.m[k] = e
|
|
}
|
|
lastQuotas.Unlock()
|
|
if read() {
|
|
t.Fatal("CodexUsedUp = true on the kept reading after its window started again")
|
|
}
|
|
}
|
|
|
|
// A hold with no window used up (the backend says not allowed, no credits,
|
|
// every window under 100%) has no reset to end it: the kept reading stays
|
|
// held through a failed read, till a fresh one says otherwise. Else a 503
|
|
// put Codex back on an account its app sends nothing for.
|
|
func TestCodexHeldWithNoWindowUpKeptTillAFreshRead(t *testing.T) {
|
|
body := heldUsage(t)
|
|
body["rate_limit"].(map[string]any)["primary_window"].(map[string]any)["used_percent"] = 40
|
|
read, down := servedUsage(t, body)
|
|
if !read() {
|
|
t.Fatal("CodexUsedUp = false on a held reading")
|
|
}
|
|
down.Store(true)
|
|
if !read() {
|
|
t.Fatal("CodexUsedUp = false on the kept held reading, no window used up")
|
|
}
|
|
}
|
|
|
|
// heldUsage is the real Pro reply with its credits spent: not allowed, no
|
|
// credits, so held.
|
|
func heldUsage(t *testing.T) map[string]any {
|
|
t.Helper()
|
|
b, err := os.ReadFile("testdata/codex_usage_pro_credits.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(b, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cr := body["credits"].(map[string]any)
|
|
cr["has_credits"], cr["balance"] = false, "0"
|
|
return body
|
|
}
|
|
|
|
// servedUsage signs in the account Codex is on and serves body as its
|
|
// /wham/usage, or a 503 once down is set. read is CodexUsedUp on a fresh
|
|
// read, which falls back on the kept reading when it fails.
|
|
func servedUsage(t *testing.T, body map[string]any) (read func() bool, down *atomic.Bool) {
|
|
t.Helper()
|
|
signIn(t)
|
|
rememberLogins(true)
|
|
lastQuotas.Lock()
|
|
lastQuotas.m, lastQuotas.loaded = nil, false
|
|
lastQuotas.Unlock()
|
|
down = new(atomic.Bool)
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if down.Load() {
|
|
http.Error(w, "upstream", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(body)
|
|
}))
|
|
t.Cleanup(fake.Close)
|
|
old := CodexBase
|
|
CodexBase = fake.URL + "/backend-api/codex"
|
|
t.Cleanup(func() { CodexBase = old })
|
|
read = func() bool {
|
|
loginUsageCache.Lock()
|
|
loginUsageCache.m = nil
|
|
loginUsageCache.Unlock()
|
|
return CodexUsedUp(context.Background())
|
|
}
|
|
return read, down
|
|
}
|