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
9 changes: 9 additions & 0 deletions backend/__tests__/unit/routes/hosted.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const mockHosted = {
isConfigured: jest.fn(),
isHostedInstallation: jest.fn(),
hostedCaps: jest.fn(() => ({ agentsPerUser: 1, turnsPerDay: 200 })),
hostedCapsForOwner: jest.fn(async () => ({ agentsPerUser: 1, turnsPerDay: 200 })),
countHostedAgentsForUser: jest.fn(),
listHostedInstallationsForUser: jest.fn(),
meterAllowsTurn: jest.fn(),
Expand Down Expand Up @@ -105,6 +106,14 @@ describe('/api/hosted', () => {
expect(off.body.configured).toBe(false);
});

// TASK-268: the page states the caller's own plan line, not a global number.
it("reports the caller's own plan caps", async () => {
mockHosted.hostedCapsForOwner.mockResolvedValueOnce({ agentsPerUser: 1, turnsPerDay: 30 });
const res = await request(app).get('/api/hosted/availability');
expect(mockHosted.hostedCapsForOwner).toHaveBeenCalledWith('owner-1');
expect(res.body.caps).toEqual({ agentsPerUser: 1, turnsPerDay: 30 });
});

it("records the caller's first visit to Bring your own (the mark contains its own failures)", async () => {
const res = await request(app).get('/api/hosted/availability');
expect(res.status).toBe(200);
Expand Down
2 changes: 2 additions & 0 deletions backend/__tests__/unit/scripts/docsSiteFacts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ describe('docs.commonly.me says what the product does', () => {

test('the hosted limits match the code', () => {
const turns = hostedDefault('DEFAULT_TURNS_PER_DAY');
const proTurns = hostedDefault('DEFAULT_TURNS_PER_DAY_PRO');
const agents = hostedDefault('DEFAULT_AGENTS_PER_USER');
expect(quickstart).toContain(`${turns} turns a day`);
expect(quickstart).toContain(`${proTurns} turns a day on Pro`);
expect(agents).toBe(1);
expect(quickstart).toMatch(/run one hosted agent/);
});
Expand Down
9 changes: 7 additions & 2 deletions backend/__tests__/unit/services/hostedRuntimeService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@ describe('hostedRuntimeService', () => {
});

it('applies defaults and ignores garbage cap values', () => {
expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 1, turnsPerDay: 200 });
// The plan line (TASK-268): 30 a day free, 200 on pro, and free when no plan is given.
expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 1, turnsPerDay: 30 });
expect(hosted.hostedCaps({ pro: true })).toEqual({ agentsPerUser: 1, turnsPerDay: 200 });
process.env.HOSTED_AGENTS_PER_USER = '3';
process.env.HOSTED_TURNS_PER_DAY = 'lots';
expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 3, turnsPerDay: 200 });
process.env.HOSTED_TURNS_PER_DAY_PRO = 'more';
expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 3, turnsPerDay: 30 });
expect(hosted.hostedCaps({ pro: true })).toEqual({ agentsPerUser: 3, turnsPerDay: 200 });
delete process.env.HOSTED_TURNS_PER_DAY_PRO;
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ jest.mock('../../../models/Pod', () => ({

jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() }));

let mockOwnerPro = false;
jest.mock('../../../models/User', () => ({
findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })),
findById: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve({ entitlements: { pro: mockOwnerPro } }) }) })),
}));

jest.mock('../../../services/agentTypingService', () => ({
Expand Down Expand Up @@ -189,4 +191,34 @@ describe('nativeRuntimeService — hosted installs on the native engine', () =>
expect(result.status).toBe('failed');
expect(axios.post).not.toHaveBeenCalled();
});

// TASK-268: the plan line. Free owners get 30 hosted turns a day, pro 200,
// and a pro owner's per-user ceiling sits above the free 120.
describe('the owner\'s plan sets the line', () => {
const counts = ({ install, user }) => AgentRun.countDocuments.mockImplementation(
async (query) => (query.userId ? user : install),
);
afterEach(() => { mockOwnerPro = false; });

test.each([
['free', false, 29, true],
['free', false, 30, false],
['pro', true, 30, true],
['pro', true, 200, false],
])('a %s owner with %i runs today: runs=%s', async (_plan, pro, installRuns, runs) => {
mockOwnerPro = pro;
counts({ install: installRuns, user: 0 });
await runAgent(hostedInstall(), mention);
expect(axios.post).toHaveBeenCalledTimes(runs ? 1 : 0);
});

test('a pro owner past the free per-user ceiling (120) still runs; a free one is refused', async () => {
counts({ install: 0, user: 150 });
mockOwnerPro = true;
expect((await runAgent(hostedInstall(), mention)).status).toBe('succeeded');
mockOwnerPro = false;
const refused = await runAgent(hostedInstall(), mention);
expect(refused).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'user_ceiling' }));
});
});
});
7 changes: 4 additions & 3 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,10 @@ export interface IUser extends Document {
// BYO agents are NEVER gated by this: "agents you bring connect free and
// unlimited" is the product's standing promise, and a per-agent cap is the
// thing this pricing model exists to avoid.
// NEVER set from a client response — `billingService` writes it from a
// signature-verified Stripe webhook, the only source of truth about
// whether money actually moved.
// NEVER set from a client response. Two authorized writers: `billingService`
// from a signature-verified Stripe webhook (the source of truth about
// whether money moved), and an instance admin's entitlements PATCH
// (routes/admin/users.ts) for pilots and design partners.
pro: boolean;
};
// Stripe linkage. `customerId` is the join key from webhook -> user; it is
Expand Down
3 changes: 2 additions & 1 deletion backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,8 @@ router.get('/events', longPollIpLimit, agentRuntimeAuth, async (req: any, res: a
// daily turn budget the feed goes empty for it — the worker idles instead
// of spending. Events stay pending and deliver after the UTC reset.
if (installation && HostedRuntimeMeter.isHostedInstallation(installation)) {
const meter = await HostedRuntimeMeter.meterAllowsTurn(agentName, instanceId);
const { turnsPerDay } = await HostedRuntimeMeter.hostedCapsForOwner(installation.installedBy);
const meter = await HostedRuntimeMeter.meterAllowsTurn(agentName, instanceId, turnsPerDay);
if (!meter.allowed) {
return res.json({ events: [], meter });
}
Expand Down
5 changes: 4 additions & 1 deletion backend/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,10 @@ router.post('/portal', checkoutLimit, auth, async (req: any, res: any) => {
});

/**
* POST /api/billing/webhook — the ONLY writer of entitlements.pro.
* POST /api/billing/webhook — the writer of entitlements.pro for paying
* customers. The other authorized writer is an instance admin's PATCH
* /api/admin/users/:userId/entitlements (pilots, design partners); no client
* request sets it.
*
* NO `auth` middleware by design: Stripe is the caller, and the signature is
* the authentication. Mounted with `express.raw` in server.ts before the
Expand Down
14 changes: 9 additions & 5 deletions backend/routes/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ const relayWorkerError = (res: express.Response, error: any) => {
router.get('/availability', hostedRateLimit, auth, async (req: any, res: any) => {
markByoOpened(getUserId(req));
const engine = engineForNewHostedInstall();
return res.json({ configured: engineAvailable(engine), engine, caps: hostedRuntime.hostedCaps() });
// The caller's own plan line (TASK-268), so the page states the limit they get.
const caps = await hostedRuntime.hostedCapsForOwner(getUserId(req));
return res.json({ configured: engineAvailable(engine), engine, caps });
});

/**
Expand Down Expand Up @@ -242,7 +244,7 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => {
agentName,
instanceId,
podId: installation.podId,
caps: hostedRuntime.hostedCaps(),
caps: await hostedRuntime.hostedCapsForOwner(userId),
});
}

Expand All @@ -265,7 +267,7 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => {
agentName,
instanceId,
podId: installation.podId,
caps: hostedRuntime.hostedCaps(),
caps: await hostedRuntime.hostedCapsForOwner(userId),
});
} catch (error) {
return relayWorkerError(res, error);
Expand Down Expand Up @@ -321,7 +323,8 @@ router.get('/status', hostedRateLimit, auth, async (req: any, res: any) => {
// No loop to report on: a native-engine agent is ready once provisioned,
// and the runtime it needs is available (checked just above).
const { provisionedAt } = readHostedConfig(installation);
const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId);
const { turnsPerDay } = await hostedRuntime.hostedCapsForOwner(getUserId(req));
const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay);
return res.json({
agentName,
instanceId,
Expand All @@ -331,7 +334,8 @@ router.get('/status', hostedRateLimit, auth, async (req: any, res: any) => {
}
const [runtime, meter] = await Promise.all([
hostedRuntime.getAgentStatus(agentName, instanceId),
hostedRuntime.meterAllowsTurn(agentName, instanceId),
hostedRuntime.hostedCapsForOwner(getUserId(req))
.then(({ turnsPerDay }: { turnsPerDay: number }) => hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay)),
]);
return res.json({ agentName, instanceId, runtime, meter });
} catch (error) {
Expand Down
16 changes: 8 additions & 8 deletions backend/services/hostedEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*
* Every site that has to tell the two engines apart asks this module.
*/
import { hostedCaps, isHostedInstallation, readRuntimeConfig } from './hostedRuntimeService';
import { isHostedInstallation, readRuntimeConfig } from './hostedRuntimeService';

export const HOSTED_ENGINE_NATIVE = 'native';
export const HOSTED_ENGINE_DURABLE_OBJECT = 'durable-object';
Expand Down Expand Up @@ -93,9 +93,9 @@ export const HOSTED_NATIVE_EVENT_TYPES: ReadonlySet<string> = new Set(['chat.men
* Object's meter, while the native runtime reads a `dailyRunCap` of 0 as no
* cap, so the zero is honoured here rather than passed through.
*/
export const hostedNativeDecline = (eventType: string): string | null => {
export const hostedNativeDecline = (eventType: string, turnsPerDay: number): string | null => {
if (!HOSTED_NATIVE_EVENT_TYPES.has(eventType)) return `event type ${eventType || 'unset'} is not answered by hosted agents`;
if (hostedCaps().turnsPerDay <= 0) return 'hosted turns are switched off (HOSTED_TURNS_PER_DAY=0)';
if (turnsPerDay <= 0) return 'hosted turns are switched off for this plan (HOSTED_TURNS_PER_DAY*=0)';
return null;
};

Expand Down Expand Up @@ -137,7 +137,7 @@ export const hostedNativeSystemPrompt = (displayName: string, about?: string): s
* installs can carry those fields because only entitled operators make them.
* The model is left unset so the runtime's default applies.
*/
export const hostedNativeRunConfig = (installation: any): Record<string, unknown> => {
export const hostedNativeRunConfig = (installation: any, turnsPerDay: number): Record<string, unknown> => {
const hosted = readHostedConfig(installation);
const displayName = String(installation?.displayName || installation?.agentName || 'Commonly Agent');
const about = typeof hosted.about === 'string' ? hosted.about : undefined;
Expand All @@ -147,9 +147,9 @@ export const hostedNativeRunConfig = (installation: any): Record<string, unknown
systemPrompt: hostedNativeSystemPrompt(displayName, about),
tools: [...HOSTED_NATIVE_TOOLS],
recentMessages: HOSTED_NATIVE_RECENT_MESSAGES,
// The hosted meter (200 turns a day) is enforced on the Durable Object's
// events feed, which a native turn never reads. The native runtime's
// per-install daily cap carries the same number instead.
dailyRunCap: hostedCaps().turnsPerDay,
// The owner's plan line (TASK-268: 30 free, 200 pro), enforced through the
// native runtime's per-install daily cap; the Durable Object's meter reads
// the same number on its events feed.
dailyRunCap: turnsPerDay,
};
};
45 changes: 40 additions & 5 deletions backend/services/hostedRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import AgentEvent from '../models/AgentEvent';
export const HOSTED_RUNTIME_TYPE = 'hosted';

const DEFAULT_AGENTS_PER_USER = 1;
const DEFAULT_TURNS_PER_DAY = 200;
// The plan line (TASK-268, ruled 2026-10-15): hosted turns a day per agent.
const DEFAULT_TURNS_PER_DAY = 30;
const DEFAULT_TURNS_PER_DAY_PRO = 200;
const WORKER_TIMEOUT_MS = 10_000;

const intEnv = (name: string, fallback: number): number => {
Expand All @@ -43,11 +45,39 @@ const intEnv = (name: string, fallback: number): number => {
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};

export const hostedCaps = () => ({
/**
* The hosted caps for a plan: 30 turns a day free, 200 on pro. Pro is
* `entitlements.pro`, which two authorized paths write: the signed Stripe
* webhook (routes/billing.ts) and an instance admin's entitlements PATCH
* (routes/admin/users.ts, how pilots and design partners get Pro). No client
* field sets it. Called without a plan it returns the free caps: a cap never
* defaults to the paid line.
*/
export const hostedCaps = ({ pro = false }: { pro?: boolean } = {}) => ({
agentsPerUser: intEnv('HOSTED_AGENTS_PER_USER', DEFAULT_AGENTS_PER_USER),
turnsPerDay: intEnv('HOSTED_TURNS_PER_DAY', DEFAULT_TURNS_PER_DAY),
turnsPerDay: pro
? intEnv('HOSTED_TURNS_PER_DAY_PRO', DEFAULT_TURNS_PER_DAY_PRO)
: intEnv('HOSTED_TURNS_PER_DAY', DEFAULT_TURNS_PER_DAY),
});

/** Whether a user is on pro. A missing user or a failed lookup reads as free. */
export const ownerIsPro = async (userId: unknown): Promise<boolean> => {
const id = String(userId ?? '');
if (!/^[a-f\d]{24}$/i.test(id)) return false;
try {
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
const User = require('../models/User');
const user = await User.findById(id).select('entitlements.pro').lean();
return user?.entitlements?.pro === true;
} catch (error) {
console.warn('[hosted] plan lookup failed; using the free caps:', (error as Error).message);
return false;
}
};

/** The hosted caps for whoever owns (installed) a hosted agent. */
export const hostedCapsForOwner = async (userId: unknown) => hostedCaps({ pro: await ownerIsPro(userId) });

export const hostedRuntimeConfig = () => ({
url: String(process.env.HOSTED_RUNTIME_URL || '').trim().replace(/\/+$/, ''),
adminToken: String(process.env.HOSTED_RUNTIME_ADMIN_TOKEN || '').trim(),
Expand Down Expand Up @@ -129,8 +159,11 @@ export interface MeterResult {
resetsAt: string;
}

export const meterAllowsTurn = async (agentName: string, instanceId: string): Promise<MeterResult> => {
const { turnsPerDay } = hostedCaps();
export const meterAllowsTurn = async (
agentName: string,
instanceId: string,
turnsPerDay: number = hostedCaps().turnsPerDay,
): Promise<MeterResult> => {
const used = await turnsToday(agentName, instanceId);
const tomorrow = new Date(utcDayStart().getTime() + 24 * 60 * 60 * 1000);
return {
Expand Down Expand Up @@ -223,6 +256,8 @@ export const getAgentStatus = async (agentName: string, instanceId: string): Pro
module.exports = {
HOSTED_RUNTIME_TYPE,
hostedCaps,
ownerIsPro,
hostedCapsForOwner,
hostedRuntimeConfig,
isConfigured,
readRuntimeConfig,
Expand Down
20 changes: 16 additions & 4 deletions backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { podHasOutsiderWritableGrant } from './nativeOutsiderGrantPolicy';
import { isSandboxConfigured, TurnSandbox } from './turnSandboxService';
import { untrustedConnectorMarker } from './brokerResultMarker';
import { hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative } from './hostedEngine';
import { hostedCapsForOwner, ownerIsPro } from './hostedRuntimeService';

// --- public surface --------------------------------------------------------

Expand Down Expand Up @@ -807,6 +808,10 @@ export async function runAgent(
// too, on a configuration built server-side: its stored config came from the
// browser and must not choose the model, the tools or the cap.
const hostedNative = hostedRunsNative(installation);
// The owner's plan line (TASK-268): 30 hosted turns a day free, 200 on pro.
const hostedTurnsPerDay = hostedNative
? (await hostedCapsForOwner(installation.installedBy)).turnsPerDay
: 0;
if (runtimeType !== 'native' && !hostedNative) {
return failedResult(
'',
Expand All @@ -815,15 +820,15 @@ export async function runAgent(
);
}
if (hostedNative) {
const decline = hostedNativeDecline(String(trigger?.type || ''));
const decline = hostedNativeDecline(String(trigger?.type || ''), hostedTurnsPerDay);
if (decline) {
// Acked by the caller, with no AgentRun: nothing ran.
return {
runId: '', status: 'succeeded', totalTurns: 0, totalTokens: 0,
};
}
}
const cfg = hostedNative ? normalizeConfig(hostedNativeRunConfig(installation)) : installedCfg;
const cfg = hostedNative ? normalizeConfig(hostedNativeRunConfig(installation, hostedTurnsPerDay)) : installedCfg;

const podId = String(installation.podId);
const agentName = String(installation.agentName || '').toLowerCase();
Expand Down Expand Up @@ -873,15 +878,22 @@ export async function runAgent(
// a runaway guard and a spend ceiling have opposite safe directions.
// Forward-only: rows without userId predate the stamp and cannot be
// counted; the window inherits the cap's fixed-UTC-midnight semantics.
const userCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120);
if (Number.isFinite(userCeiling) && userCeiling > 0) {
const freeCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120);
if (Number.isFinite(freeCeiling) && freeCeiling > 0) {
const installedBy = installation.installedBy ? String(installation.installedBy) : '';
if (!installedBy) {
console.warn(
`[native-runtime] ${agentName}:${instanceId} has no installedBy — `
+ 'per-user ceiling unenforceable for this install',
);
} else {
// A pro owner's hosted line (200 turns a day, TASK-268) sits above the
// free ceiling, so pro owners get the pro ceiling; a failed plan lookup
// reads as free, the lower one.
const proCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING_PRO ?? 300);
const userCeiling = (await ownerIsPro(installedBy)) && Number.isFinite(proCeiling) && proCeiling > 0
? proCeiling
: freeCeiling;
try {
const AgentRunModel = require('../models/AgentRun');
const dayStart = new Date();
Expand Down
2 changes: 1 addition & 1 deletion docs-site/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ run an agent:
pod in about ten seconds.

During the beta, every signed-in account can run one hosted agent, with
200 turns a day.
30 turns a day, or 200 turns a day on Pro.
</Tab>
<Tab title="Bring your own runtime">
Choose **Bring your own runtime**, name the agent, pick the pod, and click
Expand Down
Loading