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
4 changes: 2 additions & 2 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({

{/* Shared premium-session quota exhausted. Terminal for this run —
the user can exit and come
back once the oldest session in the window rolls off. */}
back once the daily Pacific reset passes. */}
{session?.status === 'rate_limited' && (
<>
<text style={{ fg: theme.secondary, marginBottom: 1 }}>
Expand All @@ -430,7 +430,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
<span fg={theme.foreground}>
{formatSessionUnits(session.recentCount)} of {session.limit}
</span>{' '}
premium sessions in the last 20 hours. Try again in{' '}
premium sessions today. Try again in{' '}
<span fg={theme.foreground}>
{formatRetryAfter(session.retryAfterMs)}
</span>
Expand Down
94 changes: 12 additions & 82 deletions common/src/constants/freebuff-models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
addDaysToYmd,
getUtcForZonedTime,
getZonedParts,
type ZonedDateParts,
} from '../util/zoned-time'

/**
* Models a freebuff user can pick between in the waiting-room model selector.
*
Expand Down Expand Up @@ -31,18 +38,14 @@ export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6'
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
export const FREEBUFF_PREMIUM_SESSION_LIMIT = 5
export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 20
export const FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE = 'America/Los_Angeles'
export const FREEBUFF_PREMIUM_SESSION_PERIOD = 'pacific_day'
/** Deprecated wire compatibility field. Premium usage now resets at midnight
* Pacific time rather than using a rolling hourly window. */
export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 24
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'

interface ZonedDateParts {
year: number
month: number
day: number
hour: number
minute: number
}

interface LocalTimeFormatOptions {
locale?: string
timeZone?: string
Expand Down Expand Up @@ -165,79 +168,6 @@ export function getFreebuffModel(id: string): FreebuffModelOption {
)
}

function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const value = (type: string) =>
parts.find((part) => part.type === type)?.value
const year = Number(value('year') ?? 0)
const month = Number(value('month') ?? 1)
const day = Number(value('day') ?? 1)
const hour = Number(value('hour') ?? 0)
const minute = Number(value('minute') ?? 0)
return {
year,
month,
day,
hour,
minute,
}
}

function addDaysToYmd(
year: number,
month: number,
day: number,
days: number,
): Pick<ZonedDateParts, 'year' | 'month' | 'day'> {
const next = new Date(Date.UTC(year, month - 1, day))
next.setUTCDate(next.getUTCDate() + days)
return {
year: next.getUTCFullYear(),
month: next.getUTCMonth() + 1,
day: next.getUTCDate(),
}
}

function getUtcForZonedTime(
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
timeZone: string,
hour: number,
minute: number,
): Date {
let guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute),
)

for (let i = 0; i < 3; i++) {
const actual = getZonedParts(guess, timeZone)
const desiredUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
hour,
minute,
)
const actualUtc = Date.UTC(
actual.year,
actual.month - 1,
actual.day,
actual.hour,
actual.minute,
)
guess = new Date(guess.getTime() + (desiredUtc - actualUtc))
}

return guess
}

function getNextFreebuffDeploymentStart(now: Date): Date {
const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
const isBeforeTodayOpen = easternNow.hour < 9
Expand Down
36 changes: 21 additions & 15 deletions common/src/types/freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@
* Usage counter surfaced to the CLI so the waiting-room UI can render
* "N of M sessions used" alongside queue/active state. Present when the
* joined model consumes premium Freebuff sessions. `recentCount` is the
* rounded session units inside `windowHours` at the time the response was
* produced — see also the standalone `rate_limited` status for the reject
* path.
* rounded session units since the last midnight Pacific reset at the time
* the response was produced — see also the standalone `rate_limited` status
* for the reject path.
*/
export interface FreebuffSessionRateLimit {
model: string
limit: number
period: 'pacific_day'
resetTimeZone: string
resetAt: string
/** Deprecated wire field kept for older clients. Premium usage now resets
* at midnight Pacific time rather than using a rolling window. */
windowHours: number
recentCount: number
}
Expand Down Expand Up @@ -63,7 +68,7 @@ export type FreebuffSessionServerResponse =
* produces `none`). */
queueDepthByModel?: Record<string, number>
/** Current quota snapshots for premium models, keyed by model id. Lets
* the picker show rolling premium-session usage before the user commits
* the picker show today's premium-session usage before the user commits
* to a queue. */
rateLimitsByModel?: FreebuffSessionRateLimitByModel
}
Expand Down Expand Up @@ -159,22 +164,23 @@ export type FreebuffSessionServerResponse =
status: 'banned'
}
| {
/** User has used up their shared premium-session quota in the rolling
* window. Returned from POST /session before the user is placed in the
* queue. `retryAfterMs` is the time until enough session units fall out
* of the window to open one quota slot — clients should show the user
* when they can try again. Terminal for the CLI's current poll session;
* the user can exit and come back later. */
/** User has used up their shared premium-session quota for the current
* Pacific day. Returned from POST /session before the user is placed in
* the queue. `retryAfterMs` is the time until the next midnight Pacific
* reset. Terminal for the CLI's current poll session; the user can exit
* and come back later. */
status: 'rate_limited'
/** The freebuff model the user tried to join. */
model: string
/** Max premium session units permitted per window (e.g. 5). */
/** Max premium session units permitted per Pacific day (e.g. 5). */
limit: number
/** Rolling window size in hours (e.g. 20). */
period: 'pacific_day'
resetTimeZone: string
resetAt: string
/** Deprecated wire field kept for older clients. */
windowHours: number
/** Premium session units inside the window at check time — will be ≥ limit. */
/** Premium session units since today's Pacific reset — will be ≥ limit. */
recentCount: number
/** Milliseconds from now until the oldest admission in the window
* exits and the user regains one quota slot. */
/** Milliseconds from now until the next Pacific midnight reset. */
retryAfterMs: number
}
35 changes: 35 additions & 0 deletions common/src/util/__tests__/zoned-time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test'

import { getZonedDayBounds } from '../zoned-time'

describe('getZonedDayBounds', () => {
test('returns the current Pacific day bounds on a normal day', () => {
const bounds = getZonedDayBounds(
new Date('2026-04-17T16:00:00Z'),
'America/Los_Angeles',
)

expect(bounds.startsAt.toISOString()).toBe('2026-04-17T07:00:00.000Z')
expect(bounds.resetsAt.toISOString()).toBe('2026-04-18T07:00:00.000Z')
})

test('handles the shorter spring-forward Pacific day', () => {
const bounds = getZonedDayBounds(
new Date('2026-03-08T09:00:00Z'),
'America/Los_Angeles',
)

expect(bounds.startsAt.toISOString()).toBe('2026-03-08T08:00:00.000Z')
expect(bounds.resetsAt.toISOString()).toBe('2026-03-09T07:00:00.000Z')
})

test('handles the longer fall-back Pacific day', () => {
const bounds = getZonedDayBounds(
new Date('2026-11-01T09:00:00Z'),
'America/Los_Angeles',
)

expect(bounds.startsAt.toISOString()).toBe('2026-11-01T07:00:00.000Z')
expect(bounds.resetsAt.toISOString()).toBe('2026-11-02T08:00:00.000Z')
})
})
98 changes: 98 additions & 0 deletions common/src/util/zoned-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
export interface ZonedDateParts {
year: number
month: number
day: number
hour: number
minute: number
}

export function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)

const get = (type: string) => {
const value = parts.find((part) => part.type === type)?.value
if (!value) throw new Error(`Missing ${type} in ${timeZone} date parts`)
return Number(value)
}

return {
year: get('year'),
month: get('month'),
day: get('day'),
hour: get('hour'),
minute: get('minute'),
}
}

export function addDaysToYmd(
year: number,
month: number,
day: number,
days: number,
): Pick<ZonedDateParts, 'year' | 'month' | 'day'> {
const next = new Date(Date.UTC(year, month - 1, day))
next.setUTCDate(next.getUTCDate() + days)
return {
year: next.getUTCFullYear(),
month: next.getUTCMonth() + 1,
day: next.getUTCDate(),
}
}

export function getUtcForZonedTime(
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
timeZone: string,
hour: number,
minute: number,
): Date {
let guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute),
)

for (let i = 0; i < 3; i++) {
const actual = getZonedParts(guess, timeZone)
const desiredUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
hour,
minute,
)
const actualUtc = Date.UTC(
actual.year,
actual.month - 1,
actual.day,
actual.hour,
actual.minute,
)
guess = new Date(guess.getTime() + (desiredUtc - actualUtc))
}

return guess
}

export function getZonedDayBounds(
now: Date,
timeZone: string,
): { startsAt: Date; resetsAt: Date } {
const nowParts = getZonedParts(now, timeZone)
const today = {
year: nowParts.year,
month: nowParts.month,
day: nowParts.day,
}
const tomorrow = addDaysToYmd(today.year, today.month, today.day, 1)

return {
startsAt: getUtcForZonedTime(today, timeZone, 0, 0),
resetsAt: getUtcForZonedTime(tomorrow, timeZone, 0, 0),
}
}
4 changes: 4 additions & 0 deletions docs/freebuff-waiting-room.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ The final tick result carries a `queueDepthByModel` map and a single `skipped` r
| `FREEBUFF_SESSION_LENGTH_MS` | env | 3_600_000 | Session lifetime |
| `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`. |

### Premium Session Quota

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.

## HTTP API

All endpoints authenticate via the standard `Authorization: Bearer <api-key>` or `x-codebuff-api-key` header.
Expand Down
6 changes: 3 additions & 3 deletions packages/internal/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,9 +911,9 @@ export const freeSession = pgTable(

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