diff --git a/docs/CODEX_SETUP.md b/docs/CODEX_SETUP.md
index e1bbfd3b..4227af79 100644
--- a/docs/CODEX_SETUP.md
+++ b/docs/CODEX_SETUP.md
@@ -282,10 +282,31 @@ only after it actually returns usage, and is dropped again as soon as it stops
working or the token changes - so a temporary failure can never pin polling to a
dead endpoint.
+### Usage percentages and plan labels
+
+onWatch scopes usage requests to the selected profile with Codex's
+`ChatGPT-Account-Id` header. It does not send alternative account headers.
+
+A running default profile stays tied to the account it started with. Switching
+the active Codex login does not make that profile adopt the other account's
+tokens or overwrite its credentials. Save the new account as a named profile,
+or restart onWatch to use the new default login.
+
+Codex's `pro`, `prolite`, and `promax` plan values display as **Pro (More)**,
+**Pro**, and **Pro (Max)**. The server supplies the same label to every
+dashboard account view.
+
+For Pro (More), the usage API and CLI `/status` can report different percentages
+for the same reset cycle. onWatch preserves the usage API percentage and shows
+a source note on the dashboard. There is no verified conversion rule for this
+difference; matching reset times alone does not establish the allowance used
+by each source.
+
### Token security
- Keep `.env` out of version control
-- onWatch only sends the token to Codex usage endpoints
+- onWatch sends the token to Codex usage endpoints and, when enabled, the
+ quota-starter endpoint
- Usage history stays local in SQLite
---
@@ -357,9 +378,9 @@ Environment overrides (the dashboard toggles take precedence at runtime):
- `CODEX_AUTO_START_5H=true` / `CODEX_AUTO_START_7D=true` - default-on without the UI.
- `CODEX_STARTER_MODEL` - override the model used for the starter request (default
- `gpt-5.5`). ChatGPT-account Codex access supports only a small set of models
- (currently `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`); set this if the default is
- rejected.
+ `gpt-6-luna`). Model availability depends on your ChatGPT plan and workspace
+ settings; set this to a model available to your account if the default is
+ rejected. See [Codex models](https://learn.chatgpt.com/docs/models).
> Beta: the Codex Responses request shape can change upstream. If starter pings
> fail, check the logs and try a different `CODEX_STARTER_MODEL`.
diff --git a/internal/agent/codex_agent.go b/internal/agent/codex_agent.go
index efdbe2fe..0fa3c5f8 100644
--- a/internal/agent/codex_agent.go
+++ b/internal/agent/codex_agent.go
@@ -144,9 +144,10 @@ func isUnstartedCodexWindow(quotaName string, resetsAt *time.Time, now time.Time
}
// SetCodexAccountID sets the Codex account_id used for the ChatGPT-Account-ID
-// header on auto quota-starter pings.
+// header on usage requests and auto quota-starter pings.
func (a *CodexAgent) SetCodexAccountID(id string) {
a.codexAccountID = id
+ a.client.SetAccountID(id)
}
// SetAutoStartCheck wires a callback that reports, fresh per poll, whether the
diff --git a/internal/agent/codex_agent_manager.go b/internal/agent/codex_agent_manager.go
index dcae1563..78389c5a 100644
--- a/internal/agent/codex_agent_manager.go
+++ b/internal/agent/codex_agent_manager.go
@@ -474,13 +474,26 @@ func (m *CodexAgentManager) startAgentForProfile(profile CodexProfile) error {
// auth contamination between profiles (see issue #55).
profilePath := filepath.Join(m.profilesDir, profile.Name+".json")
isDefaultProfile := profile.Name == "default"
+ defaultCredentials := func() *api.CodexCredentials {
+ current := api.DetectCodexCredentials(m.logger)
+ if current == nil {
+ return nil
+ }
+ // Defaults without identity metadata can still rotate tokens, but
+ // newly present, missing, or different IDs require a new agent.
+ if strings.TrimSpace(current.AccountID) != strings.TrimSpace(profile.AccountID) ||
+ strings.TrimSpace(current.UserID) != strings.TrimSpace(profile.UserID) {
+ return nil
+ }
+ return current
+ }
agent.SetTokenRefresh(func() string {
if isDefaultProfile {
- if systemCreds := api.DetectCodexCredentials(m.logger); systemCreds != nil {
+ if systemCreds := defaultCredentials(); systemCreds != nil {
return systemCreds.AccessToken
}
- return profile.Tokens.AccessToken
+ return ""
}
// Named profiles: prefer profile file, fall back to global auth.json
@@ -515,7 +528,7 @@ func (m *CodexAgentManager) startAgentForProfile(profile CodexProfile) error {
agent.SetCredentialsRefresh(func() *api.CodexCredentials {
if isDefaultProfile {
- return api.DetectCodexCredentials(m.logger)
+ return defaultCredentials()
}
// Named profiles: prefer profile file, fall back to global auth.json
@@ -546,11 +559,12 @@ func (m *CodexAgentManager) startAgentForProfile(profile CodexProfile) error {
// Write back to whichever file the credentials came from, in its
// native format. OpenCode-sourced tokens must stay in OpenCode
// format (one-time-use refresh tokens must not be lost).
- source := api.CredentialSourceCodex
- if cur := api.DetectCodexCredentials(m.logger); cur != nil {
- source = cur.Source
+ // A login switch must not replace another identity's credentials.
+ cur := defaultCredentials()
+ if cur == nil {
+ return fmt.Errorf("active Codex credentials do not match tracked default profile")
}
- return api.WriteCredentialsBySource(source, accessToken, refreshToken, idToken, expiresIn)
+ return api.WriteCredentialsBySource(cur.Source, accessToken, refreshToken, idToken, expiresIn)
}
// Named profiles: save refreshed tokens to the profile file only
diff --git a/internal/agent/codex_agent_manager_test.go b/internal/agent/codex_agent_manager_test.go
index 0d34fc3d..eeab04dc 100644
--- a/internal/agent/codex_agent_manager_test.go
+++ b/internal/agent/codex_agent_manager_test.go
@@ -6,6 +6,8 @@ import (
"encoding/json"
"io"
"log/slog"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -13,6 +15,7 @@ import (
"testing"
"time"
+ "github.com/onllm-dev/onwatch/v2/internal/api"
"github.com/onllm-dev/onwatch/v2/internal/notify"
"github.com/onllm-dev/onwatch/v2/internal/store"
"github.com/onllm-dev/onwatch/v2/internal/testutil/testhome"
@@ -176,6 +179,63 @@ func TestCodexAgentManager_LoadAndStartProfiles(t *testing.T) {
}
}
+func TestCodexAgentManager_ProfileUsageRequests_KeepAccountIDsIsolated(t *testing.T) {
+ fx := newCodexManagerFixture(t)
+ type usageRequest struct {
+ authorization string
+ accountID string
+ }
+ requests := make(chan usageRequest, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests <- usageRequest{
+ authorization: r.Header.Get("Authorization"),
+ accountID: r.Header.Get("ChatGPT-Account-Id"),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":25,"reset_at":1766000000,"limit_window_seconds":18000}}}`)
+ }))
+ t.Cleanup(server.Close)
+
+ profiles := []CodexProfile{
+ {Name: "work", AccountID: "acct-work", SavedAt: time.Now().UTC()},
+ {Name: "personal", AccountID: "acct-personal", SavedAt: time.Now().UTC()},
+ }
+ for i := range profiles {
+ profiles[i].Tokens.AccessToken = profiles[i].Name + "-token"
+ fx.writeProfile(t, profiles[i])
+ }
+ if err := fx.manager.loadAndStartProfiles(); err != nil {
+ t.Fatalf("loadAndStartProfiles: %v", err)
+ }
+ for _, profile := range profiles {
+ instance := fx.instance(profile.Name)
+ if instance == nil {
+ t.Fatalf("missing agent for profile %q", profile.Name)
+ }
+ // Background polling is disabled by the fixture. Redirect each real
+ // manager-created client before exercising its usage request.
+ api.WithCodexBaseURL(server.URL)(instance.Agent.client)
+ }
+
+ // Return to the first profile after the second to detect shared account
+ // metadata as well as missing account headers.
+ for _, profile := range []CodexProfile{profiles[0], profiles[1], profiles[0]} {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ _, err := fx.instance(profile.Name).Agent.client.FetchUsage(ctx)
+ cancel()
+ if err != nil {
+ t.Fatalf("FetchUsage(%s): %v", profile.Name, err)
+ }
+ request := <-requests
+ if request.accountID != profile.AccountID {
+ t.Errorf("profile %s account header = %q, want %q", profile.Name, request.accountID, profile.AccountID)
+ }
+ if request.authorization != "Bearer "+profile.Tokens.AccessToken {
+ t.Errorf("profile %s authorization did not use its own token", profile.Name)
+ }
+ }
+}
+
func TestCodexAgentManager_LoadAndStartProfile_DerivesNameAndSkipsDuplicate(t *testing.T) {
fx := newCodexManagerFixture(t)
@@ -293,6 +353,129 @@ func TestCodexAgentManager_StartDefaultAgent(t *testing.T) {
}
}
+func TestCodexAgentManager_DefaultProfileRejectsChangedIdentity(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ trackedAccountID string
+ trackedUserID string
+ accountID string
+ userID string
+ }{
+ {name: "different account", trackedAccountID: "acct-default", trackedUserID: "user-default", accountID: "acct-other", userID: "user-other"},
+ {name: "different workspace user", trackedAccountID: "acct-default", trackedUserID: "user-default", accountID: "acct-default", userID: "user-other"},
+ {name: "metadata-free default with known foreign account", accountID: "acct-other", userID: "user-other"},
+ {name: "missing account with different user", trackedUserID: "user-default", userID: "user-other"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ fx := newCodexManagerFixture(t)
+ authDir := filepath.Join(testHomeDir(t), ".codex")
+ if err := os.MkdirAll(authDir, 0o700); err != nil {
+ t.Fatalf("mkdir .codex: %v", err)
+ }
+ authPath := filepath.Join(authDir, "auth.json")
+ originalIDToken := makeCodexIDToken(t, time.Now().Add(24*time.Hour), test.trackedAccountID, test.trackedUserID)
+ originalAuth := `{"tokens":{"access_token":"original-token","refresh_token":"original-refresh","id_token":"` + originalIDToken + `","account_id":"` + test.trackedAccountID + `"}}`
+ if err := os.WriteFile(authPath, []byte(originalAuth), 0o600); err != nil {
+ t.Fatalf("write original auth.json: %v", err)
+ }
+ if err := fx.manager.startDefaultAgent(); err != nil {
+ t.Fatalf("startDefaultAgent: %v", err)
+ }
+ instance := fx.instance("default")
+ if instance == nil {
+ t.Fatal("missing default agent")
+ }
+
+ rotatedAuth := strings.ReplaceAll(originalAuth, "original-token", "rotated-token")
+ if err := os.WriteFile(authPath, []byte(rotatedAuth), 0o600); err != nil {
+ t.Fatalf("write rotated auth.json: %v", err)
+ }
+ if token := instance.Agent.tokenRefresh(); token != "rotated-token" {
+ t.Error("default agent rejected its own rotated access token")
+ } else {
+ instance.Agent.client.SetToken(token)
+ }
+
+ foreignIDToken := makeCodexIDToken(t, time.Now().Add(48*time.Hour), test.accountID, test.userID)
+ foreignAuth := `{"tokens":{"access_token":"foreign-token","refresh_token":"foreign-refresh","id_token":"` + foreignIDToken + `","account_id":"` + test.accountID + `"}}`
+ if err := os.WriteFile(authPath, []byte(foreignAuth), 0o600); err != nil {
+ t.Fatalf("write switched auth.json: %v", err)
+ }
+ if instance.Agent.tokenRefresh() != "" {
+ t.Error("foreign credentials should leave the current access token unchanged")
+ }
+ if instance.Agent.credsRefresh() != nil {
+ t.Error("default agent accepted another identity's refresh credentials")
+ }
+ if err := instance.Agent.tokenSave("refreshed-token", "refreshed-refresh", originalIDToken, 604800); err == nil {
+ t.Error("default agent saved refreshed credentials over another identity")
+ }
+ data, err := os.ReadFile(authPath)
+ if err != nil {
+ t.Fatalf("read auth.json: %v", err)
+ }
+ if string(data) != foreignAuth {
+ t.Error("another identity's auth.json was modified")
+ }
+ })
+ }
+}
+
+func TestCodexAgentManager_DefaultProfileAcceptsOwnTokenRotation(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ accountID string
+ userID string
+ }{
+ {name: "known identity", accountID: "acct-default", userID: "user-default"},
+ {name: "no identity metadata"},
+ {name: "missing account with known user", userID: "user-default"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ fx := newCodexManagerFixture(t)
+ authDir := filepath.Join(testHomeDir(t), ".codex")
+ if err := os.MkdirAll(authDir, 0o700); err != nil {
+ t.Fatalf("mkdir .codex: %v", err)
+ }
+ authPath := filepath.Join(authDir, "auth.json")
+ idToken := makeCodexIDToken(t, time.Now().Add(24*time.Hour), test.accountID, test.userID)
+ originalAuth := `{"tokens":{"access_token":"original-token","refresh_token":"original-refresh","id_token":"` + idToken + `","account_id":"` + test.accountID + `"}}`
+ if err := os.WriteFile(authPath, []byte(originalAuth), 0o600); err != nil {
+ t.Fatalf("write original auth.json: %v", err)
+ }
+ if err := fx.manager.startDefaultAgent(); err != nil {
+ t.Fatalf("startDefaultAgent: %v", err)
+ }
+ instance := fx.instance("default")
+ if instance == nil {
+ t.Fatal("missing default agent")
+ }
+
+ rotatedAuth := `{"tokens":{"access_token":"rotated-token","refresh_token":"rotated-refresh","id_token":"` + idToken + `","account_id":"` + test.accountID + `"}}`
+ if err := os.WriteFile(authPath, []byte(rotatedAuth), 0o600); err != nil {
+ t.Fatalf("write rotated auth.json: %v", err)
+ }
+ if instance.Agent.tokenRefresh() != "rotated-token" {
+ t.Error("default agent rejected its own rotated access token")
+ }
+ creds := instance.Agent.credsRefresh()
+ if creds == nil || creds.AccessToken != "rotated-token" || creds.RefreshToken != "rotated-refresh" {
+ t.Error("default agent rejected its own rotated refresh credentials")
+ }
+ if err := instance.Agent.tokenSave("refreshed-token", "refreshed-refresh", idToken, 604800); err != nil {
+ t.Fatalf("tokenSave: %v", err)
+ }
+ updated := api.DetectCodexCredentials(fx.logger)
+ if updated == nil || updated.AccessToken != "refreshed-token" || updated.RefreshToken != "refreshed-refresh" {
+ t.Error("default agent did not persist its own refreshed credentials")
+ }
+ if updated == nil || updated.AccountID != test.accountID || updated.UserID != test.userID {
+ t.Error("saving refreshed credentials changed the default profile identity")
+ }
+ })
+ }
+}
+
func TestCodexAgentManager_ErrorAndFallbackPaths(t *testing.T) {
fx := newCodexManagerFixture(t)
diff --git a/internal/api/codex_client.go b/internal/api/codex_client.go
index b8ee0b7d..5aa5a1ad 100644
--- a/internal/api/codex_client.go
+++ b/internal/api/codex_client.go
@@ -214,8 +214,8 @@ func (c *CodexClient) doUsageRequest(ctx context.Context, usageURL string) (*htt
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "onwatch/1.0")
if accountID := c.getAccountID(); accountID != "" {
- req.Header.Set("X-Account-Id", accountID)
- req.Header.Set("ChatClaude-Account-Id", accountID)
+ // Codex CLI's usage reader scopes account rate limits with this header.
+ req.Header.Set("ChatGPT-Account-Id", accountID)
}
resp, err := c.httpClient.Do(req)
diff --git a/internal/api/codex_client_coverage_test.go b/internal/api/codex_client_coverage_test.go
index fa9c2f5f..71b0c6b4 100644
--- a/internal/api/codex_client_coverage_test.go
+++ b/internal/api/codex_client_coverage_test.go
@@ -2,9 +2,10 @@ package api
import (
"context"
- "fmt"
+ "io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
)
@@ -113,25 +114,65 @@ func TestCodexClient_FetchUsage_FallbacksTo404BothPaths(t *testing.T) {
}
func TestCodexClient_FetchUsage_AccountIDHeaders(t *testing.T) {
- var gotXAccount, gotChatClaudeAccount string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotXAccount = r.Header.Get("X-Account-Id")
- gotChatClaudeAccount = r.Header.Get("ChatClaude-Account-Id")
- w.Header().Set("Content-Type", "application/json")
- fmt.Fprint(w, `{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":10,"reset_at":1766000000,"limit_window_seconds":18000}}}`)
- }))
- defer server.Close()
-
- client := NewCodexClient("token", discardLoggerClient(), WithCodexBaseURL(server.URL))
- client.SetAccountID("acct_test")
- _, err := client.FetchUsage(context.Background())
- if err != nil {
- t.Fatalf("FetchUsage: %v", err)
- }
- if gotXAccount != "acct_test" {
- t.Errorf("X-Account-Id = %q, want acct_test", gotXAccount)
- }
- if gotChatClaudeAccount != "acct_test" {
- t.Errorf("ChatClaude-Account-Id = %q, want acct_test", gotChatClaudeAccount)
+ for _, fallback := range []bool{false, true} {
+ name := "primary"
+ if fallback {
+ name = "fallback"
+ }
+ t.Run(name, func(t *testing.T) {
+ client := NewCodexClient("token", discardLoggerClient(), WithCodexBaseURL("https://example.invalid/backend-api/wham/usage"))
+ var requests []*http.Request
+ client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ requests = append(requests, req)
+ status := http.StatusOK
+ if fallback && req.URL.Path == "/backend-api/wham/usage" {
+ status = http.StatusNotFound
+ }
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(strings.NewReader(`{"plan_type":"pro"}`)),
+ Header: make(http.Header),
+ }, nil
+ })
+
+ for _, accountID := range []string{"", "acct_test", ""} {
+ client.SetAccountID(accountID)
+ // Re-probe both paths for every account state while retaining the
+ // same client so clearing the ID also detects stale headers.
+ client.clearFallbackBaseURL()
+ requests = nil
+ if _, err := client.FetchUsage(context.Background()); err != nil {
+ t.Fatalf("FetchUsage: %v", err)
+ }
+ paths := []string{"/backend-api/wham/usage"}
+ if fallback {
+ paths = append(paths, "/api/codex/usage")
+ }
+ if len(requests) != len(paths) {
+ t.Fatalf("requests = %d, want %d", len(requests), len(paths))
+ }
+ for i, req := range requests {
+ if req.URL.Path != paths[i] {
+ t.Errorf("request path = %q, want %q", req.URL.Path, paths[i])
+ }
+ if req.Header.Get("Authorization") != "Bearer token" {
+ t.Error("Authorization did not preserve bearer authentication")
+ }
+ if got := req.Header.Get("ChatGPT-Account-Id"); got != accountID {
+ t.Errorf("%s ChatGPT-Account-Id = %q, want %q", req.URL.Path, got, accountID)
+ }
+ if accountID == "" {
+ if _, exists := req.Header[http.CanonicalHeaderKey("ChatGPT-Account-Id")]; exists {
+ t.Error("ChatGPT-Account-Id should be absent without an account ID")
+ }
+ }
+ for _, obsolete := range []string{"X-Account-Id", "ChatClaude-Account-Id"} {
+ if _, exists := req.Header[http.CanonicalHeaderKey(obsolete)]; exists {
+ t.Errorf("%s obsolete %s header should be absent", req.URL.Path, obsolete)
+ }
+ }
+ }
+ }
+ })
}
}
diff --git a/internal/api/codex_client_test.go b/internal/api/codex_client_test.go
index ca90553b..38249175 100644
--- a/internal/api/codex_client_test.go
+++ b/internal/api/codex_client_test.go
@@ -126,14 +126,14 @@ func TestCodexClient_FetchUsage_ContextCancelled(t *testing.T) {
}
func TestCodexClient_FetchUsage_FallbacksToWhamOnCodexPath404(t *testing.T) {
- var gotPath atomic.Value
- var gotChatClaudeAccount atomic.Value
- var gotXAccount atomic.Value
+ type usageRequest struct {
+ path string
+ headers http.Header
+ }
+ requests := make(chan usageRequest, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotPath.Store(r.URL.Path)
- gotChatClaudeAccount.Store(r.Header.Get("ChatClaude-Account-Id"))
- gotXAccount.Store(r.Header.Get("X-Account-Id"))
+ requests <- usageRequest{path: r.URL.Path, headers: r.Header.Clone()}
switch r.URL.Path {
case "/api/codex/usage":
w.WriteHeader(http.StatusNotFound)
@@ -160,17 +160,22 @@ func TestCodexClient_FetchUsage_FallbacksToWhamOnCodexPath404(t *testing.T) {
t.Fatalf("PlanType = %q, want pro", resp.PlanType)
}
- path, _ := gotPath.Load().(string)
- if path != "/backend-api/wham/usage" {
- t.Fatalf("last request path = %q, want /backend-api/wham/usage", path)
- }
- chatClaudeAccount, _ := gotChatClaudeAccount.Load().(string)
- if chatClaudeAccount != "acct_123" {
- t.Fatalf("ChatClaude-Account-Id = %q, want acct_123", chatClaudeAccount)
- }
- xAccount, _ := gotXAccount.Load().(string)
- if xAccount != "acct_123" {
- t.Fatalf("X-Account-Id = %q, want acct_123", xAccount)
+ for _, path := range []string{"/api/codex/usage", "/backend-api/wham/usage"} {
+ request := <-requests
+ if request.path != path {
+ t.Errorf("request path = %q, want %q", request.path, path)
+ }
+ if accountID := request.headers.Get("ChatGPT-Account-Id"); accountID != "acct_123" {
+ t.Errorf("%s ChatGPT-Account-Id = %q, want acct_123", path, accountID)
+ }
+ if request.headers.Get("Authorization") != "Bearer oauth_token" {
+ t.Errorf("%s Authorization did not preserve bearer authentication", path)
+ }
+ for _, obsolete := range []string{"X-Account-Id", "ChatClaude-Account-Id"} {
+ if _, exists := request.headers[http.CanonicalHeaderKey(obsolete)]; exists {
+ t.Errorf("%s obsolete %s header should be absent", path, obsolete)
+ }
+ }
}
}
diff --git a/internal/api/codex_starter.go b/internal/api/codex_starter.go
index caeb8da0..bba00fa3 100644
--- a/internal/api/codex_starter.go
+++ b/internal/api/codex_starter.go
@@ -17,10 +17,10 @@ import (
const codexResponsesURL = "https://chatgpt.com/backend-api/codex/responses"
// defaultCodexStarterModel is the model used for the auto quota-starter ping.
-// ChatGPT-account Codex access only supports a small set of models (currently
-// gpt-5.5, gpt-5.4, gpt-5.4-mini); codex-specific slugs are rejected. The model
-// is overridable via CODEX_STARTER_MODEL without a rebuild (this feature is Beta).
-const defaultCodexStarterModel = "gpt-5.5"
+// GPT-6 Luna keeps this fixed-response request lightweight. Model availability
+// depends on the ChatGPT plan; override CODEX_STARTER_MODEL without a rebuild
+// when needed (this feature is Beta).
+const defaultCodexStarterModel = "gpt-6-luna"
// CodexStarterModel returns the model id used for the auto quota-starter ping,
// allowing a CODEX_STARTER_MODEL env override for the (Beta) feature.
diff --git a/internal/api/codex_starter_test.go b/internal/api/codex_starter_test.go
index 3e8bb7e5..998746b5 100644
--- a/internal/api/codex_starter_test.go
+++ b/internal/api/codex_starter_test.go
@@ -14,6 +14,7 @@ import (
)
func TestBuildCodexStarterRequest(t *testing.T) {
+ t.Setenv("CODEX_STARTER_MODEL", "")
req, err := buildCodexStarterRequest(context.Background(), codexResponsesURL, "tok_abc", "acct_123")
if err != nil {
t.Fatalf("buildCodexStarterRequest error: %v", err)
@@ -31,6 +32,11 @@ func TestBuildCodexStarterRequest(t *testing.T) {
if got := req.Header.Get("ChatGPT-Account-ID"); got != "acct_123" {
t.Errorf("ChatGPT-Account-ID = %q, want acct_123", got)
}
+ for _, obsolete := range []string{"X-Account-Id", "ChatClaude-Account-Id"} {
+ if _, exists := req.Header[http.CanonicalHeaderKey(obsolete)]; exists {
+ t.Errorf("obsolete %s header should be absent", obsolete)
+ }
+ }
if got := req.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
@@ -52,8 +58,8 @@ func TestBuildCodexStarterRequest(t *testing.T) {
if _, ok := parsed["instructions"].(string); !ok {
t.Errorf("instructions missing or not a string")
}
- if parsed["model"] != defaultCodexStarterModel {
- t.Errorf("model = %v, want %q", parsed["model"], defaultCodexStarterModel)
+ if parsed["model"] != "gpt-6-luna" {
+ t.Errorf("model = %v, want gpt-6-luna", parsed["model"])
}
}
@@ -62,8 +68,10 @@ func TestBuildCodexStarterRequest_NoAccountIDOmitsHeader(t *testing.T) {
if err != nil {
t.Fatalf("error: %v", err)
}
- if _, ok := req.Header["Chatgpt-Account-Id"]; ok {
- t.Errorf("ChatGPT-Account-ID header should be absent when account id empty")
+ for _, header := range []string{"ChatGPT-Account-Id", "X-Account-Id", "ChatClaude-Account-Id"} {
+ if _, exists := req.Header[http.CanonicalHeaderKey(header)]; exists {
+ t.Errorf("%s header should be absent when account ID is empty", header)
+ }
}
}
@@ -79,24 +87,40 @@ func TestCodexStarterModel_EnvOverride(t *testing.T) {
}
func TestSendStarterPing_Success(t *testing.T) {
- var gotAuth, gotAccount string
+ headers := make(chan http.Header, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotAuth = r.Header.Get("Authorization")
- gotAccount = r.Header.Get("ChatGPT-Account-ID")
+ headers <- r.Header.Clone()
+ w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "data: {}\n\n")
}))
defer srv.Close()
client := NewCodexClient("tok_live", nil, WithCodexStarterURL(srv.URL))
- if err := client.SendStarterPing(context.Background(), "acct_xyz"); err != nil {
- t.Fatalf("SendStarterPing error: %v", err)
- }
- if gotAuth != "Bearer tok_live" {
- t.Errorf("server saw Authorization = %q", gotAuth)
- }
- if gotAccount != "acct_xyz" {
- t.Errorf("server saw ChatGPT-Account-ID = %q", gotAccount)
+ for _, accountID := range []string{"acct_xyz", ""} {
+ if err := client.SendStarterPing(context.Background(), accountID); err != nil {
+ t.Fatalf("SendStarterPing error: %v", err)
+ }
+ got := <-headers
+ if got.Get("Authorization") != "Bearer tok_live" {
+ t.Error("Authorization did not preserve bearer authentication")
+ }
+ if got.Get("Accept") != "text/event-stream" {
+ t.Error("starter request did not accept an event stream")
+ }
+ if gotAccount := got.Get("ChatGPT-Account-Id"); gotAccount != accountID {
+ t.Errorf("server saw ChatGPT-Account-Id = %q, want %q", gotAccount, accountID)
+ }
+ if accountID == "" {
+ if _, exists := got[http.CanonicalHeaderKey("ChatGPT-Account-Id")]; exists {
+ t.Error("ChatGPT-Account-Id should be absent without an account ID")
+ }
+ }
+ for _, obsolete := range []string{"X-Account-Id", "ChatClaude-Account-Id"} {
+ if _, exists := got[http.CanonicalHeaderKey(obsolete)]; exists {
+ t.Errorf("obsolete %s header should be absent", obsolete)
+ }
+ }
}
}
diff --git a/internal/api/extra_coverage_test.go b/internal/api/extra_coverage_test.go
index f72e88c6..74670991 100644
--- a/internal/api/extra_coverage_test.go
+++ b/internal/api/extra_coverage_test.go
@@ -2911,17 +2911,17 @@ func TestAntigravityClient_FetchQuotas_ContextCancelledMidRequest(t *testing.T)
// ---------------------------------------------------------------------------
func TestCodexClient_AccountIDHeader(t *testing.T) {
- var gotAccountID string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotAccountID = r.Header.Get("X-Account-Id")
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- w.Write([]byte(`{"plan_type":"pro","rate_limit":{}}`))
- }))
- defer server.Close()
-
logger := discardLoggerCredentials()
- client := NewCodexClient("test-token", logger, WithCodexBaseURL(server.URL))
+ client := NewCodexClient("test-token", logger, WithCodexBaseURL("https://example.invalid"))
+ var headers http.Header
+ client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ headers = req.Header.Clone()
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"plan_type":"pro","rate_limit":{}}`)),
+ Header: make(http.Header),
+ }, nil
+ })
client.SetAccountID("test-account-123")
_, err := client.FetchUsage(context.Background())
@@ -2929,8 +2929,13 @@ func TestCodexClient_AccountIDHeader(t *testing.T) {
t.Fatalf("FetchUsage failed: %v", err)
}
- if gotAccountID != "test-account-123" {
- t.Errorf("X-Account-Id = %q, want 'test-account-123'", gotAccountID)
+ if gotAccountID := headers.Get("ChatGPT-Account-Id"); gotAccountID != "test-account-123" {
+ t.Errorf("ChatGPT-Account-Id = %q, want test-account-123", gotAccountID)
+ }
+ for _, obsolete := range []string{"X-Account-Id", "ChatClaude-Account-Id"} {
+ if _, exists := headers[http.CanonicalHeaderKey(obsolete)]; exists {
+ t.Errorf("obsolete %s header should be absent", obsolete)
+ }
}
}
diff --git a/internal/update/update_more_coverage_test.go b/internal/update/update_more_coverage_test.go
index c262c516..9a85a7b8 100644
--- a/internal/update/update_more_coverage_test.go
+++ b/internal/update/update_more_coverage_test.go
@@ -177,16 +177,24 @@ func TestRestart_SpawnsAppliedBinary(t *testing.T) {
t.Fatalf("Restart() = %v", err)
}
+ // The spawned shell creates the marker with O_TRUNC before printf writes, so
+ // a poll can catch a transient 0-byte file. Only a non-empty value that
+ // disagrees with the expected marker is a real failure; an empty read just
+ // means the write has not landed yet.
+ var last string
for i := 0; i < 40; i++ {
if data, err := os.ReadFile(markerPath); err == nil {
- if string(data) != "spawned" {
- t.Fatalf("spawn marker = %q, want spawned", string(data))
+ last = string(data)
+ if last == "spawned" {
+ return
+ }
+ if last != "" {
+ t.Fatalf("spawn marker = %q, want spawned", last)
}
- return
}
time.Sleep(50 * time.Millisecond)
}
- t.Fatal("spawned marker was not written by restarted process")
+ t.Fatalf("spawned marker was not written by restarted process (last read %q)", last)
}
func TestRestart_SystemdBranchUsesSystemctl(t *testing.T) {
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index 533cd540..a0dbdeac 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -8886,6 +8886,10 @@ func (h *Handler) buildCodexCurrent(accountID int64) map[string]interface{} {
response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339)
if latest.PlanType != "" {
response["planType"] = latest.PlanType
+ response["planLabel"] = codexPlanLabel(latest.PlanType)
+ }
+ if note := codexUsageSourceNote(latest.PlanType); note != "" {
+ response["usageSourceNote"] = note
}
if latest.CreditsBalance != nil {
response["creditsBalance"] = *latest.CreditsBalance
@@ -11249,6 +11253,14 @@ func codexPlanLabel(plan string) string {
if plan == "" {
return ""
}
+ switch strings.ToLower(strings.TrimSpace(plan)) {
+ case "pro":
+ return "Pro (More)"
+ case "prolite":
+ return "Pro"
+ case "promax":
+ return "Pro (Max)"
+ }
plan = strings.ReplaceAll(plan, "_", " ")
parts := strings.Fields(plan)
for i := range parts {
@@ -11260,6 +11272,15 @@ func codexPlanLabel(plan string) string {
return strings.Join(parts, " ")
}
+const codexProUsageSourceNote = "Pro (More) weekly usage comes from Codex's usage API. Codex CLI /status may show a different effective percentage; onWatch displays the API-reported value unchanged."
+
+func codexUsageSourceNote(planType string) string {
+ if strings.EqualFold(strings.TrimSpace(planType), "pro") {
+ return codexProUsageSourceNote
+ }
+ return ""
+}
+
func codexQuotaInsightLabel(name string) string {
switch name {
case "five_hour":
diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go
index 1150d9e8..f5aa960d 100644
--- a/internal/web/handlers_test.go
+++ b/internal/web/handlers_test.go
@@ -10572,6 +10572,11 @@ func TestCodexPlanLabel(t *testing.T) {
tests := []struct {
input, expected string
}{
+ {"pro", "Pro (More)"},
+ {"prolite", "Pro"},
+ {"promax", "Pro (Max)"},
+ {"self_serve_business_prolite", "Self Serve Business Prolite"},
+ {" PRO ", "Pro (More)"},
{"pro_plan", "Pro Plan"},
{"free_tier", "Free Tier"},
{"", ""},
@@ -10586,6 +10591,40 @@ func TestCodexPlanLabel(t *testing.T) {
}
}
+func TestBuildCodexCurrent_ProUsageNoteKeepsAPIReportedUtilization(t *testing.T) {
+ t.Parallel()
+ s, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer s.Close()
+
+ if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{
+ CapturedAt: time.Now().UTC(),
+ PlanType: "pro",
+ Quotas: []api.CodexQuota{{Name: "seven_day", Utilization: 87}},
+ }); err != nil {
+ t.Fatalf("InsertCodexSnapshot: %v", err)
+ }
+
+ h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex())
+ response := h.buildCodexCurrent(DefaultCodexAccountID)
+ if got := response["planLabel"]; got != "Pro (More)" {
+ t.Fatalf("planLabel = %v, want Pro (More)", got)
+ }
+ if got := response["usageSourceNote"]; got != codexProUsageSourceNote {
+ t.Fatalf("usageSourceNote = %v, want %q", got, codexProUsageSourceNote)
+ }
+
+ quotas, ok := response["quotas"].([]map[string]interface{})
+ if !ok || len(quotas) != 1 {
+ t.Fatalf("quotas = %#v, want one quota", response["quotas"])
+ }
+ if got := quotas[0]["utilization"]; got != float64(87) {
+ t.Fatalf("utilization = %v, want API-reported 87", got)
+ }
+}
+
// ═══════════════════════════════════════════════════════════════════
// ── buildInsight helper with all severity levels ──
// ═══════════════════════════════════════════════════════════════════
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
index 0b7bbb49..c27f5551 100644
--- a/internal/web/static/app.js
+++ b/internal/web/static/app.js
@@ -2823,16 +2823,17 @@ function syncCodexAutoStartBadges() {
});
}
-function renderCodexQuotaCards(quotas, containerId, planType) {
+function renderCodexQuotaCards(quotas, containerId, planType, usageSourceNote) {
const container = document.getElementById(containerId);
if (!container) return;
const visibleQuotas = filterCodexQuotasForPlan(quotas, planType);
+ const sourceNoteHTML = codexUsageSourceNoteHTML(usageSourceNote);
if (visibleQuotas.length === 0) {
- container.innerHTML = '
No Codex quota data available yet.
';
+ container.innerHTML = `${sourceNoteHTML}No Codex quota data available yet.
`;
return;
}
- container.innerHTML = visibleQuotas.map((q, i) => {
+ container.innerHTML = sourceNoteHTML + visibleQuotas.map((q, i) => {
const icon = anthropicQuotaIcons[q.name] || '';
const displayName = q.displayName || codexDisplayNames[q.name] || q.name;
const cardPercent = q.cardPercent != null ? q.cardPercent : (q.utilization || 0);
@@ -2887,24 +2888,31 @@ function renderCodexQuotaCards(quotas, containerId, planType) {
});
}
-function formatCodexPlan(planType) {
+function formatCodexPlan(planType, planLabel) {
+ if (typeof planLabel === 'string' && planLabel.trim()) return planLabel.trim();
const normalized = normalizeCodexPlanType(planType);
if (!normalized) return 'Unknown Plan';
- return normalized.charAt(0).toUpperCase() + normalized.slice(1);
+ return toTitleCase(normalized);
+}
+
+function codexUsageSourceNoteHTML(note) {
+ if (!note) return '';
+ return `${escapeHTML(note)}
`;
}
// Render Codex cards for a specific account (used in "both" view with multiple accounts)
-function renderCodexQuotaCardsForAccount(quotas, container, accountName, planType, accountId) {
+function renderCodexQuotaCardsForAccount(quotas, container, accountName, planType, accountId, usageSourceNote, planLabel) {
const visibleQuotas = filterCodexQuotasForPlan(quotas, planType);
const safeAccountId = String(accountId || accountName || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
const header = document.createElement('div');
header.className = 'codex-account-header';
header.innerHTML = `
- ${accountName}
- ${formatCodexPlan(planType)}
+ ${escapeHTML(accountName)}
+ ${escapeHTML(formatCodexPlan(planType, planLabel))}
`;
container.appendChild(header);
+ container.insertAdjacentHTML('beforeend', codexUsageSourceNoteHTML(usageSourceNote));
if (visibleQuotas.length === 0) {
const empty = document.createElement('p');
@@ -2926,7 +2934,7 @@ function renderCodexQuotaCardsForAccount(quotas, container, accountName, planTyp
const statusCfg = statusConfig[status] || statusConfig.healthy;
const cardKey = `codex-${safeAccountId}-${q.name}`;
- return `
+ return `
+ ${provider === 'codex' ? codexUsageSourceNoteHTML(account.usageSourceNote) : ''}
${quotaHTML}
View details →
`;
@@ -6855,8 +6867,9 @@ function buildAllProviderEntries() {
provider: 'codex',
cardKey,
title: `Codex - Account: ${accountName}`,
- badge: toTitleCase(account.planType || ''),
+ badge: account.planLabel || account.planType ? formatCodexPlan(account.planType, account.planLabel) : '',
planType: account.planType || '',
+ usageSourceNote: account.usageSourceNote || '',
quotas: normalizeBothQuotas('codex', account),
insights: insightPayload,
historyRows: Array.isArray(historyPayload?.history)
@@ -7600,6 +7613,7 @@ function renderAllProvidersView() {
return `
${cardHeader}
+ ${entry.provider === 'codex' ? codexUsageSourceNoteHTML(entry.usageSourceNote) : ''}
${renderProviderKPIHTML(entry.quotas, entry.provider)}
${entry.provider === "mistral" && entry.showBilling ? `
Pay-as-you-go: ${escapeHTML(mistralMoney(entry.billing?.amount,entry.billing?.currency))}
` : ""}
${(() => {
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index ea2c632c..bf8539f4 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -2440,7 +2440,7 @@ button, input, select { font-family: inherit; }
@media (min-width: 769px) and (max-width: 1023px) {
.quota-grid { grid-template-columns: repeat(2, 1fr); }
#quota-grid-gemini { grid-template-columns: repeat(3, 1fr); }
- .quota-card:nth-child(3) { grid-column: 1 / -1; }
+ .quota-card:nth-of-type(3) { grid-column: 1 / -1; }
.table-controls { flex-wrap: wrap; }
.both-view { grid-template-columns: 1fr; }
.chart-container.both-charts { grid-template-columns: 1fr; }
@@ -3722,6 +3722,21 @@ select.settings-input {
gap: 8px;
}
+.codex-usage-source-note {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+#quota-grid-codex > .codex-usage-source-note {
+ grid-column: 1 / -1;
+}
+
+.codex-account-section > .codex-usage-source-note {
+ margin: -2px 0 10px;
+}
+
.codex-account-name {
font-size: 13px;
font-weight: 600;
diff --git a/tests/e2e/tests/test_codex_usage.py b/tests/e2e/tests/test_codex_usage.py
new file mode 100644
index 00000000..d4209636
--- /dev/null
+++ b/tests/e2e/tests/test_codex_usage.py
@@ -0,0 +1,173 @@
+"""Codex usage labels, source disclosure, and quota card layout regressions."""
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import Page, expect
+
+STATIC_DIR = Path(__file__).resolve().parents[3] / "internal" / "web" / "static"
+SOURCE_NOTE = "Pro (More) weekly usage comes from Codex's usage API."
+
+
+@pytest.fixture
+def codex_page(page: Page) -> Page:
+ # Load the real renderers after DOMContentLoaded, without dashboard polling.
+ url = "http://onwatch.test/codex-usage-fixture"
+ page.route(url, lambda route: route.fulfill(
+ content_type="text/html",
+ body='
'
+ ''
+ ''
+ '',
+ ))
+ page.goto(url)
+ page.add_style_tag(path=str(STATIC_DIR / "style.css"))
+ page.add_script_tag(path=str(STATIC_DIR / "app.js"))
+ return page
+
+
+def _account(account_id: int = 1, plan: str = "pro", note: str = SOURCE_NOTE) -> dict:
+ return {
+ "accountId": account_id,
+ "accountName": f"Account {account_id}",
+ "planType": plan,
+ "planLabel": {"pro": "Pro (More)", "prolite": "Pro", "promax": "Pro (Max)"}.get(plan, ""),
+ "usageSourceNote": note,
+ "quotas": [
+ {"name": "five_hour", "utilization": 10},
+ {"name": "seven_day", "utilization": 87},
+ {"name": "code_review", "utilization": 5},
+ ],
+ }
+
+
+@pytest.mark.parametrize("plan,label", [
+ ("pro", "Pro (More)"), ("prolite", "Pro"), ("promax", "Pro (Max)"),
+])
+def test_single_codex_account_in_all_view(codex_page: Page, plan: str, label: str) -> None:
+ account = _account(plan=plan, note=SOURCE_NOTE if plan == "pro" else "")
+ codex_page.evaluate("""account => {
+ State.allProvidersCurrent = {codexAccounts: [account]};
+ renderAllProvidersView();
+ }""", account)
+ card = codex_page.locator('.provider-card[data-provider="codex"]')
+ expect(card.locator(".provider-card-badge")).to_have_text(label)
+ expect(card.locator(".codex-usage-source-note")).to_have_count(1 if plan == "pro" else 0)
+ if plan == "pro":
+ expect(card.locator(".codex-usage-source-note")).to_have_text(SOURCE_NOTE)
+ expect(card).to_contain_text("87.0%")
+
+
+def test_codex_source_note_is_escaped_in_all_view(codex_page: Page) -> None:
+ note = 'API
'
+ codex_page.evaluate("""account => {
+ State.allProvidersCurrent = {codex: account};
+ renderAllProvidersView();
+ }""", _account(note=note))
+ expect(codex_page.locator(".codex-usage-source-note")).to_have_text(note)
+ assert codex_page.evaluate("window.noteInjected") is None
+
+
+def test_multi_account_codex_notes_stay_with_their_account(codex_page: Page) -> None:
+ codex_page.evaluate("""accounts => {
+ State.allProvidersCurrent = {codexAccounts: accounts};
+ renderAllProvidersView();
+ }""", [_account(), _account(2, "plus", "")])
+ pro = codex_page.locator('.account-overview-card[data-account-id="1"]')
+ plus = codex_page.locator('.account-overview-card[data-account-id="2"]')
+ expect(pro.locator(".account-overview-badge")).to_have_text("Pro (More)")
+ expect(pro.locator(".codex-usage-source-note")).to_have_text(SOURCE_NOTE)
+ expect(plus.locator(".codex-usage-source-note")).to_have_count(0)
+
+
+def test_codex_plan_label_comes_from_server_in_all_account_views(codex_page: Page) -> None:
+ account = _account()
+ account["planLabel"] = 'Server plan '
+ codex_page.evaluate("""account => {
+ State.allProvidersCurrent = {codexAccounts: [account]};
+ renderAllProvidersView();
+ document.getElementById('quota-grid-codex').innerHTML = accountOverviewCardHTML('codex', account, 0);
+ const legacy = document.createElement('div');
+ legacy.id = 'codex-accounts-container-both';
+ document.body.append(legacy);
+ renderCodexAccountSections([account]);
+ }""", account)
+ expect(codex_page.locator(".provider-card-badge")).to_have_text(account["planLabel"])
+ expect(codex_page.locator(".account-overview-badge")).to_have_text(account["planLabel"])
+ expect(codex_page.locator(".codex-account-plan")).to_have_text(account["planLabel"])
+
+
+def test_codex_multiword_plan_fallback(codex_page: Page) -> None:
+ account = _account(plan=" SELF_SERVE_BUSINESS_PROLITE ", note="")
+ codex_page.evaluate("""account => {
+ State.allProvidersCurrent = {codexAccounts: [account]};
+ renderAllProvidersView();
+ }""", account)
+ expect(codex_page.locator(".provider-card-badge")).to_have_text("Self Serve Business Prolite")
+
+
+def test_codex_legacy_account_name_is_escaped(codex_page: Page) -> None:
+ account = _account()
+ account["accountName"] = '\" onpointerover="window.nameInjected=true" data-injected="yes">
Work'
+ codex_page.evaluate("""account => {
+ const container = document.createElement('div');
+ container.id = 'codex-accounts-container-both';
+ document.body.append(container);
+ renderCodexAccountSections([account]);
+ }""", account)
+ expect(codex_page.locator(".codex-account-name")).to_have_text(account["accountName"])
+ expect(codex_page.locator(".codex-account-name img")).to_have_count(0)
+ labels = {"five_hour": "5-Hour Limit", "seven_day": "Weekly All-Model", "code_review": "Review Requests"}
+ for name, label in labels.items():
+ card = codex_page.locator(f'#codex-accounts-container-both [data-quota="{name}"]')
+ expect(card).to_have_attribute("aria-label", f'{account["accountName"]} {label}')
+ assert card.get_attribute("onpointerover") is None
+ assert card.get_attribute("data-injected") is None
+ assert codex_page.evaluate("window.nameInjected") is None
+
+
+def test_codex_note_updates_without_plan_or_quota_count_change(codex_page: Page) -> None:
+ account = _account(note="")
+ codex_page.evaluate("account => fetchCodexUsage({data: account, mode: 'codex'})", account)
+ expect(codex_page.locator("#quota-grid-codex .codex-usage-source-note")).to_have_count(0)
+ for note in [SOURCE_NOTE, "Updated source note", ""]:
+ account["usageSourceNote"] = note
+ codex_page.evaluate("account => fetchCodexUsage({data: account, mode: 'codex'})", account)
+ rendered_note = codex_page.locator("#quota-grid-codex .codex-usage-source-note")
+ expect(rendered_note).to_have_count(1 if note else 0)
+ if note:
+ expect(rendered_note).to_have_text(note)
+
+
+def test_codex_quota_name_change_replaces_same_number_of_cards(codex_page: Page) -> None:
+ account = _account()
+ account["quotas"] = [{"name": "five_hour", "utilization": 10}]
+ codex_page.evaluate("account => fetchCodexUsage({data: account, mode: 'codex'})", account)
+ expect(codex_page.locator('#quota-grid-codex [data-quota="five_hour"]')).to_have_count(1)
+ account["quotas"] = [{"name": "seven_day", "utilization": 87}]
+ codex_page.evaluate("account => fetchCodexUsage({data: account, mode: 'codex'})", account)
+ expect(codex_page.locator('#quota-grid-codex [data-quota="five_hour"]')).to_have_count(0)
+ expect(codex_page.locator('#quota-grid-codex [data-quota="seven_day"]')).to_have_count(1)
+
+
+@pytest.mark.parametrize("width", [375, 768, 769, 900, 1280])
+def test_codex_note_preserves_quota_card_layout(codex_page: Page, width: int) -> None:
+ codex_page.set_viewport_size({"width": width, "height": 1000})
+ codex_page.evaluate("""account => {
+ renderCodexQuotaCards(account.quotas, 'quota-grid-codex', account.planType, account.usageSourceNote);
+ }""", _account())
+ grid = codex_page.locator("#quota-grid-codex")
+ expect(grid.locator(".codex-usage-source-note")).to_have_text(SOURCE_NOTE)
+ expect(grid.locator(".quota-card")).to_have_count(3)
+ expect(grid.locator('[data-quota="seven_day"]')).to_contain_text("87.0%")
+ bounds = grid.bounding_box()
+ hour = grid.locator('[data-quota="five_hour"]').bounding_box()
+ weekly = grid.locator('[data-quota="seven_day"]').bounding_box()
+ review = grid.locator('[data-quota="code_review"]').bounding_box()
+ assert bounds and hour and weekly and review
+ assert abs(hour["width"] - weekly["width"]) < 1
+ if 769 <= width <= 1023:
+ assert abs(review["width"] - bounds["width"]) < 1
+ assert review["y"] > weekly["y"]
+ else:
+ assert abs(review["width"] - hour["width"]) < 1
+ assert codex_page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")