openapi: 3.1.0
info:
  title: Artillect Public API
  version: "1.3.4"
  summary: Images, video, upscale, SwitchX, audio, 3D mesh (async poll) and LLM chat (sync + optional SSE)
  description: |
    # Introduction

    Artillect Public API v1 is the programmatic client of the same platform as Studio UI:
    shared users, token balance, projects, and generations.

    ## Quick start

    1. Create an API key at [/api](/api) (session) or via device flow (`POST /api/v1/device/code/`).
    2. `Authorization: Bearer art_...`
    3. `GET /api/v1/models/` for slugs and limits (source of truth).
    4. Submit async work (`images` / `videos` / `upscale` / `switchx` / `audio` / `mesh`) → poll `GET …/{id}/` or register a webhook.
    5. Chat: `POST /api/v1/chat/completions/` (sync JSON default; optional `stream:true` SSE for all chat models).

    ## Conventions

    - **Trailing slash:** canonical paths end with `/`.
    - **Idempotency-Key** on submit endpoints (24h Redis).
    - **X-Request-Id** echoed on responses.
    - Job ids: `{uuid}-api-image`, `-api-video`, `-api-upscale`, `-api-switchx`, `-api-audio`, `-api-mesh` (legacy image `{uuid}-api` still pollable).
    - Poll `status: completed` is returned only with durable Artillect media URLs (MinIO). While mirroring, status stays `running` with empty media + `hint`.
    - Human guide: [artillect-api.md](./artillect-api.md).

    Model-specific params: prefer discovery from `GET /api/v1/models/` over hardcoding enums here.
  contact:
    name: Artillect
    url: https://app.artillect.pro
  license:
    name: Proprietary

servers:
  - url: https://app.artillect.pro
    description: Production

tags:
  - name: Discovery
  - name: Account
  - name: Auth
  - name: Images
  - name: LoRA
  - name: Videos
  - name: Upscale
  - name: SwitchX
  - name: Audio
  - name: Mesh
  - name: Chat
  - name: Assets
  - name: Media
  - name: Feedback
  - name: Projects
  - name: Webhooks
  - name: Files
  - name: Agent Provider

security:
  - ApiKeyBearer: []

paths:
  /api/v1/provider/requests/:
    get:
      tags: [Agent Provider]
      operationId: getAgentProviderRequest
      summary: Read a Provider request billing outcome and safe diagnostics
      description: >
        Requires provider:write. Returns only a request belonging to the authenticated user and the
        same API key. This lookup never replays inference; Idempotency-Key remains unsupported.
        New requests use openrouter-cumulative-v2: integer USD units at 10 decimal places,
        per-user accumulation across keys, ceil(total USD * 100) charged incrementally.
        Legacy v1 requests retain per-request upward rounding. Per-request charged_tokens can be zero.
      parameters:
        - name: id
          in: query
          required: true
          schema: { type: string, pattern: "^ap-[a-f0-9-]{36}$" }
          description: Value of the X-Artillect-Request-Id response header
      responses:
        "200":
          description: Authorized request status; no prompts, outputs, image URLs or credentials
          content:
            application/json:
              schema:
                type: object
                required: [request, request_id]
                properties:
                  request_id: { type: string }
                  request:
                    type: object
                    properties:
                      id: { type: string }
                      model: { type: string }
                      state:
                        { type: string, enum: [reserved, pending, settled, failed, needs_review] }
                      held_tokens: { type: integer }
                      charged_tokens: { type: [integer, "null"] }
                      cost_usd:
                        {
                          type: [string, "null"],
                          description: Decimal upstream cost; separate from incremental integer charge,
                        }
                      billing_policy: { type: string }
                      created_at: { type: string, format: date-time }
                      updated_at: { type: string, format: date-time }
                      diagnostics:
                        {
                          type: object,
                          description: Allowlisted protocol/outcome/status/usage/latency fields,
                        }
        "400": { description: Invalid request id }
        "401": { description: Authentication required }
        "403": { description: Missing provider:write scope }
        "404": { description: Request not found for this user and key }
        "429": { description: Rate limit }
        "503": { description: Provider disabled or billing unavailable }
  /api/v1/provider/responses/:
    post:
      tags: [Agent Provider]
      operationId: createAgentProviderResponse
      summary: Stateless Responses API for Codex and compatible agents
      description: Text/image input and image-bearing function/custom tool results, SSE with native event names, store=false and no previous_response_id. Requires provider:write and the same personal-balance reservation policy as provider chat.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, input]
              properties:
                model: { type: string }
                input:
                  type: [string, array]
                  description: >
                    Stateless history. User message content and function_call_output/custom_tool_call_output
                    output arrays accept text blocks and input_image blocks (image_url plus optional detail).
                    At most 20 images total; inline raster images at most 5 MiB each, body 8 MiB.
                  items: { type: object }
                stream: { type: boolean }
                max_output_tokens:
                  { type: integer, minimum: 1, description: Defaults to the selected model maximum }
                store: { type: boolean, const: false }
                tools: { type: array, items: { type: object } }
                reasoning: { type: object }
      responses:
        "200":
          description: Native Responses JSON or SSE
          content:
            application/json: { schema: { type: object } }
            text/event-stream: { schema: { type: string } }
        "400": { description: Unsupported input or request budget }
        "401": { description: Invalid or missing API key }
        "402": { description: Insufficient balance }
        "403": { description: Missing provider:write scope }
        "429": { description: Admission limit }
        "502": { description: Upstream failure }
        "503": { description: Provider disabled or unavailable }
  /api/v1/provider/anthropic/v1/messages/:
    post:
      tags: [Agent Provider]
      operationId: createAgentProviderMessage
      summary: Native Anthropic Messages format for Claude Code
      description: Text/images, image-bearing user tool results, thinking and prompt cache blocks. Preserves anthropic-beta/version headers. Token counting endpoint is optional and not implemented. Requires provider:write; Bearer or x-api-key authentication.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, messages]
              properties:
                model: { type: string }
                messages:
                  type: array
                  description: >
                    User content arrays and user tool_result.content arrays accept image blocks
                    with source type=url (public HTTPS url) or type=base64 (media_type and data).
                    PNG/JPEG/WebP/GIF; at most 20 images total, 5 MiB inline each, body 8 MiB.
                  items: { type: object }
                system: { type: [string, array] }
                max_tokens: { type: integer, minimum: 1 }
                stream: { type: boolean }
                tools: { type: array, items: { type: object } }
                thinking: { type: object }
      responses:
        "200":
          description: Native Messages JSON or SSE
          content:
            application/json: { schema: { type: object } }
            text/event-stream: { schema: { type: string } }
        "400": { description: Unsupported input or request budget }
        "401": { description: Invalid or missing API key }
        "402": { description: Insufficient balance }
        "403": { description: Missing provider:write scope }
        "429": { description: Admission limit }
        "502": { description: Upstream failure }
        "503": { description: Provider disabled or unavailable }
  /api/v1/provider/models/:
    get:
      tags: [Agent Provider]
      operationId: getAgentProviderModels
      summary: Curated agent-development model list (separate product)
      description: Requires models:read. Available when AGENT_PROVIDER_ENABLED=1.
      responses:
        "200":
          description: OpenAI-compatible model list
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, object, created, owned_by]
                      properties:
                        id: { type: string }
                        object: { type: string, const: model }
                        created: { type: integer }
                        owned_by: { type: string, const: artillect }
                        context_length: { type: integer }
                        max_output_tokens: { type: integer }
                        capabilities:
                          type: object
                          properties:
                            tools: { type: boolean }
                            streaming: { type: boolean }
                            input: { type: array, items: { type: string, enum: [text, image] } }
                            image_tool_results: { type: boolean }
                            protocols:
                              {
                                type: array,
                                items:
                                  { type: string, enum: [chat/completions, responses, messages] },
                              }
        "401": { description: API key required or invalid (OpenAI error object) }
        "403": { description: Missing models:read scope }
        "429": { description: Rate limited }
        "503": { description: Provider product disabled }
  /api/v1/provider/chat/completions/:
    post:
      tags: [Agent Provider]
      operationId: createAgentProviderCompletion
      summary: Agent Chat Completions with tools and streaming
      description: |
        Separate OpenAI-compatible contract; does not change Studio/public chat.
        Requires provider:write. Text/image input, n=1, curated model IDs. Personal balance
        reserve, then v2 settlement charges the increase in ceil(cumulative user USD * 100),
        across all user keys, with unused reserve refunded. USD uses integer 1e-10 units
        with upward subprecision rounding. Legacy v1 rows keep per-request ceil settlement.
        Tool calls, tool results and reasoning are preserved. No arbitrary routing, paid plugins or Idempotency-Key support.
        Client disconnects are drained and billed; ambiguous upstream failures
        retain the reserve for recovery. Guide: /docs/artillect-provider.md.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, messages]
              properties:
                model: { type: string, example: openai/gpt-6-luna }
                messages:
                  type: array
                  minItems: 1
                  maxItems: 2048
                  items:
                    type: object
                    required: [role]
                    properties:
                      role: { type: string, enum: [system, developer, user, assistant, tool] }
                      content:
                        oneOf:
                          - { type: [string, "null"] }
                          - type: array
                            description: Image blocks only in user or tool messages; max 20 images per request.
                            items:
                              oneOf:
                                - type: object
                                  required: [type, text]
                                  additionalProperties: false
                                  properties:
                                    type: { const: text }
                                    text: { type: string }
                                - $ref: "#/components/schemas/AgentProviderChatImage"
                      tool_call_id: { type: string }
                      tool_calls: { type: array, items: { type: object } }
                      reasoning: { type: [string, "null"] }
                      reasoning_details: { type: array, items: { type: object } }
                stream: { type: boolean, default: false }
                stream_options:
                  type: object
                  properties:
                    include_usage: { type: boolean }
                max_tokens:
                  { type: integer, minimum: 1, description: Defaults to the selected model maximum }
                max_completion_tokens: { type: integer, minimum: 1 }
                tools: { type: array, maxItems: 128, items: { type: object } }
                tool_choice: { type: [string, object] }
                parallel_tool_calls: { type: boolean }
                reasoning: { type: object }
                reasoning_effort: { type: string }
                response_format: { type: object }
                temperature: { type: number, minimum: 0, maximum: 2 }
                top_p: { type: number, minimum: 0, maximum: 1 }
      responses:
        "200":
          description: OpenAI completion or SSE chunks, ending with data [DONE]
          headers:
            X-Artillect-Request-Id:
              { schema: { type: string }, description: Billing journal request ID }
            X-Artillect-Reserved-Tokens: { schema: { type: integer } }
            X-Artillect-Cost-Tokens:
              { schema: { type: integer }, description: Settled cost for sync replies }
            X-Artillect-Billing-State: { schema: { type: string, enum: [settled, pending] } }
          content:
            application/json:
              schema:
                type: object
                required: [id, choices]
                properties:
                  id: { type: string }
                  object: { type: string, const: chat.completion }
                  model: { type: string }
                  created: { type: integer }
                  choices: { type: array, items: { type: object } }
                  usage: { type: object }
            text/event-stream:
              schema: { type: string }
        "400":
          { description: "Invalid request, unsupported model/parameter or token budget exceeded" }
        "401": { description: API key required or invalid }
        "402": { description: Insufficient personal balance for reserve }
        "403": { description: Missing provider:write scope }
        "413": { description: Body exceeds 8 MiB }
        "429": { description: Daily spend cap or request rate limit exceeded }
        "502":
          { description: Upstream failure; ambiguous requests remain pending for billing recovery }
        "503": { description: "Product disabled, billing unavailable or model unavailable" }
  /api/v1/health/:
    get:
      tags: [Discovery]
      operationId: getHealth
      summary: Backend readiness (no auth, no provider call)
      security: []
      responses:
        "200":
          description: Generation backend ready
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
        "503":
          description: Backend or KIE key unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"

  /api/v1/models/:
    get:
      tags: [Discovery]
      operationId: listModels
      summary: Model catalog and limits (no auth)
      description: >
        Source of truth for public slugs, prompt limits, media slots, and
        model-specific parameters. `images[].max_prompt_chars` and
        `video[].max_prompt_chars` are character limits for the selected model.
        Chat entries additionally expose `context_length_tokens` and
        `provider_max_output_tokens` from OpenRouter. `max_input_chars` and
        `chat.max_total_chars` are null: input is bounded by the model token context,
        including output, without a universal character cap. LLM JSON bodies are at most
        8 MiB. `api_update` describes the latest integration changes with a stable URL.
        Responses using shared API headers advertise X-Artillect-API-Update and a Link
        with rel=changelog. Prefer discovery over hardcoding enums or limits.
      security: []
      responses:
        "200":
          description: Discovery JSON (images, video, chat, upscale, switchx, audio, mesh sections)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /api/v1/updates/:
    get:
      tags: [Discovery]
      operationId: listApiUpdates
      summary: Integration updates and migration instructions (no auth)
      description: >
        Stable dated notices. Compare latest.id with the stored client version and
        read latest.url on changes. Both model catalogs expose the same api_update.
      security: []
      responses:
        "200":
          description: Latest notice and update history
          content:
            application/json:
              schema:
                type: object
                required: [latest, updates]
                properties:
                  latest:
                    $ref: "#/components/schemas/ApiUpdate"
                  updates:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiUpdate"

  /api/v1/me/:
    get:
      tags: [Account]
      operationId: getMe
      summary: Identity + token balance for the API key
      responses:
        "200":
          description: Account info
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/balance/:
    get:
      tags: [Account]
      operationId: getBalance
      summary: Personal token balance
      responses:
        "200":
          description: Balance
          content:
            application/json:
              schema:
                type: object
                required: [token_balance]
                properties:
                  token_balance:
                    type: number
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/estimate/:
    post:
      tags: [Account]
      operationId: estimateCost
      summary: Token cost without charging
      description: >
        Body is the same as the corresponding submit plus
        `type: image | video | chat | switchx | upscale | audio | mesh`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [image, video, chat, switchx, upscale, audio, mesh]
              additionalProperties: true
      responses:
        "200":
          description: Estimate
          content:
            application/json:
              schema:
                type: object
                required: [type, model, cost_tokens]
                properties:
                  type:
                    type: string
                  model:
                    type: string
                  cost_tokens:
                    type: number
                  billing_mode:
                    type: string
                    enum: [metered]
                  cost_tokens_is_minimum:
                    type: boolean
                    description: Chat quotes a minimum; the actual charge depends on usage.
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/files/:
    post:
      tags: [Files]
      operationId: uploadFile
      summary: Upload media for reuse as generation input
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Stored media
          content:
            application/json:
              schema:
                type: object
                required: [key, url]
                properties:
                  key:
                    type: string
                  url:
                    type: string
                    format: uri
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/files/from-url/:
    post:
      tags: [Files]
      operationId: uploadFileFromUrl
      summary: Mirror a public HTTPS media URL into Artillect media
      description: >
        SSRF-hardened fetch (max 1 redirect hop; private/metadata IPs blocked). Same response shape as multipart upload.
        Limits image 150MB / video 200MB / audio 50MB. HEIC/HEIF, AVIF, WebP, TIFF, GIF and SVG are normalized before provider submission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Public HTTPS URL of image/video/audio
                filename:
                  type: string
                  description: Optional filename hint with extension
      responses:
        "201":
          description: Stored media
          content:
            application/json:
              schema:
                type: object
                required: [key, url]
                properties:
                  key:
                    type: string
                  url:
                    type: string
                    format: uri
                  content_type:
                    type: string
                  bytes:
                    type: integer
                  kind:
                    type: string
                    enum: [image, video, audio]
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/Error"

  /api/v1/files/presign/:
    post:
      tags: [Files]
      operationId: createFileUploadUrl
      summary: Quota-aware presigned PUT URL for image/audio (video disabled)
      description: >
        The exact size_bytes is reserved against storage quota before the PUT. Client must PUT the file
        to upload_url with required_headers (Content-Type is part of the signature), then finalize the
        asset via POST /api/v1/files/presign/finalize/. Video must use multipart / from-url instead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [size_bytes]
              properties:
                filename:
                  type: string
                content_type:
                  type: string
                size_bytes:
                  type: integer
                  minimum: 1
                  description: Exact byte size reserved in storage quota before upload
                expires_in:
                  type: integer
                  minimum: 60
                  maximum: 3600
      responses:
        "201":
          description: Presigned upload
          content:
            application/json:
              schema:
                type: object
                required: [asset_id, key, upload_url, finalize_url]
                properties:
                  asset_id:
                    type: string
                  finalize_url:
                    type: string
                    format: uri
                  key:
                    type: string
                  upload_url:
                    type: string
                    format: uri
                  url:
                    type: string
                    format: uri
                  content_type:
                    type: string
                  required_headers:
                    type: object
                    additionalProperties:
                      type: string
                  expires_in:
                    type: integer
                  kind:
                    type: string
                    enum: [image, audio]
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/Error"

  /api/v1/files/presign/finalize/:
    post:
      tags: [Files]
      operationId: finalizeFileUpload
      summary: Finalize a quota-aware direct upload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asset_id]
              properties:
                asset_id:
                  type: string
      responses:
        "200":
          description: Active Asset Library entry
          content:
            application/json:
              schema:
                type: object
                required: [asset_id, url, bytes, status]
                properties:
                  asset_id:
                    type: string
                  url:
                    type: string
                    format: uri
                  bytes:
                    type: integer
                  status:
                    type: string
                    enum: [active]
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Error"
        "413":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /api/v1/files/upload-tickets/:
    post:
      tags: [Files]
      operationId: createUploadTicket
      summary: Mint a capability ticket so an end user can upload a file themselves
      description: >
        For files your integration has no bytes for — e.g. something the user attached in an
        LLM host chat. Returns a short-lived signed ticket plus the upload URL to hand to the
        user's browser; poll GET /api/v1/files/upload-tickets/{ticket_id}/ for the result.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                purpose:
                  type: string
                  description: Shown to the user in the upload UI
                expires_in:
                  type: integer
                  minimum: 60
                  maximum: 3600
      responses:
        "201":
          description: Ticket created
          content:
            application/json:
              schema:
                type: object
                required: [ticket_id, ticket, upload_url, expires_at]
                properties:
                  ticket_id:
                    type: string
                  ticket:
                    type: string
                    description: Opaque capability token — the only credential the upload leg needs
                  upload_url:
                    type: string
                    format: uri
                  expires_at:
                    type: string
                    format: date-time
                  max_bytes:
                    type: object
                    additionalProperties:
                      type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/files/upload-tickets/{ticketId}/:
    parameters:
      - name: ticketId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Files]
      operationId: getUploadTicket
      summary: Poll an upload ticket for the file the user dropped
      responses:
        "200":
          description: Ticket state
          content:
            application/json:
              schema:
                type: object
                required: [ticket_id, status, files]
                properties:
                  ticket_id:
                    type: string
                  status:
                    type: string
                    enum: [pending, collecting, ready]
                  expected_count:
                    {
                      type: integer,
                      minimum: 1,
                      description: Present for collecting and ready tickets.,
                    }
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string }
                        url: { type: string, format: uri }
                        content_type: { type: string }
                        bytes: { type: integer }
                        kind: { type: string }
                        filename: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
    patch:
      tags: [Files]
      operationId: sealUploadTicket
      summary: Complete a collected upload batch
      description: |
        Capability ticket authentication, not API-key authentication. Send the signed upload
        ticket as Bearer Authorization or JSON ticket. A JSON body (at least {}) is required.
        The ticket must match ticketId and the batch must contain expected_count files.
      security:
        - UploadTicketBearer: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ticket:
                  {
                    type: string,
                    description: "Signed ticket, required when Authorization is absent.",
                  }
      responses:
        "200":
          description: Sealed batch. Cache-Control no-store.
          content:
            application/json:
              schema:
                type: object
                required: [ticket_id, status, expected_count, files]
                properties:
                  ticket_id: { type: string }
                  status: { type: string, const: ready }
                  expected_count: { type: integer, minimum: 1 }
                  files:
                    type: array
                    items: { type: object, additionalProperties: true }
        "400": { description: Invalid JSON body. }
        "401": { description: Missing or invalid signed upload ticket. }
        "403": { description: Ticket does not match path ticketId. }
        "409": { description: UPLOAD_TICKET_INCOMPLETE; expected files not yet uploaded. }
        "503": { description: UPLOAD_TICKET_UNAVAILABLE; ticket store unavailable. }
    post:
      tags: [Files]
      operationId: uploadWithTicket
      summary: Upload a file with a ticket (no API key)
      description: >
        Called by the end user's browser, not by your server — the ticket is the whole
        credential, so this route is CORS-open and takes no API key. Send multipart with
        `file` + `ticket` fields, or JSON with `file_base64` + `filename` + `ticket`.
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, ticket]
              properties:
                file:
                  type: string
                  format: binary
                ticket:
                  type: string
          application/json:
            schema:
              type: object
              required: [ticket, file_base64, filename]
              properties:
                ticket: { type: string }
                filename: { type: string }
                file_base64: { type: string }
                content_type: { type: string }
      responses:
        "201":
          description: Stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket_id: { type: string }
                  file:
                    type: object
                    properties:
                      key: { type: string }
                      url: { type: string, format: uri }
                      content_type: { type: string }
                      bytes: { type: integer }
                      kind: { type: string }
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"

  /api/v1/assets/:
    get:
      tags: [Assets]
      operationId: listMediaAssets
      summary: List personal and currently accessible project Assets
      description: >
        Durable library identity is `asset_id` (`ast_…`), not a storage key.
        `url` / `tile_url` are capability links (`?t=`) for the current viewer.
      parameters:
        - name: scope
          in: query
          schema:
            type: string
            enum: [all, personal, project]
            default: all
        - name: kind
          in: query
          schema:
            type: string
            enum: [image, video, audio, mesh]
        - name: project_id
          in: query
          schema:
            type: integer
            minimum: 1
        - name: q
          in: query
          schema:
            type: string
            maxLength: 100
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Page of assets
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/AssetItem"
                  next_cursor:
                    type: ["string", "null"]
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/assets/{assetId}/:
    parameters:
      - $ref: "#/components/parameters/AssetId"
    get:
      tags: [Assets]
      operationId: getMediaAsset
      summary: Resolve an Asset by durable public id
      responses:
        "200":
          description: Asset metadata + viewer URLs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetItem"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    post:
      tags: [Assets]
      operationId: copyMediaAsset
      summary: Copy an Asset into personal or project scope
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target]
              properties:
                target:
                  type: string
                  enum: [personal, project]
                project_id:
                  type: integer
                  minimum: 1
      responses:
        "201":
          description: Copied asset
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetItem"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    patch:
      tags: [Assets]
      operationId: updateMediaAsset
      summary: Non-destructive library metadata (currently pin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pinned]
              properties:
                pinned:
                  type: boolean
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [asset_id, pinned]
                properties:
                  asset_id: { type: string }
                  pinned: { type: boolean }
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    delete:
      tags: [Assets]
      operationId: deleteMediaAsset
      summary: Delete an Asset (refuses if still referenced)
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [asset_id, deleted]
                properties:
                  asset_id: { type: string }
                  deleted: { type: boolean }
                  bytes: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"

  /api/v1/assets/{assetId}/content/:
    parameters:
      - $ref: "#/components/parameters/AssetId"
      - $ref: "#/components/parameters/AssetContentToken"
    get:
      tags: [Assets]
      operationId: getMediaAssetContent
      summary: Byte stream for an Asset (capability token, not API key)
      description: >
        ACL is rechecked per request from the `t` token. Large video may 302 to a
        short-lived presigned URL. Range requests are supported.
      security: []
      responses:
        "200":
          description: Original bytes
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "206":
          description: Partial content
        "302":
          description: Presigned redirect for heavy video
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/assets/{assetId}/tile/:
    parameters:
      - $ref: "#/components/parameters/AssetId"
      - $ref: "#/components/parameters/AssetContentToken"
    get:
      tags: [Assets]
      operationId: getMediaAssetTile
      summary: 320px webp grid thumbnail
      description: Legacy assets materialize the tile on first request. Not billed.
      security: []
      responses:
        "200":
          description: webp tile
          content:
            image/webp:
              schema:
                type: string
                format: binary
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/feedback/:
    post:
      tags: [Feedback]
      operationId: submitFeedback
      summary: DX report from agents / CLI (MCP submit_feedback)
      description: |
        Charges nothing. Independent per-user rate limit (10/hour). Identical
        payload resolves to the existing row (`status: duplicate`) instead of
        inserting again. Body ≤ 32 KiB.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FeedbackRequest"
      responses:
        "200":
          description: Recorded or duplicate
          content:
            application/json:
              schema:
                type: object
                required: [status, message]
                properties:
                  id: { type: string }
                  status:
                    type: string
                    enum: [recorded, duplicate]
                  message: { type: string }
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Error"

  /api/v1/media/operations/:
    post:
      tags: [Media]
      operationId: createMediaOperation
      summary: Enqueue typed video trim or ordered concat
      description: |
        No arbitrary FFmpeg. Inputs must be owned video `ast_…` ids.
        Currently returns `503 MEDIA_OPERATIONS_WIP` until
        `MEDIA_OPERATIONS_PUBLIC_ENABLED` is on.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MediaOperationRequest"
      responses:
        "202":
          description: Queued (or idempotent replay)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaOperationAccepted"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Not enabled yet (`MEDIA_OPERATIONS_WIP`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/media/operations/{id}/:
    parameters:
      - $ref: "#/components/parameters/MediaOperationId"
    get:
      tags: [Media]
      operationId: getMediaOperation
      summary: Poll a media operation
      responses:
        "200":
          description: Current status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaOperationPoll"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "503":
          description: Not enabled yet (`MEDIA_OPERATIONS_WIP`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/media/operations/{id}/cancel/:
    parameters:
      - $ref: "#/components/parameters/MediaOperationId"
    post:
      tags: [Media]
      operationId: cancelMediaOperation
      summary: Request cancellation of a non-terminal media operation
      responses:
        "200":
          description: Cancelled or already terminal
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id: { type: string, format: uuid }
                  status: { type: string }
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "503":
          description: Not enabled yet (`MEDIA_OPERATIONS_WIP`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/device/code/:
    post:
      tags: [Auth]
      operationId: createDeviceCode
      summary: Start device authorization flow
      security: []
      responses:
        "200":
          description: Device + user codes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceCodeResponse"

  /api/v1/device/token/:
    post:
      tags: [Auth]
      operationId: pollDeviceToken
      summary: Poll for API key after user approval
      security: []
      description: >
        Call only after the user confirmed in the browser.
        Pending returns `{ status: pending }`; completed returns `api_key`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_code]
              properties:
                device_code:
                  type: string
      responses:
        "200":
          description: Pending or completed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceTokenResponse"
        "410":
          $ref: "#/components/responses/Error"

  /api/v1/device/approve/:
    post:
      tags: [Auth]
      operationId: approveDeviceCode
      summary: Approve device code (browser session JWT)
      security:
        - SessionBearer: []
      description: Used by `/connect` UI — not for CLI with art_ keys.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        "200":
          description: Approved
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/keys/:
    get:
      tags: [Auth]
      operationId: listApiKeys
      summary: List active API keys (session JWT)
      security:
        - SessionBearer: []
      responses:
        "200":
          description: Key list (no plaintext secrets)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Auth]
      operationId: createApiKey
      summary: Create API key (session JWT; plaintext returned once)
      security:
        - SessionBearer: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        "201":
          description: Created key
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/keys/{id}/:
    parameters:
      - $ref: "#/components/parameters/KeyId"
    patch:
      tags: [Auth]
      operationId: updateApiKey
      summary: Update API key name and/or scopes (session JWT)
      security:
        - SessionBearer: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: [string, "null"]
                  maxLength: 64
                scopes:
                  type: array
                  items:
                    type: string
                  description: 'Empty/omitted on create defaults to ["*"]. NULL in DB = full access.'
      responses:
        "200":
          description: Updated metadata
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    delete:
      tags: [Auth]
      operationId: revokeApiKey
      summary: Revoke API key (session JWT)
      security:
        - SessionBearer: []
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/loras/:
    get:
      tags: [LoRA]
      operationId: listLoras
      summary: List personal LoRA resources and immutable versions
      description: Requires models:read. Returns resources owned by the API key user in pages of up to 100; repeat with `cursor` from `next_cursor` to continue.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Personal LoRA library
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoraList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Error"
        "400":
          $ref: "#/components/responses/Error"

  /api/v1/loras/{id}/:
    get:
      tags: [LoRA]
      operationId: getLora
      summary: Get an owned LoRA and its version history
      description: Requires models:read. Names can change without changing the resource ID; use a specific version_id for generation.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: "^lor_[a-f0-9]{32}$"
      responses:
        "200":
          description: Owned LoRA resource
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoraResource"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/images/generations/:
    get:
      tags: [Images]
      operationId: listImageGenerations
      summary: Paginated image generation history
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Page of generations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Images]
      operationId: submitImageGeneration
      summary: Submit image generation (async)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageSubmitRequest"
      responses:
        "202":
          description: Accepted — poll `poll_url`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncSubmitResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/Error"
        "504":
          $ref: "#/components/responses/Error"

  /api/v1/images/generations/{id}/:
    parameters:
      - $ref: "#/components/parameters/GenerationId"
    get:
      tags: [Images]
      operationId: getImageGeneration
      summary: Poll image generation status
      responses:
        "200":
          description: Current status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImagePollResponse"
          headers:
            Retry-After:
              description: Seconds until next recommended poll (queued/running)
              schema:
                type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/videos/generations/:
    get:
      tags: [Videos]
      operationId: listVideoGenerations
      summary: Paginated video generation history
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Page of generations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Videos]
      operationId: submitVideoGeneration
      summary: Submit video generation (async)
      description: >
        Named media slots + optional `task`. Model-specific rules and slots —
        `GET /api/v1/models/` and markdown docs.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoSubmitRequest"
      responses:
        "202":
          description: Accepted — poll `poll_url`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VideoAsyncSubmitResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/Error"
        "504":
          $ref: "#/components/responses/Error"

  /api/v1/videos/generations/{id}/:
    parameters:
      - $ref: "#/components/parameters/GenerationId"
    get:
      tags: [Videos]
      operationId: getVideoGeneration
      summary: Poll video generation status
      responses:
        "200":
          description: Current status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VideoPollResponse"
          headers:
            Retry-After:
              schema:
                type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/upscale/generations/:
    get:
      tags: [Upscale]
      operationId: listUpscaleGenerations
      summary: Paginated upscale history (Topaz + Crystal + FLUX)
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Page of generations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Upscale]
      operationId: submitUpscaleGeneration
      summary: Submit upscale (Topaz, Crystal and FLUX Video Upscale)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpscaleSubmitRequest"
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncSubmitResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/upscale/generations/{id}/:
    parameters:
      - $ref: "#/components/parameters/GenerationId"
    get:
      tags: [Upscale]
      operationId: getUpscaleGeneration
      summary: Poll upscale status
      responses:
        "200":
          description: Current status (`images[]` or `videos[]`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpscalePollResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/audio/generations/:
    get:
      tags: [Audio]
      operationId: listAudioGenerations
      summary: Paginated audio history (Suno music)
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Page of generations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Audio]
      operationId: submitAudioGeneration
      summary: Submit audio generation, speech, voice cloning or processing (model-specific inputs)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AudioSubmitRequest"
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncSubmitResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/audio/generations/{id}/:
    parameters:
      - $ref: "#/components/parameters/GenerationId"
    get:
      tags: [Audio]
      operationId: getAudioGeneration
      summary: Poll audio generation status
      responses:
        "200":
          description: Current status (`audio[]` track URLs when completed)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AudioPollResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/mesh/generations/:
    get:
      tags: [Mesh]
      operationId: listMeshGenerations
      summary: Paginated 3D mesh history (Meshy, Hunyuan, Tripo)
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Page of generations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Mesh]
      operationId: submitMeshGeneration
      summary: Submit 3D generation or processing (Meshy, Hunyuan, Tripo, TRELLIS, SAM 3D)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MeshSubmitRequest"
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncSubmitResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/mesh/generations/{id}/:
    parameters:
      - $ref: "#/components/parameters/GenerationId"
    get:
      tags: [Mesh]
      operationId: getMeshGeneration
      summary: Poll mesh generation status
      responses:
        "200":
          description: Current status (`mesh[]` asset URLs, GLB first, when completed)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeshPollResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/switchx/generations/:
    get:
      tags: [SwitchX]
      operationId: listSwitchxGenerations
      summary: Paginated SwitchX history
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Page of generations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [SwitchX]
      operationId: submitSwitchxGeneration
      summary: Submit SwitchX / Beeble job (async)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SwitchxSubmitRequest"
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncSubmitResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/switchx/generations/{id}/:
    parameters:
      - $ref: "#/components/parameters/GenerationId"
    get:
      tags: [SwitchX]
      operationId: getSwitchxGeneration
      summary: Poll SwitchX status
      responses:
        "200":
          description: Current status (video-shaped)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VideoPollResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/generations/{id}/:
    parameters:
      - $ref: "#/components/parameters/GenerationId"
    get:
      tags: [Discovery]
      operationId: getGeneration
      summary: Modality-agnostic poll
      description: |
        Resolves kind from the generation row (needed for Studio ids that only
        carry a bare `-api` suffix). Response is the matching per-modality poll
        body plus `modality`. Prefer this when the caller has an id from a list
        and does not already know the kind. Per-modality routes remain valid.
      responses:
        "200":
          description: Same shape as the matching `GET …/generations/{id}/` plus `modality`
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerationPoll"
                  - type: object
                    required: [modality]
                    properties:
                      modality:
                        type: string
                        enum: [image, video, upscale, switchx, audio, mesh]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/chat/completions/:
    post:
      tags: [Chat]
      operationId: createChatCompletion
      summary: LLM chat completion (sync JSON or optional SSE)
      description: |
        Default: sync JSON (`stream` omit/false). Optional `stream:true` returns OpenAI-ish
        `text/event-stream` for supported chat models. Use GET /api/v1/models/ for each
        model's OpenRouter context and output limits. No universal input character cap;
        text is not truncated. HTTP JSON bodies are at most 8 MiB (413 BODY_TOO_LARGE). Output defaults to the selected model maximum; explicit limits may reach the model maximum.
        `Idempotency-Key` is sync-only (`400 STREAM_IDEMPOTENCY_UNSUPPORTED` with stream).
        Billing matches sync: reserve full estimated cost before inference, then bill actual provider cost plus 20% and return unused reserve. Interrupted or unknown-cost inference remains reserved for reconciliation.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatCompletionRequest"
      responses:
        "200":
          description: Assistant reply (JSON) or SSE stream when stream=true
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatCompletionResponse"
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE lines `data: {chat.completion.chunk}` then `data: [DONE]`.
                  Last chunk may include `usage` and `cost_tokens` with the actual `finish_reason` (including `length`) and `billing_pending`.
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/Error"
        "504":
          $ref: "#/components/responses/Error"

  /api/v1/chat/conversations/:
    get:
      tags: [Chat]
      operationId: listChatConversations
      summary: List LLM chat threads for the API key owner
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Conversation list
          content:
            application/json:
              schema:
                type: object
                required: [conversations]
                properties:
                  conversations:
                    type: array
                    items:
                      $ref: "#/components/schemas/ChatConversation"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/Error"
    post:
      tags: [Chat]
      operationId: createChatConversation
      summary: Create an empty chat thread
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                model:
                  type: string
                  description: Public chat slug from GET /models (default from catalog)
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatConversation"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/chat/conversations/{id}/:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
    get:
      tags: [Chat]
      operationId: getChatConversation
      summary: Conversation metadata
      responses:
        "200":
          description: Thread meta
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatConversation"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    delete:
      tags: [Chat]
      operationId: deleteChatConversation
      summary: Soft-delete a chat thread
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/chat/conversations/{id}/messages/:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
    get:
      tags: [Chat]
      operationId: listChatMessages
      summary: Page of stored messages (oldest → newest)
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
        - name: before_id
          in: query
          schema:
            type: integer
          description: Return messages older than this id
      responses:
        "200":
          description: Message page
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      $ref: "#/components/schemas/ChatMessage"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    post:
      tags: [Chat]
      operationId: sendChatMessage
      summary: Thread turn — server holds history
      description: |
        Client sends only the new `message` (+ optional `images`). History and
        the context window stay on the server. Stateless alternative remains
        `POST /api/v1/chat/completions/` (caller sends full `messages`).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                message: { type: string }
                images:
                  type: array
                  items: { type: string, format: uri }
                  description: HTTPS image URLs or previously uploaded file URLs
                model: { type: string }
                project_id: { type: integer }
                billing_source: { type: string }
                max_tokens: { type: integer }
                reasoning:
                  $ref: "#/components/schemas/LlmReasoning"
                reasoning_effort:
                  type: string
                  enum: [none, minimal, low, medium, high, xhigh, max]
      responses:
        "200":
          description: Assistant reply
          content:
            application/json:
              schema:
                type: object
                required: [id, conversation_id, model, content, cost_tokens]
                properties:
                  id: { type: string }
                  conversation_id: { type: integer }
                  model: { type: string }
                  content: { type: string }
                  usage:
                    type: object
                    properties:
                      input_tokens: { type: ["integer", "null"] }
                      output_tokens: { type: ["integer", "null"] }
                  cost_tokens: { type: number }
                  project_id: { type: ["integer", "null"] }
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/projects/:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: Projects available to the API user
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema:
            type: string
            enum: [active, all]
            default: active
      responses:
        "200":
          description: Project list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/ProjectSummary"
                  next_cursor:
                    type: ["string", "null"]
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Projects]
      operationId: createProject
      summary: Create a project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                description:
                  type: ["string", "null"]
                status:
                  type: string
                  enum: [active, closed, archived]
      responses:
        "201":
          description: Created
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/roles/:
    get:
      tags: [Projects]
      operationId: listProjectRoles
      summary: Catalog of member roles
      responses:
        "200":
          description: Roles
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProject
      summary: Project detail + caller role
      responses:
        "200":
          description: Project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    patch:
      tags: [Projects]
      operationId: updateProject
      summary: Update project (owner)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                description:
                  type: ["string", "null"]
                status:
                  type: string
                  enum: [active, closed, archived]
      responses:
        "200":
          description: Updated
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      tags: [Projects]
      operationId: deleteProject
      summary: Delete project (owner; not personal hub)
      responses:
        "200":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/members/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: listProjectMembers
      summary: List project members
      parameters:
        - name: state
          in: query
          schema:
            type: string
            enum: [active_or_invited, active, invited, removed, all]
            default: active_or_invited
      responses:
        "200":
          description: Members
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Projects]
      operationId: inviteProjectMember
      summary: Invite member (owner)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: integer
                email:
                  type: string
                role:
                  type: string
                role_id:
                  type: integer
                token_limit:
                  type: ["integer", "null"]
      responses:
        "201":
          description: Invited
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/members/{userId}/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: userId
        in: path
        required: true
        schema:
          type: integer
    patch:
      tags: [Projects]
      operationId: updateProjectMember
      summary: Update member role / token_limit / remove
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                role:
                  type: string
                role_id:
                  type: integer
                token_limit:
                  type: ["integer", "null"]
                action:
                  type: string
                  enum: [remove, activate]
      responses:
        "200":
          description: Updated
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      tags: [Projects]
      operationId: removeProjectMember
      summary: Remove member (owner)
      responses:
        "200":
          description: Removed
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/members/me/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    delete:
      tags: [Projects]
      operationId: leaveProject
      summary: Leave project (non-owner)
      responses:
        "200":
          description: Left
        "401":
          $ref: "#/components/responses/Unauthorized"
    patch:
      tags: [Projects]
      operationId: updateMySidebarPrefs
      summary: Sidebar favorite for self
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                is_favorite:
                  type: boolean
      responses:
        "200":
          description: Updated
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/favorites/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: listProjectFavorites
      summary: Project favorites counts + items
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
        - $ref: "#/components/parameters/Cursor"
        - name: with_items
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Favorites
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/analytics/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectAnalytics
      summary: Project stats + recent ledger/audit
      responses:
        "200":
          description: Analytics
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/billing/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectBillingContext
      summary: Caller role + token_limit / tokens_spent
      responses:
        "200":
          description: Billing context
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/elements/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: listProjectElements
      summary: Element library for @mentions in video
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
        - $ref: "#/components/parameters/Cursor"
        - name: kinds
          in: query
          description: Comma-separated `image`, `video`
          schema:
            type: string
      responses:
        "200":
          description: Elements
          content:
            application/json:
              schema:
                type: object
                properties:
                  project_id:
                    type: integer
                  items:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  next_cursor:
                    type: ["string", "null"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    post:
      tags: [Projects]
      operationId: createProjectElement
      summary: Create element (image|video)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, kind, media_json, preview_url]
              properties:
                name:
                  type: string
                kind:
                  type: string
                  enum: [image, video]
                media_json:
                  type: object
                preview_url:
                  type: string
      responses:
        "201":
          description: Created
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/elements/{elementId}/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: elementId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Projects]
      operationId: getProjectElement
      summary: Get one element
      responses:
        "200":
          description: Element
        "401":
          $ref: "#/components/responses/Unauthorized"
    patch:
      tags: [Projects]
      operationId: updateProjectElement
      summary: Update element
      responses:
        "200":
          description: Updated
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      tags: [Projects]
      operationId: deleteProjectElement
      summary: Soft-delete element
      responses:
        "200":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/elements/reorder/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Projects]
      operationId: reorderProjectElements
      summary: Reorder elements
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [ordered_ids]
              properties:
                ordered_ids:
                  type: array
                  items:
                    type: string
      responses:
        "200":
          description: OK
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/tags/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: listProjectTags
      summary: Custom project tags
      responses:
        "200":
          description: Tags
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Projects]
      operationId: createProjectTag
      summary: Create custom tag
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                color:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/projects/{id}/tags/{tagId}/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/TagId"
    patch:
      tags: [Projects]
      operationId: updateProjectTag
      summary: Rename / recolor tag
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                color:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    delete:
      tags: [Projects]
      operationId: deleteProjectTag
      summary: Delete tag and associations
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/projects/{id}/generations/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: listProjectGenerations
      summary: Project generation feed (Studio + Public API)
      description: >
        Unified project feed. Filter by `client_source` via `source` query.
        Items include `kind`, `preview_url`, and `source` (`studio` | `public_api` | `mcp` | `cli`).
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: source
          in: query
          schema:
            type: string
            enum: [all, public_api, mcp, cli, studio]
            default: all
        - name: kind
          in: query
          description: Optional `generations.kind` filter (image, video, audio, text, mesh)
          schema:
            type: string
        - name: q
          in: query
          description: Search in preview_text (min 2 chars)
          schema:
            type: string
        - name: tag_ids
          in: query
          description: Comma-separated custom tag ids (AND)
          schema:
            type: string
        - name: date
          in: query
          description: UTC day YYYY-MM-DD
          schema:
            type: string
        - name: date_from
          in: query
          schema:
            type: string
        - name: date_to
          in: query
          schema:
            type: string
        - name: mine_only
          in: query
          schema:
            type: string
            enum: ["1", "true"]
        - name: actor_user_id
          in: query
          schema:
            type: integer
        - name: service
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Feed page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectGenerationList"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/projects/{id}/generations/{genId}/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/GenId"
    patch:
      tags: [Projects]
      operationId: updateProjectGeneration
      summary: Move generation and/or set favorited mask
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                project_id:
                  type: integer
                  description: Destination project for move
                favorited:
                  type: integer
      responses:
        "200":
          description: Updated
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      tags: [Projects]
      operationId: deleteProjectGeneration
      summary: Delete generation
      responses:
        "200":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/generations/{genId}/copy/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/GenId"
    post:
      tags: [Projects]
      operationId: copyProjectGeneration
      summary: Copy generation into another project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id]
              properties:
                project_id:
                  type: integer
      responses:
        "201":
          description: Copied
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/projects/{id}/generations/{genId}/likes/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/GenId"
    get:
      tags: [Projects]
      operationId: listGenerationLikes
      summary: Who liked a generation
      parameters:
        - name: result_index
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: Likes
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    post:
      tags: [Projects]
      operationId: toggleGenerationLike
      summary: Add / remove / toggle own like
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                mode:
                  type: string
                  enum: [add, remove, toggle]
                  default: toggle
      responses:
        "200":
          description: Updated likes
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/projects/{id}/generations/{genId}/tags/:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/GenId"
    post:
      tags: [Projects]
      operationId: assignGenerationTags
      summary: Assign custom tags on an existing generation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tag_ids:
                  type: array
                  items:
                    type: integer
                mode:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/webhooks/:
    get:
      tags: [Webhooks]
      operationId: getWebhookSubscription
      summary: Current webhook subscription (secret not included)
      responses:
        "200":
          description: Active subscription
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscription"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No subscription (`WEBHOOK_NOT_FOUND`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
    post:
      tags: [Webhooks]
      operationId: upsertWebhookSubscription
      summary: Register or update HTTPS webhook for the current API key
      description: >
        One subscription per API key. `201` on first create includes `secret` (shown once).
        `200` on update returns subscription without rotating secret.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS endpoint (validated; no localhost in prod)
                include_studio:
                  type: boolean
                  default: false
                  description: Deliver events for Studio UI jobs in shared projects
      responses:
        "200":
          description: Updated subscription
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscription"
        "201":
          description: Created subscription (includes signing secret once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionCreate"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookSubscription
      summary: Remove webhook subscription for the current API key
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [deleted]
                properties:
                  deleted:
                    type: boolean
                    const: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/webhooks/rotate-secret/:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate signing secret (shown once)
      responses:
        "200":
          description: New secret
          content:
            application/json:
              schema:
                type: object
                required: [secret, secret_last4]
                properties:
                  secret:
                    type: string
                    description: New HMAC signing secret (store securely)
                  secret_last4:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /api/v1/webhooks/deliveries/:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: List outbound webhook delivery attempts for this API key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque cursor from previous `next_cursor`
        - name: status
          in: query
          schema: { type: string, enum: [pending, delivered, dead] }
        - name: job_id
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Page of deliveries (no payload in list)
          content:
            application/json:
              schema:
                type: object
                required: [items, next_cursor]
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookDeliveryListItem"
                  next_cursor:
                    type: [string, "null"]
                  request_id:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/webhooks/deliveries/{event_id}/:
    get:
      tags: [Webhooks]
      operationId: getWebhookDelivery
      summary: Delivery detail including signed payload body
      parameters:
        - name: event_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Delivery detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Not found for this API key (`WEBHOOK_DELIVERY_NOT_FOUND`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/images/generations/{id}/cancel/:
    parameters: [{ $ref: "#/components/parameters/GenerationId" }]
    post:
      tags: [Images]
      operationId: cancelImageGeneration
      summary: Cancel and refund during the four-second pre-submit window
      description: Provider submission is not cancellable. After the four-second grace window this returns 409.
      responses:
        "200":
          description: Cancelled and refunded
          content: { application/json: { schema: { $ref: "#/components/schemas/CancelResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/v1/videos/generations/{id}/cancel/:
    parameters: [{ $ref: "#/components/parameters/GenerationId" }]
    post:
      tags: [Videos]
      operationId: cancelVideoGeneration
      summary: Cancel and refund during the four-second pre-submit window
      description: Provider submission is not cancellable. After the four-second grace window this returns 409.
      responses:
        "200":
          description: Cancelled and refunded
          content: { application/json: { schema: { $ref: "#/components/schemas/CancelResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/v1/upscale/generations/{id}/cancel/:
    parameters: [{ $ref: "#/components/parameters/GenerationId" }]
    post:
      tags: [Upscale]
      operationId: cancelUpscaleGeneration
      summary: Cancel and refund during the four-second pre-submit window
      description: Provider submission is not cancellable. After the four-second grace window this returns 409.
      responses:
        "200":
          description: Cancelled and refunded
          content: { application/json: { schema: { $ref: "#/components/schemas/CancelResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/v1/switchx/generations/{id}/cancel/:
    parameters: [{ $ref: "#/components/parameters/GenerationId" }]
    post:
      tags: [SwitchX]
      operationId: cancelSwitchxGeneration
      summary: Cancel and refund during the four-second pre-submit window
      description: Provider submission is not cancellable. After the four-second grace window this returns 409.
      responses:
        "200":
          description: Cancelled and refunded
          content: { application/json: { schema: { $ref: "#/components/schemas/CancelResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/v1/audio/generations/{id}/cancel/:
    parameters: [{ $ref: "#/components/parameters/GenerationId" }]
    post:
      tags: [Audio]
      operationId: cancelAudioGeneration
      summary: Cancel and refund during the four-second pre-submit window
      description: Provider submission is not cancellable. After the four-second grace window this returns 409.
      responses:
        "200":
          description: Cancelled and refunded
          content: { application/json: { schema: { $ref: "#/components/schemas/CancelResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/v1/mesh/generations/{id}/cancel/:
    parameters: [{ $ref: "#/components/parameters/GenerationId" }]
    post:
      tags: [Mesh]
      operationId: cancelMeshGeneration
      summary: Cancel and refund during the four-second pre-submit window
      description: Provider submission is not cancellable. After the four-second grace window this returns 409.
      responses:
        "200":
          description: Cancelled and refunded
          content: { application/json: { schema: { $ref: "#/components/schemas/CancelResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/v1/generations/{id}/cancel/:
    parameters: [{ $ref: "#/components/parameters/GenerationId" }]
    post:
      tags: [Discovery]
      operationId: cancelGeneration
      summary: Cancel and refund during the four-second pre-submit window
      description: Modality-agnostic cancel for MCP/CLI. It only works before provider submission; after four seconds this returns 409.
      responses:
        "200":
          description: Cancelled and refunded
          content: { application/json: { schema: { $ref: "#/components/schemas/CancelResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

components:
  securitySchemes:
    UploadTicketBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Signed upload capability ticket, distinct from an API key.
    ApiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: art_
      description: >
        Long-lived Public API key (`art_...`). Obtain via device flow
        (`POST /api/v1/device/code/` → browser approve → `POST /api/v1/device/token/`)
        or session JWT `POST /api/v1/keys/`.
    SessionBearer:
      type: http
      scheme: bearer
      description: Browser session JWT (key management + device approve only).

  parameters:
    GenerationId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: >
        Job id from submit. Suffixes: `-api-image` (images), `-api-video`,
        `-api-upscale`, `-api-switchx`, `-api-audio`, `-api-mesh`. Legacy image poll: `{uuid}-api`.
        Studio jobs may carry a bare `-api`; use GET /api/v1/generations/{id}/.
    AssetId:
      name: assetId
      in: path
      required: true
      schema:
        type: string
        pattern: "^ast_[0-9A-HJKMNPQRSTVWXYZ]{26}$"
      description: Durable Asset public id (`ast_…`).
    AssetContentToken:
      name: t
      in: query
      required: true
      schema:
        type: string
      description: >
        Viewer capability token from `url` / `tile_url`. Not an API key.
        Content and tile routes do not accept Bearer auth.
    ConversationId:
      name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    MediaOperationId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    KeyId:
      name: id
      in: path
      required: true
      schema:
        type: string
    ProjectId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    TagId:
      name: tagId
      in: path
      required: true
      schema:
        type: integer
    GenId:
      name: genId
      in: path
      required: true
      schema:
        type: string
      description: >
        Numeric generations.id (DB) or external_job_id from feed items.
        Likes/tags routes currently expect numeric DB id.
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Cursor:
      name: cursor
      in: query
      schema:
        type: string
      description: Opaque cursor from previous `next_cursor`
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
      description: >
        Deduplicate submit/chat for 24h. Concurrent same key → 409
        IDEMPOTENCY_CONFLICT. Replay sets header Idempotent-Replay: true.

  headers:
    X-Request-Id:
      description: Correlation id (also on error bodies as `request_id`)
      schema:
        type: string

  responses:
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    Error:
      description: Error envelope
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    RateLimited:
      description: Rate limited
      headers:
        Retry-After:
          schema:
            type: integer
        RateLimit-Limit:
          schema:
            type: integer
        RateLimit-Remaining:
          schema:
            type: integer
        RateLimit-Reset:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

  schemas:
    AgentProviderImageUrl:
      type: string
      description: >
        Public HTTPS URL, maximum 8192 characters, without credentials, IP literals,
        local hostnames or nonstandard ports; or data:image/(png|jpeg|webp|gif);base64,...
        with canonical base64 and at most 5 MiB decoded. Upstream validates remote files.
        Images require live model capabilities.input to include image (otherwise IMAGE_INPUT_UNSUPPORTED).
    AgentProviderChatImage:
      type: object
      required: [type, image_url]
      additionalProperties: false
      properties:
        type: { const: image_url }
        image_url:
          type: object
          required: [url]
          additionalProperties: false
          properties:
            url: { $ref: "#/components/schemas/AgentProviderImageUrl" }
            detail: { type: string, enum: [auto, low, high] }
    AgentProviderResponseImage:
      type: object
      required: [type, image_url]
      additionalProperties: false
      properties:
        type: { const: input_image }
        image_url: { $ref: "#/components/schemas/AgentProviderImageUrl" }
        detail: { type: string, enum: [auto, low, high, original] }
    AgentProviderMessageImage:
      type: object
      required: [type, source]
      additionalProperties: false
      properties:
        type: { const: image }
        cache_control: { type: object }
        source:
          oneOf:
            - type: object
              required: [type, url]
              additionalProperties: false
              properties:
                type: { const: url }
                url:
                  {
                    type: string,
                    format: uri,
                    maxLength: 8192,
                    description: "Public HTTPS only; no credentials, IP literals, local hostnames or nonstandard ports.",
                  }
            - type: object
              required: [type, media_type, data]
              additionalProperties: false
              properties:
                type: { const: base64 }
                media_type: { type: string, enum: [image/png, image/jpeg, image/webp, image/gif] }
                data:
                  {
                    type: string,
                    maxLength: 6990508,
                    description: "Canonical base64, at most 5 MiB decoded.",
                  }
    ErrorEnvelope:
      type: object
      required: [error, code, request_id]
      description: >
        Standard error body on all Public API v1 routes. `request_id` matches
        response header `X-Request-Id` for support correlation.
      properties:
        error:
          type: string
          description: Human-readable message
        code:
          type: string
          description: Machine-readable code
          examples:
            - PROMPT_TOO_LONG
            - WEBHOOK_NOT_FOUND
            - IDEMPOTENCY_CONFLICT
            - INSUFFICIENT_BALANCE
            - INVALID_WEBHOOK_URL
        request_id:
          type: string
        phase:
          type: string
          enum: [submit, chat]
          description: Present on some submit/chat failures
        retryable:
          type: boolean
        hint:
          type: string
        docs_url:
          type: string
          format: uri-reference
          description: Often `/docs/artillect-api.md` on generation auth errors

    JobStatus:
      type: string
      enum: [queued, running, completed, failed, cancelled]
      description: >
        `completed` is only returned with durable Artillect media URLs.
        While provider CDN is still mirroring to MinIO, poll returns `running`
        (empty media + hint), not premature `completed`.

    MediaUrl:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri

    AsyncSubmitResponse:
      type: object
      required: [id, status, model, poll_url]
      properties:
        id:
          type: string
          description: Job id (`{uuid}-api-image` for new image jobs)
        status:
          $ref: "#/components/schemas/JobStatus"
        model:
          type: string
        cost_tokens:
          type: ["number", "null"]
        project_id:
          type: ["integer", "null"]
        poll_url:
          type: string
          description: Relative path; prepend base URL
          examples:
            - /api/v1/images/generations/550e8400-e29b-41d4-a716-446655440000-api-image/

    VideoAsyncSubmitResponse:
      allOf:
        - $ref: "#/components/schemas/AsyncSubmitResponse"
        - type: object
          properties:
            task:
              type: string
              description: Resolved task (t2v, i2v, flf2v, ref2v, edit, v2v, …)

    CancelResponse:
      type: object
      required: [id, status, refunded]
      properties:
        id: { type: string }
        status: { type: string, enum: [cancelled] }
        refunded: { type: boolean }
      description: >
        The generation was cancelled before provider submission. This response
        is available only during the four-second pre-submit grace window.

    BillingFields:
      type: object
      properties:
        project_id:
          type: integer
        billing_source:
          type: string
          enum: [owner, self]
        tag_ids:
          type: array
          items:
            type: integer
        tag_id:
          type: integer

    LoraSelection:
      type: object
      additionalProperties: false
      required: [id, version_id, scale]
      description: An owned immutable LoRA version. Supported by z-image-turbo-lora, qwen-image-2512-lora, flux-2-dev-lora and ltx-23-lora; raw provider weight URLs are rejected before billing.
      properties:
        id:
          type: string
          pattern: "^lor_[a-f0-9]{32}$"
        version_id:
          type: string
          pattern: "^lov_[a-f0-9]{32}$"
        scale:
          type: number
          minimum: 0
          maximum: 2

    LoraVersion:
      type: object
      required:
        [
          id,
          base_id,
          trigger_phrase,
          source,
          status,
          validation,
          bytes,
          sha256,
          steps,
          tokens,
          error,
          created_at,
          download_url,
        ]
      properties:
        id:
          type: string
          pattern: "^lov_[a-f0-9]{32}$"
        base_id:
          type: string
          enum: [z-image-turbo, qwen-image-2512, flux-2-dev, ltx-23]
        trigger_phrase:
          type: string
          description: Immutable phrase associated with these weights; include in the prompt when applicable.
        source:
          type: string
          enum: [uploaded, trained]
        status:
          type: string
          description: Use only ready versions whose validation is structure_checked or provider_verified.
        validation:
          type: string
        bytes:
          type: [integer, "null"]
        sha256:
          type: [string, "null"]
        steps:
          type: [integer, "null"]
        tokens:
          type: integer
        error:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        download_url:
          type: [string, "null"]
          description: Authenticated Artillect download URL for ready weights, not a FAL queue request ID.

    LoraResource:
      type: object
      required: [id, name, description, default_scale, prompt_prefix, current_version_id, versions]
      properties:
        id:
          type: string
          pattern: "^lor_[a-f0-9]{32}$"
        name:
          type: string
        description:
          type: string
        default_scale:
          type: number
          minimum: 0
          maximum: 2
        prompt_prefix:
          type: string
        current_version_id:
          type: [string, "null"]
        versions:
          type: array
          items:
            $ref: "#/components/schemas/LoraVersion"

    LoraList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LoraResource"
        next_cursor:
          type: [string, "null"]

    ImageSubmitRequest:
      allOf:
        - $ref: "#/components/schemas/BillingFields"
        - type: object
          properties:
            prompt:
              type: string
              description: Required unless the selected model supports promptless input. FIBO 2 accepts a prompt, one reference image or a structured VGL scene.
            model:
              type: string
              description: Public slug; omit → gpt-image-2. See GET /models.
            aspect_ratio:
              type: string
            resolution:
              type: string
            quality:
              type: string
            output_format:
              type: string
            lora_selection:
              $ref: "#/components/schemas/LoraSelection"
            input_urls:
              type: array
              items:
                type: string
            images:
              type: array
              items:
                type: string
              description: Alias of input_urls
            n:
              type: integer
            seed:
              type: integer
            krea_variant:
              type: string
              enum: [berry, jam]
              description: Krea 3 variant; berry by default. Keep model set to the public slug krea-v3.
            image_size:
              oneOf:
                - type: string
                - type: object
                  required: [width, height]
                  properties:
                    width: { type: integer, minimum: 256, maximum: 4096 }
                    height: { type: integer, minimum: 256, maximum: 4096 }
              description: "Klein accepts only 768, 1K, 1.5K, 2K, 4K or exactly matching square dimensions with published per-image prices. Krea 3 accepts auto/presets/custom: sides 256–3200 rounded up to multiples of 16, area at most 4220160 pixels; ignored for Ultra. Model validators enforce their own limits."
            enable_prompt_expansion:
              type: boolean
              description: Klein prompt expansion; defaults to true.
            bounding_boxes:
              type: array
              maxItems: 100
              items:
                type: object
                required: [description]
                additionalProperties: true
              description: "Klein regions: description (up to 500 chars), optional label; generation requires box {x_min,y_min,x_max,y_max} in 0..1 with positive area. Editing accepts box or source_box/target_box and zero-based source_image_index into up to ten input_urls. The model validator rejects unknown fields and missing source references."
            negative_prompt:
              type: string
              maxLength: 1024
              description: Krea 3; ignored for Ultra.
            reasoning_level:
              type: string
              enum: [low, medium, high, ultra]
              description: Krea 3 generation accepts low/medium; editing also accepts high/ultra. Medium by default.
            enable_safety_checker:
              type: boolean
              default: true
            style:
              type: string
              description: FIBO 2 accepts Kodak Film, Hard Flash, Hazy Dreamy or Rough Realism; requires prompt and excludes image/VGL.
            vgl:
              type: object
              additionalProperties: true
              description: FIBO 2 structured scene matching the provider Vgl schema; excludes image/style. Unknown properties are rejected by the model validator.
          additionalProperties: true

    VideoSubmitRequest:
      allOf:
        - $ref: "#/components/schemas/BillingFields"
        - type: object
          properties:
            elements:
              type: array
              maxItems: 7
              description: "Kling 4 native named subjects. Image subject: 2–4 image_urls; video subject: video_url (3–8 seconds). Optional tag, description and voice_url for a character. Cannot combine image_urls with video_url."
              items:
                type: object
                required: [name]
                properties:
                  name: { type: string, minLength: 1, maxLength: 20 }
                  image_urls: { type: array, minItems: 2, maxItems: 4, items: { type: string } }
                  video_url: { type: string }
                  tag:
                    {
                      type: string,
                      enum: [character, animal, prop, costume, scene, effect, others],
                    }
                  description: { type: string, maxLength: 100 }
                  voice_url: { type: string }
            voices:
              type: array
              maxItems: 3
              description: "Kling 4 standalone voice references, used as @name. Alternatively use reference_audios and @Audio1–3."
              items:
                type: object
                required: [name, audio_url]
                properties:
                  name: { type: string, minLength: 1, maxLength: 20 }
                  audio_url: { type: string }
            keep_original_sound:
              type: boolean
              default: false
              description: "Kling 4: retain reference video audio. Without video input, native audio is always generated."
            reasoning_level:
              type: string
              enum: [low, high]
              description: FLUX 3 Max reasoning effort, high by default; same price.
            generate_audio:
              type: boolean
              description: FLUX 3 Max native audio, true by default; same price with or without audio.
            keyframes:
              type: array
              minItems: 1
              maxItems: 10
              items:
                oneOf:
                  - type: string
                  - type: array
                    minItems: 2
                    maxItems: 2
                    items: {}
                  - type: object
                    required: [image_url, frame_index]
                    properties:
                      image_url: { type: string }
                      frame_index: { type: integer, minimum: 0 }
              description: "FLUX 3 / Max storyboard: URL, [frame_index, URL], or object. Explicit frame indices must increase, be unique and lie in 0..duration*24; exclusive with first/last frame. Max accepts integer duration 5–20s and resolution 720p/1080p."
            camera_trajectory:
              type: array
              minItems: 2
              maxItems: 12
              items:
                type: object
                required: [time, azimuth, elevation, distance]
                properties:
                  time: { type: number, minimum: 0, maximum: 1 }
                  azimuth: { type: number }
                  elevation: { type: number, minimum: -90, maximum: 90 }
                  distance: { type: number, exclusiveMinimum: 0 }
            middle_image_url: { type: string }
            middle_frame_time: { type: number }
            reference_conditions:
              type: array
              maxItems: 12
              items:
                type: object
                required: [type, url]
                properties:
                  type: { type: string, enum: [image, video, audio] }
                  url: { type: string }
            song_id:
              type: string
            upload_audio_id:
              type: string
            background_id:
              type: string
            cover_url:
              type: string
            title:
              type: string
            layout:
              type: string
            lyrics_start_row:
              type: integer
            lyrics_end_row:
              type: integer
            selection_start:
              type: integer
            selection_end:
              type: integer
            start_time:
              type: number
            resume_time:
              type: number
            output:
              type: string
            color_match:
              type: boolean
            enhance_realism:
              type: boolean
            enable_transcription:
              type: boolean
            max_generated_reference_images:
              type: integer
            reference_image_url:
              type: string
            target_audio_url:
              type: string
            lora_selection:
              $ref: "#/components/schemas/LoraSelection"
            prompt:
              type: string
            model:
              type: string
              description: Public slug; omit → kling-3-turbo. Vidu Q4 slug vidu-q4 supports i2v/ref2v, 3–16s and 540p/720p/1080p/2k/4k. See GET /models.
            task:
              type: string
            start_image_url:
              type: string
            end_image_url:
              type: string
            source_video_url:
              type: string
            preset:
              type: string
              description: VEED Subtitles style; basic presets start at 10 tokens/min, dynamic presets cost 2×.
            language:
              type: string
              description: Subtitle source language (VEED uses ru-RU or en-US; Auto Subtitle uses ru or en).
            reference_images:
              type: array
              items:
                type: string
            reference_videos:
              type: array
              items:
                type: string
            reference_audios:
              description: "Vidu Q4: up to 3 MP3 references, each 3–12 seconds and at most 50 MB; requires reference_images (1–15), exclusive with start_image_url."
              type: array
              items:
                type: string
            reference_file_urls:
              type: array
              maxItems: 1
              items:
                type: string
              description: "Wan 3 / Wan 3 Prime: one document URL; mutually exclusive with reference_link_urls and frame URLs."
            reference_link_urls:
              type: array
              maxItems: 1
              items:
                type: string
              description: "Wan 3 / Wan 3 Prime: one public webpage URL; mutually exclusive with reference_file_urls and frame URLs."
            element_names:
              type: array
              items:
                type: string
            duration:
              oneOf:
                - type: string
                - type: number
            aspect_ratio:
              type: string
            resolution:
              type: string
            output_format:
              type: string
              enum: [mp4, mov]
              default: mp4
              description: "Seedance 2.5: MP4 or MOV (H.264 4:4:4). Compression is controlled by bitrate_mode."
            draft:
              type: boolean
              description: "Seedance 2.5 preview at 480p, up to 15 images."
            draft_task_id:
              type: string
              description: "Final from a completed draft belonging to the caller, valid 7 days; 1080p only. Omit prompt, inputs, duration, ratio, audio and seed."
            bitrate_mode:
              type: string
              enum: [default, standard, high]
              description: "Post-generation compression: original, stronger, lighter."
            watermark:
              type: boolean
            return_last_frame:
              type: boolean
            content_filter:
              type: boolean
              default: true
            web_search:
              type: boolean
            omni_reference_task_type:
              type: string
              enum: [auto, reference, edit, extend]
            audio:
              type: boolean
              description: Wan 3 / Wan 3 Prime output audio track (default true).
            seed:
              type: integer
              minimum: 0
              maximum: 2147483647
            nsfw_checker:
              type: boolean
              description: Wan 3 / Wan 3 Prime content-filter toggle (default false per KIE).
          additionalProperties: true
          description: >
            Extra model params (kling_mode, generate_audio, multi_prompt, …)
            — see GET /models and markdown docs. Not fully enumerated here.

    UpscaleSubmitRequest:
      allOf:
        - $ref: "#/components/schemas/BillingFields"
        - type: object
          properties:
            model:
              type: string
              description: >
                Public slug: `topaz-image`, `topaz-video`, `crystal-image`, `crystal-video`,
                `flux-video-upscale` (aliases accepted). Topaz is API-only; Crystal and FLUX
                share the Studio `/enhance` backend. Params — see GET /models and markdown docs.
            image_url:
              type: string
            video_url:
              type: string
            enhancement_model:
              type: string
            upscale_factor:
              type: number
            creativity:
              type: integer
              minimum: 0
              maximum: 1
              description: FLUX Video Upscale detail mode (0 precise, 1 creative).
            prompt:
              type: string
              description: Optional FLUX creative detail guidance.
            safety_tolerance:
              type: integer
              minimum: 0
              maximum: 4
              description: FLUX Video Upscale moderation strictness.
          additionalProperties: true

    AudioSubmitRequest:
      allOf:
        - $ref: "#/components/schemas/BillingFields"
        - type: object
          properties:
            lyrics: { type: string, maxLength: 5000 }
            gender: { type: string, enum: [female, male] }
            styles: { type: array, items: { type: string } }
            reference_id: { type: string }
            vocal_id: { type: string }
            melody_id: { type: string }
            instrumental_id: { type: string }
            conversations:
              type: array
              minItems: 1
              maxItems: 10
              items:
                type: object
                required: [text, voice]
                properties:
                  text: { type: string, minLength: 1, maxLength: 400 }
                  voice: { type: string, enum: [Ethan, Victoria, Jake, Luna, Emma] }
            model:
              type: string
              default: suno-v6
              description: Public audio slug, including Mureka song/instrumental versions, lyrics and podcast. See GET /models.
            prompt:
              type: string
              description: >
                Lyrics when `custom_mode` is true (max 5000), otherwise a song
                description (max 500). Alias: `lyrics`.
            style:
              type: string
              description: Style / genre tags (max 1000). Required when custom_mode=true.
            title:
              type: string
              default: Generated Track
            custom_mode:
              type: boolean
              default: false
            instrumental:
              type: boolean
              default: false
            negative_tags:
              type: string
            vocal_gender:
              type: string
              enum: [m, f]
              description: Custom mode only
            style_weight:
              type: number
              minimum: 0
              maximum: 1
            weirdness_constraint:
              type: number
              minimum: 0
              maximum: 1
            audio_weight:
              type: number
              minimum: 0
              maximum: 1
          additionalProperties: true

    MeshSubmitRequest:
      allOf:
        - $ref: "#/components/schemas/BillingFields"
        - type: object
          properties:
            model:
              type: string
              default: meshy-v6-t2m
              enum:
                [
                  meshy-v6-t2m,
                  meshy-v6-i2m,
                  meshy-v7-t2m,
                  meshy-v7-i2m,
                  meshy-v7-multi-i2m,
                  meshy-v71-t2m,
                  meshy-v71-i2m,
                  meshy-v71-multi-i2m,
                  hunyuan-31-pro-t2m,
                  hunyuan-31-pro-i2m,
                  tripo-h31-t2m,
                  tripo-h31-i2m,
                  tripo-h31-multi-i2m,
                  tripo-p2-t2m,
                  tripo-p2-i2m,
                  trellis-2-i2m,
                  trellis-i2m,
                  trellis-multi-i2m,
                  sam-3d-objects,
                  sam-3d-body,
                  meshy-retexture,
                  trellis-2-retexture,
                  meshy-rigging,
                  meshy-multi-animation,
                ]
              description: >
                Public mesh slug. Omit to infer: `image_url` present → `meshy-v6-i2m`,
                otherwise `meshy-v6-t2m`. Explicitly choose the matching Tripo P2 text or image slug for P2. See GET /models.
            model_url:
              type: string
              description: Source 3D file; GLB required for rigging and animation.
            mesh_url:
              type: string
              description: Source mesh for TRELLIS.2 retexture.
            text_style_prompt:
              type: string
              maxLength: 600
            image_style_url:
              type: string
            enable_original_uv:
              type: boolean
              default: true
            height_meters:
              type: number
              default: 1.7
            animation_action_ids:
              type: array
              description: 1–10 distinct actions; duplicates are billed once.
              minItems: 1
              items:
                type: integer
                minimum: 0
                maximum: 696
            task:
              type: string
              enum: [t2m, i2m, multi-i2m, objects, body, retexture, rig, animate]
              description: Alternative to `model`; must match it when both are sent.
            prompt:
              type: string
              maxLength: 2000
              description: >
                Required for t2m; optional caption for image-to-3D models that accept it.
                Tripo P2 text-to-3D allows at most 1024 characters; Tripo P2 image-to-3D rejects prompt.
            image_url:
              type: string
              description: >
                Required for i2m. Public https URL, data URI or Artillect media URL
                (`POST /api/v1/files/`); mirrored to Artillect media (SSRF-guarded).
                Aliases `input_url`, `image`, `input_urls[0]`.
                Tripo P2 image-to-3D accepts exactly one image and does not accept prompt.
            image_urls:
              type: array
              minItems: 1
              maxItems: 4
              items:
                type: string
              description: Meshy 7 or Tripo H3.1 multi-view input; P2 accepts only one image_url.
            negative_prompt:
              type: string
              maxLength: 1024
              description: Tripo P2 text-to-3D only.
            texture:
              type: boolean
              default: true
              description: Tripo P2 texture generation; disable together with pbr for the no-texture price.
            pbr:
              type: boolean
              default: true
              description: Tripo P2 PBR enables textures and uses the selected texture tier.
            texture_quality:
              type: string
              enum: [fast, standard, detailed, extreme]
              default: standard
              description: Tripo P2 texture tier; fast/standard $1.10, detailed $1.20, extreme $1.30 per model.
            texture_version:
              type: string
              enum: [v3.5-20260815, v3.0-20250812, v2.5-20250123]
              description: Tripo P2 only; fast requires v3.5-20260815.
            face_limit:
              type: integer
              minimum: 48
              maximum: 50000
              default: 30000
              description: Tripo P2 only; quad topology caps this at 25000.
            quad:
              type: boolean
              default: false
              description: Tripo P2 quad topology; no additional charge.
            delight:
              type: boolean
              default: true
              description: Tripo P2 only.
            auto_size:
              type: boolean
              default: false
              description: Tripo P2 only.
            export_uv:
              type: boolean
              default: true
              description: Tripo P2 only.
            export_orientation:
              type: string
              enum: [+x, -x, -y, +y]
              description: Tripo P2 only.
            image_seed:
              type: integer
              minimum: 0
              maximum: 4294967295
              description: Tripo P2 text-to-3D only.
            model_seed:
              type: integer
              minimum: 0
              maximum: 4294967295
              description: Tripo P2 only.
            texture_seed:
              type: integer
              minimum: 0
              maximum: 4294967295
              description: Tripo P2 only.
            model_type:
              type: string
              enum: [standard, lowpoly, smart-topology]
              default: standard
              description: Meshy 7 only; smart-topology supports at most 15000 polygons.
            topology:
              type: string
              enum: [triangle, quad]
              default: triangle
            target_polycount:
              type: integer
              minimum: 100
              maximum: 300000
              default: 30000
            symmetry_mode:
              type: string
              enum: [off, auto, on]
              default: auto
            should_remesh:
              type: boolean
              default: true
            enable_pbr:
              type: boolean
              default: false
            is_a_t_pose:
              type: boolean
              default: false
            pose_mode:
              type: string
              enum: [a-pose, t-pose, ""]
              default: ""
              description: Meshy 7 only
            texture_prompt:
              type: string
            texture_image_url:
              type: string
            mode:
              type: string
              enum: [full, preview]
              default: full
              description: t2m only
            art_style:
              type: string
              enum: [realistic, sculpture]
              default: realistic
              description: t2m only
            enable_prompt_expansion:
              type: boolean
              default: false
              description: t2m only
            seed:
              type: integer
              minimum: 0
              maximum: 4294967295
              description: t2m only
            should_texture:
              type: boolean
              default: true
              description: Meshy image-to-3D and multi-image-to-3D
            enable_rigging:
              type: boolean
              default: false
              description: Meshy 7 only; adds 20 tokens
            rigging_height_meters:
              type: number
              exclusiveMinimum: 0
              default: 1.7
            enable_animation:
              type: boolean
              default: false
              description: Meshy 7 only; requires enable_rigging and adds 12 tokens
            animation_action_id:
              type: integer
              minimum: 0
              maximum: 696
              default: 92
            enable_safety_checker:
              type: boolean
              default: true
            ultra_mode:
              type: boolean
              default: false
              description: Meshy 7 text/single-image only; standard textured generation
          additionalProperties: true

    SwitchxSubmitRequest:
      allOf:
        - $ref: "#/components/schemas/BillingFields"
        - type: object
          required: [source_url]
          properties:
            model:
              type: string
              default: switchx
            generation_type:
              type: string
            source_url:
              type: string
            reference_image_url:
              type: string
            alpha_mode:
              type: string
            alpha_url:
              type: string
            prompt:
              type: string
            max_resolution:
              type: integer
            frame_count:
              type: integer
          additionalProperties: true

    GenerationPoll:
      type: object
      required: [id, status, model]
      description: Shared poll fields for async generations (images, video, upscale, switchx, audio, mesh)
      properties:
        id:
          type: string
          description: Job id (`{uuid}-api-image`, `{uuid}-api-video`, …; legacy images `{uuid}-api`)
        status:
          $ref: "#/components/schemas/JobStatus"
        model:
          type: string
          description: Public slug; see GET /models
        cost_tokens:
          type: ["number", "null"]
        error:
          type: ["string", "null"]
          description: Provider or platform error when status=failed
        progress:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
          description: 100 when completed, 0 when failed/cancelled, null when no trustworthy intermediate provider progress is available
        provider_sync:
          type: string
          enum: [fresh, deferred]
          description: deferred when provider status sync is best-effort
        hint:
          type: string
        recommended_poll_interval_sec:
          type: integer
          minimum: 1
          description: Suggested client poll interval; also sent as `Retry-After` header

    ImagePollResponse:
      allOf:
        - $ref: "#/components/schemas/GenerationPoll"
        - type: object
          properties:
            images:
              type: array
              items:
                $ref: "#/components/schemas/MediaUrl"

    VideoPollResponse:
      allOf:
        - $ref: "#/components/schemas/GenerationPoll"
        - type: object
          properties:
            task:
              type: string
            videos:
              type: array
              items:
                $ref: "#/components/schemas/MediaUrl"

    UpscalePollResponse:
      allOf:
        - $ref: "#/components/schemas/GenerationPoll"
        - type: object
          properties:
            images:
              type: array
              items:
                $ref: "#/components/schemas/MediaUrl"
            videos:
              type: array
              items:
                $ref: "#/components/schemas/MediaUrl"

    AudioPollResponse:
      allOf:
        - $ref: "#/components/schemas/GenerationPoll"
        - type: object
          properties:
            audio:
              type: array
              items:
                $ref: "#/components/schemas/MediaUrl"
            tracks:
              type: array
              description: Same tracks with Suno metadata when the provider returns it
              items:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    format: uri
                  title:
                    type: ["string", "null"]
                  duration:
                    type: ["number", "null"]
                  image_url:
                    type: ["string", "null"]

    MeshPollResponse:
      allOf:
        - $ref: "#/components/schemas/GenerationPoll"
        - type: object
          properties:
            task:
              type: string
              enum: [t2m, i2m]
            mesh:
              type: array
              description: Mesh assets; `.glb` first when the provider returns several formats
              items:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    format: uri
                  format:
                    type: ["string", "null"]
                    description: glb | fbx | obj | usdz | stl | blend | ply | zip

    GenerationListItem:
      type: object
      required: [id, status, model]
      properties:
        id:
          type: string
        status:
          type: string
        model:
          type: string
        created_at:
          type: ["string", "null"]
          format: date-time
        cost_tokens:
          type: ["number", "null"]
        tokens_charged:
          type: ["number", "null"]
          deprecated: true
        project_id:
          type: ["integer", "null"]
        preview_text:
          type: ["string", "null"]
        source:
          type: string
          enum: [studio, public_api, mcp, cli]
          description: Origin (`client_source`); present on list routes with source filter

    GenerationList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/GenerationListItem"
        next_cursor:
          type: ["string", "null"]

    ProjectGenerationListItem:
      allOf:
        - $ref: "#/components/schemas/GenerationListItem"
        - type: object
          required: [source, kind]
          properties:
            source:
              type: string
              enum: [studio, public_api, mcp, cli]
            kind:
              type: string
              description: generations.kind (image, video, audio, text, mesh)
            preview_url:
              type: ["string", "null"]
              format: uri

    ProjectGenerationList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ProjectGenerationListItem"
        next_cursor:
          type: ["string", "null"]

    WebhookSubscription:
      type: object
      required: [url, secret_last4, include_studio, active, events, created_at, updated_at]
      properties:
        url:
          type: string
          format: uri
        secret_last4:
          type: string
          description: Last four characters of the signing secret
        include_studio:
          type: boolean
        active:
          type: boolean
        events:
          type: array
          items:
            type: string
          description: Subscribed event types
          examples:
            - [generation.completed, generation.failed]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookSubscriptionCreate:
      allOf:
        - $ref: "#/components/schemas/WebhookSubscription"
        - type: object
          properties:
            secret:
              type: string
              description: HMAC signing secret (only on initial POST 201)

    WebhookDeliveryListItem:
      type: object
      required:
        [event_id, event_type, job_id, status, attempts, last_error, next_attempt_at, created_at]
      properties:
        event_id: { type: string }
        event_type: { type: string }
        job_id: { type: string }
        status: { type: string, enum: [pending, delivered, dead] }
        attempts: { type: integer }
        last_error: { type: ["string", "null"] }
        next_attempt_at: { type: ["string", "null"], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookDeliveryDetail:
      allOf:
        - $ref: "#/components/schemas/WebhookDeliveryListItem"
        - type: object
          required: [payload]
          properties:
            payload:
              description: Full outbound JSON body (same shape as WebhookDeliveryPayload)

    WebhookDeliveryPayload:
      type: object
      required: [id, type, created_at, api_version, data]
      description: Outbound POST body delivered to the subscriber HTTPS URL
      properties:
        id:
          type: string
          description: Delivery id (for deduplication)
        type:
          type: string
          enum: [generation.completed, generation.failed]
        created_at:
          type: string
          format: date-time
        api_version:
          type: string
          const: v1
        data:
          type: object
          required: [id, modality, status, model, images, videos, error]
          properties:
            id:
              type: string
              description: Generation job id
            modality:
              type: string
              enum: [image, video, upscale, switchx]
            status:
              type: string
              enum: [completed, failed, cancelled]
            model:
              type: string
            request_id:
              type: ["string", "null"]
            cost_tokens:
              type: ["number", "null"]
            client_source:
              type: string
              enum: [public_api, mcp, cli, studio]
            images:
              type: array
              items:
                $ref: "#/components/schemas/MediaUrl"
            videos:
              type: array
              items:
                $ref: "#/components/schemas/MediaUrl"
            error:
              type: ["string", "null"]

    LlmReasoning:
      type: object
      additionalProperties: false
      description: |
        Reasoning shares max_tokens with visible output and is billed.
        Omit to use effort low on Opus 5.5; other models retain provider defaults.
        Explicit reasoning controls are preserved. Opus 5.5 requires reasoning;
        use effort low for long creative output, not none or enabled false.
        Unsupported model efforts are rejected before reservation/inference.
      properties:
        effort:
          type: string
          enum: [none, minimal, low, medium, high, xhigh, max]
        max_tokens:
          type: integer
          minimum: 1
          description: Reasoning budget where supported; must be below the output limit.
        enabled: { type: boolean }
        exclude:
          type: boolean
          description: Hides reasoning text; does not disable or reduce billed reasoning.

    ApiUpdate:
      type: object
      required: [id, date, timezone, title, url, summary, changes, client_actions]
      properties:
        id:
          type: string
          example: "2026-10-16"
        date:
          type: string
          format: date
        timezone:
          type: string
        title:
          type: string
        url:
          type: string
          format: uri
        summary:
          type: string
        changes:
          type: array
          items:
            type: object
            required: [area, description]
            properties:
              area:
                type: string
              description:
                type: string
        client_actions:
          type: array
          items:
            type: string

    ChatCompletionRequest:
      allOf:
        - $ref: "#/components/schemas/BillingFields"
        - type: object
          required: [messages]
          properties:
            model:
              type: string
              description: Omit → gpt-6-luna
            messages:
              type: array
              minItems: 1
              description: Text is passed intact; the selected model validates token context including output. No universal character cap. HTTP JSON body limit is 8 MiB.
              items:
                type: object
                required: [role]
                properties:
                  role:
                    type: string
                    enum: [system, user, assistant]
                  content:
                    description: String or OpenAI-style content parts
                    oneOf:
                      - type: string
                      - type: array
                        items:
                          type: object
                          additionalProperties: true
                  images:
                    type: array
                    items:
                      type: string
            max_tokens:
              type: integer
              minimum: 1
              description: Output and reasoning budget; defaults to the selected model maximum.
            max_completion_tokens:
              type: integer
              minimum: 1
              description: Alias for max_tokens; specify only one output limit.
            max_output_tokens:
              type: integer
              minimum: 1
              description: Alias for max_tokens; specify only one output limit.
            reasoning:
              $ref: "#/components/schemas/LlmReasoning"
            reasoning_effort:
              type: string
              enum: [none, minimal, low, medium, high, xhigh, max]
              description: Alias for reasoning.effort; do not provide both effort fields.
            stream:
              type: boolean
              default: false
              description: |
                When true, response is text/event-stream for all chat models.
                Incompatible with Idempotency-Key.
          additionalProperties: true

    ChatCompletionResponse:
      type: object
      required: [id, model, content, cost_tokens]
      properties:
        id:
          type: string
        model:
          type: string
        content:
          type: string
        finish_reason:
          type: ["string", "null"]
          description: Actual provider stop reason, including length for an incomplete answer.
        billing_pending:
          type: boolean
          description: If true, cost_tokens is the held reserve pending authoritative provider cost; final cost appears in balance/history.
        usage:
          type: object
          properties:
            input_tokens:
              type: ["integer", "null"]
            output_tokens:
              type: ["integer", "null"]
        cost_tokens:
          type: number
        project_id:
          type: ["integer", "null"]

    HealthResponse:
      type: object
      required: [ok, generation]
      properties:
        ok:
          type: boolean
        generation:
          type: object
          properties:
            backend_enabled:
              type: boolean
            kie_configured:
              type: boolean
        hint:
          type: string

    MeResponse:
      type: object
      required: [user_id, token_balance]
      properties:
        user_id:
          type: integer
        email:
          type: ["string", "null"]
        token_balance:
          type: number

    AssetItem:
      type: object
      required: [asset_id]
      properties:
        asset_id:
          type: string
          pattern: "^ast_[0-9A-HJKMNPQRSTVWXYZ]{26}$"
        scope:
          type: string
          enum: [personal, project]
        project_id:
          type: ["integer", "null"]
        filename: { type: ["string", "null"] }
        title: { type: ["string", "null"] }
        content_type: { type: string }
        kind:
          type: string
          enum: [image, video, audio, mesh]
        bytes: { type: integer }
        source: { type: string }
        created_at: { type: string, format: date-time }
        url:
          type: string
          format: uri
          description: Viewer content URL including capability token `t`
        tile_url:
          type: ["string", "null"]
          format: uri

    FeedbackRequest:
      type: object
      required: [summary, details]
      properties:
        kind:
          type: string
          enum: [bug, confusing_docs, missing_capability, wrong_pricing, praise, other]
        severity:
          type: string
          enum: [blocker, major, minor]
        summary:
          type: string
          minLength: 8
          maxLength: 200
        details:
          type: string
          minLength: 20
          maxLength: 4000
        tool_name: { type: string }
        request_id: { type: string }
        task_id: { type: string }

    MediaOperationRequest:
      oneOf:
        - type: object
          required: [operation, input_asset_ids]
          additionalProperties: false
          properties:
            operation: { type: string, enum: [concat] }
            input_asset_ids:
              type: array
              minItems: 2
              maxItems: 8
              items:
                type: string
                pattern: "^ast_[0-9A-HJKMNPQRSTVWXYZ]{26}$"
            audio: { type: string, enum: [preserve], default: preserve }
        - type: object
          required: [operation, input_asset_ids, start_sec, end_sec]
          additionalProperties: false
          properties:
            operation: { type: string, enum: [trim] }
            input_asset_ids:
              type: array
              minItems: 1
              maxItems: 1
              items:
                type: string
                pattern: "^ast_[0-9A-HJKMNPQRSTVWXYZ]{26}$"
            start_sec: { type: number, minimum: 0 }
            end_sec: { type: number, exclusiveMinimum: 0 }
            preserve_audio: { type: boolean, default: true }

    MediaOperationAccepted:
      type: object
      required: [id, status, operation, input_asset_ids, poll_url, cancel_url]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [queued] }
        operation: { type: string, enum: [trim, concat] }
        project_id: { type: ["integer", "null"] }
        input_asset_ids:
          type: array
          items: { type: string }
        poll_url: { type: string }
        cancel_url: { type: string }
        replayed: { type: boolean }

    MediaOperationPoll:
      type: object
      required: [id, status, operation, input_asset_ids]
      properties:
        id: { type: string, format: uuid }
        status: { type: string }
        operation: { type: string, enum: [trim, concat] }
        project_id: { type: ["integer", "null"] }
        input_asset_ids:
          type: array
          items: { type: string }
        progress: { type: ["number", "null"] }
        created_at: { type: string, format: date-time }
        started_at: { type: ["string", "null"], format: date-time }
        finished_at: { type: ["string", "null"], format: date-time }
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
        output_asset_id: { type: string }
        output_url: { type: string, format: uri }

    ChatConversation:
      type: object
      required: [id, title, model]
      properties:
        id: { type: integer }
        title: { type: string }
        model: { type: string }
        message_count: { type: integer }
        last_message_at: { type: ["string", "null"], format: date-time }
        created_at: { type: string, format: date-time }

    ChatMessage:
      type: object
      required: [id, role, content]
      properties:
        id: { type: integer }
        role: { type: string }
        kind: { type: string }
        content: { type: string }
        created_at: { type: string, format: date-time }

    DeviceCodeResponse:
      type: object
      required: [device_code, user_code, verification_url, expires_in, interval]
      properties:
        device_code:
          type: string
        user_code:
          type: string
        verification_url:
          type: string
          format: uri
        expires_in:
          type: integer
        interval:
          type: integer
        instructions:
          type: string

    DeviceTokenResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [pending, completed, expired]
        interval:
          type: integer
        api_key:
          type: string
          description: Present when status=completed (art_...)
        usage:
          type: string

    ProjectSummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: ["string", "null"]
        status:
          type: string
        owner_user_id:
          type: integer
        role:
          type: string
        is_personal_generations_hub:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
