openapi: 3.1.0

# =============================================================================
# Onlist public /v1 OpenAPI spec — authoritative contract for the OpenRouter-
# compatible marketplace surface. Authored per ADR-0076 (docs/onlist/adr/).
#
# This is NOT docs/openapi/relay.json (that is the upstream new-api spec).
# Source of truth for the `provider` object is marketplace/middleware/
# resolve_listing.go (providerObject struct). Keep this file in sync; a Go
# golden-test should diff ProviderRouting field names against the struct tags.
#
# COMMITTED-vs-CURRENT note: per ADR-0076 the error envelope is documented as
# the OpenAI/OR shape {error:{message,type,code}} for ALL errors. Current code
# still emits a native {success,message,error_code} on some pre-relay paths;
# Task #2 converts them. This spec describes the committed target.
# =============================================================================

info:
  title: Onlist API
  version: 1.0.0
  license:
    name: Onlist API Terms
    url: https://onlist.io/terms
  description: |
    Onlist exposes an **OpenAI / OpenRouter-compatible** `/v1` surface. To migrate
    from OpenAI or OpenRouter, change **only the base URL and the API token** — your
    SDK and request bodies are unchanged. The one Onlist-specific addition is the
    [`provider`](#/components/schemas/ProviderRouting) routing object, sent as an
    extra field in the JSON request body.

    There is **no bespoke Onlist SDK** (mirroring OpenRouter): use the official
    OpenAI / Anthropic SDKs with `base_url` pointed here.

    ## Documented divergences from OpenRouter (honest list, per ADR-0076)
    - The API token has **no `sk-or-` prefix** (a plain bearer token; `sk-` is tolerated/stripped).
    - Unknown / unsupported OpenRouter `provider` fields are **accepted and silently ignored**
      (not `400`) and reported in the `X-Onlist-Warnings` response header.
    - `provider.only[]` is honored as a **single pin** (`only[0]`), not a multi-seller allowlist
      (use `provider.allow`).
    - `onlist/auto` (and `openrouter/auto`) resolve to **one static platform-default model**,
      not per-prompt smart selection.
    - `usage.cost` is **not** returned in the response body (cost is out of band; correlate via
      the `X-Onlist-Route-Id` header).
    - `stream_options.include_usage` is **forced `true`** when `stream:true` (a terminal usage
      chunk is required for settlement).
    - Privacy fields (`zdr`, `data_collection`) are accepted but enforced only as a **universal
      seller self-attestation** at onboarding ("I, the relay, do not retain/train on your data") —
      **not platform-verified and not end-to-end** (the upstream provider may still retain).
      `require_parameters` and `quantizations` are silently ignored (+ warning).
    - **No `X-RateLimit-*` headers** (only `Retry-After` on 429/503).
    - Not yet available: `/v1/moderations` currently returns `502`; `/v1/audio/transcriptions`
      and `/v1/audio/translations` currently return `501`.
    - Multipart endpoints (`/v1/audio/transcriptions`, `/v1/audio/translations`,
      `/v1/images/edits`) and the Gemini `/v1beta` path do **not** accept the `provider` field
      (mirrors OpenRouter); only `model` is read.

x-onlist-divergences:
  - token_no_sk_or_prefix
  - unknown_provider_fields_silently_ignored_via_X-Onlist-Warnings
  - provider_only_is_single_pin
  - auto_is_static_default_model
  - usage_cost_not_in_body
  - stream_options_include_usage_forced_true
  - privacy_fields_seller_self_attested_not_verified_not_end_to_end
  - no_x_ratelimit_headers
  - moderations_502_audio_stt_501_not_yet_available
  - provider_field_not_on_multipart_or_gemini

servers:
  - url: https://onlist.io   # Swap base_url + token to migrate from OpenRouter/OpenAI.
    description: >
      Host root. Paths are written with their full prefix (/v1/... and /v1beta/...).
      The OpenAI-compatible surface is mounted at /v1; Gemini-native paths are at /v1beta.

security:
  - BearerAuth: []

tags:
  - name: Chat
    description: OpenAI-compatible chat & text completions.
  - name: Models
    description: Public model catalog.
  - name: Embeddings
  - name: Images
  - name: Audio
  - name: Messages
    description: Anthropic-native Messages API.
  - name: Responses
    description: OpenAI Responses API.
  - name: Rerank
  - name: Gemini
    description: Gemini-native generateContent (path-addressed).
  - name: Video
    description: Async video-generation tasks (submit + poll).

paths:
  # ===========================================================================
  # CORE — fully specified
  # ===========================================================================
  /v1/chat/completions:
    post:
      tags: [Chat]
      operationId: createChatCompletion
      summary: Create a chat completion (OpenAI-compatible).
      description: >
        Drop-in OpenAI Chat Completions. Add the optional `provider` field to control
        marketplace routing. Request/response bodies are passed to/from the upstream
        seller byte-faithfully (Onlist rewrites only `model`, `stream_options.include_usage`,
        and the provider pin in place).
      parameters:
        - $ref: '#/components/parameters/HTTPReferer'
        - $ref: '#/components/parameters/XTitle'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChatCompletionRequest'
      responses:
        '200':
          description: >
            Chat completion. When `stream:true` the body is `text/event-stream`
            (a sequence of `data: <ChatCompletionChunk>` lines ending in `data: [DONE]`,
            with a final usage-only chunk).
          headers:
            X-Onlist-Route-Id: { $ref: '#/components/headers/X-Onlist-Route-Id' }
            X-Onlist-Warnings: { $ref: '#/components/headers/X-Onlist-Warnings' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatCompletionResponse' }
            text/event-stream:
              schema:
                type: string
                description: "SSE stream of ChatCompletionChunk objects terminated by `data: [DONE]`."
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/completions:
    post:
      tags: [Chat]
      operationId: createCompletion
      summary: Create a (legacy) text completion.
      description: >
        Legacy OpenAI text-completion shape. Shares the chat request schema but
        requires `prompt` instead of `messages`. Accepts the `provider` field.
      parameters:
        - $ref: '#/components/parameters/HTTPReferer'
        - $ref: '#/components/parameters/XTitle'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/ChatCompletionRequest'
              required: [model, prompt]
      responses:
        '200':
          description: Text completion (OpenAI-compatible).
          headers:
            X-Onlist-Route-Id: { $ref: '#/components/headers/X-Onlist-Route-Id' }
            X-Onlist-Warnings: { $ref: '#/components/headers/X-Onlist-Warnings' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatCompletionResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/models:
    get:
      tags: [Models]
      operationId: listModels
      summary: List available models (OpenRouter/OpenAI-compatible catalog).
      description: >
        **Public — no authentication.** Returns one entry per (author, model)
        aggregation across active, ready listings, 60s cached. `id` is
        `<author>/<model>`. Pricing is USD-per-token decimal strings.
        Emits the `X-Onlist-Catalog-Version: 1` response header.
        The sentinels `onlist/auto` / `openrouter/auto` are request-side routing
        values and do NOT appear in `data[]`.
      security: []   # public override
      responses:
        '200':
          description: The model catalog.
          headers:
            X-Onlist-Catalog-Version:
              description: Schema-version pin for the OR-compat catalog shape. Currently "1".
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelsResponse' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ===========================================================================
  # OpenAI-standard endpoints — stubs (request/response follow standard OpenAI;
  # the `provider` field is accepted in the JSON body unless marked multipart)
  # ===========================================================================
  /v1/embeddings:
    post:
      tags: [Embeddings]
      operationId: createEmbedding
      summary: Create embeddings (OpenAI-compatible).
      description: >
        Standard OpenAI embeddings (`model`, `input`, `dimensions`,
        `encoding_format`). Accepts the `provider` field. `usage` carries
        `prompt_tokens`/`total_tokens`. Billable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Standard OpenAI CreateEmbeddingRequest, plus optional `provider`.
              required: [model, input]
              properties:
                model: { type: string }
                input:
                  oneOf:
                    - type: string
                    - type: array
                      items: { type: string }
                encoding_format: { type: string, enum: [float, base64] }
                dimensions: { type: integer }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200':
          description: Embedding vectors (OpenAI shape).
          headers:
            X-Onlist-Warnings: { $ref: '#/components/headers/X-Onlist-Warnings' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/rerank:
    post:
      tags: [Rerank]
      operationId: createRerank
      summary: Rerank documents against a query (Cohere/Jina-style).
      description: >
        Provider-specific rerank shape (`model`, `query`, `documents[]`, `top_n`);
        response `results[]` with `index` + `relevance_score`. Accepts `provider`. Billable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, query, documents]
              properties:
                model: { type: string }
                query: { type: string }
                documents:
                  type: array
                  items: { type: string }
                top_n: { type: integer }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200': { description: Ranked results. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/moderations:
    post:
      tags: [Chat]
      operationId: createModeration
      summary: '[NOT YET AVAILABLE — returns 502] Classify text (OpenAI moderations).'
      description: >
        Route exists but marketplace settlement fails closed because OpenAI
        moderations responses carry no `usage`. **Currently returns HTTP 502**
        (`settlement_usage_missing`). Treat as not available.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [input]
              properties:
                model: { type: string }
                input: { type: string }
              additionalProperties: true
      responses:
        '502':
          description: Settlement could not be computed (no usage). Currently always returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /v1/audio/speech:
    post:
      tags: [Audio]
      operationId: createSpeech
      summary: Text-to-speech (OpenAI TTS).
      description: >
        Standard OpenAI `/v1/audio/speech` (`model`, `input`, `voice`,
        `response_format`, `speed`). Returns **binary audio**. Accepts `provider`.
        Available and billable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, input, voice]
              properties:
                model: { type: string }
                input: { type: string }
                voice: { type: string }
                response_format: { type: string, enum: [mp3, opus, aac, flac, wav, pcm] }
                speed: { type: number }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200':
          description: Raw audio bytes.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/audio/transcriptions:
    post:
      tags: [Audio]
      operationId: createTranscription
      summary: '[NOT YET AVAILABLE — returns 501] Speech-to-text (multipart).'
      description: >
        `multipart/form-data` (`file` + `model`). **Currently returns HTTP 501**
        (billing not configured for this relay format). The `provider` field is
        NOT accepted on this multipart endpoint (mirrors OpenRouter).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, model]
              properties:
                file: { type: string, format: binary }
                model: { type: string }
      responses:
        '501':
          description: Not implemented (format not billable). Currently always returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /v1/audio/translations:
    post:
      tags: [Audio]
      operationId: createTranslation
      summary: '[NOT YET AVAILABLE — returns 501] Audio translation (multipart).'
      description: >
        Same as transcriptions: `multipart/form-data`, **currently HTTP 501**,
        `provider` field not accepted.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, model]
              properties:
                file: { type: string, format: binary }
                model: { type: string }
      responses:
        '501':
          description: Not implemented (format not billable). Currently always returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /v1/images/generations:
    post:
      tags: [Images]
      operationId: createImage
      summary: Generate images (OpenAI Images API).
      description: >
        Standard OpenAI `CreateImageRequest` (`model`, `prompt`, `n`, `size`,
        `quality`, `response_format`). JSON body — accepts `provider`. Available/billable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                model: { type: string }
                prompt: { type: string }
                n: { type: integer }
                size: { type: string }
                quality: { type: string }
                response_format: { type: string, enum: [url, b64_json] }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200': { description: Generated image(s) (OpenAI shape). }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/images/edits:
    post:
      tags: [Images]
      operationId: createImageEdit
      summary: Edit/inpaint an image (OpenAI Images edits, multipart).
      description: >
        `multipart/form-data` (image file(s) + mask + prompt + model). Available/billable.
        The `provider` field is NOT accepted on this multipart endpoint (mirrors OpenRouter).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [image, prompt]
              properties:
                image: { type: string, format: binary }
                mask: { type: string, format: binary }
                prompt: { type: string }
                model: { type: string }
      responses:
        '200': { description: Edited image(s) (OpenAI shape). }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/responses:
    post:
      tags: [Responses]
      operationId: createResponse
      summary: OpenAI Responses API.
      description: >
        Standard OpenAI Responses request/response; usage mirrors chat-completions so
        it settles natively. Accepts `provider` in the JSON body. Available.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Standard OpenAI Responses request, plus optional `provider`.
              properties:
                model: { type: string }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200':
          description: OpenAI Responses object (or SSE when streaming).
          headers:
            X-Onlist-Route-Id: { $ref: '#/components/headers/X-Onlist-Route-Id' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  # ===========================================================================
  # Anthropic-native
  # ===========================================================================
  /v1/messages:
    post:
      tags: [Messages]
      operationId: createMessage
      summary: Anthropic Messages API (Claude-native wire format).
      description: >
        Standard Anthropic `/v1/messages`. Accepts the `provider` field as an extra
        body field. Anthropic SDKs may authenticate via `x-api-key: <token>` instead of
        `Authorization: Bearer`. `stream_options.include_usage` injection is skipped here
        (Claude SSE already emits usage). **Errors use the Claude envelope**
        `{error:{type,message}}` (no `code`/`param`). Available.
      parameters:
        - $ref: '#/components/parameters/HTTPReferer'
        - $ref: '#/components/parameters/XTitle'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Standard Anthropic Messages request, plus optional `provider`.
              required: [model, messages, max_tokens]
              properties:
                model: { type: string }
                max_tokens: { type: integer }
                messages: { type: array, items: { type: object, additionalProperties: true } }
                system: { }
                stream: { type: boolean }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200':
          description: Anthropic Message object (or SSE when streaming).
          headers:
            X-Onlist-Route-Id: { $ref: '#/components/headers/X-Onlist-Route-Id' }
        '400':
          description: Anthropic-shaped error.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClaudeErrorEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  # ===========================================================================
  # Gemini-native (path-addressed; model + action in the path, not the body)
  # ===========================================================================
  /v1beta/models/{modelAction}:
    post:
      tags: [Gemini]
      operationId: geminiGenerateContent
      summary: Gemini-native generateContent / streamGenerateContent.
      description: >
        Path-addressed Gemini API. `modelAction` is `<model>:<action>`, e.g.
        `gemini-2.5-pro:generateContent` or `gemini-2.5-pro:streamGenerateContent`.
        Model and stream are parsed from the path; the body is forwarded verbatim.
        The `provider` field and the `onlist/auto` sentinel are NOT supported on this
        path (JSON-body-only features). Gemini SDKs may authenticate via
        `x-goog-api-key: <token>`. Available.
      parameters:
        - name: modelAction
          in: path
          required: true
          schema: { type: string }
          description: '`<model>:<action>`, e.g. `gemini-2.5-pro:generateContent`.'
          example: gemini-2.5-pro:generateContent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Google GenerateContentRequest (forwarded verbatim).
              additionalProperties: true
      responses:
        '200':
          description: Google GenerateContentResponse (or SSE for streamGenerateContent).
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  # ===========================================================================
  # Async-task video (submit + poll)
  # ===========================================================================
  /v1/videos:
    post:
      tags: [Video]
      operationId: createVideo
      summary: Submit an async video-generation task (Sora-family).
      description: >
        JSON body with `model` + provider-specific params. Accepts `provider`. Submit
        debits buyer balance up front (estimate); final settlement happens in a background
        worker on terminal state, with auto-refund on failure. Returns a task with `task_id`.
        Poll with `GET /v1/videos/{task_id}`. Available.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model]
              properties:
                model: { type: string }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200': { description: Task accepted; returns a task object with `task_id`. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/videos/{task_id}:
    get:
      tags: [Video]
      operationId: getVideoTask
      summary: Poll an async video task by id.
      description: Auth required. No billing (settlement is in the worker terminal hook).
      parameters:
        - name: task_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Task status + result when complete. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/video/generations:
    post:
      tags: [Video]
      operationId: createVideoGeneration
      summary: Submit an async video task (generic family — doubao/Seedance/vidu/ali).
      description: >
        Same async-submit semantics as `/v1/videos`; per-family field extractor selects
        billing. Accepts `provider`. Poll with `GET /v1/video/generations/{task_id}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model]
              properties:
                model: { type: string }
                provider: { $ref: '#/components/schemas/Provider' }
              additionalProperties: true
      responses:
        '200': { description: Task accepted; returns a task object with `task_id`. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/video/generations/{task_id}:
    get:
      tags: [Video]
      operationId: getVideoGenerationTask
      summary: Poll a generic async video task by id.
      description: Auth required, no billing.
      parameters:
        - name: task_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Task status + result when complete. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: >
        Onlist API token issued in the buyer dashboard. Send as `Authorization: Bearer <token>`.
        Unlike OpenRouter there is **no `sk-or-` prefix** (a leading `sk-` is tolerated and
        stripped). Anthropic SDKs may instead send `x-api-key: <token>`, and Gemini SDKs
        `x-goog-api-key: <token>` — all normalize to the same token.

  parameters:
    HTTPReferer:
      name: HTTP-Referer
      in: header
      required: false
      schema: { type: string }
      example: https://myapp.example.com
      description: >
        OpenRouter-compat attribution header. Accepted for drop-in compatibility but
        NOT stored or used by Onlist. Safe to send; harmlessly ignored.
    XTitle:
      name: X-Title
      in: header
      required: false
      schema: { type: string }
      example: My Cool App
      description: >
        OpenRouter-compat attribution header (your app's display name). Accepted but
        NOT stored or used by Onlist. Safe to send; harmlessly ignored.

  headers:
    X-Onlist-Warnings:
      description: >
        Comma-and-space (`, `) joined, de-duplicated list of OpenRouter `provider`/request
        fields Onlist accepted but silently ignored, plus best-effort routing notices.
        Each entry is `ignored: <field>` (e.g. `ignored: provider.sort`) or a notice
        (e.g. `cool_pool_exhausted_fallback_used`). Parse with split on `, `. Present only
        when at least one field was ignored or a notice fired.
      schema: { type: string }
      example: "ignored: provider.sort, ignored: provider.zdr, ignored: provider.only[1:3]"
    X-Onlist-Route-Id:
      description: >
        Opaque UUID identifying this routing decision — Onlist's analogue of OpenRouter's
        `x-generation-id`. Look up routing metadata afterward via the authenticated buyer
        endpoint `GET /api/mkt/routes/{route_id}` (owner-only). Written on both stream and
        non-stream responses (before the first SSE byte on streams). Note: this is NOT the
        response body `id` field (that is the upstream completion id).
      schema: { type: string, format: uuid }
      example: 3f2a9c7e-1b4d-4a6e-9c11-2b8e0d5f7a31
    Retry-After:
      description: >
        Seconds to wait before retrying. Emitted on 429 (rate limited) and on 503
        (selected provider/listing in cooldown). Standard HTTP semantics.
      schema: { type: integer }
      example: 60

  responses:
    BadRequest:
      description: Malformed request, missing `model`/`messages`, bad provider slug, or retired sub-slug form.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            invalid_request:
              value: { error: { message: "model field required", type: "onlist_error", code: "invalid_request", param: "" } }
            sub_slug_removed:
              value: { error: { message: "provider sub-slug addressing is removed; use a bare provider slug", type: "onlist_error", code: "provider_sub_slug_removed", param: "" } }
    Unauthorized:
      description: Missing/invalid bearer token, or a stale marketplace test token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    PaymentRequired:
      description: Wallet balance insufficient for the estimated request cost, or per-token quota exhausted.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            insufficient_balance:
              value: { error: { message: "wallet balance insufficient for estimated request cost", type: "onlist_error", code: "insufficient_balance", param: "" } }
            quota_exhausted:
              value: { error: { message: "token quota exhausted (1500000/1500000)", type: "onlist_error", code: "quota_exhausted", param: "" } }
    NotFound:
      description: Pinned provider slug not found, or no active listing exists for the model.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            provider_not_found:
              value: { error: { message: "provider slug not found", type: "onlist_error", code: "provider_not_found", param: "" } }
            no_listing_for_model:
              value: { error: { message: "no active listing for the requested model", type: "onlist_error", code: "no_listing_for_model", param: "" } }
    Conflict:
      description: Listing closed after a test token was issued.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unprocessable:
      description: >
        Request shape valid but no match — author/model mismatch, the pinned provider has no
        listing for the model, or an auto sentinel was sent on an unsupported endpoint.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            author_mismatch:
              value: { error: { message: "model author segment disagrees with registered author", type: "onlist_error", code: "author_mismatch", param: "" } }
            no_listing_at_provider:
              value: { error: { message: "pinned provider has no active listing for this model", type: "onlist_error", code: "no_listing_for_model_at_provider", param: "" } }
    RateLimited:
      description: Per-token rolling-window rate cap hit. Includes a `Retry-After` header.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            rate_limited:
              value: { error: { message: "rate limit 1d exhausted (10000/10000), retry after 42s", type: "onlist_error", code: "rate_limited", param: "" } }
    ServiceUnavailable:
      description: >
        No eligible provider — disabled channels, `max_price`/allow-deny filtered the pool,
        provider inactive, or channel cooldown. `channel_cool` includes a `Retry-After` header.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            no_provider_in_max_price:
              value: { error: { message: "no listing within the requested provider.max_price", type: "onlist_error", code: "no_provider_in_max_price", param: "" } }
            no_provider_after_allow_deny:
              value: { error: { message: "provider allow/ignore preferences excluded every viable provider", type: "onlist_error", code: "no_provider_after_allow_deny", param: "" } }
            no_provider_available:
              value: { error: { message: "all matching listings have disabled upstream channels", type: "onlist_error", code: "no_provider_available", param: "" } }
    UpstreamError:
      description: >
        Error relayed verbatim from the upstream provider. The HTTP status mirrors the
        upstream; `error.code`/`type`/`metadata` come from upstream (OpenRouter-style passthrough).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            upstream:
              value: { error: { message: "This model's maximum context length is 128000 tokens.", type: "invalid_request_error", code: "context_length_exceeded", param: "messages", metadata: { provider_name: "alice-shop" } } }
    InternalError:
      description: Internal routing/billing/DB failure.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            internal_error:
              value: { error: { message: "routing failed", type: "onlist_error", code: "internal_error", param: "" } }

  schemas:
    # ---- Routing -----------------------------------------------------------
    Provider:
      description: >
        OpenRouter-compatible routing control. Either a bare provider slug (string shorthand
        for `{only:[slug]}`) or a routing object. Onlist honors a strict subset; unimplemented
        fields are silently ignored and reported in `X-Onlist-Warnings`.
      oneOf:
        - type: string
          description: Provider slug shorthand. Equivalent to `{"only":["<slug>"]}`. Must match `^[a-z0-9_-]{1,64}$`.
          example: alice-shop
        - $ref: '#/components/schemas/ProviderRouting'

    ProviderRouting:
      type: object
      description: >
        OpenRouter-compatible provider routing controls. Unimplemented fields are silently
        ignored (see `X-Onlist-Warnings`). Source of truth: marketplace/middleware/resolve_listing.go.
      additionalProperties: false
      properties:
        only:
          type: array
          items: { type: string }
          description: >
            **Single pin** (ADR-0076): only the first slug is honored; the rest are dropped and
            reported in `X-Onlist-Warnings` as `ignored: provider.only[1:N]`. Slugs must be bare
            (a `/` returns 400 `provider_sub_slug_removed`). Use `allow` for a multi-seller allowlist.
          example: [alice-shop]
        sort:
          type: string
          enum: [price, throughput]
          description: >
            Routing variant. `price` = cheapest first, `throughput` = fastest first. Other values
            (including OpenRouter's `latency`) are ignored + warned. A model-slug suffix
            (`:floor`/`:nitro`) overrides this field.
        order:
          type: array
          items: { type: string }
          description: >
            Provider try-order priority queue; P3 failover advances in this order. Non-string
            elements are skipped + warned. Suppresses sticky-channel affinity when set.
        allow:
          type: array
          items: { type: string }
          description: Request-level provider allowlist, merged with token/account preferences. Draining the pool returns 503 `no_provider_after_allow_deny`.
        ignore:
          type: array
          items: { type: string }
          description: Request-level provider denylist. Deny wins over allow.
        allow_fallbacks:
          type: boolean
          default: true
          description: When `false`, disables cross-provider failover. Absent = `true`.
        max_price:
          $ref: '#/components/schemas/ProviderMaxPrice'
        zdr:
          description: >
            Accepted but **not platform-verified**. Enforced only as a universal seller
            self-attestation (every seller attests at onboarding that the relay does not retain/train);
            NOT end-to-end (the upstream may still retain). Does not influence routing; warned.
        data_collection:
          description: Accepted but not enforced end-to-end (seller self-attestation). Silently ignored for routing + warned.
        require_parameters:
          type: boolean
          description: Not implemented. Silently ignored + warned.
        quantizations:
          type: array
          items: { type: string }
          description: Not implemented. Silently ignored + warned.
        variant:
          type: string
          deprecated: true
          description: Deprecated. Any value is ignored + warned.
        preferred_max_latency:
          description: Not implemented. Silently ignored + warned.
        preferred_min_throughput:
          description: Not implemented. Silently ignored + warned.

    ProviderMaxPrice:
      type: object
      description: >
        USD-per-token price caps (OpenRouter native format, e.g. `0.0000025` = $2.5/M).
        `prompt`/`completion` are enforced fail-closed; `image`/`request` are ignored (chat-only scope).
      properties:
        prompt:
          type: number
          description: Max input price (USD/token). Enforced; draining the pool returns 503 `no_provider_in_max_price`.
        completion:
          type: number
          description: Max output price (USD/token). Enforced.
        image:
          type: number
          description: Not implemented (chat-only). Ignored + warned.
        request:
          type: number
          description: Not implemented (chat-only). Ignored + warned.

    # ---- Chat request ------------------------------------------------------
    ChatCompletionRequest:
      type: object
      required: [model, messages]
      description: >
        OpenAI-compatible chat completion request. Only modeled fields are guaranteed to be
        forwarded upstream; arbitrary unknown top-level keys (and the OpenRouter sampling
        extensions `min_p`/`top_a`/`repetition_penalty`) are dropped (no `X-Onlist-Warnings`
        entry for those top-level drops).
      properties:
        model:
          type: string
          description: >
            Model id (e.g. `openai/gpt-4o` or bare `gpt-4o`). The sentinels `onlist/auto` and
            `openrouter/auto` resolve to one static platform-default model (not per-prompt selection).
          example: openai/gpt-4o
        messages:
          type: array
          items: { $ref: '#/components/schemas/Message' }
          description: Conversation messages (required for chat).
        prompt:
          oneOf:
            - type: string
            - type: array
              items: { type: string }
          description: Legacy text-completion prompt (used by /v1/completions instead of `messages`).
        provider:
          $ref: '#/components/schemas/Provider'
        models:
          type: array
          items: { type: string }
          description: >
            OpenRouter-compatible fallback list. Honored as a try-in-order fallback (the body
            `model` is rewritten for each attempt), gated by `provider.allow_fallbacks`.
          example: [openai/gpt-4o, anthropic/claude-3.5-sonnet]
        stream:
          type: boolean
          default: false
          description: >
            Enable SSE streaming. When `true`, `stream_options.include_usage` is forced `true`
            so a terminal usage chunk is emitted for settlement.
        stream_options:
          type: object
          properties:
            include_usage:
              type: boolean
              description: Forced to `true` internally when `stream:true` (client value overridden).
        temperature: { type: number, example: 0.7 }
        top_p: { type: number, example: 1.0 }
        top_k: { type: integer, description: Forwarded to upstreams that support it (Claude/Gemini/etc.). }
        max_tokens: { type: integer, description: Max generated tokens (must be <= 2^30). }
        max_completion_tokens: { type: integer, description: Newer alias for max_tokens (o-series/gpt-5). }
        n: { type: integer, default: 1 }
        stop:
          oneOf:
            - type: string
            - type: array
              items: { type: string }
        presence_penalty: { type: number }
        frequency_penalty: { type: number }
        logit_bias: { type: object, additionalProperties: { type: number } }
        logprobs: { type: boolean }
        top_logprobs: { type: integer }
        seed:
          type: number
          description: Deterministic sampling seed. Wire-decoded as a number (not strictly integer).
        response_format: { $ref: '#/components/schemas/ResponseFormat' }
        tools:
          type: array
          items: { $ref: '#/components/schemas/Tool' }
        tool_choice:
          description: '`"none"` | `"auto"` | `"required"` or `{"type":"function","function":{"name":"..."}}`.'
          oneOf:
            - type: string
              enum: [none, auto, required]
            - type: object
        parallel_tool_calls: { type: boolean }
        reasoning:
          type: object
          description: 'OpenRouter/unified reasoning controls, e.g. `{"effort":"high"}` or `{"max_tokens":2000}` or `{"exclude":true}`. Passed through.'
        reasoning_effort:
          type: string
          enum: [minimal, low, medium, high]
          description: OpenAI o-series effort hint.
        modalities:
          type: array
          items: { type: string }
          description: Requested output modalities, e.g. `["text","audio"]`.
        user: { type: string, description: End-user identifier. }
      additionalProperties: true

    Message:
      type: object
      required: [role]
      properties:
        role:
          type: string
          enum: [system, user, assistant, tool, developer]
        content:
          description: String for plain text, an array of content parts for multimodal, or null (e.g. an assistant message with only tool_calls).
          oneOf:
            - type: string
            - type: array
              items: { $ref: '#/components/schemas/ContentPart' }
            - type: 'null'
        name: { type: string }
        tool_calls:
          type: array
          items: { $ref: '#/components/schemas/ToolCall' }
        tool_call_id: { type: string, description: Required on `tool`-role messages; the id of the tool call being answered. }
        reasoning_content: { type: string }

    ContentPart:
      description: A multimodal content part. The `type` field discriminates the variant.
      oneOf:
        - type: object
          required: [type, text]
          properties:
            type: { type: string, enum: [text] }
            text: { type: string }
        - type: object
          required: [type, image_url]
          properties:
            type: { type: string, enum: [image_url] }
            image_url:
              oneOf:
                - type: string
                - type: object
                  required: [url]
                  properties:
                    url: { type: string, description: http(s) URL or a `data:` base64 URI. }
                    detail: { type: string, enum: [low, high, auto], default: high }
            cache_control: { type: object, description: OpenRouter/Anthropic cache-control passthrough. }
        - type: object
          required: [type, input_audio]
          properties:
            type: { type: string, enum: [input_audio] }
            input_audio:
              type: object
              properties:
                data: { type: string, description: base64-encoded audio. }
                format: { type: string, example: wav }
        - type: object
          required: [type, file]
          properties:
            type: { type: string, enum: [file] }
            file:
              type: object
              properties:
                file_id: { type: string }
                filename: { type: string }
                file_data: { type: string, description: base64-encoded file. }

    Tool:
      type: object
      required: [type]
      properties:
        type: { type: string, enum: [function, custom] }
        function:
          type: object
          required: [name]
          properties:
            name: { type: string }
            description: { type: string }
            parameters: { type: object, description: JSON Schema for the function arguments. }
        id: { type: string }

    ToolCall:
      type: object
      properties:
        id: { type: string }
        type: { type: string, enum: [function] }
        function:
          type: object
          properties:
            name: { type: string }
            arguments: { type: string, description: JSON-encoded arguments string. }

    ResponseFormat:
      type: object
      description: OpenAI structured-output / JSON-mode control.
      properties:
        type: { type: string, enum: [text, json_object, json_schema] }
        json_schema:
          type: object
          required: [name]
          properties:
            name: { type: string }
            description: { type: string }
            schema: { type: object }
            strict: { type: boolean }

    # ---- Chat response -----------------------------------------------------
    ChatCompletionResponse:
      type: object
      required: [id, object, created, model, choices]
      properties:
        id: { type: string, description: Upstream completion id (e.g. `chatcmpl-...`). NOT the Onlist route id. }
        object: { type: string, const: chat.completion }
        created: { type: integer, description: Unix epoch seconds (upstream-set). }
        model: { type: string, description: Resolved bare upstream model id (no `author/` prefix or `:floor`/`:nitro` suffix). Not the value the buyer submitted. }
        choices:
          type: array
          items: { $ref: '#/components/schemas/Choice' }
        usage: { $ref: '#/components/schemas/Usage' }
        system_fingerprint: { type: string }

    Choice:
      type: object
      required: [index, message]
      properties:
        index: { type: integer }
        message: { $ref: '#/components/schemas/ResponseMessage' }
        finish_reason:
          type: [string, 'null']
          enum: [stop, length, tool_calls, content_filter, function_call, null]
        logprobs: { type: [object, 'null'] }

    ResponseMessage:
      type: object
      required: [role]
      properties:
        role: { type: string, const: assistant }
        content: { type: [string, 'null'] }
        tool_calls:
          type: array
          items: { $ref: '#/components/schemas/ToolCall' }
        refusal: { type: [string, 'null'] }
        reasoning: { type: string }

    Usage:
      type: object
      description: Token accounting. Note Onlist does NOT add an OpenRouter-style `cost` field.
      required: [prompt_tokens, completion_tokens, total_tokens]
      properties:
        prompt_tokens: { type: integer }
        completion_tokens: { type: integer }
        total_tokens: { type: integer }
        prompt_tokens_details:
          type: object
          properties:
            cached_tokens: { type: integer }
            audio_tokens: { type: integer }
        completion_tokens_details:
          type: object
          properties:
            reasoning_tokens: { type: integer }
            audio_tokens: { type: integer }
            accepted_prediction_tokens: { type: integer }
            rejected_prediction_tokens: { type: integer }

    ChatCompletionChunk:
      type: object
      description: "One SSE `data:` frame when streaming. The final frame before `data: [DONE]` has `choices: []` and a populated `usage`."
      required: [id, object, created, model, choices]
      properties:
        id: { type: string }
        object: { type: string, const: chat.completion.chunk }
        created: { type: integer }
        model: { type: string }
        choices:
          type: array
          items: { $ref: '#/components/schemas/ChunkChoice' }
        usage:
          oneOf:
            - $ref: '#/components/schemas/Usage'
            - type: 'null'
        system_fingerprint: { type: string }

    ChunkChoice:
      type: object
      required: [index, delta]
      properties:
        index: { type: integer }
        delta: { $ref: '#/components/schemas/Delta' }
        finish_reason:
          type: [string, 'null']
        logprobs: { type: [object, 'null'] }

    Delta:
      type: object
      properties:
        role: { type: string, description: Present only on the first delta. }
        content: { type: string }
        tool_calls:
          type: array
          items: { type: object }
        reasoning: { type: string }

    # ---- Models catalog ----------------------------------------------------
    ModelsResponse:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/Model' }

    Model:
      type: object
      required: [id, name, owned_by, created, pricing]
      properties:
        id:
          type: string
          description: '`<author>/<model>` (e.g. `anthropic/claude-sonnet-4.6`). May be an OpenRouter public-alias form when one exists.'
          example: anthropic/claude-sonnet-4.6
        name:
          type: string
          description: Currently mirrors the `id` string (not a human-pretty label).
        owned_by:
          type: string
          description: The model author namespace (segment before the first `/`). Not the seller/provider.
          example: anthropic
        canonical_slug:
          type: string
          description: The un-translated `<author>/<canonical_model>` form, stable across public-alias translation.
        created:
          type: integer
          description: >
            **Listing `created_at`** (when the seller listed it on Onlist) — NOT the model's
            release date. Honest divergence from OpenRouter.
        description:
          type: string
          description: Present in the schema but currently always omitted.
        context_length: { type: integer }
        architecture: { $ref: '#/components/schemas/Architecture' }
        pricing: { $ref: '#/components/schemas/Pricing' }
        supported_parameters:
          type: array
          items: { type: string }
          description: >
            OpenRouter's exact vocabulary for catalog-synced models; an Onlist capability-enum
            fallback (`tools`/`vision`/`json_mode`/`streaming`/`reasoning`/`function_calling`/
            `structured_outputs`/`web_search`) for uncataloged models.
        quantization:
          type: string
          enum: [none, fp16, fp8, int8, int4]
          description: Single string (OpenRouter uses `architecture.quantizations[]`).
        top_provider: { $ref: '#/components/schemas/TopProvider' }

    Architecture:
      type: object
      properties:
        modality: { type: string, description: 'Legacy combined string, e.g. `text+image->text`.', example: 'text+image->text' }
        input_modalities:
          type: array
          items: { type: string, enum: [text, image, audio, video] }
        output_modalities:
          type: array
          items: { type: string, enum: [text, image, audio, video] }
        tokenizer: { type: string, description: Currently always empty/omitted. }

    Pricing:
      type: object
      description: >
        USD-per-token (prompt/completion) or USD-per-call (request) decimal strings, e.g.
        `"0.0000020000"` = $2/M. Onlist emits ONLY these keys (no image/audio/cache/web_search
        pricing). For per-request-billed models, `prompt`/`completion` are `"0"` and `request` is set.
      required: [prompt, completion]
      properties:
        prompt: { type: string, example: "0.0000030000" }
        completion: { type: string, example: "0.0000150000" }
        request: { type: string, description: Flat USD-per-call for per-request-billed listings. }

    TopProvider:
      type: object
      description: Reflects the cheapest listing in the aggregation group (not a single named provider).
      properties:
        context_length: { type: integer }
        max_completion_tokens: { type: integer }
        is_moderated: { type: boolean, description: Currently hardcoded false. }

    # ---- Errors ------------------------------------------------------------
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error: { $ref: '#/components/schemas/Error' }

    Error:
      type: object
      required: [message, type, code]
      properties:
        message:
          type: string
          description: "Human-readable message. May include a `(request id: <id>)` suffix for tracing."
          example: "wallet balance insufficient for estimated request cost (request id: 20260530-abc123)"
        type:
          type: string
          description: >
            Error family label (a string, NOT an HTTP status). Onlist-originated pre-relay errors
            use `onlist_error`; upstream-relayed errors echo the upstream type (default `upstream_error`).
          example: onlist_error
        code:
          type: string
          description: Machine-readable slug. **A STRING, never a numeric HTTP code.** Branch on this in client SDKs.
          example: insufficient_balance
        param:
          type: [string, 'null']
          description: OpenAI-compat field; empty/omitted for most Onlist pre-relay errors.
        metadata:
          type: [object, 'null']
          additionalProperties: true
          description: OpenRouter-style passthrough metadata. Present ONLY on upstream-relayed provider errors.

    ClaudeErrorEnvelope:
      type: object
      description: Anthropic /v1/messages error variant (no `code`/`param`/`metadata`).
      required: [error]
      properties:
        error:
          type: object
          required: [type, message]
          properties:
            type: { type: string, example: overloaded_error }
            message: { type: string }
