Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/lib/src/billing/__tests__/credit-pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,74 @@ describe('MACHINE_MARKUP_BPS floor clamp', () => {
expect(MACHINE_MARKUP_BPS).toBe(15000);
});
});

describe('realtime session constants', () => {
const KEYS = [
'REALTIME_SESSION_HOLD_ESTIMATE_CENTS',
'REALTIME_MAX_SESSION_SECONDS',
'REALTIME_IDLE_TIMEOUT_SECONDS',
'REALTIME_MAX_INFLIGHT',
'REALTIME_MAX_GLOBAL_SESSIONS',
'CREDIT_HOLD_TTL_SECONDS',
];

beforeEach(() => {
vi.resetModules();
for (const key of KEYS) delete process.env[key];
});

it('defaults: 10c hold, 600s max session, 120s idle, 2 per user, 8 global', async () => {
const c = await import('../credit-pricing');
expect(c.REALTIME_SESSION_HOLD_ESTIMATE_CENTS).toBe(10);
expect(c.REALTIME_MAX_SESSION_SECONDS).toBe(600);
expect(c.REALTIME_IDLE_TIMEOUT_SECONDS).toBe(120);
expect(c.REALTIME_MAX_INFLIGHT).toBe(2);
expect(c.REALTIME_MAX_GLOBAL_SESSIONS).toBe(8);
});

it('keeps a session shorter than the hold TTL, so the reconcile cron cannot sweep a live call', async () => {
// The load-bearing invariant behind REALTIME_MAX_SESSION_SECONDS' default: a
// session that outlived CREDIT_HOLD_TTL_SECONDS would have its own reservation
// reclaimed mid-call. Raising the cap requires raising the TTL in the same change.
const { REALTIME_MAX_SESSION_SECONDS, CREDIT_HOLD_TTL_SECONDS } = await import(
'../credit-pricing'
);
expect(REALTIME_MAX_SESSION_SECONDS).toBeLessThan(CREDIT_HOLD_TTL_SECONDS);
// ...with a real settle margin, not merely one second under.
expect(CREDIT_HOLD_TTL_SECONDS - REALTIME_MAX_SESSION_SECONDS).toBeGreaterThanOrEqual(300);
});

it('reaps an idle session well before the hard duration cap fires', async () => {
const { REALTIME_IDLE_TIMEOUT_SECONDS, REALTIME_MAX_SESSION_SECONDS } = await import(
'../credit-pricing'
);
expect(REALTIME_IDLE_TIMEOUT_SECONDS).toBeLessThan(REALTIME_MAX_SESSION_SECONDS);
});

it('caps global concurrency at or under what 40,000 tokens/min supports', async () => {
// PROVEN: the OpenAI account is limited to 40k tokens/min across ALL sessions on
// our key. A continuously-talking session burns roughly 4k tokens/min, so the
// global cap must stay at or below ~10 or we throttle calls already in progress.
const { REALTIME_MAX_GLOBAL_SESSIONS } = await import('../credit-pricing');
expect(REALTIME_MAX_GLOBAL_SESSIONS).toBeLessThanOrEqual(40_000 / 4_000);
expect(REALTIME_MAX_GLOBAL_SESSIONS).toBeGreaterThan(0);
});

it('allows more than one session per user so a zombie session is not a lockout', async () => {
const { REALTIME_MAX_INFLIGHT, REALTIME_MAX_GLOBAL_SESSIONS } = await import(
'../credit-pricing'
);
expect(REALTIME_MAX_INFLIGHT).toBeGreaterThanOrEqual(2);
expect(REALTIME_MAX_INFLIGHT).toBeLessThan(REALTIME_MAX_GLOBAL_SESSIONS);
});

it('takes env overrides, and ignores garbage in favour of the documented default', async () => {
process.env.REALTIME_MAX_SESSION_SECONDS = '300';
process.env.REALTIME_MAX_GLOBAL_SESSIONS = 'nope';
const { REALTIME_MAX_SESSION_SECONDS, REALTIME_MAX_GLOBAL_SESSIONS } = await import(
'../credit-pricing'
);
expect(REALTIME_MAX_SESSION_SECONDS).toBe(300);
expect(REALTIME_MAX_GLOBAL_SESSIONS).toBe(8);
});
});
69 changes: 69 additions & 0 deletions packages/lib/src/billing/credit-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,75 @@ export const VOICE_HOLD_ESTIMATE_CENTS = envInt('VOICE_HOLD_ESTIMATE_CENTS', 2);
*/
export const VOICE_MAX_INFLIGHT = envInt('VOICE_MAX_INFLIGHT', 4);

/**
* Per-SESSION hold estimate for an audio-native realtime call. Realtime differs from
* every other hold in this file in a way that lets it be small: usage arrives
* INCREMENTALLY, as a `usage` object on each `response.done`, so the session settles
* continuously as it talks rather than once at the end. The hold therefore only has to
* cover the window between settles plus the session's opening moments — not the whole
* call — and a long conversation is billed as it happens instead of arriving as one
* surprise at hangup.
*
* 10¢ is roughly 1.5–2 minutes of live conversation at the 1.5x markup (a chatty minute
* runs a few cents of real cost, dominated by audio output at $64/1M tokens). Like every
* hold here it is an ESTIMATE, not a cap: the real cost always settles exactly via
* consumeCredits, {@link REALTIME_MAX_SESSION_SECONDS} bounds the tail of a single
* session, and {@link REALTIME_MAX_INFLIGHT} bounds concurrent overdraw. Tune via env.
*/
export const REALTIME_SESSION_HOLD_ESTIMATE_CENTS = envInt('REALTIME_SESSION_HOLD_ESTIMATE_CENTS', 10);

/**
* Hard ceiling on a single realtime session's wall-clock duration — the backstop for a
* call nobody ever hangs up (a pinned-open tab with a hot mic bills for room noise:
* server VAD happily fires on ambient sound).
*
* The 600s default is NOT arbitrary — it is bounded by {@link CREDIT_HOLD_TTL_SECONDS}
* (900s), the age at which the reconcile cron may sweep a hold. A session allowed to
* outlive that would have its own reservation reclaimed out from under it mid-call. 600s
* leaves a 5-minute settle margin inside that TTL. Raising this REQUIRES raising the hold
* TTL in the same change; ten minutes is also a generous ceiling for one voice session,
* and starting another call is free.
*/
export const REALTIME_MAX_SESSION_SECONDS = envInt('REALTIME_MAX_SESSION_SECONDS', 600);

/**
* Reap a realtime session after this long with no conversational activity. Distinct from
* the hard duration cap: this catches the ABANDONED call — the user walked away or
* switched apps — which otherwise keeps a WebRTC session open, holds a concurrency slot,
* and streams ambient audio into a model that bills per input token. 120s is far longer
* than any natural pause in speech, so it cannot cut off someone who is merely thinking,
* while still freeing the slot promptly.
*/
export const REALTIME_IDLE_TIMEOUT_SECONDS = envInt('REALTIME_IDLE_TIMEOUT_SECONDS', 120);

/**
* Max concurrent realtime sessions per user. Voice is physically exclusive — one mouth,
* one pair of ears — so the semantically "correct" cap is 1. The default is 2 on purpose:
* a call ends on hard refresh, but the server-side session record can linger until the
* idle reaper catches it, and a cap of 1 would lock the user out of reconnecting for up to
* {@link REALTIME_IDLE_TIMEOUT_SECONDS}. The spare slot makes a zombie session an
* annoyance instead of a lockout, while still refusing the many-simultaneous-calls
* overdraw that {@link VOICE_MAX_INFLIGHT} exists to prevent on the STT/TTS path.
*/
export const REALTIME_MAX_INFLIGHT = envInt('REALTIME_MAX_INFLIGHT', 2);

/**
* Max concurrent realtime sessions across the WHOLE deployment — a ceiling the per-user
* cap structurally cannot enforce, because the binding constraint is not per user: the
* OpenAI account is rate-limited to 40,000 tokens/MINUTE (measured live via
* `rate_limits.updated`), shared by every session on our key. Past that ceiling OpenAI
* throttles, and the failure lands on whichever calls happen to be in flight — including
* calls that were already going fine. A global cap converts that into a clean refusal at
* session start.
*
* Sizing: a continuously-talking session burns roughly 4,000 tokens/min once audio in,
* audio out and replayed conversation context are counted, which puts the account ceiling
* near 10 concurrent sessions. The default of 8 leaves headroom, since a long model
* response bursts well above the average. Raise it only alongside the account's rate
* limit — this number is a fact about the OpenAI account, not a product decision.
*/
export const REALTIME_MAX_GLOBAL_SESSIONS = envInt('REALTIME_MAX_GLOBAL_SESSIONS', 8);

/**
* Flat per-call hold estimate for AI image generation. The real cost isn't known
* until OpenRouter responds (it varies by image model and resolution), so the gate
Expand Down
Loading