diff --git a/src/backend/controllers/events/limits.test.ts b/src/backend/controllers/events/limits.test.ts index b9574cb48..d673becd5 100644 --- a/src/backend/controllers/events/limits.test.ts +++ b/src/backend/controllers/events/limits.test.ts @@ -27,6 +27,7 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, + ORG_SEAT_FREE_SUBSCRIPTION, } from '../../services/metering/consts.js'; import { EVENTS_BROADCAST_DELIVERY_LIMIT, @@ -58,6 +59,23 @@ describe('the tiered subscription quotas', () => { expect(tier.bySubscription[DEFAULT_TEMP_SUBSCRIPTION]).toBe(0); }); + it('holds a free plan nobody enumerated to the free cap, not the paid one', () => { + // A team seat resolves to `org_seat_free`, which no tier names. Falling + // through to `limit` would give it more than an ordinary free account. + for (const tier of tiers.map(([, t]) => t)) { + expect(limitFor(tier, ORG_SEAT_FREE_SUBSCRIPTION)).toBe( + tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION], + ); + } + }); + + it('still holds an unresolved or paid plan to the base', () => { + for (const tier of tiers.map(([, t]) => t)) { + expect(limitFor(tier, null)).toBe(tier.limit); + expect(limitFor(tier, 'some-paid-tier')).toBe(tier.limit); + } + }); + it('keeps what one app may take below what the account may hold', () => { for (const plan of [ null, diff --git a/src/backend/controllers/events/limits.ts b/src/backend/controllers/events/limits.ts index 64b11323a..45c8be9b3 100644 --- a/src/backend/controllers/events/limits.ts +++ b/src/backend/controllers/events/limits.ts @@ -21,6 +21,7 @@ import type { RouteRateLimit } from '../../core/http/types'; import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, + FREE_SUBSCRIPTION_IDS, } from '../../services/metering/consts.js'; // -- Shared event limits --------------------------------------------- @@ -61,14 +62,18 @@ const tiered = (paid: number, free: number, temp: number): TieredLimit => ({ }, }); -/** The cap one plan sees. An unresolved plan is held to the base. */ +/** The cap one plan sees; an unlisted free plan takes the free one, not `limit`. */ export const limitFor = ( tier: TieredLimit, subscriptionId: string | null, -): number => - (subscriptionId === null - ? undefined - : tier.bySubscription[subscriptionId]) ?? tier.limit; +): number => { + if (subscriptionId === null) return tier.limit; + const own = tier.bySubscription[subscriptionId]; + if (typeof own === 'number') return own; + return FREE_SUBSCRIPTION_IDS.has(subscriptionId) + ? (tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION] ?? tier.limit) + : tier.limit; +}; /** * What a plan-tiered quota resolves to: what an account may hold, and what one diff --git a/src/backend/core/http/middleware/rateLimit.js b/src/backend/core/http/middleware/rateLimit.js index 94f5426f9..5e08cce27 100644 --- a/src/backend/core/http/middleware/rateLimit.js +++ b/src/backend/core/http/middleware/rateLimit.js @@ -21,6 +21,10 @@ import crypto from 'node:crypto'; import { withSpan } from '../../../util/span.js'; import { HttpError } from '../HttpError.js'; +import { + DEFAULT_FREE_SUBSCRIPTION, + FREE_SUBSCRIPTION_IDS, +} from '../../../services/metering/consts.js'; /** * Sliding-window rate limiter with swappable, **co-resident** backends. @@ -808,6 +812,15 @@ export const CONCURRENT_SLOT_TTL_MS = ORPHAN_SAFETY_TTL_MS; * actor, no metering, metering throws) falls through to the base — rate / * concurrency limiting should never _amplify_ a request failure path. */ +// An unlisted free plan would otherwise take `limit`, the paid cap. +function overrideFor(bySubscription, subscriptionId) { + const own = bySubscription[subscriptionId]; + if (typeof own === 'number') return own; + return FREE_SUBSCRIPTION_IDS.has(subscriptionId) + ? bySubscription[DEFAULT_FREE_SUBSCRIPTION] + : undefined; +} + async function resolveSubscriptionLimit(req, opts) { const base = opts.limit; if (!opts.bySubscription || !meteringService) return base; @@ -815,7 +828,7 @@ async function resolveSubscriptionLimit(req, opts) { if (!actor?.user?.uuid) return base; try { const sub = await meteringService.getActorSubscription(actor); - const override = opts.bySubscription[sub.id]; + const override = overrideFor(opts.bySubscription, sub.id); return typeof override === 'number' ? override : base; } catch { return base; diff --git a/src/backend/core/http/middleware/rateLimit.test.js b/src/backend/core/http/middleware/rateLimit.test.js index eba19f91a..96ec4e2e8 100644 --- a/src/backend/core/http/middleware/rateLimit.test.js +++ b/src/backend/core/http/middleware/rateLimit.test.js @@ -406,6 +406,51 @@ describe('rateLimitGate — bySubscription overrides', () => { expect(await runGate(opts, paidReq)).toBeUndefined(); }); + it('holds a free plan the spec never named to the free cap', async () => { + // A team seat resolves to `org_seat_free`; no driver enumerates it, and + // falling through to `limit` would outrank an ordinary free account. + configureRateLimit({ + metering: { + getActorSubscription: async () => ({ id: 'org_seat_free' }), + }, + }); + const opts = { + limit: 100, + window: 60_000, + bySubscription: { user_free: 1 }, + key: 'user', + scope: 'rl-sub-orgseat', + }; + const req = () => ({ + actor: { user: { id: 7, uuid: 'seat' } }, + headers: {}, + }); + expect(await runGate(opts, req())).toBeUndefined(); + expect(isHttpError(await runGate(opts, req()))).toBe(true); + }); + + it('still gives a paid plan the base when it names no cap of its own', async () => { + configureRateLimit({ + metering: { + getActorSubscription: async () => ({ id: 'some-paid-tier' }), + }, + }); + const opts = { + limit: 2, + window: 60_000, + bySubscription: { user_free: 1 }, + key: 'user', + scope: 'rl-sub-paid-unlisted', + }; + const req = () => ({ + actor: { user: { id: 8, uuid: 'paid2' } }, + headers: {}, + }); + expect(await runGate(opts, req())).toBeUndefined(); + expect(await runGate(opts, req())).toBeUndefined(); + expect(isHttpError(await runGate(opts, req()))).toBe(true); + }); + it('falls back to the base `limit` when metering throws', async () => { configureRateLimit({ metering: { diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts index 7801911ad..6b3709dec 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts @@ -738,6 +738,41 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => { }); } + it('rejects subscriber-only models for a team seat on the free org plan', async () => { + // `org_seat_free` pays nothing, so it must not reach a paid model — + // the gate checks every free plan, not two named ones. + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { + id: 'subonly-seat', + aliases: [], + costs_currency: 'usd-cents', + costs: { 'input-tokens': 100, 'output-tokens': 100 }, + max_tokens: 8192, + subscriberOnly: true, + }, + ]); + const d = await makeDriver(); + vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue( + 1_000_000, + ); + vi.spyOn( + server.services.metering, + 'getActorSubscription', + ).mockResolvedValue({ id: 'org_seat_free' } as never); + + await expect( + withTestActor(() => + d.complete({ + model: 'subonly-seat', + messages: [{ role: 'user', content: 'hi' }], + }), + ), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'permission_denied', + }); + }); + it('rejects subscriber-only models for the default free subscription', async () => { vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ { diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index d154dcc16..1972b148a 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -23,10 +23,7 @@ import { EventMap } from '../../clients/event/types.js'; import type { Actor } from '../../core/actor.js'; import { Context } from '../../core/context.js'; import { HttpError, isHttpError } from '../../core/http/HttpError.js'; -import { - DEFAULT_FREE_SUBSCRIPTION, - DEFAULT_TEMP_SUBSCRIPTION, -} from '../../services/metering/consts.js'; +import { FREE_SUBSCRIPTION_IDS } from '../../services/metering/consts.js'; import type { CreditHold } from '../../services/metering/types.js'; import { NO_CREDIT_HOLD } from '../../services/metering/types.js'; import type { DriverStreamResult } from '../meta.js'; @@ -920,10 +917,8 @@ export class ChatCompletionDriver extends PuterDriver { if (model.subscriberOnly) { const subscription = await metering.getActorSubscription(actor); - const isDefaultPolicy = - subscription.id === DEFAULT_FREE_SUBSCRIPTION || - subscription.id === DEFAULT_TEMP_SUBSCRIPTION; - if (isDefaultPolicy) { + // Every free plan, not two named ones. + if (FREE_SUBSCRIPTION_IDS.has(subscription.id)) { throw new HttpError( 403, `The model ${model.id} is only available to subscribers. Please subscribe to access this model.`,