diff --git a/PRIVACY.md b/PRIVACY.md index df5caef..650e71f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -9,7 +9,8 @@ Depending on the providers you enable, `limitping` may read: - Claude Code OAuth credentials from the macOS Keychain or `~/.claude/.credentials.json` -- Codex OAuth credentials from `~/.codex/auth.json` or `$CODEX_HOME/auth.json` +- Codex/Spark OAuth credentials from `~/.codex/auth.json` or + `$CODEX_HOME/auth.json` - Your `limitping` configuration from `~/.config/limitping/config.toml` - Provider usage responses used to calculate reset times @@ -18,17 +19,18 @@ The tool may write: - `~/.config/limitping/config.toml` when you run `limitping config init` - `~/.config/limitping/litellm_prices.json`, a cached copy of the LiteLLM pricing dataset used for Codex cost estimates -- Rotated Claude/Codex OAuth tokens back to the same credential stores used by - the official CLIs, when a refresh is required +- Rotated Claude/Codex/Spark OAuth tokens back to the same credential stores + used by the official CLIs, when a refresh is required ## Network Requests `limitping` makes network requests only to support the command you run: - Anthropic Claude Code OAuth and usage endpoints -- ChatGPT/Codex OAuth and usage endpoints +- ChatGPT/Codex/Spark OAuth and usage endpoints - GitHub releases, when using `install.sh` -- The LiteLLM pricing dataset on GitHub, for Codex equivalent API-cost estimates +- The LiteLLM pricing dataset on GitHub, for Codex/Spark equivalent API-cost + estimates The tool does not send provider credentials to unrelated services. diff --git a/README.md b/README.md index 132f78e..c286e38 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ ![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white) ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey) -Start the next **Claude Code** or **Codex** rate-limit window the moment the -previous one resets. +Start the next **Claude Code**, **Codex**, or **Spark** rate-limit window the +moment the previous one resets. -Claude Code and Codex subscription limits run on **5-hour rolling windows** -(plus a weekly cap). A fresh 5h window does not start just because the previous -one reset; it starts when you send the first billable request. If that happens -hours later, the gap is wasted and your window schedule drifts. +Claude Code, Codex, and Spark subscription limits run on **5-hour rolling +windows** (plus a weekly cap). A fresh 5h window does not start just because the +previous one reset; it starts when you send the first billable request. If that +happens hours later, the gap is wasted and your window schedule drifts. `limitping` watches the reset time and sends one tiny request through the official provider CLI right after rollover. Run it once, keep `watch` in the @@ -28,6 +28,7 @@ after the terminal closes. ``` claude ✓ pinged (6.6s) codex ✓ pinged (13.6s) +spark ✓ pinged (12.4s) ``` ## Highlights @@ -37,10 +38,10 @@ codex ✓ pinged (13.6s) `bg start` with `bg status`, `bg logs -f`, and `bg stop`. - Shows 5h and weekly usage, reset countdowns, and background watcher state from read-only usage endpoints. -- Triggers Claude Code and Codex through their official CLIs using your existing - logged-in credentials. -- Detects active Claude/Codex turns via CLI hooks and defers its own ping so it - does not compete with your work. +- Triggers Claude Code, Codex, and Spark through their official CLIs using your + existing logged-in credentials. +- Detects active Claude/Codex turns via CLI hooks; Spark uses the Codex hook + signal because it runs through the Codex CLI. - Includes dry-run modes, weekly-limit guards, reset buffers, cheap-model defaults, macOS notifications, local config, and no telemetry. @@ -68,6 +69,7 @@ provider quota: `limitping ping --dry-run`, `limitping watch --dry-run`, or |---|---|---|---| | **Claude Code** | `…/api/oauth/usage` | interactive Claude Code CLI | OAuth (Keychain / `~/.claude`) | | **Codex** | `…/backend-api/wham/usage` | interactive Codex CLI | OAuth (`~/.codex/auth.json`) | +| **Spark** | `…/backend-api/wham/usage` (`additional_rate_limits`) | interactive Codex CLI with `gpt-5.3-codex-spark` | OAuth (`~/.codex/auth.json`) | ## How it works @@ -81,10 +83,10 @@ Two cleanly separated jobs: When `watch` sees a 5h window has reset, it first checks whether a Claude/Codex session is actively mid-turn. If one is, `limitping` waits and re-reads usage instead of sending its own ping, because that session's next model request will -start the new window naturally. This check relies on the -[CLI hooks](#active-session-detection-hooks) (installed automatically by the -install script); without them, `limitping` skips the check and pings as soon as -the window resets. +start the new window naturally. Spark uses the Codex activity signal. This check +relies on the [CLI hooks](#active-session-detection-hooks) (installed +automatically by the install script); without them, `limitping` skips the check +and pings as soon as the window resets. - **Claude**: reads `GET https://api.anthropic.com/api/oauth/usage` using the OAuth token from the macOS Keychain (`Claude Code-credentials`) or @@ -96,9 +98,13 @@ the window resets. OAuth token from `~/.codex/auth.json`. Triggering uses a TTY-backed interactive `codex ""` session; headless `codex exec` can consume tokens without anchoring the subscription-backed Codex window. +- **Spark**: uses the same Codex usage endpoint, OAuth token, hooks, and + interactive CLI path. It reads the `GPT-5.3-Codex-Spark` entry from + `additional_rate_limits`, sends the ping with model `gpt-5.3-codex-spark`, + and appears as a separate `spark` provider. Claude/Codex tokens are reused from the official tools (no separate login) and -refreshed on 401. +refreshed on 401. Spark reuses the Codex token. ## Install @@ -154,7 +160,7 @@ go build -o bin/limitping ./cmd/limitping ``` Each provider you enable needs its own credentials: the `claude` / `codex` CLIs -logged in. +logged in. Spark uses the Codex CLI credentials. ## Usage @@ -166,9 +172,10 @@ limitping status -v # also print raw JSON limitping ping # trigger all enabled providers now (alias: p) limitping ping claude # Claude only limitping ping codex # Codex only +limitping ping spark # Spark only limitping ping --dry-run # show the commands without sending limitping watch # foreground daemon: ping each window at reset (alias: w) -limitping watch claude # watch only one provider (claude|codex) +limitping watch claude # watch only one provider (claude|codex|spark) limitping watch --live # optional live heartbeat/status line limitping watch --dry-run # log when pings would fire, without sending limitping bg start # run watch in the background, freeing the terminal @@ -203,7 +210,7 @@ Short aliases are also available for config commands: `limitping c i` for | `uninstall` | `rm`, `remove` | `ping` shows the exact command and a live timer (a spinner on a terminal). -Current Claude/Codex interactive trigger sessions do not expose reliable +Current Claude/Codex/Spark interactive trigger sessions do not expose reliable machine-readable per-ping token or cost data, so success output normally shows elapsed time only: @@ -212,6 +219,8 @@ claude → claude --model haiku . claude ✓ pinged (6.6s) codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok codex ✓ pinged (13.6s) +spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok +spark ✓ pinged (12.4s) ``` Use `status` or `bg status` for the authoritative 5h/weekly window view after a @@ -284,6 +293,14 @@ 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 align_start = "" + +[spark] +enabled = false # opt in; Spark is a separate Codex-backed watch target +prompt = "ok" +model = "gpt-5.3-codex-spark" +reasoning_effort = "low" +extra_args = [] +align_start = "" ``` Top-level keys: @@ -305,11 +322,13 @@ your budget: - **Claude → `haiku`**: also avoids the separate weekly Opus bucket. - **Codex → `gpt-5.4-mini`**: the mini variant (see `~/.codex/models_cache.json` for what your plan offers). +- **Spark → `gpt-5.3-codex-spark`**: a Codex-backed Spark target, disabled by + default so upgrades do not add another quota-consuming ping. -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. +Claude/Codex/Spark 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. ### Active-session detection (hooks) @@ -330,7 +349,8 @@ This registers limitping's hooks in `~/.claude/settings.json` and written). The hooks invoke the hidden `limitping hook ` command on `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop` (Claude also `SessionEnd`) to record whether a session is mid-turn under -`~/.config/limitping/activity/`. +`~/.config/limitping/activity/`. Spark runs through the Codex CLI and uses the +Codex hook/activity marker; there is no separate Spark hook config. > [!NOTE] > Claude Code loads its hooks automatically — nothing to do there. **Codex** @@ -393,9 +413,9 @@ launchctl load ~/Library/LaunchAgents/com.limitping.watch.plist uses a minimal prompt and low reasoning, so the cost is tiny but non-zero. - The **usage endpoints are unofficial** and could change; they're read-only and isolated per provider for easy patching. -- macOS-first: Keychain reads and notifications are macOS-only. Codex `auth.json` - is cross-platform; Claude on Linux uses `~/.claude/.credentials.json`; - notifications are a no-op off macOS. +- macOS-first: Keychain reads and notifications are macOS-only. Codex/Spark + `auth.json` is cross-platform; Claude on Linux uses + `~/.claude/.credentials.json`; notifications are a no-op off macOS. ## Layout @@ -403,7 +423,7 @@ launchctl load ~/Library/LaunchAgents/com.limitping.watch.plist cmd/limitping CLI entry internal/config TOML config internal/usage normalized usage model -internal/auth Claude (Keychain) + Codex (auth.json) tokens +internal/auth Claude (Keychain) + Codex/Spark (auth.json) tokens internal/provider per-provider ReadUsage (endpoint) + Trigger (CLI) internal/activity hook-based active-session state (shared by the hook cmd + scheduler) internal/pricing pricing helpers for providers that expose token usage @@ -424,9 +444,9 @@ go vet ./... go test ./... ``` -Providers are isolated in `internal/provider` (one file each) with a small -`Provider` interface (`ReadUsage` + `Trigger`), so adding a new provider is -mostly a self-contained file plus wiring in `internal/cli` and `internal/config`. +Providers are isolated in `internal/provider` behind a small `Provider` +interface (`ReadUsage` + `Trigger`), so adding a new provider is mostly +self-contained provider code plus wiring in `internal/cli` and `internal/config`. **Releasing** is automated: push a tag and GitHub Actions runs GoReleaser to build the cross-platform binaries and publish a Release. diff --git a/README.zh-CN.md b/README.zh-CN.md index db02447..f782494 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,9 +12,9 @@ ![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white) ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey) -在上一个窗口重置的瞬间,立即启动下一个 **Claude Code** / **Codex** 限额窗口。 +在上一个窗口重置的瞬间,立即启动下一个 **Claude Code** / **Codex** / **Spark** 限额窗口。 -Claude Code 和 Codex 的订阅限额按 **5 小时滚动窗口**(外加周限额)计算。新的 5h 窗口 +Claude Code、Codex 和 Spark 的订阅限额按 **5 小时滚动窗口**(外加周限额)计算。新的 5h 窗口 不会因为上一个窗口重置就自动开始,而是从你下一次真正发起计费请求时才开始。如果你隔了 几个小时才再次使用,这段空档就被浪费了,窗口节奏也会越拖越偏。 @@ -24,6 +24,7 @@ Claude Code 和 Codex 的订阅限额按 **5 小时滚动窗口**(外加周限 ``` claude ✓ pinged (6.6s) codex ✓ pinged (13.6s) +spark ✓ pinged (12.4s) ``` ## 亮点 @@ -33,7 +34,7 @@ codex ✓ pinged (13.6s) `bg status`、`bg logs -f`、`bg stop` 管理。 - `status` / `bg status` 会展示 5h 与周用量、重置倒计时以及后台监听状态。 - 通过只读用量端点读取状态,通过官方 Claude Code / Codex CLI 触发窗口,复用已有登录态。 -- 通过 CLI 钩子识别正在进行中的 Claude/Codex 会话,必要时推迟自己的 ping,不抢你的对话。 +- 通过 CLI 钩子识别正在进行中的 Claude/Codex 会话;Spark 通过 Codex CLI 运行,复用 Codex 钩子信号。 - 内置 dry-run、周限额保护、重置缓冲、低成本模型默认值、macOS 通知、本地配置,且不带遥测。 ## 快速开始 @@ -60,6 +61,7 @@ limitping bg logs -f |---|---|---|---| | **Claude Code** | `…/api/oauth/usage` | 交互式 Claude Code CLI | OAuth(钥匙串 / `~/.claude`) | | **Codex** | `…/backend-api/wham/usage` | 交互式 Codex CLI | OAuth(`~/.codex/auth.json`) | +| **Spark** | `…/backend-api/wham/usage` (`additional_rate_limits`) | 使用 `gpt-5.3-codex-spark` 的交互式 Codex CLI | OAuth(`~/.codex/auth.json`) | ## 工作原理 @@ -72,8 +74,9 @@ limitping bg logs -f 当 `watch` 发现 5h 窗口已经重置时,会先检查是否有 Claude/Codex 会话正处于对话进行中。 如果有,`limitping` 会等待并重新读取用量,而不是自己发 ping,因为这个会话的下一次模型 -请求会自然起算新窗口。这个检查依赖 [CLI 钩子](#活跃会话检测钩子)(安装脚本会自动装好); -未安装钩子时,`limitping` 会跳过该检查,窗口一重置就直接 ping(绝不靠扫描进程来猜)。 +请求会自然起算新窗口。Spark 使用 Codex 活跃会话信号。这个检查依赖 +[CLI 钩子](#活跃会话检测钩子)(安装脚本会自动装好);未安装钩子时,`limitping` 会跳过该检查, +窗口一重置就直接 ping(绝不靠扫描进程来猜)。 - **Claude**:用 macOS 钥匙串(`Claude Code-credentials`)或 `~/.claude/.credentials.json` 里的 OAuth token,读 `GET https://api.anthropic.com/api/oauth/usage`。触发使用带 @@ -83,8 +86,12 @@ limitping bg logs -f `GET https://chatgpt.com/backend-api/wham/usage`。触发使用带 TTY 的交互式 `codex ""` 会话;headless `codex exec` 可能会消耗 token,但不一定起算 Codex 订阅窗口。 +- **Spark**:复用 Codex 用量端点、OAuth token、钩子和交互式 CLI 路径,但从 + `additional_rate_limits` 中读取 `GPT-5.3-Codex-Spark` 条目,用 + `gpt-5.3-codex-spark` 模型发送 ping,并作为独立的 `spark` Provider 展示。 -Claude/Codex 的 token 直接复用官方工具(无需另外登录),遇到 401 会自动刷新。 +Claude/Codex 的 token 直接复用官方工具(无需另外登录),遇到 401 会自动刷新。Spark 复用 +Codex token。 ## 安装 @@ -138,7 +145,8 @@ go install github.com/wavever/CCLimitPing/cmd/limitping@latest go build -o bin/limitping ./cmd/limitping ``` -你启用的每个 Provider 各自需要凭据:登录好的 `claude` / `codex` CLI。 +你启用的每个 Provider 各自需要凭据:登录好的 `claude` / `codex` CLI。Spark 使用 +Codex CLI 凭据。 ## 使用 @@ -150,9 +158,10 @@ limitping status -v # 额外打印原始 JSON limitping ping # 立即触发所有已启用的 Provider(简称: p) limitping ping claude # 只触发 Claude limitping ping codex # 只触发 Codex +limitping ping spark # 只触发 Spark limitping ping --dry-run # 只打印将执行的命令,不真正发送 limitping watch # 前台守护:在每个窗口重置时自动 ping(简称: w) -limitping watch claude # 只监测某一个 Provider(claude|codex) +limitping watch claude # 只监测某一个 Provider(claude|codex|spark) limitping watch --live # 可选:显示实时心电图状态行 limitping watch --dry-run # 只记录何时会触发,不真正发送 limitping bg start # 在后台运行 watch,释放终端 @@ -186,7 +195,7 @@ limitping uninstall # 删除 limitping 以及配置/缓存(简称: rm | `upgrade` | `up`、`update` | | `uninstall` | `rm`、`remove` | -`ping` 会显示具体命令和实时计时(终端下是 spinner)。当前 Claude/Codex 都用交互式 +`ping` 会显示具体命令和实时计时(终端下是 spinner)。当前 Claude/Codex/Spark 都用交互式 触发,CLI 不提供可靠的逐次 machine-readable token/费用数据,所以成功输出通常只显示耗时: ``` @@ -194,6 +203,8 @@ claude → claude --model haiku . claude ✓ pinged (6.6s) codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok codex ✓ pinged (13.6s) +spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok +spark ✓ pinged (12.4s) ``` ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。 @@ -264,6 +275,14 @@ model = "gpt-5.4-mini" # 用于触发的最便宜 Codex 模型 reasoning_effort = "low" # 启用 web_search/image_gen 工具时,"minimal" 会被拒绝 extra_args = [] # 额外 Codex CLI 参数;--json 等 exec-only 参数会被忽略 align_start = "" + +[spark] +enabled = false # 需显式启用;Spark 是独立的 Codex-backed watch 目标 +prompt = "ok" +model = "gpt-5.3-codex-spark" +reasoning_effort = "low" +extra_args = [] +align_start = "" ``` 顶层配置项: @@ -281,10 +300,12 @@ align_start = "" - **Claude → `haiku`**:同时避开单独的周 Opus 额度池。 - **Codex → `gpt-5.4-mini`**:mini 变体(你的套餐有哪些见 `~/.codex/models_cache.json`)。 +- **Spark → `gpt-5.3-codex-spark`**:一个 Codex-backed Spark 目标;默认关闭,避免升级后 + 自动多一次消耗额度的 ping。 -Claude/Codex 运行时都拿不到每个模型的价格(Anthropic 本地价格缓存是空的;Codex 的模型 -缓存没有价格字段),所以这里用"最便宜模型"作为合理默认,而不是实时查价。需要的话可 -按 Provider 覆盖 `model`。 +Claude/Codex/Spark 运行时都拿不到每个模型的价格(Anthropic 本地价格缓存是空的;Codex +的模型缓存没有价格字段),所以这里用"最便宜模型"作为合理默认,而不是实时查价。需要的话 +可按 Provider 覆盖 `model`。 ### 活跃会话检测(钩子) @@ -301,7 +322,8 @@ limitping hooks install # 两个 Provider 都装(或 limitping hooks inst 这会把 limitping 的钩子写入 `~/.claude/settings.json` 和 `~/.codex/hooks.json`(保留你已有 的配置,并写入 `.bak` 备份)。钩子会在 `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop`(Claude 还有 `SessionEnd`)时调用隐藏命令 `limitping hook `,把会话是否 -处于对话进行中记录到 `~/.config/limitping/activity/`。 +处于对话进行中记录到 `~/.config/limitping/activity/`。Spark 通过 Codex CLI 运行,复用 +Codex 钩子/活跃状态标记,没有单独的 Spark 钩子配置。 > [!NOTE] > Claude Code 会自动加载钩子,无需操作。**Codex** 对自定义命令钩子要求一次性信任: @@ -357,8 +379,8 @@ launchctl load ~/Library/LaunchAgents/com.limitping.watch.plist - 触发会**消耗一点额度**(约每 5h 一次 ≈ 每周 33 次)。ping 用最小 prompt + 低 reasoning, 成本很小但非零。 - **用量端点是非官方接口**,可能变更;它们都是只读的,并按 Provider 隔离,方便单独热修。 -- 以 macOS 为主:钥匙串读取和通知仅限 macOS。Codex 的 `auth.json` 跨平台;Claude 在 - Linux 上用 `~/.claude/.credentials.json`;非 macOS 上通知为空操作。 +- 以 macOS 为主:钥匙串读取和通知仅限 macOS。Codex/Spark 的 `auth.json` 跨平台;Claude + 在 Linux 上用 `~/.claude/.credentials.json`;非 macOS 上通知为空操作。 ## 目录结构 @@ -366,7 +388,7 @@ launchctl load ~/Library/LaunchAgents/com.limitping.watch.plist cmd/limitping CLI 入口 internal/config TOML 配置 internal/usage 归一化的用量模型 -internal/auth Claude(钥匙串)+ Codex(auth.json)token +internal/auth Claude(钥匙串)+ Codex/Spark(auth.json)token internal/provider 各 Provider 的 ReadUsage(端点)+ Trigger(CLI) internal/activity 基于钩子的活跃会话状态(hook 命令与 scheduler 共用) internal/pricing 为能暴露 token 用量的 Provider 准备的价格辅助代码 @@ -387,9 +409,9 @@ go vet ./... go test ./... ``` -Provider 都隔离在 `internal/provider`(每家一个文件),只需实现一个很小的 `Provider` -接口(`ReadUsage` + `Trigger`),所以新增一个 Provider 基本就是一个自包含文件,加上在 -`internal/cli` 和 `internal/config` 里接一下线。 +Provider 都隔离在 `internal/provider`,只需实现一个很小的 `Provider` 接口(`ReadUsage` + +`Trigger`),所以新增一个 Provider 基本是自包含的 Provider 代码,加上在 `internal/cli` +和 `internal/config` 里接一下线。 **发版**是自动的:打一个 tag 并推送,GitHub Actions 会跑 GoReleaser 交叉编译各平台 二进制并发布 Release。 diff --git a/SECURITY.md b/SECURITY.md index d68cfe1..55cb1e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ security contact, without including sensitive details. Helpful reports include: -- The affected command or provider (`claude` or `codex`) +- The affected command or provider (`claude`, `codex`, or `spark`) - The operating system and `limitping version` - A minimal reproduction that does not include credentials - Whether the issue affects token handling, config files, usage endpoints, or @@ -46,6 +46,7 @@ Out of scope: - Claude Code OAuth credentials from the macOS Keychain or `~/.claude/.credentials.json` -- Codex OAuth credentials from `~/.codex/auth.json` or `$CODEX_HOME/auth.json` +- Codex/Spark OAuth credentials from `~/.codex/auth.json` or + `$CODEX_HOME/auth.json` Do not share these files or raw `status -v` output in public reports. diff --git a/internal/cli/bg.go b/internal/cli/bg.go index b04e838..da038de 100644 --- a/internal/cli/bg.go +++ b/internal/cli/bg.go @@ -104,7 +104,7 @@ func newBgStartCmd() *cobra.Command { Short: text.bgStartShort, Long: text.bgStartLong, Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), - ValidArgs: []string{"claude", "codex", "all"}, + ValidArgs: []string{"claude", "codex", "spark", "all"}, RunE: func(cmd *cobra.Command, args []string) error { return runBgStart(cmd.OutOrStdout(), argOrAll(args), dryRun) }, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b496ce0..4f81ea6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -122,8 +122,10 @@ func buildProvider(name string, cfg config.Config) (provider.Provider, error) { return provider.NewClaude(cfg.Claude), nil case "codex": return provider.NewCodex(cfg.Codex), nil + case "spark": + return provider.NewSpark(cfg.Spark), nil default: - return nil, fmt.Errorf("unknown provider %q (want claude, codex, or all)", name) + return nil, fmt.Errorf("unknown provider %q (want claude, codex, spark, or all)", name) } } @@ -136,6 +138,9 @@ func enabledProviders(cfg config.Config) []provider.Provider { if cfg.Codex.Enabled { ps = append(ps, provider.NewCodex(cfg.Codex)) } + if cfg.Spark.Enabled { + ps = append(ps, provider.NewSpark(cfg.Spark)) + } return ps } @@ -191,6 +196,11 @@ func buildTargets(cfg config.Config) ([]scheduler.Target, error) { return nil, err } } + if cfg.Spark.Enabled { + if err := add(provider.NewSpark(cfg.Spark), cfg.Spark.AlignStart); err != nil { + return nil, err + } + } if len(targets) == 0 { return nil, fmt.Errorf("no providers enabled in config") } @@ -222,6 +232,8 @@ func providerAlignStart(cfg config.Config, name string) string { return cfg.Claude.AlignStart case "codex": return cfg.Codex.AlignStart + case "spark": + return cfg.Spark.AlignStart } return "" } diff --git a/internal/cli/i18n.go b/internal/cli/i18n.go index 4142922..f762ecd 100644 --- a/internal/cli/i18n.go +++ b/internal/cli/i18n.go @@ -123,7 +123,7 @@ func isChineseLocale() bool { } var enText = cliText{ - rootShort: "Keep Claude Code / Codex rate-limit windows back-to-back", + rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back", rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.", helpFlag: "help for this command", usageTemplate: `Usage:{{if .Runnable}} @@ -179,7 +179,7 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e pingLong: `Trigger a rate-limit window immediately by sending the minimal message for the selected provider. Arguments: - provider Optional. One of: claude, codex, all. + provider Optional. One of: claude, codex, spark, all. Defaults to all, which pings every enabled provider. Examples: @@ -192,7 +192,7 @@ Examples: watchLong: `Run the foreground daemon. When a provider's 5h window resets, limitping sends the minimal message to start the next window. Arguments: - provider Optional. One of: claude, codex, all. + provider Optional. One of: claude, codex, spark, all. Defaults to all, which watches every enabled provider. Examples: @@ -219,7 +219,7 @@ Only one watcher runs at a time, foreground or background. The background proces bgStartLong: `Launch the watch daemon in the background (detached from the terminal) and return immediately, freeing your shell. Output goes to a log file under the config directory. Arguments: - provider Optional. One of: claude, codex, all. + provider Optional. One of: claude, codex, spark, all. Defaults to all, which watches every enabled provider. Examples: @@ -232,7 +232,7 @@ Examples: bgLogsFollowFlag: "follow the log output (like tail -f)", bgLogsLinesFlag: "number of trailing log lines to show", - bgHintStart: "Start it with: limitping bg start [claude|codex] [--dry-run]", + bgHintStart: "Start it with: limitping bg start [claude|codex|spark] [--dry-run]", bgHintManage: "Manage it with: limitping bg logs -f | limitping bg stop", bgNotRunning: "Background watch: not running.", bgClearedStaleFmt: "Background watch: not running (cleared stale pid %d).\n", @@ -263,14 +263,14 @@ Examples: hooksShort: "Manage Claude/Codex hooks for accurate active-session detection", hooksLong: `Manage the hooks that let limitping tell whether a Claude Code or Codex session is actually mid-turn (rather than merely running). -When installed, limitping defers its ping while you're actively working and resumes once the turn ends. Without hooks limitping skips this check and pings as soon as the window resets. The install script sets these hooks up automatically.`, +When installed, limitping defers its ping while you're actively working and resumes once the turn ends. Spark uses the Codex hook signal because it runs through the Codex CLI. Without hooks limitping skips this check and pings as soon as the window resets. The install script sets these hooks up automatically.`, hooksInstallShort: "Register limitping's hooks in the Claude/Codex configs", hooksInstallLong: `Register limitping's hooks in ~/.claude/settings.json and ~/.codex/hooks.json (existing settings are preserved; a .bak backup is written). Arguments: provider Optional. One of: claude, codex, all. Defaults to all. -Claude Code loads its hooks automatically. Codex requires a one-time trust: run /hooks inside Codex to enable them. +Claude Code loads its hooks automatically. Codex requires a one-time trust: run /hooks inside Codex to enable them. Spark uses the Codex hook signal. Examples: limitping hooks install @@ -298,7 +298,7 @@ Examples: } var zhText = cliText{ - rootShort: "让 Claude Code / Codex 的限额窗口自动接龙", + rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙", rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。", helpFlag: "显示此命令的帮助", usageTemplate: `用法:{{if .Runnable}} @@ -354,7 +354,7 @@ var zhText = cliText{ pingLong: `通过向指定 Provider 发送最小消息,立即触发一个限额窗口。 参数: - provider 可选。取值: claude、codex、all。 + provider 可选。取值: claude、codex、spark、all。 默认是 all,会 ping 所有已启用的 Provider。 示例: @@ -367,7 +367,7 @@ var zhText = cliText{ watchLong: `以前台守护方式运行。某个 Provider 的 5h 窗口重置后,limitping 会发送最小消息来开启下一个窗口。 参数: - provider 可选。取值: claude、codex、all。 + provider 可选。取值: claude、codex、spark、all。 默认是 all,会监测所有已启用的 Provider。 示例: @@ -394,7 +394,7 @@ var zhText = cliText{ bgStartLong: `在后台(脱离终端)启动 watch 守护进程并立即返回,释放当前终端。输出会写入配置目录下的日志文件。 参数: - provider 可选。取值: claude、codex、all。 + provider 可选。取值: claude、codex、spark、all。 默认是 all,会监测所有已启用的 Provider。 示例: @@ -407,7 +407,7 @@ var zhText = cliText{ bgLogsFollowFlag: "持续跟踪日志输出(类似 tail -f)", bgLogsLinesFlag: "显示最后多少行日志", - bgHintStart: "启动: limitping bg start [claude|codex] [--dry-run]", + bgHintStart: "启动: limitping bg start [claude|codex|spark] [--dry-run]", bgHintManage: "管理: limitping bg logs -f | limitping bg stop", bgNotRunning: "后台监听:未在运行。", bgClearedStaleFmt: "后台监听:未在运行(已清理失效的 pid %d)。\n", @@ -438,14 +438,14 @@ var zhText = cliText{ hooksShort: "管理 Claude/Codex 钩子,精确判断会话是否正在运行", hooksLong: `管理用于判断 Claude Code 或 Codex 会话是否真正处于对话进行中(而非仅仅进程存在)的钩子。 -安装后,limitping 会在你正在使用时推迟 ping,并在一轮对话结束后恢复。未安装钩子时,limitping 会跳过该检查,窗口一重置就直接 ping。安装脚本会自动装好这些钩子。`, +安装后,limitping 会在你正在使用时推迟 ping,并在一轮对话结束后恢复。Spark 通过 Codex CLI 运行,因此复用 Codex 钩子信号。未安装钩子时,limitping 会跳过该检查,窗口一重置就直接 ping。安装脚本会自动装好这些钩子。`, hooksInstallShort: "在 Claude/Codex 配置中注册 limitping 的钩子", hooksInstallLong: `在 ~/.claude/settings.json 和 ~/.codex/hooks.json 中注册 limitping 的钩子(保留已有配置,并写入 .bak 备份)。 参数: provider 可选。取值: claude、codex、all。默认是 all。 -Claude Code 会自动加载钩子;Codex 需要一次性信任:在 Codex 中运行 /hooks 启用它们。 +Claude Code 会自动加载钩子;Codex 需要一次性信任:在 Codex 中运行 /hooks 启用它们。Spark 复用 Codex 钩子信号。 示例: limitping hooks install diff --git a/internal/cli/ping.go b/internal/cli/ping.go index 12ee172..7ab3454 100644 --- a/internal/cli/ping.go +++ b/internal/cli/ping.go @@ -22,7 +22,7 @@ func newPingCmd() *cobra.Command { Short: text.pingShort, Long: text.pingLong, Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), - ValidArgs: []string{"claude", "codex", "all"}, + ValidArgs: []string{"claude", "codex", "spark", "all"}, RunE: func(cmd *cobra.Command, args []string) error { name := "all" if len(args) > 0 { diff --git a/internal/cli/provider_selection_test.go b/internal/cli/provider_selection_test.go new file mode 100644 index 0000000..74efc3a --- /dev/null +++ b/internal/cli/provider_selection_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "testing" + + "github.com/wavever/CCLimitPing/internal/config" + "github.com/wavever/CCLimitPing/internal/provider" + "github.com/wavever/CCLimitPing/internal/scheduler" +) + +func TestDefaultConfigDoesNotEnableSpark(t *testing.T) { + cfg := config.Default() + + providers := enabledProviders(cfg) + if got, want := providerNames(providers), []string{"claude", "codex"}; !sameStrings(got, want) { + t.Fatalf("enabled providers = %#v, want %#v", got, want) + } + + targets, err := buildTargets(cfg) + if err != nil { + t.Fatalf("buildTargets: %v", err) + } + if got, want := targetNames(targets), []string{"claude", "codex"}; !sameStrings(got, want) { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func TestExplicitSparkSelectionWorksWhenDisabled(t *testing.T) { + cfg := config.Default() + + providers, err := selectProviders(cfg, "spark") + if err != nil { + t.Fatalf("selectProviders: %v", err) + } + if got, want := providerNames(providers), []string{"spark"}; !sameStrings(got, want) { + t.Fatalf("providers = %#v, want %#v", got, want) + } + + targets, err := selectTargets(cfg, "spark") + if err != nil { + t.Fatalf("selectTargets: %v", err) + } + if got, want := targetNames(targets), []string{"spark"}; !sameStrings(got, want) { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func TestEnabledSparkAppearsInAllSelections(t *testing.T) { + cfg := config.Default() + cfg.Spark.Enabled = true + + providers := enabledProviders(cfg) + if got, want := providerNames(providers), []string{"claude", "codex", "spark"}; !sameStrings(got, want) { + t.Fatalf("enabled providers = %#v, want %#v", got, want) + } + + targets, err := buildTargets(cfg) + if err != nil { + t.Fatalf("buildTargets: %v", err) + } + if got, want := targetNames(targets), []string{"claude", "codex", "spark"}; !sameStrings(got, want) { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func providerNames(ps []provider.Provider) []string { + names := make([]string, len(ps)) + for i, p := range ps { + names[i] = p.Name() + } + return names +} + +func targetNames(targets []scheduler.Target) []string { + names := make([]string, len(targets)) + for i, t := range targets { + names[i] = t.Provider.Name() + } + return names +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/cli/watch.go b/internal/cli/watch.go index 4389271..c97a292 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -22,7 +22,7 @@ func newWatchCmd() *cobra.Command { Short: text.watchShort, Long: text.watchLong, Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), - ValidArgs: []string{"claude", "codex", "all"}, + ValidArgs: []string{"claude", "codex", "spark", "all"}, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index b9e6c6f..6783a7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,8 +28,8 @@ func (d Duration) MarshalText() ([]byte, error) { return []byte(d.Duration.String()), nil } -// ProviderConfig holds the per-provider knobs. ReasoningEffort applies only to -// Codex and is ignored by Claude. +// ProviderConfig holds the per-provider knobs. ReasoningEffort applies to +// Codex-backed providers and is ignored by Claude. type ProviderConfig struct { Enabled bool `toml:"enabled"` Prompt string `toml:"prompt"` @@ -52,6 +52,7 @@ type Config struct { Claude ProviderConfig `toml:"claude"` Codex ProviderConfig `toml:"codex"` + Spark ProviderConfig `toml:"spark"` } // Default returns the built-in defaults used when no config file exists. @@ -72,6 +73,12 @@ func Default() Config { Model: "gpt-5.4-mini", ReasoningEffort: "low", }, + Spark: ProviderConfig{ + Enabled: false, + Prompt: "ok", + Model: "gpt-5.3-codex-spark", + ReasoningEffort: "low", + }, } } @@ -186,4 +193,14 @@ model = "gpt-5.4-mini" reasoning_effort = "low" extra_args = [] align_start = "" + +[spark] +# Spark is a separate watch target backed by the Codex CLI and credentials. +# Disabled by default so upgrades do not add another quota-consuming ping. +enabled = false +prompt = "ok" +model = "gpt-5.3-codex-spark" +reasoning_effort = "low" +extra_args = [] +align_start = "" ` diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 3b8d9a3..a010e87 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "time" + "unicode" "github.com/BurntSushi/toml" "github.com/creack/pty" @@ -27,6 +28,7 @@ const ( codexChatGPTPath = "/wham/usage" codexAPIPath = "/api/codex/usage" codexUserAgent = "limitping" + sparkDefaultModel = "gpt-5.3-codex-spark" codexTurnMinWait = 4 * time.Second codexTurnQuiet = 2500 * time.Millisecond @@ -44,15 +46,76 @@ type Codex struct { } func NewCodex(cfg config.ProviderConfig) *Codex { - return &Codex{cfg: cfg, auth: auth.NewCodexAuth()} + return &Codex{ + cfg: cfg, + auth: auth.NewCodexAuth(), + } } func (c *Codex) Name() string { return "codex" } -func (c *Codex) ActiveTask(_ context.Context) (string, bool, error) { - // Active-session detection relies entirely on the CLI hooks (see `limitping - // hooks install`). Without them we don't guess from the process list — the - // scheduler just pings. +func (c *Codex) ActiveTask(ctx context.Context) (string, bool, error) { + return codexActiveTask(ctx) +} + +func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { + body, r, err := readCodexUsage(ctx, c.auth) + if err != nil { + return nil, err + } + return codexUsageToUsage(c.Name(), body, r, r.RateLimit), nil +} + +func (c *Codex) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { + return triggerCodex(ctx, c.cfg, dryRun) +} + +// Spark is a separate provider backed by Codex auth and CLI transport. +// Its usage window is the Spark-specific entry inside the Codex usage payload. +type Spark struct { + cfg config.ProviderConfig + auth *auth.CodexAuth +} + +// NewSpark returns the Spark provider. It shares Codex credentials and the +// Codex CLI binary, but it owns provider identity and usage selection. +func NewSpark(cfg config.ProviderConfig) *Spark { + if cfg.Model == "" { + cfg.Model = sparkDefaultModel + } + return &Spark{ + cfg: cfg, + auth: auth.NewCodexAuth(), + } +} + +func (s *Spark) Name() string { return "spark" } + +func (s *Spark) ActiveTask(ctx context.Context) (string, bool, error) { + return codexActiveTask(ctx) +} + +func (s *Spark) ReadUsage(ctx context.Context) (*usage.Usage, error) { + body, r, err := readCodexUsage(ctx, s.auth) + if err != nil { + return nil, err + } + rateLimit, err := sparkRateLimitFromResponse(r, s.cfg.Model) + if err != nil { + return nil, err + } + return codexUsageToUsage(s.Name(), body, r, rateLimit), nil +} + +func (s *Spark) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { + return triggerCodex(ctx, s.cfg, dryRun) +} + +func codexActiveTask(_ context.Context) (string, bool, error) { + // Active-session detection relies entirely on the Codex CLI hooks (see + // `limitping hooks install`). Without them we don't guess from the process + // list; the scheduler just pings. Spark uses the same activity signal + // because its actual CLI session is still `codex`. if !activity.Enabled("codex") { return "", false, nil } @@ -65,24 +128,36 @@ type codexWindow struct { ResetAt int64 `json:"reset_at"` } +type codexRateLimit struct { + Allowed bool `json:"allowed"` + LimitReached bool `json:"limit_reached"` + Primary codexWindow `json:"primary_window"` + Secondary codexWindow `json:"secondary_window"` +} + +type codexAdditionalRateLimit struct { + LimitName string `json:"limit_name"` + MeteredFeature string `json:"metered_feature"` + RateLimit codexRateLimit `json:"rate_limit"` +} + +type codexCredits struct { + HasCredits bool `json:"has_credits"` + Unlimited bool `json:"unlimited"` + Balance string `json:"balance"` +} + type codexUsageResp struct { - PlanType string `json:"plan_type"` - RateLimit struct { - Allowed bool `json:"allowed"` - LimitReached bool `json:"limit_reached"` - Primary codexWindow `json:"primary_window"` - Secondary codexWindow `json:"secondary_window"` - } `json:"rate_limit"` - Credits *struct { - HasCredits bool `json:"has_credits"` - Unlimited bool `json:"unlimited"` - Balance string `json:"balance"` - } `json:"credits"` + PlanType string `json:"plan_type"` + RateLimit codexRateLimit `json:"rate_limit"` + AdditionalRateLimits []codexAdditionalRateLimit `json:"additional_rate_limits"` + Credits *codexCredits `json:"credits"` } -func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { - accountID, _ := c.auth.AccountID(ctx) - body, err := fetchWithAuth(ctx, c.auth, func(token string) (*http.Request, error) { +func readCodexUsage(ctx context.Context, auth *auth.CodexAuth) ([]byte, codexUsageResp, error) { + var r codexUsageResp + accountID, _ := auth.AccountID(ctx) + body, err := fetchWithAuth(ctx, auth, func(token string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL(), nil) if err != nil { return nil, err @@ -96,22 +171,24 @@ func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { return req, nil }) if err != nil { - return nil, err + return nil, r, err } - var r codexUsageResp if err := json.Unmarshal(body, &r); err != nil { - return nil, fmt.Errorf("codex usage: parsing response: %w", err) + return nil, r, fmt.Errorf("codex usage: parsing response: %w", err) } + return body, r, nil +} +func codexUsageToUsage(provider string, body []byte, r codexUsageResp, rateLimit codexRateLimit) *usage.Usage { u := &usage.Usage{ - Provider: "codex", + Provider: provider, Plan: r.PlanType, FetchedAt: time.Now(), Raw: body, - LimitReached: r.RateLimit.LimitReached, - FiveHour: codexWindowToUsage(r.RateLimit.Primary), - Weekly: codexWindowToUsage(r.RateLimit.Secondary), + LimitReached: rateLimit.LimitReached, + FiveHour: codexWindowToUsage(rateLimit.Primary), + Weekly: codexWindowToUsage(rateLimit.Secondary), } if r.Credits != nil { u.Credits = &usage.Credits{ @@ -120,7 +197,27 @@ func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { Balance: r.Credits.Balance, } } - return u, nil + return u +} + +func sparkRateLimitFromResponse(r codexUsageResp, model string) (codexRateLimit, error) { + target := normalizeCodexLimitName(model) + for _, additional := range r.AdditionalRateLimits { + if normalizeCodexLimitName(additional.LimitName) == target { + return additional.RateLimit, nil + } + } + return codexRateLimit{}, fmt.Errorf("codex usage: no rate limit found for provider %q model %q", "spark", model) +} + +func normalizeCodexLimitName(s string) string { + var b strings.Builder + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToLower(r)) + } + } + return b.String() } func codexUsageURL() string { @@ -192,19 +289,19 @@ func codexWindowToUsage(w codexWindow) usage.Window { } } -func (c *Codex) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { - prompt := c.cfg.Prompt +func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) (*TriggerResult, error) { + prompt := cfg.Prompt if prompt == "" { prompt = "ok" } args := []string{} - if c.cfg.ReasoningEffort != "" { - args = append(args, "-c", "model_reasoning_effort="+c.cfg.ReasoningEffort) + if cfg.ReasoningEffort != "" { + args = append(args, "-c", "model_reasoning_effort="+cfg.ReasoningEffort) } - if c.cfg.Model != "" { - args = append(args, "-m", c.cfg.Model) + if cfg.Model != "" { + args = append(args, "-m", cfg.Model) } - args = append(args, codexInteractiveArgs(c.cfg.ExtraArgs)...) + args = append(args, codexInteractiveArgs(cfg.ExtraArgs)...) args = append(args, prompt) res := &TriggerResult{Command: "codex " + shellJoin(args)} if dryRun { diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index 507fa16..c8ee5c4 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -67,6 +67,89 @@ func TestCodexReadUsageSendsCompatibleHeaders(t *testing.T) { } } +func TestSparkReadUsageReportsSparkProvider(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","account_id":"account-123"}}` + if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte(authJSON), 0o600); err != nil { + t.Fatal(err) + } + + usageHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{ + "plan_type": "plus", + "rate_limit": { + "limit_reached": false, + "primary_window": {"used_percent": 5, "limit_window_seconds": 18000, "reset_at": 4102444800}, + "secondary_window": {"used_percent": 7, "limit_window_seconds": 604800, "reset_at": 4103049600} + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "limit_reached": false, + "primary_window": {"used_percent": 1, "limit_window_seconds": 18000, "reset_at": 4102444900}, + "secondary_window": {"used_percent": 2, "limit_window_seconds": 604800, "reset_at": 4103049700} + } + } + ] + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })} + + u, err := NewSpark(config.ProviderConfig{}).ReadUsage(context.Background()) + if err != nil { + t.Fatalf("ReadUsage: %v", err) + } + if u.Provider != "spark" || u.Plan != "plus" { + t.Fatalf("usage = %#v, want spark/plus", u) + } + if u.FiveHour.UsedPercent != 1 || u.Weekly.UsedPercent != 2 { + t.Fatalf("windows = %#v %#v", u.FiveHour, u.Weekly) + } +} + +func TestSparkReadUsageRequiresSparkLimit(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","account_id":"account-123"}}` + if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte(authJSON), 0o600); err != nil { + t.Fatal(err) + } + + usageHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{ + "plan_type": "plus", + "rate_limit": { + "limit_reached": false, + "primary_window": {"used_percent": 5, "limit_window_seconds": 18000, "reset_at": 4102444800}, + "secondary_window": {"used_percent": 7, "limit_window_seconds": 604800, "reset_at": 4103049600} + } + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })} + + _, err := NewSpark(config.ProviderConfig{}).ReadUsage(context.Background()) + if err == nil || !strings.Contains(err.Error(), `model "gpt-5.3-codex-spark"`) { + t.Fatalf("ReadUsage error = %v, want missing spark limit", err) + } +} + func TestCodexUsageURLFromBase(t *testing.T) { cases := map[string]string{ "": "https://chatgpt.com/backend-api/wham/usage", @@ -120,6 +203,26 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { } } +func TestSparkTriggerDryRunUsesSparkModel(t *testing.T) { + c := NewSpark(config.ProviderConfig{ + Prompt: "ok", + Model: "gpt-5.3-codex-spark", + ReasoningEffort: "low", + }) + + if c.Name() != "spark" { + t.Fatalf("Name() = %q, want spark", c.Name()) + } + res, err := c.Trigger(context.Background(), true) + if err != nil { + t.Fatalf("dry-run trigger: %v", err) + } + want := "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", diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a5b6b44..9ec0df9 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -45,7 +45,7 @@ func newUsageHTTPClient() *http.Client { // Provider abstracts a single AI coding provider. type Provider interface { - // Name is the stable identifier ("claude", "codex"). + // Name is the stable identifier ("claude", "codex", "spark"). Name() string // ReadUsage fetches the current rate-limit snapshot. This is a read-only // call against the provider's usage endpoint and consumes no quota.