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
51 changes: 49 additions & 2 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
res_proxy: 'residential proxy',
tor: 'Tor',
vpn: 'VPN',
hosting: 'hosting network',
service: 'privacy service',
}

const formatPrivacySignalList = (
Expand All @@ -101,6 +103,38 @@ const formatPrivacySignalList = (
return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}`
}

const getLimitedModeReason = (
session: FreebuffSessionResponse | null,
): string | null => {
if (!session || !('countryBlockReason' in session)) {
return 'reduced free model access'
}

const countryCode =
'countryCode' in session &&
session.countryCode &&
session.countryCode !== 'UNKNOWN'
? session.countryCode
: null

switch (session.countryBlockReason) {
case 'anonymous_network':
return `${formatPrivacySignalList(
session.ipPrivacySignals ?? undefined,
)} detected`
case 'country_not_allowed':
return `outside available countries${countryCode ? ` (${countryCode})` : ''}`
case 'anonymized_or_unknown_country':
case 'missing_client_ip':
case 'unresolved_client_ip':
return 'location could not be verified'
case 'ip_privacy_lookup_failed':
return 'network check could not finish'
default:
return 'reduced free model access'
}
}

const TakeoverPrompt: React.FC = () => {
const theme = useTheme()
const [pending, setPending] = useState(false)
Expand Down Expand Up @@ -261,6 +295,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
const isQueued = session?.status === 'queued'
const accessTier =
session && 'accessTier' in session ? session.accessTier : 'full'
const limitedModeReason =
accessTier === 'limited' ? getLimitedModeReason(session) : null
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
// state: show the picker with live N-in-line hints and a prompt. Picking a
// model triggers joinFreebuffQueue, which POSTs and transitions us to
Expand Down Expand Up @@ -337,17 +373,28 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
>
{/* Top-right exit affordance so mouse users have a clear way out even
when they don't know Ctrl+C works. width: '100%' is required for
justifyContent: 'flex-end' to actually push the X to the right. */}
justifyContent to actually push the X to the right. */}
<box
style={{
width: '100%',
flexDirection: 'row',
justifyContent: 'flex-end',
justifyContent: 'space-between',
paddingTop: 1,
paddingLeft: 2,
paddingRight: 2,
flexShrink: 0,
}}
>
<box>
{limitedModeReason && (
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
<span fg={theme.secondary} attributes={TextAttributes.BOLD}>
Limited mode
</span>
<span fg={theme.muted}> · {limitedModeReason}</span>
</text>
)}
</box>
<Button
onClick={exitFreebuffCleanly}
onMouseOver={() => setExitHover(true)}
Expand Down
20 changes: 20 additions & 0 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,25 @@ function toLandingSession(
? current.queueDepthByModel
: undefined
const rateLimitsByModel = getRateLimitsByModel(current)
const countryCode =
current && 'countryCode' in current ? current.countryCode : undefined
const countryBlockReason =
current && 'countryBlockReason' in current
? current.countryBlockReason
: undefined
const ipPrivacySignals =
current && 'ipPrivacySignals' in current
? current.ipPrivacySignals
: undefined

return {
status: 'none',
...(accessTier ? { accessTier } : {}),
...(queueDepthByModel ? { queueDepthByModel } : {}),
...(rateLimitsByModel ? { rateLimitsByModel } : {}),
...(countryCode ? { countryCode } : {}),
...(countryBlockReason ? { countryBlockReason } : {}),
...(ipPrivacySignals ? { ipPrivacySignals } : {}),
}
}

Expand Down Expand Up @@ -632,6 +645,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
rateLimitsByModel:
response.rateLimitsByModel ??
landingSession.rateLimitsByModel,
countryCode: response.countryCode ?? landingSession.countryCode,
countryBlockReason:
response.countryBlockReason ??
landingSession.countryBlockReason,
ipPrivacySignals:
response.ipPrivacySignals ??
landingSession.ipPrivacySignals,
})
}
})
Expand Down
24 changes: 16 additions & 8 deletions common/src/types/freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,21 @@ export type FreebuffIpPrivacySignal =
| 'hosting'
| 'service'

export interface FreebuffLimitedModeReason {
/** Present for limited access so the model picker can explain why the
* reduced model set is shown without re-running geo/IP logic locally. */
countryCode?: string | null
countryBlockReason?: FreebuffCountryBlockReason | null
ipPrivacySignals?: FreebuffIpPrivacySignal[] | null
}

export type FreebuffSessionServerResponse =
| {
/** Waiting room is globally off; free-mode requests flow through
* unchanged. Client should treat this as "admitted forever". */
status: 'disabled'
}
| {
| ({
/** User has no session row. CLI must POST to (re-)queue. Also returned
* when `getSessionState` notices the user has been swept past the
* grace window. */
Expand All @@ -88,8 +96,8 @@ export type FreebuffSessionServerResponse =
* the picker show today's premium-session usage before the user commits
* to a queue. */
rateLimitsByModel?: FreebuffSessionRateLimitByModel
}
| {
} & FreebuffLimitedModeReason)
| ({
status: 'queued'
accessTier: FreebuffAccessTier
instanceId: string
Expand All @@ -108,8 +116,8 @@ export type FreebuffSessionServerResponse =
/** Premium-session quota for this model. Absent for unlimited models. */
rateLimit?: FreebuffSessionRateLimit
rateLimitsByModel?: FreebuffSessionRateLimitByModel
}
| {
} & FreebuffLimitedModeReason)
| ({
status: 'active'
accessTier: FreebuffAccessTier
instanceId: string
Expand All @@ -121,8 +129,8 @@ export type FreebuffSessionServerResponse =
/** Premium-session quota for this model. Absent for unlimited models. */
rateLimit?: FreebuffSessionRateLimit
rateLimitsByModel?: FreebuffSessionRateLimitByModel
}
| {
} & FreebuffLimitedModeReason)
| ({
/** Session is over. While `instanceId` is present we're inside the
* server-side grace window — chat requests still go through so the
* agent can finish, but the CLI must not accept new prompts. Once
Expand All @@ -143,7 +151,7 @@ export type FreebuffSessionServerResponse =
* session ended. Lets the post-session banner show "N of M premium
* sessions used today" without an extra round-trip. */
rateLimitsByModel?: FreebuffSessionRateLimitByModel
}
} & FreebuffLimitedModeReason)
| {
/** Another CLI on the same account rotated our instance id. Polling
* stops and the UI shows a "close the other CLI" screen. The server
Expand Down
31 changes: 31 additions & 0 deletions web/src/app/api/v1/freebuff/session/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ describe('POST /api/v1/freebuff/session', () => {
expect(body.status).toBe('queued')
expect(body.accessTier).toBe('limited')
expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)
expect(body.countryCode).toBe('JP')
expect(body.countryBlockReason).toBe('country_not_allowed')
expect(sessionDeps.rows.get('u1')).toMatchObject({
access_tier: 'limited',
country_code: 'JP',
Expand Down Expand Up @@ -341,6 +343,35 @@ describe('GET /api/v1/freebuff/session', () => {
const body = await resp.json()
expect(body.status).toBe('none')
expect(body.accessTier).toBe('limited')
expect(body.countryCode).toBe('JP')
expect(body.countryBlockReason).toBe('country_not_allowed')
expect(body.ipPrivacySignals).toBeNull()
})

test('returns limited-mode privacy reason on GET', async () => {
const sessionDeps = makeSessionDeps()
const resp = await getFreebuffSession(
makeReq('ok', { cfCountry: 'US' }),
makeDeps(sessionDeps, 'u1', {
getCountryAccess: async () => ({
allowed: false,
countryCode: 'US',
blockReason: 'anonymous_network',
cfCountry: 'US',
geoipCountry: null,
ipPrivacy: { signals: ['vpn', 'hosting'] },
hasClientIp: true,
clientIpHash: 'test-ip-hash',
}),
}),
)
expect(resp.status).toBe(200)
const body = await resp.json()
expect(body.status).toBe('none')
expect(body.accessTier).toBe('limited')
expect(body.countryCode).toBe('US')
expect(body.countryBlockReason).toBe('anonymous_network')
expect(body.ipPrivacySignals).toEqual(['vpn', 'hosting'])
})

test('rechecks country on GET so access tier changes are visible immediately', async () => {
Expand Down
10 changes: 10 additions & 0 deletions web/src/app/api/v1/freebuff/session/_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ function toSessionCountryAccess(
}
}

function toLimitedModeReason(countryAccess: FreeModeCountryAccess) {
if (countryAccess.allowed) return {}
return {
countryCode: countryAccess.countryCode,
countryBlockReason: countryAccess.blockReason,
ipPrivacySignals: countryAccess.ipPrivacy?.signals ?? null,
}
}

/** Header the CLI uses to identify which instance is polling. Used by GET to
* detect when another CLI on the same account has rotated the id. */
export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
Expand Down Expand Up @@ -220,6 +229,7 @@ export async function getFreebuffSession(
message: 'Call POST to join the waiting room.',
queueDepthByModel: state.queueDepthByModel,
rateLimitsByModel: state.rateLimitsByModel,
...toLimitedModeReason(countryAccess),
},
{ status: 200 },
)
Expand Down
22 changes: 22 additions & 0 deletions web/src/server/free-session/__tests__/session-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ describe('toSessionStateResponse', () => {
})
})

test('limited queued row includes limited-mode reason metadata', () => {
const view = toSessionStateResponse({
row: row({
status: 'queued',
access_tier: 'limited',
country_code: 'US',
country_block_reason: 'anonymous_network',
ip_privacy_signals: ['vpn'],
}),
position: 1,
...baseArgs,
now,
})
expect(view).toMatchObject({
status: 'queued',
accessTier: 'limited',
countryCode: 'US',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['vpn'],
})
})

test('active unexpired row maps to active response with remaining ms', () => {
const admittedAt = new Date(now.getTime() - 10 * 60_000)
const expiresAt = new Date(now.getTime() + 50 * 60_000)
Expand Down
12 changes: 12 additions & 0 deletions web/src/server/free-session/session-view.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { InternalSessionRow, SessionStateResponse } from './types'

function limitedModeReasonFromRow(row: InternalSessionRow) {
if ((row.access_tier ?? 'full') !== 'limited') return {}
return {
countryCode: row.country_code ?? null,
countryBlockReason: row.country_block_reason ?? null,
ipPrivacySignals: row.ip_privacy_signals ?? null,
}
}

/**
* Pure function converting an internal session row (or absence thereof) into
* the public response shape. Never reads the clock — caller supplies `now` so
Expand Down Expand Up @@ -33,6 +42,7 @@ export function toSessionStateResponse(params: {
admittedAt: (row.admitted_at ?? row.created_at).toISOString(),
expiresAt: row.expires_at.toISOString(),
remainingMs: expiresAtMs - nowMs,
...limitedModeReasonFromRow(row),
}
}
const graceEndsMs = expiresAtMs + graceMs
Expand All @@ -45,6 +55,7 @@ export function toSessionStateResponse(params: {
expiresAt: row.expires_at.toISOString(),
gracePeriodEndsAt: new Date(graceEndsMs).toISOString(),
gracePeriodRemainingMs: graceEndsMs - nowMs,
...limitedModeReasonFromRow(row),
}
}
}
Expand All @@ -60,6 +71,7 @@ export function toSessionStateResponse(params: {
queueDepthByModel,
estimatedWaitMs: estimateWaitMs({ position }),
queuedAt: row.queued_at.toISOString(),
...limitedModeReasonFromRow(row),
}
}

Expand Down
Loading