openapi: 3.1.0

# ─────────────────────────────────────────────────────────────
# Canonical API contract. Lives inside the handler package so it is
# embedded into the binary (see system.go) — the deployed API can never
# serve a spec that disagrees with its own build. `make gen-sdk` reads
# this file to produce the typed clients under /sdk.
# ─────────────────────────────────────────────────────────────

info:
  title: MeasyAI API
  version: "2.0.0"
  summary: Self-hosted, privacy-first AI chat and completions.
  description: |
    Two surfaces share one host:

    * **`/v1/*`** — the public developer API. Request and response shapes
      mirror OpenAI's, so an existing OpenAI SDK works by changing only the
      base URL and the key:

      ```python
      from openai import OpenAI
      client = OpenAI(api_key="msy_...", base_url="https://api.measyai.com/v1")
      ```

    * **everything else** — the endpoints the web app uses. Authenticated
      with the `.measyai.com` session cookie rather than a bearer token.

    ## Privacy

    Prompts are never used for training. The guarantee is enforced in the
    upstream request itself (`provider.data_collection: "deny"`), not just
    in policy — see `internal/stream/body.go`.

    ## Errors

    Every failure returns the same envelope, shaped like OpenAI's so SDK
    error handling works unchanged.
  license:
    name: MIT
  contact:
    name: MeasyAI
    url: https://measyai.com

servers:
  - url: https://api.measyai.com
    description: Production
  - url: http://localhost:8080
    description: Local development

tags:
  - name: Chat
    description: OpenAI-compatible completions.
  - name: Models
    description: Available models.
  - name: Batches
    description: Asynchronous bulk completions.
  - name: Auth
    description: Registration, login, session and profile (web app).
  - name: Projects
    description: Workspaces — the unit of data isolation.
  - name: API keys
    description: Minting and revoking developer credentials.
  - name: Conversations
    description: Persistent threads (web app).
  - name: Analytics
    description: Usage, latency and cost.
  - name: Webhooks
    description: Signed event delivery.
  - name: Privacy
    description: GDPR data export and erasure.
  - name: System
    description: Health and the spec itself.

security:
  - ApiKeyAuth: []

paths:
  # ── public developer API ───────────────────────────────────
  /v1/chat/completions:
    post:
      tags: [Chat]
      operationId: createChatCompletion
      summary: Create a chat completion
      description: |
        Set `stream: true` for a `text/event-stream` response of
        `chat.completion.chunk` objects terminated by `data: [DONE]` —
        the format OpenAI SDKs already parse.

        Fields not listed below (`temperature`, `top_p`, `tools`,
        `tool_choice`, `response_format`, …) are forwarded to the upstream
        provider unchanged, which is how tool calling passes through.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatCompletionRequest"
            examples:
              simple:
                summary: Single-turn
                value:
                  model: anthropic/claude-sonnet-4.5
                  messages:
                    - role: user
                      content: Explain HTTP/3 in two sentences.
              streaming:
                summary: Streamed
                value:
                  model: anthropic/claude-sonnet-4.5
                  stream: true
                  messages:
                    - role: system
                      content: You are terse.
                    - role: user
                      content: Why is Postgres MVCC useful?
      responses:
        "200":
          description: A completion, or an SSE stream when `stream` is true.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatCompletionResponse"
            text/event-stream:
              schema:
                type: string
                description: "`data:`-prefixed ChatCompletionChunk objects, ending with `data: [DONE]`."
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /v1/models:
    get:
      tags: [Models]
      operationId: listModels
      summary: List available models
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: The model catalogue.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/batches:
    get:
      tags: [Batches]
      operationId: listBatches
      summary: List batches
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: Batches, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Batch" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Batches]
      operationId: createBatch
      summary: Create a batch
      description: |
        Queues up to 1000 completions. Returns immediately; poll
        `GET /v1/batches/{batchId}` for progress, or subscribe to the
        `batch.completed` webhook.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateBatchRequest" }
      responses:
        "201":
          description: The queued batch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Batch" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/batches/{batchId}:
    parameters:
      - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Batches]
      operationId: getBatch
      summary: Retrieve a batch and its items
      responses:
        "200":
          description: The batch with per-item results.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchWithItems" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── auth (web app) ─────────────────────────────────────────
  /auth/register:
    post:
      tags: [Auth]
      operationId: register
      summary: Create an account
      description: Also creates the account's first project. Sets the session cookie.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterRequest" }
      responses:
        "201":
          description: Registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/login:
    post:
      tags: [Auth]
      operationId: login
      summary: Sign in
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
      responses:
        "200":
          description: Signed in.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/logout:
    post:
      tags: [Auth]
      operationId: logout
      summary: Sign out
      security:
        - SessionCookie: []
      responses:
        "204": { description: Signed out. }

  /auth/me:
    get:
      tags: [Auth]
      operationId: getCurrentUser
      summary: The signed-in user
      security:
        - SessionCookie: []
      responses:
        "200":
          description: The current user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Auth]
      operationId: updateProfile
      summary: Update the profile
      security:
        - SessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [display_name]
              properties:
                display_name: { type: string, maxLength: 80 }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /auth/password:
    post:
      tags: [Auth]
      operationId: changePassword
      summary: Change the password
      description: Revokes every session, including the caller's.
      security:
        - SessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string, format: password }
                new_password: { type: string, format: password, minLength: 10 }
      responses:
        "204": { description: Changed; sign in again. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── projects ───────────────────────────────────────────────
  /projects:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: List projects
      security:
        - SessionCookie: []
      responses:
        "200":
          description: Projects the user belongs to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  projects:
                    type: array
                    items: { $ref: "#/components/schemas/Project" }
    post:
      tags: [Projects]
      operationId: createProject
      summary: Create a project
      security:
        - SessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
                slug:
                  type: string
                  pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
                  description: Derived from the name when omitted.
                system_prompt: { type: string }
                default_model: { type: string }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Projects]
      operationId: getProject
      summary: Retrieve a project
      responses:
        "200":
          description: The project.
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Projects]
      operationId: updateProject
      summary: Update a project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                system_prompt: { type: string }
                default_model: { type: string }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
    delete:
      tags: [Projects]
      operationId: deleteProject
      summary: Delete a project
      description: Soft delete. Refused when it is the account's last project.
      security:
        - SessionCookie: []
      responses:
        "204": { description: Deleted. }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── api keys ───────────────────────────────────────────────
  /projects/{projectId}/api-keys:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [API keys]
      operationId: listApiKeys
      summary: List API keys
      security:
        - SessionCookie: []
      responses:
        "200":
          description: Keys, including revoked ones.
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
    post:
      tags: [API keys]
      operationId: createApiKey
      summary: Create an API key
      description: |
        The `key` field in the response is the **only** time the plaintext
        value exists after this call — only a SHA-256 digest is stored.
      security:
        - SessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 60 }
                scopes:
                  type: array
                  items: { $ref: "#/components/schemas/Scope" }
                  default: [chat:read, chat:write]
      responses:
        "201":
          description: Created — store the key now.
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_key:
                    allOf:
                      - $ref: "#/components/schemas/ApiKey"
                      - type: object
                        properties:
                          key:
                            type: string
                            example: msy_R8kQ2m…
                            description: Shown once, never again.

  /projects/{projectId}/api-keys/{keyId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: keyId, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [API keys]
      operationId: revokeApiKey
      summary: Revoke an API key
      security:
        - SessionCookie: []
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_key: { $ref: "#/components/schemas/ApiKey" }

  # ── conversations ──────────────────────────────────────────
  /projects/{projectId}/conversations:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Conversations]
      operationId: listConversations
      summary: List conversations
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: Conversations, most recently updated first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations:
                    type: array
                    items: { $ref: "#/components/schemas/Conversation" }
    post:
      tags: [Conversations]
      operationId: createConversation
      summary: Create a conversation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                model: { type: string }
                system_prompt: { type: string }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation: { $ref: "#/components/schemas/Conversation" }

  /conversations/{conversationId}:
    parameters:
      - { name: conversationId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Conversations]
      operationId: getConversation
      summary: Retrieve a conversation with its messages
      responses:
        "200":
          description: The conversation and its full message history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation: { $ref: "#/components/schemas/Conversation" }
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/Message" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Conversations]
      operationId: updateConversation
      summary: Rename or reconfigure a conversation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                model: { type: string }
                system_prompt: { type: string }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation: { $ref: "#/components/schemas/Conversation" }
    delete:
      tags: [Conversations]
      operationId: deleteConversation
      summary: Delete a conversation
      responses:
        "204": { description: Deleted. }

  /conversations/{conversationId}/messages:
    parameters:
      - { name: conversationId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Conversations]
      operationId: sendMessage
      summary: Post a message and start generating
      description: |
        Returns `202` immediately. The reply is **not** in this response —
        it streams over `wss://api.measyai.com/ws?conversation_id=…`.
        Both message ids come back so the client can render the user's
        bubble and an empty assistant bubble right away.

        Set `parent_message_id` to rewind first:
        * with `content` — edit-and-resend (replaces that message onward)
        * without `content` — regenerate (keeps it, drops what followed)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content: { type: string, maxLength: 262144 }
                model:
                  type: string
                  description: Overrides the conversation's model for this turn.
                parent_message_id: { type: string, format: uuid }
      responses:
        "202":
          description: Accepted; watch the WebSocket for tokens.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendMessageResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /conversations/{conversationId}/stop:
    parameters:
      - { name: conversationId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Conversations]
      operationId: stopGeneration
      summary: Stop the in-flight generation
      description: Broadcast through Redis, so it works across API nodes.
      responses:
        "204": { description: Stop signalled. }

  /conversations/{conversationId}/stream-state:
    parameters:
      - { name: conversationId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Conversations]
      operationId: getStreamState
      summary: Replay an in-flight generation
      description: |
        Returns what a running generation has produced so far, so a client
        reconnecting mid-stream catches up instead of waiting for the next
        token. `null` when nothing is buffered.
      responses:
        "200":
          description: The buffered partial reply, if any.
          content:
            application/json:
              schema:
                type: object
                properties:
                  snapshot:
                    oneOf:
                      - $ref: "#/components/schemas/StreamSnapshot"
                      - type: "null"

  /models:
    get:
      tags: [Models]
      operationId: listModelsWeb
      summary: List models with pricing
      description: As `/v1/models`, plus per-million-token costs for the dashboard.
      responses:
        "200":
          description: Models with pricing.
          content:
            application/json:
              schema:
                type: object
                properties:
                  models:
                    type: array
                    items: { $ref: "#/components/schemas/ModelInfo" }

  # ── analytics ──────────────────────────────────────────────
  /projects/{projectId}/analytics:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Analytics]
      operationId: getAnalytics
      summary: Usage, latency and cost
      description: |
        Percentiles are computed over raw events, so the window is capped at
        90 days. Empty buckets are returned as zeroes rather than omitted,
        so charts do not interpolate across idle periods.
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - name: bucket
          in: query
          schema: { type: string, enum: [hour, day] }
          description: Defaults to `hour` for ranges under 3 days, else `day`.
      responses:
        "200":
          description: The analytics payload.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── webhooks ───────────────────────────────────────────────
  /projects/{projectId}/webhooks:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhooks
      security:
        - SessionCookie: []
      responses:
        "200":
          description: Registered webhooks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a webhook
      description: |
        The response contains `secret` — the HMAC-SHA256 signing key, shown
        once. Verify deliveries as described under `WebhookDelivery`.

        Targets must be publicly routable: loopback, private, link-local and
        carrier-grade-NAT addresses are rejected, and the check is repeated
        at dial time to close DNS-rebinding.
      security:
        - SessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                description: { type: string }
                events:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        "201":
          description: Registered — store the secret now.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook:
                    allOf:
                      - $ref: "#/components/schemas/Webhook"
                      - type: object
                        properties:
                          secret: { type: string, example: whsec_… }
        "400": { $ref: "#/components/responses/BadRequest" }

  /projects/{projectId}/webhooks/{webhookId}:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: webhookId, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update a webhook
      security:
        - SessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                description: { type: string }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/WebhookEvent" }
                active: { type: boolean }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook: { $ref: "#/components/schemas/Webhook" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook
      security:
        - SessionCookie: []
      responses:
        "204": { description: Deleted. }

  /projects/{projectId}/webhook-deliveries:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Delivery log
      security:
        - SessionCookie: []
      parameters:
        - { name: webhook_id, in: query, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: Deliveries, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }

  /projects/{projectId}/webhook-deliveries/{deliveryId}/replay:
    parameters:
      - { name: projectId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: deliveryId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Webhooks]
      operationId: replayWebhookDelivery
      summary: Replay a delivery
      description: Re-queues the original payload, so the signature covers the same bytes.
      security:
        - SessionCookie: []
      responses:
        "202":
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }

  # ── privacy ────────────────────────────────────────────────
  /privacy/exports:
    get:
      tags: [Privacy]
      operationId: listExports
      summary: List data exports
      security:
        - SessionCookie: []
      responses:
        "200":
          description: Export jobs, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items: { $ref: "#/components/schemas/ExportJob" }
    post:
      tags: [Privacy]
      operationId: requestExport
      summary: Request a data export
      description: GDPR Art. 20. Built asynchronously; poll until `status` is `ready`.
      security:
        - SessionCookie: []
      responses:
        "202":
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/ExportJob" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /privacy/exports/{jobId}/download:
    parameters:
      - { name: jobId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Privacy]
      operationId: downloadExport
      summary: Download a finished export
      security:
        - SessionCookie: []
      responses:
        "200":
          description: A ZIP archive.
          content:
            application/zip:
              schema: { type: string, format: binary }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The export is not ready yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /privacy/deletion:
    get:
      tags: [Privacy]
      operationId: getDeletionStatus
      summary: Pending deletion, if any
      security:
        - SessionCookie: []
      responses:
        "200":
          description: The pending deletion, or null.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deletion:
                    oneOf:
                      - $ref: "#/components/schemas/AccountDeletion"
                      - type: "null"
    post:
      tags: [Privacy]
      operationId: requestDeletion
      summary: Request account deletion
      description: |
        GDPR Art. 17. The account is disabled immediately and hard-purged
        after the grace period. Sign-in still works during that window, but
        only to cancel the deletion.
      security:
        - SessionCookie: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        "202":
          description: Scheduled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deletion: { $ref: "#/components/schemas/AccountDeletion" }
    delete:
      tags: [Privacy]
      operationId: cancelDeletion
      summary: Cancel a pending deletion
      security:
        - SessionCookie: []
      responses:
        "204": { description: Cancelled; the account is active again. }

  # ── system ─────────────────────────────────────────────────
  /health:
    get:
      tags: [System]
      operationId: health
      summary: Liveness
      security: []
      responses:
        "200":
          description: The process is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  uptime_sec: { type: integer }

  /ready:
    get:
      tags: [System]
      operationId: ready
      summary: Readiness
      description: Verifies Postgres and Redis.
      security: []
      responses:
        "200": { description: Ready to serve. }
        "503": { description: A dependency is unavailable. }

  /openapi.yaml:
    get:
      tags: [System]
      operationId: getOpenApiSpec
      summary: This document
      security: []
      responses:
        "200":
          description: The spec.
          content:
            application/yaml:
              schema: { type: string }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer msy_…`

        Mint keys in the dashboard. Scopes are enforced per endpoint:
        `chat:read`, `chat:write`, `analytics:read`.
    SessionCookie:
      type: apiKey
      in: cookie
      name: measyai_session
      description: |
        Set by `/auth/login` and `/auth/register` on `.measyai.com`, so it is
        valid on both `measyai.com` and `api.measyai.com`. Browser clients
        must send requests with `credentials: "include"`.

  responses:
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Authenticated, but not permitted (often a missing scope).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource, or it is not visible to this caller.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: The request conflicts with existing state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait.
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unavailable:
      description: An upstream dependency is unavailable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable, machine-readable identifier.
              example: insufficient_scope
            message:
              type: string
              description: Safe to show a user; never contains internals.
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - conflict_error
                - rate_limit_error
                - api_error
            param:
              type: string
              description: The offending field, for validation failures.
        request_id:
          type: string
          description: Echoed as `X-Request-ID`; quote it in bug reports.

    Scope:
      type: string
      enum: [chat:read, chat:write, analytics:read]

    ChatMessage:
      type: object
      required: [role]
      properties:
        role:
          type: string
          enum: [system, user, assistant, tool]
        content:
          description: A string, or an array of content parts.
          oneOf:
            - type: string
            - type: array
              items: { type: object }
        name: { type: string }
        tool_calls:
          type: array
          items: { type: object }
        tool_call_id: { type: string }

    ChatCompletionRequest:
      type: object
      required: [messages]
      additionalProperties: true
      description: Unlisted properties are forwarded to the provider unchanged.
      properties:
        model:
          type: string
          description: Defaults to the server's configured model.
          example: anthropic/claude-sonnet-4.5
        messages:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/ChatMessage" }
        stream:
          type: boolean
          default: false

    Usage:
      type: object
      properties:
        prompt_tokens: { type: integer }
        completion_tokens: { type: integer }
        total_tokens: { type: integer }

    ChatCompletionResponse:
      type: object
      properties:
        id: { type: string, example: chatcmpl-… }
        object: { type: string, const: chat.completion }
        created: { type: integer, description: Unix seconds. }
        model: { type: string }
        choices:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              message: { $ref: "#/components/schemas/ChatMessage" }
              finish_reason: { type: string }
        usage: { $ref: "#/components/schemas/Usage" }

    ChatCompletionChunk:
      type: object
      description: One SSE frame when `stream` is true.
      properties:
        id: { type: string }
        object: { type: string, const: chat.completion.chunk }
        created: { type: integer }
        model: { type: string }
        choices:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              delta:
                type: object
                properties:
                  role: { type: string }
                  content: { type: string }
                  tool_calls:
                    type: array
                    items: { type: object }
              finish_reason:
                oneOf:
                  - type: string
                  - type: "null"
        usage: { $ref: "#/components/schemas/Usage" }

    ModelList:
      type: object
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              object: { type: string, const: model }
              created: { type: integer }
              owned_by: { type: string }
              name: { type: string }
              context_length: { type: integer }

    ModelInfo:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        context_length: { type: integer }
        prompt_cost_micros:
          type: integer
          format: int64
          description: Micros of USD per million prompt tokens.
        completion_cost_micros:
          type: integer
          format: int64
          description: Micros of USD per million completion tokens.

    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        display_name: { type: string }
        email_verified: { type: boolean }
        created_at: { type: string, format: date-time }

    AuthResponse:
      type: object
      properties:
        user: { $ref: "#/components/schemas/User" }

    RegisterRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email, maxLength: 254 }
        password: { type: string, format: password, minLength: 10, maxLength: 256 }
        display_name: { type: string, maxLength: 80 }
        turnstile_token:
          type: string
          description: Cloudflare Turnstile token. Required unless disabled server-side.

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password }
        turnstile_token: { type: string }

    Project:
      type: object
      properties:
        id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        system_prompt:
          type: string
          description: Inherited by new conversations that do not set their own.
        default_model: { type: string }
        role:
          type: string
          enum: [owner, admin, member]
          description: The requesting user's role, when resolved via membership.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ApiKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        name: { type: string }
        key_prefix:
          type: string
          description: First characters only — enough to identify, useless to use.
          example: msy_R8kQ2m
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        last_used_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time }

    Conversation:
      type: object
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        title: { type: string }
        model: { type: string }
        system_prompt: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Message:
      type: object
      properties:
        id: { type: string, format: uuid }
        conversation_id: { type: string, format: uuid }
        role:
          type: string
          enum: [system, user, assistant, tool]
        content: { type: string }
        model: { type: string }
        token_count: { type: integer }
        finish_reason: { type: string }
        error:
          type: string
          description: Set when generation failed; `content` may be partial.
        created_at: { type: string, format: date-time }

    SendMessageResponse:
      type: object
      properties:
        user_message:
          description: Absent on regenerate, which reuses the existing turn.
          oneOf:
            - $ref: "#/components/schemas/Message"
            - type: "null"
        assistant_message:
          allOf:
            - $ref: "#/components/schemas/Message"
          description: Created empty; filled in as generation completes.
        stream_id:
          type: string
          format: uuid
          description: The assistant message id that incoming tokens belong to.

    StreamSnapshot:
      type: object
      properties:
        message_id: { type: string, format: uuid }
        content: { type: string, description: Everything generated so far. }
        seq: { type: integer, description: Last token sequence number. }
        active: { type: boolean, description: False once generation finished. }

    AnalyticsPoint:
      type: object
      properties:
        bucket: { type: string, format: date-time }
        requests: { type: integer }
        errors: { type: integer }
        prompt_tokens: { type: integer }
        completion_tokens: { type: integer }
        cost_micros: { type: integer, format: int64 }
        latency_p50_ms: { type: integer }
        latency_p95_ms: { type: integer }
        first_token_p50_ms: { type: integer }
        first_token_p95_ms: { type: integer }

    AnalyticsResponse:
      type: object
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        bucket: { type: string, enum: [hour, day] }
        totals:
          type: object
          properties:
            requests: { type: integer }
            errors: { type: integer }
            error_rate: { type: number, format: double }
            prompt_tokens: { type: integer }
            completion_tokens: { type: integer }
            total_tokens: { type: integer }
            cost_micros: { type: integer, format: int64 }
            latency_p50_ms: { type: integer }
            latency_p95_ms: { type: integer }
            first_token_p50_ms: { type: integer }
            first_token_p95_ms: { type: integer }
        series:
          type: array
          items: { $ref: "#/components/schemas/AnalyticsPoint" }
        by_model:
          type: array
          items:
            type: object
            properties:
              model: { type: string }
              requests: { type: integer }
              errors: { type: integer }
              prompt_tokens: { type: integer }
              completion_tokens: { type: integer }
              cost_micros: { type: integer, format: int64 }

    WebhookEvent:
      type: string
      enum:
        - message.completed
        - message.failed
        - conversation.created
        - batch.completed
        - api_key.created
        - api_key.revoked

    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        url: { type: string, format: uri }
        description: { type: string }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }
        active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WebhookDelivery:
      type: object
      description: |
        Deliveries carry these headers:

        | Header | Meaning |
        |---|---|
        | `X-MeasyAI-Signature` | `v1=<hex HMAC-SHA256>` |
        | `X-MeasyAI-Timestamp` | Unix seconds, **inside** the signed string |
        | `X-MeasyAI-Event` | Event name |
        | `X-MeasyAI-Delivery` | This delivery's id |

        Verify by computing `HMAC_SHA256(secret, "<timestamp>." + rawBody)`
        and comparing in constant time. Reject timestamps outside your
        tolerance — that is what stops a captured delivery being replayed.

        Failures retry at 30s, 2m, 10m, 30m and 1h; a non-retryable 4xx
        stops immediately.
      properties:
        id: { type: string, format: uuid }
        webhook_id: { type: string, format: uuid }
        event: { $ref: "#/components/schemas/WebhookEvent" }
        payload: { type: object }
        status: { type: string, enum: [pending, success, failed] }
        attempts: { type: integer }
        response_status: { type: integer }
        response_body: { type: string }
        error: { type: string }
        next_retry_at: { type: string, format: date-time }
        delivered_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    CreateBatchRequest:
      type: object
      required: [requests]
      properties:
        endpoint:
          type: string
          default: /v1/chat/completions
          enum: [/v1/chat/completions]
        requests:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: object
            required: [body]
            properties:
              custom_id:
                type: string
                description: Your identifier, echoed back on the result.
              body: { $ref: "#/components/schemas/ChatCompletionRequest" }

    Batch:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: batch }
        project_id: { type: string, format: uuid }
        endpoint: { type: string }
        status:
          type: string
          enum: [queued, in_progress, completed, failed, canceled]
        total: { type: integer }
        completed: { type: integer }
        failed: { type: integer }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }

    BatchWithItems:
      allOf:
        - $ref: "#/components/schemas/Batch"
        - type: object
          properties:
            items:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  custom_id: { type: string }
                  status: { type: string, enum: [queued, completed, failed] }
                  response: { $ref: "#/components/schemas/ChatCompletionResponse" }
                  error: { type: string }
                  completed_at: { type: string, format: date-time }

    ExportJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, ready, failed, expired]
        size_bytes: { type: integer, format: int64 }
        error: { type: string }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        expires_at:
          type: string
          format: date-time
          description: After this, the file is deleted and must be re-requested.

    AccountDeletion:
      type: object
      properties:
        reason: { type: string }
        requested_at: { type: string, format: date-time }
        purge_after:
          type: string
          format: date-time
          description: Cancellable until this moment; irreversible afterwards.
