import { resolver } from 'hono-openapi' import { z } from 'zod' import type { OpenAPIV3_1 } from 'openapi-types' /** * OpenAPI schemas for the ai worker. * * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to generate * the spec and are never wired into `hono-openapi`'s `validator()`. Same rationale as the * auth/accounts/econ/match/playersettings workers: a reverse-engineered protocol, lenient * handlers, no runtime validation. * * Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a * meta'd schema used in a response emits a `$ref` the framework doesn't always hoist into * `components.schemas`, leaving a dangling reference. Leaving meta off makes every schema * inline, which renders correctly in any tool. */ /** Emit a zod schema as an `application/json` response body. */ export function json(schema: z.ZodType, description: string) { return { description, content: { 'application/json': { schema: resolver(schema) } } } } /** The empty-body 401 the auth-gated routes return. */ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' } /** Bearer-JWT security requirement, for the auth-gated routes. */ export const AUTHED = [{ bearerAuth: [] }] /** An optional integer query parameter. */ export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { return { name, in: 'query', required: false, description, schema: { type: 'integer' } } } /** * An optional boolean query parameter. The client spells these .NET-style (`False`, not * `false`), which is worth recording even where the value is ignored. */ export function boolQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { return { name, in: 'query', required: false, description, schema: { type: 'boolean' } } } /** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */ export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject { return { name, in: 'path', required: true, description, schema: { type: 'integer' } } } /** * An `application/json` request body. * * The schema is emitted directly rather than through `resolver()` — zod's `$schema` key * and `additionalProperties: false` are dropped, since the handler reads the fields it * knows and ignores the rest, so a closed object would misreport it as stricter than it is. */ export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) return { description, content: { 'application/json': { schema: jsonSchema as OpenAPIV3_1.SchemaObject } }, } } // ---- Response schemas ------------------------------------------------------ /** `GET /` — the root health check. */ export const HealthResponse = z.object({ service: z.literal('ai'), status: z.literal('ok'), }) /** * 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). */ export const RoomieAiAccess = z.object({ success: z.literal(true), error_id: z.null(), error: z.null(), 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'), NextSubscriptionEnergyRechargeAt: z .string() .nullable() .describe('When the budget refills. Null — nothing depletes, so nothing recharges'), OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'), }), }) /** * The refusal every Game AI read answers with. It is a 200 carrying `success: false`, not * an HTTP error — the client branches on the body, and an error status would surface as a * failed request rather than the "not available here" state it is meant to show. */ export const GameAiAccessDenied = z.object({ success: z.literal(false), error_id: z.string().describe('Machine-readable reason, e.g. `AI.RoomDoesNotSupportGameAI`'), error: z.string().describe('The message shown to the player'), }) /** * The same refusal, plus an explicit `value: null`. The spend summary carries the key * where the access check omits it — the access check answers a yes/no and has nothing to * carry, while this one's payload slot exists and is simply empty. Reproduced as the * reference server sends it; don't unify the two. */ export const GameAiSpendSummaryDenied = GameAiAccessDenied.extend({ value: z.null().describe('The spend summary. Null — there is no Game AI spend to report'), }) /** * `GET /makerai/user/access` — always granted, in an envelope that belongs to this endpoint * alone: PascalCase `Success`/`Error` beside a snake_case `error_id`, and no `value` slot. * It is neither the Game AI refusal's all-lowercase body nor the Roomie check's * `{ success, error_id, error, value }`. Reproduced as the reference sends it — the casing * mix is not a typo to normalise. */ export const MakerAiAccessResponse = z.object({ Success: z.boolean().describe('Whether the caller may use Maker AI. Always true'), Error: z.null().describe('The failure message. Null — the check always passes'), error_id: z.null().describe('The failure code. Null — the check always passes'), }) /** * Maker AI's dollar balances. A FLAT body — no `{ success, error, value }` envelope — and * every figure zero, since nothing here bills for model usage. */ export const MakerAiBalances = z.object({ UsageDollars: z.number().describe('Dollars of model usage spent this period. Always 0'), UsersMaxUsageDollars: z.number().describe('The caller’s usage ceiling. Always 0'), RRPlusUsageDollars: z.number().describe('Usage spent against the RR+ allowance. Always 0'), UsersMaxRRPlusUsageDollars: z.number().describe('The RR+ allowance ceiling. Always 0'), TimeBalanceStatus: z.string().describe('Time-balance bucket state, e.g. `Empty`'), TimeExpiresAt: z .string() .describe('When the time balance lapses. `DateTime.MinValue` — there is none'), UsageBalanceStatus: z.string().describe('Usage-balance bucket state, e.g. `Good`'), UsagePercent: z.number().describe('Share of the usage ceiling consumed. Always 0'), RRPlusUsageBalanceStatus: z.string().describe('RR+ usage bucket state, e.g. `Good`'), RRPlusUsagePercent: z.number().describe('Share of the RR+ allowance consumed. Always 0'), }) /** The body the client posts to open a realtime session. Read for documentation only. */ export const RealtimeSessionCreateBody = z.object({ AIType: z .string() .optional() .describe('Which assistant the client is opening a session for, e.g. `Roomie`'), }) /** * The realtime-session refusal. `{ success, error, error_id, value }` — note `error_id` is * an empty string rather than a machine-readable code, and `value` (which would carry the * session id and its client secret) is null. */ export const RealtimeSessionDenied = z.object({ success: z.literal(false), error: z.string().describe('The message shown to the player'), error_id: z.string().describe('Empty — the reference server sends no code for this refusal'), value: z.null().describe('The session credentials. Null: no session is created'), }) /** * What Roomie knows about the caller: a prose profile it is primed with, and the discrete * facts behind it. Both empty here — nothing observes the player to build them. */ export const RoomieUserFacts = z.object({ UserContext: z.string().describe('A prose profile Roomie is primed with. Empty'), UserFacts: z .array( z.object({ Id: z.string().describe('GUID identifying the fact'), CreatedAt: z.string().describe('When the fact was recorded'), Emotion: z.string().describe('Sentiment attached to the fact, e.g. `neutral`'), Predicate: z.string().describe('The relation, e.g. `identifies as`'), Object: z.string().describe('The value the predicate points at'), }) ) .describe('The recorded facts. Always empty — nothing here observes the player'), })