openapi: 3.1.0

# The daemon's HTTP control API — what `spinloop daemon` always exposes, and what
# `spinloop serve --api` exposes alongside a foreground engine.
#
# This file is checked against the implementation by
# internal/daemon/openapi_test.go: the routes here must match the ones the
# handler registers, and each schema's properties must match the JSON tags of
# the Go struct it describes. The test compares names, not types — where the two
# could still disagree, the Go code is the source of truth.
#
# Prose on the behaviour a schema cannot express lives in docs/http-api.md.

info:
  title: spinloop daemon control API
  summary: Supervise and observe a local inference engine.
  description: |
    The control API drives one supervised inference engine: start it, stop it,
    ask what it is doing, and set what it serves next.

     It listens on `:4242` by default (`--api-addr` overrides; on the daemon,
     `--loopback` binds `127.0.0.1:4242`). A non-loopback
     listen with no bearer token configured refuses to start, since the API can
     start and stop processes; a loopback listen may go tokenless.

    Under `spinloop daemon` nothing runs until a start request asks, and stopping
    the engine never ends the daemon — the API keeps answering. Under
    `spinloop serve --api` the engine is foreground-managed, so start always
    fails as already-running and stopping the engine ends serve itself.
  license:
    name: MIT
    identifier: MIT
  version: "1"

servers:
  - url: http://127.0.0.1:4242
    description: The daemon's default loopback address.

security:
  - bearerAuth: []

tags:
  - name: engine
    description: Driving and observing the supervised engine.
  - name: config
    description: What the engine serves.

paths:
  /v1/status:
    get:
      tags: [engine]
      operationId: getStatus
      summary: Report the supervised engine's state and idle time.
      description: |
        `lastActiveAt` and `idleSeconds` are the daemon's own answer to whether
        the engine is busy, derived from token counters it samples every 15
        seconds. Both are absent until an engine has run.
      responses:
        "200":
          description: The engine's current state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/start:
    post:
      tags: [engine]
      operationId: startEngine
      summary: Start the engine.
      description: |
        The body may carry a deploy config naming what to run, and the key the
        engine is gated with — validated and persisted exactly as a push, then
        started. With no body, the stored deploy config is served, gated with
        the stored key; with nothing stored, the start fails saying so.

        A body sent with a rejected start is not stored, neither its config nor
        its key.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StartRequest"
      responses:
        "200":
          description: The engine started; the reply is its new state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          description: |
            The config is invalid, the engine failed to start, there is nothing
            to serve, or a key was supplied for an engine that cannot be gated
            by one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: An engine is already running. Nothing changed, and a carried config was not stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/stop:
    post:
      tags: [engine]
      operationId: stopEngine
      summary: Stop the engine.
      description: |
        Idempotent: stopping when nothing runs succeeds. Under `spinloop daemon`
        this never ends the daemon itself.
      responses:
        "200":
          description: The engine is stopped; the reply is its new state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          description: The engine could not be stopped.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/metrics:
    get:
      tags: [engine]
      operationId: getMetrics
      summary: Return collected engine and host metrics.
      description: |
        Engine token counters scraped from the engine's own Prometheus endpoint,
        plus host GPU, CPU and memory figures. Every stat is optional: a host
        with no source for one simply omits it, so a machine without
        `nvidia-smi` reports engine stats and no GPU figures.
      responses:
        "200":
          description: The collected stats.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Stats"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/logs:
    get:
      tags: [engine]
      operationId: getLogs
      summary: Return a slice of the engine's captured output.
      description: |
        The supervised engine's stdout and stderr, as captured to the file
        `/v1/status` reports as `logPath`. Read-only: it never touches the
        engine, so it answers whether the engine is running, stopped or
        crashed — the last of which is when it is wanted most.

        Reads are always bounded. Omit `offset` to read the **end** of the log;
        pass the `nextOffset` from a previous reply to receive only what has
        been appended since, which makes following exact — no overlap window
        and no de-duplication. Nothing rotates this file, so it grows for the
        daemon's lifetime and a full read is never offered.
      parameters:
        - name: offset
          in: query
          required: false
          description: |
            Byte position to read from, normally the `nextOffset` of a previous
            reply. Omitted, the end of the log is returned.
          schema:
            type: integer
            format: int64
            minimum: 0
        - name: limit
          in: query
          required: false
          description: |
            Maximum bytes to return. Capped by the daemon regardless of what is
            asked for; omitted, a default slice is returned.
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        "200":
          description: The requested slice of the log.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LogsResponse"
        "400":
          description: A query parameter was not a whole number, or was negative.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/deploy-config:
    put:
      tags: [config]
      operationId: putDeployConfig
      summary: Set what the next start serves.
      description: |
        A running engine is deliberately untouched — the config takes effect on
        the next start, which the reply says.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeployConfig"
      responses:
        "200":
          description: The config was stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          description: The config is invalid or names a runner this host cannot serve.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        The token comes from the `SPINLOOP_API_TOKEN` environment variable —
        reachable through the Spinloop's adjacent `.env` — never from a flag, so
        the secret stays out of the process table. When no token is configured
        the API is unauthenticated, which `Listen` permits only on loopback.

  responses:
    Unauthorized:
      description: The bearer token is missing or wrong.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    StartRequest:
      type: object
      description: |
        The body of a start request: what to run, and the key the engine is
        gated with. Mirrors Go's daemon.StartRequest, which embeds a
        DeployConfig.
      allOf:
        - $ref: '#/components/schemas/DeployConfig'
        - type: object
          properties:
            engineApiKey:
              type: string
              description: |
                The API key the engine is started with. Supplied by the caller
                — a node sources no key of its own — and travelling with the
                config it accompanies: a start carrying a config and no key
                opens the engine, while a start carrying neither reuses what
                was stored.

                It is never returned. No reply, error, or log line produced by
                this API contains it, and the daemon passes it to the engine
                as a file path rather than an argument, so it does not appear
                in the node's process list either.

    StatusResponse:
      type: object
      description: The supervised engine's state. Mirrors Go's daemon.StatusResponse.
      required: [state]
      properties:
        state:
          type: string
          enum: [idle, running, stopped, crashed]
          description: |
            `idle` means nothing has been started yet; `crashed` means the
            engine exited unprompted, and is reported rather than restarted.
        runner:
          type: string
          description: The engine being served, when known.
          examples: [llamacpp, omlx, vllm, mtplx]
        model:
          type: string
          description: What it is serving, when known.
        servedName:
          type: string
          description: |
            The name the engine answers to — the served name its deploy config
            or Spinloop set — reported beside `model` when set. An aliased
            engine answers to both, and a caller may know either.
        uptimeSeconds:
          type: integer
          description: How long the engine has been running. Zero unless running.
        logPath:
          type: string
          description: The engine's log file, when running under the daemon.
        lastActiveAt:
          type: string
          format: date-time
          description: |
            When the engine last did any work, RFC 3339. Moved forward by a
            sample showing requests in flight or a moved counter, and by an
            engine start. Absent until an engine has run.
        idleSeconds:
          type: integer
          description: Seconds since `lastActiveAt`. Absent when that is.
        engine:
          allOf:
            - $ref: '#/components/schemas/EngineEndpoint'
          description: |
            Where the running engine answers inference requests. Absent unless
            an engine is running.
        ready:
          type: string
          enum: [ready, not-ready]
          description: |
            Whether the running engine last answered its own health check,
            distinct from `state` reaching `running` — the process can be
            alive while still loading weights. Absent, not `not-ready`, when
            it does not apply: no engine is running, the running engine's
            runner has no known health-check convention, or this daemon
            predates the check. Mirrored on Stats' `ready`, from the same
            record.
        version:
          type: string
          description: |
            The spinloop binary's build-time version string, from
            `main.version`. Set to "dev" when not overridden at build time.

    EngineEndpoint:
      type: object
      description: |
        Where the supervised engine serves. Parts rather than a URL on
        purpose: the daemon knows its engine binds `127.0.0.1:8080`, which is
        useless to anyone else, and it cannot know the name a client reaches
        this host by — a LAN name, a tailscale name, a published container
        port. The caller composes these against the host it already has. A node
        that does know that name — a remote environment, whose control plane
        publishes the instance's address — reports it in `host`.
      required: [port]
      properties:
        host:
          type: string
          description: |
            The name or address a client reaches the engine by, when the node
            knows it. A daemon leaves it absent; a remote environment's status
            fills it with the instance's published address.
        port:
          type: integer
          description: |
            The port the engine listens on — the engine's, never the control
            API's. One is not derivable from the other.
          examples: [8080, 8000]
        path:
          type: string
          description: |
            The OpenAI-compatible path prefix, when it is not the usual `/v1`.
            Absent means the default.
        loopbackOnly:
          type: boolean
          description: |
            The engine is bound to loopback, so it answers only on that
            machine. Lets a remote caller explain a refused connection rather
            than merely suffer it.
        requiresKey:
          type: boolean
          description: |
            The engine was started with an API key, so a caller needs one. The
            key itself is never reported under any endpoint: authorisation to
            drive a node is not authorisation to be handed its engine's
            credential.

    Stats:
      type: object
      description: Collected engine and host metrics. Mirrors Go's metrics.Stats.
      required: [state]
      properties:
        state:
          type: string
          enum: [idle, running, stopped, crashed]
        runner:
          type: string
        modelId:
          type: string
        servedName:
          type: string
          description: |
            The name the engine answers to beside the model id, mirroring
            StatusResponse's `servedName` from the same record — so a caller
            resolving a stopped host's model from its metrics gets the same
            name a running host reports on its status.
        uptimeSeconds:
          type: integer
        tokens:
          $ref: "#/components/schemas/TokenStats"
        gpus:
          type: array
          items:
            $ref: "#/components/schemas/GpuStat"
        cpu:
          $ref: "#/components/schemas/CpuStat"
        memory:
          $ref: "#/components/schemas/MemoryStat"
        history:
          type: array
          description: |
            The retained system readings, oldest first — one per sampler tick
            while an engine ran, covering at most the last 10 minutes. They
            survive a stop (the readings up to the stop say what the engine
            was doing until it stopped) and clear when the next engine
            starts. Absent where no reading has been taken, on the same
            absence-not-zero terms as the rest of the reply.
          items:
            $ref: "#/components/schemas/HistorySample"
        errors:
          type: array
          description: Collection failures. An absent source is omitted rather than reported here.
          items:
            type: string
        lastActiveAt:
          type: string
          format: date-time
          description: |
            When the engine last did any work, RFC 3339. The same value
            `/v1/status` reports, from the same record. Reported whatever the
            engine's state — a stopped engine still says when it last worked,
            even though the figures above are absent. Absent until an engine
            has run.
        idleSeconds:
          type: integer
          description: Seconds since `lastActiveAt`. Absent when that is, and absent at zero.
        ready:
          type: string
          enum: [ready, not-ready]
          description: |
            Whether the running engine last answered its own health check.
            The same value `/v1/status` reports, from the same record.
            Absent, not `not-ready`, when it does not apply: no engine is
            running, its runner has no known health-check convention, or
            this daemon predates the check.
        retainUntil:
          type: string
          format: date-time
          description: |
            The environment's retention deadline, RFC 3339: the idle sweep
            will not terminate the instance before it. A property of the cloud
            instance, not the engine, so it is absent for local daemon nodes
            and for remote environments without an update URL. The stats reply
            carries it only while it is a time in the future — a passed
            deadline keeps nothing, so it is dropped there and on this read.

    TokenStats:
      type: object
      description: Per-engine token and request counters, read from its Prometheus endpoint.
      properties:
        running:
          type: integer
          description: Requests in flight (processing plus waiting/deferred).
        counter:
          type: integer
          description: The sum of the engine's cumulative counters — the activity signal.
        promptTokens:
          type: integer
        generationTokens:
          type: integer
        requests:
          type: integer

    GpuStat:
      type: object
      description: One GPU's figures, from nvidia-smi.
      properties:
        index:
          type: integer
        name:
          type: string
          examples: ["NVIDIA L40S"]
        utilization:
          type: integer
          description: Percent.
        memoryUsed:
          type: integer
          description: Bytes.
        memoryTotal:
          type: integer
          description: Bytes.
        temperature:
          type: integer
          description: Degrees Celsius.

    CpuStat:
      type: object
      description: Whole-host CPU utilisation.
      properties:
        utilization:
          type: number
          description: Percent.

    MemoryStat:
      type: object
      description: System memory, in bytes.
      properties:
        total:
          type: integer
        used:
          type: integer

    HistorySample:
      type: object
      description: |
        One retained reading of the host's figures, as the bar format plots
        it: a 0-100% figure per series rather than the raw one. The field
        names are one letter each because the readings ride the remote relay
        over SSM, whose command output truncates at 4KB — forty samples of
        the window must fit that budget alongside the current reading.
      required: [t]
      properties:
        t:
          type: integer
          description: When the reading was taken, unix seconds.
        c:
          type: number
          description: Whole-host CPU utilisation, percent.
        m:
          type: number
          description: System memory used over total, percent.
        g:
          type: array
          items:
            $ref: "#/components/schemas/HistoryGPU"

    HistoryGPU:
      type: object
      description: One GPU's figures in a retained reading, one-letter fields as its parent.
      required: [i, u]
      properties:
        i:
          type: integer
          description: The GPU's index.
        u:
          type: integer
          description: Utilisation, percent.
        m:
          type: number
          description: Memory used over total, percent. Absent where the GPU reports no total.

    DeployConfig:
      type: object
      description: |
        What to serve, in the same shape `spinloop remote deploy` derives from an
        Spinloop. Mirrors Go's remote.DeployConfig. There is no default runner: an
        unset or invalid one fails loudly rather than guessing.
      required: [runner]
      properties:
        runner:
          type: string
          # The engines a deploy config can name. mtplx appears because a fleet
          # node can be woken with it; it is not a cloud runner (no machine
          # image). omlx is not a wakeable runner yet.
          enum: [llamacpp, vllm, mtplx]
        modelId:
          type: string
          description: The weights to serve — a Hugging Face repo, or a local path on the instance.
        quant:
          type: string
          description: The quantisation to select, where the runner takes one.
        contextSize:
          type: integer
        parallel:
          type: integer
          description: |
            The number of concurrent request slots the engine should run
            with. Zero/absent means unset: no parallelism flag is added, and
            contextSize is used unscaled. Translated into each runner's own
            flag at start time — llamacpp's ctx-size is scaled by this value,
            since llama.cpp divides that budget across its parallel slots;
            vllm's context is left unscaled, since its concurrency is bounded
            independently via max-num-seqs.
        servedModelName:
          type: string
          description: The name the endpoint advertises the model under.
        serveArgs:
          type: array
          description: Runner-specific flags, pre-tokenised.
          items:
            type: string
        companions:
          type: object
          description: |
            Companion weights loaded beside the main weights, keyed by role.
            Each value is a bare filename within the model's own Hugging Face
            repo, never a path — the deployment decides where it lands on disk
            and names it there itself. Omitted when there are none.
          additionalProperties:
            type: string
          propertyNames:
            enum: [draft, mmproj]
        spinloopVersion:
          type: string
          description: |
            The spinloop release the environment's instances install at boot.
            Empty or absent means the boot installs the latest published
            release. A pin is normalised before it is sent — the leading v
            of a release tag is not part of the version (1.26.1, not
            v1.26.1). The environment's control plane reads it when it
            renders the boot script; the daemon does not act on it.
        instanceType:
          type: string
          description: |
            The EC2 instance type the environment's instances launch as — a
            family and size separated by a dot (g6e.xlarge). Empty or absent
            means launch as the control plane's default type. A property of
            the deployment: the environment's control plane reads it when it
            launches a fresh instance; a re-wake of a stopped instance keeps
            the type it launched with. The daemon does not act on it.

    Message:
      type: object
      description: A plain acknowledgement, where there is nothing to report but acceptance.
      required: [message]
      properties:
        message:
          type: string

    LogsResponse:
      type: object
      description: |
        A bounded slice of the engine's captured output, with the cursor needed
        to read on from it.
      required: [content, nextOffset, size]
      properties:
        content:
          type: string
          description: The slice of the log that was read.
        nextOffset:
          type: integer
          format: int64
          description: |
            The position immediately after `content`. Pass it back as `offset`
            to receive only what has been appended since.
        size:
          type: integer
          format: int64
          description: The log's current length, so a caller can see how far behind it is.
        path:
          type: string
          description: The log file being read, matching `logPath` from status.
        missing:
          type: boolean
          description: |
            There is no log file: no engine has ever run here, or the daemon
            forwards engine output to its own stdio. Distinct from a log that
            exists and is empty.
        staleOffset:
          type: boolean
          description: |
            The requested `offset` is past the end of the log — it was truncated
            or replaced — so resume from `nextOffset` rather than waiting for a
            position that will never arrive.

    Error:
      type: object
      description: The failure reply. Every non-2xx status carries one.
      required: [error]
      properties:
        error:
          type: string
