# Otium public API — OpenAPI specification.
#
# This describes the internet-facing submit surface served by pkg/api (binary
# otium-api) on :8080. It is the contract AR15.Build's pipeline integrates against
# via an OpenAI base-URL swap. It deliberately does NOT cover the operator surface
# (pkg/adminapi, :8481), which has a distinct blast radius and is not customer-facing.
#
# Status: bootstrap. Kept hand-written and in lock-step with pkg/api so it can later
# back published API docs (e.g. a rendered reference + try-it console). When you change
# a handler, request body, or wire type in pkg/api / pkg/openai, update this file.
#
# OpenAPI 3.0.3 is used deliberately: it is the version with the broadest code-generator
# support (oapi-codegen, openapi-generator), which is what backs `task api:client`.
# Nullable fields use `nullable: true`; fixed values use a single-entry `enum`.
openapi: 3.0.3

info:
  title: Otium Public API
  version: 0.1.0
  description: |
    Submit and track flexible-deadline batch AI inference jobs.

    Otium runs flexible-deadline AI inference on idle and low-cost compute. Work is
    accepted quickly, buffered durably, and scheduled to wait for the cheapest
    acceptable compute within the caller's SLA window (1h–7d).

    Two surfaces share this base URL:

    - A small **native** submit API (`POST /v1/jobs`, `GET /v1/jobs/{id}`).
    - An **OpenAI-compatible** Files + Batch API (`/v1/files`, `/v1/batches`) so an
      existing OpenAI client integrates by swapping its base URL. These endpoints
      speak OpenAI's JSON shapes and error envelope. They register only when the
      backing object + batch stores are wired.

    **Flexible deadlines.** The SLA tier (native `tier`, or the batch
    `completion_window`) is the latency-for-cost knob: a longer window gives the
    scheduler more freedom to wait for cheap compute. Otium launches with the
    `remnant` (<7d) tier as the native default; shorter tiers roll out as pricing is
    calibrated.
  contact:
    name: Otium
    url: https://github.com/spectrum-labs-tech/otium
  license:
    name: See repository LICENSE
    url: https://github.com/spectrum-labs-tech/otium/blob/main/LICENSE

servers:
  - url: http://localhost:8080
    description: Local dev instance (otium-api).

security:
  - bearerAuth: []

tags:
  - name: Health
    description: Liveness / readiness probes.
  - name: Jobs
    description: Otium-native job submission and lookup.
  - name: Files
    description: OpenAI-compatible Files API (batch input upload, output download).
  - name: Batches
    description: OpenAI-compatible Batch API.

paths:
  /livez:
    get:
      tags: [Health]
      summary: Liveness probe.
      description: |
        Answers only "is this process running". It never consults a dependency, and that is
        the point: a downed database or payload key store is a readiness condition, not a
        reason to restart the process. Restarting cannot reach an unreachable vault — it just
        churns the pod and blocks the rollout that would carry the fix. Unauthenticated.
      security: []
      operationId: getLive
      responses:
        "200":
          description: The process is running.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthOK"

  /healthz:
    get:
      tags: [Health]
      summary: Readiness probe.
      description: |
        Reports the observed health of the dependencies that gate every payload path — the
        database and the payload encryption key store — so a load balancer can route around a
        degraded instance. Use /livez for liveness. Unauthenticated.
      security: []
      operationId: getHealth
      responses:
        "200":
          description: Healthy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthOK"
        "503":
          description: A gating dependency (database or key store) is unavailable; this instance is degraded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthDegraded"

  /v1/jobs:
    post:
      tags: [Jobs]
      summary: Submit a job.
      description: |
        Validates, persists, and acknowledges a single inference job, then returns
        immediately — scheduling and execution happen asynchronously.

        **Idempotency.** Supply `idempotency_key` to make resubmission safe: a repeat
        with the same key (per tenant) returns the original job with `200` instead of
        creating a second. A brand-new job is created with `201`.
      operationId: submitJob
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitJobRequest"
      responses:
        "201":
          description: Job created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
        "200":
          description: Idempotent replay — the existing job for this idempotency key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
        "400":
          description: Invalid request (bad JSON, missing model, unknown tier or boundary).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NativeError"
        "429":
          description: Per-client-IP submit rate limit exceeded.
        "503":
          description: Database unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NativeError"

  /v1/jobs/{id}:
    get:
      tags: [Jobs]
      summary: Get a job.
      description: Returns a job by id. A tenant may only read its own jobs.
      operationId: getJob
      parameters:
        - $ref: "#/components/parameters/JobId"
      responses:
        "200":
          description: The job.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
        "404":
          description: Not found, or owned by another tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NativeError"

  /v1/files:
    post:
      tags: [Files]
      summary: Upload a batch input file.
      description: |
        Uploads a JSONL batch input file (`purpose=batch`). Each line is a
        `BatchRequestInput`. OpenAI-compatible. Uploads above the server limit are
        rejected with `413`.
      operationId: uploadFile
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [purpose, file]
              properties:
                purpose:
                  type: string
                  enum: [batch]
                  description: Only `batch` is accepted on upload.
                file:
                  type: string
                  format: binary
                  description: JSONL file; one BatchRequestInput per line.
      responses:
        "200":
          description: Uploaded file metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FileObject"
        "400":
          description: Unsupported purpose or missing file field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"
        "413":
          description: File exceeds the maximum upload size.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"
    get:
      tags: [Files]
      summary: List files.
      operationId: listFiles
      parameters:
        - name: purpose
          in: query
          required: false
          schema:
            type: string
            enum: [batch, batch_output]
          description: Filter by purpose. Omit for all of the tenant's files.
      responses:
        "200":
          description: A single page of files (OpenAI list envelope).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FileList"

  /v1/files/{id}:
    get:
      tags: [Files]
      summary: Get file metadata.
      operationId: getFile
      parameters:
        - $ref: "#/components/parameters/FileId"
      responses:
        "200":
          description: File metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FileObject"
        "404":
          description: No such file (or owned by another tenant).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"
    delete:
      tags: [Files]
      summary: Delete a file.
      operationId: deleteFile
      parameters:
        - $ref: "#/components/parameters/FileId"
      responses:
        "200":
          description: Deletion acknowledgement.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FileDeleted"
        "404":
          description: No such file (or owned by another tenant).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"

  /v1/files/{id}/content:
    get:
      tags: [Files]
      summary: Download file content.
      description: Streams the raw JSONL content of a file.
      operationId: getFileContent
      parameters:
        - $ref: "#/components/parameters/FileId"
      responses:
        "200":
          description: Raw JSONL content.
          content:
            application/jsonl:
              schema:
                type: string
        "404":
          description: No such file (or owned by another tenant).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"

  /v1/batches:
    post:
      tags: [Batches]
      summary: Create a batch.
      description: |
        Creates a batch over a previously uploaded input file. OpenAI-compatible.

        **Completion window → SLA tier.** `completion_window` accepts OpenAI's `24h`
        (and empty, treated as `24h`), the shorthands `1h` / `6h` / `7d` / `168h`,
        and Otium's own tier names as an extension — the flexible-deadline knob.

        **Data residency.** Set `metadata.boundary` (e.g. `us`, `eu`) to lock every
        job in the batch to a residency boundary.
      operationId: createBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBatchRequest"
      responses:
        "200":
          description: The created batch (status `validating`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Batch"
        "400":
          description: Invalid request (bad JSON, missing input_file_id, unsupported endpoint/window, too much metadata, invalid boundary).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"
        "404":
          description: input_file_id does not exist or is not owned by the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"
    get:
      tags: [Batches]
      summary: List batches.
      operationId: listBatches
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
          description: Max batches to return. Defaults to the server's page size.
      responses:
        "200":
          description: A single page of batches (OpenAI list envelope).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchList"

  /v1/batches/{id}:
    get:
      tags: [Batches]
      summary: Get a batch.
      operationId: getBatch
      parameters:
        - $ref: "#/components/parameters/BatchId"
      responses:
        "200":
          description: The batch.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Batch"
        "404":
          description: No such batch (or owned by another tenant).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"

  /v1/batches/{id}/cancel:
    post:
      tags: [Batches]
      summary: Cancel a batch.
      description: |
        Requests cancellation: the batch moves to `cancelling`, the processor stops
        outstanding jobs and finalizes what completed. Idempotent while already
        `cancelling`. Cancelling a terminal batch is a `409`.
      operationId: cancelBatch
      parameters:
        - $ref: "#/components/parameters/BatchId"
      responses:
        "200":
          description: The batch, now `cancelling` (or unchanged if already cancelling).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Batch"
        "404":
          description: No such batch (or owned by another tenant).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"
        "409":
          description: The batch is in a terminal status and cannot be cancelled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAIError"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <api-key>`. With a key store wired the token is
        verified and resolved to a tenant; otherwise the token is taken as the tenant
        id (PoC stand-in). A missing/invalid key degrades to the `anonymous` tenant.

  parameters:
    JobId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Job id.
    FileId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: File id (e.g. `file-<uuid>`).
    BatchId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: Batch id (e.g. `batch_<uuid>`).

  schemas:
    # --- shared enums ---
    Tier:
      type: string
      description: SLA tier — the completion window the caller will wait.
      enum: [rush, priority, standard, remnant]
      x-windows:
        rush: "< 1 hour"
        priority: "< 6 hours"
        standard: "< 24 hours"
        remnant: "< 7 days"
    Boundary:
      type: string
      description: |
        Data-residency boundary (never a raw cloud region). Empty means unconstrained.
        Coarse and fine tags coexist (e.g. a region in `eu` may also be in `eu-de`).
      enum: [af, apac, ca, eu, eu-de, me, na, sa, uk, us]
    Sensitivity:
      type: string
      description: |
        Data-sensitivity class. Empty defaults to `internal` (trusted-only). Sets the minimum
        worker trust tier the job may run on. See docs/trust-tiers.md.
      enum: [confidential, internal, public]
    TrustTier:
      type: string
      description: |
        Minimum worker trust tier required to run the job, derived from its sensitivity at
        submit. Control-plane-assigned to workers; never self-reported.
      enum: [known_provider, owner_trusted, untrusted]
    JobStatus:
      type: string
      description: Position in the job lifecycle state machine.
      enum:
        - accepted
        - hold
        - queued
        - scheduled
        - dispatched
        - running
        - succeeded
        - failed
        - interrupted
        - expired
        - dead_letter
        - cancelled
    BatchStatus:
      type: string
      description: OpenAI batch lifecycle status.
      enum:
        - validating
        - failed
        - in_progress
        - finalizing
        - completed
        - expired
        - cancelling
        - cancelled

    # --- native jobs ---
    SubmitJobRequest:
      type: object
      required: [model]
      properties:
        model:
          type: string
          description: >-
            What to run. An intent alias (e.g. `otium-medium`) or an exact catalog model id.
            Required. An alias is resolved to a concrete model server-side; an unresolvable
            value is rejected with 400.
        otium:
          allOf:
            - $ref: "#/components/schemas/OtiumIntent"
          description: >-
            Optional intent extension — declare an outcome (task, quality floor, family) rather
            than only a model. OpenAI-compatible (stock clients ignore it).
        tier:
          allOf:
            - $ref: "#/components/schemas/Tier"
          description: SLA tier. Omit to default to `remnant`.
        payload:
          type: string
          description: Small inline request body (PoC). Production payloads live in object storage.
        idempotency_key:
          type: string
          description: Per-tenant key making resubmission safe. A repeat returns the original job.
        max_attempts:
          type: integer
          minimum: 1
          default: 3
          description: Max execution attempts before dead-lettering. Non-positive values default to 3.
        boundary:
          allOf:
            - $ref: "#/components/schemas/Boundary"
          description: Locks the job to a residency boundary. Empty means unconstrained.
        sensitivity:
          allOf:
            - $ref: "#/components/schemas/Sensitivity"
          description: Data-sensitivity class. Empty defaults to `internal` (trusted-only).
        max_tokens:
          type: integer
          description: Output-token ceiling. Bounds the up-front cost estimate the prepaid gate reserves; 0 uses a default ceiling.
      additionalProperties: false

    OtiumIntent:
      type: object
      description: >-
        The optional `otium` selection extension: declare an outcome instead of a model. Rung 1
        routes on the `model` alias alone; these fields are carried for the measured router and
        planner (later rungs) and do not yet change the resolved model.
      properties:
        task:
          type: string
          description: The task kind, e.g. extract | classify | summarize | generate | chat.
        quality:
          type: string
          description: The quality floor — the cheapest execution that meets it, e.g. good | high | best.
        family:
          type: string
          description: Model-family constraint — auto (default) | qwen | gemma | glm | an exact model id.
      additionalProperties: false

    Job:
      type: object
      description: The durable record of one unit of deferred inference work.
      properties:
        id:
          type: string
          format: uuid
        idempotency_key:
          type: string
        tenant:
          type: string
        product:
          type: string
          description: The sellable SKU the job was submitted under (billing keys on it). Empty for pre-product jobs.
        model:
          type: string
          description: The concrete model the job runs on (dispatch/provisioning key on it).
        status:
          $ref: "#/components/schemas/JobStatus"
        tier:
          $ref: "#/components/schemas/Tier"
        deadline:
          type: string
          format: date-time
          description: SLA deadline; the job expires if not completed by this time.
        boundary:
          $ref: "#/components/schemas/Boundary"
        sensitivity:
          $ref: "#/components/schemas/Sensitivity"
        required_trust:
          $ref: "#/components/schemas/TrustTier"
        run_id:
          type: string
          description: >-
            Workflow run this job is an attempt of. Empty for an ordinary job. A workflow
            job is scheduled, leased and executed by exactly the same path as any other;
            these two fields are only how a completion finds its graph node.
        node_key:
          type: string
          description: Workflow node this job is an attempt of. Empty for an ordinary job.
        attempts:
          type: integer
        max_attempts:
          type: integer
        last_error:
          type: string
          description: Category/summary of the last failure. Never contains payload content.
        payload:
          type: string
        result:
          type: string
        input_ref:
          type: string
          description: Object-storage key for the input when not stored inline.
        result_ref:
          type: string
          description: Object-storage key for the result when not stored inline.
        leased_by:
          type: string
          description: Worker id holding the job while running.
        lease_expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        queued_at:
          type: string
          format: date-time
        scheduled_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
        input_tokens:
          type: integer
        output_tokens:
          type: integer
        cached_tokens:
          type: integer
        cost_usd:
          type: number
        estimated_cost_usd:
          type: number
      required:
        [
          id,
          tenant,
          model,
          status,
          tier,
          deadline,
          attempts,
          max_attempts,
          created_at,
        ]

    # --- OpenAI-compatible files ---
    FileObject:
      type: object
      description: OpenAI representation of an uploaded or generated file.
      properties:
        id:
          type: string
        object:
          type: string
          enum: [file]
        bytes:
          type: integer
          format: int64
        created_at:
          type: integer
          format: int64
          description: Creation time, Unix seconds.
        filename:
          type: string
        purpose:
          type: string
          enum: [batch, batch_output]
      required: [id, object, bytes, created_at, filename, purpose]

    FileList:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: "#/components/schemas/FileObject"
        has_more:
          type: boolean
      required: [object, data]

    FileDeleted:
      type: object
      properties:
        id:
          type: string
        object:
          type: string
          enum: [file]
        deleted:
          type: boolean
      required: [id, object, deleted]

    # --- OpenAI-compatible batches ---
    CreateBatchRequest:
      type: object
      required: [input_file_id]
      properties:
        input_file_id:
          type: string
          description: Id of an uploaded file with purpose `batch`, owned by the caller.
        endpoint:
          type: string
          enum: ["/v1/chat/completions"]
          default: "/v1/chat/completions"
          description: The only endpoint supported today.
        completion_window:
          type: string
          description: |
            OpenAI `24h` (default), the shorthands `1h` / `6h` / `7d` / `168h`, or an
            Otium tier name (`rush`, `priority`, `standard`, `remnant`).
          default: "24h"
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 16
          description: |
            Up to 16 string key/value pairs. The reserved key `boundary` locks every
            job in the batch to a residency boundary.
      additionalProperties: false

    Batch:
      type: object
      description: OpenAI representation of a batch. Unset timestamps serialize as null.
      properties:
        id:
          type: string
        object:
          type: string
          enum: [batch]
        endpoint:
          type: string
        errors:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/BatchErrorList"
        input_file_id:
          type: string
        completion_window:
          type: string
        status:
          $ref: "#/components/schemas/BatchStatus"
        output_file_id:
          type: string
        error_file_id:
          type: string
        created_at:
          type: integer
          format: int64
          description: Unix seconds.
        in_progress_at:
          type: integer
          format: int64
          nullable: true
        expires_at:
          type: integer
          format: int64
          nullable: true
        finalizing_at:
          type: integer
          format: int64
          nullable: true
        completed_at:
          type: integer
          format: int64
          nullable: true
        failed_at:
          type: integer
          format: int64
          nullable: true
        expired_at:
          type: integer
          format: int64
          nullable: true
        cancelling_at:
          type: integer
          format: int64
          nullable: true
        cancelled_at:
          type: integer
          format: int64
          nullable: true
        request_counts:
          $ref: "#/components/schemas/RequestCounts"
        metadata:
          type: object
          additionalProperties:
            type: string
      required:
        [
          id,
          object,
          endpoint,
          input_file_id,
          completion_window,
          status,
          created_at,
          request_counts,
        ]

    BatchList:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: "#/components/schemas/Batch"
        has_more:
          type: boolean
      required: [object, data]

    RequestCounts:
      type: object
      description: Per-batch progress tally.
      properties:
        total:
          type: integer
        completed:
          type: integer
        failed:
          type: integer
      required: [total, completed, failed]

    BatchErrorList:
      type: object
      description: Non-fatal errors discovered during validation.
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: "#/components/schemas/BatchError"
      required: [object, data]

    BatchError:
      type: object
      properties:
        code:
          type: string
        message:
          type: string
        param:
          type: string
        line:
          type: integer
          nullable: true
          description: 1-based offending input-file line, when applicable.
      required: [code, message]

    # --- batch file (JSONL) line shapes ---
    BatchRequestInput:
      type: object
      description: One line of an uploaded batch input file (JSONL).
      properties:
        custom_id:
          type: string
        method:
          type: string
          example: POST
        url:
          type: string
          example: /v1/chat/completions
        body:
          type: object
          description: Raw chat-completion request, passed through byte-for-byte.
      required: [custom_id, method, url, body]

    BatchRequestOutput:
      type: object
      description: One line of a generated batch output or error file. Exactly one of response/error is set.
      properties:
        id:
          type: string
        custom_id:
          type: string
        response:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/OutputResponse"
        error:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/OutputError"
      required: [id, custom_id]

    OutputResponse:
      type: object
      properties:
        status_code:
          type: integer
        request_id:
          type: string
        body:
          type: object
          description: Raw chat-completion response the runtime produced.
      required: [status_code, request_id, body]

    OutputError:
      type: object
      properties:
        code:
          type: string
        message:
          type: string
      required: [code, message]

    # --- errors ---
    HealthOK:
      type: object
      properties:
        status:
          type: string
          enum: [ok]
      required: [status]

    HealthDegraded:
      type: object
      properties:
        status:
          type: string
          enum: [degraded]
        database:
          type: string
          enum: [down]
      required: [status, database]

    NativeError:
      type: object
      description: Native-surface error envelope (`POST /v1/jobs`, `GET /v1/jobs/{id}`).
      properties:
        error:
          type: string
      required: [error]

    OpenAIError:
      type: object
      description: OpenAI-compatible error envelope used by the Files and Batch endpoints.
      properties:
        error:
          type: object
          properties:
            message:
              type: string
            type:
              type: string
              enum: [invalid_request_error, server_error]
            param:
              type: string
              nullable: true
            code:
              type: string
              nullable: true
          required: [message, type]
      required: [error]
