openapi: 3.1.0

info:
  title: CLARA Maps API
  version: 1.0.0
  summary: >
    Street-level environmental map layers as standard vector tiles: thermal
    comfort, pedestrian wind comfort, air quality and 3D buildings, per city.
  description: |
    The CLARA Maps API serves street-level environmental model output as
    [TileJSON 3.0.0](https://github.com/mapbox/tilejson-spec) and Mapbox
    Vector Tiles, ready to consume in MapLibre GL, Mapbox GL, OpenLayers or
    deck.gl. Live conditions are updated hourly; forecasts run out to roughly
    48 hours.

    **Cities.** The map product is available in more than one city, and the
    list grows without changes to this API. `GET /v1/cities?product=map`
    (see the main CLARA API specification) returns every city that has map
    coverage, with its identifier, name, bounding box and available layers.

    **Layers.** Up to four per city: `thermal` (UTCI thermal comfort),
    `wind` (pedestrian wind comfort), `air` (pollutant concentrations, one
    layer carrying every pollutant as feature properties) and `buildings`
    (static 3D footprints). Which layers a city has is declared in its
    catalog; clients read the catalog rather than assuming a fixed set.

    **Integration model.** A client hardcodes only the catalog URL and its
    API key. Every other URL is discovered from the catalog response and
    arrives already signed, so archive naming, timestamps and the number of
    forecast steps can change without any client change. Clients never
    construct tile URLs.

    **Authentication.** Two mechanisms, depending on who issues the request.
    The catalog is called by your own code and takes an API key as
    `Authorization: Bearer <key>`. Secret keys (`clara_sk_`) belong on your
    server; publishable keys (`clara_pk_`) may be used from a browser page
    and are checked against the key's origin allowlist. TileJSON and tile
    URLs are fetched internally by map libraries, which attach no custom
    headers, so those carry a signature in the query string (`exp` plus
    `sig`) instead. The catalog returns them fully signed.

    **Errors.** RFC 9457 `application/problem+json`. Every error carries a
    machine-readable `code`, a human-readable `detail` and a `request_id`
    for support. An empty tile returns `204 No Content`, which is not an
    error.

    **Timestamps.** All times are ISO 8601 UTC, and are additionally given
    as epoch milliseconds in `timestamp_ms`. The spacing between forecast
    steps is irregular; clients must read each `timestamp_ms` rather than
    assuming a fixed interval.

    **Classification scales.** The class definitions used to colour the
    layers (air quality index classes, UTCI stress categories, wind comfort
    classes) are served by the main API at `GET /v1/scales`, public and
    cacheable.
  contact:
    name: CLARA (BuildWind)
    url: https://www.clara.city
    email: support@clara.city
  license:
    name: Proprietary
    url: https://www.clara.city

externalDocs:
  description: Human-readable documentation
  url: https://www.clara.city/docs/map

servers:
  - url: https://api.clara.city/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Catalog
    description: >
      Which timestamps are currently available for a city, with a signed
      TileJSON URL for every layer at every timestep.
  - name: Tiles
    description: >
      TileJSON documents and Mapbox Vector Tiles. Authorised by a signed URL
      rather than a header, so map libraries can fetch them directly.

paths:
  /map/{city}:
    get:
      operationId: getCatalog
      tags: [Catalog]
      summary: Available timestamps and signed TileJSON URLs
      description: |
        Returns every timestamp currently available for the city, each with a
        signed TileJSON URL per layer. This is the only endpoint a client
        calls directly, and the only URL it should hardcode.

        The response is assembled on demand from the live and forecast model
        windows, so it always reflects what is actually servable at that
        moment.

        `live` is a single timestep, the most recent observed hour, replaced
        hourly a couple of minutes past the hour. `forecast` is an ordered
        array of upcoming timesteps whose length and spacing both vary.
        `static` holds layers with no time dimension.

        A layer key may be absent for a given timestep if that variable has
        no data for it; clients should check for the key rather than assume
        every layer is present.

        Cached server-side for 60 seconds. If the upstream model service is
        unavailable, the last known-good catalog is served with the header
        `X-Cache: stale` and freshly re-signed URLs.

        Example:

        ```
        curl -sS https://api.clara.city/v1/map/ravenna \
          -H "Authorization: Bearer <YOUR_CLARA_KEY>"
        ```
      parameters:
        - $ref: "#/components/parameters/City"
      responses:
        "200":
          description: Catalog of available timesteps.
          headers:
            X-Cache:
              description: >
                `hit`, `miss`, or `stale`. `stale` means the upstream model
                service was unreachable and a cached catalog was served; the
                data may be up to an hour old but every URL in it is valid.
              schema:
                type: string
                enum: [hit, miss, stale]
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              description: Requests allowed per minute for this key.
              schema: { type: integer }
            X-RateLimit-Remaining:
              description: Requests remaining in the current minute window.
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Catalog"
              example:
                city: { id: ravenna, name: Ravenna, country: IT }
                meta:
                  crs: "EPSG:3857"
                  tile_format: mvt
                  resolution_m: 5
                  generated_utc: "2026-08-19T10:43:53Z"
                  catalog_ttl_s: 60
                  url_expires_utc: "2026-08-20T11:00:00Z"
                static:
                  buildings:
                    tilejson: "https://api.clara.city/v1/map/ravenna/tiles/buildings.json?kid=4b1f0c9ae37d&exp=1787223600&sig=6XMACDk4Sc"
                live:
                  timestamp: "2026-08-19T10:00:00Z"
                  timestamp_ms: 1787133600000
                  layers:
                    thermal:
                      tilejson: "https://api.clara.city/v1/map/ravenna/tiles/thermal/1787133600000.json?kid=4b1f0c9ae37d&exp=1787223600&sig=6XMACDk4Sc"
                      range: { min: 26.9, max: 29.06 }
                    wind:
                      tilejson: "https://api.clara.city/v1/map/ravenna/tiles/wind/1787133600000.json?kid=4b1f0c9ae37d&exp=1787223600&sig=6XMACDk4Sc"
                forecast:
                  - timestamp: "2026-08-19T12:00:00Z"
                    timestamp_ms: 1787140800000
                    layers:
                      thermal:
                        tilejson: "https://api.clara.city/v1/map/ravenna/tiles/forecast/thermal/1787140800000.json?kid=4b1f0c9ae37d&exp=1787223600&sig=6XMACDk4Sc"
                        range: { min: 24.0, max: 26.22 }
                      wind:
                        tilejson: "https://api.clara.city/v1/map/ravenna/tiles/forecast/wind/1787140800000.json?kid=4b1f0c9ae37d&exp=1787223600&sig=6XMACDk4Sc"
                  - timestamp: "2026-08-19T14:00:00Z"
                    timestamp_ms: 1787148000000
                    layers:
                      thermal:
                        tilejson: "https://api.clara.city/v1/map/ravenna/tiles/forecast/thermal/1787148000000.json?kid=4b1f0c9ae37d&exp=1787223600&sig=6XMACDk4Sc"
                      wind:
                        tilejson: "https://api.clara.city/v1/map/ravenna/tiles/forecast/wind/1787148000000.json?kid=4b1f0c9ae37d&exp=1787223600&sig=6XMACDk4Sc"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /map/{city}/tiles/{layer}/{timestamp}.json:
    get:
      operationId: getTileJson
      tags: [Tiles]
      summary: TileJSON for one layer at one live timestamp
      security: []
      description: |
        The TileJSON 3.0.0 document for one layer at one timestamp: the tile
        URL template, the vector layer names and their fields, bounds and the
        zoom range. Hand the catalog's `tilejson` URL to your map library as
        a vector source URL; the library fetches this document and the tiles
        itself.

        These URLs come from the catalog already signed. Do not construct
        them.
      parameters:
        - $ref: "#/components/parameters/City"
        - $ref: "#/components/parameters/Layer"
        - $ref: "#/components/parameters/TimestampMs"
        - $ref: "#/components/parameters/Kid"
        - $ref: "#/components/parameters/Exp"
        - $ref: "#/components/parameters/Sig"
      responses:
        "200":
          description: TileJSON document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TileJson"
        "401":
          $ref: "#/components/responses/SignatureRejected"
        "404":
          $ref: "#/components/responses/NotFound"

  /map/{city}/tiles/{layer}/{timestamp}/{z}/{x}/{y}.mvt:
    get:
      operationId: getTile
      tags: [Tiles]
      summary: One vector tile of a live layer
      security: []
      description: |
        A single Mapbox Vector Tile. Fetched by the map library from the URL
        template in the TileJSON document; there is normally no reason to
        call it by hand. An empty tile answers `204 No Content`.
      parameters:
        - $ref: "#/components/parameters/City"
        - $ref: "#/components/parameters/Layer"
        - $ref: "#/components/parameters/TimestampMs"
        - $ref: "#/components/parameters/Z"
        - $ref: "#/components/parameters/X"
        - $ref: "#/components/parameters/Y"
        - $ref: "#/components/parameters/Kid"
        - $ref: "#/components/parameters/Exp"
        - $ref: "#/components/parameters/Sig"
      responses:
        "200":
          description: The tile bytes.
          content:
            application/vnd.mapbox-vector-tile:
              schema:
                type: string
                format: binary
        "204":
          description: The tile exists but contains no features. Not an error.
        "401":
          $ref: "#/components/responses/SignatureRejected"
        "404":
          $ref: "#/components/responses/NotFound"

  /map/{city}/tiles/forecast/{layer}/{timestamp}.json:
    get:
      operationId: getForecastTileJson
      tags: [Tiles]
      summary: TileJSON for one layer at one forecast timestamp
      security: []
      description: >
        Identical to the live TileJSON endpoint, for a forecast timestep.
        The catalog's `forecast` array carries these URLs already signed.
      parameters:
        - $ref: "#/components/parameters/City"
        - $ref: "#/components/parameters/Layer"
        - $ref: "#/components/parameters/TimestampMs"
        - $ref: "#/components/parameters/Kid"
        - $ref: "#/components/parameters/Exp"
        - $ref: "#/components/parameters/Sig"
      responses:
        "200":
          description: TileJSON document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TileJson"
        "401":
          $ref: "#/components/responses/SignatureRejected"
        "404":
          $ref: "#/components/responses/NotFound"

  /map/{city}/tiles/forecast/{layer}/{timestamp}/{z}/{x}/{y}.mvt:
    get:
      operationId: getForecastTile
      tags: [Tiles]
      summary: One vector tile of a forecast layer
      security: []
      description: >
        Identical to the live tile endpoint, for a forecast timestep. An
        empty tile answers `204 No Content`.
      parameters:
        - $ref: "#/components/parameters/City"
        - $ref: "#/components/parameters/Layer"
        - $ref: "#/components/parameters/TimestampMs"
        - $ref: "#/components/parameters/Z"
        - $ref: "#/components/parameters/X"
        - $ref: "#/components/parameters/Y"
        - $ref: "#/components/parameters/Kid"
        - $ref: "#/components/parameters/Exp"
        - $ref: "#/components/parameters/Sig"
      responses:
        "200":
          description: The tile bytes.
          content:
            application/vnd.mapbox-vector-tile:
              schema:
                type: string
                format: binary
        "204":
          description: The tile exists but contains no features. Not an error.
        "401":
          $ref: "#/components/responses/SignatureRejected"
        "404":
          $ref: "#/components/responses/NotFound"

  /map/{city}/tiles/{static}.json:
    get:
      operationId: getStaticTileJson
      tags: [Tiles]
      summary: TileJSON for a static layer
      security: []
      description: >
        TileJSON for a layer with no time dimension, currently `buildings`.
        The catalog's `static` block carries these URLs already signed.
      parameters:
        - $ref: "#/components/parameters/City"
        - $ref: "#/components/parameters/Static"
        - $ref: "#/components/parameters/Kid"
        - $ref: "#/components/parameters/Exp"
        - $ref: "#/components/parameters/Sig"
      responses:
        "200":
          description: TileJSON document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TileJson"
        "401":
          $ref: "#/components/responses/SignatureRejected"
        "404":
          $ref: "#/components/responses/NotFound"

  /map/{city}/tiles/{static}/{z}/{x}/{y}.mvt:
    get:
      operationId: getStaticTile
      tags: [Tiles]
      summary: One vector tile of a static layer
      security: []
      description: >
        A single vector tile of a static layer. An empty tile answers
        `204 No Content`.
      parameters:
        - $ref: "#/components/parameters/City"
        - $ref: "#/components/parameters/Static"
        - $ref: "#/components/parameters/Z"
        - $ref: "#/components/parameters/X"
        - $ref: "#/components/parameters/Y"
        - $ref: "#/components/parameters/Kid"
        - $ref: "#/components/parameters/Exp"
        - $ref: "#/components/parameters/Sig"
      responses:
        "200":
          description: The tile bytes.
          content:
            application/vnd.mapbox-vector-tile:
              schema:
                type: string
                format: binary
        "204":
          description: The tile exists but contains no features. Not an error.
        "401":
          $ref: "#/components/responses/SignatureRejected"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        A CLARA API key. `clara_sk_` keys are secret and belong on a server;
        `clara_pk_` keys are publishable, may appear in a browser page, and
        are checked against the key's origin allowlist at the edge.

  headers:
    XRequestId:
      description: Correlation id. Quote it when reporting a problem.
      schema:
        type: string

  parameters:
    City:
      name: city
      in: path
      required: true
      description: >
        City identifier, lowercase. The list of cities with map coverage is
        served by `GET /v1/cities?product=map` on the main CLARA API.
      schema:
        type: string
        pattern: "^[a-z][a-z0-9-]*$"
      example: ravenna
    Layer:
      name: layer
      in: path
      required: true
      description: >
        Layer identifier. Which layers a city has is declared in its catalog;
        not every city carries every layer.
      schema:
        type: string
        enum: [thermal, wind, air]
      example: thermal
    Static:
      name: static
      in: path
      required: true
      description: Static layer identifier.
      schema:
        type: string
        enum: [buildings]
      example: buildings
    TimestampMs:
      name: timestamp
      in: path
      required: true
      description: >
        The timestep as epoch milliseconds, exactly as given by the catalog's
        `timestamp_ms`.
      schema:
        type: integer
        format: int64
      example: 1787133600000
    Z:
      name: z
      in: path
      required: true
      description: Zoom level.
      schema: { type: integer, minimum: 0 }
    X:
      name: x
      in: path
      required: true
      description: Tile column.
      schema: { type: integer, minimum: 0 }
    Y:
      name: y
      in: path
      required: true
      description: Tile row.
      schema: { type: integer, minimum: 0 }
    Kid:
      name: kid
      in: query
      required: true
      description: >
        Key identifier. Stamped by the catalog so a signed URL is bound to the
        key that requested it; do not construct or modify.
      schema:
        type: string
        pattern: "^[0-9a-f]{12}$"
      example: 4b1f0c9ae37d

    Exp:
      name: exp
      in: query
      required: true
      description: >
        Signature expiry as epoch seconds. Stamped by the catalog; do not
        construct or modify.
      schema:
        type: integer
        format: int64
      example: 1787223600
    Sig:
      name: sig
      in: query
      required: true
      description: >
        URL signature. Stamped by the catalog; do not construct or modify.
      schema:
        type: string
      example: 6XMACDk4Sc

  schemas:
    Catalog:
      type: object
      required: [city, meta, static, forecast]
      properties:
        city:
          $ref: "#/components/schemas/CityRef"
        meta:
          $ref: "#/components/schemas/CatalogMeta"
        static:
          type: object
          description: >
            Layers with no time dimension, keyed by layer id. Currently
            `buildings`.
          additionalProperties:
            $ref: "#/components/schemas/LayerRef"
        live:
          allOf:
            - $ref: "#/components/schemas/TimeStep"
          description: >
            The current hour. ABSENT when the key's horizon scope does not
            include `live`: an object has no empty form, so the property is
            omitted rather than nulled. See `horizons` on `GET /v1/key`.
        forecast:
          type: array
          description: >
            Upcoming timesteps in time order. Length and spacing vary; read
            each entry's `timestamp_ms`.

            EMPTY when the key's horizon scope does not include `forecast`.
            Empty therefore means either "not sold" or "no forecast data this
            hour"; read `horizons` on `GET /v1/key` to tell them apart.
          items:
            $ref: "#/components/schemas/TimeStep"
    CityRef:
      type: object
      required: [id, name, country]
      properties:
        id:
          type: string
          description: City identifier, as used in URLs.
          example: ravenna
        name:
          type: string
          example: Ravenna
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
          example: IT
    CatalogMeta:
      type: object
      required: [crs, tile_format, generated_utc, catalog_ttl_s, url_expires_utc]
      properties:
        crs:
          type: string
          description: Coordinate reference system of the tiles.
          example: "EPSG:3857"
        tile_format:
          type: string
          enum: [mvt]
        resolution_m:
          type: number
          description: Grid resolution of the underlying model, metres.
          example: 5
        generated_utc:
          type: string
          format: date-time
          description: When this catalog was assembled.
        catalog_ttl_s:
          type: integer
          description: >
            Server-side cache lifetime of this catalog, seconds. Re-fetching
            more often returns the same document.
          example: 60
        url_expires_utc:
          type: string
          format: date-time
          description: >
            When the signed URLs in this catalog stop working. Re-fetch the
            catalog before then; a fresh catalog always carries valid URLs.
    TimeStep:
      type: object
      required: [timestamp, timestamp_ms, layers]
      properties:
        timestamp:
          type: string
          format: date-time
          description: The modelled hour, ISO 8601 UTC.
        timestamp_ms:
          type: integer
          format: int64
          description: The same instant as epoch milliseconds.
        layers:
          type: object
          description: >
            Signed TileJSON URL per layer. A key is absent when that layer
            has no data for this timestep.
          additionalProperties:
            $ref: "#/components/schemas/LayerRef"
    LayerRef:
      type: object
      required: [tilejson]
      properties:
        tilejson:
          type: string
          format: uri
          description: Signed TileJSON URL. Pass to the map library as-is.
        range:
          $ref: "#/components/schemas/LayerRange"
    LayerRange:
      type: object
      description: |
        The lowest and highest value this layer reaches across the whole
        covered area at this hour. Optional and per hour: treat its absence
        as "no range published", not as an error.

        It is what a `relative` scale needs. A quantity whose useful span
        moves with the season cannot be drawn on a fixed ramp, since a UTCI
        ramp wide enough for a January night leaves a July afternoon using a
        fraction of the palette. Fit the ramp to the hour on screen instead.

        Today only `thermal` carries one, because the upstream statistics
        service computes a minmax for UTCI alone. It sits inside the layer
        entry, so it inherits that layer's metric gate and needs no scope of
        its own.
      required: [min, max]
      properties:
        min:
          type: number
          example: 26.9
        max:
          type: number
          example: 29.06
    TileJson:
      type: object
      description: TileJSON 3.0.0 document.
      required: [tilejson, tiles]
      properties:
        tilejson:
          type: string
          example: "3.0.0"
        name:
          type: string
        tiles:
          type: array
          description: Tile URL templates, already signed.
          items:
            type: string
        vector_layers:
          type: array
          items:
            $ref: "#/components/schemas/VectorLayer"
        bounds:
          type: array
          description: "[west, south, east, north] in degrees."
          items:
            type: number
          minItems: 4
          maxItems: 4
        minzoom:
          type: integer
        maxzoom:
          type: integer
    VectorLayer:
      type: object
      description: |
        One named layer inside the vector tiles, with its feature fields.
        The source-layer names and fields per CLARA layer:

        | Layer | source-layer | Fields |
        | --- | --- | --- |
        | thermal | `UTCI` | `UTCI` (degrees C) |
        | wind | `Velocity` | `Ucomfort`, `Ugust` (m/s) |
        | air | `Concentration` | `C` (NO2), `C_PM25`, `C_PM10` (ug/m3) |
        | buildings | `3d-buildings` | `Height` (m) |
      required: [id]
      properties:
        id:
          type: string
          description: The source-layer name to reference in map styles.
          example: UTCI
        fields:
          type: object
          additionalProperties:
            type: string
        minzoom:
          type: integer
        maxzoom:
          type: integer
    Problem:
      type: object
      description: RFC 9457 problem details.
      required: [type, code, title, status, detail]
      properties:
        type:
          type: string
          format: uri
          description: Link to the error's documentation.
          example: "https://www.clara.city/docs/errors#forbidden"
        code:
          type: string
          description: Machine-readable error code.
          example: forbidden
        title:
          type: string
          example: Forbidden
        status:
          type: integer
          example: 403
        detail:
          type: string
          example: This key is not scoped to the city "ravenna".
        request_id:
          type: string
          description: Correlation id, also sent as X-Request-Id.

  responses:
    Unauthorized:
      description: Missing, malformed, unknown or expired API key.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: "https://www.clara.city/docs/errors#unauthorized"
            code: unauthorized
            title: Unauthorized
            status: 401
            detail: "Missing or malformed API key. Send it as 'Authorization: Bearer <key>'."
            request_id: 0f3c1a7e-2d0b-4f5a-9a17-1c2b3d4e5f60
    SignatureRejected:
      description: >
        The URL is unsigned, its signature is invalid, or it has expired
        (`code: url_expired`). Fetch the catalog again to obtain fresh URLs.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: "https://www.clara.city/docs/errors#url_expired"
            code: url_expired
            title: URL expired
            status: 401
            detail: This URL has expired; fetch the catalog again.
            request_id: 0f3c1a7e-2d0b-4f5a-9a17-1c2b3d4e5f60
    Forbidden:
      description: >
        The key is valid but not entitled: disabled, not scoped to this city,
        or the map product is not included.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: "https://www.clara.city/docs/errors#forbidden"
            code: forbidden
            title: Forbidden
            status: 403
            detail: This key is not scoped to the city "ravenna".
            request_id: 0f3c1a7e-2d0b-4f5a-9a17-1c2b3d4e5f60
    NotFound:
      description: Unknown city, layer, timestamp or tile address.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: "https://www.clara.city/docs/errors#not_found"
            code: not_found
            title: Not found
            status: 404
            detail: Unknown city "rvenna".
            request_id: 0f3c1a7e-2d0b-4f5a-9a17-1c2b3d4e5f60
    RateLimited:
      description: The key's per-minute rate limit is exceeded.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
        Retry-After:
          description: Seconds until the next minute window.
          schema: { type: integer }
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: "https://www.clara.city/docs/errors#rate_limited"
            code: rate_limited
            title: Rate limit exceeded
            status: 429
            detail: Rate limit of 100 requests per minute exceeded.
            request_id: 0f3c1a7e-2d0b-4f5a-9a17-1c2b3d4e5f60
    UpstreamUnavailable:
      description: >
        The upstream model service is unreachable and no cached catalog was
        available. Retry shortly.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: "https://www.clara.city/docs/errors#upstream_unavailable"
            code: upstream_unavailable
            title: Upstream unavailable
            status: 503
            detail: Could not reach the model service. Retry shortly.
            request_id: 0f3c1a7e-2d0b-4f5a-9a17-1c2b3d4e5f60
