diff --git a/README.md b/README.md index 62a9e56..62342cb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ CCLimitPing;如果只需要一个可嵌入现有 cron/plist/监控体系的 Co - `--dry-run` 在执行模型调用或推送指标前预览操作。 - `--if-5h-full` 只在 5 小时额度恢复到 100% 时执行 ping;此时默认还会结合已配置的 cron 目标做相位对齐:仅在满额且到达对齐时间后才 ping(指标仍正常上报)。 - `--without-align` 关闭相位对齐,只保留满额判断(满额即 ping);仅在配合 `--if-5h-full` 时有意义。不带任何 flag 时 `ping` 立即执行,与对齐无关。 +- `--until-anchored` 持续 ping 直到 5 小时窗口开始计时(被首次使用锚定):起始输入较大,每轮翻倍,每次 ping 后回读用量确认是否已计时,计时后立即停止。 - `align` 子命令管理多条 cron 目标(每条带唯一 id 和自己的延时预算 max-delay,可按 id 单独删除),并预览未来的 ping/刷新时间。 - 将用量、重置券数量和最近成功 ping 时间推送到 Pushgateway。 - 成功 ping 时间持久化,后续上报不会被 `ping_completed=0` 覆盖。 @@ -107,6 +108,7 @@ limitping ping --dry-run limitping ping limitping ping --if-5h-full limitping ping --if-5h-full --without-align +limitping ping --until-anchored limitping align add "0 0 * * *" limitping align list limitping align preview --count 5 @@ -126,6 +128,32 @@ codex exec -m -c model_reasoning_effort=low ping `limitping_ping_completed` 记为 `0`。满足满额条件后,默认还会做相位对齐(见下一节), 只有到达对齐时间才真正 ping;加 `--without-align` 可关闭对齐,恢复“满额即 ping”。 +### 窗口是否已计时(active 判定) + +Codex 的 5 小时滚动窗口按“重置后首次使用”锚定:窗口尚未被使用时,接口返回的 +`reset_at` 是“假设此刻起算、整段窗口之后到期”的滚动占位值,此时 `remaining` 恒等于 +窗口长度、`used_percent` 取整为 0;一旦被首次请求锚定,`reset_at` 冻结、`remaining` +随之低于窗口长度。因此 `status`/指标中的窗口 `active` 判定为:`used_percent > 0`,或 +`remaining` 已明显小于窗口长度(即 `reset_at` 已冻结)——只要满足其一,就表示窗口已开始 +计时。这样即使一次很轻的 ping 消耗不足 1%、`used_percent` 仍显示 0,也能正确反映窗口 +已被锚定。 + +### 让窗口开始计时(`--until-anchored`) + +若你想在额度满时主动把 5 小时窗口“打着”,让它从此刻开始倒数(这样稍后来用时可以少等 +一截),使用 `limitping ping --until-anchored`: + +```sh +limitping ping --until-anchored +``` + +它会先回读用量,若窗口已在计时则直接结束;否则从一个较大的起始输入出发发送 ping,每轮 +把输入长度翻倍以逐步增加单次消耗,每次 ping 后等待片刻再回读用量,一旦检测到窗口开始 +计时立即停止,或到达轮数上限后停止。`--dry-run` 只打印将执行的命令而不实际调用。 + +窗口锚定机制、`active` 判定与该命令的更多细节见 +[docs/five-hour-window-anchoring.md](docs/five-hour-window-anchoring.md)。 + ## 刷新时间对齐(`--if-5h-full` 默认开启) 如果你希望 5h 窗口的刷新时刻尽量落在某个固定时间(例如每晚 00:00,好让 23:00 开始 diff --git a/docs/five-hour-window-anchoring.md b/docs/five-hour-window-anchoring.md new file mode 100644 index 0000000..6c500c8 --- /dev/null +++ b/docs/five-hour-window-anchoring.md @@ -0,0 +1,64 @@ +# 5 小时窗口锚定说明 + +本文说明 Codex 5 小时滚动窗口的“锚定”(首次使用起算)机制,以及 `limitping` 如何 +判断窗口是否已开始计时、如何用 `ping --until-anchored` 主动让窗口开始计时。 + +## 背景:窗口按“首次使用”锚定 + +Codex 的 5 小时额度是滚动窗口,其计时不是从上一个窗口重置的边界自动开始,而是锚定在 +**窗口重置后的第一次请求**。在窗口尚未被使用时: + +- 后端返回的 `reset_at` 是“假设此刻起算、整段窗口之后到期”的**滚动占位值**,会随当前 + 时间一起向后移动; +- 换算出的 `remaining` 恒等于整段窗口长度(5 小时); +- `used_percent` 因尚无消耗而为 0。 + +一旦发生首次请求,窗口被锚定: + +- `reset_at` **冻结**为“锚定时刻 + 5 小时”,不再随当前时间移动; +- `remaining` 随之低于整段窗口长度,并单调递减。 + +因此,“窗口是否已经开始计时”与“消耗了多少额度”是两件事:一次极小的请求即可把窗口 +锚定并启动计时,但它消耗的额度可能不足 1%,四舍五入后 `used_percent` 仍显示 0。 + +## `active` 判定 + +`limitping` 在 `status` 输出和 `limitping_window_active` 指标中给出窗口是否“在计时”。 +判据为下列任一成立(且 `reset_at` 晚于当前时间): + +- `used_percent > 0`;或 +- `remaining` 明显小于整段窗口长度(即 `reset_at` 已冻结)。 + +为容忍占位值在秒级换算时的亚秒截断,`remaining` 与窗口长度的比较保留了数秒的余量。 +采用该判据后,即使一次很轻的 ping 让 `used_percent` 仍显示 0,只要窗口已被锚定, +`active` 也能正确反映为“在计时”。 + +实现见 `internal/usage/usage.go` 中的 `windowCounting` 与 `makeWindow`。 + +## `ping --until-anchored` + +当 5 小时额度处于满额(窗口未计时)状态时,可以主动发送请求把窗口“打着”,让它从此刻 +开始倒数;这样稍后真正开始工作时可以少等一段时间。 + +```sh +limitping ping --until-anchored +``` + +行为: + +1. 先回读一次用量。若窗口已经在计时,直接结束,不再发送请求。 +2. 否则发送 ping,并在每次 ping 后等待片刻再回读用量,确认窗口是否已开始计时。 +3. 为在不过度消耗的前提下提高单轮成功率,起始输入取一个较大的长度,之后每轮把输入 + 长度翻倍逐步增加单次消耗;一旦检测到窗口开始计时立即停止,或到达轮数上限后停止。 + +长输入通过标准输入传给 `codex exec`,避免过长的命令行参数。`--dry-run` 只打印将要执行 +的命令,不实际调用模型。 + +相关参数(起始输入长度、每轮倍增、轮数上限、每轮回读前的等待时长)定义在 +`internal/cli/cli.go`,可按后端计量粒度调整。 + +## 说明 + +- 让窗口开始计时所需的最小请求大小,取决于后端的计量粒度,可能随时间变化; + `--until-anchored` 通过“逐轮增大并回读确认”来适应这一点,而非假设某个固定阈值。 +- `status` 只读取用量、不消耗额度;实际消耗来自 `codex exec`(包括 `ping`)。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1928a99..48c91bc 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -180,6 +180,7 @@ func newPingCmd(options *globalOptions) *cobra.Command { var dryRun bool var ifFull bool var withoutAlign bool + var untilAnchored bool cmd := &cobra.Command{ Use: "ping", Aliases: []string{"p"}, @@ -187,16 +188,18 @@ func newPingCmd(options *globalOptions) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return executePing(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), pingParams{ - dryRun: dryRun, - ifFull: ifFull, - withoutAlign: withoutAlign, - pushEndpoint: options.pushMetric, + dryRun: dryRun, + ifFull: ifFull, + withoutAlign: withoutAlign, + untilAnchored: untilAnchored, + pushEndpoint: options.pushMetric, }) }, } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "只打印将执行的命令") cmd.Flags().BoolVar(&ifFull, "if-5h-full", false, "仅在 5h 可用额度为 100% 时执行;默认会结合已配置的 cron 目标做相位对齐(见 align 子命令),仅在满额且到达对齐时间后才 ping") cmd.Flags().BoolVar(&withoutAlign, "without-align", false, "关闭相位对齐,只保留满额判断(满额即 ping);仅在配合 --if-5h-full 时有意义") + cmd.Flags().BoolVar(&untilAnchored, "until-anchored", false, "持续 ping 直到 5h 窗口开始计时:起始输入较大,每轮翻倍,每次 ping 后回读用量确认是否已锚定;与 --if-5h-full/--without-align 互斥使用") return cmd } @@ -272,13 +275,25 @@ func localizeHelpFlags(cmd *cobra.Command) { } type pingParams struct { - dryRun bool - ifFull bool - withoutAlign bool - pushEndpoint string -} + dryRun bool + ifFull bool + withoutAlign bool + untilAnchored bool + pushEndpoint string +} + +// ping until-anchored 模式的参数:从一个较大的起始长度出发,每轮把 prompt 长度翻倍, +// 每次 ping 后等待片刻再检查 5h 窗口是否已开始计时,直到计时或到达轮数上限。 +const ( + pingAnchorStartRunes = 2000 + pingAnchorMaxRounds = 6 + pingAnchorCheckDelay = 15 * time.Second +) func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, params pingParams) error { + if params.untilAnchored { + return executePingUntilAnchored(ctx, stdout, stderr, params) + } dryRun := params.dryRun ifFull := params.ifFull pushEndpoint := params.pushEndpoint @@ -377,6 +392,94 @@ func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, return wrapStateError(stateErr) } +// executePingUntilAnchored 持续发送 ping 直到 5h 窗口开始计时(被首次使用锚定)。 +// +// 极小的 ping 未必能立刻让窗口进入计时,且窗口是否计时无法从单次调用即时得知, +// 需要回读用量确认。为提高单轮成功率同时避免一开始就发过大的请求,这里从一个较大的 +// 起始 prompt 长度出发,每轮把长度翻倍,逐步增加单次消耗;每次 ping 后等待片刻再回读 +// 用量,一旦检测到窗口在计时立即停止,或到达轮数上限后停止。 +func executePingUntilAnchored(ctx context.Context, stdout, stderr io.Writer, params pingParams) error { + if snapshot, err := readUsage(ctx); err == nil { + if window := snapshot.FiveHour; window != nil && window.Active { + fmt.Fprintln(stdout, "5h 窗口已在计时,无需 ping。") + return maybePush(ctx, stdout, snapshot, params, false, nil) + } + } + + model, err := models.Weakest(ctx) + if err != nil { + return err + } + + runes := pingAnchorStartRunes + for round := 1; round <= pingAnchorMaxRounds; round++ { + prompt := anchorPrompt(runes) + fmt.Fprintf(stdout, "第 %d 轮:发送 ping(输入约 %d 字符,模型 %s)...\n", round, len([]rune(prompt)), model) + if params.dryRun { + fmt.Fprintf(stdout, "将执行: codex exec -m %s -c model_reasoning_effort=low -(stdin 输入约 %d 字符)\n", model, runes) + } else { + if err := runAnchorPing(ctx, stdout, stderr, model, prompt); err != nil { + return fmt.Errorf("第 %d 轮 ping 失败: %w", round, err) + } + _ = pingstate.Record(time.Now()) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pingAnchorCheckDelay): + } + } + + snapshot, err := readUsage(ctx) + if err != nil { + return fmt.Errorf("回读 5h 用量: %w", err) + } + window := snapshot.FiveHour + if params.dryRun { + fmt.Fprintln(stdout, "dry-run:跳过实际 ping 与锚定检查。") + return maybePush(ctx, stdout, snapshot, params, false, nil) + } + if window != nil && window.Active { + fmt.Fprintf(stdout, "5h 窗口已开始计时,%s 后重置。\n", formatDurationCN(time.Duration(window.RemainingSeconds)*time.Second)) + return maybePush(ctx, stdout, snapshot, params, true, nil) + } + fmt.Fprintln(stdout, "窗口尚未开始计时,加大输入后重试。") + runes *= 2 + } + + return fmt.Errorf("已尝试 %d 轮仍未观察到 5h 窗口开始计时", pingAnchorMaxRounds) +} + +// anchorPrompt 生成指定长度的输入,用重复段落放大输入 token,并要求模型只做最小回复。 +func anchorPrompt(runes int) string { + const seg = "分布式限流滚动窗口令牌桶漏桶共识算法幂等背压。" + if runes < 1 { + runes = 1 + } + segRunes := []rune(seg) + body := make([]rune, 0, runes) + for len(body) < runes { + body = append(body, segRunes...) + } + body = body[:runes] + return "这是背景资料,只需回复两个字「已读」,不要复述:\n" + string(body) +} + +// runAnchorPing 通过 stdin 把长输入喂给 codex exec,避免超长命令行参数。 +func runAnchorPing(ctx context.Context, stdout, stderr io.Writer, model, prompt string) error { + cmd := exec.CommandContext(ctx, "codex", "exec", "-m", model, "-c", "model_reasoning_effort=low", "-") + cmd.Stdin = strings.NewReader(prompt) + cmd.Stdout, cmd.Stderr = stdout, stderr + return cmd.Run() +} + +// maybePush 在配置了 --push-metric 时推送快照,否则直接返回。 +func maybePush(ctx context.Context, stdout io.Writer, snapshot *usage.Snapshot, params pingParams, pingCompleted bool, planner *align.Planner) error { + if params.pushEndpoint == "" { + return nil + } + return pushSnapshot(ctx, stdout, snapshot, 0, params.pushEndpoint, params.dryRun, pingCompleted, planner) +} + func pushSnapshot(ctx context.Context, out io.Writer, snapshot *usage.Snapshot, collectionDuration time.Duration, endpoint string, dryRun, pingCompleted bool, planner *align.Planner) error { lastSuccessfulPing, err := pingstate.LastSuccessfulPing() if err != nil { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index dd14c42..7a92af7 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -53,6 +53,26 @@ func TestShellJoin(t *testing.T) { } } +func TestAnchorPromptLength(t *testing.T) { + for _, n := range []int{1, 100, 2000, 4000} { + prompt := anchorPrompt(n) + body := strings.TrimPrefix(prompt, "这是背景资料,只需回复两个字「已读」,不要复述:\n") + if got := len([]rune(body)); got != n { + t.Fatalf("anchorPrompt(%d) body runes = %d", n, got) + } + } +} + +func TestPingUntilAnchoredFlag(t *testing.T) { + var out bytes.Buffer + if err := Run([]string{"ping", "--help"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "--until-anchored") { + t.Fatalf("ping help missing --until-anchored:\n%s", out.String()) + } +} + func TestCobraHelp(t *testing.T) { var out bytes.Buffer if err := Run([]string{"--help"}, strings.NewReader(""), &out, &out); err != nil { diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 999b6e8..8b87d7d 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -227,6 +227,10 @@ func classifyWindows(rate rateLimit, now time.Time) (five, weekly *Window) { return five, weekly } +// windowCountingSlackSeconds 容忍占位值因亚秒截断带来的偏差:窗口尚未开始计时时, +// 计算出的 remaining 约等于整段窗口长度(或少 1 秒)。 +const windowCountingSlackSeconds = 5 + func makeWindow(raw backendWindow, now time.Time) *Window { remaining := int64(0) reset := "" @@ -235,10 +239,10 @@ func makeWindow(raw backendWindow, now time.Time) *Window { resetTime := time.Unix(raw.ResetAt, 0) reset = resetTime.Format(time.RFC3339) remaining = int64(resetTime.Sub(now).Seconds()) - active = raw.UsedPercent > 0 && resetTime.After(now) if remaining < 0 { remaining = 0 } + active = resetTime.After(now) && windowCounting(raw.UsedPercent, remaining, raw.LimitWindowSeconds) } remainingPercent := 100 - raw.UsedPercent if remainingPercent < 0 { @@ -250,6 +254,23 @@ func makeWindow(raw backendWindow, now time.Time) *Window { } } +// windowCounting 判断滚动窗口是否已经开始倒计时(即已被首次使用锚定)。 +// +// 后端在窗口尚未被使用时,reset_at 会返回“假设此刻起算、整段窗口之后到期”的滚动 +// 占位值,此时 remaining 恒等于窗口长度且 used_percent 取整为 0;窗口一旦被首次 +// 请求锚定,reset_at 冻结,remaining 随之低于窗口长度。因此只要满足以下任一条件即 +// 认为窗口在计时:used_percent 已经能反映出非零消耗,或 remaining 明显小于窗口长度 +// (用 slack 容忍占位值的亚秒截断)。windowSeconds 缺失时退回到 used_percent 判据。 +func windowCounting(usedPercent float64, remainingSeconds int64, windowSeconds int) bool { + if usedPercent > 0 { + return true + } + if windowSeconds <= 0 { + return false + } + return remainingSeconds < int64(windowSeconds)-windowCountingSlackSeconds +} + func usageURL(base string) string { base = strings.TrimRight(strings.TrimSpace(base), "/") if base == "" { diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go index 9b447c0..ccb1e9d 100644 --- a/internal/usage/usage_test.go +++ b/internal/usage/usage_test.go @@ -30,6 +30,35 @@ func TestClassifyWindowsByDuration(t *testing.T) { } } +func TestWindowActiveByCounting(t *testing.T) { + const window = 18000 + now := time.Unix(100000, 0) + + // 未被使用、reset_at 为“此刻起算整段窗口后到期”的滚动占位:remaining≈window,应视为未计时。 + placeholder := makeWindow(backendWindow{UsedPercent: 0, LimitWindowSeconds: window, ResetAt: now.Unix() + window}, now) + if placeholder.Active { + t.Fatalf("placeholder window should be inactive: %#v", placeholder) + } + + // used_percent 取整为 0,但 reset_at 已冻结、remaining 明显小于窗口长度:应视为已计时。 + counting := makeWindow(backendWindow{UsedPercent: 0, LimitWindowSeconds: window, ResetAt: now.Unix() + window - 600}, now) + if !counting.Active { + t.Fatalf("counting window with frozen reset should be active: %#v", counting) + } + + // used_percent > 0 时无条件视为已计时。 + used := makeWindow(backendWindow{UsedPercent: 3, LimitWindowSeconds: window, ResetAt: now.Unix() + window}, now) + if !used.Active { + t.Fatalf("window with non-zero usage should be active: %#v", used) + } + + // reset_at 已过期:无论如何都视为未生效。 + expired := makeWindow(backendWindow{UsedPercent: 50, LimitWindowSeconds: window, ResetAt: now.Unix() - 10}, now) + if expired.Active { + t.Fatalf("expired window should be inactive: %#v", expired) + } +} + func TestUsageURL(t *testing.T) { if got := usageURL("https://chatgpt.com/backend-api"); got != "https://chatgpt.com/backend-api/wham/usage" { t.Fatalf("got %q", got)