openapi: "3.1.0"

info:
  title: "Forge — LLM Gateway API"
  version: "0.1.0"
  description: |
    **Contract pinned — not yet served.** This gateway is the committed client path for LLM
    dispatch on APEX. The contract below is stable to build against behind a flag or mock; it is
    not yet live. Until launch, LLM dispatch is available to platform products only.

    The gateway gives your application one credential and one contract for language-model calls:
    an Anthropic-Messages-compatible dispatch operation and a model catalog. Requests carry your
    Forge-issued tenant API key; every call is metered per tenant and priced in South African
    Rand (`cost_zar`). Prompt-cache token counts are reported so cache-aware clients can verify
    savings.

    Send an `X-Correlation-ID` request header to thread each call through the tenant cost plane —
    the same identifier appears on the corresponding transaction record in the Forge control
    plane, giving you a per-call token and Rand audit trail.

    Streaming is supported on the dispatch operation: set `stream: true` to receive Server-Sent
    Events. Provider stream events are passed through unchanged, followed by a terminal
    `apex_usage` event carrying token counts and `cost_zar` for the whole call.

servers:
  - url: https://forge.dev.apex.reisiger.org
    description: Development — planned

tags:
  - name: LLM Dispatch
    description: >-
      Model-agnostic message dispatch. One metered turn per call — your application owns any
      agentic loop and submits each turn as a fresh request.
  - name: Model Catalog
    description: >-
      The models available for dispatch. The catalog is maintained by the platform and changes
      without a gateway release; always select models by querying the catalog rather than
      hard-coding identifiers.

security:
  - bearerAuth: []

paths:
  /api/v1/llm/messages:
    post:
      operationId: dispatchMessages
      tags: [LLM Dispatch]
      summary: Dispatch a Messages call
      x-apex-availability: planned
      description: |
        Anthropic-Messages-compatible dispatch. Message content, `system`, `tools`,
        `tool_choice`, `thinking` and `output_config` are passed to the selected provider
        verbatim, so existing Messages-shaped payloads work without translation — including raw
        content blocks (text, tool use, tool results, images) and prompt-cache directives.

        The response reports the provider that served the call, token counts (including
        prompt-cache creation and read tokens) and the metered cost in Rand.

        With `stream: true` the response is a Server-Sent Events stream: provider events
        (`message_start`, `content_block_delta`, and so on) are passed through unchanged,
        followed by a terminal `apex_usage` event whose data carries the same token counts and
        `cost_zar` as the non-streaming response body. If the call fails mid-stream, an
        `apex_error` event is emitted with data `{"status": <HTTP status>, "detail": "..."}`.
      parameters:
        - $ref: "#/components/parameters/XCorrelationId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessagesRequest"
            examples:
              simple:
                summary: Single-turn call
                value:
                  model: claude-sonnet-4-6
                  system: "You are a concise analyst. Answer in one paragraph."
                  messages:
                    - role: user
                      content: "Summarise the commercial risk in the clause below.\n\n[clause text]"
                  max_tokens: 1024
              streaming:
                summary: Streaming call with block content
                value:
                  model: claude-sonnet-4-6
                  messages:
                    - role: user
                      content:
                        - type: text
                          text: "List three follow-up questions for this tender."
                  max_tokens: 2048
                  stream: true
      responses:
        "200":
          description: >-
            Completed call. `application/json` when `stream` is false; `text/event-stream` when
            `stream` is true.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessagesResponse"
              example:
                content:
                  - type: text
                    text: "The clause shifts consequential-loss liability to the supplier without a cap…"
                stop_reason: end_turn
                model: claude-sonnet-4-6
                provider: anthropic
                input_tokens: 412
                output_tokens: 186
                cache_creation_input_tokens: 0
                cache_read_input_tokens: 0
                cost_zar: 0.0873
                metadata: {}
            text/event-stream:
              schema:
                type: string
                description: >-
                  Server-Sent Events. Provider events are passed through unchanged; the stream
                  ends with a terminal `apex_usage` event (token counts and `cost_zar`, same
                  shape as the non-streaming response) or an `apex_error` event with
                  `{"status", "detail"}` on failure.
              example: |
                event: message_start
                data: {"type":"message_start","message":{"id":"msg_01…","model":"claude-sonnet-4-6"}}

                event: content_block_delta
                data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The clause"}}

                event: apex_usage
                data: {"input_tokens":412,"output_tokens":186,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cost_zar":0.0873,"model":"claude-sonnet-4-6","provider":"anthropic"}
        "400":
          $ref: "#/components/responses/UnknownModel"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/ProviderFailure"

  /api/v1/llm/models:
    get:
      operationId: listModels
      tags: [Model Catalog]
      summary: List available models
      x-apex-availability: planned
      description: |
        Returns the model catalog, optionally filtered by provider and tier. The catalog is
        curated by the platform and evolves without notice — treat it as the source of truth for
        which model identifiers are dispatchable, and select by `tier` where you can rather than
        pinning a specific identifier.

        Tier vocabulary: `flagship` (highest capability), `balanced` (capability/cost balance),
        `fast` (low latency, low cost), `reasoning` (extended deliberation), `code`
        (code-specialised).
      parameters:
        - name: provider
          in: query
          required: false
          description: Filter by provider, for example `anthropic` or `openai`.
          schema:
            type: string
        - name: tier
          in: query
          required: false
          description: Filter by capability tier.
          schema:
            type: string
            enum: [flagship, balanced, fast, reasoning, code]
      responses:
        "200":
          description: Matching catalog entries.
          content:
            application/json:
              schema:
                type: object
                required: [models]
                properties:
                  models:
                    type: array
                    items:
                      $ref: "#/components/schemas/ModelCatalogEntry"
              example:
                models:
                  - model_id: claude-sonnet-4-6
                    provider: anthropic
                    tier: balanced
                  - model_id: gpt-5.4-mini
                    provider: openai
                    tier: fast
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Forge-issued tenant API key (`apex_live_…` or `apex_test_…`) presented as a bearer
        token. The key must carry the `llm:invoke` scope. Keys are issued and rotated in the
        Forge control plane; the tenant metered for each call is the tenant the key belongs to.

  parameters:
    XCorrelationId:
      name: X-Correlation-ID
      in: header
      required: false
      description: >-
        Recommended. Correlation identifier threaded through metering and the tenant cost plane,
        so this call's token and Rand usage can be matched to a transaction record.
      schema:
        type: string
      example: "b3e1a7c2-4f0d-4d55-9a41-7d2f8c31e6aa"

  schemas:
    Message:
      type: object
      description: >-
        One conversation turn in the Messages shape. `content` is either a plain string or a
        list of raw content blocks (text, tool use, tool result, image — prompt-cache directives
        included), passed to the provider verbatim.
      required: [role, content]
      properties:
        role:
          type: string
          description: "`user` or `assistant`."
          enum: [user, assistant]
        content:
          description: Plain text, or raw content blocks passed through to the provider.
          oneOf:
            - type: string
            - type: array
              items:
                $ref: "#/components/schemas/ContentBlock"

    ContentBlock:
      type: object
      description: >-
        A raw Messages content block (for example `{"type": "text", "text": "…"}`, tool-use,
        tool-result or image blocks). Blocks are passed through to the provider without
        modification, so any block shape the selected model supports is accepted.
      required: [type]
      properties:
        type:
          type: string
          description: Block type, for example `text`, `tool_use`, `tool_result`, `image`.
      additionalProperties: true

    MessagesRequest:
      type: object
      required: [model, messages]
      properties:
        model:
          type: string
          description: Model identifier from the catalog (`GET /api/v1/llm/models`).
          example: claude-sonnet-4-6
        messages:
          type: array
          description: Conversation turns, oldest first.
          items:
            $ref: "#/components/schemas/Message"
        system:
          description: System prompt — a plain string or a list of system content blocks.
          oneOf:
            - type: string
            - type: array
              items:
                $ref: "#/components/schemas/ContentBlock"
        tools:
          type: array
          description: >-
            Tool definitions in the Messages tool shape, passed to the provider verbatim. The
            gateway does not execute tools — tool-use turns are returned to your application.
          items:
            type: object
            additionalProperties: true
        tool_choice:
          type: object
          description: Tool-choice directive in the Messages shape, passed through verbatim.
          additionalProperties: true
        thinking:
          type: object
          description: >-
            Extended-thinking configuration in the Messages shape (for example
            `{"type": "enabled", "budget_tokens": 10000}`), passed through verbatim.
          additionalProperties: true
        output_config:
          type: object
          description: Structured-output configuration, passed through verbatim.
          additionalProperties: true
        temperature:
          type: number
          default: 0.0
          description: Sampling temperature.
        max_tokens:
          type: integer
          default: 4096
          description: Maximum output tokens for this call.
        stream:
          type: boolean
          default: false
          description: >-
            When true, the response is a Server-Sent Events stream ending with a terminal
            `apex_usage` event.

    MessagesResponse:
      type: object
      required:
        - content
        - stop_reason
        - model
        - provider
        - input_tokens
        - output_tokens
        - cost_zar
      properties:
        content:
          type: array
          description: Response content blocks (text, tool-use and thinking blocks as produced by the model).
          items:
            $ref: "#/components/schemas/ContentBlock"
        stop_reason:
          type: ["string", "null"]
          description: Why generation stopped, for example `end_turn`, `tool_use` or `max_tokens`.
        model:
          type: string
          description: Model that served the call.
        provider:
          type: string
          description: Provider that served the call, for example `anthropic`.
        input_tokens:
          type: integer
          description: Input tokens consumed.
        output_tokens:
          type: integer
          description: Output tokens produced.
        cache_creation_input_tokens:
          type: integer
          default: 0
          description: Input tokens written to the prompt cache on this call.
        cache_read_input_tokens:
          type: integer
          default: 0
          description: Input tokens served from the prompt cache on this call.
        cost_zar:
          type: number
          description: Metered cost of this call in South African Rand.
        metadata:
          type: object
          description: Additional call metadata.
          additionalProperties: true

    ModelCatalogEntry:
      type: object
      description: >-
        One dispatchable model. Entries carry the identifier, provider and capability tier;
        additional descriptive fields (such as context-window size) may be present.
      required: [model_id, provider, tier]
      properties:
        model_id:
          type: string
          description: Identifier to use in the `model` field of a dispatch request.
          example: claude-sonnet-4-6
        provider:
          type: string
          description: Serving provider, for example `anthropic` or `openai`.
        tier:
          type: string
          description: Capability tier.
          enum: [flagship, balanced, fast, reasoning, code]
      additionalProperties: true

    Error:
      type: object
      description: Error envelope.
      properties:
        detail:
          type: string
          description: Human-readable description of the failure.

  responses:
    UnknownModel:
      description: The request is invalid — most commonly an unknown `model` identifier.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "unknown model: claude-sonnet-3"
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "invalid or expired API key"
    Forbidden:
      description: The API key is valid but does not carry the `llm:invoke` scope.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "scope llm:invoke required"
    RateLimited:
      description: >-
        Tenant rate limit exceeded — requests per minute or concurrent calls. Back off and
        retry.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "rate limit exceeded for tenant"
    ProviderFailure:
      description: The upstream model provider returned an error for this call.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "provider error: upstream request failed"
