From d969424285be104c7dde00cf401060b238bd0b6a Mon Sep 17 00:00:00 2001 From: lawrence <245315189+lkliu7@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:18:30 -0700 Subject: [PATCH] Add Codex multi-model support --- README.md | 11 +- README.zh-CN.md | 7 +- internal/cli/bg.go | 41 +++++++- internal/cli/status.go | 25 ++++- internal/config/config.go | 28 ++++- internal/provider/codex.go | 174 +++++++++++++++++++++++++++----- internal/provider/codex_test.go | 74 ++++++++++++++ 7 files changed, 323 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 132f78e..64a6d1b 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,9 @@ align_start = "" # optional RFC3339 anchor for the first window; empty [codex] enabled = true prompt = "ok" +# Use either "model" (single) or "models" (array) for Codex. +# If different models maintain separate rate limit windows, list all of them: +# models = ["gpt-5.4-mini", "gpt-5.3-codex-spark"] model = "gpt-5.4-mini" # cheapest Codex model for triggering reasoning_effort = "low" # "minimal" is rejected when web_search/image_gen tools are enabled extra_args = [] # extra Codex CLI args; exec-only flags such as --json are ignored @@ -308,8 +311,12 @@ your budget: Claude/Codex don't expose per-model prices at runtime (Anthropic's local cost cache is empty; Codex's model cache has no price field), so the cheapest model is -a sensible default rather than a live price lookup. Override `model` per provider -if you prefer. +a sensible default rather than a live price lookup. Override `model` (or `models`) +per provider if you prefer. + +If you use multiple Codex models that have independent windows (for example +`gpt-5.3-codex-spark`), configure `models = ["gpt-5.4-mini", "gpt-5.3-codex-spark"]` +so `limitping` pings every listed model each time a window resets. ### Active-session detection (hooks) diff --git a/README.zh-CN.md b/README.zh-CN.md index db02447..e02db73 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -260,6 +260,8 @@ align_start = "" # 可选 RFC3339:首个窗口的相位锚点;留空 = [codex] enabled = true prompt = "ok" +# 使用 "model"(单个)或 "models"(数组)。若不同模型有独立窗口,可列出多个: +# models = ["gpt-5.4-mini", "gpt-5.3-codex-spark"] model = "gpt-5.4-mini" # 用于触发的最便宜 Codex 模型 reasoning_effort = "low" # 启用 web_search/image_gen 工具时,"minimal" 会被拒绝 extra_args = [] # 额外 Codex CLI 参数;--json 等 exec-only 参数会被忽略 @@ -284,7 +286,10 @@ align_start = "" Claude/Codex 运行时都拿不到每个模型的价格(Anthropic 本地价格缓存是空的;Codex 的模型 缓存没有价格字段),所以这里用"最便宜模型"作为合理默认,而不是实时查价。需要的话可 -按 Provider 覆盖 `model`。 +按 Provider 覆盖 `model`(或用 `models` 数组)。 + +如果 Codex 的不同模型(如 `gpt-5.3-codex-spark`)有各自的窗口,请配置 +`models = ["gpt-5.4-mini", "gpt-5.3-codex-spark"]`,这样每次重置时都会为所有模型 ping。 ### 活跃会话检测(钩子) diff --git a/internal/cli/bg.go b/internal/cli/bg.go index b04e838..938c453 100644 --- a/internal/cli/bg.go +++ b/internal/cli/bg.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/cobra" "github.com/wavever/CCLimitPing/internal/config" + "github.com/wavever/CCLimitPing/internal/provider" ) const ( @@ -266,15 +267,45 @@ func runBgStatus(ctx context.Context, out io.Writer) error { return nil } - names := make([]string, len(providers)) - for i, p := range providers { - names[i] = p.Name() + // Build watching list, expanding Codex to show the configured model(s) + // being pinged (e.g. gpt-5.3-codex-spark). + watchNames := make([]string, 0, len(providers)) + for _, p := range providers { + n := p.Name() + if n == "codex" { + ms := config.CodexModels(cfg.Codex) + if len(ms) == 1 { + n = "codex (" + ms[0] + ")" + } else if len(ms) > 1 { + n = "codex (" + strings.Join(ms, ", ") + ")" + } + } + watchNames = append(watchNames, n) } - fmt.Fprintf(out, " %s: %s%s\n", text.bgFieldWatching, strings.Join(names, ", "), dryRunNote(st.DryRun)) + fmt.Fprintf(out, " %s: %s%s\n", text.bgFieldWatching, strings.Join(watchNames, ", "), dryRunNote(st.DryRun)) - // Per-provider usage, the same view as `limitping status`. + // Per-provider usage (expanded per Codex model when the config lists + // multiple models so that `bg status` shows e.g. gpt-5.3-codex-spark usage). fmt.Fprintln(out) for _, p := range providers { + if codex, ok := p.(*provider.Codex); ok { + models := config.CodexModels(cfg.Codex) + if len(models) > 0 { + for _, m := range models { + mctx, mcancel := context.WithTimeout(ctx, bgUsageTimeout) + mu, merr := codex.ReadUsageForModel(mctx, m) + mcancel() + if merr != nil { + // fall back to a generic read + rctx, rcancel := context.WithTimeout(ctx, bgUsageTimeout) + mu, _ = p.ReadUsage(rctx) + rcancel() + } + printUsageWithLabel(out, "codex ("+m+")", mu, false) + } + continue + } + } rctx, cancel := context.WithTimeout(ctx, bgUsageTimeout) u, uerr := p.ReadUsage(rctx) cancel() diff --git a/internal/cli/status.go b/internal/cli/status.go index 1297a56..23ba3da 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -72,6 +72,22 @@ func runStatus(ctx context.Context, out, progress io.Writer, text cliText, provi entries = append(entries, newStatusJSON(u, verbose)) continue } + // For Codex with multiple configured models, show separate usage + // blocks using model-specific data from additional_rate_limits when available. + if codex, ok := p.(*provider.Codex); ok { + ccfg, _ := config.Load() + models := config.CodexModels(ccfg.Codex) + if len(models) > 0 { + for _, m := range models { + mu, merr := codex.ReadUsageForModel(ctx, m) + if merr != nil { + mu = u + } + printUsageWithLabel(out, "codex ("+m+")", mu, verbose) + } + continue + } + } printUsage(out, u, verbose) } if jsonOut { @@ -154,11 +170,18 @@ func newWindowJSON(w usage.Window) *windowJSON { } func printUsage(out io.Writer, u *usage.Usage, verbose bool) { + printUsageWithLabel(out, u.Provider, u, verbose) +} + +// printUsageWithLabel prints the usage snapshot using the provided label +// as the provider/model heading (used by bg status to show per-Codex-model +// usage when multiple models are configured for pinging). +func printUsageWithLabel(out io.Writer, label string, u *usage.Usage, verbose bool) { plan := u.Plan if plan != "" { plan = " (" + plan + ")" } - fmt.Fprintf(out, "%s%s\n", u.Provider, plan) + fmt.Fprintf(out, "%s%s\n", label, plan) fmt.Fprintf(out, " 5h %s\n", fmtWindow(u.FiveHour)) fmt.Fprintf(out, " weekly %s\n", fmtWindow(u.Weekly)) if u.Credits != nil && (u.Credits.HasCredits || u.Credits.Unlimited) { diff --git a/internal/config/config.go b/internal/config/config.go index b9e6c6f..1564308 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,15 +30,35 @@ func (d Duration) MarshalText() ([]byte, error) { // ProviderConfig holds the per-provider knobs. ReasoningEffort applies only to // Codex and is ignored by Claude. +// +// For Codex, you can specify a single model via "model" or multiple via "models". +// When "models" is set, all listed models are pinged (useful if different +// Codex models have separate rate-limit windows). type ProviderConfig struct { Enabled bool `toml:"enabled"` Prompt string `toml:"prompt"` ExtraArgs []string `toml:"extra_args"` Model string `toml:"model"` + Models []string `toml:"models"` ReasoningEffort string `toml:"reasoning_effort"` AlignStart string `toml:"align_start"` } +// CodexModels returns the list of models that the Codex provider should ping +// (and that should be reflected in status reports). It prefers the "models" +// array, falls back to the singular "model" field, and finally the default. +func CodexModels(cfg ProviderConfig) []string { + if len(cfg.Models) > 0 { + cp := make([]string, len(cfg.Models)) + copy(cp, cfg.Models) + return cp + } + if cfg.Model != "" { + return []string{cfg.Model} + } + return []string{"gpt-5.4-mini"} +} + // Config is the full configuration. type Config struct { // WeeklyThreshold: skip pinging when the weekly window's utilization @@ -178,9 +198,11 @@ align_start = "" [codex] enabled = true prompt = "ok" -# Cheapest Codex model for triggering (see ~/.codex/models_cache.json for the -# list available to your plan). Empty = use the Codex default model. -model = "gpt-5.4-mini" +# Codex model(s) for triggering. Use "model" for one, or "models" (array) to +# ping multiple models on every window reset (e.g. ["gpt-5.4-mini", "gpt-5.3-codex-spark"] +# if they maintain separate 5h windows). See ~/.codex/models_cache.json. +# model = "gpt-5.4-mini" # cheapest Codex model for triggering +models = ["gpt-5.4-mini"] # "low" keeps the ping cheap; "minimal" is rejected when web_search/image_gen # tools are enabled in your Codex config. reasoning_effort = "low" diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 3b8d9a3..687e6fc 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -65,6 +65,20 @@ type codexWindow struct { ResetAt int64 `json:"reset_at"` } +type codexAdditionalRateLimit struct { + // Identifiers used across responses for model-specific (e.g. gpt-5.3-codex-spark) limits. + LimitName string `json:"limit_name"` + MeteredFeature string `json:"metered_feature"` + Title string `json:"title"` + ID string `json:"id"` + Name string `json:"name"` + + RateLimit struct { + Primary codexWindow `json:"primary_window"` + Secondary codexWindow `json:"secondary_window"` + } `json:"rate_limit"` +} + type codexUsageResp struct { PlanType string `json:"plan_type"` RateLimit struct { @@ -73,7 +87,8 @@ type codexUsageResp struct { Primary codexWindow `json:"primary_window"` Secondary codexWindow `json:"secondary_window"` } `json:"rate_limit"` - Credits *struct { + AdditionalRateLimits []codexAdditionalRateLimit `json:"additional_rate_limits"` + Credits *struct { HasCredits bool `json:"has_credits"` Unlimited bool `json:"unlimited"` Balance string `json:"balance"` @@ -81,6 +96,68 @@ type codexUsageResp struct { } func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { + r, body, err := c.fetchCodexUsage(ctx) + if err != nil { + return nil, err + } + u := &usage.Usage{ + Provider: "codex", + Plan: r.PlanType, + FetchedAt: time.Now(), + Raw: body, + LimitReached: r.RateLimit.LimitReached, + FiveHour: codexWindowToUsage(r.RateLimit.Primary), + Weekly: codexWindowToUsage(r.RateLimit.Secondary), + } + if r.Credits != nil { + u.Credits = &usage.Credits{ + HasCredits: r.Credits.HasCredits, + Unlimited: r.Credits.Unlimited, + Balance: r.Credits.Balance, + } + } + return u, nil +} + +// ReadUsageForModel returns usage data specific to the given Codex model +// (e.g. "gpt-5.3-codex-spark") when the /wham/usage response includes a +// matching entry in additional_rate_limits. Falls back to the main +// primary/secondary windows otherwise. +func (c *Codex) ReadUsageForModel(ctx context.Context, model string) (*usage.Usage, error) { + r, body, err := c.fetchCodexUsage(ctx) + if err != nil { + return nil, err + } + + prim := r.RateLimit.Primary + sec := r.RateLimit.Secondary + if model != "" { + if p, s := c.pickAdditionalWindows(r, model); (p.UsedPercent > 0 || p.LimitWindowSeconds > 0) || (s.UsedPercent > 0 || s.LimitWindowSeconds > 0) { + prim = p + sec = s + } + } + + u := &usage.Usage{ + Provider: "codex", + Plan: r.PlanType, + FetchedAt: time.Now(), + Raw: body, + LimitReached: r.RateLimit.LimitReached, + FiveHour: codexWindowToUsage(prim), + Weekly: codexWindowToUsage(sec), + } + if r.Credits != nil { + u.Credits = &usage.Credits{ + HasCredits: r.Credits.HasCredits, + Unlimited: r.Credits.Unlimited, + Balance: r.Credits.Balance, + } + } + return u, nil +} + +func (c *Codex) fetchCodexUsage(ctx context.Context) (codexUsageResp, []byte, error) { accountID, _ := c.auth.AccountID(ctx) body, err := fetchWithAuth(ctx, c.auth, func(token string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL(), nil) @@ -96,31 +173,37 @@ func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { return req, nil }) if err != nil { - return nil, err + return codexUsageResp{}, nil, err } var r codexUsageResp if err := json.Unmarshal(body, &r); err != nil { - return nil, fmt.Errorf("codex usage: parsing response: %w", err) + return codexUsageResp{}, nil, fmt.Errorf("codex usage: parsing response: %w", err) } + return r, body, nil +} - u := &usage.Usage{ - Provider: "codex", - Plan: r.PlanType, - FetchedAt: time.Now(), - Raw: body, - LimitReached: r.RateLimit.LimitReached, - FiveHour: codexWindowToUsage(r.RateLimit.Primary), - Weekly: codexWindowToUsage(r.RateLimit.Secondary), - } - if r.Credits != nil { - u.Credits = &usage.Credits{ - HasCredits: r.Credits.HasCredits, - Unlimited: r.Credits.Unlimited, - Balance: r.Credits.Balance, +func (c *Codex) pickAdditionalWindows(r codexUsageResp, model string) (codexWindow, codexWindow) { + m := strings.ToLower(model) + for _, a := range r.AdditionalRateLimits { + cands := []string{ + strings.ToLower(a.LimitName), + strings.ToLower(a.MeteredFeature), + strings.ToLower(a.Title), + strings.ToLower(a.ID), + strings.ToLower(a.Name), + } + for _, n := range cands { + if n == "" { + continue + } + if n == m || strings.Contains(n, m) || strings.Contains(m, n) || + (strings.Contains(m, "spark") && strings.Contains(n, "spark")) { + return a.RateLimit.Primary, a.RateLimit.Secondary + } } } - return u, nil + return codexWindow{}, codexWindow{} } func codexUsageURL() string { @@ -193,19 +276,47 @@ func codexWindowToUsage(w codexWindow) usage.Window { } func (c *Codex) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { + models := c.effectiveModels() prompt := c.cfg.Prompt if prompt == "" { prompt = "ok" } - args := []string{} - if c.cfg.ReasoningEffort != "" { - args = append(args, "-c", "model_reasoning_effort="+c.cfg.ReasoningEffort) + + if dryRun { + if len(models) == 1 { + return c.triggerOne(ctx, models[0], prompt, true) + } + // Multi-model dry-run: return a combined command string showing every ping. + parts := make([]string, len(models)) + for i, m := range models { + r, _ := c.triggerOne(ctx, m, prompt, true) + parts[i] = r.Command + } + return &TriggerResult{Command: strings.Join(parts, " ; ")}, nil } - if c.cfg.Model != "" { - args = append(args, "-m", c.cfg.Model) + + // Real execution: ping every configured model. + var last *TriggerResult + var firstErr error + for _, m := range models { + res, err := c.triggerOne(ctx, m, prompt, false) + last = res + if err != nil && firstErr == nil { + firstErr = err + } } - args = append(args, codexInteractiveArgs(c.cfg.ExtraArgs)...) - args = append(args, prompt) + return last, firstErr +} + +// effectiveModels returns the list of models to ping for this Codex provider. +func (c *Codex) effectiveModels() []string { + return config.CodexModels(c.cfg) +} + +// triggerOne performs (or dry-runs) a single Codex interactive ping for the +// given model. It contains the original per-invocation logic. +func (c *Codex) triggerOne(ctx context.Context, model, prompt string, dryRun bool) (*TriggerResult, error) { + args := buildCodexArgs(c.cfg, model, prompt) res := &TriggerResult{Command: "codex " + shellJoin(args)} if dryRun { return res, nil @@ -238,6 +349,19 @@ func (c *Codex) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error return res, codexInteractiveStop(ctx, cmd, ptmx, done, output) } +func buildCodexArgs(cfg config.ProviderConfig, model, prompt string) []string { + args := []string{} + if cfg.ReasoningEffort != "" { + args = append(args, "-c", "model_reasoning_effort="+cfg.ReasoningEffort) + } + if model != "" { + args = append(args, "-m", model) + } + args = append(args, codexInteractiveArgs(cfg.ExtraArgs)...) + args = append(args, prompt) + return args +} + func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { start := time.Now() deadline := time.After(maxWait) diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index 507fa16..c650f68 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -67,6 +67,64 @@ func TestCodexReadUsageSendsCompatibleHeaders(t *testing.T) { } } +func TestCodexReadUsageForModelUsesAdditionalRateLimits(t *testing.T) { + oldClient := usageHTTPClient + defer func() { usageHTTPClient = oldClient }() + + home := t.TempDir() + t.Setenv("CODEX_HOME", home) + authJSON := `{"tokens":{"access_token":"access-token","refresh_token":"refresh-token"}}` + _ = os.WriteFile(filepath.Join(home, "auth.json"), []byte(authJSON), 0o600) + + usageHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{ + "plan_type": "pro", + "rate_limit": { + "limit_reached": false, + "primary_window": {"used_percent": 55, "limit_window_seconds": 18000, "reset_at": 4102444800}, + "secondary_window": {"used_percent": 40, "limit_window_seconds": 604800, "reset_at": 4103049600} + }, + "additional_rate_limits": [ + { + "limit_name": "gpt-5.3-codex-spark", + "rate_limit": { + "primary_window": {"used_percent": 23, "limit_window_seconds": 18000, "reset_at": 4102445000}, + "secondary_window": {"used_percent": 11, "limit_window_seconds": 604800, "reset_at": 4103049700} + } + } + ] + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })} + + c := NewCodex(config.ProviderConfig{}) + + // Main usage + u, _ := c.ReadUsage(context.Background()) + if u.FiveHour.UsedPercent != 55 { + t.Fatalf("main 5h = %v, want 55", u.FiveHour.UsedPercent) + } + + // Spark-specific + us, err := c.ReadUsageForModel(context.Background(), "gpt-5.3-codex-spark") + if err != nil { + t.Fatalf("ReadUsageForModel: %v", err) + } + if us.FiveHour.UsedPercent != 23 || us.Weekly.UsedPercent != 11 { + t.Fatalf("spark windows = 5h:%v weekly:%v, want 23/11", us.FiveHour.UsedPercent, us.Weekly.UsedPercent) + } + + // Unknown model falls back + uf, _ := c.ReadUsageForModel(context.Background(), "unknown-model") + if uf.FiveHour.UsedPercent != 55 { + t.Fatalf("fallback 5h = %v, want main 55", uf.FiveHour.UsedPercent) + } +} + func TestCodexUsageURLFromBase(t *testing.T) { cases := map[string]string{ "": "https://chatgpt.com/backend-api/wham/usage", @@ -120,6 +178,22 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { } } +func TestCodexTriggerDryRunMultipleModels(t *testing.T) { + c := NewCodex(config.ProviderConfig{ + Prompt: "ok", + Models: []string{"gpt-5.4-mini", "gpt-5.3-codex-spark"}, + ReasoningEffort: "low", + }) + res, err := c.Trigger(context.Background(), true) + if err != nil { + t.Fatalf("dry-run: %v", err) + } + want := "codex -c model_reasoning_effort=low -m gpt-5.4-mini ok ; codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok" + if res.Command != want { + t.Fatalf("command = %q, want %q", res.Command, want) + } +} + func TestCodexInteractiveArgsDropsExecOnlyFlags(t *testing.T) { got := codexInteractiveArgs([]string{ "--skip-git-repo-check",