openapi: 3.1.0 info: title: IronStratum Inference API version: 1.0.0 description: | OpenAI-compatible inference API. Point any OpenAI SDK at https://api.ironstratum.com/v1 with an sk-ironstratum- key. Platform behaviors worth wiring a client around: - Every response carries an x-request-id header; idempotent replays restore the ORIGINAL request's id (the ledger row stays correlated). - An Idempotency-Key header makes retries safe: same key + identical body replays the stored response; same key + different body is a 409. - Streaming always emits a final usage chunk (choices: [], usage set) before data: [DONE], regardless of stream_options. usage.cost is the retail charge at 6 decimal places. - Unknown or unsupported request parameters fail fast with a 400 that names the parameter — nothing is silently ignored. servers: - url: https://api.ironstratum.com description: Production paths: /v1/chat/completions: post: operationId: createChatCompletion summary: Create a chat completion description: > Creates a completion for the message list. Set stream: true for the SSE chunk stream (terminal usage chunk, then data: [DONE]). The model field takes an IronStratum alias from GET /v1/models. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ChatCompletionRequest' responses: "200": description: > The completion. Non-stream requests return application/json; stream: true returns text/event-stream of chat completion chunks ending with the final usage chunk and [DONE]. headers: x-request-id: $ref: '#/components/headers/XRequestId' Idempotency-Replayed: description: > "true" when this response was replayed from an Idempotency-Key cache entry (the original x-request-id is restored alongside it). schema: type: string x-ratelimit-limit-requests: $ref: '#/components/headers/XRatelimitReserved' x-ratelimit-limit-tokens: $ref: '#/components/headers/XRatelimitReserved' x-ratelimit-remaining-requests: $ref: '#/components/headers/XRatelimitReserved' x-ratelimit-remaining-tokens: $ref: '#/components/headers/XRatelimitReserved' x-ratelimit-reset-requests: $ref: '#/components/headers/XRatelimitReserved' x-ratelimit-reset-tokens: $ref: '#/components/headers/XRatelimitReserved' content: application/json: schema: $ref: '#/components/schemas/ChatCompletionResponse' text/event-stream: schema: $ref: '#/components/schemas/ChatCompletionChunk' "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "409": $ref: '#/components/responses/idempotency_key_reused' "429": $ref: '#/components/responses/rate_limited' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/models: get: operationId: listModels summary: List available models description: > The listed public aliases — the exact set the pricing page and llms.txt carry. No auth required: anonymous callers see the same list; a presented-but-invalid key still 401s (absent ≠ invalid). Members carry the additive pricing + context_tokens fields. security: - bearerAuth: [] - {} responses: "200": description: The model list. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ModelList' "401": $ref: '#/components/responses/invalid_api_key' /v1/models/{alias}: get: operationId: retrieveModel summary: Retrieve one model parameters: - name: alias in: path required: true description: An IronStratum model alias (qwen3.8-27b, ...). schema: type: string description: > No auth required; same additive fields as the list members. Unlisted, internal, and unknown aliases share one no-hint 404. security: - bearerAuth: [] - {} responses: "200": description: The model entry. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ModelEntry' "401": $ref: '#/components/responses/invalid_api_key' "404": $ref: '#/components/responses/model_not_found' /v1/health: get: operationId: health summary: Service health security: [] responses: "200": description: Service status. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/HealthStatus' /v1/credits: get: operationId: retrieveCredits summary: Wallet balance and 30-day spend description: Programmatic spend visibility — the wallet balance and the trailing 30-day spend for the key's account. security: - bearerAuth: [] responses: "200": description: The credit summary. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/CreditSummary' "401": $ref: '#/components/responses/invalid_api_key' "404": $ref: '#/components/responses/not_found' /v1/hf/billing: post: operationId: queryHfBillingBatch summary: Hugging Face provider settlement batch lookup description: > HF's documented settlement protocol (verified 2026-08-21): a batch of request ids, answered with one entry per id with a known in-window cost. Dedicated channel-hf env key only — any other valid key is 403 channel_key_required. Idempotent on the append-only ledger. Unknown-yet ids are omitted (HF retries on its next run) and ids past the ~30-minute settlement horizon are omitted (HF has given up) — omission, not an error code, is this protocol's unknown/expired model. Nothing known → {"requests":null}. A malformed body is 400 invalid_json; an absent or non-array requestIds field is 400 invalid_param (house taxonomy, pre-existing codes). This URL is registered with HF at application. security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BillingQuery' responses: "200": description: One entry per known in-window request id. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/BillingAnswer' "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "403": $ref: '#/components/responses/channel_key_required' "500": $ref: '#/components/responses/internal_error' /v1/conversations: get: operationId: listConversations summary: List the key's conversations description: > Key-scoped, live conversations only (deleted conversations are invisible), most-recently-active first. Pagination never errors: limit defaults to 25, non-positive becomes 25, above 100 clamps to 100; total is the unpaginated count. security: - bearerAuth: [] parameters: - name: limit in: query schema: type: integer default: 25 maximum: 100 description: Page size; never an error (§4.5 limit law). - name: offset in: query schema: type: integer default: 0 responses: "200": description: The list envelope. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: type: object properties: object: type: string enum: [list] data: type: array items: $ref: '#/components/schemas/Conversation' total: type: integer description: Unpaginated live count for this key. limit: type: integer offset: type: integer "401": $ref: '#/components/responses/invalid_api_key' delete: operationId: deleteAllConversations summary: Delete every conversation of the key description: > The ledger-integrity delete applied to every live conversation of this key: turn content removed, rows tombstoned; usage_events rows survive with conversation_id nulled (anonymized financial ledger). Deleted session_ids are never revived. security: - bearerAuth: [] responses: "204": description: Every live conversation of the key was tombstoned. headers: x-request-id: $ref: '#/components/headers/XRequestId' "401": $ref: '#/components/responses/invalid_api_key' /v1/conversations/{id}: get: operationId: retrieveConversation summary: Conversation detail with turns and cost description: > The conversation's turns in exchange order plus the cost summary itemized from its linked usage_events rows. Key-scoped: missing, deleted, and foreign ids are indistinguishable 404s. security: - bearerAuth: [] parameters: - name: id in: path required: true schema: type: string format: uuid responses: "200": description: The conversation with turns + cost summary. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ConversationDetail' "401": $ref: '#/components/responses/invalid_api_key' "404": $ref: '#/components/responses/conversation_not_found' delete: operationId: deleteConversation summary: Delete one conversation description: > Ledger-integrity delete: turns removed, row tombstoned, linked usage_events rows survive anonymized. Idempotent — this key's id answers 204 live or already deleted; a foreign id is the same 404 as any other not-found. Deletion is final: a session_id referencing a deleted conversation is not available, never revived. security: - bearerAuth: [] parameters: - name: id in: path required: true schema: type: string format: uuid responses: "204": description: Deleted (or already deleted — idempotent). headers: x-request-id: $ref: '#/components/headers/XRequestId' "401": $ref: '#/components/responses/invalid_api_key' "404": $ref: '#/components/responses/conversation_not_found' /v1/embeddings: post: operationId: createEmbeddings summary: Create embeddings (unit-billed, tokens) description: > Task #15 specialty route (route_type: embeddings). JSON passthrough both ways: the body crosses minimally normalized (model mapped to the serving slot's upstream id), the upstream response returns verbatim. Accepted params — model, input (string or string array; token arrays are a 400), encoding_format, dimensions; everything else is unknown_param. Metering: unit_type='tokens' from the upstream usage.prompt_tokens (total_tokens fallback, else ceil(runes/4) — approx4 recorded in the ledger's media). Idempotency-Key carries replay semantics on this route (contract §8 route-scope). security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [model, input] properties: model: type: string description: An IronStratum alias whose route_type is embeddings. input: description: A string or an array of strings (token arrays are rejected). encoding_format: type: string dimensions: type: integer responses: "200": description: The upstream embeddings response, passed through verbatim. headers: x-request-id: $ref: '#/components/headers/XRequestId' Idempotency-Replayed: description: '"true" when replayed from an Idempotency-Key cache entry.' schema: type: string content: application/json: schema: type: object description: The upstream embeddings response, passed through verbatim — free-form by design. "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "409": $ref: '#/components/responses/idempotency_key_reused' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/audio/speech: post: operationId: createSpeech summary: Generate audio from text (unit-billed, chars) description: > Task #15 specialty route (route_type: speech); per-alias input caps added task #17. The response is BINARY audio via chunked transfer: bytes leave the gateway as they arrive from the card (flush per read — no idle gap past load-balancer walls), never a Content-Length. Content-Type forwards from the upstream (default audio/mpeg); no other upstream header crosses. No stream param — chunked transfer is the only mode. Input is length-capped per alias where the card does not cap itself (400 invalid_param param=input, refused before any upstream connection, unbilled; values in the gateway's models.yaml, sized so buffered synthesis fits the card host's ~55-60 s no-byte edge await and the gateway's per-alias first-byte wall: chatterbox-tts 400 (measured 2026-09-09: 0.085 s/char buffered synthesis — 600 chars reached 53.6 s to first byte; the stub-era 20000 is gone) · kokoro-tts 3500 (F-3 measured 0.0149 s/char — ~52.8 s at cap; 4000 would run ~60 s, an edge kill) · pocket-tts 2500 (F-3 measured 0.0188 s/char — ~48 s at cap) · audio8-tts 250 runes (review F-2 2026-09-09: ~0.22 s/char measured × 250 = ~56 s under the edge kill — the old 400 derived from a ~100 s wall that does not exist)). The card synthesizes BEFORE the first byte crosses — first-byte latency of tens of seconds is normal at the caps (chatterbox ~41 s at 400 chars); text longer than a cap is the client's to chunk (one request per chunk, concatenated at playback). Metering: unit_type='chars' = rune count of input, counted at the gateway. Idempotency-Key is accepted with NO replay semantics on this route (§8 route-scope) — a retrying client re-executes and re-bills. Client contract: do not set client read timeouts below the expected generation time. security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [model, input, voice] properties: model: type: string description: An IronStratum alias whose route_type is speech. input: type: string description: The text to synthesize (billed per rune). voice: type: string description: An upstream catalog voice id (GET /v1/audio/voices). response_format: type: string speed: type: number responses: "200": description: > Chunked binary audio. Content-Type is the upstream's declared audio type (default audio/mpeg); a mid-generation upstream death ends the transfer abruptly — delivered bytes stand, the turn is not billed. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: audio/mpeg: schema: type: string format: binary audio/wav: schema: type: string format: binary "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/audio/voices: get: operationId: listVoices summary: The voice catalog for one speech model description: > Task #15 specialty route. model is REQUIRED (voices are per-SKU — an implicit default would hand out ids valid only for a different model's catalog); unknown query parameters are rejected (the §3 fail-fast law on the query string). Thin JSON passthrough of the alias's chain slot; no metering (a catalog read does no billable work — an exhausted wallet still lists), no ledger row. security: - bearerAuth: [] parameters: - name: model in: query required: true description: An IronStratum alias whose route_type is speech. schema: type: string responses: "200": description: The upstream voice catalog, passed through verbatim. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: type: object description: The upstream voice catalog, passed through verbatim — free-form by design. "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/audio/transcriptions: post: operationId: createTranscription summary: Transcribe audio (unit-billed, audio-seconds) description: > Task #15 specialty route (route_type: transcriptions); seam contract updated task #17 (batch leg). Batch variant (default): the CLIENT face is multipart/form-data, but the GATEWAY translates at the seam — the file part's bytes cross to the upstream as the RAW request body under the file part's Content-Type, and a non-empty language field forwards as the upstream ?lang= query (the card owns the dedicated-code whitelist); JSON response passthrough. language is required in practice: omitted or non-dedicated reaches the card's default path and the client receives a 502 relayed VERBATIM — terminal, non-retryable, the one non-gateway-envelope error on this surface (do not retry the same language). Body-shape law (proven live, task #17 E1): the card's in-cluster JSON error body never crosses the Salad edge (Cloudflare replaces origin 5xx bodies with its own "error code: 502" text page), so clients see the edge's page and the gateway keys the terminal on the 502 status alone. Accepted form fields — file (required), model (required), language, prompt, response_format, temperature, stream; unknown fields are 400 unknown_param; prompt / response_format / temperature are accepted then DROPPED on the batch leg (the card never consumed them). Supported containers = the card's decode set (WAV / FLAC / Ogg-Opus) within the 25 MiB upload cap (413); an undecodable upload is 502 upstream_rejected (the card's reason lands in the ops log, not the client body); effective request ceiling ≈ 240 s — client-side chunking for longer audio. Streaming variant (stream=true): response is text/event-stream — data lines carrying {"type":"transcription.partial"|"transcription.final"|"transcription.refine","text":...} events (refine additionally carries the auto-detected "lang" — additive metadata, never meters), ": ping" keep-alive before the first event, terminal data: [DONE]; the stream ends at [DONE] after the card's own close OR a short post-event idle (the card ends a session by going silent — task #17 live law; silence-after-events is a clean end, not an error), while a mid-stream upstream DEATH ends with the §6 terminal error event. Streaming accepts raw PCM/WAV only (audio/L16;rate=16000 family — anything else is 400 unsupported_audio_format); language is AUTO-DETECTED on the stream (no selection in the WS protocol — the refine event carries the detected code). Metering: unit_type='audio_seconds' = ceil of the upstream-reported duration (streaming: the sum of final-event durations), else — batch only — the wall-clock; the streaming fallback is the uploaded PCM's own duration, ceil(len/32000) at 16 kHz s16le mono (duration_source:"pcm_bytes", task #17 review F-1 — wall-clock billed dial + upload + the idle-detect tail ~2x over). Idempotency-Key: accepted, no replay semantics (§8 route-scope). security: - bearerAuth: [] requestBody: required: true content: multipart/form-data: schema: type: object required: [file, model] properties: file: type: string format: binary description: The audio file (≤ 25 MiB). model: type: string description: An IronStratum alias whose route_type is transcriptions. language: type: string description: > Required in practice on hayamimi-stt — forwarded as the upstream ?lang= query; omitted/non-dedicated codes get the card's 502 non_dedicated_language body verbatim. Optional on whisper (auto-detected when omitted; forwarded as a form field on the provider's native seam). prompt: type: string response_format: type: string temperature: type: string stream: type: string description: '"true" selects the SSE streaming variant.' responses: "200": description: > Batch: the upstream JSON response verbatim. stream=true: text/event-stream of transcription events ending [DONE]. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: type: object description: Batch mode — the upstream JSON response verbatim — free-form by design. text/event-stream: schema: type: string "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "413": $ref: '#/components/responses/request_too_large' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/rerank: post: operationId: rerankDocuments summary: Rerank documents against a query (unit-billed, searches) description: > Task #15 specialty route (route_type: rerank; Cohere/Jina shape). JSON passthrough both ways. Accepted params — model, query, a non-empty documents array, top_n; everything else is unknown_param. Metering: unit_type='searches' — one search per request (one query over N documents); the corpus size is recorded in the ledger's media, unpriced. Idempotency-Key carries replay semantics on this route (contract §8 route-scope). security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [model, query, documents] properties: model: type: string description: An IronStratum alias whose route_type is rerank. query: type: string documents: type: array items: type: string description: Non-empty (an empty corpus is a 400, not a free no-op). top_n: type: integer responses: "200": description: The upstream rerank response, passed through verbatim. headers: x-request-id: $ref: '#/components/headers/XRequestId' Idempotency-Replayed: description: '"true" when replayed from an Idempotency-Key cache entry.' schema: type: string content: application/json: schema: type: object description: The upstream rerank response, passed through verbatim — free-form by design. "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "409": $ref: '#/components/responses/idempotency_key_reused' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/ocr: post: operationId: ocrDocument summary: OCR a document (unit-billed, pages) description: > Task #19 specialty route (route_type: ocr). multipart/form-data forwarded byte-identical to the card (no re-multipart, no model swap; the boundary is preserved). Accepted form fields — file (at least one part; each image part is one page, a single-shot PDF is one part), model (required), mode (print | archival — must match the alias's lane), diplomatic (t | f, the archival fidelity flag), language; unknown fields are 400 unknown_param. Upload cap 25 MiB (413) — the ONE cap shared with the transcriptions family; over-cap payloads are the client's to chunk into page-range jobs. Response: the card's JSON verbatim. Metering: unit_type='pages' = the card-reported pages count, else the file-part count (media records the basis); priced per 1000 pages. Idempotency-Key: accepted, no replay semantics (§8 route-scope, the multipart family law). security: - bearerAuth: [] requestBody: required: true content: multipart/form-data: schema: type: object required: [file, model] properties: file: type: array items: type: string format: binary description: The page images (or one single-shot PDF), ≤ 25 MiB total. model: type: string description: An IronStratum alias whose route_type is ocr. mode: type: string enum: [print, archival] description: Must match the model's lane — a consistency check, not a router. diplomatic: type: string enum: [t, f] language: type: string responses: "200": description: The card's OCR response verbatim (text, per-page results, pages). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: type: object description: The card's OCR response verbatim (text, per-page results, pages) — free-form by design. "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "413": $ref: '#/components/responses/request_too_large' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/ocr/jobs: post: operationId: ocrJobCreate summary: Enqueue an async OCR job (long-processing documents) description: > Task #19 specialty route (route_type: ocr): the async-job variant for long-PROCESSING documents (the ~100 s edge wall) — never bigger bodies (the 25 MiB ONE cap applies identically; over-cap payloads are client-chunked into page-range jobs). Same multipart contract and §3 gate as /v1/ocr. The create body forwards byte-identical; the answer is the card's 202 {job_id}. NOTHING meters at create — the work has not happened; metering lands exactly once at the done-observing status read. security: - bearerAuth: [] requestBody: required: true content: multipart/form-data: schema: type: object required: [file, model] properties: file: type: string format: binary description: The document (≤ 25 MiB). model: type: string description: An IronStratum alias whose route_type is ocr. mode: type: string enum: [print, archival] diplomatic: type: string enum: [t, f] language: type: string responses: "200": description: > The card's enqueue answer verbatim (202-class: {job_id}); poll GET /v1/ocr/jobs/{id}?model=... for status. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: type: object description: The card's enqueue answer verbatim (202-class {job_id}) — free-form by design. "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/model_not_found' "413": $ref: '#/components/responses/request_too_large' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/ocr/jobs/{id}: get: operationId: ocrJobStatus summary: Read an async OCR job's status (the meter-once completion read) description: > Task #19 specialty route (route_type: ocr). `model` is a REQUIRED query parameter (the gateway is job-stateless — the id alone cannot route); stray query params are 400 per the §3 law. Statuses pass through verbatim (queued | running | done | failed). The read that observes done meters ONCE (the payload's pages — the card CONSUMES the done payload on the first read: fetch-once, so a re-poll answers job_not_found and completion cannot double-bill; persist the payload client-side, it cannot be re-fetched); an unknown, already-consumed, or TTL-expired job answers the GATEWAY's job_not_found envelope, never the card's foreign 404 body; a failed job passes its error through and meters nothing. NOT balance-guarded — an unmetered catalog-class read (the voices law). security: - bearerAuth: [] parameters: - name: id in: path required: true schema: type: string - name: model in: query required: true schema: type: string description: The alias the job was created with. responses: "200": description: > The job's status payload verbatim (status + text/results/pages when done, the error object when failed). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: type: object description: The job's status payload verbatim (status, text/results/pages when done, the error object when failed) — free-form by design. "400": $ref: '#/components/responses/invalid_param' "401": $ref: '#/components/responses/invalid_api_key' "403": $ref: '#/components/responses/key_revoked' "404": $ref: '#/components/responses/job_not_found' "500": $ref: '#/components/responses/internal_error' "502": $ref: '#/components/responses/upstream_rejected' "503": $ref: '#/components/responses/no_provider_available' /v1/audio/transcriptions/stream: get: operationId: streamTranscription summary: Streaming transcription over WebSocket (VASR; unit-billed, audio-seconds) description: > Task #19 specialty route (route_type: transcriptions, the streaming INPUT transport): a WebSocket upgrade. SUCCESS is the 101 protocol switch — there is deliberately no 200 row; the documented HTTP statuses are exactly the pre-hijack set {401, 402, 403, 500} (auth and the balance guard run on the UPGRADE request as plain HTTP). After the upgrade: the first client frame is a JSON text config frame — model (required) and format (required; pcm_s16le only — 400-class refusals ride error FRAMES after the hijack, not HTTP), sample_rate (optional, 16000) and channels (optional, 1); the model is validated before the format. Then binary PCM frames (16 kHz mono s16le) flow gateway→card; the card's transcription.partial / transcription.final events (with speaker labels and per-final durations) flow back as JSON text frames verbatim. End-of-audio is the client's CLOSE FRAME (read side open) — forwarded to the card as a TEXT flush marker {"action":"flush"} (never a close control frame, so the card's remaining events drain FULLY before the card initiates the close); mid-stream failures are ONE §6-shape error frame ({"error":{"code":...,"retryable":...}}) then close — never a bare drop. Metering: unit_type='audio_seconds' = ceil of the summed final-event durations when any final carried one, else the stream wall-clock. The POST fallback legs (session-create + SSE out) are designed but UNBUILT — documented == built == test-exercised. security: - bearerAuth: [] responses: "401": $ref: '#/components/responses/invalid_api_key' "402": $ref: '#/components/responses/credit_balance_exhausted' "403": $ref: '#/components/responses/key_revoked' "500": $ref: '#/components/responses/internal_error' components: securitySchemes: bearerAuth: type: http scheme: bearer description: An sk-ironstratum- API key from the console (/keys). headers: XRequestId: description: > Correlation id emitted on every response. Idempotent replays restore the original execution's id so the replay stays correlated with the ledger row the first execution wrote. schema: type: string XRatelimitReserved: description: > Reserved by contract, documented from day one, and absent from responses until per-key rate limiting is enabled — absence is not a contract break (contract §7). See the rate-posture guide for the platform-wide ceiling that applies today. schema: type: string schemas: ChatCompletionRequest: type: object additionalProperties: false required: [model, messages] properties: model: type: string description: IronStratum alias (from GET /v1/models). messages: type: array minItems: 1 description: OpenAI message shapes, including image parts (vision aliases) and tool_calls / tool results. items: type: object required: [role] properties: role: type: string description: The one structural key every message must carry — missing or empty is a 400 at the gateway (nothing deeper is checked; vision arrays etc. forward per §3). stream: type: boolean max_completion_tokens: type: integer minimum: 0 description: Canonical max-token name. max_tokens: type: integer minimum: 0 description: Legacy max-token name — honored identically. temperature: type: number top_p: type: number top_k: type: integer min_p: type: number frequency_penalty: type: number presence_penalty: type: number stop: description: Stop sequence(s). oneOf: - type: string - type: array items: type: string seed: type: integer n: type: integer logit_bias: type: object user: type: string parallel_tool_calls: type: boolean tools: type: array items: type: object tool_choice: description: Tool selection mode or a named tool. oneOf: - type: string - type: object response_format: type: object reasoning_effort: type: string enum: [minimal, low, medium, high] stream_options: type: object description: The recognized inner surface is include_usage (boolean). metadata: type: object logprobs: type: boolean top_logprobs: type: integer session_id: type: string description: > Optional conversation UUID (delta-only session mode). When set, messages carry ONLY the new turn; the gateway assembles the stored prefix. An unseen id auto-creates the conversation. Canonical hyphenated hex, case-insensitive. Responses echo the field on session-mode turns only (additive, contract §10.1). store: description: NOT SUPPORTED — a clear 400 (invalid_param), never a silent ignore. nullable: true service_tier: description: NOT SUPPORTED — a clear 400 (invalid_param), never a silent ignore. nullable: true ChatCompletionResponse: type: object required: [id, object, created, model, choices, usage] properties: id: type: string object: type: string created: type: integer model: type: string choices: type: array items: $ref: '#/components/schemas/Choice' usage: $ref: '#/components/schemas/Usage' session_id: type: string description: > Present only on session-mode turns: the echoed session_id from the request (additive, contract §10.1 — omitted on full-context turns). ChatCompletionChunk: type: object description: > One SSE chunk (object: "chat.completion.chunk"). The final usage chunk carries empty choices and the full usage; the stream then ends with data: [DONE]. properties: id: type: string object: type: string created: type: integer model: type: string choices: type: array items: $ref: '#/components/schemas/ChunkChoice' usage: $ref: '#/components/schemas/Usage' session_id: type: string description: > Present only on session-mode streams: the echoed session_id rides every chunk (additive, contract §10.1 — omitted on full-context streams). Usage: type: object description: Token usage plus our retail charge. Shape-stable across every provider (mediation normalizes). properties: prompt_tokens: type: integer completion_tokens: type: integer total_tokens: type: integer prompt_tokens_details: $ref: '#/components/schemas/PromptTokensDetails' completion_tokens_details: $ref: '#/components/schemas/CompletionTokensDetails' cost: type: number description: Our retail charge at 6 decimal places. PromptTokensDetails: type: object description: Cache-read visibility (cache reads bill at the cached rate). properties: cached_tokens: type: integer CompletionTokensDetails: type: object description: Thinking-token visibility (reasoning bills as output). properties: reasoning_tokens: type: integer Choice: type: object properties: index: type: integer message: $ref: '#/components/schemas/Message' finish_reason: type: string nullable: true ChunkChoice: type: object description: finish_reason is explicit null until the final content chunk. properties: index: type: integer delta: $ref: '#/components/schemas/Delta' finish_reason: type: string nullable: true Message: type: object properties: role: type: string content: type: string reasoning_content: type: string description: The model's separated reasoning stream (present when the model thinks and reasoning is not disabled via reasoning_effort) — joined from the reasoning deltas on non-stream responses. tool_calls: type: array items: type: object Delta: type: object description: The streamed fragment — role on the first chunk, content fragments after; tool_calls fragments span deltas. properties: role: type: string content: type: string reasoning_content: type: string description: Reasoning fragment — models that emit separated reasoning (Ornith) stream it under this field. tool_calls: type: array items: type: object ErrorObject: type: object properties: message: type: string type: type: string param: type: string description: Present only when a specific field caused the error. code: type: string ErrorEnvelope: type: object required: [error] properties: error: $ref: '#/components/schemas/ErrorObject' ModelEntry: type: object properties: id: type: string description: The alias clients pass as model. object: type: string owned_by: type: string created: type: integer route_type: type: string enum: [chat, embeddings, speech, transcriptions, rerank] description: > The route family this alias serves (task #15, additive): a model answers only its own route — a chat alias called at /v1/embeddings is the plain 404, never a cross-family proxy. context_tokens: type: integer description: The sellable context window, in tokens (chat rows). pricing: oneOf: - $ref: '#/components/schemas/ModelPricing' - $ref: '#/components/schemas/UnitPricing' ModelPricing: type: object description: > Retail per-1M-token USD prices (chat rows) — the same data the console pricing page publishes. Omitted only when the loader has no retail snapshot for the alias (never on a listed public alias). properties: input_per_1m_usd: type: number format: double output_per_1m_usd: type: number format: double cached_input_per_1m_usd: type: number format: double UnitPricing: type: object description: > Retail unit prices for non-token routes (task #15, additive): one price per billed unit — chars (speech), audio_seconds (transcriptions), searches (rerank), tokens (embeddings). A model entry carries EITHER this shape or ModelPricing, never a mix. properties: unit: type: string description: The billed unit (tokens | chars | audio_seconds | searches). unit_price_usd: type: number format: double description: USD per per_units units. per_units: type: number format: double description: The unit basis (e.g. 1000000 chars, 3600 audio-seconds, 1000 searches). ModelList: type: object properties: object: type: string data: type: array items: $ref: '#/components/schemas/ModelEntry' HealthStatus: type: object properties: status: type: string version: type: string time: type: string CreditSummary: type: object properties: object: type: string data: type: object properties: balance: type: number description: Prepaid wallet balance (a hard cap — it cannot go negative). spend_30d: type: number description: Trailing 30-day spend. BillingQuery: type: object description: The HF settlement batch — HF polls every minute with the ids it routed. An absent requestIds is a 400 invalid_param. required: [requestIds] properties: requestIds: type: array items: type: string description: The x-request-id values the gateway stamped on the routed requests. BillingEntry: type: object description: One request's settlement answer — idempotent on the append-only ledger. properties: requestId: type: string description: The request's x-request-id. costNanoUsd: type: integer format: int64 description: round(sum of the request's retail cost across its ledger rows × 1e9). BillingAnswer: type: object description: The batch answer — one entry per known in-window id, request order. properties: requests: type: array nullable: true items: $ref: '#/components/schemas/BillingEntry' description: The JSON null literal (not []) when no id is answerable. Conversation: type: object description: One list row — identity + bookkeeping, no content (FR-X.1). properties: id: type: string format: uuid description: The conversation id clients pass as session_id. model_alias: type: string created_at: type: string last_active_at: type: string turn_count: type: integer ConversationDetail: type: object description: The full conversation payload — turns in exchange order plus the cost summary itemized from usage_events (FR-X.1, FR-C.6). allOf: - $ref: '#/components/schemas/Conversation' - type: object properties: turns: type: array items: $ref: '#/components/schemas/Turn' cost: $ref: '#/components/schemas/CostSummary' Turn: type: object properties: role: type: string content: description: The message object verbatim as exchanged (text parts today, vision parts later). nullable: true tokens: type: integer created_at: type: string CostSummary: type: object description: Per-conversation cost line the console renders (FR-C.6). properties: turns: type: integer input_tokens: type: integer output_tokens: type: integer retail_usd: type: number upstream_usd: type: number responses: invalid_json: description: 400 — the request body is not valid JSON (invalid_request_error / invalid_json). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Request body is not valid JSON." type: invalid_request_error code: invalid_json invalid_param: description: > 400 — a known field with a malformed value, or an unsupported field such as store / service_tier (invalid_request_error / invalid_param). Siblings under 400 — invalid_json, unknown_param. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "The 'max_tokens' parameter must be an integer greater than or equal to 0." type: invalid_request_error param: max_tokens code: invalid_param unknown_param: description: 400 — a parameter the API does not accept; rejected loudly, never silently ignored (invalid_request_error / unknown_param). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Unknown parameter: 'verbosity'." type: invalid_request_error param: verbosity code: unknown_param invalid_api_key: description: 401 — the bearer token is missing or unknown (authentication_error / invalid_api_key). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Invalid API key." type: authentication_error code: invalid_api_key credit_balance_exhausted: description: 402 — the prepaid wallet is empty; top up to continue (insufficient_credits / credit_balance_exhausted). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Credit balance exhausted." type: insufficient_credits code: credit_balance_exhausted key_revoked: description: 403 — the key exists but was revoked; rotate it out of client configuration (permission_error / key_revoked). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "This API key has been revoked." type: permission_error code: key_revoked model_not_found: description: 404 — the model alias does not exist; list live aliases via GET /v1/models (invalid_request_error / model_not_found). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "The model 'does-not-exist' does not exist." type: invalid_request_error param: model code: model_not_found idempotency_key_reused: description: 409 — an Idempotency-Key was reused with a different request body; a key names one request (invalid_request_error / idempotency_key_reused). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "This Idempotency-Key was already used with a different request body." type: invalid_request_error param: Idempotency-Key code: idempotency_key_reused channel_key_required: description: 403 — the billing route requires its dedicated channel key; this valid key belongs to another surface (permission_error / channel_key_required). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "This endpoint requires its dedicated channel key." type: permission_error param: Authorization code: channel_key_required rate_limited: description: > 429 — too many requests against one resource; back off and retry (rate_limit_error / rate_limited). Emitted today when a concurrent request holds the same session_id past the bounded lock wait; per-key request/token throttling remains future work (contract §7 headers stay reserved until then). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Rate limited." type: rate_limit_error code: rate_limited internal_error: description: 500 — our fault; retry (an Idempotency-Key makes the retry safe) and quote x-request-id if it persists (api_error / internal_error). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Internal error." type: api_error code: internal_error upstream_rejected: description: 502 — a provider rejected the request and failover could not rescue it; retrying the same body is usually correct (api_error / upstream_rejected). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Upstream rejected the request." type: api_error code: upstream_rejected no_provider_available: description: 503 — every slot in the model's chain is unavailable; back off and retry, or try another model (api_error / no_provider_available). headers: x-request-id: $ref: '#/components/headers/XRequestId' Retry-After: description: Seconds to wait before retrying — flap and reschedule windows are short (bounded in-slot retry has already been exhausted server-side). schema: type: integer content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "No provider available." type: api_error code: no_provider_available context_length_exceeded: description: > 400 — the assembled session context (stored turns + the new delta) exceeds the model's context_tokens; start a new session_id or trim the conversation (invalid_request_error / context_length_exceeded). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "The assembled session context exceeds this model's context_tokens. Start a new session_id or trim the conversation." type: invalid_request_error param: messages code: context_length_exceeded sessions_unavailable: description: > 400 — session_id was sent but this gateway runs without the conversation store (JSONL fallback); resend without session_id (invalid_request_error / sessions_unavailable). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Conversations are unavailable on this gateway (no conversation store); resend without session_id." type: invalid_request_error param: session_id code: sessions_unavailable conversation_not_found: description: > 404 — the conversation id is missing, deleted, foreign to this key, or malformed: indistinguishable by design (invalid_request_error / conversation_not_found). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "The conversation does not exist." type: invalid_request_error param: id code: conversation_not_found not_found: description: > 404 — GET /v1/credits with an env/operator key: no wallet is associated with this API key (invalid_request_error / not_found). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "No wallet is associated with this API key." type: invalid_request_error code: not_found request_too_large: description: > 413 — the /v1/audio/transcriptions upload exceeds the 25 MiB cap (invalid_request_error / request_too_large; task #15). The body is refused before any upstream connection is made. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "The upload exceeds the 25 MiB limit." type: invalid_request_error param: file code: request_too_large unsupported_audio_format: description: > 400 — the streaming transcription variant (stream=true) ships the upload as raw PCM frames on its WebSocket leg, which is lossless only when the file is already audio/L16;rate=16000 WAV/PCM (invalid_request_error / unsupported_audio_format; task #15). Transcode the audio or use the batch variant (codec-agnostic passthrough). Container transcoding is out of scope day-one. The task #19 WS face (/v1/audio/transcriptions/stream) answers this code as an in-socket error frame, never HTTP — the component stays HTTP-side for the SSE variant. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "Streaming transcription accepts raw PCM/WAV uploads only (audio/L16;rate=16000). Transcode the audio or use the batch variant." type: invalid_request_error param: file code: unsupported_audio_format job_not_found: description: > 404 — the async OCR job does not exist or has expired out of the card's job TTL (invalid_request_error / job_not_found; task #19). The gateway's own envelope — the card's foreign 404 body is mapped, never passed through. ocrjobget-only by construction (a create cannot 404 a job). headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' example: error: message: "The job does not exist or has expired." type: invalid_request_error param: job_id code: job_not_found