diff --git a/apps/ai/src/ai.app.ts b/apps/ai/src/ai.app.ts index 25c6783..b55aa7c 100644 --- a/apps/ai/src/ai.app.ts +++ b/apps/ai/src/ai.app.ts @@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetVersion } from '@repo/jwt' import { AUTHED, @@ -34,8 +34,8 @@ import type { App } from './context' * * - Game AI is a SERVER-side feature. This server cannot provide it, so both its reads are * refused and the client hides the feature. - * - Roomie runs on the CLIENT and only asks this service what it may spend, so the budget - * reads are granted in full. The session that would actually reach a model + * - Roomie's native 2026 companion gets zero energy and no generated output audio; + * older clients keep the full budget. The session that would actually reach a model * (`/realtime-session/create`) is where it stops. * - Maker AI meters model usage in dollars. Nothing here bills, so every figure is zero. */ @@ -163,26 +163,27 @@ const app = new Hono() } ) - // Roomie AI's energy budget. Granted, unlike Game AI above: Roomie runs on the client - // and only asks this service how much energy it has, so the honest answer for a server - // that meters nothing is "as much as you can count". + // 20260323.14 has native low-energy and no-energy companion support. Advertising + // full energy selects a conversation backend we do not provide. Grant access with + // zero energy and the client's recognized no-credit result instead. A successful + // access response still opens a realtime session even when its energy is zero. + // Preserve the older client's budget and recharge-field contract. .get( '/roomieai/user/access', describeRoute({ tags: ['Roomie AI', '2025'], summary: 'The caller’s Roomie AI energy budget', description: [ - 'What Roomie may spend: an energy ceiling, what is left of it, and when it next', - 'refills. Nothing here meters energy, so the budget is `int.MaxValue` and never', - 'depletes — which is why `NextSubscriptionEnergyRechargeAt` is null, there being no', - 'spend to recharge from.', + '20260323.14 receives the recognized no-credit result and zero energy for its native', + 'companion mode. Its DTO has no recharge field. Older clients retain the full', + 'int32-max budget and null recharge time. No model session is created.', '', 'The envelope is `{ success, error_id, error, value }`, NOT the flat body the Game AI', 'check answers with. The two are different shapes on purpose — don’t unify them.', ].join(' '), security: AUTHED, responses: { - 200: json(RoomieAiAccess, 'The energy budget — always granted, always full'), + 200: json(RoomieAiAccess, 'Build-specific conversation access and companion fallback'), 401: UNAUTHORIZED_RESPONSE, }, }), @@ -190,6 +191,20 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(c) + const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) + if (version === '20260323.14') { + return c.json({ + success: false, + error_id: 'AI.InsufficientRoomieAICreditNoSub', + error: 'AI conversation is disabled. Roomie companion mode is available.', + value: { + MaxEnergyFromSubscriptions: 0, + EnergyLeft: 0, + OutputAudioEnabled: false, + }, + }) + } + return c.json({ success: true, error_id: null, @@ -389,7 +404,7 @@ app.get( 'refusals, because the features fail at different points. Game AI is a server-side', 'feature this server cannot provide, so both its reads refuse. Roomie and Maker AI', 'only ask what the caller may SPEND, which nothing here meters, so those reads are', - 'granted in full; the refusal lands instead on `POST /realtime-session/create`, the', + 'granted with a build-specific budget; conversation refuses on `POST /realtime-session/create`, the', 'one call whose real answer is a working credential rather than a description of one.', '', 'The refusals are 200s carrying `success: false`, which is the shape the client', diff --git a/apps/ai/src/openapi.ts b/apps/ai/src/openapi.ts index 2812c55..aa75835 100644 --- a/apps/ai/src/openapi.ts +++ b/apps/ai/src/openapi.ts @@ -71,22 +71,23 @@ export const HealthResponse = z.object({ /** * The Roomie AI access envelope — the `{ success, error_id, error, value }` shape, unlike - * the flat Game AI refusal above. Roomie is granted here, with its energy budget pinned at - * the maximum a signed 32-bit int holds (see INT32_MAX). + * the flat Game AI refusal above. 2026 uses the no-credit fallback with zero energy; + * older builds retain their full signed-32-bit budget and recharge field. */ export const RoomieAiAccess = z.object({ - success: z.literal(true), - error_id: z.null(), - error: z.null(), + success: z.boolean(), + error_id: z.string().nullable(), + error: z.string().nullable(), value: z.object({ MaxEnergyFromSubscriptions: z .int() - .describe('The energy ceiling a subscription buys — pinned to int32 max'), - EnergyLeft: z.int().describe('Energy remaining. Never spent here, so also int32 max'), + .describe('Zero for the 2026 companion; int32 max for older builds'), + EnergyLeft: z.int().describe('Zero for the 2026 companion; int32 max for older builds'), NextSubscriptionEnergyRechargeAt: z .string() .nullable() - .describe('When the budget refills. Null — nothing depletes, so nothing recharges'), + .optional() + .describe('Null on older builds; omitted for the 2026 DTO'), OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'), }), }) diff --git a/apps/ai/src/test/integration/api.test.ts b/apps/ai/src/test/integration/api.test.ts index 884c74b..4b20825 100644 --- a/apps/ai/src/test/integration/api.test.ts +++ b/apps/ai/src/test/integration/api.test.ts @@ -27,10 +27,10 @@ function b64url(input: ArrayBuffer | string): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -async function bearer(sub = '42'): Promise> { +async function bearer(sub = '42', version?: string): Promise> { const now = Math.floor(Date.now() / 1000) const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( - JSON.stringify({ sub, exp: now + 3600 }) + JSON.stringify({ sub, exp: now + 3600, 'rn.ver': version }) )}` const key = await crypto.subtle.importKey( 'raw', @@ -96,6 +96,35 @@ describe('GET /gameai/user/access', () => { }) describe('GET /roomieai/user/access', () => { + it('selects the 2026 no-credit companion fallback without model credentials', async () => { + const headers = await bearer('42', '20260323.14') + const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, { headers }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + success: false, + error_id: 'AI.InsufficientRoomieAICreditNoSub', + error: 'AI conversation is disabled. Roomie companion mode is available.', + value: { MaxEnergyFromSubscriptions: 0, EnergyLeft: 0, OutputAudioEnabled: false }, + }) + // Entitlement to local activities must never mint a model credential. + const session = await SELF.fetch(`${ORIGIN}/realtime-session/create`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ AIType: 'Roomie' }), + }) + expect(session.status).toBe(200) + expect(await session.json()).toMatchObject({ success: false, value: null }) + }) + + it('preserves the older build budget', async () => { + const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, { + headers: await bearer('42', '20250718.01'), + }) + expect(await res.json()).toMatchObject({ + value: { EnergyLeft: 2147483647, OutputAudioEnabled: true }, + }) + }) + // Granted, unlike the Game AI check: Roomie runs on the client and only asks for its // energy budget, which nothing here meters. it('grants an int32-max energy budget', async () => {