feat: add 5h fixed interval reset mode for rate limiting#972
feat: add 5h fixed interval reset mode for rate limiting#972sususu98 wants to merge 1 commit intoding113:devfrom
Conversation
Support both "rolling" (sliding window) and "fixed" (interval from anchor) modes for 5-hour quota limits on users, keys, and providers. Uses atomic Lua script for Redis INCRBYFLOAT+EXPIRE to prevent TTL-less keys on crash. - Add fiveHourResetMode/fiveHourResetAnchor columns to users/keys/providers - Implement fixed block calculation from anchor timestamp in time-utils - Add lease window identity tracking for mode-switch cache invalidation - Add TRACK_COST_5H_FIXED_WINDOW Lua script for atomic cost tracking - Extract strict datetime validation to date-input.ts - Add UI controls for 5h reset mode/anchor across all entity forms - Add i18n support for all 5 languages - Add comprehensive tests for anchor parsing, timezone handling, and validation
📝 WalkthroughWalkthrough该PR为 Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Warning Gemini encountered an error creating the summary. You can try again by commenting |
| type TranslationFunction = (key: string, values?: Record<string, string>) => string; | ||
|
|
||
| function validateFiveHourResetAnchor( | ||
| anchor: Date, | ||
| tError: TranslationFunction | ||
| ): { error: string; errorCode: string } | null { | ||
| if (Number.isNaN(anchor.getTime())) { | ||
| return { | ||
| error: tError("INVALID_FORMAT", { field: tError("FIVE_HOUR_RESET_ANCHOR_FIELD") }), | ||
| errorCode: ERROR_CODES.INVALID_FORMAT, | ||
| }; | ||
| } | ||
|
|
||
| if (isFutureDate(anchor)) { | ||
| return { | ||
| error: tError("FIVE_HOUR_RESET_ANCHOR_MUST_NOT_BE_FUTURE"), | ||
| errorCode: ERROR_CODES.FIVE_HOUR_RESET_ANCHOR_MUST_NOT_BE_FUTURE, |
There was a problem hiding this comment.
validateFiveHourResetAnchor duplicated across three action files
An identical function (or near-identical: keys.ts uses a local TranslationFunction alias while providers.ts and users.ts use Awaited<ReturnType<typeof getTranslations<"errors">>>) is defined in each of keys.ts, providers.ts, and users.ts. Any future change to the validation rules (e.g., adding a minimum-age check) must be made in three places and is prone to drift.
The same pattern was already solved for parseDateInputAsTimezone and isFutureDate being shared from src/lib/utils/date-input.ts. Consider extracting validateFiveHourResetAnchor into a shared utility module (e.g. src/lib/rate-limit/anchor-validation.ts or alongside date-input.ts) that all three action files import.
The same duplication exists at src/actions/providers.ts:122-143 and src/actions/users.ts:297-318.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/keys.ts
Line: 36-52
Comment:
**`validateFiveHourResetAnchor` duplicated across three action files**
An identical function (or near-identical: `keys.ts` uses a local `TranslationFunction` alias while `providers.ts` and `users.ts` use `Awaited<ReturnType<typeof getTranslations<"errors">>>`) is defined in each of `keys.ts`, `providers.ts`, and `users.ts`. Any future change to the validation rules (e.g., adding a minimum-age check) must be made in three places and is prone to drift.
The same pattern was already solved for `parseDateInputAsTimezone` and `isFutureDate` being shared from `src/lib/utils/date-input.ts`. Consider extracting `validateFiveHourResetAnchor` into a shared utility module (e.g. `src/lib/rate-limit/anchor-validation.ts` or alongside `date-input.ts`) that all three action files import.
The same duplication exists at `src/actions/providers.ts:122-143` and `src/actions/users.ts:297-318`.
How can I resolve this? If you propose a fix, please make it concise.| five_hour_reset_mode: provider.fiveHourResetMode, | ||
| five_hour_reset_anchor: provider.fiveHourResetAnchor ?? provider.createdAt ?? undefined, |
There was a problem hiding this comment.
Raw anchor passed without
resolveEffective5hFixedAnchor validation
In rate-limit-guard.ts the anchor is pre-resolved before passing it to the service:
const key5hAnchor = resolveEffective5hFixedAnchor(key.fiveHourResetAnchor, key.createdAt);But here and at line 1050, the anchor is passed raw:
five_hour_reset_anchor: provider.fiveHourResetAnchor ?? provider.createdAt ?? undefined,The service layer does call resolveEffective5hFixedAnchor on whatever is passed in, so the behaviour is ultimately the same. But the asymmetry is confusing for readers and could cause unexpected behaviour if the service's internal handling ever changes. For consistency, consider applying resolveEffective5hFixedAnchor at the call site here too, matching the pattern used in rate-limit-guard.ts.
The same applies at line 1050 and in response-handler.ts lines ~3449-3450.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/provider-selector.ts
Line: 649-650
Comment:
**Raw anchor passed without `resolveEffective5hFixedAnchor` validation**
In `rate-limit-guard.ts` the anchor is pre-resolved before passing it to the service:
```ts
const key5hAnchor = resolveEffective5hFixedAnchor(key.fiveHourResetAnchor, key.createdAt);
```
But here and at line 1050, the anchor is passed raw:
```ts
five_hour_reset_anchor: provider.fiveHourResetAnchor ?? provider.createdAt ?? undefined,
```
The service layer does call `resolveEffective5hFixedAnchor` on whatever is passed in, so the behaviour is ultimately the same. But the asymmetry is confusing for readers and could cause unexpected behaviour if the service's internal handling ever changes. For consistency, consider applying `resolveEffective5hFixedAnchor` at the call site here too, matching the pattern used in `rate-limit-guard.ts`.
The same applies at line 1050 and in `response-handler.ts` lines ~3449-3450.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Code Review Summary
This PR adds configurable 5-hour fixed interval reset mode for rate limiting, complementing the existing rolling window behavior. The implementation is well-structured with proper database schema changes, Redis atomic operations via Lua scripts, and comprehensive UI/i18n support.
PR Size: XL
- Lines changed: 7,621 (7,320 additions + 301 deletions)
- Files changed: 78
Note: This is a large PR. Consider splitting future features of similar scope into smaller, focused PRs for easier review:
- Schema + core rate-limit logic (lease, service, time-utils)
- Actions (keys, providers, users)
- UI components + i18n
Issues Found
| Category | Critical | High | Medium | Low |
|---|---|---|---|---|
| Logic/Bugs | 0 | 0 | 0 | 0 |
| Security | 0 | 0 | 0 | 0 |
| Error Handling | 0 | 0 | 0 | 0 |
| Types | 0 | 0 | 0 | 0 |
| Comments/Docs | 0 | 0 | 0 | 0 |
| Tests | 0 | 0 | 0 | 0 |
| Simplification | 0 | 0 | 0 | 0 |
Key Implementation Highlights
Positive patterns observed:
- Atomic Redis operations:
TRACK_COST_5H_FIXED_WINDOWLua script correctly uses INCRBYFLOAT + EXPIRE atomically to prevent TTL-less keys on crash - Cache invalidation:
buildLeaseWindowIdentitycorrectly invalidates cache when switching between fixed/rolling modes - Timezone awareness: Anchor timestamps are properly parsed using system timezone via
parseDateInputAsTimezone - Fail-open design: Rate limiting gracefully falls back to database queries when Redis is unavailable
- i18n coverage: All 5 languages (zh-CN, zh-TW, en, ja, ru) properly supported
Review Coverage
- Logic and correctness - Clean
- Security (OWASP Top 10) - Clean
- Error handling - Clean (proper logging, fail-open patterns)
- Type safety - Clean
- Documentation accuracy - Clean
- Test coverage - Adequate (time-utils, lease, actions, UI forms)
- Code clarity - Good
Automated review by Claude AI
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: be389fab0b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const year = d.getFullYear(); | ||
| const month = String(d.getMonth() + 1).padStart(2, "0"); | ||
| const day = String(d.getDate()).padStart(2, "0"); | ||
| const hours = String(d.getHours()).padStart(2, "0"); | ||
| const minutes = String(d.getMinutes()).padStart(2, "0"); |
There was a problem hiding this comment.
Format datetime-local values in configured system timezone
formatDateToDatetimeLocal renders using the browser's local clock (getFullYear/getHours), but the submitted value is later parsed on the server with parseDateInputAsTimezone(..., resolveSystemTimezone()). When an admin's browser timezone differs from the configured system timezone, existing 5h anchors are shown shifted and saving the displayed value stores a different instant, which moves fixed-window reset boundaries unexpectedly.
Useful? React with 👍 / 👎.
| const ttl5hKey = await getTTLForPeriodWithMode("5h", "00:00", "fixed", key5hAnchor); | ||
| const key5hFixed = RateLimitService.resolve5hFixedCostKey("key", keyId, key5hAnchor, nowDate); |
There was a problem hiding this comment.
Derive fixed-window TTL from the same timestamp as key suffix
In fixed 5h mode, the Redis key suffix is computed from nowDate (which comes from createdAtMs), but TTL is computed via getTTLForPeriodWithMode, which uses the current wall clock internally. If a request starts before a block boundary and finishes after it, cost is written to the previous block key with a fresh TTL for the new block, so expired block keys can linger for hours and accumulate under long-running traffic.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/lib/security/api-key-auth-cache.ts (1)
145-157:⚠️ Potential issue | 🟠 Major旧缓存命中时会把新加的
fiveHourResetMode漏成undefined。这里补了
fiveHourResetAnchor,但CACHE_VERSION仍然是1,hydrate 逻辑也没有校验/兜底fiveHourResetMode。上线后命中旧版缓存 payload 时,Key/User会以缺少 mode 的形态返回,直到 TTL 过期;新限流逻辑如果直接读取这个字段,就会拿到不完整配置。建议至少在 hydrate 阶段把缺失值回填为"rolling",或者直接 bump cache version。建议修复
function hydrateKeyFromCache(keyString: string, payload: CachedKeyPayloadV1): Key | null { const key = payload.key as unknown as Record<string, unknown>; if (!key || typeof key !== "object") return null; @@ + const fiveHourResetMode = + key.fiveHourResetMode === "fixed" || key.fiveHourResetMode === "rolling" + ? key.fiveHourResetMode + : "rolling"; + return { ...(payload.key as Omit<Key, "key">), key: keyString, @@ + fiveHourResetMode, fiveHourResetAnchor: fiveHourResetAnchor === undefined ? undefined : fiveHourResetAnchor, } as Key; } @@ + const fiveHourResetMode = + user.fiveHourResetMode === "fixed" || user.fiveHourResetMode === "rolling" + ? user.fiveHourResetMode + : "rolling"; + return { ...(payload.user as User), @@ + fiveHourResetMode, fiveHourResetAnchor: fiveHourResetAnchor === undefined ? undefined : fiveHourResetAnchor, } as User; }Based on learnings: explicitly document and enforce distinct meanings for null and undefined.
Also applies to: 177-189
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/security/api-key-auth-cache.ts` around lines 145 - 157, The hydrate logic in src/lib/security/api-key-auth-cache.ts is not backfilling the new fiveHourResetMode field (so old cache hits yield undefined mode); update the hydrate function that builds the cached Key payload (the same place where fiveHourResetAnchor is parsed) to default missing fiveHourResetMode to "rolling" (or alternatively increment CACHE_VERSION) so old cached payloads are normalized; ensure the returned object includes fiveHourResetMode: payload.fiveHourResetMode ?? "rolling" and apply the same fix where similar hydration occurs later in the file.
🧹 Nitpick comments (4)
tests/integration/usage-ledger.test.ts (1)
281-323: 改进建议:保持超时配置格式的一致性。该测试的重构本身是正确的,添加 60 秒超时对于涉及数据库操作的集成测试是合理的。但是,该测试使用了多行格式:
test( "test name", { timeout: 60_000 }, async () => { ... } )而第 325 行的另一个超时测试使用了内联格式:
test("test name", { timeout: 60_000 }, async () => { ... })建议统一这两个测试的格式风格,以提高代码的可维护性和可读性。
♻️ 建议的一致性改进
将第 325 行的测试也改为多行格式:
- test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { + test( + "backfill is idempotent when running twice", + { timeout: 60_000 }, + async () => { const requestId = await insertMessageRequestRow({或者将第 281-323 行的测试改为内联格式(如果字符数不超过 100):
- test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { - timeout: 60_000, - }, - async () => { + test("backfill copies non-warmup message_request rows when ledger rows are missing", { timeout: 60_000 }, async () => { const userId = nextUserId();不过,考虑到第一个测试名称较长,多行格式可能更具可读性,因此建议采用第一种方案。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/integration/usage-ledger.test.ts` around lines 281 - 323, The test "backfill copies non-warmup message_request rows when ledger rows are missing" uses the multi-line test(...) timeout style while another nearby test uses the inline style; make the styles consistent by converting the other test that currently calls test("...", { timeout: 60_000 }, async () => { ... }) to the multi-line form used here (test(\n "description",\n { timeout: 60_000 },\n async () => { ... }\n)); update the other test invocation accordingly so both use the same multi-line formatting.src/app/[locale]/dashboard/_components/user/user-key-manager.tsx (1)
43-66: 建议抽取keyOwnerUser构造逻辑,避免双分支字段漂移Line 59-60 和 Line 115-116 需要在两处同步维护相同字段。本次已出现重复扩展,后续继续加字段时容易漏改。
可选重构示例
+ const buildKeyOwnerUser = (user: UserDisplay | undefined) => + user + ? { + id: user.id, + name: user.name, + description: user.note || "", + role: user.role, + rpm: user.rpm, + dailyQuota: user.dailyQuota, + providerGroup: user.providerGroup || "default", + createdAt: new Date(), + updatedAt: new Date(), + limit5hUsd: user.limit5hUsd ?? undefined, + limitWeeklyUsd: user.limitWeeklyUsd ?? undefined, + limitMonthlyUsd: user.limitMonthlyUsd ?? undefined, + limitConcurrentSessions: user.limitConcurrentSessions ?? undefined, + fiveHourResetMode: user.fiveHourResetMode ?? "rolling", + fiveHourResetAnchor: user.fiveHourResetAnchor ?? null, + dailyResetMode: user.dailyResetMode ?? "fixed", + dailyResetTime: user.dailyResetTime ?? "00:00", + isEnabled: user.isEnabled, + expiresAt: user.expiresAt ?? undefined, + } + : undefined; ... - keyOwnerUser={ - activeUser - ? { ... } - : undefined - } + keyOwnerUser={buildKeyOwnerUser(activeUser)} ... - keyOwnerUser={ - activeUser - ? { ... } - : undefined - } + keyOwnerUser={buildKeyOwnerUser(activeUser)}Also applies to: 99-122
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/`[locale]/dashboard/_components/user/user-key-manager.tsx around lines 43 - 66, The keyOwnerUser object construction is duplicated and will drift when fields change; extract the mapping into a single helper (e.g., buildKeyOwnerUser or mapActiveUserToKeyOwner) that accepts activeUser and returns the normalized object (handling defaults like providerGroup, createdAt/updatedAt, limit* fields, reset modes/times, isEnabled, expiresAt, etc.); replace both inline constructions of keyOwnerUser with a call to that helper and ensure the helper is exported/defined near the UserKeyManager component in user-key-manager.tsx so both usages reference the same source of truth.src/types/key.ts (1)
54-55: 建议把five_hour_reset_anchor的null / undefined语义直接写进类型注释。这两个位置在行为上已经区分了“省略字段”和“显式清空”,但类型本身没有把这个约定写出来,后续在 action / repository 层很容易把两者混用。建议明确
undefined = 不传/不更新,null = 清空并回退默认锚点。Based on learnings: In TypeScript interfaces, explicitly document and enforce distinct meanings for null and undefined. Example: for numeric limits like limitTotalUsd, use 'number | null | undefined' when null signifies explicitly unlimited (e.g., matches DB schema or special UI logic) and undefined signifies 'inherit default'. This pattern should be consistently reflected in type definitions across related fields to preserve semantic clarity between database constraints and UI behavior.
Also applies to: 82-83
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/types/key.ts` around lines 54 - 55, Update the type signature and JSDoc for five_hour_reset_anchor (and similarly for the other fields noted around the 82-83 region) to explicitly include both undefined and null in the type and document their semantics: make the type Date | null | undefined and add a short comment stating "undefined = field omitted / no update; null = explicitly clear and revert to default anchor", so callers and repository/action code can distinguish omission from explicit clearing; update any adjacent types (e.g., numeric limit fields) to follow the same pattern (T | null | undefined) with matching comments.src/actions/users.ts (1)
1259-1271: 5h 锚点解析/校验流程已经复制了三遍。
resolveSystemTimezone() -> parseDateInputAsTimezone() -> validateFiveHourResetAnchor()现在分别散落在addUser、createUserOnly和editUser。后续只要锚点规则再改一次,就很容易漏改其中一个分支。建议抽成一个小 helper,统一返回Date | null或结构化错误。Also applies to: 1463-1475, 1637-1651
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/actions/users.ts` around lines 1259 - 1271, The repeated flow resolveSystemTimezone -> parseDateInputAsTimezone -> validateFiveHourResetAnchor is duplicated across addUser, createUserOnly and editUser; extract it into a single helper (e.g., parseAndValidateFiveHourResetAnchor) that accepts the raw input and tError and returns either a Date | null or a structured error result, then replace the inline blocks in addUser, createUserOnly and editUser to call this helper and return on error or use the returned Date; ensure the helper calls resolveSystemTimezone, parseDateInputAsTimezone and validateFiveHourResetAnchor internally and preserves existing error shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@messages/ru/quota.json`:
- Around line 239-242: The description for the message key fiveHourResetAnchor
is missing that the anchor value is interpreted in the system timezone and
converted to UTC; update the "description" text for fiveHourResetAnchor to
explicitly state that the anchor is parsed using the system timezone (and then
converted to UTC by the action layer), and apply the same wording change to the
other anchor message entries mentioned in the comment (the similar reset-anchor
keys in the file) so cross-timezone admins understand the timezone source.
In `@src/actions/keys.ts`:
- Around line 1155-1158: 批量更新直接把 updates.fiveHourResetAnchor 写入 dbUpdates 绕过了
addKey/editKey 对 fiveHourResetAnchor 的时区解析与未来时间校验;请在更新前复用或提取出 addKey/editKey
使用的解析/校验逻辑(例如 parse/validateFiveHourResetAnchor)对 updates.fiveHourResetAnchor
进行按系统时区的解析并拒绝未来时间,然后仅在验证通过后再把规范化后的值赋给 dbUpdates.fiveHourResetAnchor;保留对
fiveHourResetMode 的现有赋值逻辑不变,确保批量编辑与其它入口的时区语义一致并阻止写入未来 anchor。
In `@src/actions/providers.ts`:
- Around line 629-630: Provider 的 5h 用量计算仍然硬编码使用 getTimeRangeForPeriod("5h") /
getResetInfo("5h") 并在批量接口中复用同一范围,导致配置为 fixed 的 provider 仍按 rolling 计算。请修改调用
getProviderLimitUsage() 和 getProviderLimitUsageBatch() 的逻辑,改为基于每个 provider 的
fiveHourResetMode / fiveHourResetAnchor(在本文件中标识为 fiveHourResetMode /
fiveHourResetAnchor 或 validated.five_hour_reset_mode /
validated.five_hour_reset_anchor)来分别计算时间范围和重置信息(替换对 getTimeRangeForPeriod("5h")
/ getResetInfo("5h") 的直接调用);对 getProviderLimitUsageBatch() 不要为所有 provider 使用单一
5h 范围,改为为每个 provider 生成各自的范围并传入对应参数以确保 fixed window 语义正确生效。
In `@src/app/`[locale]/dashboard/_components/user/edit-user-dialog.tsx:
- Around line 66-67: Move the import of formatDateToDatetimeLocal to the top of
the module with the other imports (placing it after the existing import list),
so it appears before the EditUserSchema and EditUserValues declarations; ensure
the symbol formatDateToDatetimeLocal is imported prior to any use or definition
of EditUserSchema/EditUserValues to avoid transpile/runtime errors.
In `@src/app/`[locale]/dashboard/_components/user/forms/add-key-form.tsx:
- Around line 89-90: When submitting the form ensure hidden anchors are dropped:
normalize the payload so that when data.fiveHourResetMode === 'rolling' you set
fiveHourResetAnchor to null before sending (i.e., replace uses of
data.fiveHourResetAnchor in the submit/payload assembly with a normalized
value). Locate where the submit payload is built (the code referencing
fiveHourResetMode and fiveHourResetAnchor) and enforce the rule there;
optionally also clear the form field when the UI toggles mode, but the mandatory
fix is payload normalization so backend never receives a stale anchor.
In
`@src/app/`[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts:
- Around line 192-201: When SET_FIVE_HOUR_RESET_MODE changes to "rolling",
ensure the anchor is cleared and marked dirty so build-patch-draft.ts will emit
a clear: either update the SET_FIVE_HOUR_RESET_MODE dispatch handler (or
reducer) to set state.rateLimit.fiveHourResetAnchor = "" and add
"rateLimit.fiveHourResetAnchor" to dirtyFields (so SET_FIVE_HOUR_RESET_ANCHOR is
effectively flagged), or modify build-patch-draft.ts to treat mode changes as
implicit clears by checking state.rateLimit.fiveHourResetMode === "rolling" (in
addition to dirtyFields) and emitting draft.five_hour_reset_anchor = { clear:
true } when appropriate; reference symbols: SET_FIVE_HOUR_RESET_MODE,
SET_FIVE_HOUR_RESET_ANCHOR, dirtyFields, state.rateLimit.fiveHourResetMode,
state.rateLimit.fiveHourResetAnchor, and draft.five_hour_reset_anchor.
In `@src/lib/rate-limit/service.ts`:
- Around line 1509-1523: The code reads a non-existent fixed 5h key when
fiveHourMode === "fixed" but fiveHourConfig.resetAnchor is missing, causing
reads of `provider:${providerId}:cost_5h_fixed` that are never written; update
the logic in the block using fiveHourConfig/fiveHourMode (around fiveHourConfig,
fiveHourMode, RateLimitService.resolve5hFixedCostKey, pipeline.get, queryMeta)
to: if resetAnchor is absent, either fallback to the rolling query branch (treat
as isRolling=true) or derive a valid fixed anchor (e.g. from provider
createdAt/created timestamp used by trackCost) and then call
resolve5hFixedCostKey with that anchor before calling pipeline.get; ensure
queryMeta reflects the chosen period/isRolling accordingly.
- Around line 186-197: The five-hour fixed mode is falling back to rolling
because resolveEffective5hFixedAnchor is called with only five_hour_reset_anchor
and never given the entity createdAt, so when anchor is null the code treats it
as rolling; update the call sites (including where normalizeResetTime,
fiveHourResetMode, fiveHourAnchor and the costLimits array are built) to pass
the entity's createdAt into resolveEffective5hFixedAnchor (e.g., compute a
single effective5hAnchor = resolveEffective5hFixedAnchor(five_hour_reset_anchor,
createdAt)), then reuse that effective5hAnchor throughout all 5h fixed branches
(checks, tracing, current usage reads and lease logic) so fixed mode uses the
createdAt fallback consistently instead of degrading to rolling.
In `@src/lib/validation/schemas.ts`:
- Around line 22-40: The Zod schemas (FIVE_HOUR_RESET_ANCHOR_STRING,
FIVE_HOUR_RESET_ANCHOR_UPDATE_NULLABLE, etc.) currently embed hardcoded Chinese
error messages; replace those literal messages with the project's i18n error key
or error-code mechanism (e.g., use the existing translation key constant or
return an error code string that the form layer resolves via i18n.t instead of a
literal message). Concretely, remove the Chinese string passed to .refine(...)
and return/throw the canonical validation key or use the shared helper (the same
pattern used elsewhere for field errors) so the UI can translate into
zh-CN/zh-TW/en/ja/ru; ensure the preprocess/refine calls still surface that key
(or Error object containing the key) and update any unit tests to expect the
i18n key instead of the hardcoded Chinese text.
---
Outside diff comments:
In `@src/lib/security/api-key-auth-cache.ts`:
- Around line 145-157: The hydrate logic in
src/lib/security/api-key-auth-cache.ts is not backfilling the new
fiveHourResetMode field (so old cache hits yield undefined mode); update the
hydrate function that builds the cached Key payload (the same place where
fiveHourResetAnchor is parsed) to default missing fiveHourResetMode to "rolling"
(or alternatively increment CACHE_VERSION) so old cached payloads are
normalized; ensure the returned object includes fiveHourResetMode:
payload.fiveHourResetMode ?? "rolling" and apply the same fix where similar
hydration occurs later in the file.
---
Nitpick comments:
In `@src/actions/users.ts`:
- Around line 1259-1271: The repeated flow resolveSystemTimezone ->
parseDateInputAsTimezone -> validateFiveHourResetAnchor is duplicated across
addUser, createUserOnly and editUser; extract it into a single helper (e.g.,
parseAndValidateFiveHourResetAnchor) that accepts the raw input and tError and
returns either a Date | null or a structured error result, then replace the
inline blocks in addUser, createUserOnly and editUser to call this helper and
return on error or use the returned Date; ensure the helper calls
resolveSystemTimezone, parseDateInputAsTimezone and validateFiveHourResetAnchor
internally and preserves existing error shape.
In `@src/app/`[locale]/dashboard/_components/user/user-key-manager.tsx:
- Around line 43-66: The keyOwnerUser object construction is duplicated and will
drift when fields change; extract the mapping into a single helper (e.g.,
buildKeyOwnerUser or mapActiveUserToKeyOwner) that accepts activeUser and
returns the normalized object (handling defaults like providerGroup,
createdAt/updatedAt, limit* fields, reset modes/times, isEnabled, expiresAt,
etc.); replace both inline constructions of keyOwnerUser with a call to that
helper and ensure the helper is exported/defined near the UserKeyManager
component in user-key-manager.tsx so both usages reference the same source of
truth.
In `@src/types/key.ts`:
- Around line 54-55: Update the type signature and JSDoc for
five_hour_reset_anchor (and similarly for the other fields noted around the
82-83 region) to explicitly include both undefined and null in the type and
document their semantics: make the type Date | null | undefined and add a short
comment stating "undefined = field omitted / no update; null = explicitly clear
and revert to default anchor", so callers and repository/action code can
distinguish omission from explicit clearing; update any adjacent types (e.g.,
numeric limit fields) to follow the same pattern (T | null | undefined) with
matching comments.
In `@tests/integration/usage-ledger.test.ts`:
- Around line 281-323: The test "backfill copies non-warmup message_request rows
when ledger rows are missing" uses the multi-line test(...) timeout style while
another nearby test uses the inline style; make the styles consistent by
converting the other test that currently calls test("...", { timeout: 60_000 },
async () => { ... }) to the multi-line form used here (test(\n "description",\n
{ timeout: 60_000 },\n async () => { ... }\n)); update the other test
invocation accordingly so both use the same multi-line formatting.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c3ddd8e5-d29b-44bc-8d87-2c890a2bf975
📒 Files selected for processing (78)
drizzle/0086_furry_donald_blake.sqldrizzle/meta/0086_snapshot.jsondrizzle/meta/_journal.jsonmessages/en/dashboard.jsonmessages/en/errors.jsonmessages/en/quota.jsonmessages/en/settings/providers/form/sections.jsonmessages/ja/dashboard.jsonmessages/ja/errors.jsonmessages/ja/quota.jsonmessages/ja/settings/providers/form/sections.jsonmessages/ru/dashboard.jsonmessages/ru/errors.jsonmessages/ru/quota.jsonmessages/ru/settings/providers/form/sections.jsonmessages/zh-CN/dashboard.jsonmessages/zh-CN/errors.jsonmessages/zh-CN/quota.jsonmessages/zh-CN/settings/providers/form/sections.jsonmessages/zh-TW/dashboard.jsonmessages/zh-TW/errors.jsonmessages/zh-TW/quota.jsonmessages/zh-TW/settings/providers/form/sections.jsonsrc/actions/keys.tssrc/actions/providers.tssrc/actions/users.tssrc/app/[locale]/dashboard/_components/user/create-user-dialog.tsxsrc/app/[locale]/dashboard/_components/user/edit-user-dialog.tsxsrc/app/[locale]/dashboard/_components/user/forms/add-key-form.tsxsrc/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsxsrc/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsxsrc/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsxsrc/app/[locale]/dashboard/_components/user/forms/user-form.tsxsrc/app/[locale]/dashboard/_components/user/user-key-manager.tsxsrc/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.tssrc/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.tssrc/app/[locale]/settings/providers/_components/forms/provider-form/index.tsxsrc/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsxsrc/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.tssrc/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsxsrc/app/v1/_lib/proxy/provider-selector.tssrc/app/v1/_lib/proxy/rate-limit-guard.tssrc/app/v1/_lib/proxy/response-handler.tssrc/drizzle/schema.tssrc/lib/auth.tssrc/lib/provider-patch-contract.tssrc/lib/rate-limit/lease-service.tssrc/lib/rate-limit/lease.tssrc/lib/rate-limit/service.tssrc/lib/rate-limit/time-utils.tssrc/lib/redis/lua-scripts.tssrc/lib/security/api-key-auth-cache.tssrc/lib/utils/date-input.tssrc/lib/utils/date.tssrc/lib/utils/error-messages.tssrc/lib/validation/schemas.tssrc/repository/_shared/transformers.tssrc/repository/key.tssrc/repository/provider.tssrc/repository/user.tssrc/types/key.tssrc/types/provider.tssrc/types/user.tstests/integration/usage-ledger.test.tstests/unit/actions/keys-edit-key-expires-at-clear.test.tstests/unit/actions/providers-apply-engine.test.tstests/unit/actions/providers-patch-contract.test.tstests/unit/actions/providers.test.tstests/unit/actions/users-edit-user-expires-at-clear.test.tstests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsxtests/unit/dashboard/edit-key-form-expiry-clear-ui.test.tsxtests/unit/dashboard/user-form-expiry-clear-ui.test.tsxtests/unit/lib/rate-limit/lease-service.test.tstests/unit/lib/rate-limit/lease.test.tstests/unit/lib/rate-limit/time-utils.test.tstests/unit/settings/providers/build-patch-draft.test.tstests/unit/settings/providers/provider-form-endpoint-pool.test.tsxtests/unit/validation/user-schemas-expires-at-clear.test.ts
| "fiveHourResetAnchor": { | ||
| "label": "Якорное время сброса 5ч", | ||
| "description": "Начальное время для режима фиксированных интервалов. По умолчанию - время создания." | ||
| }, |
There was a problem hiding this comment.
fiveHourResetAnchor 的说明最好补上“按系统时区解析”。
这里的 anchor 在 action 层是按系统时区转换成 UTC 的,但当前文案只说明了默认值,没有说明时区来源。跨时区管理员很容易按浏览器本地时间理解,最后会保存出偏移几小时的锚点。
Also applies to: 341-344
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@messages/ru/quota.json` around lines 239 - 242, The description for the
message key fiveHourResetAnchor is missing that the anchor value is interpreted
in the system timezone and converted to UTC; update the "description" text for
fiveHourResetAnchor to explicitly state that the anchor is parsed using the
system timezone (and then converted to UTC by the action layer), and apply the
same wording change to the other anchor message entries mentioned in the comment
(the similar reset-anchor keys in the file) so cross-timezone admins understand
the timezone source.
| five_hour_reset_mode: validatedData.fiveHourResetMode, | ||
| five_hour_reset_anchor: fiveHourResetAnchor, |
There was a problem hiding this comment.
Key 的 5h 用量展示还停留在 rolling 逻辑。
这里已经把 fiveHourResetMode / fiveHourResetAnchor 落库了,但同文件后面的 getKeyLimitUsage() 仍调用 getTimeRangeForPeriod("5h") / getResetInfo("5h"),不会读取这两个新字段。fixed 模式下,仪表盘上的 5h 已用额度和重置时间会和真实限流窗口不一致。
Also applies to: 599-605
| if (updates.fiveHourResetMode !== undefined) | ||
| dbUpdates.fiveHourResetMode = updates.fiveHourResetMode; | ||
| if (updates.fiveHourResetAnchor !== undefined) | ||
| dbUpdates.fiveHourResetAnchor = updates.fiveHourResetAnchor; |
There was a problem hiding this comment.
批量更新路径绕过了 anchor 的解析和校验。
addKey/editKey 都会按系统时区解析 fiveHourResetAnchor 并拒绝 future anchor,这里却直接把 updates.fiveHourResetAnchor 写进数据库。这样 batch edit 可以写入未来时间,而且和其它入口的时区语义也不一致。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/actions/keys.ts` around lines 1155 - 1158, 批量更新直接把
updates.fiveHourResetAnchor 写入 dbUpdates 绕过了 addKey/editKey 对
fiveHourResetAnchor 的时区解析与未来时间校验;请在更新前复用或提取出 addKey/editKey 使用的解析/校验逻辑(例如
parse/validateFiveHourResetAnchor)对 updates.fiveHourResetAnchor
进行按系统时区的解析并拒绝未来时间,然后仅在验证通过后再把规范化后的值赋给 dbUpdates.fiveHourResetAnchor;保留对
fiveHourResetMode 的现有赋值逻辑不变,确保批量编辑与其它入口的时区语义一致并阻止写入未来 anchor。
| five_hour_reset_mode: validated.five_hour_reset_mode ?? "rolling", | ||
| five_hour_reset_anchor: fiveHourResetAnchor, |
There was a problem hiding this comment.
Provider 的 5h 用量查询还没有接入 fixed window 语义。
这几处已经把 fiveHourResetMode / fiveHourResetAnchor 写进去了,但同文件后面的 getProviderLimitUsage() 和 getProviderLimitUsageBatch() 仍固定调用 getTimeRangeForPeriod("5h") / getResetInfo("5h"),批量版本还把 5h 范围作为所有 provider 的共享值。配置成 fixed 的 provider 在管理台看到的 5h 已用额度和重置信息会继续按 rolling 计算,和真实限流窗口不一致。
Also applies to: 826-828, 1579-1595
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/actions/providers.ts` around lines 629 - 630, Provider 的 5h 用量计算仍然硬编码使用
getTimeRangeForPeriod("5h") / getResetInfo("5h") 并在批量接口中复用同一范围,导致配置为 fixed 的
provider 仍按 rolling 计算。请修改调用 getProviderLimitUsage() 和
getProviderLimitUsageBatch() 的逻辑,改为基于每个 provider 的 fiveHourResetMode /
fiveHourResetAnchor(在本文件中标识为 fiveHourResetMode / fiveHourResetAnchor 或
validated.five_hour_reset_mode /
validated.five_hour_reset_anchor)来分别计算时间范围和重置信息(替换对 getTimeRangeForPeriod("5h")
/ getResetInfo("5h") 的直接调用);对 getProviderLimitUsageBatch() 不要为所有 provider 使用单一
5h 范围,改为为每个 provider 生成各自的范围并传入对应参数以确保 fixed window 语义正确生效。
| import { formatDateToDatetimeLocal } from "@/lib/utils/date"; | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 验证当前文件中 import 语句的位置
rg -n "^import " src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx | head -20Repository: ding113/claude-code-hub
Length of output: 1058
🏁 Script executed:
#!/bin/bash
# Check the actual content at lines 66-67 and surrounding context
sed -n '60,75p' src/app/\[locale\]/dashboard/_components/user/edit-user-dialog.tsx | cat -nRepository: ding113/claude-code-hub
Length of output: 714
🏁 Script executed:
#!/bin/bash
# Search for formatDateToDatetimeLocal usage in the file
rg "formatDateToDatetimeLocal" src/app/\[locale\]/dashboard/_components/user/edit-user-dialog.tsx -nRepository: ding113/claude-code-hub
Length of output: 256
导入语句位置错误,应移至文件顶部。
formatDateToDatetimeLocal 的导入语句位于第 66 行,位置在 EditUserSchema 定义和 EditUserValues 类型定义之后。import 语句必须位于所有代码之前,位于文件的最顶部。此缺陷会导致运行时或转译错误。
应将该导入语句移至文件顶部,与其他导入语句一起(在现有导入列表的末尾,约第 45 行之后)。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`[locale]/dashboard/_components/user/edit-user-dialog.tsx around
lines 66 - 67, Move the import of formatDateToDatetimeLocal to the top of the
module with the other imports (placing it after the existing import list), so it
appears before the EditUserSchema and EditUserValues declarations; ensure the
symbol formatDateToDatetimeLocal is imported prior to any use or definition of
EditUserSchema/EditUserValues to avoid transpile/runtime errors.
| fiveHourResetMode: data.fiveHourResetMode, | ||
| fiveHourResetAnchor: data.fiveHourResetAnchor || null, |
There was a problem hiding this comment.
切回 rolling 时需要主动丢弃隐藏的 anchor。
fiveHourResetAnchor 在切到 rolling 后只是被隐藏,没有被清空;当前提交逻辑仍会直接发送 data.fiveHourResetAnchor。用户如果先选了 fixed 并填写 anchor,再切回 rolling 提交,后端仍会收到旧值并把它存下来。至少需要在提交层按 mode 归一化成 null;是否在 UI 切换时同步清空,可以再按交互取舍。
建议修复
- fiveHourResetAnchor: data.fiveHourResetAnchor || null,
+ fiveHourResetAnchor:
+ data.fiveHourResetMode === "fixed" ? data.fiveHourResetAnchor || null : null,- onValueChange={(value: "fixed" | "rolling") =>
- form.setValue("fiveHourResetMode", value)
- }
+ onValueChange={(value: "fixed" | "rolling") => {
+ form.setValue("fiveHourResetMode", value);
+ if (value === "rolling") {
+ form.setValue("fiveHourResetAnchor", "");
+ }
+ }}Also applies to: 279-317
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`[locale]/dashboard/_components/user/forms/add-key-form.tsx around
lines 89 - 90, When submitting the form ensure hidden anchors are dropped:
normalize the payload so that when data.fiveHourResetMode === 'rolling' you set
fiveHourResetAnchor to null before sending (i.e., replace uses of
data.fiveHourResetAnchor in the submit/payload assembly with a normalized
value). Locate where the submit payload is built (the code referencing
fiveHourResetMode and fiveHourResetAnchor) and enforce the rule there;
optionally also clear the form field when the UI toggles mode, but the mandatory
fix is payload normalization so backend never receives a stale anchor.
| if (dirtyFields.has("rateLimit.fiveHourResetMode")) { | ||
| draft.five_hour_reset_mode = { set: state.rateLimit.fiveHourResetMode }; | ||
| } | ||
| if (dirtyFields.has("rateLimit.fiveHourResetAnchor")) { | ||
| if (state.rateLimit.fiveHourResetAnchor === "") { | ||
| draft.five_hour_reset_anchor = { clear: true }; | ||
| } else { | ||
| draft.five_hour_reset_anchor = { set: state.rateLimit.fiveHourResetAnchor }; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 检查 mode 切换是否会同时清空/标脏 fiveHourResetAnchor
fd 'provider-form-context\.tsx$|provider-form-types\.ts$' src -t f | while read -r file; do
echo "=== $file ==="
rg -n -C3 "SET_FIVE_HOUR_RESET_MODE|SET_FIVE_HOUR_RESET_ANCHOR|ACTION_TO_FIELD_PATH|fiveHourResetAnchor" "$file"
doneRepository: ding113/claude-code-hub
Length of output: 3759
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 查找表单组件中 fiveHourResetMode 变更时是否有 effect 或处理逻辑
fd 'provider-form\.' src -t f | while read -r file; do
echo "=== $file ==="
rg -n -B2 -A5 "SET_FIVE_HOUR_RESET_MODE|fiveHourResetMode.*onChange|useEffect.*fiveHourResetMode" "$file" || echo "(no match)"
done
echo ""
echo "=== 检查是否有 useWatch 或 useEffect 在 mode 变更时清空 anchor ==="
rg -n "useWatch.*fiveHourResetMode|useEffect.*\[.*fiveHourResetMode" src -t ts -t tsxRepository: ding113/claude-code-hub
Length of output: 265
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 查找所有包含 fiveHourResetMode 的源文件
echo "=== 查找相关的表单和批量编辑文件 ==="
fd 'provider-form|batch-edit' src --type f | head -20
echo ""
echo "=== 检查 batch-edit 目录下是否有处理 anchor 清空的逻辑 ==="
rg -n "fiveHourResetMode|fiveHourResetAnchor" src/app/\[locale\]/settings/providers/_components/batch-edit/ -A3 -B3
echo ""
echo "=== 查找 build-patch-draft.ts 周围是否有钩子处理 mode 变更 ==="
cat -n src/app/\[locale\]/settings/providers/_components/batch-edit/build-patch-draft.ts | head -50
echo ""
echo "=== 检查 batch-edit 中是否有处理 mode 清空 anchor 的代码 ==="
rg -n "rolling.*clear\|mode.*anchor\|SET_FIVE_HOUR_RESET" src/app/\[locale\]/settings/providers/_components/batch-edit/Repository: ding113/claude-code-hub
Length of output: 6791
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 查找使用 build-patch-draft 的文件
echo "=== 查找使用 buildPatchDraftFromFormState 的地方 ==="
rg -l "buildPatchDraftFromFormState" src --type f
echo ""
echo "=== 查找批量编辑对话框或表单 ==="
fd "batch.*edit.*dialog\|batch.*edit.*form" src --type f
echo ""
echo "=== 检查批量编辑组件中是否有 onChange 处理 fiveHourResetMode ==="
rg -n "fiveHourResetMode" src/app/\[locale\]/settings/providers/_components/ -A2 -B2 | head -50Repository: ding113/claude-code-hub
Length of output: 155
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 查找使用 build-patch-draft 的文件
echo "=== 查找使用 buildPatchDraftFromFormState 的地方 ==="
rg "buildPatchDraftFromFormState" src
echo ""
echo "=== 查找批量编辑组件文件 ==="
fd "batch" src/app/\[locale\]/settings/providers/_components
echo ""
echo "=== 检查批量编辑对话框的完整内容(查看 form 处理逻辑)==="
find src/app/\[locale\]/settings/providers/_components/batch-edit -name "*.tsx" -o -name "*.ts" | head -10Repository: ding113/claude-code-hub
Length of output: 1991
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 检查 provider-batch-dialog.tsx 中的 form 处理逻辑
echo "=== provider-batch-dialog.tsx 内容 ==="
wc -l src/app/\[locale\]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx
echo ""
echo "=== 查看 form state 和 dirty tracking 部分 ==="
sed -n '1,100p' src/app/\[locale\]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx
echo ""
echo "=== 查看 dirtyFields 和 patch 生成的逻辑 ==="
rg -n "dirtyFields\|buildPatchDraftFromFormState" src/app/\[locale\]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx -B3 -A3Repository: ding113/claude-code-hub
Length of output: 3459
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 查看完整的 BatchEditDialog 组件逻辑(包括 form 处理)
echo "=== 查看 BatchEditDialog 部分 (行 100-250) ==="
sed -n '100,250p' src/app/\[locale\]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx
echo ""
echo "=== 查看 buildPatchDraftFromFormState 调用处 (行 250-350) ==="
sed -n '250,350p' src/app/\[locale\]/settings/providers/_components/batch-edit/provider-batch-dialog.tsxRepository: ding113/claude-code-hub
Length of output: 8458
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 检查 LimitsSection 中的 five hour reset mode 字段处理
echo "=== 查找 LimitsSection 的位置 ==="
fd "limits-section" src
echo ""
echo "=== 查看 limits-section.tsx 内容 ==="
wc -l src/app/\[locale\]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx
echo ""
echo "=== 检查 limits-section 中是否有 onChange 处理 fiveHourResetMode ==="
rg -n "fiveHourResetMode\|SET_FIVE_HOUR_RESET_MODE" src/app/\[locale\]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx -B2 -A5Repository: ding113/claude-code-hub
Length of output: 387
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 查看 limits-section 中关于 five hour reset 的部分
echo "=== 搜索 limits-section.tsx 中的 fiveHourResetMode 相关代码 ==="
grep -n "fiveHourResetMode\|five.hour\|FIVE_HOUR" src/app/\[locale\]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx
echo ""
echo "=== 查看 limits-section.tsx 文件主要结构 (前150行) ==="
head -150 src/app/\[locale\]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx
echo ""
echo "=== 查看 dispatch 的 SET_FIVE_HOUR_RESET_MODE 处理代码 ==="
sed -n '200,400p' src/app/\[locale\]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx | grep -n -B3 -A10 "fiveHourResetMode\|SET_FIVE_HOUR_RESET_MODE"Repository: ding113/claude-code-hub
Length of output: 7243
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 检查当 mode 变更时是否有任何清空 anchor 的逻辑
echo "=== 完整查看 SET_FIVE_HOUR_RESET_MODE dispatch 和之后的处理 ==="
sed -n '230,280p' src/app/\[locale\]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx
echo ""
echo "=== 检查是否有 useEffect 或其他 hook 在 mode 变更时清空 anchor ==="
grep -n "useEffect\|useCallback\|fiveHourResetMode" src/app/\[locale\]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx | head -20
echo ""
echo "=== 再次确认 provider-form-context.tsx 的 reducer 对 SET_FIVE_HOUR_RESET_MODE 的处理 ==="
sed -n '595,610p' src/app/\[locale\]/settings/providers/_components/forms/provider-form/provider-form-context.tsxRepository: ding113/claude-code-hub
Length of output: 4129
批量编辑中切换 fiveHourResetMode 到 rolling 时需自动清空 fiveHourResetAnchor
当前实现中,SET_FIVE_HOUR_RESET_MODE 和 SET_FIVE_HOUR_RESET_ANCHOR 在 dirty tracking、reducer、patch 生成中都是独立的。用户在批量编辑时将 5 小时重置模式从 fixed 切到 rolling,只有 mode 字段被标记为 dirty,anchor 不会被标记。这导致 build-patch-draft.ts 的第 195 行条件永远不满足,历史 anchor 值会保留在数据库中。后续若再切回 fixed,会意外复用这个陈旧的窗口时间。
建议在 SET_FIVE_HOUR_RESET_MODE 的 dispatch handler 中添加逻辑:当 mode 变为 rolling 时,自动清空 anchor 并标记其为 dirty(或在 reducer 中直接清空并标记),或在 build-patch-draft.ts 第 192 行判断 mode 为 rolling 时显式生成 { clear: true}。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/app/`[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts
around lines 192 - 201, When SET_FIVE_HOUR_RESET_MODE changes to "rolling",
ensure the anchor is cleared and marked dirty so build-patch-draft.ts will emit
a clear: either update the SET_FIVE_HOUR_RESET_MODE dispatch handler (or
reducer) to set state.rateLimit.fiveHourResetAnchor = "" and add
"rateLimit.fiveHourResetAnchor" to dirtyFields (so SET_FIVE_HOUR_RESET_ANCHOR is
effectively flagged), or modify build-patch-draft.ts to treat mode changes as
implicit clears by checking state.rateLimit.fiveHourResetMode === "rolling" (in
addition to dirtyFields) and emitting draft.five_hour_reset_anchor = { clear:
true } when appropriate; reference symbols: SET_FIVE_HOUR_RESET_MODE,
SET_FIVE_HOUR_RESET_ANCHOR, dirtyFields, state.rateLimit.fiveHourResetMode,
state.rateLimit.fiveHourResetAnchor, and draft.five_hour_reset_anchor.
| const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); | ||
| const dailyResetMode = limits.daily_reset_mode ?? "fixed"; | ||
| const fiveHourResetMode = limits.five_hour_reset_mode ?? "rolling"; | ||
| const fiveHourAnchor = resolveEffective5hFixedAnchor(limits.five_hour_reset_anchor); | ||
| const costLimits: CostLimit[] = [ | ||
| { amount: limits.limit_5h_usd, period: "5h", name: "5小时" }, | ||
| { | ||
| amount: limits.limit_5h_usd, | ||
| period: "5h", | ||
| name: "5小时", | ||
| resetMode: fiveHourResetMode, | ||
| anchor: fiveHourAnchor ?? undefined, | ||
| }, |
There was a problem hiding this comment.
5h fixed 模式没有吃到 createdAt fallback,会被整体退化成 rolling。
这些入口都只调用了 resolveEffective5hFixedAnchor(...five_hour_reset_anchor),没有把“anchor 为空时回退到实体 createdAt”的语义一起传进来。结果是 five_hour_reset_mode === "fixed" 且 anchor 为 null 时,检查、追踪、当前用量读取和 lease 分支都会改走 rolling 5h 键/窗口,默认 fixed 模式实际上不会按固定 5 小时块重置。建议把实体创建时间一并透传到这里,先解析出统一的 effective anchor,再复用到所有 5h fixed 路径。
Also applies to: 844-860, 1007-1020, 1635-1656
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/rate-limit/service.ts` around lines 186 - 197, The five-hour fixed
mode is falling back to rolling because resolveEffective5hFixedAnchor is called
with only five_hour_reset_anchor and never given the entity createdAt, so when
anchor is null the code treats it as rolling; update the call sites (including
where normalizeResetTime, fiveHourResetMode, fiveHourAnchor and the costLimits
array are built) to pass the entity's createdAt into
resolveEffective5hFixedAnchor (e.g., compute a single effective5hAnchor =
resolveEffective5hFixedAnchor(five_hour_reset_anchor, createdAt)), then reuse
that effective5hAnchor throughout all 5h fixed branches (checks, tracing,
current usage reads and lease logic) so fixed mode uses the createdAt fallback
consistently instead of degrading to rolling.
| const fiveHourConfig = fiveHourResetConfigs?.get(providerId); | ||
| const fiveHourMode = (fiveHourConfig?.resetMode ?? "rolling") as DailyResetMode; | ||
|
|
||
| // 5h: 根据模式选择查询方式 | ||
| if (fiveHourMode === "fixed") { | ||
| const fixed5hKey = fiveHourConfig?.resetAnchor | ||
| ? RateLimitService.resolve5hFixedCostKey( | ||
| "provider", | ||
| providerId, | ||
| fiveHourConfig.resetAnchor, | ||
| new Date(now) | ||
| ) | ||
| : `provider:${providerId}:cost_5h_fixed`; | ||
| pipeline.get(fixed5hKey); | ||
| queryMeta.push({ providerId, period: "5h", isRolling: false }); |
There was a problem hiding this comment.
缺少锚点时这里会查询一个从未写入的 fixed 5h key。
trackCost() 和 DB 回填写入的都是 provider:${id}:cost_5h_fixed_<blockStartMs>。当前 fiveHourMode === "fixed" 且 resetAnchor 为空时却去读 provider:${providerId}:cost_5h_fixed,这个 key 在现有实现里不会被任何路径写入,所以 batch 结果会把该提供商的 5h 用量永久看成 0。拿不到有效锚点时应回退到 rolling 查询,或者先把 createdAt fallback 解析出来再拼 fixed key。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/rate-limit/service.ts` around lines 1509 - 1523, The code reads a
non-existent fixed 5h key when fiveHourMode === "fixed" but
fiveHourConfig.resetAnchor is missing, causing reads of
`provider:${providerId}:cost_5h_fixed` that are never written; update the logic
in the block using fiveHourConfig/fiveHourMode (around fiveHourConfig,
fiveHourMode, RateLimitService.resolve5hFixedCostKey, pipeline.get, queryMeta)
to: if resetAnchor is absent, either fallback to the rolling query branch (treat
as isRolling=true) or derive a valid fixed anchor (e.g. from provider
createdAt/created timestamp used by trackCost) and then call
resolve5hFixedCostKey with that anchor before calling pipeline.get; ensure
queryMeta reflects the chosen period/isRolling accordingly.
| const FIVE_HOUR_RESET_ANCHOR_STRING = z | ||
| .string() | ||
| .refine((value) => value === "" || isValidDateTimeString(value), "5小时重置锚点时间格式无效"); | ||
|
|
||
| const FIVE_HOUR_RESET_ANCHOR_STRING_WITH_DEFAULT = z.preprocess( | ||
| (value) => normalizeFiveHourResetAnchorInput(value === null ? "" : value), | ||
| FIVE_HOUR_RESET_ANCHOR_STRING.optional().default("") | ||
| ); | ||
|
|
||
| const FIVE_HOUR_RESET_ANCHOR_OPTIONAL_STRING = z.preprocess( | ||
| (value) => normalizeFiveHourResetAnchorInput(value === null ? "" : value), | ||
| FIVE_HOUR_RESET_ANCHOR_STRING.optional() | ||
| ); | ||
|
|
||
| const FIVE_HOUR_RESET_ANCHOR_UPDATE_NULLABLE = z.preprocess((val) => { | ||
| if (val === undefined) return undefined; | ||
| if (val === null || val === "") return null; | ||
| return normalizeFiveHourResetAnchorInput(val); | ||
| }, z.string().refine(isValidDateTimeString, "5小时重置锚点时间格式无效").nullable().optional()); |
There was a problem hiding this comment.
新的锚点校验文案仍然是硬编码中文。
这两个 message 会直接进入 user/key/provider 表单校验错误;现在写死中文会让非 zh-CN 语言的界面回退到错误语言。这里应该继续走现有的错误码/翻译键链路,而不是在 schema 里放字面量。
As per coding guidelines "All user-facing strings must use i18n (5 languages supported: zh-CN, zh-TW, en, ja, ru). Never hardcode display text"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/validation/schemas.ts` around lines 22 - 40, The Zod schemas
(FIVE_HOUR_RESET_ANCHOR_STRING, FIVE_HOUR_RESET_ANCHOR_UPDATE_NULLABLE, etc.)
currently embed hardcoded Chinese error messages; replace those literal messages
with the project's i18n error key or error-code mechanism (e.g., use the
existing translation key constant or return an error code string that the form
layer resolves via i18n.t instead of a literal message). Concretely, remove the
Chinese string passed to .refine(...) and return/throw the canonical validation
key or use the shared helper (the same pattern used elsewhere for field errors)
so the UI can translate into zh-CN/zh-TW/en/ja/ru; ensure the preprocess/refine
calls still surface that key (or Error object containing the key) and update any
unit tests to expect the i18n key instead of the hardcoded Chinese text.
Summary
Add configurable 5-hour quota reset mode (
fixedorrolling) for users, keys, and providers. This enables administrators to choose between a fixed 5-hour block window with a configurable anchor timestamp, or the existing rolling/sliding window behavior.Fixes #947
Problem
The 5-hour rate limit (
limit_5h_usd) previously only supported a rolling/sliding window mode using Redis ZSET. While this provides smooth usage tracking, some use cases require:fixedandrollingmodesSolution
Replicate the existing
daily_reset_modepattern to support both modes for the 5-hour limit:rolling(default)fixedKey implementation decisions:
TRACK_COST_5H_FIXED_WINDOW) for INCRBYFLOAT+EXPIRE to prevent TTL-less keys on crash'rolling'- zero behavioral change for existing deploymentsChanges
Schema & Migration
five_hour_reset_modeandfive_hour_reset_anchoronusers,keys,providerstablesdaily_reset_modePostgreSQL enum (no new enum creation)drizzle/0086_furry_donald_blake.sqlCore Changes
time-utils.ts,lease.ts,lease-service.ts,service.tsupdated for fixed block calculationrate-limit-guard.ts,provider-selector.ts,response-handler.tspass new fields throughAPI & Actions
keys.ts,providers.ts,users.tshandle new fields with timezone-aware anchor parsingFrontend
Validation & Utilities
date-input.tsfor reuseprovider-patch-contract.tsfor batch operationsInternationalization
Breaking Changes
None. The migration is purely additive with default
'rolling'ensuring backward compatibility.Testing
Automated Tests
get5hFixedBlockRangeandget5hFixedBlockKeySuffix(time-utils)Manual Testing
Files Changed (66 files)
Schema & Types:
schema.ts,user.ts,key.ts,provider.ts,schemas.tsRepository:
user.ts,key.ts,provider.ts,transformers.tsRate Limit Core:
service.ts,time-utils.ts,lease.ts,lease-service.tsProxy:
rate-limit-guard.ts,response-handler.ts,provider-selector.tsActions:
users.ts,keys.ts,providers.tsFrontend: All provider/user/key form components
i18n: All 5 language files
Description enhanced by Claude AI
Greptile Summary
This PR introduces a fixed 5-hour interval reset mode for rate limiting, complementing the existing rolling-window 5h behaviour. When
fiveHourResetMode = "fixed", usage is bucketed into discrete 5-hour blocks anchored to a configurable timestamp (fiveHourResetAnchor, falling back tocreatedAt); each block gets its own Redis string key keyed by block-start milliseconds, written atomically via a new Lua script (TRACK_COST_5H_FIXED_WINDOW). WhenfiveHourResetMode = "rolling"(the default), the existing ZSET sliding-window path is preserved unchanged. The change is wired end-to-end: DB migration, schema, repository, types, actions, proxy pipeline, UI forms, i18n, and tests are all updated consistently.Key changes and findings:
time-utils.ts,lease.ts,lease-service.ts,service.ts):get5hFixedBlockRange/get5hFixedBlockKeySuffixcorrectly compute block boundaries and produce per-block Redis key suffixes. Lease window-identity tracking (buildLeaseWindowIdentity) correctly invalidates cached leases on block transitions or mode switches.lua-scripts.ts):TRACK_COST_5H_FIXED_WINDOWuses an atomic LuaINCRBYFLOAT + EXPIREto prevent TTL-less keys on crash.EXPIREis called on every increment (harmless, but slightly re-resets an already-correct TTL on each write — see inline comment).service.ts): Onlykeyandprovider5h fixed costs are written to Redis; user-level 5h costs always fall back to a DB query. This is documented as a TODO but means every request with a user 5h fixed limit will generate a synchronous DB round-trip, which could become a bottleneck at scale.providers.ts):mapApplyUpdatesToRepositoryFormatthrows a plainErroron future-date anchor validation. The outertry-catchcatches it and returns{ ok: false, error: message }, but noerrorCodeis included — inconsistent with all other structured returns inapplyProviderBatchPatch.validateFiveHourResetAnchoris copy-pasted intokeys.ts,providers.ts, andusers.ts; should be extracted to a shared utility.rate-limit-guard.tspre-resolves the anchor withresolveEffective5hFixedAnchor(anchor, createdAt)before passing it to the service, butprovider-selector.tsandresponse-handler.tspass the raw?? createdAtvalue directly. The service layer re-resolves it, so behaviour is the same, but the asymmetry is a readability concern.Confidence Score: 4/5
mapApplyUpdatesToRepositoryFormaterror path issue is a minor inconsistency (missingerrorCode) caught by the outer try-catch. No data-loss or security issues found. Code duplication and anchor-resolution asymmetry are style-level concerns. Score reflects one concrete follow-up (user Redis tracking) before high-load production use.Important Files Changed
get5hFixedBlockRange,get5hFixedBlockKeySuffix, andresolveEffective5hFixedAnchor; all existing TTL/range functions extended with optionalanchorparam; logic is correct and well-tested.TRACK_COST_5H_FIXED_WINDOWLua script with atomic INCRBYFLOAT + EXPIRE to prevent TTL-less keys; logic is sound, though EXPIRE resets TTL on every increment (see review comment).buildLeaseWindowIdentitycorrectly encodes block-start timestamp for fixed mode.five_hour_reset_anchorvalidated for future-date inmapApplyUpdatesToRepositoryFormat, but validation failure throws a plain Error (noerrorCode) that is caught by the outer catch — inconsistent with all other structured error returns in this function.createdAtfallback viaresolveEffective5hFixedAnchor; error messages correctly branch on fixed vs rolling mode to include or omit a reset time.?? createdAt) without going throughresolveEffective5hFixedAnchorfirst, unlike the consistent pattern used in rate-limit-guard.ts.isValidDateTimeStringfor format validation; handles null/empty string correctly.isValidDateTimeLocalString,isValidDateTimeString, andisFutureDate; regex + calendar validation is thorough (leap year handling, days-per-month).five_hour_reset_mode(reusing existingdaily_reset_modeenum, defaults torolling) and nullablefive_hour_reset_anchortimestamp to keys, providers, and users tables; backward-safe.Sequence Diagram
sequenceDiagram participant Client participant RateLimitGuard participant LeaseService participant RateLimitService participant Redis participant DB Client->>RateLimitGuard: Incoming request RateLimitGuard->>RateLimitGuard: Resolve 5h anchor (fiveHourResetAnchor OR createdAt) RateLimitGuard->>LeaseService: checkCostLimitsWithLease(key.id, mode=fixed, anchor) LeaseService->>LeaseService: buildLeaseWindowIdentity => "5h:fixed:BLOCK_START_MS" LeaseService->>Redis: GET cached lease alt Cache hit AND identity matches Redis-->>LeaseService: BudgetLease (same block) LeaseService-->>RateLimitGuard: allowed / denied else Cache miss OR identity changed (new block) LeaseService->>DB: Query spend in current 5h block DB-->>LeaseService: Cost entries LeaseService->>Redis: Cache new lease with block TTL LeaseService-->>RateLimitGuard: allowed / denied end alt Request allowed RateLimitGuard->>Client: Pass through to provider note over RateLimitService,Redis: After response - trackCost alt fixed mode RateLimitService->>Redis: Lua script: INCRBYFLOAT key cost, then EXPIRE key remaining_block_secs else rolling mode RateLimitService->>Redis: Lua script: ZADD + ZREMRANGEBYSCORE on ZSET end else Rate limited RateLimitGuard->>Client: 429 RateLimitError with resetTime endComments Outside Diff (3)
src/actions/providers.ts, line 1591-1602 (link)errorCodein batch-patch flowmapApplyUpdatesToRepositoryFormatthrows a plainErrorwhen the future-date validation fails inside the batch-apply path. The outertry-catchinapplyProviderBatchPatchdoes catch it and returns{ ok: false, error: message }, but the response loses theerrorCodethat all other structured error returns in this function include.Callers that pattern-match on
errorCode(e.g. to display a localised error or to distinguish a validation failure from an internal error) will seeundefined. All other validation errors in the same function return a propererrorCode.Consider either returning a structured result from
mapApplyUpdatesToRepositoryFormat(matching the rest of the codebase's{ ok, error, errorCode }pattern), or at minimum re-throwing a typed error that carries the code:Prompt To Fix With AI
src/lib/rate-limit/service.ts, line 403-405 (link)The comment acknowledges this explicitly, but it's worth calling out as a concrete correctness gap rather than just a performance note:
Under
fixed5h mode, every inbound request that hits a user-level 5h limit will fall through the Redis fast path (since nothing writes touser:${userId}:cost_5h_fixed_*) and issue a synchronous DB query. For high-throughput scenarios this may become a bottleneck and effectively removes the benefits of the Redis-first architecture for user-level fixed 5h rate limiting. The rolling mode continues to be tracked via the existing ZSET path, so only users on fixed mode are affected.This is tracked as a TODO, but consider prioritising it before enabling fixed mode widely in production.
Prompt To Fix With AI
src/lib/redis/lua-scripts.ts, line 278-291 (link)EXPIREis called unconditionally on every increment, not just on key creationThe Lua script resets the TTL on every
INCRBYFLOAT:Because
ttlis calculated asMath.ceil((blockEnd - now) / 1000)immediately beforeredis.evalis called, each write resets the TTL to the current remaining time in the block. Within a single block this is functionally harmless (the key always expires at approximately block-end), but it means the TTL in Redis is always slightly behind the wall-clock calculation (by the few milliseconds of network round-trip).A tighter alternative that avoids re-resetting already-correct TTLs is to use
EXPIRE key ttl XX(only set TTL if key already has one) combined withEXPIRE key ttl NX(only set TTL if key has none). That said, the current approach is safe; this is a minor precision note rather than a bug.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat: add 5h fixed interval reset mode f..." | Re-trigger Greptile