Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 覆盖。
Expand Down Expand Up @@ -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
Expand All @@ -126,6 +128,32 @@ codex exec -m <model> -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 开始
Expand Down
64 changes: 64 additions & 0 deletions docs/five-hour-window-anchoring.md
Original file line number Diff line number Diff line change
@@ -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`)。
121 changes: 112 additions & 9 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,23 +180,26 @@ 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"},
Short: "用当前最弱的可见模型发送最小 ping",
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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 22 additions & 1 deletion internal/usage/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand All @@ -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 {
Expand All @@ -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 == "" {
Expand Down
29 changes: 29 additions & 0 deletions internal/usage/usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading