From e95f7e47d063adab27abf3bb13fb996f2741e869 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:22:35 +1100 Subject: [PATCH] Publish Reactor POST /tokens and keep command paths off the HTTP document. (#643) The generation document's only HTTP operation is POST /tokens. Model commands stay component schemas tagged x-modelschemas-transport: realtime, because those paths 404 on api.reactor.inc. --- .changeset/reactor-tokens-realtime.md | 4 + openapi.json | 2 +- packages/client/src/generated/sdk.gen.ts | 2 +- skill/modelschemas/SKILL.md | 5 +- src/server/llms-txt.ts | 5 +- src/server/openapi.ts | 2 +- src/server/provider-openapi.ts | 78 +++++--- src/server/provider-openapi.worker.test.ts | 73 ++++++- src/server/providers/adapters/hints.json | 2 +- src/server/providers/adapters/reactor.test.ts | 40 ++++ src/server/providers/adapters/reactor.ts | 187 +++++++++++++++++- src/server/providers/types.ts | 15 ++ 12 files changed, 377 insertions(+), 38 deletions(-) create mode 100644 .changeset/reactor-tokens-realtime.md diff --git a/.changeset/reactor-tokens-realtime.md b/.changeset/reactor-tokens-realtime.md new file mode 100644 index 00000000..51933cda --- /dev/null +++ b/.changeset/reactor-tokens-realtime.md @@ -0,0 +1,4 @@ +--- +--- + +Reactor's generation OpenAPI publishes POST /tokens and keeps command schemas off the HTTP paths (#643). No package behaviour changes. diff --git a/openapi.json b/openapi.json index 29fd1f2b..9c6069d0 100644 --- a/openapi.json +++ b/openapi.json @@ -546,7 +546,7 @@ "get": { "operationId": "getProviderOpenApi", "summary": "Generation-only OpenAPI document for one provider", - "description": "Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. Other model-grained providers (Reactor) keep one path per endpoint; bare or with ?activity=, each body is an input_/output_ component schema tagged x-modelschemas-model. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints.", + "description": "Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. Reactor publishes platform HTTP only (POST /tokens, Reactor-API-Key). Model commands stay component schemas tagged x-modelschemas-model and x-modelschemas-transport: realtime, not paths on the API host. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints.", "parameters": [ { "name": "provider", diff --git a/packages/client/src/generated/sdk.gen.ts b/packages/client/src/generated/sdk.gen.ts index 6b50d1ed..5cac2f76 100644 --- a/packages/client/src/generated/sdk.gen.ts +++ b/packages/client/src/generated/sdk.gen.ts @@ -228,7 +228,7 @@ export const getSchema = ( /** * Generation-only OpenAPI document for one provider * - * Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. Other model-grained providers (Reactor) keep one path per endpoint; bare or with ?activity=, each body is an input_/output_ component schema tagged x-modelschemas-model. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints. + * Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. Reactor publishes platform HTTP only (POST /tokens, Reactor-API-Key). Model commands stay component schemas tagged x-modelschemas-model and x-modelschemas-transport: realtime, not paths on the API host. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints. */ export const getProviderOpenApi = ( options: Options, diff --git a/skill/modelschemas/SKILL.md b/skill/modelschemas/SKILL.md index dd6449c1..3b2fe4ee 100644 --- a/skill/modelschemas/SKILL.md +++ b/skill/modelschemas/SKILL.md @@ -159,8 +159,9 @@ spec syncs daily. Responses are JSON unless noted (`text/typescript`, parameter (x-modelschemas-encode: raw — do not percent-encode slashes). Bare, the bodies are untyped; ?activity= inlines each model's own body schema (up to 1024 models); ?model= is that one model's paths. Reactor - keeps one path per endpoint, each body an input_/output_ component - (x-modelschemas-model). A filter that matches none: 400 + publishes POST /tokens only. Model commands are input_/output_ + components tagged x-modelschemas-transport: realtime, not HTTP paths. + A filter that matches none: 400 spec_requires_selector. HTTP only (no MCP tool). 7. POST /v1/validate {"provider","endpointId","payload"} — check a payload. 8. POST /v1/estimate {"provider","model","request"?,"usage"?} — {amount, diff --git a/src/server/llms-txt.ts b/src/server/llms-txt.ts index 51d5e5fa..156c27d9 100644 --- a/src/server/llms-txt.ts +++ b/src/server/llms-txt.ts @@ -62,8 +62,9 @@ spec syncs daily. Responses are JSON unless noted (\`text/typescript\`, parameter (x-modelschemas-encode: raw — do not percent-encode slashes). Bare, the bodies are untyped; ?activity= inlines each model's own body schema (up to 1024 models); ?model= is that one model's paths. Reactor - keeps one path per endpoint, each body an input_/output_ component - (x-modelschemas-model). A filter that matches none: 400 + publishes POST /tokens only. Model commands are input_/output_ + components tagged x-modelschemas-transport: realtime, not HTTP paths. + A filter that matches none: 400 spec_requires_selector. HTTP only (no MCP tool). 7. POST /v1/validate {"provider","endpointId","payload"} — check a payload. 8. POST /v1/estimate {"provider","model","request"?,"usage"?} — {amount, diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 105055f7..cd4f8238 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -588,7 +588,7 @@ export const openApiDocument = { 'Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). ' + 'Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). ' + "With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. " + - 'Other model-grained providers (Reactor) keep one path per endpoint; bare or with ?activity=, each body is an input_/output_ component schema tagged x-modelschemas-model. ' + + 'Reactor publishes platform HTTP only (POST /tokens, Reactor-API-Key). Model commands stay component schemas tagged x-modelschemas-model and x-modelschemas-transport: realtime, not paths on the API host. ' + 'A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. ' + 'An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints.', parameters: [ diff --git a/src/server/provider-openapi.ts b/src/server/provider-openapi.ts index f84591ba..7fbd1ced 100644 --- a/src/server/provider-openapi.ts +++ b/src/server/provider-openapi.ts @@ -511,9 +511,16 @@ function schemaComponentKey( return unique } -function withModelAnnotation(schema: unknown, rawId: string): unknown { - if (isObject(schema)) return { ...schema, 'x-modelschemas-model': rawId } - return { 'x-modelschemas-model': rawId, allOf: [schema] } +function withModelAnnotation( + schema: unknown, + rawId: string, + transport?: 'realtime', +): Record { + const marked: Record = isObject(schema) + ? { ...schema, 'x-modelschemas-model': rawId } + : { 'x-modelschemas-model': rawId, allOf: [schema] } + if (transport !== undefined) marked['x-modelschemas-transport'] = transport + return marked } function schemaUnion( @@ -583,6 +590,7 @@ async function endpointOwners( function buildModelComponents( selected: Array, owners: Map, + transport?: 'realtime', ): { endpoints: Array; schemas: Record } { const used = new Set() const schemas: Record = {} @@ -594,7 +602,7 @@ function buildModelComponents( ) => { if (schema === undefined) return undefined const name = schemaComponentKey(kind, endpoint.id, used) - schemas[name] = withModelAnnotation(schema, owner) + schemas[name] = withModelAnnotation(schema, owner, transport) return { $ref: `#/components/schemas/${name}` } } const typed = selected.map((endpoint) => { @@ -1129,6 +1137,7 @@ export async function assembleProviderOpenApi( const config = getProvider(providerId) const grain = resolveSpecGrain(config) + const realtime = config?.realtimeCommands === true let pinnedModel: string | undefined if (query.model !== undefined) { @@ -1185,8 +1194,10 @@ export async function assembleProviderOpenApi( const loaded = await loadEndpoints(db, providerId, activity, !lean) let selected = loaded if (pinnedModel !== undefined) { - const matching = loaded.filter((e) => - endpointMatchesModel(e.id, e.path, pinnedModel), + const matching = loaded.filter( + (e) => + endpointMatchesModel(e.id, e.path, pinnedModel) || + (realtime && e.path.startsWith(`/${pinnedModel}/`)), ) if (matching.length > 0) selected = matching else if (grain === 'model') { @@ -1201,11 +1212,12 @@ export async function assembleProviderOpenApi( selected = selectQueueEndpoints(loaded, queueRawIds) } - // A model-grained provider without a queue (Reactor) keeps one path per - // endpoint; each body becomes an `input_`/`output_` component tagged with - // the model that owns the path. + // A model-grained provider without a queue keeps one component pair per + // endpoint, tagged with the model that owns the path. Realtime commands + // (Reactor) stay components on a pinned document too: they are not HTTP + // paths, so inlining them onto `connect.servers` would publish 404s. let owners: Map | undefined - if (grain === 'model' && pinnedModel === undefined && !queue) { + if (grain === 'model' && !queue && (pinnedModel === undefined || realtime)) { owners = await endpointOwners(db, providerId, selected) if (!owners) return refuseModelGrained(providerId, loaded.length) } @@ -1260,7 +1272,13 @@ export async function assembleProviderOpenApi( Object.assign(paths, built.paths) queueSchemas = built.schemas } else { - const built = owners ? buildModelComponents(selected, owners) : undefined + const built = owners + ? buildModelComponents( + selected, + owners, + realtime ? 'realtime' : undefined, + ) + : undefined if (built) queueSchemas = built.schemas const catalog = owners !== undefined || @@ -1293,20 +1311,26 @@ export async function assembleProviderOpenApi( : modelIds // An upstream `#stream` path key (Mistral) is the same URL. Twins are // built aside and merged into the base once their schemas are hoisted. - for (const endpoint of built?.endpoints ?? selected) { - const path = endpoint.path.split('#')[0] ?? endpoint.path - const target = path === endpoint.path ? paths : twinPaths - const owner = owners?.get(endpoint.id) - for (const op of operationsFor( - providerId, - { ...endpoint, path }, - connect, - owner !== undefined ? [owner] : idsFor(endpoint.id), - pinnedModel, - )) { - const item = target[op.path] ?? {} - item[op.method] = op.operation - target[op.path] = item + // Realtime commands are the component schemas above. Paths are the + // provider's platform HTTP operations (Reactor POST /tokens). + if (realtime) { + Object.assign(paths, config.platformOperations?.(pinnedModel) ?? {}) + } else { + for (const endpoint of built?.endpoints ?? selected) { + const path = endpoint.path.split('#')[0] ?? endpoint.path + const target = path === endpoint.path ? paths : twinPaths + const owner = owners?.get(endpoint.id) + for (const op of operationsFor( + providerId, + { ...endpoint, path }, + connect, + owner !== undefined ? [owner] : idsFor(endpoint.id), + pinnedModel, + )) { + const item = target[op.path] ?? {} + item[op.method] = op.operation + target[op.path] = item + } } } } @@ -1349,7 +1373,9 @@ export async function assembleProviderOpenApi( title: `${config?.displayName ?? providerId} (modelschemas)`, version: revision, description: owners - ? `Generation-only OpenAPI assembled by modelschemas from stored endpoint schemas. Call this provider directly with your own key — this is not a proxy. One path per endpoint; each body is a component schema (input_/output_) tagged with its model (x-modelschemas-model).` + ? realtime + ? `Generation-only OpenAPI assembled by modelschemas from stored endpoint schemas. Call this provider directly with your own key — this is not a proxy. HTTP paths are platform operations only. Model commands are not HTTP: each body is a component schema (input_/output_) tagged with its model (x-modelschemas-model) and x-modelschemas-transport: realtime.` + : `Generation-only OpenAPI assembled by modelschemas from stored endpoint schemas. Call this provider directly with your own key — this is not a proxy. One path per endpoint; each body is a component schema (input_/output_) tagged with its model (x-modelschemas-model).` : queue ? `Generation-only OpenAPI assembled by modelschemas from stored endpoint schemas. Call this provider directly with your own key — this is not a proxy. One queue client: endpoint_id is the model raw id${lean ? "; bodies are untyped here, pass ?activity= to inline each model's own schema" : ", and each component schema is that model's own body (x-modelschemas-model)"}.` : `Generation-only OpenAPI assembled by modelschemas from live endpoint schemas. Call this provider directly with your own key — this is not a proxy.`, diff --git a/src/server/provider-openapi.worker.test.ts b/src/server/provider-openapi.worker.test.ts index 78df0313..b8d21ea2 100644 --- a/src/server/provider-openapi.worker.test.ts +++ b/src/server/provider-openapi.worker.test.ts @@ -1473,20 +1473,89 @@ describe('assembleProviderOpenApi', () => { paths: Record components: { schemas: Record> } } - expect(Object.keys(doc.paths).sort()).toEqual(paths) + expect(Object.keys(doc.paths)).toEqual(['/tokens']) + for (const path of paths) expect(doc.paths[path]).toBeUndefined() + const tokens = doc.paths['/tokens'] as { + post?: { + operationId?: string + parameters?: Array<{ name?: string; in?: string; required?: boolean }> + requestBody?: { + content?: { + 'application/json'?: { + schema?: { required?: Array } + } + } + } + responses?: { + '200'?: { + content?: { + 'application/json'?: { + schema?: { required?: Array } + } + } + } + } + } + } + expect(tokens.post?.operationId).toBe('createSessionToken') + expect(tokens.post?.parameters).toEqual([ + expect.objectContaining({ + name: 'Reactor-API-Key', + in: 'header', + required: true, + }), + ]) + expect( + tokens.post?.requestBody?.content?.['application/json']?.schema + ?.required, + ).toEqual(['authorization_details']) + expect( + tokens.post?.responses?.['200']?.content?.['application/json']?.schema + ?.required, + ).toEqual(['jwt', 'expires_at']) const schemas = doc.components.schemas expect(schemas['input_reactor_helios_events_set_prompt']).toMatchObject({ 'x-modelschemas-model': 'reactor/helios', + 'x-modelschemas-transport': 'realtime', }) expect( schemas['output_reactor_lingbot-world-2_events_pause'], - ).toMatchObject({ 'x-modelschemas-model': 'reactor/lingbot-world-2' }) + ).toMatchObject({ + 'x-modelschemas-model': 'reactor/lingbot-world-2', + 'x-modelschemas-transport': 'realtime', + }) expect( Object.keys(schemas).filter((key) => /^(input|output)_/.test(key)), ).toHaveLength(paths.length * 2) + expect( + (result.document as { info?: { description?: string } }).info + ?.description, + ).toContain('x-modelschemas-transport: realtime') expect(unresolvedRefs(result.document)).toEqual([]) } + const pinned = await assembleProviderOpenApi(db, 'reactor', { + model: 'reactor/helios', + }) + expect(pinned.ok).toBe(true) + if (!pinned.ok) return + const pinnedDoc = pinned.document as { + paths: Record + components: { schemas: Record } + } + expect(Object.keys(pinnedDoc.paths)).toEqual(['/tokens']) + expect(JSON.stringify(pinnedDoc.paths['/tokens'])).toContain( + '"enum":["reactor/helios"]', + ) + expect( + pinnedDoc.components.schemas['input_reactor_helios_events_set_prompt'], + ).toBeDefined() + expect( + pinnedDoc.components.schemas[ + 'input_reactor_lingbot-world-2_events_pause' + ], + ).toBeUndefined() + await db .insert(endpoints) .values({ diff --git a/src/server/providers/adapters/hints.json b/src/server/providers/adapters/hints.json index 13a9b302..ac94a636 100644 --- a/src/server/providers/adapters/hints.json +++ b/src/server/providers/adapters/hints.json @@ -242,7 +242,7 @@ "specUrl": "https://docs.reactor.inc", "modelsUrl": "https://api.reactor.inc/pricing", "activities": ["video"], - "notes": "No api.reactor.inc/openapi.json. Per-model OpenAPI 3.1 (POST /events/) is compiled by the runtime and embedded in https://reactor.inc/models/{slug}/api; the JS SDK loads the same document via requestSchema() after a live session is ready. fetchSpec extracts those pages (keyless). Catalog is public GET /pricing. Auth for live sessions is Reactor-API-Key." + "notes": "No api.reactor.inc/openapi.json. Per-model OpenAPI 3.1 (POST /events/) is compiled by the runtime and embedded in https://reactor.inc/models/{slug}/api; the JS SDK loads the same document via requestSchema() after a live session is ready. Those paths are realtime sendCommand messages, not HTTP. The HTTP call is POST /tokens (Reactor-API-Key), which mints the session JWT. fetchSpec extracts the model pages (keyless). Catalog is public GET /pricing." }, "worldlabs": { "displayName": "World Labs", diff --git a/src/server/providers/adapters/reactor.test.ts b/src/server/providers/adapters/reactor.test.ts index 6c40feed..9da397c7 100644 --- a/src/server/providers/adapters/reactor.test.ts +++ b/src/server/providers/adapters/reactor.test.ts @@ -8,6 +8,8 @@ import { extractOpenApiFromHtml, modelApiPageUrl, provider, + reactorPlatformOperations, + sessionTokenRequestSchema, } from './reactor.ts' const MINI_SPEC = { @@ -254,6 +256,44 @@ describe('reactor fetchSpec', () => { }) }) +describe('reactor POST /tokens', () => { + it('documents the session JWT call and leaves command paths out of it', () => { + expect(provider.realtimeCommands).toBe(true) + const post = reactorPlatformOperations(undefined)['/tokens']?.post + if (!post) throw new Error('missing POST /tokens') + expect(post.parameters).toEqual([ + expect.objectContaining({ + name: 'Reactor-API-Key', + in: 'header', + required: true, + }), + ]) + const body = sessionTokenRequestSchema() + expect(body.required).toEqual(['authorization_details']) + const details = ( + body.properties as { + authorization_details: { items: { required: Array } } + } + ).authorization_details + expect(details.items.required).toEqual(['type', 'resources']) + const response = ( + post.responses as { + '200': { + content: { + 'application/json': { schema: { required: Array } } + } + } + } + )['200'] + expect(response.content['application/json'].schema.required).toEqual([ + 'jwt', + 'expires_at', + ]) + const pinned = reactorPlatformOperations('reactor/helios')['/tokens']?.post + expect(JSON.stringify(pinned)).toContain('"enum":["reactor/helios"]') + }) +}) + describe('reactor seed metadata', () => { it('exports the isolation-required fields', () => { expect(provider.id).toBe('reactor') diff --git a/src/server/providers/adapters/reactor.ts b/src/server/providers/adapters/reactor.ts index 02ea964a..c09fca8b 100644 --- a/src/server/providers/adapters/reactor.ts +++ b/src/server/providers/adapters/reactor.ts @@ -13,8 +13,13 @@ * pulls those pages, extracts the document, and namespaces paths under the * connect slug so models do not collide. Catalog is public GET /pricing. * - * Auth for live sessions is `Reactor-API-Key` (`rk_...`); browser clients - * exchange it for a JWT via POST /tokens. Sync/poll stay keyless. + * Auth for live sessions is `Reactor-API-Key` (`rk_...`). The only HTTP + * call is POST /tokens, which mints the session JWT. Command paths in the + * extracted documents (`POST /events/`) are `sendCommand` messages + * on the session channel: fetchSpec still stores their schemas, and the + * assembled document publishes them as components tagged + * `x-modelschemas-transport: realtime`, not as paths on api.reactor.inc. + * Sync/poll stay keyless. */ import type { Activity } from '#/db/schema.ts' import { contentHash } from '#/server/kv.ts' @@ -469,6 +474,182 @@ function catalogFromPricing( return models } +function matchItem(pinnedModel: string | undefined): Schema { + return pinnedModel === undefined + ? { + type: 'string', + description: + 'Connect slug (`reactor/helios`). No wildcard; list every model the token may start.', + } + : { type: 'string', enum: [pinnedModel] } +} + +/** Request body for `POST /tokens`. Optional fields are the documented ones. */ +export function sessionTokenRequestSchema(pinnedModel?: string): Schema { + return { + type: 'object', + additionalProperties: false, + required: ['authorization_details'], + properties: { + expires_after: { + type: 'integer', + minimum: 1, + description: + 'Token lifetime in seconds. Session-scoped tokens default to 1 hour. Values at or above 21600 (6 hours) are clamped, and the response is still 200, so read expires_at.', + }, + authorization_details: { + type: 'array', + minItems: 1, + description: + 'Scope the token. Omitting this mints an unscoped token; the assembled operation requires it.', + items: { + type: 'object', + additionalProperties: false, + required: ['type', 'resources'], + properties: { + type: { + type: 'string', + const: 'session', + description: + 'The token can create sessions and do what those sessions need, and nothing else.', + }, + resources: { + type: 'object', + additionalProperties: false, + required: ['models'], + properties: { + models: { + type: 'object', + additionalProperties: false, + required: ['match'], + properties: { + match: { + type: 'array', + minItems: 1, + items: matchItem(pinnedModel), + description: + 'Models the token may start sessions for. Every id must be visible to the API key.', + }, + }, + }, + sessions: { + type: 'object', + additionalProperties: false, + required: ['bind'], + properties: { + bind: { + type: 'array', + minItems: 1, + items: { type: 'string' }, + description: + 'Open session ids, owned by this account, that this token did not create and may still act on.', + }, + }, + }, + }, + }, + constraints: { + type: 'object', + additionalProperties: false, + properties: { + max_sessions: { + type: 'integer', + minimum: 1, + maximum: 500, + description: + 'Sessions this token may create in total. Defaults to 5. Closing a session does not restore capacity.', + }, + max_session_duration_seconds: { + type: 'integer', + minimum: 1, + maximum: 86400, + description: + 'Cap every session this token creates, in seconds (24 hours). Omit for no token cap; an account maximum still applies.', + }, + }, + }, + }, + }, + }, + }, + } +} + +function sessionTokenResponseSchema(): Schema { + return { + type: 'object', + required: ['jwt', 'expires_at'], + properties: { + jwt: { + type: 'string', + description: + 'Session JWT. Pass it to the JS SDK. Do not send the API key to the browser.', + }, + expires_at: { + type: 'integer', + description: + 'Unix epoch seconds when the JWT expires. The server clamps expires_after, so read this value.', + }, + }, + } +} + +/** + * The one HTTP operation on `api.reactor.inc` that a Reactor integration + * calls. Command schemas stay out of `paths` (see `realtimeCommands`). + */ +export function reactorPlatformOperations( + pinnedModel: string | undefined, +): Record> { + return { + '/tokens': { + post: { + operationId: 'createSessionToken', + summary: 'Mint a session-scoped JWT', + description: + 'Exchange a Reactor-API-Key for a short-lived session JWT. Send this from the server. Model commands are not HTTP on this host: their schemas are components tagged x-modelschemas-transport: realtime, and the JS SDK sends them on the session channel after connect() is ready. A missing key returns 401.', + parameters: [ + { + name: 'Reactor-API-Key', + in: 'header', + required: true, + description: 'API key starting with `rk_`. Server-side only.', + schema: { type: 'string' }, + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: sessionTokenRequestSchema(pinnedModel), + }, + }, + }, + responses: { + '200': { + description: 'Session JWT.', + content: { + 'application/json': { schema: sessionTokenResponseSchema() }, + }, + }, + '401': { + description: 'Missing or invalid Reactor-API-Key.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['error'], + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + } +} + async function listModels(_env: ProviderSecrets): Promise { try { const text = await fetchText(REACTOR_PRICING_URL) @@ -506,6 +687,8 @@ export const provider: ProviderConfig = { }, security: [{ apiKey: [] }], }, + realtimeCommands: true, + platformOperations: reactorPlatformOperations, fetchSpec, listModels, classify, diff --git a/src/server/providers/types.ts b/src/server/providers/types.ts index 2dfef8c6..bbd79c59 100644 --- a/src/server/providers/types.ts +++ b/src/server/providers/types.ts @@ -762,6 +762,21 @@ export interface ProviderConfig { * activities from the default document. */ connectByActivity?: Partial> + /** + * Stored endpoints are realtime commands, not HTTP on `connect.servers`. + * `GET /v1/openapi/{id}` keeps their schemas as components tagged + * `x-modelschemas-transport: realtime` and publishes + * {@link platformOperations} as the only paths. + */ + realtimeCommands?: true + /** + * HTTP operations that are not model commands (Reactor `POST /tokens`). + * Added to every assembled document when {@link realtimeCommands} is set. + * `pinnedModel` is the `?model=` raw id, when the caller passed one. + */ + platformOperations?: ( + pinnedModel: string | undefined, + ) => Record> /** Fetch + parse the provider's OpenAPI spec document(s). */ fetchSpec: (env: ProviderEnvironment) => Promise /** List currently served models from the provider's cheap models endpoint. */