[ai] Serve the 2026 companion fallback without realtime credentials (#118)

This commit is contained in:
dukedotdev
2026-10-12 14:21:25 -04:00
committed by GitHub
parent 0e699cab2b
commit 58a9b08548
3 changed files with 67 additions and 22 deletions
+27 -12
View File
@@ -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<App>()
}
)
// 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<App>()
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',
+9 -8
View File
@@ -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'),
}),
})
+31 -2
View File
@@ -27,10 +27,10 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
async function bearer(sub = '42', version?: string): Promise<Record<string, string>> {
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 () => {