# ─────────────────────────────────────────────────────────────────────
# Stav AI — Public API spec
#
# Hand-curated OpenAPI 3.1 spec covering ONLY the customer-facing surface
# (inference + public catalogue browse). The internal portal-API
# Swagger is deliberately disabled (docs_url=None) and nginx 404s the
# old `/docs` and `/openapi.json` paths.
#
# Strategy & implementation docs live in the internal docs/ tree
# (developer docs strategy + implementation plan).
#
# This file is the contract. Endpoints listed here are long-term
# customer commitments. Endpoints in the implementation but NOT in this
# spec are internal and may change without notice.
# ─────────────────────────────────────────────────────────────────────
openapi: 3.1.0

info:
  title: Stav AI API
  version: "1.0.0"
  summary: EU-sovereign AI inference platform
  description: |
    The Stav AI API gives you OpenAI-compatible and Anthropic-compatible
    inference against a curated catalogue of sovereign-OSS, routed-commercial,
    and Bring-Your-Own models — all running in EU jurisdictions with full
    data residency.

    ## Two SDK contracts, one API

    - **OpenAI-compatible** (`/v1/chat/completions`, `/v1/embeddings`,
      `/v1/models`) — drop-in for the official OpenAI SDKs. Set
      `base_url="https://api.stav.ai/v1"` and your existing code works.
    - **Anthropic-compatible** (`/v1/messages`) — drop-in for the official
      Anthropic SDK. Set `base_url="https://api.stav.ai/v1"` and
      `claude-*` models proxy verbatim to Anthropic (preserving
      `cache_control`, `thinking`, beta features). Every other catalogue
      model translates to the OpenAI Chat Completions contract under the
      hood — same call, same shape, different upstream.

    ## Sovereignty model

    Every response carries a `X-Stav-Sovereignty-Tier` header indicating
    whether the request was served by:

    - **`sovereign`** — Stav-hosted OSS model (Qwen3, Llama 4, Mistral,
      DeepSeek, etc.) running on Stav infrastructure in the EU.
    - **`routed-commercial`** — A commercial model proxied through Stav
      (OpenAI, Anthropic, Mistral La Plateforme, Google, xAI, Cohere).
      Stav holds the upstream relationship; your traffic terms are
      governed by Stav's Terms of Service, not the commercial vendor's.
    - **`byo`** — A model you brought yourself (custom HuggingFace
      weights or fine-tune deployed on dedicated Stav infrastructure).

    All inference is logged, billed, and audit-trailed by Stav regardless
    of tier.

    ## Smart Router

    Pass `model="auto"` to let Stav's Smart Router pick the best model
    for the request based on prompt classification (complexity, domain,
    sensitivity) and the team's routing policy. The response includes
    `routing_metadata` describing what was picked and why.

    ## Authentication

    All authenticated endpoints take a Bearer token in the `Authorization`
    header. Tokens look like `hm_live_...` for production keys.

    To create a key, go to the [Customer Portal → Connect → Keys](https://stav.ai/app/connect/keys).
    Keep the secret value safe — it's shown once at creation and never again.

    ## Rate limits

    Default plan: 60 requests/minute per key, 1M tokens/day. Headers on
    every response report:

    - `X-RateLimit-Limit` — your plan's per-minute cap
    - `X-RateLimit-Remaining` — requests left in this window
    - `X-RateLimit-Reset` — Unix timestamp when the window rolls over

    Exceeding the cap returns `429` with a `Retry-After` header.

    ## Request IDs

    Every response carries `X-Request-Id` (also in error envelopes as
    `request_id`). Quote it when contacting support.
  termsOfService: https://stav.ai/legal/terms
  contact:
    name: Stav AI Support
    url: https://stav.ai/support
    email: support@stav.ai
  license:
    name: Proprietary
    url: https://stav.ai/legal/terms

servers:
  - url: https://api.stav.ai
    description: Production
  - url: https://staging-api.stav.ai
    description: Staging (rate-limited, may reset state without notice)

tags:
  - name: Chat
    description: |
      Conversational and tool-using inference. Two contracts available:
      OpenAI-compatible (`/v1/chat/completions`) and Anthropic-compatible
      (`/v1/messages`). Pick the one whose SDK you already use.
  - name: Embeddings
    description: Vector embeddings for search, clustering, and retrieval.
  - name: Models
    description: |
      Models accessible to the authenticated caller. Filtered by key
      scope and team plan.
  - name: Catalogue
    description: |
      Public, anonymous browse of the full Stav model catalogue. No
      auth required. ETag-cached, rate-limited 60 req/min/IP.

security:
  - bearerAuth: []

paths:
  # ───────────────────────────────────────────────────────────────────
  # Inference — Chat
  # ───────────────────────────────────────────────────────────────────
  /v1/chat/completions:
    post:
      tags: [Chat]
      summary: Create a chat completion (OpenAI-compatible)
      operationId: createChatCompletion
      description: |
        Generates a model response for a conversation. Drop-in for the
        OpenAI Python or TypeScript SDK — set `base_url="https://api.stav.ai/v1"`
        and your existing code works.

        Supports streaming via SSE (`stream=true`), function calling
        (`tools[]`), structured output (`response_format`), and prompt
        caching markers (`cache_control` on individual messages, where
        the underlying model supports it).

        Pass `model="auto"` to invoke the Smart Router.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChatCompletionRequest'
            examples:
              simple:
                summary: Simple completion
                value:
                  model: qwen3-32b
                  messages:
                    - role: user
                      content: "Explain EU AI Act Article 26 in three bullets."
              with_tools:
                summary: With built-in web_search tool
                value:
                  model: qwen3-32b
                  messages:
                    - role: user
                      content: "What's the latest EU Digital Omnibus status?"
                  tools:
                    - type: web_search
              smart_router:
                summary: Smart Router (model=auto)
                value:
                  model: auto
                  messages:
                    - role: user
                      content: "Write a Python script to deduplicate this CSV."
              streaming:
                summary: Streaming response
                value:
                  model: qwen3-32b
                  messages:
                    - role: user
                      content: "Tell me a story."
                  stream: true
      responses:
        '200':
          description: Successful completion.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestId'
            X-Stav-Sovereignty-Tier:
              $ref: '#/components/headers/SovereigntyTier'
            X-RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChatCompletionResponse'
            text/event-stream:
              schema:
                type: string
                description: |
                  Server-sent events stream. Each event is a `data:` line
                  containing a JSON-encoded `ChatCompletionChunk`,
                  terminated by `data: [DONE]`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  # ───────────────────────────────────────────────────────────────────
  # Inference — Messages (Anthropic-compatible)
  # ───────────────────────────────────────────────────────────────────
  /v1/messages:
    post:
      tags: [Chat]
      summary: Create a message (Anthropic-compatible)
      operationId: createMessage
      description: |
        Anthropic Messages API. Drop-in for the official Anthropic SDK —
        set `base_url="https://api.stav.ai/v1"` and `api_key=hm_live_...`.

        **Routing:**

        - `claude-*` models are proxied verbatim to `api.anthropic.com`.
          Full Messages-API fidelity including `cache_control` markers,
          extended `thinking`, and beta features.
        - Every other catalogue model (sovereign OSS, BYO, routed-commercial
          non-Anthropic) is translated to the OpenAI Chat Completions
          contract and dispatched through the same path as
          `/v1/chat/completions`. `model="auto"` invokes the Smart Router.

        Per-key rate limits apply. App attribution via the `X-Stav-App-Id`
        header is supported and recorded on the inference log.
      parameters:
        - in: header
          name: X-Stav-App-Id
          required: false
          schema: { type: string }
          description: Optional app slug for attribution on the inference log.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessagesRequest'
            examples:
              claude:
                summary: Claude (proxied to Anthropic)
                value:
                  model: claude-sonnet-4-5
                  max_tokens: 1024
                  messages:
                    - role: user
                      content: "Summarise CLOUD Act Section 102."
              sovereign:
                summary: Sovereign model via Anthropic contract
                value:
                  model: qwen3-32b
                  max_tokens: 1024
                  messages:
                    - role: user
                      content: "Same prompt, sovereign model."
      responses:
        '200':
          description: |
            When `stream=false` (default), returns an Anthropic `Message`
            JSON object. When `stream=true`, returns a `text/event-stream`
            Anthropic SSE response with events `message_start`,
            `content_block_start`, `content_block_delta`,
            `content_block_stop`, `message_delta`, `message_stop`,
            `ping`, `error`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestId'
            X-Stav-Sovereignty-Tier:
              $ref: '#/components/headers/SovereigntyTier'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessagesResponse'
            text/event-stream:
              schema:
                type: string
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503':
          description: Upstream Anthropic credential not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/responses:
    post:
      tags: [Chat]
      summary: Create a response (OpenAI Responses API, passthrough)
      operationId: createResponse
      description: |
        OpenAI Responses API. Faithful passthrough to OpenAI for
        routed-commercial **OpenAI** models — drop-in for the official
        OpenAI SDK: set `base_url="https://api.stav.ai/v1"` and
        `api_key=hm_live_...`, then call `client.responses.create(...)`.

        This is the only surface for OpenAI's **Responses-API-only** models
        (`gpt-5-codex`, `codex-*`, `o*-pro`), which cannot be served on
        `/v1/chat/completions`. Dual-surface models (`gpt-5`, `o`-series)
        are also accepted here so their reasoning features (`reasoning`,
        encrypted reasoning, built-in tools) are fully expressed.

        The request body is forwarded verbatim to OpenAI (with the
        Stav model slug mapped to the provider model id) and the response /
        SSE stream is returned untouched. Sovereign, BYO, and non-OpenAI
        commercial models are rejected (`model_not_responses_capable`) — use
        `/v1/chat/completions` for those. Per-key rate limits apply; one
        inference-log row is written per call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: >
                OpenAI Responses API request body, forwarded verbatim. See
                the OpenAI Responses API reference. `model` must be an OpenAI
                routed-commercial catalogue slug.
              required: [model]
              properties:
                model: { type: string, example: gpt-5-codex }
                input:
                  description: String or array of input items (OpenAI Responses shape).
                  oneOf:
                    - { type: string }
                    - { type: array, items: { type: object, additionalProperties: true } }
                stream: { type: boolean, default: false }
              additionalProperties: true
            examples:
              codex:
                summary: Codex (Responses-only)
                value:
                  model: gpt-5-codex
                  input: "Write a Python function that reverses a linked list."
      responses:
        '200':
          description: |
            When `stream=false` (default), returns an OpenAI Responses
            object. When `stream=true`, returns a `text/event-stream` of
            the OpenAI Responses event protocol (`response.created`,
            `response.output_text.delta`, …, `response.completed`).
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestId'
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
            text/event-stream:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503':
          description: Upstream OpenAI credential not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/responses/{response_id}:
    parameters:
      - in: path
        name: response_id
        required: true
        schema: { type: string }
        description: Id of a stored response (from a prior POST /v1/responses).
    get:
      tags: [Chat]
      summary: Retrieve a stored response
      operationId: getResponse
      description: |
        OpenAI Responses passthrough — retrieve a stored response by id.
        Requires the original request to have used `store: true` (the
        default). Query params (e.g. `include`) are forwarded.
      responses:
        '200':
          description: The stored OpenAI Responses object.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }
    delete:
      tags: [Chat]
      summary: Delete a stored response
      operationId: deleteResponse
      description: OpenAI Responses passthrough — delete a stored response by id.
      responses:
        '200':
          description: Deletion confirmation object.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /v1/responses/{response_id}/cancel:
    post:
      tags: [Chat]
      summary: Cancel an in-progress (background) response
      operationId: cancelResponse
      parameters:
        - in: path
          name: response_id
          required: true
          schema: { type: string }
      description: |
        OpenAI Responses passthrough — cancel a response created with
        `background: true` that is still in progress.
      responses:
        '200':
          description: The updated (cancelling/cancelled) response object.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /v1/responses/{response_id}/input_items:
    get:
      tags: [Chat]
      summary: List input items for a stored response
      operationId: listResponseInputItems
      parameters:
        - in: path
          name: response_id
          required: true
          schema: { type: string }
        - in: query
          name: limit
          required: false
          schema: { type: integer }
        - in: query
          name: order
          required: false
          schema: { type: string, enum: [asc, desc] }
        - in: query
          name: after
          required: false
          schema: { type: string }
        - in: query
          name: before
          required: false
          schema: { type: string }
      description: |
        OpenAI Responses passthrough — list the input items for a stored
        response. Pagination query params are forwarded verbatim.
      responses:
        '200':
          description: A paginated list of input items.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  # ───────────────────────────────────────────────────────────────────
  # Inference — Embeddings
  # ───────────────────────────────────────────────────────────────────
  /v1/embeddings:
    post:
      tags: [Embeddings]
      summary: Create embeddings
      operationId: createEmbedding
      description: |
        Generates vector embeddings for one or more inputs. Drop-in for
        the OpenAI SDK.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmbeddingRequest'
            examples:
              single:
                value:
                  model: bge-m3
                  input: "Sovereignty is not a feature, it is a contract."
              batch:
                value:
                  model: bge-m3
                  input:
                    - "First chunk of text."
                    - "Second chunk of text."
                    - "Third chunk of text."
      responses:
        '200':
          description: Successful embedding response.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-Stav-Sovereignty-Tier: { $ref: '#/components/headers/SovereigntyTier' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmbeddingResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/rerank:
    post:
      tags: [Embeddings]
      summary: Rerank documents against a query
      operationId: createRerank
      description: |
        Reranks `documents` by relevance to a `query` using a sovereign
        cross-encoder reranker. Jina/Cohere-compatible request and response
        shape. Requires the `inference:rerank` scope. The model's catalogue
        `category` must be `rerank`.

        v1 is sovereign-only — a routed-commercial reranker returns `501`
        (`rerank_commercial_unsupported`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RerankRequest' }
            examples:
              rag:
                value:
                  model: bge-reranker-v2-m3
                  query: "What is the CLOUD Act?"
                  documents:
                    - "The CLOUD Act lets US authorities compel disclosure of data held by US providers."
                    - "Sourdough requires a mature starter."
                    - "FISA Section 702 governs foreign-intelligence surveillance."
                  top_n: 2
      responses:
        '200':
          description: Documents scored and ordered by relevance.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-Stav-Sovereignty-Tier: { $ref: '#/components/headers/SovereigntyTier' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RerankResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ModelNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501':
          description: Reranking not supported for the requested (commercial) model.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ───────────────────────────────────────────────────────────────────
  # Models — accessible to the caller
  # ───────────────────────────────────────────────────────────────────
  /v1/models:
    get:
      tags: [Models]
      summary: List models accessible to the caller
      operationId: listModels
      description: |
        Lists every model the authenticated key can invoke, filtered by
        key scope, team plan, and any team-level model allow/deny lists.
        Drop-in for the OpenAI SDK's `client.models.list()`.
      responses:
        '200':
          description: List of accessible models.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelList' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/models/{model_id}:
    get:
      tags: [Models]
      summary: Get metadata for a specific model
      operationId: retrieveModel
      parameters:
        - in: path
          name: model_id
          required: true
          schema: { type: string }
          description: |
            Model identifier, with optional tier suffix (`:fast` or
            `:precision`). Example: `qwen3-32b:fast`.
      responses:
        '200':
          description: Model metadata.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Model' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/ModelNotFound' }

  # ───────────────────────────────────────────────────────────────────
  # Catalogue — anonymous browse
  # ───────────────────────────────────────────────────────────────────
  /v1/public/models:
    get:
      tags: [Catalogue]
      summary: Browse the public model catalogue
      operationId: listCatalogueModels
      security: []  # anonymous
      description: |
        Lists every active model in the Stav catalogue. No auth required.
        ETag-cached. Default rate limit 60 req/min/IP.
      parameters:
        - { in: query, name: page, schema: { type: integer, minimum: 1, default: 1 }, description: Page number (1-based) }
        - { in: query, name: page_size, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { in: query, name: category, schema: { type: string, enum: [llm, code, embedding, vision, speech, multimodal] } }
        - { in: query, name: creator_slug, schema: { type: string } }
        - { in: query, name: is_open_weights, schema: { type: boolean } }
        - { in: query, name: featured, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string } }
        - { in: query, name: model_family, schema: { type: string } }
        - { in: query, name: sort_by, schema: { type: string, enum: [sort_order, name, quality_index, arena_elo, parameter_count, created_at], default: sort_order } }
        - { in: query, name: sort_dir, schema: { type: string, enum: [asc, desc], default: asc } }
      responses:
        '200':
          description: Paginated catalogue list.
          headers:
            ETag: { schema: { type: string } }
            Cache-Control: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogueModelList' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/public/models/{slug}:
    get:
      tags: [Catalogue]
      summary: Get a catalogue model by slug
      operationId: getCatalogueModel
      security: []
      parameters:
        - in: path
          name: slug
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Full model detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogueModelDetail' }
        '404':
          description: Model not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/public/families:
    get:
      tags: [Catalogue]
      summary: List model families
      operationId: listFamilies
      security: []
      responses:
        '200':
          description: All active model families (groupings like Qwen3, Llama 4, Claude).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FamilyList' }

  /v1/public/families/{slug}:
    get:
      tags: [Catalogue]
      summary: Get a model family by slug
      operationId: getFamily
      security: []
      parameters:
        - { in: path, name: slug, required: true, schema: { type: string } }
      responses:
        '200':
          description: Family detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Family' }
        '404': { $ref: '#/components/responses/ModelNotFound' }

  /v1/public/families/{slug}/models:
    get:
      tags: [Catalogue]
      summary: List models in a family
      operationId: listFamilyModels
      security: []
      parameters:
        - { in: path, name: slug, required: true, schema: { type: string } }
      responses:
        '200':
          description: Models that belong to this family.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogueModelList' }

  /v1/public/model-creators:
    get:
      tags: [Catalogue]
      summary: List model creators
      operationId: listCreators
      security: []
      description: |
        Returns every creator with at least one active catalogue model
        (Qwen, Meta, Mistral, OpenAI, Anthropic, DeepSeek, etc.).
      responses:
        '200':
          description: Creator list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatorList' }

  /v1/public/model-creators/{slug}:
    get:
      tags: [Catalogue]
      summary: Get a model creator by slug
      operationId: getCreator
      security: []
      parameters:
        - { in: path, name: slug, required: true, schema: { type: string } }
      responses:
        '200':
          description: Creator detail with their model list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Creator' }
        '404': { $ref: '#/components/responses/ModelNotFound' }

  /v1/public/providers:
    get:
      tags: [Catalogue]
      summary: List inference providers
      operationId: listProviders
      security: []
      description: |
        Inference providers Stav routes through. Includes the sovereign
        Stav-hosted provider plus commercial providers (Anthropic,
        OpenAI, Mistral La Plateforme, Google, etc.) accessed via Stav's
        upstream relationships.
      responses:
        '200':
          description: Provider list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProviderList' }

  /v1/public/providers/{slug}:
    get:
      tags: [Catalogue]
      summary: Inference provider detail
      operationId: getProvider
      security: []
      description: |
        Full provider detail — sovereignty posture (Stav's assessment),
        the provider's own Trust Profile (legal documents +
        provenance-stamped certifications), the first-party Creator link
        when applicable, and the endpoints served.
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Provider detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProviderDetail' }
        '404':
          description: Provider not found.

  /v1/public/sovereignty:
    get:
      tags: [Catalogue]
      summary: Sovereignty levels
      operationId: listSovereigntyLevels
      security: []
      description: |
        The three sovereignty tiers and what each guarantees about data
        residency, CLOUD Act exposure, and audit trail.
      responses:
        '200':
          description: Sovereignty taxonomy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SovereigntyList' }

  /v1/public/capabilities:
    get:
      tags: [Catalogue]
      summary: Capabilities taxonomy
      operationId: listCapabilities
      security: []
      description: |
        Capabilities catalogue (reasoning, tool-use, vision, code-gen,
        long-context, multilingual, etc.) used to tag models.
      responses:
        '200':
          description: Capability list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CapabilityList' }

# ─────────────────────────────────────────────────────────────────────
# Reusable components
# ─────────────────────────────────────────────────────────────────────
components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: hm_live_...
      description: |
        Stav API key. Format `hm_live_<random>` (production) or
        `hm_test_<random>` (sandbox, when available).

        Create a key at https://stav.ai/app/connect/keys.

  headers:
    RequestId:
      description: Unique request identifier. Quote this when contacting support.
      schema: { type: string, example: "req-7a3f9c2e1b8d4a5f" }
    SovereigntyTier:
      description: |
        Which sovereignty tier served this request. One of `sovereign`,
        `routed-commercial`, or `byo`.
      schema:
        type: string
        enum: [sovereign, routed-commercial, byo]
    RateLimitLimit:
      description: Per-minute request cap on this key.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix timestamp (seconds) when the current window rolls over.
      schema: { type: integer, format: int64 }

  responses:
    BadRequest:
      description: Invalid request — malformed JSON, missing required fields, or invalid parameter values.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: API key lacks the required scope (e.g., `inference:messages`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ModelNotFound:
      description: Model id is not in the catalogue, or the caller's key doesn't have access to it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-key or per-IP rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UpstreamError:
      description: An upstream provider (Anthropic, OpenAI, etc.) returned an error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:

    # ─── Error envelope ────────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [message, type]
          properties:
            type:
              type: string
              description: Coarse error category.
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - rate_limit_error
                - api_error
                - overloaded_error
            code:
              type: [string, 'null']
              description: Fine-grained machine-readable error code.
              example: model_not_available
            message:
              type: string
              description: Human-readable error message. May change without notice — match on `type`/`code`, not `message`.
            request_id:
              type: string
              description: Same as the `X-Request-Id` response header.

    # ─── Chat completions (OpenAI-compatible) ──────────────────────
    ChatCompletionRequest:
      type: object
      required: [model, messages]
      properties:
        model:
          type: string
          description: |
            Catalogue model slug, optionally suffixed with `:fast` or
            `:precision` to pick a specific quantization tier. Use
            `auto` to invoke the Smart Router.
          examples:
            - qwen3-32b
            - qwen3-32b:precision
            - llama-4-maverick
            - claude-sonnet-4-5
            - auto
        messages:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/ChatMessage' }
        temperature: { type: number, minimum: 0, maximum: 2, default: 1 }
        top_p:       { type: number, minimum: 0, maximum: 1, default: 1 }
        max_tokens:  { type: integer, minimum: 1 }
        stream:      { type: boolean, default: false }
        stream_options:
          type: object
          description: Streaming options, applied only when `stream=true`.
          properties:
            include_usage:
              type: boolean
              description: "When true, a final SSE chunk carries a `usage` object with token counts and an empty `choices` array, before the `[DONE]` sentinel."
        stop:
          oneOf:
            - { type: string }
            - { type: array, items: { type: string } }
        n: { type: integer, minimum: 1, default: 1, description: "Number of choices to return. Only 1 is supported — `n>1` returns 400 (`unsupported_parameter`)." }
        presence_penalty:  { type: number, minimum: -2, maximum: 2, description: "Presence penalty. Forwarded to sovereign (vLLM) and OpenAI-compatible providers; returns 400 (`unsupported_parameter`) for providers that don't support it." }
        frequency_penalty: { type: number, minimum: -2, maximum: 2, description: "Frequency penalty. Same support/rejection rules as `presence_penalty`." }
        logprobs:     { type: boolean, description: "Return log probabilities of output tokens. Forwarded to sovereign (vLLM) and OpenAI-compatible providers; 400 (`unsupported_parameter`) elsewhere." }
        top_logprobs: { type: integer, minimum: 0, maximum: 20, description: "Number of most-likely tokens to return per position (0–20). Requires `logprobs=true`." }
        response_format:
          oneOf:
            - { $ref: '#/components/schemas/ResponseFormatText' }
            - { $ref: '#/components/schemas/ResponseFormatJsonObject' }
            - { $ref: '#/components/schemas/ResponseFormatJsonSchema' }
        tools:
          type: array
          description: |
            Tools (functions) the model may invoke. Stav's built-in tool
            types — `web_search`, `code_execution`, `file_search`,
            `url_fetch`, `document_ocr` — run on sovereign Stav
            infrastructure by default. User-defined `function` tools
            execute on your side; Stav returns the call shape and you
            invoke them.
          items: { $ref: '#/components/schemas/Tool' }
        tool_choice:
          oneOf:
            - { type: string, enum: [none, auto, required] }
            - { $ref: '#/components/schemas/ToolChoiceFunction' }
        user:
          type: string
          description: End-user identifier for abuse-detection telemetry. Not billed against your team's user-key budgets.
        seed:
          type: integer
          description: Best-effort deterministic sampling. Sovereign models honour this; routed-commercial respects upstream support.

    ChatMessage:
      type: object
      required: [role]
      properties:
        role: { type: string, enum: [system, user, assistant, tool] }
        content:
          description: |
            String for text-only messages, or array of content parts
            (text, image_url, input_audio) for multimodal models.
          oneOf:
            - { type: string }
            - { type: array, items: { $ref: '#/components/schemas/ContentPart' } }
        name: { type: string, description: Optional name for the message author. }
        tool_call_id: { type: string, description: Required when role is `tool`. }
        tool_calls:
          type: array
          description: Tool calls the assistant made (assistant-role messages only).
          items: { $ref: '#/components/schemas/ToolCall' }

    ContentPart:
      oneOf:
        - type: object
          required: [type, text]
          properties:
            type: { type: string, const: text }
            text: { type: string }
            cache_control:
              type: object
              description: Anthropic-style cache marker. Honoured when the underlying model supports prompt caching.
              properties:
                type: { type: string, const: ephemeral }
        - type: object
          required: [type, image_url]
          properties:
            type: { type: string, const: image_url }
            image_url:
              type: object
              required: [url]
              properties:
                url: { type: string, description: "HTTPS URL or data:image/...;base64,... data URI." }
                detail: { type: string, enum: [auto, low, high], default: auto }

    Tool:
      oneOf:
        - $ref: '#/components/schemas/FunctionTool'
        - $ref: '#/components/schemas/BuiltInTool'

    FunctionTool:
      type: object
      required: [type, function]
      properties:
        type: { type: string, const: function }
        function:
          type: object
          required: [name]
          properties:
            name: { type: string }
            description: { type: string }
            parameters: { description: JSON Schema for the function's arguments. }

    BuiltInTool:
      type: object
      required: [type]
      description: |
        Stav-managed tool that runs on sovereign infrastructure. The
        model issues a call; Stav executes it and feeds the result back
        into the conversation transparently.
      properties:
        type:
          type: string
          enum: [web_search, code_execution, file_search, url_fetch, document_ocr]
          description: |
            * `web_search` — Qwant-backed sovereign web search.
            * `code_execution` — Firecracker-sandboxed code runner (Python, Node).
            * `file_search` — Qdrant-backed retrieval over files you've uploaded.
            * `url_fetch` — Fetch and parse a URL.
            * `document_ocr` — Extract text from a PDF or image.

    ToolChoiceFunction:
      type: object
      required: [type, function]
      properties:
        type: { type: string, const: function }
        function:
          type: object
          required: [name]
          properties:
            name: { type: string }

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

    ResponseFormatText:
      type: object
      required: [type]
      properties:
        type: { type: string, const: text }

    ResponseFormatJsonObject:
      type: object
      required: [type]
      properties:
        type: { type: string, const: json_object }

    ResponseFormatJsonSchema:
      type: object
      required: [type, json_schema]
      properties:
        type: { type: string, const: json_schema }
        json_schema:
          type: object
          required: [name, schema]
          properties:
            name: { type: string }
            description: { type: string }
            strict: { type: boolean, default: false }
            schema: { description: A JSON Schema 2020-12 document. }

    ChatCompletionResponse:
      type: object
      required: [id, object, created, model, choices]
      properties:
        id: { type: string, example: "chatcmpl-abc123..." }
        object: { type: string, const: chat.completion }
        created: { type: integer, format: int64, description: Unix timestamp (seconds). }
        model: { type: string, description: The model id actually used (may differ from the request if `model="auto"` or a tier suffix was resolved). }
        choices:
          type: array
          items:
            type: object
            required: [index, message, finish_reason]
            properties:
              index: { type: integer }
              message: { $ref: '#/components/schemas/ChatMessage' }
              finish_reason: { type: string, enum: [stop, length, tool_calls, content_filter, function_call] }
              logprobs: { type: [object, 'null'] }
        usage: { $ref: '#/components/schemas/Usage' }
        routing_metadata:
          $ref: '#/components/schemas/RoutingMetadata'

    Usage:
      type: object
      properties:
        prompt_tokens: { type: integer }
        completion_tokens: { type: integer }
        total_tokens: { type: integer }
        cache_read_input_tokens:
          type: integer
          description: Tokens served from prompt cache (90% discount applied). Present when `cache_control` markers are used.
        cache_creation_input_tokens:
          type: integer

    RoutingMetadata:
      type: object
      description: |
        Present when the request used Smart Router (`model="auto"`) or
        when sovereignty-relevant routing happened. Lets you log what
        Stav actually picked and why.
      properties:
        selected_model: { type: string }
        sovereignty_tier:
          type: string
          enum: [sovereign, routed-commercial, byo]
        reason: { type: string, description: "Human-readable selection reason (e.g., 'complexity=high, code=true, sensitive=false → llama-4-maverick:precision')." }
        classifier_version: { type: string, description: Smart Router classifier version (DeBERTa head id). }

    # ─── Messages (Anthropic-compatible) ───────────────────────────
    MessagesRequest:
      type: object
      required: [model, max_tokens, messages]
      properties:
        model: { type: string, examples: [claude-sonnet-4-5, claude-opus-4-6, qwen3-32b] }
        max_tokens: { type: integer, minimum: 1 }
        messages:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/AnthropicMessage' }
        system:
          oneOf:
            - { type: string }
            - { type: array, items: { $ref: '#/components/schemas/AnthropicContentBlock' } }
        temperature: { type: number, minimum: 0, maximum: 1 }
        top_p: { type: number, minimum: 0, maximum: 1 }
        top_k: { type: integer, minimum: 0 }
        stream: { type: boolean, default: false }
        stop_sequences: { type: array, items: { type: string } }
        tools:
          type: array
          items: { $ref: '#/components/schemas/AnthropicTool' }
        tool_choice:
          oneOf:
            - { type: object, properties: { type: { type: string, const: auto } } }
            - { type: object, properties: { type: { type: string, const: any } } }
            - { type: object, required: [type, name], properties: { type: { type: string, const: tool }, name: { type: string } } }
        thinking:
          type: object
          description: Extended thinking config (Anthropic models only).
          properties:
            type: { type: string, enum: [enabled, disabled] }
            budget_tokens: { type: integer }
        metadata:
          type: object
          properties:
            user_id: { type: string }

    AnthropicMessage:
      type: object
      required: [role, content]
      properties:
        role: { type: string, enum: [user, assistant] }
        content:
          oneOf:
            - { type: string }
            - { type: array, items: { $ref: '#/components/schemas/AnthropicContentBlock' } }

    AnthropicContentBlock:
      oneOf:
        - type: object
          required: [type, text]
          properties:
            type: { type: string, const: text }
            text: { type: string }
            cache_control:
              type: object
              properties:
                type: { type: string, const: ephemeral }
        - type: object
          required: [type, source]
          properties:
            type: { type: string, const: image }
            source:
              type: object
              required: [type, media_type, data]
              properties:
                type: { type: string, enum: [base64, url] }
                media_type: { type: string, examples: [image/jpeg, image/png, image/webp, image/gif] }
                data: { type: string }
        - type: object
          required: [type, id, name, input]
          properties:
            type: { type: string, const: tool_use }
            id: { type: string }
            name: { type: string }
            input: { description: JSON object — the tool call arguments. }
        - type: object
          required: [type, tool_use_id, content]
          properties:
            type: { type: string, const: tool_result }
            tool_use_id: { type: string }
            content:
              oneOf:
                - { type: string }
                - { type: array, items: { $ref: '#/components/schemas/AnthropicContentBlock' } }
            is_error: { type: boolean, default: false }

    AnthropicTool:
      type: object
      required: [name, input_schema]
      properties:
        name: { type: string }
        description: { type: string }
        input_schema: { description: JSON Schema 2020-12 document for the tool's input. }
        cache_control:
          type: object
          properties:
            type: { type: string, const: ephemeral }

    MessagesResponse:
      type: object
      required: [id, type, role, content, model, stop_reason, usage]
      properties:
        id: { type: string, example: msg_01XYZ... }
        type: { type: string, const: message }
        role: { type: string, const: assistant }
        content:
          type: array
          items: { $ref: '#/components/schemas/AnthropicContentBlock' }
        model: { type: string }
        stop_reason:
          type: string
          enum: [end_turn, max_tokens, stop_sequence, tool_use]
        stop_sequence: { type: [string, 'null'] }
        usage:
          type: object
          properties:
            input_tokens: { type: integer }
            output_tokens: { type: integer }
            cache_creation_input_tokens: { type: integer }
            cache_read_input_tokens: { type: integer }

    # ─── Embeddings ────────────────────────────────────────────────
    EmbeddingRequest:
      type: object
      required: [model, input]
      properties:
        model: { type: string, examples: [bge-m3, jina-embeddings-v3, mxbai-embed-large] }
        input:
          oneOf:
            - { type: string }
            - { type: array, items: { type: string }, maxItems: 2048 }
        encoding_format: { type: string, enum: [float, base64], default: float }
        dimensions: { type: integer, description: "Truncate to this many dimensions (Matryoshka-style). Only honoured by models that support truncation." }
        user: { type: string }

    EmbeddingResponse:
      type: object
      required: [object, data, model, usage]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items:
            type: object
            required: [object, index, embedding]
            properties:
              object: { type: string, const: embedding }
              index: { type: integer }
              embedding:
                oneOf:
                  - { type: array, items: { type: number } }
                  - { type: string, description: Base64-encoded float32 array when `encoding_format=base64`. }
        model: { type: string }
        usage:
          type: object
          properties:
            prompt_tokens: { type: integer }
            total_tokens: { type: integer }

    # ─── Rerank ────────────────────────────────────────────────────
    RerankRequest:
      type: object
      required: [model, query, documents]
      properties:
        model: { type: string, examples: [bge-reranker-v2-m3, jina-reranker-v2] }
        query: { type: string, description: The search query to score documents against. }
        documents:
          type: array
          items: { type: string }
          minItems: 1
          description: Documents to score and reorder by relevance to the query.
        top_n: { type: integer, minimum: 1, description: Return only the top-N most relevant documents. }
        return_documents: { type: boolean, default: false, description: Echo each document's text back in the results. }

    RerankResponse:
      type: object
      required: [object, model, results, usage]
      properties:
        object: { type: string, const: list }
        model: { type: string }
        results:
          type: array
          items:
            type: object
            required: [index, relevance_score]
            properties:
              index: { type: integer, description: Index of the document in the original input list. }
              relevance_score: { type: number, description: Higher = more relevant. }
              document:
                type: [object, 'null']
                properties: { text: { type: string } }
                description: Present only when `return_documents=true`.
        usage:
          type: object
          properties:
            total_tokens: { type: integer }

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

    Model:
      type: object
      required: [id, object, created, owned_by]
      properties:
        id: { type: string, example: qwen3-32b }
        object: { type: string, const: model }
        created: { type: integer, format: int64 }
        owned_by: { type: string, example: stav-sovereign }
        sovereignty_tier:
          type: string
          enum: [sovereign, routed-commercial, byo]
        context_window: { type: integer, description: Max context tokens. }
        supports_tools: { type: boolean }
        supports_vision: { type: boolean }
        supports_thinking: { type: boolean }
        available_tiers:
          type: array
          items: { type: string, enum: [fast, precision] }
          description: Quantization tiers available via `:fast` / `:precision` suffix.

    # ─── Catalogue ─────────────────────────────────────────────────
    CatalogueModelList:
      type: object
      required: [items, total, page, page_size]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CatalogueModelSummary' }
        total: { type: integer }
        page: { type: integer }
        page_size: { type: integer }

    CatalogueModelSummary:
      type: object
      required: [slug, name, creator_slug, category]
      properties:
        slug: { type: string }
        name: { type: string }
        creator_slug: { type: string }
        creator_name: { type: string }
        family_slug: { type: [string, 'null'] }
        category: { type: string, enum: [llm, code, embedding, vision, speech, multimodal] }
        sovereignty_tier: { type: string, enum: [sovereign, routed-commercial, byo] }
        is_open_weights: { type: boolean }
        parameter_count: { type: [integer, 'null'] }
        context_window: { type: [integer, 'null'] }
        quality_index: { type: [number, 'null'] }
        arena_elo: { type: [integer, 'null'] }
        featured: { type: boolean }
        price_per_million_input_tokens_eur: { type: [number, 'null'] }
        price_per_million_output_tokens_eur: { type: [number, 'null'] }

    CatalogueModelDetail:
      allOf:
        - $ref: '#/components/schemas/CatalogueModelSummary'
        - type: object
          properties:
            description: { type: string }
            curated_description: { type: [string, 'null'] }
            capabilities:
              type: array
              items: { type: string }
            available_tiers:
              type: array
              items: { type: string, enum: [fast, precision] }
            benchmarks:
              type: array
              items:
                type: object
                properties:
                  slug: { type: string }
                  name: { type: string }
                  score: { type: number }
                  measured_at: { type: string, format: date-time }
            release_date: { type: [string, 'null'], format: date }

    FamilyList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Family' }

    Family:
      type: object
      required: [slug, name, creator_slug]
      properties:
        slug: { type: string }
        name: { type: string }
        creator_slug: { type: string }
        description: { type: string }
        model_count: { type: integer }

    CreatorList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Creator' }

    Creator:
      type: object
      required: [slug, name]
      properties:
        slug: { type: string }
        name: { type: string }
        country: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        logo_url: { type: [string, 'null'] }
        model_count: { type: integer }

    ProviderList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Provider' }

    Provider:
      type: object
      required: [slug, name, sovereignty]
      properties:
        slug: { type: string }
        name: { type: string }
        sovereignty: { type: string, enum: [sovereign, eu_hosted, non_eu] }
        jurisdiction: { type: [string, 'null'], enum: [eea, eu, us, cn, other, null] }
        cloud_act_exposure: { type: boolean }
        data_residency: { type: [string, 'null'], enum: [eea, eu, global, unknown, null] }
        country: { type: [string, 'null'] }
        country_name: { type: [string, 'null'] }
        region: { type: [string, 'null'] }
        endpoint_count: { type: [integer, 'null'] }
        is_first_party: { type: [boolean, 'null'] }

    ProviderCertification:
      type: object
      required: [name, status]
      properties:
        name: { type: string, example: ISO 27001 }
        scope: { type: [string, 'null'] }
        status: { type: string, enum: [current, expired, in_progress, not_held] }
        source_url: { type: [string, 'null'] }
        verified_at: { type: [string, 'null'], description: 'ISO date (YYYY-MM-DD)' }

    ProviderDetail:
      type: object
      required: [provider, endpoints]
      description: >
        Full provider detail with its Trust Profile (the provider's own
        published legal documents + certifications, curated and
        provenance-first) and the list of endpoints it serves.
      properties:
        provider:
          type: object
          properties:
            slug: { type: string }
            name: { type: string }
            sovereignty: { type: string, enum: [sovereign, eu_hosted, non_eu] }
            jurisdiction: { type: [string, 'null'] }
            cloud_act_exposure: { type: boolean }
            data_residency: { type: [string, 'null'] }
            country: { type: [string, 'null'] }
            country_name: { type: [string, 'null'] }
            region: { type: [string, 'null'] }
            description: { type: [string, 'null'] }
            website_url: { type: [string, 'null'] }
            endpoint_count: { type: integer }
            terms_url: { type: [string, 'null'] }
            privacy_policy_url: { type: [string, 'null'] }
            dpa_url: { type: [string, 'null'] }
            subprocessors_url: { type: [string, 'null'] }
            certifications:
              type: [array, 'null']
              items: { $ref: '#/components/schemas/ProviderCertification' }
            trust_profile_verified_at: { type: [string, 'null'] }
            first_party_creator:
              type: [object, 'null']
              properties:
                slug: { type: string }
                name: { type: string }
        compliance:
          type: [object, 'null']
          description: >
            Stav's serving-side compliance assessment of the operator —
            Stav's own judgement (score, verdict, dimension breakdown,
            severity-banded risk items, safeguards, privacy-policy issues,
            and the assessment narrative). Distinct from the provider's
            own Trust Profile facts above. Null until assessed. Exposed on
            the detail endpoint only — the providers index omits it.
          properties:
            score:
              type: [integer, 'null']
              description: Headline 0–100 serving-side composite.
            verdict:
              type: [string, 'null']
              enum: [trusted, conditional, elevated_risk, high_risk, null]
            dimensions:
              type: array
              description: >
                Five serving-side dimensions — serving_residency,
                legal_exposure, serving_retention, security_posture,
                contractual_posture.
              items:
                type: object
                properties:
                  slug: { type: string }
                  label: { type: string }
                  score: { type: integer }
                  note: { type: [string, 'null'] }
            risk_assessment:
              type: [object, 'null']
              description: '{overall_risk, signal_count, categories[]}'
            safeguards:
              type: [array, 'null']
              items: { type: object }
            privacy_policy_issues:
              type: [array, 'null']
              items: { type: object }
            body:
              type: [string, 'null']
              description: Stav's serving-side assessment narrative.
            verified_at:
              type: [string, 'null']
              description: Last human sign-off of the assessment.
        endpoints:
          type: array
          items:
            type: object
            properties:
              model_slug: { type: [string, 'null'] }
              inference_tier: { type: [string, 'null'] }
        endpoint_count: { type: integer }

    SovereigntyList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            type: object
            required: [tier, label, description]
            properties:
              tier: { type: string, enum: [sovereign, routed-commercial, byo] }
              label: { type: string }
              description: { type: string }
              data_residency: { type: string, example: EU (Norway) }
              cloud_act_exposed: { type: boolean }

    CapabilityList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            type: object
            required: [slug, name]
            properties:
              slug: { type: string }
              name: { type: string }
              description: { type: string }

# External docs link surfaces at the renderer top.
externalDocs:
  description: Narrative documentation — quickstart, concepts, recipes
  url: https://stav.ai/docs
