diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index 3a67ffed8..c3111b277 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -7,8 +7,10 @@ import { DEFAULT_FREEBUFF_MODEL_ID, FALLBACK_FREEBUFF_MODEL_ID, FREEBUFF_MODELS, + FREEBUFF_PREMIUM_SESSION_LIMIT, getFreebuffDeploymentAvailabilityLabel, isFreebuffModelAvailable, + isFreebuffPremiumModelId, } from '@codebuff/common/constants/freebuff-models' import { joinFreebuffQueue } from '../hooks/use-freebuff-session' @@ -31,6 +33,10 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [ ...FREEBUFF_MODELS.filter((model) => model.id !== DEFAULT_FREEBUFF_MODEL_ID), ] +function formatSessionUnits(units: number): string { + return Number.isInteger(units) ? String(units) : units.toFixed(1) +} + /** * Dual-purpose model picker: * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking @@ -45,11 +51,6 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [ * Always stacked vertically. On narrow terminals where the longest one-line * label wouldn't fit, the secondary details (warning / deployment hours) * spill onto an indented second line under the name. - * - * No queue-position hint: traffic doesn't reach the threshold where a wait - * would form, so showing "N in line" everywhere just adds noise (and width). - * The picker still surfaces "Closed" (outside deployment hours) and "Limit - * used" (per-user quota) inline since those gate the actual click. */ export const FreebuffModelSelector: React.FC = () => { const theme = useTheme() @@ -91,15 +92,30 @@ export const FreebuffModelSelector: React.FC = () => { } }, [now, selectedModel, session, setSelectedModel]) + const committedModelId = session?.status === 'queued' ? session.model : null + const rateLimitsByModel = + session && 'rateLimitsByModel' in session + ? session.rateLimitsByModel + : undefined + + const getQuotaHint = useCallback( + (modelId: string): string => { + const rateLimit = rateLimitsByModel?.[modelId] + if (rateLimit) { + return `${formatSessionUnits(rateLimit.recentCount)}/${rateLimit.limit} used` + } + return isFreebuffPremiumModelId(modelId) + ? `0/${FREEBUFF_PREMIUM_SESSION_LIMIT} used` + : 'Unlimited' + }, + [rateLimitsByModel], + ) + const BUTTON_CHROME = 4 // 2 border + 2 padding // Decide whether secondary details (warning / deployment hours) get their - // own indented line under the name. Trigger: the widest one-line button - // wouldn't fit in our content budget. All buttons share a uniform width so - // the column reads as a clean stack of equal choices. We size to the - // *label* — Closed / Limit used hints can transiently push the text past - // this width, but they're rare (deployment hours closing, daily quota hit) - // and a small one-time grow is fine. + // own indented line under the name. All buttons share a uniform width so + // the column reads as a clean stack of equal choices. const { wrapDetails, buttonOuterWidth } = useMemo(() => { const detailsTextLen = (model: FreebuffModelOption): number => { const parts: number[] = [] @@ -108,9 +124,14 @@ export const FreebuffModelSelector: React.FC = () => { } if (model.warning) parts.push(model.warning.length) if (parts.length === 0) return 0 - return parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 3 /* " · " */ + return ( + parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 3 + ) /* " · " */ } + const hintLen = (model: FreebuffModelOption): number => + Math.max(getQuotaHint(model.id).length, 'Closed'.length) + const oneLineLen = (model: FreebuffModelOption): number => { const inlineDetails = detailsTextLen(model) return ( @@ -118,12 +139,19 @@ export const FreebuffModelSelector: React.FC = () => { model.displayName.length + 3 /* " · " */ + model.tagline.length + - (inlineDetails > 0 ? 3 + inlineDetails : 0) + (inlineDetails > 0 ? 3 + inlineDetails : 0) + + 1 /* space before hint */ + + hintLen(model) ) } const labelLineLen = (model: FreebuffModelOption): number => - 2 + model.displayName.length + 3 + model.tagline.length + 2 + + model.displayName.length + + 3 + + model.tagline.length + + 1 + + hintLen(model) const detailsLineLen = (model: FreebuffModelOption): number => { const len = detailsTextLen(model) @@ -148,16 +176,8 @@ export const FreebuffModelSelector: React.FC = () => { contentMaxWidth, ), } - }, [contentMaxWidth, deploymentAvailabilityLabel]) + }, [contentMaxWidth, deploymentAvailabilityLabel, getQuotaHint]) - // "Already committed to this model" — only when the server has us queued - // on it. On the landing screen (status 'none'), nothing is committed yet, - // so picking the focused model is always a real action (first join). - const committedModelId = session?.status === 'queued' ? session.model : null - const rateLimitsByModel = - session && 'rateLimitsByModel' in session - ? session.rateLimitsByModel - : undefined const isJoinable = useCallback( (modelId: string) => { if (!isFreebuffModelAvailable(modelId, new Date(now))) return false @@ -230,19 +250,13 @@ export const FreebuffModelSelector: React.FC = () => { const isHovered = hoveredId === model.id const isFocused = focusedId === model.id const isAvailable = isFreebuffModelAvailable(model.id, new Date(now)) - const rateLimit = rateLimitsByModel?.[model.id] - const isQuotaExhausted = - rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit - const canJoin = isAvailable && !isQuotaExhausted + const canJoin = isJoinable(model.id) // Clickable whenever picking would actually do something — i.e. // anything except re-picking the queue we're already in. const interactable = !pending && canJoin && model.id !== committedModelId - const hint = !isAvailable - ? 'Closed' - : isQuotaExhausted - ? 'Limit used' - : '' + const quotaHint = getQuotaHint(model.id) + const hint = isAvailable ? quotaHint : 'Closed' // Focused row: green border + arrow indicator + bold name. The name // itself stays the normal foreground color so it doesn't shout — the @@ -251,7 +265,7 @@ export const FreebuffModelSelector: React.FC = () => { const fgColor = canJoin ? theme.foreground : theme.muted const mutedColor = theme.muted const warningColor = theme.secondary - const hintColor = theme.secondary + const hintColor = canJoin ? theme.muted : theme.secondary const borderColor = isFocused ? theme.primary @@ -303,16 +317,17 @@ export const FreebuffModelSelector: React.FC = () => { {showInlineWarning && ( · {model.warning} )} - {hint && {hint}} + {hint} {showWrappedDetails && ( - + {model.availability === 'deployment_hours' && ( {deploymentAvailabilityLabel} )} - {model.availability === 'deployment_hours' && - model.warning && · } + {model.availability === 'deployment_hours' && model.warning && ( + · + )} {model.warning && ( {model.warning} )} diff --git a/cli/src/components/status-bar.tsx b/cli/src/components/status-bar.tsx index 4216a1d66..82c2b16d8 100644 --- a/cli/src/components/status-bar.tsx +++ b/cli/src/components/status-bar.tsx @@ -66,6 +66,9 @@ const formatSessionRemaining = (ms: number): string => { return minutes === 0 ? `${hours}h left` : `${hours}h ${minutes}m left` } +const formatSessionUnits = (units: number): string => + Number.isInteger(units) ? String(units) : units.toFixed(1) + interface StatusBarProps { timerStartTime: number | null isAtBottom: boolean @@ -131,7 +134,8 @@ export const StatusBar = ({ case 'clipboard': // Use green color for feedback success messages - const isFeedbackSuccess = statusIndicatorState.message.includes('Feedback sent') + const isFeedbackSuccess = + statusIndicatorState.message.includes('Feedback sent') return ( {statusIndicatorState.message} @@ -142,12 +146,7 @@ export const StatusBar = ({ return Reconnected case 'retrying': - return ( - - ) + return case 'connecting': return @@ -180,8 +179,17 @@ export const StatusBar = ({ freebuffSession?.status === 'active' ? getFreebuffModel(freebuffSession.model).displayName : null + const quotaText = + freebuffSession?.status === 'active' && freebuffSession.rateLimit + ? `Premium ${formatSessionUnits(freebuffSession.rateLimit.recentCount)}/${freebuffSession.rateLimit.limit} used · ` + : freebuffSession?.status === 'active' + ? 'Unlimited · ' + : '' return ( - {modelName ? `${modelName} · ` : ''}{formatSessionRemaining(sessionProgress.remainingMs)} + + {modelName ? `${modelName} · ` : ''} + {quotaText}Free session ·{' '} + {formatSessionRemaining(sessionProgress.remainingMs)} ) } @@ -258,12 +266,18 @@ export const StatusBar = ({ }} > {elapsedTimeContent} - {onStop && (statusIndicatorState.kind === 'waiting' || statusIndicatorState.kind === 'streaming') && ( - ■ Esc - )} - {onEndSession && statusIndicatorState.kind === 'idle' && freebuffSession?.status === 'active' && ( - ✕ End session - )} + {onStop && + (statusIndicatorState.kind === 'waiting' || + statusIndicatorState.kind === 'streaming') && ( + ■ Esc + )} + {onEndSession && + statusIndicatorState.kind === 'idle' && + freebuffSession?.status === 'active' && ( + + ✕ End session + + )} {sessionProgress !== null && sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS && statusIndicatorState.kind !== 'idle' && ( diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index a87980905..36de9a86d 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -3,10 +3,7 @@ import { useRenderer } from '@opentui/react' import React, { useMemo, useState } from 'react' import { Button } from './button' -import { - ChoiceAdBanner, - CHOICE_AD_BANNER_HEIGHT, -} from './choice-ad-banner' +import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner' import { FreebuffModelSelector } from './freebuff-model-selector' import { ShimmerText } from './shimmer-text' import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit' @@ -59,6 +56,9 @@ const formatRetryAfter = (ms: number): string => { return rem === 0 ? `${hours}h` : `${hours}h ${rem}m` } +const formatSessionUnits = (units: number): string => + Number.isInteger(units) ? String(units) : units.toFixed(1) + const PRIVACY_SIGNAL_LABELS: Partial> = { anonymous: 'anonymized network', @@ -263,17 +263,16 @@ export const WaitingRoomScreen: React.FC = ({ Elapsed {formatElapsed(elapsedMs)} - {/* Per-model session quota (e.g. DeepSeek V4 Pro caps at 5/12h). - Only rendered for rate-limited models so the Minimax queue - stays clutter-free. */} + {/* Premium session quota. Minimax is unlimited, so it has no + rateLimit payload and skips this line. */} {session.rateLimit && ( - Sessions + Premium sessions - {session.rateLimit.recentCount} /{' '} + {formatSessionUnits(session.rateLimit.recentCount)} /{' '} {session.rateLimit.limit} - used in last {session.rateLimit.windowHours}h + used in the last 20 hours )} @@ -346,8 +345,8 @@ export const WaitingRoomScreen: React.FC = ({ )} - {/* Per-model session quota exhausted (e.g. 5+ DeepSeek sessions in - the last 12h). Terminal for this run — the user can exit and come + {/* 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. */} {session?.status === 'rate_limited' && ( <> @@ -357,10 +356,9 @@ export const WaitingRoomScreen: React.FC = ({ You've used{' '} - {session.recentCount} of {session.limit} + {formatSessionUnits(session.recentCount)} of {session.limit} {' '} - hour-long sessions on {session.model} in the last{' '} - {session.windowHours}h. Try again in{' '} + premium sessions in the last 20 hours. Try again in{' '} {formatRetryAfter(session.retryAfterMs)} diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts index 657d5343d..3f9618328 100644 --- a/common/src/constants/freebuff-models.ts +++ b/common/src/constants/freebuff-models.ts @@ -30,6 +30,8 @@ export const FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID = 'deepseek/deepseek-v4-pro' 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 const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York' const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles' @@ -78,7 +80,7 @@ export const FREEBUFF_MODELS = [ { id: FREEBUFF_MINIMAX_MODEL_ID, displayName: 'MiniMax M2.7', - tagline: 'Fastest', + tagline: 'Fastest, unlimited', availability: 'always', }, ] as const satisfies readonly FreebuffModelOption[] @@ -92,6 +94,12 @@ export const LEGACY_FREEBUFF_MODELS = [ }, ] as const satisfies readonly FreebuffModelOption[] +export const FREEBUFF_PREMIUM_MODEL_IDS = [ + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, +] as const + export const SUPPORTED_FREEBUFF_MODELS = [ ...FREEBUFF_MODELS, ...LEGACY_FREEBUFF_MODELS, @@ -100,6 +108,7 @@ export const SUPPORTED_FREEBUFF_MODELS = [ export type FreebuffModelId = (typeof FREEBUFF_MODELS)[number]['id'] export type SupportedFreebuffModelId = (typeof SUPPORTED_FREEBUFF_MODELS)[number]['id'] +export type FreebuffPremiumModelId = (typeof FREEBUFF_PREMIUM_MODEL_IDS)[number] /** What new freebuff users see selected in the picker. DeepSeek is the * smartest of the free options; the picker surfaces its data-collection @@ -136,6 +145,13 @@ export function isSupportedFreebuffModelId( return SUPPORTED_FREEBUFF_MODELS.some((m) => m.id === id) } +export function isFreebuffPremiumModelId( + id: string | null | undefined, +): id is FreebuffPremiumModelId { + if (!id) return false + return FREEBUFF_PREMIUM_MODEL_IDS.some((modelId) => modelId === id) +} + export function resolveSupportedFreebuffModel( id: string | null | undefined, ): SupportedFreebuffModelId { diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index b80ffed26..6f44d202b 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -7,11 +7,12 @@ */ /** - * Per-model 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 has a rate limit applied. `recentCount` is the number of - * admissions inside `windowHours` at the time the response was produced — - * see also the standalone `rate_limited` status for the reject path. + * 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. */ export interface FreebuffSessionRateLimit { model: string @@ -61,9 +62,9 @@ export type FreebuffSessionServerResponse = * Present on GET responses; not returned from POST (POST never * produces `none`). */ queueDepthByModel?: Record - /** Current quota snapshots for rate-limited models, keyed by model id. - * Lets the picker show exhausted daily/session caps before the user - * commits to a queue. */ + /** Current quota snapshots for premium models, keyed by model id. Lets + * the picker show rolling premium-session usage before the user commits + * to a queue. */ rateLimitsByModel?: FreebuffSessionRateLimitByModel } | { @@ -81,9 +82,7 @@ export type FreebuffSessionServerResponse = queueDepthByModel: Record estimatedWaitMs: number queuedAt: string - /** Rate-limit quota for rate-limited models. Absent - * for unlimited models or when the status was produced outside the - * rate-limit check path (e.g. pure read via GET). */ + /** Premium-session quota for this model. Absent for unlimited models. */ rateLimit?: FreebuffSessionRateLimit rateLimitsByModel?: FreebuffSessionRateLimitByModel } @@ -95,9 +94,7 @@ export type FreebuffSessionServerResponse = admittedAt: string expiresAt: string remainingMs: number - /** Rate-limit quota for rate-limited models. Absent - * for unlimited models or when the status was produced outside the - * rate-limit check path (e.g. pure read via GET). */ + /** Premium-session quota for this model. Absent for unlimited models. */ rateLimit?: FreebuffSessionRateLimit rateLimitsByModel?: FreebuffSessionRateLimitByModel } @@ -162,21 +159,20 @@ export type FreebuffSessionServerResponse = status: 'banned' } | { - /** User has used up their per-model admission quota in the rolling - * window. Returned from POST - * /session before the user is placed in the queue. `retryAfterMs` is - * the time until the oldest admission inside the window falls off - * and one quota slot opens up — clients should show the user when - * they can try again. Terminal for the CLI's current poll session; + /** 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. */ status: 'rate_limited' /** The freebuff model the user tried to join. */ model: string - /** Max admissions permitted per window (e.g. 5). */ + /** Max premium session units permitted per window (e.g. 5). */ limit: number /** Rolling window size in hours (e.g. 20). */ windowHours: number - /** Admission count inside the window at check time — will be ≥ limit. */ + /** Premium session units inside the window at check time — will be ≥ limit. */ recentCount: number /** Milliseconds from now until the oldest admission in the window * exits and the user regains one quota slot. */ diff --git a/packages/internal/src/db/migrations/0050_overrated_stellaris.sql b/packages/internal/src/db/migrations/0050_overrated_stellaris.sql new file mode 100644 index 000000000..9255e390b --- /dev/null +++ b/packages/internal/src/db/migrations/0050_overrated_stellaris.sql @@ -0,0 +1 @@ +ALTER TABLE "free_session_admit" ADD COLUMN "session_units" numeric(3, 1) DEFAULT '1.0' NOT NULL; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0050_snapshot.json b/packages/internal/src/db/migrations/meta/0050_snapshot.json new file mode 100644 index 000000000..7e56edc6e --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0050_snapshot.json @@ -0,0 +1,3198 @@ +{ + "id": "4c7aa6ac-8afc-4c2c-b0a4-2bbfcde731b8", + "prevId": "927c6e1e-457f-4815-99d1-96701792e9e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": ["provider", "providerAccountId"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gravity'" + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extra_pixels": { + "name": "extra_pixels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": ["imp_url"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": ["publisher_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": ["publisher_id", "id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": ["agent_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": ["user_id", "type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session": { + "name": "free_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "free_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active_instance_id": { + "name": "active_instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_checked_at": { + "name": "country_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_session_queue": { + "name": "idx_free_session_queue", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_session_expiry": { + "name": "idx_free_session_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_user_id_user_id_fk": { + "name": "free_session_user_id_user_id_fk", + "tableFrom": "free_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session_admit": { + "name": "free_session_admit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session_units": { + "name": "session_units", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + } + }, + "indexes": { + "idx_free_session_admit_user_model_time": { + "name": "idx_free_session_admit_user_model_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "admitted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_admit_user_id_user_id_fk": { + "name": "free_session_admit_user_id_user_id_fk", + "tableFrom": "free_session_admit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_customer_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": ["org_id", "feature"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": ["accepted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": ["org_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": ["approved_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": ["referrer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": ["referred_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": ["referrer_id", "referred_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_auth_hash": { + "name": "cli_auth_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_cli_auth_code_idx": { + "name": "session_cli_auth_code_idx", + "columns": [ + { + "expression": "fingerprint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cli_auth_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"session\".\"cli_auth_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": ["fingerprint_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_customer_id"] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": ["referral_code"] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": ["discord_id"] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": ["handle"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": ["pending", "completed"] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": ["running", "completed", "failed", "cancelled"] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": ["running", "completed", "skipped"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["anthropic", "gemini", "openai"] + }, + "public.free_session_status": { + "name": "free_session_status", + "schema": "public", + "values": ["queued", "active"] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": ["owner", "admin", "member"] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": ["web", "pat", "cli"] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index d93bf8857..6dcc93004 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1777929052630, "tag": "0049_loud_madame_masque", "breakpoints": true + }, + { + "idx": 50, + "version": "7", + "when": 1777936763321, + "tag": "0050_overrated_stellaris", + "breakpoints": true } ] } diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 28406296d..ee4f32509 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -911,7 +911,9 @@ export const freeSession = pgTable( /** * Audit log of every admission — one row per queued→active transition. Used - * to rate-limit heavy users (e.g. no more than 5 DeepSeek sessions per 12h). + * 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. * * Separate from `free_session` because that table is one-row-per-user (state, * not history); the UPSERT path there would otherwise destroy prior admissions. @@ -932,6 +934,12 @@ export const freeSessionAdmit = pgTable( }) .notNull() .defaultNow(), + session_units: numeric('session_units', { + precision: 3, + scale: 1, + }) + .notNull() + .default('1.0'), }, (table) => [ // Rate-limit lookup: WHERE user_id=$1 AND model=$2 AND admitted_at > $cutoff diff --git a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts index 6f630e4d2..af77ac8f5 100644 --- a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts +++ b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts @@ -112,7 +112,7 @@ function makeSessionDeps(overrides: Partial = {}): SessionDeps & { promoteQueuedUser: async () => null, // No admits in handler tests — the rate-limit check reads empty and // every request falls through to the queue. - listRecentAdmits: async () => [], + listRecentPremiumAdmits: async () => [], now: () => now, getSessionRow: async (userId) => rows.get(userId) ?? null, queueDepthsByModel: async () => { @@ -124,7 +124,7 @@ function makeSessionDeps(overrides: Partial = {}): SessionDeps & { return out }, queuePositionFor: async () => 1, - endSession: async (userId) => { + endSession: async ({ userId }) => { rows.delete(userId) }, joinOrTakeOver: async ({ userId, model, now, countryAccess }) => { diff --git a/web/src/server/free-session/__tests__/public-api.test.ts b/web/src/server/free-session/__tests__/public-api.test.ts index 153021d8e..d29c2cb1f 100644 --- a/web/src/server/free-session/__tests__/public-api.test.ts +++ b/web/src/server/free-session/__tests__/public-api.test.ts @@ -5,6 +5,8 @@ import { FREEBUFF_GEMINI_PRO_MODEL_ID, FREEBUFF_GLM_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_PREMIUM_SESSION_LIMIT, + FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, } from '@codebuff/common/constants/freebuff-models' import { @@ -26,6 +28,7 @@ interface AdmitRecord { user_id: string model: string admitted_at: Date + session_units?: number } function makeDeps(overrides: Partial = {}): SessionDeps & { @@ -67,17 +70,20 @@ function makeDeps(overrides: Partial = {}): SessionDeps & { } return n }, - listRecentAdmits: async ({ userId, model, since, limit }) => { + listRecentPremiumAdmits: async ({ userId, models, since }) => { return admits .filter( (a) => a.user_id === userId && - a.model === model && + models.includes(a.model) && a.admitted_at.getTime() >= since.getTime(), ) .sort((a, b) => a.admitted_at.getTime() - b.admitted_at.getTime()) - .slice(0, limit) - .map((a) => a.admitted_at) + .map((a) => ({ + admittedAt: a.admitted_at, + model: a.model, + sessionUnits: a.session_units ?? 1, + })) }, promoteQueuedUser: async ({ userId, model, sessionLengthMs, now }) => { const row = rows.get(userId) @@ -86,12 +92,38 @@ function makeDeps(overrides: Partial = {}): SessionDeps & { row.admitted_at = now row.expires_at = new Date(now.getTime() + sessionLengthMs) row.updated_at = now - admits.push({ user_id: userId, model, admitted_at: now }) + admits.push({ + user_id: userId, + model, + admitted_at: now, + session_units: 1, + }) return row }, now: () => currentNow, getSessionRow: async (userId) => rows.get(userId) ?? null, - endSession: async (userId) => { + endSession: async ({ userId, now, sessionLengthMs }) => { + const row = rows.get(userId) + if ( + row?.status === 'active' && + row.admitted_at && + row.expires_at && + row.expires_at.getTime() > now.getTime() + ) { + const latest = admits + .filter((a) => a.user_id === userId && a.model === row.model) + .sort((a, b) => b.admitted_at.getTime() - a.admitted_at.getTime())[0] + if (latest) { + const usedMs = Math.max( + 0, + Math.min( + sessionLengthMs, + now.getTime() - row.admitted_at.getTime(), + ), + ) + latest.session_units = Math.ceil((usedMs / sessionLengthMs) * 10) / 10 + } + } rows.delete(userId) }, queueDepthsByModel: async () => { @@ -239,8 +271,8 @@ describe('requestSession', () => { expect(deps.rows.get('u1')?.model).toBe(FREEBUFF_GLM_MODEL_ID) expect(state.rateLimit).toEqual({ model: FREEBUFF_GLM_MODEL_ID, - limit: 5, - windowHours: 12, + limit: FREEBUFF_PREMIUM_SESSION_LIMIT, + windowHours: FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, recentCount: 0, }) }) @@ -269,8 +301,8 @@ describe('requestSession', () => { expect(state.instanceId).not.toBe('inst-pre') expect(state.rateLimit).toEqual({ model: FREEBUFF_GLM_MODEL_ID, - limit: 5, - windowHours: 12, + limit: FREEBUFF_PREMIUM_SESSION_LIMIT, + windowHours: FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, recentCount: 0, }) }) @@ -282,7 +314,11 @@ describe('requestSession', () => { deps._tick(new Date(deps._now().getTime() + 1000)) await requestSession({ userId: 'u2', model: DEFAULT_MODEL, deps }) deps._tick(new Date(deps._now().getTime() + 1000)) - await requestSession({ userId: 'u3', model: 'deepseek/deepseek-v4-pro', deps }) + await requestSession({ + userId: 'u3', + model: 'deepseek/deepseek-v4-pro', + deps, + }) const state = await getSessionState({ userId: 'u1', deps }) if (state.status !== 'queued') throw new Error('unreachable') @@ -396,51 +432,101 @@ describe('requestSession', () => { expect(s3.status).toBe('active') }) - // Per-user rate limit (5 DeepSeek admissions per 18h) — the wire limit is + // Per-user premium session limit (5 units per 20h) — the wire limit is // hard-coded in public-api.ts, so tests seed the fake admit log directly - // rather than configuring it. DeepSeek runs 24/7, so the open-time anchor - // here just keeps these scenarios deterministic against the test clock. - const DEEPSEEK_MODEL = FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID - const DEEPSEEK_LIMIT = 5 - const DEEPSEEK_WINDOW_HOURS = 18 - const DEEPSEEK_OPEN_TIME = new Date('2026-04-17T16:00:00Z') - - test('rate_limited: 5th DeepSeek admit in window blocks the 6th attempt', async () => { - deps._tick(DEEPSEEK_OPEN_TIME) - // Seed 5 admits inside the 18h window, spaced so we can verify retryAfter + // rather than configuring it. + const PREMIUM_MODEL = FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID + const KIMI_MODEL = FREEBUFF_KIMI_MODEL_ID + const PREMIUM_LIMIT = FREEBUFF_PREMIUM_SESSION_LIMIT + const PREMIUM_WINDOW_HOURS = FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS + const PREMIUM_OPEN_TIME = new Date('2026-04-17T16:00:00Z') + + test('rate_limited: shared premium pool blocks the next premium session at 5 units', async () => { + deps._tick(PREMIUM_OPEN_TIME) + const now = deps._now() + for (let i = 0; i < PREMIUM_LIMIT; i++) { + deps.admits.push({ + user_id: 'u1', + model: i === 0 ? KIMI_MODEL : PREMIUM_MODEL, + admitted_at: new Date(now.getTime() - (19 - i) * 60 * 60 * 1000), + }) + } + + const state = await requestSession({ + userId: 'u1', + model: PREMIUM_MODEL, + deps, + }) + expect(state.status).toBe('rate_limited') + if (state.status !== 'rate_limited') throw new Error('unreachable') + expect(state.model).toBe(PREMIUM_MODEL) + expect(state.limit).toBe(PREMIUM_LIMIT) + expect(state.windowHours).toBe(PREMIUM_WINDOW_HOURS) + expect(state.recentCount).toBe(PREMIUM_LIMIT) + expect(state.retryAfterMs).toBe(60 * 60 * 1000) + expect(deps.rows.has('u1')).toBe(false) + }) + + test('rate_limited: DeepSeek admit outside 20h window does not count', async () => { + deps._tick(PREMIUM_OPEN_TIME) + const now = deps._now() + deps.admits.push({ + user_id: 'u1', + model: PREMIUM_MODEL, + admitted_at: new Date(now.getTime() - 21 * 60 * 60 * 1000), + }) + + const state = await requestSession({ + userId: 'u1', + model: PREMIUM_MODEL, + deps, + }) + expect(state.status).toBe('queued') + if (state.status !== 'queued') throw new Error('unreachable') + expect(state.rateLimit).toEqual({ + model: PREMIUM_MODEL, + limit: PREMIUM_LIMIT, + windowHours: PREMIUM_WINDOW_HOURS, + recentCount: 0, + }) + }) + + test('rate_limited: 5th Kimi admit in window blocks the 6th attempt', async () => { + deps._tick(PREMIUM_OPEN_TIME) + // Seed 5 admits inside the 20h window, spaced so we can verify retryAfter // points at the oldest one sliding off. const now = deps._now() - // Oldest: 17h ago (still in window). Next 4: 1h, 2h, 3h, 4h ago. - const ages = [17, 4, 3, 2, 1] + // Oldest: 19h ago (still in window). Next 4: 1h, 2h, 3h, 4h ago. + const ages = [19, 4, 3, 2, 1] for (const hoursAgo of ages) { deps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: KIMI_MODEL, admitted_at: new Date(now.getTime() - hoursAgo * 60 * 60 * 1000), }) } const state = await requestSession({ userId: 'u1', - model: DEEPSEEK_MODEL, + model: KIMI_MODEL, deps, }) expect(state.status).toBe('rate_limited') if (state.status !== 'rate_limited') throw new Error('unreachable') - expect(state.model).toBe(DEEPSEEK_MODEL) - expect(state.limit).toBe(DEEPSEEK_LIMIT) - expect(state.windowHours).toBe(DEEPSEEK_WINDOW_HOURS) - expect(state.recentCount).toBe(DEEPSEEK_LIMIT) - // Oldest admit is 17h ago; slot opens when it hits 18h, i.e. in 1h. + expect(state.model).toBe(KIMI_MODEL) + expect(state.limit).toBe(PREMIUM_LIMIT) + expect(state.windowHours).toBe(PREMIUM_WINDOW_HOURS) + expect(state.recentCount).toBe(PREMIUM_LIMIT) + // Oldest admit is 19h ago; slot opens when it hits 20h, i.e. in 1h. expect(state.retryAfterMs).toBe(60 * 60 * 1000) // Blocked before any row is written — the user doesn't take a queue slot. expect(deps.rows.has('u1')).toBe(false) }) - test('rate_limited: legacy GLM 5.1 keeps the deployment-hours quota', async () => { - deps._tick(DEEPSEEK_OPEN_TIME) + test('rate_limited: legacy GLM 5.1 uses the shared premium quota', async () => { + deps._tick(PREMIUM_OPEN_TIME) const now = deps._now() - for (let i = 0; i < DEEPSEEK_LIMIT; i++) { + for (let i = 0; i < PREMIUM_LIMIT; i++) { deps.admits.push({ user_id: 'u1', model: FREEBUFF_GLM_MODEL_ID, @@ -456,26 +542,26 @@ describe('requestSession', () => { expect(state.status).toBe('rate_limited') if (state.status !== 'rate_limited') throw new Error('unreachable') expect(state.model).toBe(FREEBUFF_GLM_MODEL_ID) - expect(state.limit).toBe(DEEPSEEK_LIMIT) - expect(state.windowHours).toBe(12) + expect(state.limit).toBe(PREMIUM_LIMIT) + expect(state.windowHours).toBe(PREMIUM_WINDOW_HOURS) }) - test('rate_limited: admits outside the 18h window do not count', async () => { - deps._tick(DEEPSEEK_OPEN_TIME) - // 5 admits, each just over 18h old → all fall off the window. + test('rate_limited: admits outside the 20h window do not count', async () => { + deps._tick(PREMIUM_OPEN_TIME) + // 5 admits, each just over 20h old → all fall off the window. const now = deps._now() for (let i = 0; i < 5; i++) { deps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, admitted_at: new Date( - now.getTime() - (DEEPSEEK_WINDOW_HOURS * 60 * 60 * 1000 + 60_000 + i), + now.getTime() - (PREMIUM_WINDOW_HOURS * 60 * 60 * 1000 + 60_000 + i), ), }) } const state = await requestSession({ userId: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, deps, }) expect(state.status).toBe('queued') @@ -504,48 +590,76 @@ describe('requestSession', () => { }) test('queued DeepSeek response carries the current admit count', async () => { - deps._tick(DEEPSEEK_OPEN_TIME) + deps._tick(PREMIUM_OPEN_TIME) const now = deps._now() // 2 admits in the window — under the limit so the user still queues. deps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, admitted_at: new Date(now.getTime() - 60 * 60 * 1000), }) deps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, admitted_at: new Date(now.getTime() - 30 * 60 * 1000), }) const state = await requestSession({ userId: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, deps, }) if (state.status !== 'queued') throw new Error('unreachable') expect(state.rateLimit).toEqual({ - model: DEEPSEEK_MODEL, - limit: DEEPSEEK_LIMIT, - windowHours: DEEPSEEK_WINDOW_HOURS, + model: PREMIUM_MODEL, + limit: PREMIUM_LIMIT, + windowHours: PREMIUM_WINDOW_HOURS, recentCount: 2, }) }) - test('rate_limited: takeover of an active DeepSeek row is allowed even when at cap', async () => { - // Reclaim path: user has an active+unexpired DeepSeek session and restarts + test('rate_limited: fractional premium usage under the cap can start another session', async () => { + deps._tick(PREMIUM_OPEN_TIME) + const now = deps._now() + deps.admits.push({ + user_id: 'u1', + model: KIMI_MODEL, + admitted_at: new Date(now.getTime() - 19 * 60 * 60 * 1000), + session_units: 0.9, + }) + for (let i = 0; i < 4; i++) { + deps.admits.push({ + user_id: 'u1', + model: KIMI_MODEL, + admitted_at: new Date(now.getTime() - (i + 1) * 60 * 60 * 1000), + }) + } + + const state = await requestSession({ + userId: 'u1', + model: KIMI_MODEL, + deps, + }) + + expect(state.status).toBe('queued') + if (state.status !== 'queued') throw new Error('unreachable') + expect(state.rateLimit?.recentCount).toBe(4.9) + }) + + test('rate_limited: takeover of an active premium row is allowed even when at cap', async () => { + // Reclaim path: user has an active+unexpired premium session and restarts // the CLI. POST must rotate their instance id (takeover) and NOT reject // with rate_limited — otherwise they'd be stranded with a live session // they can't reconnect to. The 5th admission is already in the log, so // this also exercises "at the cap" rather than "over the cap". - deps._tick(DEEPSEEK_OPEN_TIME) + deps._tick(PREMIUM_OPEN_TIME) const now = deps._now() // Seed 5 prior admits (the cap), with the latest one matching the // active row we're about to install. - const ages = [11, 4, 3, 2, 0] + const ages = [19, 4, 3, 2, 0] for (const hoursAgo of ages) { deps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, admitted_at: new Date(now.getTime() - hoursAgo * 60 * 60 * 1000), }) } @@ -556,7 +670,7 @@ describe('requestSession', () => { user_id: 'u1', status: 'active', active_instance_id: 'inst-pre', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, queued_at: admittedAt, admitted_at: admittedAt, expires_at: new Date(admittedAt.getTime() + SESSION_LEN), @@ -566,27 +680,27 @@ describe('requestSession', () => { const state = await requestSession({ userId: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, deps, }) expect(state.status).toBe('active') if (state.status !== 'active') throw new Error('unreachable') // Instance id rotated; quota snapshot still reflects the full window. expect(state.instanceId).not.toBe('inst-pre') - expect(state.rateLimit?.recentCount).toBe(DEEPSEEK_LIMIT) + expect(state.rateLimit?.recentCount).toBe(PREMIUM_LIMIT) }) - test('rate_limited: reclaim of a queued DeepSeek row is allowed even when at cap', async () => { + test('rate_limited: reclaim of a queued premium row is allowed even when at cap', async () => { // Same reclaim exception for queued rows: if a user has already queued // (say they slipped in just before their 5th admit landed), a subsequent // POST from the same CLI must preserve their queue position instead of // flipping to rate_limited. - deps._tick(DEEPSEEK_OPEN_TIME) + deps._tick(PREMIUM_OPEN_TIME) const now = deps._now() - for (let i = 0; i < DEEPSEEK_LIMIT; i++) { + for (let i = 0; i < PREMIUM_LIMIT; i++) { deps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, admitted_at: new Date(now.getTime() - (i + 1) * 60 * 60 * 1000), }) } @@ -595,7 +709,7 @@ describe('requestSession', () => { user_id: 'u1', status: 'queued', active_instance_id: 'inst-pre', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, queued_at: queuedAt, admitted_at: null, expires_at: null, @@ -605,7 +719,7 @@ describe('requestSession', () => { const state = await requestSession({ userId: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, deps, }) expect(state.status).toBe('queued') @@ -613,20 +727,20 @@ describe('requestSession', () => { // Same position (1) since we preserved queued_at and nobody else is // ahead; the instance id rotated so any prior CLI is superseded. expect(state.instanceId).not.toBe('inst-pre') - expect(state.rateLimit?.recentCount).toBe(DEEPSEEK_LIMIT) + expect(state.rateLimit?.recentCount).toBe(PREMIUM_LIMIT) }) - test('rate_limited: expired DeepSeek row is not a reclaim — quota still applies', async () => { + test('rate_limited: expired premium row is not a reclaim — quota still applies', async () => { // The stored row's expires_at is in the past, so it doesn't represent // an in-flight session. This POST is effectively a fresh request and // must be blocked by the quota. - deps._tick(DEEPSEEK_OPEN_TIME) + deps._tick(PREMIUM_OPEN_TIME) const now = deps._now() - const ages = [11, 4, 3, 2, 1] + const ages = [19, 4, 3, 2, 1] for (const hoursAgo of ages) { deps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, admitted_at: new Date(now.getTime() - hoursAgo * 60 * 60 * 1000), }) } @@ -635,7 +749,7 @@ describe('requestSession', () => { user_id: 'u1', status: 'active', active_instance_id: 'inst-pre', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, queued_at: admittedAt, admitted_at: admittedAt, expires_at: new Date(admittedAt.getTime() + SESSION_LEN), @@ -644,7 +758,7 @@ describe('requestSession', () => { }) const state = await requestSession({ userId: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, deps, }) expect(state.status).toBe('rate_limited') @@ -652,18 +766,18 @@ describe('requestSession', () => { test('instant-admit bumps the quota count for the freshly-written admit row', async () => { const admitDeps = makeDeps({ getInstantAdmitCapacity: () => 3 }) - admitDeps._tick(DEEPSEEK_OPEN_TIME) + admitDeps._tick(PREMIUM_OPEN_TIME) // 1 existing admit in the window; this new call should instant-admit and // write a second row, so the response's recentCount reflects 2. const now = admitDeps._now() admitDeps.admits.push({ user_id: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, admitted_at: new Date(now.getTime() - 30 * 60 * 1000), }) const state = await requestSession({ userId: 'u1', - model: DEEPSEEK_MODEL, + model: PREMIUM_MODEL, deps: admitDeps, }) if (state.status !== 'active') throw new Error('unreachable') @@ -697,6 +811,27 @@ describe('getSessionState', () => { expect(state).toEqual({ status: 'none', queueDepthByModel: {} }) }) + test('no row surfaces used premium quota before joining', async () => { + const now = deps._now() + deps.admits.push({ + user_id: 'u1', + model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + admitted_at: new Date(now.getTime() - 19 * 60 * 60 * 1000), + }) + + const state = await getSessionState({ userId: 'u1', deps }) + expect(state.status).toBe('none') + if (state.status !== 'none') throw new Error('unreachable') + expect( + state.rateLimitsByModel?.[FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID], + ).toEqual({ + model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + limit: FREEBUFF_PREMIUM_SESSION_LIMIT, + windowHours: FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, + recentCount: 1, + }) + }) + test('active session with matching instance id returns active', async () => { await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) const row = deps.rows.get('u1')! @@ -740,7 +875,11 @@ describe('getSessionState', () => { model: 'deepseek/deepseek-v4-pro', admitted_at: new Date(now.getTime() - 60 * 60 * 1000), }) - await requestSession({ userId: 'u1', model: 'deepseek/deepseek-v4-pro', deps }) + await requestSession({ + userId: 'u1', + model: 'deepseek/deepseek-v4-pro', + deps, + }) const row = deps.rows.get('u1')! row.status = 'active' row.admitted_at = now @@ -753,23 +892,27 @@ describe('getSessionState', () => { }) if (state.status !== 'active') throw new Error('unreachable') expect(state.rateLimit).toEqual({ - model: 'deepseek/deepseek-v4-pro', - limit: 5, - windowHours: 18, + model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + limit: FREEBUFF_PREMIUM_SESSION_LIMIT, + windowHours: FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, recentCount: 1, }) }) - test('active session only fetches quota for its own model', async () => { + test('active session only fetches one shared premium quota snapshot', async () => { deps._tick(new Date('2026-04-17T16:00:00Z')) let listRecentAdmitsCalls = 0 - const originalListRecentAdmits = deps.listRecentAdmits - deps.listRecentAdmits = async (params) => { + const originalListRecentAdmits = deps.listRecentPremiumAdmits + deps.listRecentPremiumAdmits = async (params) => { listRecentAdmitsCalls++ return originalListRecentAdmits(params) } - await requestSession({ userId: 'u1', model: 'deepseek/deepseek-v4-pro', deps }) + await requestSession({ + userId: 'u1', + model: 'deepseek/deepseek-v4-pro', + deps, + }) const row = deps.rows.get('u1')! row.status = 'active' row.admitted_at = deps._now() @@ -1117,6 +1260,23 @@ describe('endUserSession', () => { expect(deps.rows.has('u1')).toBe(false) }) + test('rounds active premium session usage up to nearest tenth on early end', async () => { + const deps = makeDeps({ getInstantAdmitCapacity: () => 3 }) + deps._tick(new Date('2026-04-17T16:00:00Z')) + const state = await requestSession({ + userId: 'u1', + model: FREEBUFF_KIMI_MODEL_ID, + deps, + }) + expect(state.status).toBe('active') + deps._tick(new Date(deps._now().getTime() + 14 * 60 * 1000)) + + await endUserSession({ userId: 'u1', deps }) + + expect(deps.rows.has('u1')).toBe(false) + expect(deps.admits[0]?.session_units).toBe(0.3) + }) + test('is no-op when disabled', async () => { const deps = makeDeps({ isWaitingRoomEnabled: () => false }) deps.rows.set('u1', { diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index 52d5d442b..a1a065abe 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -3,9 +3,11 @@ import { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_DEPLOYMENT_HOURS_LABEL, FREEBUFF_GEMINI_PRO_MODEL_ID, - FREEBUFF_GLM_MODEL_ID, - FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_PREMIUM_MODEL_IDS, + FREEBUFF_PREMIUM_SESSION_LIMIT, + FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, isFreebuffModelAvailable, + isFreebuffPremiumModelId, isSupportedFreebuffModelId, resolveSupportedFreebuffModel, } from '@codebuff/common/constants/freebuff-models' @@ -23,7 +25,7 @@ import { FreeSessionModelLockedError, getSessionRow, joinOrTakeOver, - listRecentAdmits, + listRecentPremiumAdmits, promoteQueuedUser, queueDepthsByModel, queuePositionFor, @@ -40,72 +42,106 @@ import type { SessionStateResponse, } from './types' -/** - * Per-model admission rate limits. Keyed by freebuff model id; a model not - * in the map has no rate limit applied. Minimax is cheap enough to leave - * unlimited. - * - * Hard-coded rather than env-driven: the values need to be observable in the - * code review, and the CLI already renders the numbers via `rateLimit` on - * queued/active responses — changing them is a deliberate, typed edit. - */ -const RATE_LIMITS: Record = { - [FREEBUFF_GLM_MODEL_ID]: { limit: 5, windowHours: 12 }, - [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]: { limit: 5, windowHours: 18 }, - [FREEBUFF_KIMI_MODEL_ID]: { limit: 5, windowHours: 18 }, +function roundSessionUnits(units: number): number { + return Math.round(units * 10) / 10 } -/** Fetch the caller's current quota snapshot for `model`, or undefined if the - * model isn't rate-limited. Used by both POST (after admit) and GET polls so - * the CLI's "N of M sessions used" line stays live instead of disappearing - * after the first poll. Also returns the oldest admit in-window and the - * window duration so callers that need `retryAfterMs` don't have to re-query - * or duplicate the window math. */ -async function fetchRateLimitSnapshot( +function getRetryAfterMsForPremiumLimit(params: { + admits: Awaited> + totalUnits: number + targetUnits: number + windowMs: number + now: Date +}): number { + let remainingUnits = params.totalUnits + for (const admit of params.admits) { + remainingUnits = roundSessionUnits(remainingUnits - admit.sessionUnits) + if (remainingUnits <= params.targetUnits) { + return Math.max( + 0, + admit.admittedAt.getTime() + params.windowMs - params.now.getTime(), + ) + } + } + return 0 +} + +function canStartPremiumSession(snapshot: FreebuffSessionRateLimit): boolean { + return snapshot.recentCount < snapshot.limit +} + +interface PremiumQuotaSnapshot { + recentCount: number + admits: Awaited> + windowMs: number +} + +async function fetchPremiumQuotaSnapshot( userId: string, - model: string, deps: SessionDeps, -): Promise< - | { info: FreebuffSessionRateLimit; oldest: Date | null; windowMs: number } - | undefined -> { - const cfg = RATE_LIMITS[model] - if (!cfg) return undefined +): Promise { const now = nowOf(deps) - const windowMs = cfg.windowHours * 60 * 60 * 1000 + const windowMs = FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS * 60 * 60 * 1000 const since = new Date(now.getTime() - windowMs) - const admits = await deps.listRecentAdmits({ + const admits = await deps.listRecentPremiumAdmits({ userId, - model, since, - limit: cfg.limit, + models: FREEBUFF_PREMIUM_MODEL_IDS, }) return { - info: { - model, - limit: cfg.limit, - windowHours: cfg.windowHours, - recentCount: admits.length, - }, - oldest: admits[0] ?? null, + recentCount: roundSessionUnits( + admits.reduce((sum, admit) => sum + admit.sessionUnits, 0), + ), + admits, windowMs, } } +function toRateLimitInfo( + model: string, + snapshot: PremiumQuotaSnapshot, +): FreebuffSessionRateLimit { + return { + model, + limit: FREEBUFF_PREMIUM_SESSION_LIMIT, + windowHours: FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, + recentCount: snapshot.recentCount, + } +} + +/** Fetch the caller's current shared premium-session quota snapshot for + * `model`, or undefined if the model is unlimited. Used by both POST (after + * admit) and GET polls so the CLI's "N of M sessions used" line stays live + * instead of disappearing after the first poll. */ +async function fetchRateLimitSnapshot( + userId: string, + model: string, + deps: SessionDeps, +): Promise< + | { + info: FreebuffSessionRateLimit + admits: Awaited> + windowMs: number + } + | undefined +> { + if (!isFreebuffPremiumModelId(model)) return undefined + const snapshot = await fetchPremiumQuotaSnapshot(userId, deps) + return { + info: toRateLimitInfo(model, snapshot), + admits: snapshot.admits, + windowMs: snapshot.windowMs, + } +} + async function fetchRateLimitsByModel( userId: string, deps: SessionDeps, ): Promise> { - const entries = await Promise.all( - Object.keys(RATE_LIMITS).map(async (model) => { - const snapshot = await fetchRateLimitSnapshot(userId, model, deps) - return snapshot ? ([model, snapshot.info] as const) : null - }), - ) + const snapshot = await fetchPremiumQuotaSnapshot(userId, deps) return Object.fromEntries( - entries.filter( - (entry): entry is readonly [string, FreebuffSessionRateLimit] => - entry !== null, + FREEBUFF_PREMIUM_MODEL_IDS.map( + (model) => [model, toRateLimitInfo(model, snapshot)] as const, ), ) } @@ -134,7 +170,11 @@ export interface SessionDeps { now: Date countryAccess?: FreeSessionCountryAccessMetadata }) => Promise - endSession: (userId: string) => Promise + endSession: (params: { + userId: string + now: Date + sessionLengthMs: number + }) => Promise queueDepthsByModel: () => Promise> queuePositionFor: (params: { userId: string @@ -145,15 +185,12 @@ export interface SessionDeps { * bound to a given model. Compared against the model's configured * `instantAdmitCapacity` to decide whether a new joiner skips the queue. */ activeCountForModel: (model: string) => Promise - /** Rate-limit helper: oldest-first admission timestamps for (userId, model) - * inside the window. The caller uses `rows.length` as the count (capped - * at `limit`) and `rows[0]` as the oldest for `retryAfterMs`. */ - listRecentAdmits: (params: { + /** Rate-limit helper: oldest-first premium admissions inside the window. */ + listRecentPremiumAdmits: (params: { userId: string - model: string + models: readonly string[] since: Date - limit: number - }) => Promise + }) => Promise<{ admittedAt: Date; model: string; sessionUnits: number }[]> /** Instant-admit promotion: flips a specific queued row to active. Returns * the updated row or null if the row wasn't in a queued state. */ promoteQueuedUser: (params: { @@ -182,7 +219,7 @@ const defaultDeps: SessionDeps = { queueDepthsByModel, queuePositionFor, activeCountForModel, - listRecentAdmits, + listRecentPremiumAdmits, promoteQueuedUser, getInstantAdmitCapacity, isWaitingRoomEnabled, @@ -291,8 +328,8 @@ export async function requestSession(params: { } // Rate-limit check runs before joinOrTakeOver so heavy users never even - // create a queued row. Only models listed in RATE_LIMITS are gated; others - // (Minimax today) fall through unchanged. + // create a queued row. Premium models share one 20h session-unit pool; + // Minimax falls through unchanged as unlimited. // // Takeover/reclaim exception: a user who already holds a queued or // active+unexpired row on this same model is re-anchoring (CLI restart, @@ -319,13 +356,14 @@ export async function requestSession(params: { if (!isReclaim) { const snapshot = await fetchRateLimitSnapshot(params.userId, model, deps) - if (snapshot && snapshot.info.recentCount >= snapshot.info.limit) { - // Oldest admit's window-anniversary is when one slot opens back up. - // Clamped at 0 so a clock skew can't surface a negative retry-after. - const retryAfterMs = Math.max( - 0, - (snapshot.oldest?.getTime() ?? 0) + snapshot.windowMs - now.getTime(), - ) + if (snapshot && !canStartPremiumSession(snapshot.info)) { + const retryAfterMs = getRetryAfterMsForPremiumLimit({ + admits: snapshot.admits, + totalUnits: snapshot.info.recentCount, + targetUnits: snapshot.info.limit, + windowMs: snapshot.windowMs, + now, + }) return { status: 'rate_limited', model, @@ -493,7 +531,11 @@ export async function endUserSession(params: { ) { return } - await deps.endSession(params.userId) + await deps.endSession({ + userId: params.userId, + now: nowOf(deps), + sessionLengthMs: deps.sessionLengthMs, + }) } export type SessionGateResult = diff --git a/web/src/server/free-session/store.ts b/web/src/server/free-session/store.ts index 1a8d2dba0..660f7a34a 100644 --- a/web/src/server/free-session/store.ts +++ b/web/src/server/free-session/store.ts @@ -1,7 +1,7 @@ import { db } from '@codebuff/internal/db' import { coerceBool } from '@codebuff/internal/db/advisory-lock' import * as schema from '@codebuff/internal/db/schema' -import { and, asc, count, eq, gte, lt, sql } from 'drizzle-orm' +import { and, asc, count, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm' import { FREEBUFF_ADMISSION_LOCK_ID } from './config' @@ -161,10 +161,70 @@ export async function joinOrTakeOver(params: { return row as InternalSessionRow } -export async function endSession(userId: string): Promise { - await db - .delete(schema.freeSession) - .where(eq(schema.freeSession.user_id, userId)) +export function getRoundedSessionUnits(params: { + admittedAt: Date | null + now: Date + sessionLengthMs: number +}): number { + const { admittedAt, now, sessionLengthMs } = params + if (!admittedAt || sessionLengthMs <= 0) return 0 + const usedMs = Math.max( + 0, + Math.min(sessionLengthMs, now.getTime() - admittedAt.getTime()), + ) + return Math.ceil((usedMs / sessionLengthMs) * 10) / 10 +} + +export async function endSession(params: { + userId: string + now: Date + sessionLengthMs: number +}): Promise { + const { userId, now, sessionLengthMs } = params + await db.transaction(async (tx) => { + const [row] = await tx + .select() + .from(schema.freeSession) + .where(eq(schema.freeSession.user_id, userId)) + .for('update') + .limit(1) + + if ( + row?.status === 'active' && + row.admitted_at && + row.expires_at && + row.expires_at.getTime() > now.getTime() + ) { + const sessionUnits = getRoundedSessionUnits({ + admittedAt: row.admitted_at, + now, + sessionLengthMs, + }).toFixed(1) + + const [latestAdmit] = await tx + .select({ id: schema.freeSessionAdmit.id }) + .from(schema.freeSessionAdmit) + .where( + and( + eq(schema.freeSessionAdmit.user_id, userId), + eq(schema.freeSessionAdmit.model, row.model), + ), + ) + .orderBy(desc(schema.freeSessionAdmit.admitted_at)) + .limit(1) + + if (latestAdmit) { + await tx + .update(schema.freeSessionAdmit) + .set({ session_units: sessionUnits }) + .where(eq(schema.freeSessionAdmit.id, latestAdmit.id)) + } + } + + await tx + .delete(schema.freeSession) + .where(eq(schema.freeSession.user_id, userId)) + }) } export async function queueDepth(params: { model: string }): Promise { @@ -459,36 +519,44 @@ export async function promoteQueuedUser(params: { }) } +export interface RecentSessionAdmit { + admittedAt: Date + model: string + sessionUnits: number +} + /** - * List admissions for `userId` on `model` whose `admitted_at` is within the - * window `[since, ∞)`, ordered oldest-first. Caller gets both the count - * (array length, capped at `limit`) and the oldest timestamp (`rows[0]`) — - * the oldest is needed to compute `retryAfterMs` when the window is full, - * so one query covers both the check and the reject path. - * - * Drives the per-user, per-model rate limit (e.g. at most 5 DeepSeek sessions - * in the last 12h) enforced before `joinOrTakeOver`. + * List premium-model admissions for `userId` inside `[since, ∞)`, ordered + * oldest-first. Each row carries charged session units; manual early end can + * revise a freshly written 1.0-unit admit down to a fractional value. */ -export async function listRecentAdmits(params: { +export async function listRecentPremiumAdmits(params: { userId: string - model: string + models: readonly string[] since: Date - limit: number -}): Promise { - const { userId, model, since, limit } = params +}): Promise { + const { userId, models, since } = params + if (models.length === 0) return [] const rows = await db - .select({ admitted_at: schema.freeSessionAdmit.admitted_at }) + .select({ + admitted_at: schema.freeSessionAdmit.admitted_at, + model: schema.freeSessionAdmit.model, + session_units: schema.freeSessionAdmit.session_units, + }) .from(schema.freeSessionAdmit) .where( and( eq(schema.freeSessionAdmit.user_id, userId), - eq(schema.freeSessionAdmit.model, model), + inArray(schema.freeSessionAdmit.model, [...models]), gte(schema.freeSessionAdmit.admitted_at, since), ), ) .orderBy(asc(schema.freeSessionAdmit.admitted_at)) - .limit(limit) - return rows.map((r) => r.admitted_at) + return rows.map((r) => ({ + admittedAt: r.admitted_at, + model: r.model, + sessionUnits: Number(r.session_units), + })) } /** Stable 31-bit hash so model-keyed advisory lock ids don't overflow int4. */