Files
recflare/apps/ai/src/openapi.ts
T
2026-08-21 15:16:00 -04:00

185 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 callers 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'),
})