diff --git a/docs/code-mode.md b/docs/code-mode.md index a994aa461..6f52cce3e 100644 --- a/docs/code-mode.md +++ b/docs/code-mode.md @@ -81,11 +81,11 @@ where Enterprise and admin gating apply again. - **Per plan:** saved code tools are capped per plan — Community 3, Starter 5, Team 20, Enterprise unlimited (same on Cloud and self-hosted; an Enterprise contract can override the cap with the licence feature `maxCodeTools`). Inline - code (`code_execute` / `code_propose_tool`) is Team and above. A Cloud trial - with a card on file is treated as Team for code mode for the length of the - trial (inline code and the Team cap of 20), so a high-intent user can try - everything; a free, card-less trial stays tools-only with the cap of 3. A - self-hosted trial carries no billing and so is never promoted. + code (`code_execute` / `code_propose_tool`) is Team and above. Every Cloud trial + is treated as Team for code mode for the length of the trial (inline code and + the Team cap of 20), so a new workspace can try everything; only the run + quotas below differ for a trial without a card. A self-hosted trial is not + promoted. - **Writes** happen only in an admin-approved code tool with *Allow write tools* on; inline code and read-only tools can never write. - **Audit:** every run is recorded (a `code_runs` row), and each tool call the @@ -119,10 +119,11 @@ tool calls it may make, and limits on output size. A program that exceeds a limit fails with a clear error; it never affects other runs or the backend. On Cloud, where the runner is shared between tenants, two per-workspace quotas -apply on top of the per-run limits: a **monthly run cap** (trial 100, Starter -500, Team 5,000, Enterprise unlimited; an Enterprise contract can override it -with the licence feature `maxCodeRunsPerMonth`) and a **concurrency cap** on -simultaneous runs (trial/Starter 1, Team 3, Enterprise 10). Over a quota, a run -is refused with a clear message and nothing is executed. A card-backed trial -gets the Team quotas. Self-hosted is single-tenant and not metered by runs, so +apply on top of the per-run limits: a **monthly run cap** (trial without a card 500, +Starter 500, Team 5,000, Enterprise unlimited; an Enterprise contract can +override it with the licence feature `maxCodeRunsPerMonth`) and a **concurrency +cap** on simultaneous runs (trial without a card and Starter 1, Team 3, +Enterprise 10). Over a quota, a run is refused with a clear message and nothing +is executed. A trial with a card on file gets the Team quotas. Every Cloud trial +has the Team code-mode features: inline code and up to 20 saved code tools. Self-hosted is single-tenant and not metered by runs, so neither quota applies there. diff --git a/docs/connectors/file-search.md b/docs/connectors/file-search.md index a1d40bbfe..7ff5dccf2 100644 --- a/docs/connectors/file-search.md +++ b/docs/connectors/file-search.md @@ -167,14 +167,15 @@ AI clients, and search keeps working on the last index during a sync. | | Trial (Cloud) | Starter | Team | Enterprise | |---|---|---|---|---| -| File Search connectors | 1 | 1 | 3 | by contract (self-hosted: unlimited) | -| Indexed files, all connectors together | 500 | 500 | 2,000 | by contract (self-hosted: unlimited) | -| Largest file (Cloud) | 10 MB | 25 MB | 50 MB | 200 MB | +| File Search connectors | 3 | 1 | 3 | by contract (self-hosted: unlimited) | +| Indexed files, all connectors together | 2,000 | 500 | 2,000 | by contract (self-hosted: unlimited) | +| Largest file (Cloud) | 50 MB | 25 MB | 50 MB | 200 MB | | Automatic sync | daily | daily | daily | down to every 15 minutes (self-hosted: 5) | -| Manual syncs per day | 1 | 1 | 3 | unlimited | +| Manual syncs per day | 3 | 1 | 3 | unlimited | | OCR, folder permissions, workspace policy | – | – | – | ✓ | -Self-hosted: the self-hosted 30-day trial has the Team limits; file size is +The Cloud trial has the Team limits, so you can try everything before you +pick a plan. Self-hosted: the self-hosted 30-day trial has the Team limits too; file size is set by `FILES_MAX_FILE_BYTES` (default 100 MB). Self-hosted Starter and Team are no longer sold; existing Starter and Team keys keep the limits above, and more comes with [Enterprise](https://anythingmcp.com/contact?plan=enterprise). @@ -204,7 +205,7 @@ lets AI clients publish files they write. deleted by the worker. - Limits: Starter links last at most 24 hours, with 100 MB for the files of all active links together and 10 MB per file; Team 7 days, 2 GB, 50 MB. The - Cloud trial has no File Share. On the Cloud, program files (`.exe`, `.msi`, + Cloud trial has the Team limits. On the Cloud, program files (`.exe`, `.msi`, `.js`, …) cannot be shared. ## Permissions and governance diff --git a/docs/plans/file-search.md b/docs/plans/file-search.md index 11d9a28d1..ea3735a06 100644 --- a/docs/plans/file-search.md +++ b/docs/plans/file-search.md @@ -543,7 +543,8 @@ existing keys need no reissue. | File Share | – | internal + public, ≤ 24 h | ≤ 7 days | no limit, internal links without expiry | | OCR, folder permissions, org policy | – | – | – | ✓ | -The trial mirrors Starter (as it does for connectors) minus File Share. +The trial column is the original design. Since 2026-11-09 the Cloud trial has +the Team limits, File Share included (see `file-search-quotas.ts`). Self-hosted Community shows File Search in the catalog with a lock and **Start 30-day trial** / **Buy a licence**; the existing Business trial (`EditionService.startTrial`, once per instance) unlocks it with Team limits. diff --git a/llms-full.txt b/llms-full.txt index 178b0b727..d7b6e57e9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1396,14 +1396,15 @@ AI clients, and search keeps working on the last index during a sync. | | Trial (Cloud) | Starter | Team | Enterprise | |---|---|---|---|---| -| File Search connectors | 1 | 1 | 3 | by contract (self-hosted: unlimited) | -| Indexed files, all connectors together | 500 | 500 | 2,000 | by contract (self-hosted: unlimited) | -| Largest file (Cloud) | 10 MB | 25 MB | 50 MB | 200 MB | +| File Search connectors | 3 | 1 | 3 | by contract (self-hosted: unlimited) | +| Indexed files, all connectors together | 2,000 | 500 | 2,000 | by contract (self-hosted: unlimited) | +| Largest file (Cloud) | 50 MB | 25 MB | 50 MB | 200 MB | | Automatic sync | daily | daily | daily | down to every 15 minutes (self-hosted: 5) | -| Manual syncs per day | 1 | 1 | 3 | unlimited | +| Manual syncs per day | 3 | 1 | 3 | unlimited | | OCR, folder permissions, workspace policy | – | – | – | ✓ | -Self-hosted: the self-hosted 30-day trial has the Team limits; file size is +The Cloud trial has the Team limits, so you can try everything before you +pick a plan. Self-hosted: the self-hosted 30-day trial has the Team limits too; file size is set by `FILES_MAX_FILE_BYTES` (default 100 MB). Self-hosted Starter and Team are no longer sold; existing Starter and Team keys keep the limits above, and more comes with [Enterprise](). @@ -1433,7 +1434,7 @@ lets AI clients publish files they write. deleted by the worker. - Limits: Starter links last at most 24 hours, with 100 MB for the files of all active links together and 10 MB per file; Team 7 days, 2 GB, 50 MB. The - Cloud trial has no File Share. On the Cloud, program files (`.exe`, `.msi`, + Cloud trial has the Team limits. On the Cloud, program files (`.exe`, `.msi`, `.js`, …) cannot be shared. ## Permissions and governance @@ -6964,11 +6965,11 @@ where Enterprise and admin gating apply again. - **Per plan:** saved code tools are capped per plan — Community 3, Starter 5, Team 20, Enterprise unlimited (same on Cloud and self-hosted; an Enterprise contract can override the cap with the licence feature `maxCodeTools`). Inline - code (`code_execute` / `code_propose_tool`) is Team and above. A Cloud trial - with a card on file is treated as Team for code mode for the length of the - trial (inline code and the Team cap of 20), so a high-intent user can try - everything; a free, card-less trial stays tools-only with the cap of 3. A - self-hosted trial carries no billing and so is never promoted. + code (`code_execute` / `code_propose_tool`) is Team and above. Every Cloud trial + is treated as Team for code mode for the length of the trial (inline code and + the Team cap of 20), so a new workspace can try everything; only the run + quotas below differ for a trial without a card. A self-hosted trial is not + promoted. - **Writes** happen only in an admin-approved code tool with *Allow write tools* on; inline code and read-only tools can never write. - **Audit:** every run is recorded (a `code_runs` row), and each tool call the @@ -7002,12 +7003,13 @@ tool calls it may make, and limits on output size. A program that exceeds a limit fails with a clear error; it never affects other runs or the backend. On Cloud, where the runner is shared between tenants, two per-workspace quotas -apply on top of the per-run limits: a **monthly run cap** (trial 100, Starter -500, Team 5,000, Enterprise unlimited; an Enterprise contract can override it -with the licence feature `maxCodeRunsPerMonth`) and a **concurrency cap** on -simultaneous runs (trial/Starter 1, Team 3, Enterprise 10). Over a quota, a run -is refused with a clear message and nothing is executed. A card-backed trial -gets the Team quotas. Self-hosted is single-tenant and not metered by runs, so +apply on top of the per-run limits: a **monthly run cap** (trial without a card 500, +Starter 500, Team 5,000, Enterprise unlimited; an Enterprise contract can +override it with the licence feature `maxCodeRunsPerMonth`) and a **concurrency +cap** on simultaneous runs (trial without a card and Starter 1, Team 3, +Enterprise 10). Over a quota, a run is refused with a clear message and nothing +is executed. A trial with a card on file gets the Team quotas. Every Cloud trial +has the Team code-mode features: inline code and up to 20 saved code tools. Self-hosted is single-tenant and not metered by runs, so neither quota applies there. --- diff --git a/packages/backend/src/file-search/file-search-quotas.spec.ts b/packages/backend/src/file-search/file-search-quotas.spec.ts index 21fd1ab3b..dc843e944 100644 --- a/packages/backend/src/file-search/file-search-quotas.spec.ts +++ b/packages/backend/src/file-search/file-search-quotas.spec.ts @@ -5,7 +5,8 @@ describe('File Search quotas', () => { expect(quotaForPlan('starter', { cloud: true })).toMatchObject({ maxConnectors: 1, maxFiles: 500, minIntervalMinutes: 1440, manualSyncsPerDay: 1 }); expect(quotaForPlan('team', { cloud: true })).toMatchObject({ maxConnectors: 3, maxFiles: 2000, minIntervalMinutes: 1440 }); expect(quotaForPlan('enterprise', { cloud: true })).toMatchObject({ minIntervalMinutes: 15, ocr: true, folderRules: true, policy: true }); - expect(quotaForPlan('trial', { cloud: true })!.share).toBeNull(); + expect(quotaForPlan('trial', { cloud: true })).toMatchObject({ plan: 'trial', maxConnectors: 3, maxFiles: 2000, maxFileBytes: 50 * 1024 * 1024, manualSyncsPerDay: 3, ocr: false }); + expect(quotaForPlan('trial', { cloud: true })!.share).toMatchObject({ maxTtlHours: 7 * 24, maxStoredBytes: 2 * 1024 * 1024 * 1024 }); expect(quotaForPlan('starter', { cloud: true })!.share).toMatchObject({ maxTtlHours: 24, maxStoredBytes: 100 * 1024 * 1024, maxFileBytes: 10 * 1024 * 1024, publicLinks: true }); expect(quotaForPlan('business', { cloud: true })).toMatchObject({ maxFiles: 2000, plan: 'business' }); }); @@ -25,7 +26,7 @@ describe('File Search quotas', () => { it('suggests the next plan and clamps the interval to the plan minimum', () => { expect(nextPlan('starter', true)?.plan).toBe('team'); - expect(nextPlan('trial', true)?.plan).toBe('team'); + expect(nextPlan('trial', true)?.plan).toBe('enterprise'); expect(nextPlan('trial', false)?.plan).toBe('enterprise'); expect(nextPlan('enterprise', true)).toBeNull(); expect(clampInterval(60, quotaForPlan('team', { cloud: true })!)).toBe(1440); diff --git a/packages/backend/src/file-search/file-search-quotas.ts b/packages/backend/src/file-search/file-search-quotas.ts index 33c57e21c..a4d7d5b47 100644 --- a/packages/backend/src/file-search/file-search-quotas.ts +++ b/packages/backend/src/file-search/file-search-quotas.ts @@ -41,20 +41,6 @@ const CLOUD_SHARE_ENTERPRISE: FileShareQuota = { }; const CLOUD: Record = { - trial: { - plan: 'trial', - maxConnectors: 1, - maxFiles: 500, - maxSourceBytes: 500 * MB, - maxTextChars: 25_000_000, - maxFileBytes: 10 * MB, - minIntervalMinutes: DAY_MIN, - manualSyncsPerDay: 1, - share: null, - ocr: false, - folderRules: false, - policy: false, - }, starter: { plan: 'starter', maxConnectors: 1, @@ -99,6 +85,10 @@ const CLOUD: Record = { }, }; CLOUD.business = { ...CLOUD.team, plan: 'business' }; +// The Cloud trial has the Team limits, so a new workspace can try everything +// before it picks a plan (decided 2026-11-09). Before checkout, the paywall +// tells a workspace above Starter what Starter would not include. +CLOUD.trial = { ...CLOUD.team, share: { ...CLOUD_SHARE_TEAM }, plan: 'trial' }; function selfHosted(maxFileBytes: number): Record { const share = (ttl: number | null, internalWithoutExpiry = false): FileShareQuota => ({ @@ -189,8 +179,8 @@ function applyOverrides(base: FileSearchQuota, overrides: unknown): FileSearchQu /** * The plan to suggest when a limit is reached (wizard and pause banner): the * first plan after the current one that really includes more connectors or - * files. A Cloud trial (Starter limits) is pointed at Team, a self-hosted - * evaluation (Team limits) at Enterprise. + * files. A Cloud trial and a self-hosted evaluation (both with Team limits) + * are pointed at Enterprise. */ export function nextPlan(plan: string, cloud: boolean): { plan: string; quota: FileSearchQuota } | null { const order = ['trial', 'starter', 'team', 'enterprise']; diff --git a/packages/backend/src/license/code-mode-gating.spec.ts b/packages/backend/src/license/code-mode-gating.spec.ts index 75d26fe9c..b35502a3d 100644 --- a/packages/backend/src/license/code-mode-gating.spec.ts +++ b/packages/backend/src/license/code-mode-gating.spec.ts @@ -4,6 +4,7 @@ import { monthlyCodeRunCapForPlan, concurrentCodeRunCapForPlan, startOfUtcMonth, + codeRunQuotaPlan, } from './code-run-quotas'; function guard({ cloud, license, codeToolCount }: { cloud: boolean; license: any; codeToolCount: number }) { @@ -60,6 +61,10 @@ describe('code-mode-quotas', () => { expect(effectiveCodeModePlan('trial', false)).toBe('trial'); expect(codeToolCapForPlan(effectiveCodeModePlan('trial', false))).toBe(3); expect(inlineCodeAllowedForPlan(effectiveCodeModePlan('trial', false))).toBe(false); + // On Cloud every trial is Team for code mode, with or without a card. + expect(effectiveCodeModePlan('trial', false, true)).toBe('team'); + expect(inlineCodeAllowedForPlan(effectiveCodeModePlan('trial', false, true))).toBe(true); + expect(effectiveCodeModePlan('starter', false, true)).toBe('starter'); // The card flag only lifts a trial, nothing else. expect(effectiveCodeModePlan('starter', true)).toBe('starter'); expect(effectiveCodeModePlan('community', true)).toBe('community'); @@ -69,7 +74,7 @@ describe('code-mode-quotas', () => { describe('code-run-quotas', () => { it('caps monthly code runs per plan (null = unlimited)', () => { - expect(monthlyCodeRunCapForPlan('trial')).toBe(100); + expect(monthlyCodeRunCapForPlan('trial')).toBe(500); expect(monthlyCodeRunCapForPlan('starter')).toBe(500); expect(monthlyCodeRunCapForPlan('team')).toBe(5000); expect(monthlyCodeRunCapForPlan('business')).toBe(5000); @@ -93,20 +98,45 @@ describe('code-run-quotas', () => { expect(concurrentCodeRunCapForPlan('mystery')).toBeNull(); }); + it('runs a card trial on Team quotas and a trial without a card on the trial quotas', () => { + expect(codeRunQuotaPlan('trial', true)).toBe('team'); + expect(codeRunQuotaPlan('trial', false)).toBe('trial'); + expect(codeRunQuotaPlan('starter', true)).toBe('starter'); + expect(codeRunQuotaPlan(null, false)).toBe('community'); + expect(monthlyCodeRunCapForPlan(codeRunQuotaPlan('trial', false))).toBe(500); + expect(concurrentCodeRunCapForPlan(codeRunQuotaPlan('trial', false))).toBe(1); + expect(monthlyCodeRunCapForPlan(codeRunQuotaPlan('trial', true))).toBe(5000); + }); + it('startOfUtcMonth is the first of the month at UTC midnight', () => { const d = startOfUtcMonth(new Date('2026-11-17T09:30:00Z')); expect(d.toISOString()).toBe('2026-11-01T00:00:00.000Z'); }); }); -describe('LicenseGuardService.codeModePlanForOrg', () => { - it('resolves a card-backed trial to team, a free trial to trial', async () => { +describe('LicenseGuardService.codeModePlanForOrg / codeRunPlanForOrg', () => { + it('resolves every Cloud trial to team for features, a self-hosted trial to trial', async () => { const cardTrial = { plan: 'trial', status: 'active', billing: { status: 'trialing' } }; await expect(guard({ cloud: true, license: cardTrial, codeToolCount: 0 }).codeModePlanForOrg('org1')).resolves.toBe('team'); - await expect(guard({ cloud: true, license: { plan: 'trial', status: 'active' }, codeToolCount: 0 }).codeModePlanForOrg('org1')).resolves.toBe('trial'); + await expect(guard({ cloud: true, license: { plan: 'trial', status: 'active' }, codeToolCount: 0 }).codeModePlanForOrg('org1')).resolves.toBe('team'); + await expect(guard({ cloud: false, license: { plan: 'trial', status: 'active' }, codeToolCount: 0 }).codeModePlanForOrg('org1')).resolves.toBe('trial'); await expect(guard({ cloud: true, license: { plan: 'team' }, codeToolCount: 0 }).codeModePlanForOrg('org1')).resolves.toBe('team'); await expect(guard({ cloud: false, license: null, codeToolCount: 0 }).codeModePlanForOrg('org1')).resolves.toBe('community'); }); + + it('keeps the small run budget for a trial without a card', async () => { + const cardTrial = { plan: 'trial', status: 'active', billing: { status: 'trialing' } }; + await expect(guard({ cloud: true, license: cardTrial, codeToolCount: 0 }).codeRunPlanForOrg('org1')).resolves.toBe('team'); + await expect(guard({ cloud: true, license: { plan: 'trial', status: 'active' }, codeToolCount: 0 }).codeRunPlanForOrg('org1')).resolves.toBe('trial'); + await expect(guard({ cloud: true, license: { plan: 'starter' }, codeToolCount: 0 }).codeRunPlanForOrg('org1')).resolves.toBe('starter'); + }); + + it('gives a Cloud trial without a card inline code and the Team tool cap', async () => { + const free = { plan: 'trial', status: 'active', features: {} }; + await expect(guard({ cloud: true, license: free, codeToolCount: 0 }).canUseInlineCode('org1')).resolves.toBe(true); + await expect(guard({ cloud: true, license: free, codeToolCount: 19 }).checkCanCreateCodeTool('org1')).resolves.toBeUndefined(); + await expect(guard({ cloud: true, license: free, codeToolCount: 20 }).checkCanCreateCodeTool('org1')).rejects.toThrow(/20 code tools on the team plan/); + }); }); describe('LicenseGuardService.checkCanCreateCodeTool (per-plan cap, Cloud + self-hosted)', () => { @@ -158,15 +188,14 @@ describe('LicenseGuardService.canUseInlineCode', () => { await expect(guard({ cloud: false, license: null, codeToolCount: 0 }).canUseInlineCode('org1')).resolves.toBe(false); }); - it('is true for a card-backed trial (billing.status trialing), false for a free trial', async () => { + it('is true for every Cloud trial, with or without a card, and false for a self-hosted trial', async () => { const cardTrial = { plan: 'trial', status: 'active', billing: { status: 'trialing' } }; await expect(guard({ cloud: true, license: cardTrial, codeToolCount: 0 }).canUseInlineCode('org1')).resolves.toBe(true); - // A free trial has no Stripe billing. - await expect(guard({ cloud: true, license: { plan: 'trial', status: 'active' }, codeToolCount: 0 }).canUseInlineCode('org1')).resolves.toBe(false); - // A trial whose subscription is not trialing (e.g. past_due) is not a live card trial. + await expect(guard({ cloud: true, license: { plan: 'trial', status: 'active' }, codeToolCount: 0 }).canUseInlineCode('org1')).resolves.toBe(true); await expect( guard({ cloud: true, license: { plan: 'trial', status: 'active', billing: { status: 'past_due' } }, codeToolCount: 0 }).canUseInlineCode('org1'), - ).resolves.toBe(false); + ).resolves.toBe(true); + await expect(guard({ cloud: false, license: { plan: 'trial', status: 'active' }, codeToolCount: 0 }).canUseInlineCode('org1')).resolves.toBe(false); }); }); @@ -181,10 +210,10 @@ describe('LicenseGuardService.checkCanCreateCodeTool — card-backed trial cap', ).rejects.toThrow(/20 code tools on the team plan/); }); - it('a free trial still caps at 3', async () => { + it('a self-hosted trial still caps at 3', async () => { const freeTrial = { plan: 'trial', status: 'active', features: {} }; await expect( - guard({ cloud: true, license: freeTrial, codeToolCount: 3 }).checkCanCreateCodeTool('org1'), + guard({ cloud: false, license: freeTrial, codeToolCount: 3 }).checkCanCreateCodeTool('org1'), ).rejects.toThrow(/3 code tools on the trial plan/); }); }); diff --git a/packages/backend/src/license/code-mode-quotas.ts b/packages/backend/src/license/code-mode-quotas.ts index 45184c77f..f866fb3eb 100644 --- a/packages/backend/src/license/code-mode-quotas.ts +++ b/packages/backend/src/license/code-mode-quotas.ts @@ -52,16 +52,18 @@ export function inlineCodeAllowedForPlan(plan: string | null | undefined): boole } /** - * The plan a workspace is treated as for code-mode gating. A Cloud trial that - * has added a card (`cardTrial`) is high-intent and low-abuse — a payment method - * is on file — so it gets the full Team experience during the trial (inline code - * and the Team cap) to drive conversion. A free (card-less) trial, and every - * other plan, is unchanged. Self-hosted trials have no card and so never match. + * The plan a workspace is treated as for code-mode gating (saved code tools and + * inline code). Every Cloud trial gets the Team experience, so a new workspace + * can try everything before it picks a plan (decided 2026-11-09). Run quotas + * are resolved separately (`codeRunQuotaPlan`): a trial without a card keeps a + * small run budget. A self-hosted trial is unchanged unless it has a card, + * which it never does (no Stripe billing there). */ export function effectiveCodeModePlan( plan: string | null | undefined, cardTrial: boolean, + cloud = false, ): string { - if (cardTrial && plan === 'trial') return 'team'; + if (plan === 'trial' && (cardTrial || cloud)) return 'team'; return plan ?? 'community'; } diff --git a/packages/backend/src/license/code-run-quotas.ts b/packages/backend/src/license/code-run-quotas.ts index 2227467f5..59a1959a3 100644 --- a/packages/backend/src/license/code-run-quotas.ts +++ b/packages/backend/src/license/code-run-quotas.ts @@ -14,14 +14,15 @@ * reissue. An Enterprise contract can override the monthly cap per key with the * licence feature `maxCodeRunsPerMonth` (a number caps, `null` = unlimited). * - * A card-backed trial is resolved to `team` upstream (see - * `effectiveCodeModePlan`), so it gets the Team quotas during the trial. + * A card-backed trial gets the Team quotas during the trial; a trial without + * a card keeps the `trial` entry, a small budget that protects the shared + * runner from throwaway sign-ups (see `codeRunQuotaPlan`). */ /** Code runs allowed per calendar month per plan. `null` = unlimited. */ export const MONTHLY_CODE_RUN_CAPS: Record = { community: null, // self-hosted default; Cloud-only enforcement, so never hit - trial: 100, + trial: 500, starter: 500, team: 5000, business: 5000, @@ -65,3 +66,13 @@ export function concurrentCodeRunCapForPlan(plan: string | null | undefined): nu export function startOfUtcMonth(now: Date = new Date()): Date { return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); } + +/** + * The plan whose run quotas apply. A trial with a card on file runs on the + * Team quotas; a trial without one keeps the `trial` quotas even though its + * code-mode features are Team's; every other plan is its own. + */ +export function codeRunQuotaPlan(plan: string | null | undefined, cardTrial: boolean): string { + if (plan === 'trial') return cardTrial ? 'team' : 'trial'; + return plan ?? 'community'; +} diff --git a/packages/backend/src/license/license-guard.service.ts b/packages/backend/src/license/license-guard.service.ts index 8dcef023b..3f0765806 100644 --- a/packages/backend/src/license/license-guard.service.ts +++ b/packages/backend/src/license/license-guard.service.ts @@ -4,6 +4,7 @@ import { LicenseService } from './license.service'; import { DeploymentService } from '../common/deployment.service'; import { cardTrialEnd } from './license-checkout'; import { codeToolCapForPlan, inlineCodeAllowedForPlan, effectiveCodeModePlan } from './code-mode-quotas'; +import { codeRunQuotaPlan } from './code-run-quotas'; import type { LicenseInfo } from './license.service'; export interface UsageCap { @@ -181,25 +182,34 @@ export class LicenseGuardService { } /** - * The plan this workspace is treated as for code mode. A card-backed Cloud - * trial (licence `plan='trial'` with a live `billing.status='trialing'`, i.e. - * a card on file) is promoted to the Team experience for the length of the - * trial — inline code and the Team code-tool cap — to let a high-intent user - * try everything. A free trial (no billing) and every other plan are their - * own plan. Self-hosted trials carry no Stripe billing, so they never match. + * The plan this workspace is treated as for code mode. Every Cloud trial is + * promoted to the Team experience (inline code and the Team code-tool cap) + * so a new workspace can try everything. Self-hosted trials carry no Stripe + * billing and are not promoted. */ private codeModePlan(license: LicenseInfo | null): string { - const cardTrial = - license?.plan === 'trial' && license?.billing?.status === 'trialing'; - return effectiveCodeModePlan(license?.plan, cardTrial); + return effectiveCodeModePlan(license?.plan, this.isCardTrial(license), this.deployment.isCloud()); + } + + /** A Cloud trial with a card on file (`billing.status='trialing'`). */ + private isCardTrial(license: LicenseInfo | null): boolean { + return license?.plan === 'trial' && license?.billing?.status === 'trialing'; + } + + /** + * The plan whose code-run quotas apply to a workspace: Team for a card-backed + * trial, `trial` (a small budget) for a trial without a card, otherwise the + * licence plan. `community` when there is no resolvable licence. + */ + async codeRunPlanForOrg(organizationId?: string): Promise { + const license = await this.licenseService.getCurrentLicense(organizationId); + return codeRunQuotaPlan(license?.plan, this.isCardTrial(license)); } /** - * The effective code-mode plan for a workspace (a card-backed trial resolves - * to `team`), so the run-quota enforcement in the MCP code path can look up - * this workspace's monthly and concurrency caps. Plan resolution stays here, - * next to the other code-mode gating. Returns `community` when there is no - * resolvable licence. + * The effective code-mode plan for a workspace (a Cloud trial resolves to + * `team`). Plan resolution stays here, next to the other code-mode gating. + * Returns `community` when there is no resolvable licence. */ async codeModePlanForOrg(organizationId?: string): Promise { const license = await this.licenseService.getCurrentLicense(organizationId); diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.code.spec.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.code.spec.ts index 2a77caa69..d5588affd 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.code.spec.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.code.spec.ts @@ -110,6 +110,7 @@ function build( const license = { checkLicenseActive: jest.fn().mockResolvedValue(undefined), codeModePlanForOrg: jest.fn().mockResolvedValue(opts.plan ?? 'team'), + codeRunPlanForOrg: jest.fn().mockResolvedValue(opts.plan ?? 'team'), }; const kg = { scheduleObservationalIngest: jest.fn() }; const reject = { execute: jest.fn().mockRejectedValue(new Error('unexpected engine call')) }; @@ -320,6 +321,19 @@ describe('DynamicMcpTools — Cloud run quotas', () => { expect(where.createdAt.gte).toBeInstanceOf(Date); }); + it('tells a trial without a card how to raise its run budget', async () => { + const { executor, prisma } = build([codeTool(), innerRead], (b) => b.run('inner_read', {}), { + cloud: true, + plan: 'trial', + codeRunCount: 500, // at the trial monthly cap + }); + const res = await executor.executeTool('summary', {}, { organizationId: 'org1', allowedToolIds: ['code-1', 'read-1'] }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toContain('500 runs during a trial without a card'); + expect(res.content[0].text).toContain('Add a payment card'); + expect(prisma.codeRun.create).not.toHaveBeenCalled(); + }); + it('runs and records when under the monthly cap', async () => { const { executor, prisma } = build([codeTool(), innerRead], (b) => b.run('inner_read', {}), { cloud: true, diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts index b2a1b29eb..ffc83c84b 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts @@ -1450,7 +1450,7 @@ export class DynamicMcpTools { return { allowed: true, release: noRelease }; } - const plan = await this.licenseGuard.codeModePlanForOrg(organizationId); + const plan = await this.licenseGuard.codeRunPlanForOrg(organizationId); // 1. Monthly run cap — authoritative from the audit table, so it holds even // when Redis is down. The current run has no row yet, so `used` counts @@ -1466,7 +1466,10 @@ export class DynamicMcpTools { if (used >= monthly) { return { allowed: false, - reason: `Monthly code run limit reached (${monthly} runs on the ${plan} plan this month). It resets at the start of next month; upgrade the plan for more.`, + reason: + plan === 'trial' + ? `Monthly code run limit reached (${monthly} runs during a trial without a card). Add a payment card to the trial under Settings → License to raise it to ${monthlyCodeRunCapForPlan('team')?.toLocaleString('en')} runs.` + : `Monthly code run limit reached (${monthly} runs on the ${plan} plan this month). It resets at the start of next month; upgrade the plan for more.`, release: noRelease, }; }