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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- 远程 Claude Docker 槽改用镜像烘焙 kernel 入口(`/opt/kin/kin-kernel`,`REMOTE_KERNEL_ENTRY`),不改 GPT 与本机/KVM 命令;新建或重建容器后生效。(#489、#496)
- 修复 Codex 返回配额耗尽(如 `usage_limit_reached`)且无利用率响应头时仍展示过期低用量:作废该槽未达上限窗口的过期利用率缓存,面板显示为未测量(`—`),保留新鲜测量与已封顶窗口;通用限流与成功无头响应行为不变。(#474、#487)
- `docker/kin-kvm/fetch-cloud.py` 在调用 `qemu-img` 前校验源路径为常规文件且非符号链接,收紧路径处理(CWE-78 防御加深)。(#471)
- 用量探测可选 `usage_probe.reset_delay_sec` / `reset_jitter_sec`(各 0–3600,默认 0):在已知 Extra 5h reset 后,将已到期的周期探测推迟到 `reset + delay + 按账号稳定偏移`;不改官方退避、手动/事件探测与 Messages 健康监视;快照暴露待探测的 `next_probe_at`。(#488、#497)

## 1.3.181 — 2026-10-17

Expand Down
20 changes: 20 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,26 @@ curl -sS http://127.0.0.1:8787/v1/usage -H "Authorization: Bearer $KEY"



### 周期额度查询的重置后错峰

`PUT /api/panel/routing` 可设置(沿用面板鉴权;不是手动 `/probe` 请求体):

```json
{
"usage_probe": {
"enabled": true,
"reset_delay_sec": 120,
"reset_jitter_sec": 600
}
}
```

两项均默认 `0`、范围 `0`–`3600` 秒,省略时保留旧行为。对已到期且带有效重置时间的 Extra `5h` 窗口,周期查询的最早时间为 `reset + reset_delay_sec + 每账号稳定偏移`;偏移在 `0`–`reset_jitter_sec` 内,由账号身份和该轮 reset 确定,重复 tick、进程重启及列表重排不会重新抽签。上例为重置后 120–720 秒;实际执行仍受 `interval_sec` 的 tick 粒度、并发、单飞及退避限制,不保证每个账号不同秒执行。等待项在 `GET /api/panel/usage-probe` 的快照中显示 `reason: "reset_probe_delay"` 和 `next_probe_at`(最早时间,不是预约保证)。

这只调整**周期额度探测链路的执行时间**,不新增探测:现有链路以官方 `GET /api/oauth/usage` 查询为主,满足套餐复核条件时仍可能执行现有 Fable Messages 探测;本改动不改变这些条件。没有有效已到期 5h reset 的未采样账号、仅 7d 到期或无有效 5h reset 的账号保留原逻辑;已有 `last_probe.ok === true` 且探测时间不早于本轮 reset 的账号不再被旧 reset 延迟,仅由真实流量更新的 `headers.sampled_at` 不触发该豁免。官方 usage 退避优先,主动/过期样本和套餐复核路径不会绕过等待。主动调用 `POST /api/panel/usage-probe` 同样运行周期规则;手动 `POST /api/panel/probe`、事件 `probeNow`、真实用户流量及独立 Messages 健康检查不受此延迟影响。

因此,这些设置**不保证上游 5h 计费/额度窗口错开**:官方 usage GET 是否会开启窗口没有得到验证,既有 Fable Messages、真实流量及独立 Messages 健康检查也可能触发新窗口。延迟会让面板/额度恢复信息更晚刷新;需要即时查询时使用手动 `/probe`。当前仅提供 routing/API 配置,不新增面板控件。

## 健康

```bash
Expand Down
38 changes: 38 additions & 0 deletions src/lib/oauth/usage-probe-monitor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* wipe would otherwise pin the panel at 0 while usage_logs keep growing.
* Those slots get one /usage hop, then back off.
*/
import { createHash } from 'node:crypto'
import { shouldProbeFable, usageProbeBackoffRemainingMs } from './crs-usage-probe.mjs'
import { hasRefreshPresence } from './oauth-credentials.mjs'
import { vmHasProxyPath } from './credential-refresh-monitor.mjs'
Expand All @@ -17,6 +18,9 @@ export const DEFAULT_USAGE_PROBE = Object.freeze({
enabled: false,
interval_sec: 60,
stale_sec: 300,
// Opt-in: defer the first periodic hop after an Extra 5h reset.
reset_delay_sec: 0,
reset_jitter_sec: 0,
run_on_start: true,
concurrency: 2,
timeout_ms: 60_000,
Expand Down Expand Up @@ -45,6 +49,8 @@ export function normalizeUsageProbeConfig(raw = {}) {
enabled: asBool(src.enabled, DEFAULT_USAGE_PROBE.enabled),
interval_sec: clampInt(src.interval_sec, 30, 3600, DEFAULT_USAGE_PROBE.interval_sec),
stale_sec: clampInt(src.stale_sec, 60, 86_400, DEFAULT_USAGE_PROBE.stale_sec),
reset_delay_sec: clampInt(src.reset_delay_sec, 0, 3600, DEFAULT_USAGE_PROBE.reset_delay_sec),
reset_jitter_sec: clampInt(src.reset_jitter_sec, 0, 3600, DEFAULT_USAGE_PROBE.reset_jitter_sec),
run_on_start: asBool(src.run_on_start, DEFAULT_USAGE_PROBE.run_on_start),
concurrency: clampInt(src.concurrency, 1, 8, DEFAULT_USAGE_PROBE.concurrency),
timeout_ms: clampInt(src.timeout_ms, 10_000, 180_000, DEFAULT_USAGE_PROBE.timeout_ms),
Expand Down Expand Up @@ -148,6 +154,27 @@ export function isUsageProbeDue(account = {}, opts = {}) {
return { due: true, reason: 'extra_window_elapsed' }
}

/** A stable deadline, not a sleep or a fresh random draw on each timer tick.
* Only gates an already-due periodic hop; never creates extra requests or delays
* real traffic, manual probeNow, or the separate Messages health monitor.
*/
function resetProbeAt(account, vm, config, now) {
if (!config.reset_delay_sec && !config.reset_jitter_sec) return null
const unified = account?.unified || {}
const reset = parseResetMs(unified.headers?.['5h']?.reset)
if (!Number.isFinite(reset) || reset > now) return null
const probe = account?.last_probe || unified.last_probe
const probed = Date.parse(probe?.at || probe?.probed_at || '')
if (probe?.ok === true && Number.isFinite(probed) && probed >= reset) return null
const id = String(account?.account_id || vm.claude?.account_uuid || vm.account_uuid || vm.id)
const hash = createHash('sha256')
.update(JSON.stringify([id, reset]))
.digest()
.readUInt32BE(0)
const jitter = hash % (config.reset_jitter_sec + 1)
return reset + (config.reset_delay_sec + jitter) * 1000
}

/** Queued probes beyond the running `concurrency` workers; more are dropped, never blocked on. */
const MAX_QUEUED_PROBES = 64
const PROBE_BACKOFF_BASE_MS = 60_000
Expand Down Expand Up @@ -275,6 +302,17 @@ export function createUsageProbeMonitor(opts = {}) {
})
continue
}
const nextProbeAt = resetProbeAt(account, vm, config, nowFn())
if (nextProbeAt != null && nextProbeAt > nowFn()) {
items.push({
vm_id: vm.id,
ok: true,
reason: 'reset_probe_delay',
source: null,
next_probe_at: new Date(nextProbeAt).toISOString(),
})
continue
}
due.push({ vm, reason: decision.reason })
}
items.push(...(await Promise.all(due.map(({ vm, reason }) => probeVm(vm, reason)))))
Expand Down
183 changes: 183 additions & 0 deletions test/unit/usage-probe-reset-delay.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createUsageProbeMonitor, normalizeUsageProbeConfig } from '../../src/lib/oauth/usage-probe-monitor.mjs'

const reset = Date.parse('2026-10-17T01:00:00.000Z')
const vm = { id: 'vm-01', status: 'running', has_refresh: true, proxy: { host: '127.0.0.1', port: 1080 } }
function account(id = 'account-a', extra = {}) {
return {
account_id: id,
unified: {
headers: { '5h': { utilization: 1, reset: new Date(reset).toISOString(), stale_reason: 'reset_elapsed' } },
...extra,
},
}
}
function fixture({ config = {}, accounts = [account()], active = false, result = { ok: true } } = {}) {
let now = reset
const calls = []
const targets = accounts.map((_, i) => ({ ...vm, id: `vm-${String(i + 1).padStart(2, '0')}` }))
const monitor = createUsageProbeMonitor({
config: { reset_delay_sec: 120, reset_jitter_sec: 600, ...config },
now: () => now,
listTargets: () => targets,
accountForVm: (target) => accounts[targets.indexOf(target)],
isActive: () => active,
probeOne: async (target) => {
calls.push({ id: target.id, at: now })
return result
},
})
return {
monitor,
calls,
setNow: (n) => {
now = n
},
}
}

test('reset delay is opt-in and bounded; normalization preserves zero', () => {
const defaults = normalizeUsageProbeConfig()
assert.equal(defaults.reset_delay_sec, 0)
assert.equal(defaults.reset_jitter_sec, 0)
const cfg = normalizeUsageProbeConfig({ reset_delay_sec: 9000, reset_jitter_sec: -1 })
assert.equal(cfg.reset_delay_sec, 3600)
assert.equal(cfg.reset_jitter_sec, 0)
assert.equal(normalizeUsageProbeConfig({ reset_delay_sec: '120', reset_jitter_sec: '600' }).reset_jitter_sec, 600)
})

test('default settings retain immediate elapsed-window probing', async () => {
const f = fixture({ config: { reset_delay_sec: 0, reset_jitter_sec: 0 } })
assert.equal((await f.monitor.runOnce()).due, 1)
assert.equal(f.calls.length, 1)
})

test('elapsed 5h accounts have stable per-account deadlines, not one fleet deadline', async () => {
const accounts = [account('account-a'), account('account-b'), account('account-c')]
const f = fixture({ accounts })
const run = await f.monitor.runOnce()
assert.equal(run.due, 0)
assert.equal(f.calls.length, 0)
const times = run.items.map((item) => {
assert.equal(item.reason, 'reset_probe_delay')
const at = Date.parse(item.next_probe_at)
assert.ok(at >= reset + 120_000 && at <= reset + 720_000)
return at
})
assert.ok(new Set(times).size > 1)
// Repeated ticks cannot redraw jitter and postpone a deadline indefinitely.
f.setNow(reset + 1000)
assert.deepEqual(
(await f.monitor.runOnce()).items.map((x) => x.next_probe_at),
run.items.map((x) => x.next_probe_at),
)
// Restart and VM-list ordering changes do not move an account's deadline.
const reversed = fixture({ accounts: [...accounts].reverse() })
assert.deepEqual(
(await reversed.monitor.runOnce()).items.map((x) => x.next_probe_at).reverse(),
run.items.map((x) => x.next_probe_at),
)
f.setNow(reset + 720_000)
assert.equal((await f.monitor.runOnce()).due, 3)
assert.equal(f.calls.length, 3)
})

test('fixed post-reset delay releases exactly at the boundary', async () => {
const f = fixture({ config: { reset_jitter_sec: 0 } })
f.setNow(reset + 119_999)
assert.equal((await f.monitor.runOnce()).due, 0)
f.setNow(reset + 120_000)
assert.equal((await f.monitor.runOnce()).due, 1)
assert.equal(f.calls[0].at, reset + 120_000)
})

for (const extra of [{}, { account_tier: 'pro', fable: { plan_denied: true } }]) {
test(`active/stale and tier-recheck paths cannot bypass a pending reset delay (${extra.account_tier || 'unknown'})`, async () => {
const f = fixture({ accounts: [account('account-a', extra)], active: true })
assert.equal((await f.monitor.runOnce()).due, 0)
assert.equal(f.calls.length, 0)
})
}

test('manual/event probe remains immediate and shares existing backoff', async () => {
const f = fixture({ result: { ok: false } })
assert.equal((await f.monitor.runOnce()).due, 0)
assert.equal((await f.monitor.probeNow('vm-01')).ok, false)
assert.equal(f.calls.length, 1)
assert.equal((await f.monitor.probeNow('vm-01')).reason, 'probe_backoff')
assert.equal(f.calls.length, 1)
})

test('a successful post-reset sample is not held again by the old reset', async () => {
const a = account()
const f = fixture({ accounts: [a], active: true, config: { reset_delay_sec: 600, reset_jitter_sec: 0 } })
assert.equal((await f.monitor.runOnce()).due, 0)
a.unified.last_probe = { at: new Date(reset + 1000).toISOString(), ok: true }
f.setNow(reset + 400_000)
// Active freshness is overdue; a prior post-reset hop already consumed the delay.
assert.equal((await f.monitor.runOnce()).due, 1)
})

test('official usage backoff still wins and no wait item erases it', async () => {
const f = fixture({
accounts: [account('account-a', { usage_rate_limited_until: new Date(reset + 900_000).toISOString() })],
})
const run = await f.monitor.runOnce()
assert.equal(run.due, 0)
assert.equal(f.calls.length, 0)
assert.equal(run.items[0].reason, 'reconcile_extra')
})

test('seven-day-only elapsed and never-sampled accounts are not delayed', async () => {
const f = fixture({
accounts: [
account('weekly', { headers: { '7d': { reset: new Date(reset).toISOString() } } }),
{ account_id: 'new' },
],
})
assert.equal((await f.monitor.runOnce()).due, 2)
})

test('missing/invalid 5h reset cannot invent a deadline', async () => {
const f = fixture({
accounts: [account('unknown', { headers: { '5h': { stale_reason: 'reset_elapsed', reset: 'invalid' } } })],
})
assert.equal((await f.monitor.runOnce()).due, 1)
})

test('future 5h reset does not delay active freshness sampling', async () => {
const f = fixture({
accounts: [
account('future', { headers: { '5h': { reset: new Date(reset + 3600_000).toISOString(), utilization: 0.2 } } }),
],
active: true,
})
assert.equal((await f.monitor.runOnce()).due, 1)
})

test('routing updates preserve delay config and propagate it to the monitor', async () => {
const { createRoutingRuntime } = await import('../../src/lib/admin/routing-runtime.mjs')
const f = fixture({ config: { reset_delay_sec: 0, reset_jitter_sec: 0 } })
const ctx = {
routingConfig: {},
usageProbeMonitor: { setConfig: (cfg) => f.monitor.setConfig(cfg, { restart: false }) },
}
const runtime = createRoutingRuntime(ctx)
runtime.applyUsageProbeRoutingBody({ usage_probe: { reset_delay_sec: 120, reset_jitter_sec: 600 } })
assert.equal(ctx.routingConfig.usage_probe.reset_delay_sec, 120)
assert.equal(f.monitor.getConfig().reset_jitter_sec, 600)
assert.equal((await f.monitor.runOnce()).due, 0)
runtime.applyUsageProbeRoutingBody({ usage_probe: { concurrency: 1 } }, ctx.routingConfig.usage_probe)
assert.equal(f.monitor.getConfig().reset_delay_sec, 120)
assert.equal(f.monitor.getConfig().reset_jitter_sec, 600)
runtime.applyUsageProbeRoutingBody({ usage_probe: { reset_delay_sec: 0, reset_jitter_sec: 0 } })
assert.equal((await f.monitor.runOnce()).due, 1)
})

test('config changes apply without retaining stale delay state', async () => {
const f = fixture()
assert.equal((await f.monitor.runOnce()).due, 0)
f.monitor.setConfig({ reset_delay_sec: 0, reset_jitter_sec: 0 }, { restart: false })
assert.equal((await f.monitor.runOnce()).due, 1)
})
Loading