Skip to content

Commit f029b95

Browse files
committed
Count freebuff premium sessions by Pacific day
1 parent 71b65a1 commit f029b95

9 files changed

Lines changed: 279 additions & 219 deletions

File tree

cli/src/components/waiting-room-screen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
419419

420420
{/* Shared premium-session quota exhausted. Terminal for this run —
421421
the user can exit and come
422-
back once the oldest session in the window rolls off. */}
422+
back once the daily Pacific reset passes. */}
423423
{session?.status === 'rate_limited' && (
424424
<>
425425
<text style={{ fg: theme.secondary, marginBottom: 1 }}>
@@ -430,7 +430,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
430430
<span fg={theme.foreground}>
431431
{formatSessionUnits(session.recentCount)} of {session.limit}
432432
</span>{' '}
433-
premium sessions in the last 20 hours. Try again in{' '}
433+
premium sessions today. Try again in{' '}
434434
<span fg={theme.foreground}>
435435
{formatRetryAfter(session.retryAfterMs)}
436436
</span>

common/src/constants/freebuff-models.ts

Lines changed: 12 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import {
2+
addDaysToYmd,
3+
getUtcForZonedTime,
4+
getZonedParts,
5+
type ZonedDateParts,
6+
} from '../util/zoned-time'
7+
18
/**
29
* Models a freebuff user can pick between in the waiting-room model selector.
310
*
@@ -31,18 +38,14 @@ export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
3138
export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6'
3239
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
3340
export const FREEBUFF_PREMIUM_SESSION_LIMIT = 5
34-
export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 20
41+
export const FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE = 'America/Los_Angeles'
42+
export const FREEBUFF_PREMIUM_SESSION_PERIOD = 'pacific_day'
43+
/** Deprecated wire compatibility field. Premium usage now resets at midnight
44+
* Pacific time rather than using a rolling hourly window. */
45+
export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 24
3546
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
3647
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'
3748

38-
interface ZonedDateParts {
39-
year: number
40-
month: number
41-
day: number
42-
hour: number
43-
minute: number
44-
}
45-
4649
interface LocalTimeFormatOptions {
4750
locale?: string
4851
timeZone?: string
@@ -165,79 +168,6 @@ export function getFreebuffModel(id: string): FreebuffModelOption {
165168
)
166169
}
167170

168-
function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
169-
const parts = new Intl.DateTimeFormat('en-US', {
170-
timeZone,
171-
year: 'numeric',
172-
month: '2-digit',
173-
day: '2-digit',
174-
hour: '2-digit',
175-
minute: '2-digit',
176-
hourCycle: 'h23',
177-
}).formatToParts(date)
178-
const value = (type: string) =>
179-
parts.find((part) => part.type === type)?.value
180-
const year = Number(value('year') ?? 0)
181-
const month = Number(value('month') ?? 1)
182-
const day = Number(value('day') ?? 1)
183-
const hour = Number(value('hour') ?? 0)
184-
const minute = Number(value('minute') ?? 0)
185-
return {
186-
year,
187-
month,
188-
day,
189-
hour,
190-
minute,
191-
}
192-
}
193-
194-
function addDaysToYmd(
195-
year: number,
196-
month: number,
197-
day: number,
198-
days: number,
199-
): Pick<ZonedDateParts, 'year' | 'month' | 'day'> {
200-
const next = new Date(Date.UTC(year, month - 1, day))
201-
next.setUTCDate(next.getUTCDate() + days)
202-
return {
203-
year: next.getUTCFullYear(),
204-
month: next.getUTCMonth() + 1,
205-
day: next.getUTCDate(),
206-
}
207-
}
208-
209-
function getUtcForZonedTime(
210-
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
211-
timeZone: string,
212-
hour: number,
213-
minute: number,
214-
): Date {
215-
let guess = new Date(
216-
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute),
217-
)
218-
219-
for (let i = 0; i < 3; i++) {
220-
const actual = getZonedParts(guess, timeZone)
221-
const desiredUtc = Date.UTC(
222-
parts.year,
223-
parts.month - 1,
224-
parts.day,
225-
hour,
226-
minute,
227-
)
228-
const actualUtc = Date.UTC(
229-
actual.year,
230-
actual.month - 1,
231-
actual.day,
232-
actual.hour,
233-
actual.minute,
234-
)
235-
guess = new Date(guess.getTime() + (desiredUtc - actualUtc))
236-
}
237-
238-
return guess
239-
}
240-
241171
function getNextFreebuffDeploymentStart(now: Date): Date {
242172
const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
243173
const isBeforeTodayOpen = easternNow.hour < 9

common/src/types/freebuff-session.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@
1010
* Usage counter surfaced to the CLI so the waiting-room UI can render
1111
* "N of M sessions used" alongside queue/active state. Present when the
1212
* joined model consumes premium Freebuff sessions. `recentCount` is the
13-
* rounded session units inside `windowHours` at the time the response was
14-
* produced — see also the standalone `rate_limited` status for the reject
15-
* path.
13+
* rounded session units since the last midnight Pacific reset at the time
14+
* the response was produced — see also the standalone `rate_limited` status
15+
* for the reject path.
1616
*/
1717
export interface FreebuffSessionRateLimit {
1818
model: string
1919
limit: number
20+
period: 'pacific_day'
21+
resetTimeZone: string
22+
resetAt: string
23+
/** Deprecated wire field kept for older clients. Premium usage now resets
24+
* at midnight Pacific time rather than using a rolling window. */
2025
windowHours: number
2126
recentCount: number
2227
}
@@ -63,7 +68,7 @@ export type FreebuffSessionServerResponse =
6368
* produces `none`). */
6469
queueDepthByModel?: Record<string, number>
6570
/** Current quota snapshots for premium models, keyed by model id. Lets
66-
* the picker show rolling premium-session usage before the user commits
71+
* the picker show today's premium-session usage before the user commits
6772
* to a queue. */
6873
rateLimitsByModel?: FreebuffSessionRateLimitByModel
6974
}
@@ -159,22 +164,23 @@ export type FreebuffSessionServerResponse =
159164
status: 'banned'
160165
}
161166
| {
162-
/** User has used up their shared premium-session quota in the rolling
163-
* window. Returned from POST /session before the user is placed in the
164-
* queue. `retryAfterMs` is the time until enough session units fall out
165-
* of the window to open one quota slot — clients should show the user
166-
* when they can try again. Terminal for the CLI's current poll session;
167-
* the user can exit and come back later. */
167+
/** User has used up their shared premium-session quota for the current
168+
* Pacific day. Returned from POST /session before the user is placed in
169+
* the queue. `retryAfterMs` is the time until the next midnight Pacific
170+
* reset. Terminal for the CLI's current poll session; the user can exit
171+
* and come back later. */
168172
status: 'rate_limited'
169173
/** The freebuff model the user tried to join. */
170174
model: string
171-
/** Max premium session units permitted per window (e.g. 5). */
175+
/** Max premium session units permitted per Pacific day (e.g. 5). */
172176
limit: number
173-
/** Rolling window size in hours (e.g. 20). */
177+
period: 'pacific_day'
178+
resetTimeZone: string
179+
resetAt: string
180+
/** Deprecated wire field kept for older clients. */
174181
windowHours: number
175-
/** Premium session units inside the window at check time — will be ≥ limit. */
182+
/** Premium session units since today's Pacific reset — will be ≥ limit. */
176183
recentCount: number
177-
/** Milliseconds from now until the oldest admission in the window
178-
* exits and the user regains one quota slot. */
184+
/** Milliseconds from now until the next Pacific midnight reset. */
179185
retryAfterMs: number
180186
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { getZonedDayBounds } from '../zoned-time'
4+
5+
describe('getZonedDayBounds', () => {
6+
test('returns the current Pacific day bounds on a normal day', () => {
7+
const bounds = getZonedDayBounds(
8+
new Date('2026-04-17T16:00:00Z'),
9+
'America/Los_Angeles',
10+
)
11+
12+
expect(bounds.startsAt.toISOString()).toBe('2026-04-17T07:00:00.000Z')
13+
expect(bounds.resetsAt.toISOString()).toBe('2026-04-18T07:00:00.000Z')
14+
})
15+
16+
test('handles the shorter spring-forward Pacific day', () => {
17+
const bounds = getZonedDayBounds(
18+
new Date('2026-03-08T09:00:00Z'),
19+
'America/Los_Angeles',
20+
)
21+
22+
expect(bounds.startsAt.toISOString()).toBe('2026-03-08T08:00:00.000Z')
23+
expect(bounds.resetsAt.toISOString()).toBe('2026-03-09T07:00:00.000Z')
24+
})
25+
26+
test('handles the longer fall-back Pacific day', () => {
27+
const bounds = getZonedDayBounds(
28+
new Date('2026-11-01T09:00:00Z'),
29+
'America/Los_Angeles',
30+
)
31+
32+
expect(bounds.startsAt.toISOString()).toBe('2026-11-01T07:00:00.000Z')
33+
expect(bounds.resetsAt.toISOString()).toBe('2026-11-02T08:00:00.000Z')
34+
})
35+
})

common/src/util/zoned-time.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
export interface ZonedDateParts {
2+
year: number
3+
month: number
4+
day: number
5+
hour: number
6+
minute: number
7+
}
8+
9+
export function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
10+
const parts = new Intl.DateTimeFormat('en-US', {
11+
timeZone,
12+
year: 'numeric',
13+
month: '2-digit',
14+
day: '2-digit',
15+
hour: '2-digit',
16+
minute: '2-digit',
17+
hourCycle: 'h23',
18+
}).formatToParts(date)
19+
20+
const get = (type: string) => {
21+
const value = parts.find((part) => part.type === type)?.value
22+
if (!value) throw new Error(`Missing ${type} in ${timeZone} date parts`)
23+
return Number(value)
24+
}
25+
26+
return {
27+
year: get('year'),
28+
month: get('month'),
29+
day: get('day'),
30+
hour: get('hour'),
31+
minute: get('minute'),
32+
}
33+
}
34+
35+
export function addDaysToYmd(
36+
year: number,
37+
month: number,
38+
day: number,
39+
days: number,
40+
): Pick<ZonedDateParts, 'year' | 'month' | 'day'> {
41+
const next = new Date(Date.UTC(year, month - 1, day))
42+
next.setUTCDate(next.getUTCDate() + days)
43+
return {
44+
year: next.getUTCFullYear(),
45+
month: next.getUTCMonth() + 1,
46+
day: next.getUTCDate(),
47+
}
48+
}
49+
50+
export function getUtcForZonedTime(
51+
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
52+
timeZone: string,
53+
hour: number,
54+
minute: number,
55+
): Date {
56+
let guess = new Date(
57+
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute),
58+
)
59+
60+
for (let i = 0; i < 3; i++) {
61+
const actual = getZonedParts(guess, timeZone)
62+
const desiredUtc = Date.UTC(
63+
parts.year,
64+
parts.month - 1,
65+
parts.day,
66+
hour,
67+
minute,
68+
)
69+
const actualUtc = Date.UTC(
70+
actual.year,
71+
actual.month - 1,
72+
actual.day,
73+
actual.hour,
74+
actual.minute,
75+
)
76+
guess = new Date(guess.getTime() + (desiredUtc - actualUtc))
77+
}
78+
79+
return guess
80+
}
81+
82+
export function getZonedDayBounds(
83+
now: Date,
84+
timeZone: string,
85+
): { startsAt: Date; resetsAt: Date } {
86+
const nowParts = getZonedParts(now, timeZone)
87+
const today = {
88+
year: nowParts.year,
89+
month: nowParts.month,
90+
day: nowParts.day,
91+
}
92+
const tomorrow = addDaysToYmd(today.year, today.month, today.day, 1)
93+
94+
return {
95+
startsAt: getUtcForZonedTime(today, timeZone, 0, 0),
96+
resetsAt: getUtcForZonedTime(tomorrow, timeZone, 0, 0),
97+
}
98+
}

docs/freebuff-waiting-room.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ The final tick result carries a `queueDepthByModel` map and a single `skipped` r
162162
| `FREEBUFF_SESSION_LENGTH_MS` | env | 3_600_000 | Session lifetime |
163163
| `SESSION_GRACE_MS` | `web/src/server/free-session/config.ts` | 1_800_000 | Drain window after expiry — gate still admits requests so an in-flight agent can finish, but the CLI is expected to block new prompts. Hard cutoff at `expires_at + grace`. |
164164

165+
### Premium Session Quota
166+
167+
DeepSeek, Kimi, and legacy GLM share a per-user premium quota. The server counts `free_session_admit` rows from the last midnight in `America/Los_Angeles`; when the user reaches `FREEBUFF_PREMIUM_SESSION_LIMIT`, the next premium `POST /session` is rejected until the next Pacific midnight reset. MiniMax remains unlimited.
168+
165169
## HTTP API
166170

167171
All endpoints authenticate via the standard `Authorization: Bearer <api-key>` or `x-codebuff-api-key` header.

packages/internal/src/db/schema.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -911,9 +911,9 @@ export const freeSession = pgTable(
911911

912912
/**
913913
* Audit log of every admission — one row per queued→active transition. Used
914-
* to track shared premium-session usage for Freebuff's 5 sessions / 20h
915-
* allowance. `session_units` starts at 1.0 and may be reduced when users end
916-
* active sessions early.
914+
* to track shared premium-session usage for Freebuff's 5 sessions per Pacific
915+
* day allowance. `session_units` starts at 1.0 and may be reduced when users
916+
* end active sessions early.
917917
*
918918
* Separate from `free_session` because that table is one-row-per-user (state,
919919
* not history); the UPSERT path there would otherwise destroy prior admissions.

0 commit comments

Comments
 (0)