# xCloud AI public API v1 - source of truth for the wire contract.
# internal/apitypes mirrors these schemas; keep both in sync.
openapi: 3.0.3
info:
  title: xCloud AI API
  description: >
    Public REST surface over the xCloud AI order resources. Model
    placements first; adapters and training runs follow. Authenticate
    with a per-user bearer token issued by the platform.


    Every response - success, error and the head of a stream - carries
    `X-Xcloud-Version` (the server build) and `X-Xcloud-Api-Version`
    (this contract). Clients read them off the calls they already make
    to notice a control-plane release; the matching client binary is
    served from the same host (`GET /download/`).


    Lifecycle contract: names are the identity (tenant-scoped); `GET`
    on a deleted or foreign object is 404; only a placement's
    `schedule` and `teams` change in place (`PATCH`), everything else
    is create/delete. The Terraform/OpenTofu provider
    (`registry.xcloud-ai.devops.x-ion.org/xcloud/xcloudai`) and the Go SDK
    (`gitlab.x-ion.de/xlab/xcloud-ai-go`) build on exactly this.
  version: "1.0"
servers:
  - url: /
security:
  - bearerToken: []
paths:
  /v1/version:
    get:
      summary: Server build and API version
      security: []
      responses:
        "200":
          description: Version info
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VersionInfo"
  /v1/model-placements:
    get:
      summary: List model placements of the calling tenant
      description: >
        Admin-scoped tokens list ALL placements (platform view, full
        internal names); tenant tokens see only their own.
      responses:
        "200":
          description: Placement list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlacementList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Order a model placement (dedicated serving instance)
      parameters:
        - name: dryRun
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
          description: >
            Validate and render only: returns the placement as understood
            plus the Kubernetes object that WOULD be created. Nothing is
            persisted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Placement"
      responses:
        "201":
          description: Placement created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Placement"
        "200":
          description: Dry-run result (dryRun=true)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DryRunResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "409":
          description: A placement with this name already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
  /v1/model-placements/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
    get:
      summary: Get one placement
      responses:
        "200":
          description: Placement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Placement"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      summary: Change the mutable fields of a placement in place
      description: >
        Lifecycle contract (XAI-0048): only `schedule` and `teams` change
        in place. Every other field is fixed at creation - a different
        model, class, unit count, runtime, served name or command is a
        new placement (delete + create; Terraform: RequiresReplace).
        Fields absent from the body are left alone; `schedule: {}` turns
        a scheduled placement into a standing one; `teams: []` removes
        all extra team grants. Immutable fields in the body answer 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlacementPatch"
      responses:
        "200":
          description: The updated placement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Placement"
        "400":
          description: Invalid patch, or an immutable field in the body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Concurrent modification - retry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
    delete:
      summary: Tear a placement down
      responses:
        "204":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/training-jobs:
    get:
      summary: List training jobs of the calling tenant
      description: Admin-scoped tokens list ALL jobs (full internal names).
      responses:
        "200":
          description: Training job list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TrainingJobList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Submit a training run (LoRA adapter)
      parameters:
        - name: dryRun
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
          description: Validate and render only; nothing is persisted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TrainingJob"
      responses:
        "201":
          description: Training job submitted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TrainingJob"
        "200":
          description: Dry-run result (dryRun=true)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TrainingDryRunResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "409":
          description: A training job with this name already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
  /v1/training-jobs/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
    get:
      summary: Get one training job (phase, progress, artifact)
      responses:
        "200":
          description: Training job
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TrainingJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      summary: Cancel a job and remove it
      description: >
        A running job is killed and its capacity freed immediately;
        artifacts and logs in S3 stay.
      responses:
        "204":
          description: Cancelled/removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/training-jobs/{name}/logs:
    get:
      summary: Stream the job's pod log
      description: >
        Live while the job runs (follow=true), or the finished pod's log
        as long as the job exists. Plain text, chunked. Logs of deleted
        jobs are gone (S3 persistence is a planned follow-up). 501 when
        the server has no payload log access configured.
      parameters:
        - name: name
          in: path
          required: true
          schema:
            type: string
        - name: follow
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
        - name: tail
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: Log stream
          content:
            text/plain:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: No job yet (still awaiting capacity)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "501":
          description: Log access not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
  /v1/adapters:
    get:
      summary: List hosted adapters of the calling tenant
      responses:
        "200":
          description: Adapter list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdapterList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Host a LoRA adapter on a placement
      parameters:
        - name: dryRun
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Adapter"
      responses:
        "201":
          description: Adapter ordered (pipeline starts at Validating)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Adapter"
        "200":
          description: Dry-run result (dryRun=true)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdapterDryRunResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "409":
          description: An adapter with this name already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
  /v1/adapters/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
    get:
      summary: Get one adapter (pipeline phase, revision, border alias)
      responses:
        "200":
          description: Adapter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Adapter"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      summary: Unhost an adapter (deregisters the border alias; the artifact in S3 stays)
      responses:
        "204":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/pricing:
    get:
      summary: Public price list - models and GPU card rates
      description: >
        Unauthenticated by design (CORS *): the platform model catalog
        (EUR, adapter aliases excluded) plus GPU card rates. Partial
        cards pay the dominant share (billingRule); minSliceGiB is the
        smallest bookable slice. The control-plane API is the price
        authority - not the OpenAI-compatible gateway.
      operationId: getPricing
      security: []
      responses:
        "200":
          description: The price list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PricingInfo"
  /v1/datastore:
    get:
      summary: Tenant datastore coordinates + credentials
      description: >
        The training datastore is a Ceph S3 account provisioned by the
        xcloud backend (quota and billing live there). Tenant tokens
        retrieve endpoint, bucket and credentials; data itself never
        travels through this API. Admin tokens get 403. Leak rotation
        is triggered via the xcloud backend UI. 404 until provisioned.
      operationId: getDatastore
      responses:
        "200":
          description: Datastore info incl. credentials (no-store)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DatastoreInfo"
        "403":
          description: Admin tokens hold no customer storage credentials
        "404":
          description: Not provisioned yet
  /v1/models:
    get:
      summary: Model catalog - public platform models plus your own
      description: >
        Aliases, mode, runtime, base model and prices (EUR, per 1M
        tokens or per second/character/image). Prices come from the
        border gateway (priceSource says so). The list merges the
        public catalog with the caller's own placements and adapters
        (visibility=private, source, phase); other tenants' adapter
        aliases are filtered out. 501 when unconfigured.
      operationId: listModels
      responses:
        "200":
          description: The catalog
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelList"
        "501":
          description: Catalog source not configured
  /v1/compute/gpu-classes:
    get:
      summary: Bookable GPU classes with current availability
      operationId: listGPUClasses
      responses:
        "200":
          description: Classes with supply/bookable/surplus units
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GPUClassList"
        "501":
          description: Capacity viewer not configured
  /v1/compute/gpu-classes/{name}/slots:
    get:
      summary: Next bookable windows for units x class (28-day horizon)
      operationId: gpuClassSlots
      parameters:
        - name: name
          in: path
          required: true
          schema: {type: string}
        - name: units
          in: query
          required: true
          schema: {type: integer, minimum: 1}
        - name: hours
          in: query
          required: true
          schema: {type: integer, minimum: 1}
        - name: sameFabricDomain
          in: query
          schema: {type: boolean}
      responses:
        "200":
          description: Bookable windows
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GPUSlotList"
  /v1/usage:
    get:
      summary: Tokens and EUR from the billing ledger - one call
      description: >
        Aggregates the billing-readable ledger for the caller's tenant
        (admin tokens see all tenants, optionally ?tenant=). The ledger
        holds completed UTC days; "through" reports freshness. Defaults
        to the current month.
      operationId: getUsage
      parameters:
        - name: from
          in: query
          schema: {type: string, format: date}
        - name: to
          in: query
          schema: {type: string, format: date}
        - name: groupBy
          in: query
          schema: {type: string, enum: [model, day, key], default: model}
        - name: tenant
          in: query
          description: Admin only - filter one tenant.
          schema: {type: string}
      responses:
        "200":
          description: Aggregated usage report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageReport"
        "501":
          description: Usage ledger not configured
  /v1/keys:
    get:
      summary: List the caller's API keys (metadata only, never tokens)
      description: >
        Keys are declared platform-side (GitOps) as ApiKey orders. The
        API exposes metadata and rotation only - there is no create or
        delete. Admin tokens list all keys (full CR names, read-only).
      operationId: listKeys
      responses:
        "200":
          description: Keys of the caller's tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyList"
  /v1/keys/{name}/rotate:
    post:
      summary: Rotate a key and return the new token exactly once
      description: >
        Bumps the declared rotation counter and waits for the operator
        to materialize the new token (normally sub-second). Two-slot
        A/B semantics - the previous token stays valid until the NEXT
        rotation retires it. Admin tokens are read-only (403).
      operationId: rotateKey
      parameters:
        - name: name
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The freshly minted token (only appearance of plaintext)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyRotateResult"
        "403":
          description: Admin tokens are read-only
        "404":
          description: No such key in the caller's tenant
        "504":
          description: Rotation ordered, but not materialized in time
  /v1/agents:
    get:
      summary: List agent instances of the calling tenant
      responses:
        "200":
          description: Agent list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Order a hosted agent instance in the workspace
      parameters:
        - name: dryRun
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Agent"
      responses:
        "201":
          description: Agent ordered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "200":
          description: Dry-run result (dryRun=true)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentDryRunResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "409":
          description: An agent with this name already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
  /v1/agents/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
    get:
      summary: Get one agent instance
      responses:
        "200":
          description: Agent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      summary: Tear an agent down (gateway key revoked, team deleted)
      responses:
        "204":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin tokens are read-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/auth/sso:
    get:
      summary: Browser-login (SSO) configuration for the CLI
      security: []
      responses:
        "200":
          description: SSO flow endpoints
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SSOInfo"
        "404":
          description: SSO not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
  /v1/auth/exchange:
    post:
      summary: Exchange an identity-provider access token for an xCloud API token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accessToken]
              properties:
                accessToken:
                  type: string
      responses:
        "200":
          description: Minted token (shown exactly once; expires after ~30 days)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SSOExchangeResponse"
        "401":
          description: Identity provider rejected the token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
        "403":
          description: Wrong audience, no tenant group, or unknown order
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"
components:
  securitySchemes:
    bearerToken:
      type: http
      scheme: bearer
      description: Platform-issued per-user API token.
  responses:
    Unauthorized:
      description: Missing or unknown token
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/APIError"
    NotFound:
      description: No such placement (foreign placements are indistinguishable)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/APIError"
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/APIError"
  schemas:
    Placement:
      type: object
      required: [name, model, gpuClass, units]
      properties:
        name:
          type: string
          description: Placement name, unique per customer (DNS label).
          pattern: "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"
        model:
          type: string
          description: Base model reference (HF id, artifact path, profile name).
        servedName:
          type: string
          description: Public model alias at the API border. Defaults to name.
        runtime:
          type: string
          enum: [vllm, llamacpp, sglang]
          default: vllm
        gpuClass:
          type: string
          description: Capacity unit class, e.g. r9700-1 or hgx-b300-8.
        units:
          type: integer
          format: int32
          minimum: 1
        sameFabricDomain:
          type: boolean
          description: Request a contiguous unit block.
        schedule:
          $ref: "#/components/schemas/Schedule"
        command:
          type: array
          items:
            type: string
          description: >
            Advanced: overrides the serving process in the platform
            runtime image (argv). Image, mounts, and network stay
            platform-owned; a custom command may break the managed
            endpoint contract - the customer's trade-off. Omit for the
            simple "just host a model" variant.
        teams:
          type: array
          items:
            type: string
          description: >
            Workspace-gateway teams that should see this model. An
            existing team is used, a missing one is created. WHO is in a
            team stays the customer's decision - the platform registers
            the model and never assigns people. A team "<name>-users" is
            created for every placement regardless, as the handle to
            assign members to.
        status:
          readOnly: true
          allOf:
            - $ref: "#/components/schemas/PlacementStatus"
        createdAt:
          type: string
          format: date-time
          readOnly: true
    PlacementPatch:
      type: object
      description: >
        The mutable placement fields (PATCH). A missing field is left
        alone; an empty schedule object makes the placement standing; an
        empty teams array removes all extra grants.
      properties:
        schedule:
          $ref: "#/components/schemas/Schedule"
        teams:
          type: array
          items:
            type: string
    Schedule:
      type: object
      description: >
        Booked time window. Both times must be set together; omit the
        schedule entirely for a standing (open-ended) placement.
      properties:
        startTime:
          type: string
          format: date-time
        endTime:
          type: string
          format: date-time
    PlacementStatus:
      type: object
      properties:
        phase:
          type: string
          enum:
            - Pending
            - AwaitingCapacity
            - AwaitingMaterializer
            - Ready
            - Displaced
            - Failed
        reservation:
          type: string
        inferenceService:
          type: string
        message:
          type: string
    PlacementList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Placement"
    DryRunResult:
      type: object
      required: [placement, rendered]
      properties:
        placement:
          $ref: "#/components/schemas/Placement"
        rendered:
          type: object
          description: The Kubernetes object that would have been created.
    VersionInfo:
      type: object
      properties:
        version:
          type: string
        apiVersion:
          type: string
    TrainingJob:
      type: object
      required: [name, baseModel, class, gpuClass, units, data, output]
      properties:
        name:
          type: string
          pattern: "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"
        baseModel:
          type: string
          description: Model being tuned (HF id or artifact path).
        class:
          type: string
          enum: [shared, dedicated, cluster]
          description: Training product class - the customer picks the class, never the materializer.
        gpuClass:
          type: string
        units:
          type: integer
          format: int32
          minimum: 1
        sameFabricDomain:
          type: boolean
        data:
          $ref: "#/components/schemas/S3Ref"
        output:
          $ref: "#/components/schemas/S3Ref"
        schedule:
          allOf:
            - $ref: "#/components/schemas/Schedule"
          description: >
            Omitted startTime means "as soon as admitted"; endTime doubles
            as the job's hard deadline.
        training:
          $ref: "#/components/schemas/TrainingParams"
        canary:
          type: string
          enum: [auto, none]
          default: auto
        status:
          readOnly: true
          allOf:
            - $ref: "#/components/schemas/TrainingJobStatus"
        createdAt:
          type: string
          format: date-time
          readOnly: true
    S3Ref:
      type: object
      required: [bucket, key]
      properties:
        bucket:
          type: string
        key:
          type: string
          description: Object key (artifact) or prefix (dataset).
        endpoint:
          type: string
    TrainingParams:
      type: object
      properties:
        epochs:
          type: integer
          format: int32
          minimum: 1
        learningRate:
          type: string
          pattern: "^[0-9].?[0-9]+([eE][-+]?[0-9]+)?$"
          description: e.g. 0.0001 or 1.0e-4
        maxLen:
          type: integer
          format: int32
          minimum: 1
        rank:
          type: integer
          format: int32
          minimum: 1
        alpha:
          type: integer
          format: int32
          minimum: 1
        gradAccum:
          type: integer
          format: int32
          minimum: 1
    TrainingJobStatus:
      type: object
      properties:
        phase:
          type: string
          enum: [Pending, AwaitingCapacity, Running, Succeeded, Failed, BlockedOnMaterializer]
        reservation:
          type: string
        progress:
          $ref: "#/components/schemas/TrainingProgress"
        artifact:
          $ref: "#/components/schemas/TrainingArtifact"
        log:
          allOf:
            - $ref: "#/components/schemas/S3Ref"
          description: Persisted full job log (survives job cleanup).
        message:
          type: string
    TrainingProgress:
      type: object
      properties:
        epoch:
          type: integer
          format: int32
        epochs:
          type: integer
          format: int32
        loss:
          type: string
    TrainingArtifact:
      type: object
      properties:
        s3:
          $ref: "#/components/schemas/S3Ref"
        sha256:
          type: string
    TrainingJobList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/TrainingJob"
    TrainingDryRunResult:
      type: object
      required: [trainingJob, rendered]
      properties:
        trainingJob:
          $ref: "#/components/schemas/TrainingJob"
        rendered:
          type: object
    Adapter:
      type: object
      required: [name, placementRef]
      properties:
        name:
          type: string
          pattern: "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"
        trainingJobRef:
          type: string
          description: Source, variant 1 - one of your finished training jobs.
        s3:
          allOf:
            - $ref: "#/components/schemas/AdapterS3"
          description: Source, variant 2 - an uploaded artifact (sha256 mandatory).
        placementRef:
          type: string
          description: Your own placement (dedicated) or a shared platform lane.
        scale:
          type: string
          description: LoRA scale at request time.
        canary:
          type: boolean
          default: true
          description: Cross-talk canary gate.
        status:
          readOnly: true
          allOf:
            - $ref: "#/components/schemas/AdapterStatus"
        createdAt:
          type: string
          format: date-time
          readOnly: true
    AdapterS3:
      type: object
      required: [bucket, key, sha256]
      properties:
        bucket:
          type: string
        key:
          type: string
        endpoint:
          type: string
        sha256:
          type: string
    AdapterStatus:
      type: object
      properties:
        phase:
          type: string
          enum: [Validating, Converting, AwaitingDeploy, CanaryGate, Live, Failed]
        revision:
          type: string
        alias:
          type: string
          description: Border-facing model alias once registered.
        message:
          type: string
        artifact:
          $ref: "#/components/schemas/TrainingArtifact"
    AdapterList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Adapter"
    AdapterDryRunResult:
      type: object
      required: [adapter, rendered]
      properties:
        adapter:
          $ref: "#/components/schemas/Adapter"
        rendered:
          type: object
    PricingInfo:
      type: object
      properties:
        currency: {type: string}
        models:
          type: array
          items: {$ref: "#/components/schemas/Model"}
        gpuClasses:
          type: array
          items:
            type: object
            properties:
              name: {type: string}
              memoryGiB: {type: integer}
              eurPerHour: {type: number}
              note: {type: string}
        billingRule: {type: string}
        minSliceGiB: {type: integer}
    DatastoreInfo:
      type: object
      required: [endpoint, bucket, accessKey, secretKey]
      properties:
        endpoint: {type: string}
        bucket: {type: string}
        region: {type: string}
        accessKey: {type: string}
        secretKey: {type: string}
    Model:
      type: object
      properties:
        name: {type: string}
        mode: {type: string}
        runtime: {type: string}
        baseModel: {type: string}
        size: {type: string}
        modelCard: {type: string}
        adapter: {type: string}
        supportsVision: {type: boolean}
        pricing:
          type: object
          description: EUR; only the fields matching the mode are set.
          properties:
            inputPer1MTokensEUR: {type: number}
            outputPer1MTokensEUR: {type: number}
            perSecondEUR: {type: number}
            perCharacterEUR: {type: number}
            perImageEUR: {type: number}
        priceSource: {type: string}
        visibility: {type: string, enum: [public, private]}
        source: {type: string, enum: [catalog, placement, adapter]}
        phase: {type: string}
    ModelList:
      type: object
      properties:
        items:
          type: array
          items: {$ref: "#/components/schemas/Model"}
    GPUClassList:
      type: object
      properties:
        takenAt: {type: string, format: date-time}
        items:
          type: array
          items:
            type: object
            properties:
              name: {type: string}
              supply: {type: integer}
              sellableFree: {type: integer}
              surplusFree: {type: integer}
              eurPerHour: {type: number}
    GPUSlotList:
      type: object
      properties:
        class: {type: string}
        units: {type: integer}
        hours: {type: integer}
        items:
          type: array
          items:
            type: object
            properties:
              start: {type: string, format: date-time}
              end: {type: string, format: date-time}
    UsageReport:
      type: object
      properties:
        tenant: {type: string}
        from: {type: string, format: date}
        to: {type: string, format: date}
        through:
          type: string
          format: date
          description: Newest ledger day found (ledger = completed UTC days).
        totals:
          $ref: "#/components/schemas/UsageTotals"
        rows:
          type: array
          items:
            type: object
            properties:
              tenant: {type: string}
              model: {type: string}
              day: {type: string, format: date}
              keyAlias: {type: string}
              requests: {type: integer}
              promptTokens: {type: integer}
              completionTokens: {type: integer}
              unit: {type: string}
              quantity: {type: number}
              costEUR: {type: number}
    UsageTotals:
      type: object
      properties:
        requests: {type: integer}
        promptTokens: {type: integer}
        completionTokens: {type: integer}
        costEUR: {type: number}
    ApiKey:
      type: object
      properties:
        name:
          type: string
        subject:
          type: string
        expiresAt:
          type: string
          format: date-time
        rotation:
          type: integer
          format: int64
        status:
          type: object
          properties:
            phase:
              type: string
              enum: [Pending, Ready, Failed]
            message:
              type: string
            fingerprint:
              type: string
              description: sha256 prefix of the current token
            previousFingerprint:
              type: string
            rotatedAt:
              type: string
              format: date-time
        createdAt:
          type: string
          format: date-time
    ApiKeyList:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ApiKey"
    ApiKeyRotateResult:
      type: object
      required: [name, token, fingerprint, rotation]
      properties:
        name:
          type: string
        token:
          type: string
          description: The new bearer token - shown exactly once.
        fingerprint:
          type: string
        rotation:
          type: integer
          format: int64
        previousFingerprint:
          type: string
          description: Still valid until the next rotation retires it.
    Agent:
      type: object
      required: [name, model]
      properties:
        name:
          type: string
          pattern: "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"
        model:
          type: string
          description: >
            Workspace-gateway model alias; the agent's key is scoped to
            exactly this model.
        approvals:
          type: string
          default: off
        memory:
          type: string
          enum: [hindsight]
          description: Optional private, persistent Hindsight memory bank.
        endpointModel:
          type: string
          description: Workspace-gateway alias published for this agent.
          readOnly: true
        status:
          readOnly: true
          allOf:
            - $ref: "#/components/schemas/AgentStatus"
        createdAt:
          type: string
          format: date-time
          readOnly: true
    AgentStatus:
      type: object
      properties:
        phase:
          type: string
          enum: [Pending, Ready, Failed, BlockedOnEntitlement]
        message:
          type: string
    AgentList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Agent"
    AgentDryRunResult:
      type: object
      required: [agent, rendered]
      properties:
        agent:
          $ref: "#/components/schemas/Agent"
        rendered:
          type: object
    SSOInfo:
      type: object
      properties:
        issuer:
          type: string
        clientID:
          type: string
        deviceAuthorizationEndpoint:
          type: string
        tokenEndpoint:
          type: string
    SSOExchangeResponse:
      type: object
      properties:
        token:
          type: string
        aiService:
          type: string
        subject:
          type: string
        expiresAt:
          type: string
          format: date-time
    APIError:
      type: object
      required: [error]
      properties:
        error:
          type: string
