openapi: 3.0.3
info:
  title: Usage API
  version: 1.0.0
  description: >
    Billing-grade usage metering. One append-only usage event is recorded per
    completed LLM generation, capturing the provider's reported token counts —
    input, output, cached, and reasoning tokens — attributed to the project,
    agent, and generation. Events are immutable, so historical usage never
    changes after the fact.
  contact:
    name: SOAT API Support

servers:
  - url: '{baseUrl}'
    description: Base URL of your SOAT deployment (e.g. https://your-soat.com or http://localhost:5047)
    variables:
      baseUrl:
        description: The base URL of your SOAT deployment
        default: http://localhost:5047

tags:
  - name: Usage
    description: Inspect raw usage events

security:
  - bearerAuth: []

paths:
  /api/v1/usage/events:
    get:
      tags:
        - Usage
      summary: List usage events
      description: >
        Returns the raw usage events the caller can access, most recent first,
        narrowed by any combination of the same filters the aggregate takes —
        agent, generation, trace, actor, session, AI provider, orchestration
        run, orchestration, tool, trigger, action, outcome, meter type, model
        or `source` — so
        a rollup and the events behind it are addressed the same way. Each
        event is the per-generation token usage as reported by the provider,
        for audit and reconciliation.


        An id filter matches the id the event recorded, so it still selects
        the spend of an agent, provider, session or tool since deleted. An id
        no event in scope recorded yields an empty page rather than dropping
        the filter, so a mistyped narrowing can never widen the list past what
        was asked for.
      operationId: listUsageEvents
      parameters:
        - name: agent_id
          in: query
          required: false
          description: Filter by agent public ID
          schema:
            type: string
        - name: generation_id
          in: query
          required: false
          description: Filter by generation public ID
          schema:
            type: string
        - name: trace_id
          in: query
          required: false
          description: Filter by trace public ID
          schema:
            type: string
        - name: actor_id
          in: query
          required: false
          description: >
            Filter by the actor (end user) the usage is attributed to.
          schema:
            type: string
        - name: session_id
          in: query
          required: false
          description: >
            Filter by the session the usage was produced in.
          schema:
            type: string
        - name: ai_provider_id
          in: query
          required: false
          description: >
            Filter by the provider record the spend was billed against.
          schema:
            type: string
        - name: orchestration_run_id
          in: query
          required: false
          description: >
            Filter by the orchestration run that metered the event.
          schema:
            type: string
        - name: orchestration_id
          in: query
          required: false
          description: >
            Filter by orchestration — the events of the runs it started
            itself, not the subtree its `loop` or `sub_orchestration` nodes
            started. An orchestration that does not exist in scope yields an
            empty page.
          schema:
            type: string
        - name: model
          in: query
          required: false
          description: >
            Filter by the as-billed SKU the event recorded. Matched as
            recorded, so it still selects events whose provider has since been
            deleted.
          schema:
            type: string
        - name: trigger_id
          in: query
          required: false
          description: Filter by the trigger that initiated the generation
          schema:
            type: string
        - name: action_id
          in: query
          required: false
          description: Filter by logical action id
          schema:
            type: string
        - name: tool_id
          in: query
          required: false
          description: >
            Filter by the tool a `tool_execution` event metered, including a
            tool another project shares with one in scope.
          schema:
            type: string
        - name: publisher_project_id
          in: query
          required: false
          description: >
            Filter to the calls made through a share of a resource the named
            project owns.
          schema:
            type: string
        - name: outcome
          in: query
          required: false
          description: >
            Filter by how a `tool_execution` call ended: `ok`, `error` or
            `timeout`. Every other meter carries no outcome and is matched by
            none of them.
          schema:
            type: string
        - name: meter_type
          in: query
          required: false
          description: >
            Filter by meter type (e.g. `llm_tokens`, `compute_execution`,
            `api_request`, `storage`, `tool_execution`)
          schema:
            type: string
        - name: source
          in: query
          required: false
          description: >
            Filter by what the spend was incurred for. `eval` is an eval run's
            item generations, `eval_judge` an `llm_judge` scorer's own
            completion and `eval_scorer` a `tool` scorer's call, so
            verification spend is `source` in (`eval`, `eval_judge`,
            `eval_scorer`); `decider` is a decision's evaluation; `embedding`
            is any embedding call. Ordinary agent
            traffic carries no source and is matched by none of them.
          schema:
            type: string
            example: eval
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '400':
          description: >-
            Bad Request — a query parameter this endpoint does not declare. An
            unrecognised filter is rejected rather than ignored, since ignoring
            one answers a narrow question with the unfiltered list.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '200':
          description: Paginated list of usage events
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/UsageEvent'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/usage/aggregate:
    get:
      tags:
        - Usage
      summary: Get aggregated usage for a project
      description: >
        Returns a project's usage rolled up over an optional `[from, to]` time
        window, optionally bucketed by a single dimension and narrowed by any
        combination of sixteen filters — session, actor, agent, AI provider,
        orchestration run, orchestration, generation, trace, tool, publisher
        project, meter type,
        model, source, trigger, action and outcome. That is how "what did this conversation
        cost", "what has this end user cost" and "what did this agent cost" are
        answered. Each group and the grand total carry an `event_count`,
        summed token counts, a
        measured `quantity` per component (so infra meters report their real
        amount, not zeros), and `cost_usd` (null when no event in the bucket was
        priced). This is the per-project cost-by-range/by-category query — a
        monthly figure without scanning raw usage events client-side.


        Narrowings intersect and apply to the whole rollup — every bucket,
        `totals` and `totals.distinct` alike — so each composes with any
        `group_by`, and all sixteen are echoed back under `filters`. The ten
        naming a resource match the id the event recorded, so a deleted
        resource's spend stays selectable; an id no event recorded empties the
        rollup rather than being dropped: a mistyped id must never read back as
        the project's whole spend. `orchestration_id` is the exception — the
        event records no orchestration, so a deleted one empties the rollup.
        The other six are matched against the value the event recorded.


        `groups` is paginated. Its `total` is the number of distinct buckets in
        the window — bucket cardinality, including a null bucket for the events
        the dimension does not apply to, and never a count of entities. To
        count entities, send `include=distinct` and read `totals.distinct`.
        `totals` always describes the whole window — never the page above it,
        and never the bucketing: omit `group_by` and `totals` is unchanged
        while `groups` is empty.
      operationId: getUsageAggregate
      parameters:
        - name: project_id
          in: query
          required: true
          description: Project public ID to aggregate usage for
          schema:
            type: string
        - name: group_by
          in: query
          required: false
          description: >
            Dimension to bucket by. Omit it to skip bucketing entirely:
            `totals` still describes the whole window and `groups` is an empty
            page, which is what "what did this one thing cost" wants. A value
            naming no dimension is a `400`.
            `day` buckets on the event's UTC calendar
            day; the others bucket on the matching column. `ai_provider` buckets
            on the provider the spend was billed against — a routed
            generation's serving target, or the agent's pinned provider.
            `model` buckets on the
            model id *and* the provider that served it, so one model name served
            by two providers is two groups (see `ai_provider_id`). `source` buckets by
            what the spend was incurred for (`eval`, `eval_judge`), which is how
            verification spend is priced apart from the traffic serving real
            users; unlabelled traffic collapses into the single `null` bucket.
            `orchestration_run` buckets on the orchestration run the event
            belongs to — a standalone generation runs no orchestration and
            collapses into the single `null` bucket, so the bucket count under
            this dimension is not a count of runs.
          schema:
            $ref: '#/components/schemas/UsageGroupBy'
        - name: include
          in: query
          required: false
          description: >
            Optional extras to compute. The only value is `distinct`, which
            adds `totals.distinct` — one distinct-entity count per attribution
            column. It is opt-in because each key costs the database one extra
            sort of the window, so the default response keeps its cost whatever
            the event table grows to carry; a caller counting entities sends
            one request at `limit=1&include=distinct`. Any other value is a
            `400`.
          schema:
            type: string
            enum: [distinct]
        - name: from
          in: query
          required: false
          description: >
            Inclusive lower bound (ISO-8601 timestamp) on the event created_at.
            Omit for no lower bound.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          description: >
            Inclusive upper bound (ISO-8601 timestamp) on the event created_at.
            Omit for no upper bound.
          schema:
            type: string
            format: date-time
        - name: meter_type
          in: query
          required: false
          description: >
            Narrow the rollup to one meter type (e.g. `llm_tokens`,
            `compute_execution`, `api_request`, `storage`, `tool_execution`). Omit to include
            every meter. An unknown type yields an empty rollup, not an error.
          schema:
            type: string
        - name: session_id
          in: query
          required: false
          description: >
            Narrow the whole rollup — every bucket, `totals` and
            `totals.distinct` alike — to one session's traffic. Combines with
            `group_by`: `group_by=day&session_id=…` is a conversation's spend
            per day, `group_by=model` is the same spend split by model. Like
            every id narrowing it matches the id the event recorded, so a
            deleted session's spend stays selectable; an id no event in this
            project recorded yields an empty rollup, never the project total.
          schema:
            type: string
        - name: actor_id
          in: query
          required: false
          description: >
            The same narrowing, for one end user across every session they
            appear in. This is the figure behind a `scope: actor`,
            `metric: cost_usd` quota — what that actor has spent, against the
            cap it is held to.
          schema:
            type: string
        - name: agent_id
          in: query
          required: false
          description: >
            Narrow to one agent's traffic, across every session, run and
            trigger that dispatched it.
          schema:
            type: string
        - name: ai_provider_id
          in: query
          required: false
          description: >
            Narrow to the spend billed against one provider record — a routed
            generation's serving target, or the agent's pinned provider.
          schema:
            type: string
        - name: orchestration_run_id
          in: query
          required: false
          description: Narrow to the events one orchestration run metered.
          schema:
            type: string
        - name: orchestration_id
          in: query
          required: false
          description: >
            Narrow to the runs of one orchestration — the runs it started
            itself, never the subtree a `loop` or `sub_orchestration` node
            started under it, which is metered against the child orchestration
            where it was incurred. Additive: summed across a project's
            orchestrations the figures reach the project total exactly once.
            For the subtree figure of a single invocation, read `usage` on the
            run.
          schema:
            type: string
        - name: generation_id
          in: query
          required: false
          description: >
            Narrow to one generation's events. A generation writes more than
            one when it meters several dimensions (tokens and compute).
          schema:
            type: string
        - name: trace_id
          in: query
          required: false
          description: Narrow to the events recorded under one trace.
          schema:
            type: string
        - name: model
          in: query
          required: false
          description: >
            Narrow to one model, matched against the as-billed SKU the event
            recorded. A value naming no model yields an empty rollup, not an
            error. Under `group_by=model` this is a single-bucket rollup; under
            any other dimension it is that model's spend split by the
            dimension.
          schema:
            type: string
        - name: source
          in: query
          required: false
          description: >
            Narrow by what the spend was incurred for — `eval` and `eval_judge`
            are verification spend, `decider` is decision spend. Ordinary
            traffic carries no source, so it
            cannot be selected by this filter; omit the filter for everything.
          schema:
            type: string
        - name: trigger_id
          in: query
          required: false
          description: >
            Narrow to the spend one trigger initiated. Matched as recorded
            rather than resolved, so it still selects the spend of a trigger
            that has since been deleted.
          schema:
            type: string
        - name: action_id
          in: query
          required: false
          description: >
            Narrow to one caller-supplied action label, for per-action spend.
            Matched as recorded, like `trigger_id`.
          schema:
            type: string
        - name: tool_id
          in: query
          required: false
          description: >
            Narrow to the executions of one tool, including one another project
            shares with this one.
          schema:
            type: string
        - name: publisher_project_id
          in: query
          required: false
          description: >
            Narrow to the calls made through a share of a resource the named
            project owns.
          schema:
            type: string
        - name: outcome
          in: query
          required: false
          description: >
            Narrow `tool_execution` events by how the call ended — `ok`,
            `error` or `timeout` — matched as recorded. `outcome=error` is a
            tool's failures without a second meter.
          schema:
            type: string
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: The aggregated usage rollup
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageAggregate'
        '400':
          description: >-
            Bad Request (missing project_id, invalid group_by or timestamp, or a
            query parameter this endpoint does not declare — an unrecognised
            filter is rejected rather than ignored, since ignoring one returns
            the project-wide total in answer to a narrower question)
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/usage/thresholds:
    get:
      tags:
        - Usage
      summary: List usage thresholds
      description: >
        Lists the usage alert thresholds the caller can access, optionally
        filtered by project_id. Each threshold fires the
        `usage_thresholds.crossed` webhook when a project's cost or token usage
        over a calendar-month or rolling-24h window crosses the configured
        value.
      operationId: listUsageThresholds
      parameters:
        - name: project_id
          in: query
          required: false
          description: Filter by project public ID
          schema:
            type: string
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: The usage thresholds
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/UsageThreshold'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
    post:
      tags:
        - Usage
      summary: Create a usage threshold
      description: >
        Creates a usage alert threshold on a project. Thresholds are immutable
        apart from deletion — to change one, delete and recreate it (which
        resets its fire state).
      operationId: createUsageThreshold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUsageThresholdRequest'
      responses:
        '201':
          description: The created threshold
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageThreshold'
        '400':
          description: Bad Request (missing/invalid metric, window, or threshold)
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/usage/thresholds/{threshold_id}:
    delete:
      tags:
        - Usage
      summary: Delete a usage threshold
      description: >
        Deletes a usage threshold, resetting its fire state. Recreating a
        threshold starts its once-per-window / hysteresis state fresh.
      operationId: deleteUsageThreshold
      x-soat-resource:
        kind: usage
        from: threshold_id
      parameters:
        - name: threshold_id
          in: path
          required: true
          description: Threshold public ID
          schema:
            type: string
      responses:
        '204':
          description: Deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Threshold not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/usage/receipt:
    get:
      tags:
        - Usage
      summary: Get a generation or orchestration-run billing receipt
      description: >
        Returns a billing receipt. Pass generation_id for a per-generation
        receipt, or orchestration_run_id for a receipt summed across the
        orchestration run's events — both share the same shape (per-model line
        items with tokens, the price-book version that priced them, and cost,
        plus totals). Exactly one of generation_id or orchestration_run_id must
        be supplied.
      operationId: getUsageReceipt
      parameters:
        - name: generation_id
          in: query
          required: false
          description: >
            Generation public ID. Mutually exclusive with orchestration_run_id.
          schema:
            type: string
        - name: orchestration_run_id
          in: query
          required: false
          description: >
            Orchestration run public ID. Returns the receipt summed across every
            generation the run metered. Mutually exclusive with generation_id.
          schema:
            type: string
      responses:
        '200':
          description: The generation or run usage receipt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageReceipt'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Generation not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/usage/prices:
    get:
      tags:
        - Usage
      summary: Get the price book
      description: >
        Returns the global price book — the versioned per-provider/model unit
        prices used to compute usage cost at write time. Readable by any
        authenticated user.
      operationId: getPriceBook
      responses:
        '200':
          description: The current price book
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PriceBookResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
    put:
      tags:
        - Usage
      summary: Upsert price-book rows
      description: >
        Upserts price rows keyed on (provider, model, effective_from). Admin
        only. `effective_from` must be in the future once the
        (provider, model, component) is priced — past prices are immutable so
        recorded costs stay explainable; ship corrections as new future-dated
        rows. A first price, with no row in this scope or any broader one, may
        be dated now or earlier.
      operationId: upsertPriceBook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertPricesRequest'
      responses:
        '200':
          description: The upserted price rows
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PriceBookResponse'
        '400':
          description: Bad Request (e.g. non-future effective_from)
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    UsageComponent:
      type: object
      description: >
        One priced dimension of a usage event. Every meter type is expressed as
        components, so tokens and infra are uniform: an `llm_tokens` event has
        `input_tokens` / `output_tokens` (+ `cached_tokens` for prompt-cache
        reads, `cache_write_tokens` for prompt-cache writes, and a non-billable
        `reasoning_tokens` detail), a `compute_execution` event has one
        `compute_second` component.


        The three input dimensions are disjoint and partition the provider's
        reported prompt tokens: `input_tokens` is the uncached input **alone**.
        They are separate components because they are separately priced — a
        cache read costs a fraction of an uncached token and a cache write
        costs more than one. A dimension with no price row of its own falls
        back to the `input_tokens` rate, never to zero.
      properties:
        component:
          type: string
          description: The measured dimension (e.g. `input_tokens`, `compute_second`)
        quantity:
          type: number
          description: The measured amount, expressed in `unit`
        unit:
          type: string
          description: Unit `quantity` is measured in (`token`, `compute_second`, …)
        billable:
          type: boolean
          description: >
            Whether this component contributes to cost. Non-billable details
            (e.g. `reasoning_tokens`, a subset of `output_tokens`) are never
            priced and never double-counted into billable totals.
        unit_price:
          type: number
          nullable: true
          description: USD per `unit`, frozen at write time; null when unpriced
        cost_usd:
          type: number
          nullable: true
          description: quantity × unit_price, frozen at write time; null when unpriced
        price_id:
          type: string
          nullable: true
          description: Public ID of the price-book row that priced this component

    UsageEvent:
      type: object
      description: >
        One metered occurrence: a whole generation, a node execution, an
        embedding call. A generation is one event however many steps it took —
        a four-step turn is four LLM calls and one event, written when the turn
        ends. Attribution and total cost live here; the measured quantities
        live in `components`.
      properties:
        id:
          type: string
          description: Public ID of the usage event
          example: ue_V1StGXR8Z5jdHi6B
        project_id:
          x-soat-ref: projects
          type: string
          description: Public ID of the project the usage is attributed to
        orchestration_run_id:
          type: string
          nullable: true
          description: >
            Public ID of the orchestration run that initiated the occurrence.
            Null for standalone events.
        node_id:
          type: string
          nullable: true
          description: Orchestration node ID within the run, when applicable
        agent_id:
          x-soat-ref: agents
          type: string
          nullable: true
          description: Public ID of the agent that ran the generation
        generation_id:
          x-soat-ref: generations
          type: string
          nullable: true
          description: Public ID of the generation this usage was recorded for
        trace_id:
          x-soat-ref: traces
          type: string
          nullable: true
          description: Public ID of the trace this usage belongs to
        actor_id:
          x-soat-ref: actors
          type: string
          nullable: true
          description: >
            Public ID of the actor (end user) the metered occurrence was
            produced for, frozen at write time. Null when no end user is behind
            the work — orchestration runs, triggers, direct API generations.
        session_id:
          x-soat-ref: sessions
          type: string
          nullable: true
          description: >
            Public ID of the session the metered occurrence ran in, frozen at
            write time. Null for work not dispatched through a session.
        ai_provider_id:
          x-soat-ref: ai-providers
          type: string
          nullable: true
          description: >
            Public ID of the AI provider instance billed — the target a model
            route picked for the turn, or the agent's pinned provider. Kept
            after the provider is deleted, like every attribution id on the
            event.
        trigger_id:
          x-soat-ref: triggers
          type: string
          nullable: true
          description: >
            Public ID of the trigger that initiated the generation (agent-target
            triggers). Null when not started by a trigger.
        action_id:
          type: string
          nullable: true
          description: >
            Caller-supplied logical action label, for rolling spend up per
            action. Null when the caller did not label the action.
        tool_id:
          x-soat-ref: tools
          type: string
          nullable: true
          description: >
            Public ID of the tool a `tool_execution` event metered. Null on
            every other meter and for an inline tool definition.
        publisher_project_id:
          x-soat-ref: projects
          type: string
          nullable: true
          description: >
            The project that owns the resource this event metered, when the
            call reached it through a share. The event itself belongs to the
            calling project. Null for a call on the project's own resources.
        document_id:
          x-soat-ref: documents
          type: string
          nullable: true
          description: >
            Public ID of the document an `embedding` event embedded a chunk of —
            ingestion, a re-chunk, a conversation's turns. Null on a query
            embedding and on every other meter.
        memory_store_id:
          x-soat-ref: memory-stores
          type: string
          nullable: true
          description: >
            Public ID of the memory store an `embedding` event embedded written
            content for. The store rather than the memory: stored content is
            shared by every memory in the store that states it. Null on a query
            embedding and on every other meter.
        outcome:
          type: string
          nullable: true
          description: >
            How a `tool_execution` call that went out ended, whatever the target
            answered: `ok`, `error` (including a non-2xx answer), or `timeout`.
            Null on every other meter.
        meter_type:
          type: string
          description: >
            What the event measures: `llm_tokens`, `compute_execution`,
            `api_request`, `storage`, or `tool_execution` — one outbound tool
            call, a single `tool_call` component of quantity 1, unpriced.
        source:
          type: string
          nullable: true
          description: >
            What the spend was incurred for. `eval` is an eval run's item
            generations and `eval_judge` an `llm_judge` scorer's own completion,
            and `eval_scorer` a `tool` scorer's call, so verification spend is
            `source` in (`eval`, `eval_judge`, `eval_scorer`) and they are
            distinct so running a suite can be priced apart from grading it. `decider` is a decision's evaluation. `embedding` is any embedding call — the endpoint, document
            ingestion, a memory store write, or a search's query vector. Null for
            ordinary agent traffic.
          example: eval
        provider:
          type: string
          description: >
            As-billed SKU vendor slug, retained even if the AI provider is
            deleted. `soat` for platform meter types.
          example: openai
        model:
          type: string
          description: Model id, or the billable SKU for platform meter types
          example: gpt-4o
        cost_usd:
          type: number
          nullable: true
          description: >
            Total USD cost — the sum of the priced component costs, frozen at
            write time. Null when no component was priced.
        components:
          type: array
          items:
            $ref: '#/components/schemas/UsageComponent'
        created_at:
          type: string
          format: date-time

    UsageGroupBy:
      type: string
      description: >
        The dimension an aggregate buckets on. `orchestration_run` buckets on
        the orchestration run; work that runs no orchestration collapses into
        the single `null` bucket. `tool` buckets `tool_execution` events on
        the tool they metered; every other meter is the `null` bucket.
      enum:
        [
          model,
          ai_provider,
          agent,
          orchestration_run,
          day,
          meter_type,
          actor,
          session,
          source,
          tool,
        ]

    UsageDistinct:
      type: object
      description: >
        How many distinct entities of each kind the window touched — the
        counters a "how many generations / runs / actors this cycle" question
        reads. Present on `totals` exactly when `include=distinct` was sent,
        and never on a group — filling it per bucket would mean a
        `COUNT(DISTINCT)` per bucket on the page query.


        Nulls are not counted: a generation-less completion (`chat`,
        `memory_extraction`, `memory_consolidation`, `eval_judge`) contributes
        to `event_count` and to nothing here; a standalone generation
        contributes to `generations` and not to `orchestration_runs`; a retried
        orchestration node — a second event and a second generation — counts
        once as a run.


        Every figure describes one window and none of them add. Two adjacent
        windows' `sessions` overlap wherever a session spans the boundary, and
        a run that straddles midnight is in two `day` windows. A wider figure
        is a wider query, never a sum of narrower ones.
      properties:
        generations:
          type: integer
        traces:
          type: integer
        orchestration_runs:
          type: integer
        agents:
          type: integer
        actors:
          type: integer
        sessions:
          type: integer
        ai_providers:
          type: integer
        tools:
          type: integer
        documents:
          type: integer
        memory_stores:
          type: integer
        publisher_projects:
          type: integer

    UsageAggregateComponent:
      type: object
      description: >
        One measured dimension of a bucket, summed across its events. This is
        what makes the rollup uniform over meter types: the token fields only
        describe `llm_tokens`, so an infra meter's amount is read here.
      properties:
        component:
          type: string
          description: >
            The measured dimension: `input_tokens`, `cached_tokens`,
            `cache_write_tokens`, `output_tokens`, `reasoning_tokens`,
            `compute_second`, `request`, `gb_day`, `chunk_count`,
            `record_gb_day`, …
        unit:
          type: string
          description: >
            Unit `quantity` is measured in: `token`, `compute_second`,
            `request`, `gb_day`, `count`.
        quantity:
          type: number
          description: >
            Summed measured amount in `unit`. Non-integer for fractional
            measures (GB-days, compute seconds).
          example: 0.4
        cost_usd:
          type: number
          nullable: true
          description: >
            Sum of the priced component costs; null when no price row covered
            the component — the quantity is still captured, it does not mean the
            usage was free.

    UsageAggregateBucket:
      allOf:
        - $ref: './usage-totals.yaml#/components/schemas/UsageTotals'
        - type: object
          description: >
            Summed token counts, measured component quantities, and cost for a
            bucket (or the grand total).
          properties:
            event_count:
              type: integer
              description: >
                Usage events measured in the bucket. On `totals` this counts
                the whole `[from, to]` window.
            components:
              type: array
              description: >
                Every component measured in the bucket, sorted by `component`
                then `unit`. The token fields cover `llm_tokens` only, so this
                is where a `storage` / `api_request` / `compute_execution`
                bucket reports what it actually measured.
              items:
                $ref: '#/components/schemas/UsageAggregateComponent'

    UsageAggregateTotals:
      allOf:
        - $ref: '#/components/schemas/UsageAggregateBucket'
        - type: object
          properties:
            distinct:
              $ref: '#/components/schemas/UsageDistinct'

    UsageAggregateFilters:
      type: object
      description: >
        Every narrowing the rollup applied, echoed back exactly as the caller
        sent it and null when unset. Echoed because a rollup of zeros is
        otherwise indistinguishable from a project that spent nothing, and
        always complete so a caller reading one key can tell "not filtered"
        from "this endpoint does not know that filter". The ten naming a
        resource are resolved against the project and empty the rollup when
        they name nothing in it; the other six are matched against the value
        the event recorded.
      properties:
        meter_type:
          type: string
          nullable: true
        model:
          type: string
          nullable: true
        source:
          type: string
          nullable: true
        trigger_id:
          type: string
          nullable: true
        action_id:
          type: string
          nullable: true
        session_id:
          x-soat-ref: sessions
          type: string
          nullable: true
        actor_id:
          x-soat-ref: actors
          type: string
          nullable: true
        agent_id:
          x-soat-ref: agents
          type: string
          nullable: true
        ai_provider_id:
          x-soat-ref: ai-providers
          type: string
          nullable: true
        orchestration_run_id:
          x-soat-ref: orchestration-runs
          type: string
          nullable: true
        orchestration_id:
          x-soat-ref: orchestrations
          type: string
          nullable: true
        generation_id:
          x-soat-ref: generations
          type: string
          nullable: true
        trace_id:
          x-soat-ref: traces
          type: string
          nullable: true
        tool_id:
          x-soat-ref: tools
          type: string
          nullable: true
        publisher_project_id:
          x-soat-ref: projects
          type: string
          nullable: true
        outcome:
          type: string
          nullable: true
    UsageAggregate:
      type: object
      properties:
        project_id:
          x-soat-ref: projects
          type: string
        from:
          type: string
          format: date-time
          nullable: true
          description: Lower bound applied, echoed back; null when unbounded
        to:
          type: string
          format: date-time
          nullable: true
          description: Upper bound applied, echoed back; null when unbounded
        group_by:
          allOf:
            - $ref: '#/components/schemas/UsageGroupBy'
          nullable: true
          description: >
            Dimension applied, echoed back; null when the caller asked for no
            bucketing, in which case `groups` is an empty page.
        filters:
          $ref: '#/components/schemas/UsageAggregateFilters'
        groups:
          type: object
          description: >
            One entry per distinct value in the chosen dimension, paginated.
            Ordered by `cost_usd` descending, ties broken by `key` then
            `ai_provider_id` ascending, with nulls last — so the first page is
            the biggest spenders and paging never repeats or skips a bucket.
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/UsageAggregateGroup'
            total:
              type: integer
              description: >
                Distinct buckets in the window, independent of `limit`,
                including a null bucket for the events the dimension does not
                apply to. Bucket cardinality is not an entity count on any
                dimension that admits nulls: a project whose traffic is
                standalone generations has exactly one
                `group_by=orchestration_run` bucket whatever its volume. Read
                `totals.distinct` (under `include=distinct`) to count
                entities.
            limit:
              type: integer
            offset:
              type: integer
        totals:
          allOf:
            - $ref: '#/components/schemas/UsageAggregateTotals'
            - description: >
                The whole `[from, to]` window, never the `groups` page above it.

    UsageAggregateGroup:
      allOf:
        - type: object
          properties:
            key:
              type: string
              nullable: true
              description: >
                The bucket's value in the chosen dimension (a model id,
                meter type, AI provider / agent / orchestration run / actor /
                session / tool public id, or `YYYY-MM-DD` UTC day). Null when the
                dimension does not apply to an event — under
                `actor`/`session` this is the bucket holding everything with
                no end user behind it, and under `orchestration_run` the one
                holding every generation that ran no orchestration.
            ai_provider_id:
              type: string
              x-soat-ref: ai-providers
              nullable: true
              description: >
                Under `group_by=model`, the provider that served the
                bucket's model; null on every other dimension. A model id
                does not identify its provider on its own — one project
                can hold two providers serving byte-identical model names
                — so the model dimension buckets on (model,
                `ai_provider_id`) and two groups may repeat one `key`
                with different providers. The groups still sum to
                `totals`.
        - $ref: '#/components/schemas/UsageAggregateBucket'

    UsageThreshold:
      type: object
      description: >
        A per-project alert rule on windowed usage. When the project's `metric`
        over `window` crosses `threshold`, a `usage_thresholds.crossed` webhook
        fires. Fire state is enforced with once-per-window (calendar) / 10%
        re-arm (rolling) hysteresis.
      properties:
        id:
          type: string
          description: Public ID of the threshold
          example: uthr_V1StGXR8Z5jdHi6B
        project_id:
          x-soat-ref: projects
          type: string
        metric:
          type: string
          enum: [cost_usd, tokens]
          description: >
            What is measured: `cost_usd` (across all meter types) or `tokens`
            (input + output + cached).
        window:
          type: string
          enum: [calendar_month, rolling_24h]
          description: >
            The evaluation window: the current UTC calendar month, or the
            trailing 24 hours.
        threshold:
          type: number
          description: The value the windowed aggregate must cross to fire
        last_fired_at:
          type: string
          format: date-time
          nullable: true
          description: When the threshold last fired; null until first fire
        fired_window_key:
          type: string
          nullable: true
          description: >
            The `YYYY-MM` window key of the last fire (calendar_month
            hysteresis); null for rolling_24h and before the first fire.
        created_at:
          type: string
          format: date-time

    CreateUsageThresholdRequest:
      type: object
      required:
        - project_id
        - metric
        - window
        - threshold
      additionalProperties: false
      properties:
        project_id:
          x-soat-ref: projects
          type: string
        metric:
          type: string
          enum: [cost_usd, tokens]
        window:
          type: string
          enum: [calendar_month, rolling_24h]
        threshold:
          type: number
          description: Must be greater than 0

    UsageReceipt:
      type: object
      properties:
        generation_id:
          description: >
            Present on a per-generation receipt; absent on an
            orchestration-run receipt.
          x-soat-ref: generations
          type: string
        orchestration_run_id:
          description: >
            Present on an orchestration-run receipt (summed across the run's
            events); absent on a per-generation receipt.
          x-soat-ref: orchestration-runs
          type: string
        line_items:
          type: array
          description: >
            One line per usage event — for a generation receipt, the events on
            that generation; for an orchestration-run receipt, every event
            across the run.
          items:
            type: object
            properties:
              id:
                type: string
                description: The usage event's ID
                example: ue_V1StGXR8Z5jdHi6B
              meter_type:
                type: string
              provider:
                type: string
              model:
                type: string
              node_id:
                type: string
                nullable: true
                description: >
                  Orchestration node that produced the event. On an
                  orchestration-run receipt
                  every line carries it — an `agent` node's `llm_tokens` line and
                  the `compute_execution` line of each node execution alike — so
                  grouping the lines by `node_id` gives the per-node cost the
                  receipt total alone hides. A retried node contributes one line
                  per attempt, all under the same `node_id` — the event records no
                  attempt number — which is the intended reading for spend: a
                  retry is real money. Null when no node produced the event (a
                  standalone generation, a run-level meter).
              cost_usd:
                type: number
                nullable: true
              components:
                type: array
                items:
                  $ref: '#/components/schemas/UsageComponent'
        by_meter_type:
          type: array
          description: >
            Per-meter-type cost rollup — the "tokens + infra" split. A
            single-type receipt has one entry whose cost equals the receipt
            total.
          items:
            type: object
            properties:
              meter_type:
                type: string
              cost_usd:
                type: number
                nullable: true
        totals:
          $ref: './usage-totals.yaml#/components/schemas/UsageTotals'

    Price:
      type: object
      description: >
        A versioned unit price for one billable component of a SKU. Cost is
        uniform across meter types — quantity × unit_price.
      properties:
        id:
          type: string
          description: Public ID of the price row
          example: price_V1StGXR8Z5jdHi6B
        ai_provider_id:
          x-soat-ref: ai-providers
          type: string
          nullable: true
          description: >
            Null for a global default price. Set when this row is a
            per-provider override for a specific AI provider instance.
        meter_type:
          type: string
          description: Meter type this SKU belongs to (`llm_tokens`, `compute_execution`, …)
        provider:
          type: string
          description: SKU vendor slug (`openai`, or `soat` for platform SKUs)
          example: openai
        model:
          type: string
          description: Model id, or the billable SKU for platform meter types
          example: gpt-4o
        component:
          type: string
          description: The component this row prices (`input_tokens`, `compute_second`, …)
        unit:
          type: string
          description: Unit `unit_price` is denominated in (`token`, `compute_second`, …)
        unit_price:
          type: number
          description: USD per `unit` (for token components, USD per token)
        resource:
          type: string
          nullable: true
          description: The tool or agent SRN a resource row prices; null for a SKU row
        quantity:
          type: object
          nullable: true
          additionalProperties: true
          description: A resource row's quantity expression; null prices one per call
        effective_from:
          type: string
          format: date-time
          description: The row with the latest effective_from <= now() prices a call
        created_at:
          type: string
          format: date-time

    PriceBookResponse:
      type: object
      properties:
        prices:
          type: array
          items:
            $ref: '#/components/schemas/Price'

    UpsertPricesRequest:
      type: object
      required:
        - prices
      additionalProperties: false
      properties:
        prices:
          type: array
          items:
            type: object
            required:
              - component
              - unit
              - unit_price
              - effective_from
            description: >
              A price row for one component of a SKU. For LLM SKUs supply the
              `input_tokens` / `output_tokens` (and optionally `cached_tokens`
              and `cache_write_tokens`) components with unit `token`; for
              platform SKUs supply the platform component (e.g.
              `compute_second`).
            additionalProperties: false
            properties:
              ai_provider_id:
                x-soat-ref: ai-providers
                type: string
                nullable: true
                description: >
                  Omit for a global default price; set to a provider public ID
                  to record a per-provider override.
              meter_type:
                type: string
                default: llm_tokens
                description: >
                  Defaults to `llm_tokens`. Set to a platform meter type
                  (e.g. `compute_execution`) for a platform SKU.
              resource:
                type: string
                description: >-
                  The SRN of one tool or agent, `srn:<project_id>:<tool|agent>:<id>`,
                  for a resource row; omit `provider` and `model`. A tool's row
                  prices its `tool_execution` events, beside `tool_call`; an
                  agent's prices its `llm_tokens` events, beside the provider
                  cost. A client tool has no execution of its own and cannot
                  be priced.
                example: srn:proj_V1StGXR8Z5jdHi6B:tool:tool_V1StGXR8Z5jdHi6B
              quantity:
                type: object
                additionalProperties: true
                description: >-
                  On a resource row only: JSON Logic giving the quantity
                  `unit_price` multiplies, over `{ input, action, response,
                  outcome, duration_ms }` for a tool and `{ response: { usage,
                  cost_usd, steps, tool_calls, stop_reason }, outcome }` for an
                  agent. Omit to price one per call. A result that is not a
                  finite number >= 0 records quantity `0` and no cost.
                example: { var: response.page_count }
              provider:
                type: string
                description: The SKU vendor; omit on a resource row
              model:
                type: string
                description: The SKU; omit on a resource row
              component:
                type: string
                description: The component this row prices (`input_tokens`, `compute_second`, …)
              unit:
                type: string
                description: Unit `unit_price` is denominated in (`token`, `compute_second`, …)
              unit_price:
                type: number
                description: USD per `unit`
              effective_from:
                type: string
                format: date-time
                description: Must be in the future; past prices are immutable
