openapi: 3.0.3
info:
  title: prompter
  description: |
    Independent, stateless service in front of nine AI-platform collectors
    (`chatgpt`/`claude`/`gemini`/`deepseek`/`grok`/`metaai`/`perplexity`/
    `aimode`/`aio`). It asks one platform a single question and returns the raw
    answer, plus native citations when the platform provides them. **It keeps no
    persistent state**: every `/v1/collect` call is a real, live upstream API
    request (responses are not cached, each call incurs a new cost).

    Consumer services connect to this API over an HTTP client with `X-API-Key`.
    prompter is a stateless microservice and makes no assumptions about the
    caller.

    ## Authentication
    Every `/v1/*` endpoint requires the `X-API-Key` header (see
    `securitySchemes.ApiKeyAuth`). Keys are issued with `prompter/cmd/tenantinit`
    and begin with the `pr_` prefix. The server stores only the SHA-256 hash of
    the key; the raw value cannot be recovered, so a lost key must be reissued.
    `GET /healthz` is the exception: it requires no authentication (it exists for
    container health checks).

    ## Timeouts and retries
    All four provider families (LLM based: chatgpt/claude/gemini/deepseek/grok/
    metaai, plus Perplexity and DataForSEO AI Mode/Overviews) use the same
    default budget of **25000 ms** when `timeoutMs` is omitted. This budget is
    not per outbound request; it applies to the whole call including any retries
    (only Perplexity retries internally on 429/5xx, up to three attempts).

    ## Error body masking (important, verified behavior)
    On `5xx` responses (500 or 503) the real error message is never leaked to
    the client: `error.message` is always overwritten with the fixed string
    `"An unexpected error occurred."` and `error.details` is cleared (see
    `httpx.Fail`, the `status >= 500` branch). The real cause (for example which
    upstream returned which error) is only visible in prompter's own server logs
    (`slog.Error`, written just before `Fail` is called) and cannot be
    distinguished client side. On `4xx` (400/401/404) the message is real and
    explicit.
  version: "1.0.0"
  contact:
    name: prompter engineering team
  x-internal-notes: >-
    This service is not designed to be exposed directly to the public internet.
    It is consumed by trusted internal consumer services over `X-API-Key`. The
    version number tracks SemVer informally.

servers:
  - url: 'http://{host}:{port}'
    description: >-
      Configurable server. The {host} variable expresses every context with a
      single template (see the variable descriptions below).
    variables:
      host:
        default: localhost
        description: >-
          `localhost` (from the host machine, via docker-compose's API_PORT) or
          `host.docker.internal` (from a consumer service's own Docker network).
          In production, replace this with a real internal DNS name.
      port:
        default: "8092"
        description: The API_PORT in docker-compose.yml (host side port mapping).

security:
  - ApiKeyAuth: []

tags:
  - name: collect
    description: Send a query to a single AI platform
  - name: platforms
    description: Registry introspection (list of registered platforms)
  - name: ops
    description: Operational endpoints (no authentication required)

paths:
  /healthz:
    get:
      tags: [ops]
      summary: Container health check
      description: >-
        An HTTP based check for external monitoring tools, alongside the
        `HEALTHCHECK` in `docker-compose.yml` (the `api -healthcheck` flag is a
        separate TCP dial check and does not call this endpoint). Requires no
        authentication (see `cmd/api/main.go`, where `/healthz` is registered
        outside the `authed` group).
      operationId: healthz
      security: []
      responses:
        '200':
          description: The service is up. There is no body (status code only).

  /v1/collect:
    post:
      tags: [collect]
      summary: Ask a single AI platform one question
      description: |
        Flow: (1) `promptText` is checked for emptiness, empty gives **400**.
        (2) `platform` is checked against the registry (see `GET /v1/platforms`),
        if it is not registered (a typo, or a platform with no integration such
        as `"copilot"`) it gives **404**. (3) The platform is checked for support
        of this `countryCode`, if unsupported it gives **400** (all platforms are
        currently unrestricted, so this branch is not triggered in practice but
        is part of the contract). (4) A real upstream request is made:
          - `chatgpt`/`claude`/`gemini`/`deepseek`/`grok`/`metaai` go to the
            provider's chat/completions endpoint. These platforms have no native
            citation mechanism, so `citations` is always empty and links found in
            the answer text go to `textLinks` (see the `TextLink` schema).
          - `perplexity` goes to the Perplexity Sonar API (`chat/completions`),
            where the native `citations`/`search_results` fields are read. Links
            in the answer text are also added to `textLinks`, independently of
            the native citations.
          - `aimode`/`aio` go to the DataForSEO SERP API (respectively
            `google/ai_mode/live/advanced` and `google/organic/live/advanced`).
            The model may not have produced an AI summary for this query, which
            is an honest "empty" result (**200** with empty `rawText`/
            `citations`/`textLinks`), not an error.
      operationId: collect
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CollectRequest'
            examples:
              perplexity:
                summary: Perplexity Sonar example
                value:
                  platform: perplexity
                  promptText: "which dishwasher detergent is best, briefly"
                  countryCode: us
                  language: en
              customTimeout:
                summary: With a custom timeout (10 seconds)
                value:
                  platform: chatgpt
                  promptText: "what do you know about the Godiva chocolate brand?"
                  countryCode: us
                  language: en
                  timeoutMs: 10000
      responses:
        '200':
          description: Success. A real answer was received from the platform (the answer may also be empty, see the aimode/aio note above).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CollectResult'
                required: [data]
              examples:
                perplexityWithCitations:
                  summary: Perplexity with populated native citations
                  value:
                    data:
                      rawText: "Finish and Fairy are among the most preferred dishwasher detergents."
                      rawHtml: ""
                      citations:
                        - url: "https://example.com/best-detergent-review"
                          title: "Best dishwasher detergent brands"
                          position: 1
                        - url: "https://example.com/dishwasher-tablets-2026"
                          title: "Dishwasher tablet recommendations, 2026"
                          position: 2
                      textLinks: []
                      collectedAt: "2026-08-16T13:47:09.441666Z"
                claudeWithTextLinks:
                  summary: >-
                    Claude, with Markdown links found inside the answer. These go
                    to `textLinks`, not `citations`: Claude has no native citation
                    mechanism, these links are the model's own prose and are not
                    retrieval evidence (see the TextLink schema description).
                  value:
                    data:
                      rawText: "In this market the most preferred dishwasher detergents are [Finish](https://example.com/finish), [Somat](https://example.com/somat), [Fairy](https://example.com/fairy) and [Pril](https://example.com/pril)."
                      rawHtml: ""
                      citations: []
                      textLinks:
                        - url: "https://example.com/finish"
                          label: "Finish"
                          position: 1
                        - url: "https://example.com/somat"
                          label: "Somat"
                          position: 2
                        - url: "https://example.com/fairy"
                          label: "Fairy"
                          position: 3
                        - url: "https://example.com/pril"
                          label: "Pril"
                          position: 4
                      collectedAt: "2026-08-16T14:02:40.016043Z"
                aioEmptyHonestResult:
                  summary: aio, the model produced no AI summary for this query (an honest empty result, not an error)
                  value:
                    data:
                      rawText: ""
                      rawHtml: ""
                      citations: []
                      textLinks: []
                      collectedAt: "2026-08-16T13:47:09.441666Z"
        '400':
          description: Input validation error (`promptText` is empty) or the platform does not support this country code. The message is real and explicit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                emptyPrompt:
                  value:
                    error:
                      code: validation_failed
                      message: "promptText must not be empty"
                unsupportedCountry:
                  summary: Not triggered by any platform today (all platforms are unrestricted), kept as a contract record.
                  value:
                    error:
                      code: validation_failed
                      message: "Platform is not supported for this country code: xx"
        '401':
          description: X-API-Key is missing, or invalid or revoked. The message is real and explicit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                missingKey:
                  value:
                    error:
                      code: unauthorized
                      message: "The X-API-Key header is required."
                invalidKey:
                  value:
                    error:
                      code: unauthorized
                      message: "Invalid API key."
        '404':
          description: Unknown platform code, not registered at all (a typo, or a platform with no real integration such as `"copilot"`). The message is real and explicit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                error:
                  code: not_found
                  message: "Unknown platform: copilot"
        '422':
          description: >-
            This tenant has spent its daily request quota or cost budget for
            this platform today (see `PROMPTER_DAILY_QUOTA`/
            `PROMPTER_DAILY_COST_BUDGET_USD`, computed per tenant and platform
            from the persistent `usage_ledger` table). Do not confuse this with
            `RateLimited`/**429**: it means "try again tomorrow", retrying
            immediately is pointless.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                dailyQuota:
                  value:
                    error:
                      code: quota_exceeded
                      message: "The daily request quota for this platform is spent, try again tomorrow."
                costBudget:
                  value:
                    error:
                      code: quota_exceeded
                      message: "The daily cost budget for this platform is spent, try again tomorrow."
        '429':
          description: >-
            A transport level guard keyed by API key (KeyID):
            `PROMPTER_RATE_LIMIT_RPM` (requests per minute, fixed window) or
            `PROMPTER_MAX_CONCURRENT` (concurrent in-flight requests) was
            exceeded. It is keyed by the API key that made this request, not by
            TenantID (if a tenant has several keys and one hits this limit, the
            others are not affected). Retrying after a short delay is meaningful
            (do not confuse this with `quota_exceeded`/**422**).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                rpm:
                  value:
                    error:
                      code: rate_limited
                      message: "Request rate limit exceeded, please try again later."
                concurrency:
                  value:
                    error:
                      code: rate_limited
                      message: "Concurrent request limit exceeded for this API key, please wait for in-flight requests to finish."
        '500':
          description: >-
            A generic upstream or internal error (for example an LLM provider
            returned 4xx/5xx, a response could not be parsed, or a panic was
            recovered). **`error.message` is always overwritten with a fixed
            string** (see the "Error body masking" section above), the real cause
            is only in the server logs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                error:
                  code: internal
                  message: "An unexpected error occurred."
        '503':
          description: >-
            One of two distinct causes may have triggered this, and they cannot
            be told apart from the client body (both are `error.code=unavailable`
            with a fixed message), they only diverge in prompter's server log:


            (1) The platform is registered but its API key/credentials are not
            configured (`UnconfiguredProvider`, note it still appears in
            `GET /v1/platforms`, "registered" and "works" are separate concepts).
            This is a permanent state, it lasts until an operator adds
            credentials and restarts the service.


            (2) The provider level circuit breaker is open (see
            `PROMPTER_CIRCUIT_BREAKER_THRESHOLD`/`PROMPTER_CIRCUIT_BREAKER_COOLDOWN`),
            because the provider failed consecutively up to the configured
            threshold, so it is temporarily disabled for all tenants (for the
            cooldown period). This is not permanent, it retries automatically
            after the cooldown, so it is meaningful for the client to retry after
            a short delay.


            In both cases **`error.message` is overwritten with a fixed string**
            (503 is included in the `status >= 500` masking rule), which cause
            applied is only visible in prompter's logs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                error:
                  code: unavailable
                  message: "An unexpected error occurred."

  /v1/platforms:
    get:
      tags: [platforms]
      summary: List every registered platform in the registry
      description: >-
        A fixed list the consumer side can fetch once at startup and cache
        locally. A platform whose API key is not configured
        (`UnconfiguredProvider`) still stays in the list; it only returns **503**
        on a real `POST /v1/collect` call (the honesty principle: the registry
        keeps the distinction between "exists" and "works"). The only platform
        with no real integration (`"copilot"`) never appears in the list. The
        order is deterministic, following the registration order in the server
        (`chatgpt` then `gemini`, `claude`, `grok`, `metaai`, `deepseek`,
        `perplexity`, `aimode`, `aio`, see `registry.go`, `DefaultRegistry`).
      operationId: listPlatforms
      responses:
        '200':
          description: The list of registered platforms.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PlatformInfo'
                required: [data]
              example:
                data:
                  - code: chatgpt
                    unsupportedCountries: []
                  - code: gemini
                    unsupportedCountries: []
                  - code: claude
                    unsupportedCountries: []
                  - code: grok
                    unsupportedCountries: []
                  - code: metaai
                    unsupportedCountries: []
                  - code: deepseek
                    unsupportedCountries: []
                  - code: perplexity
                    unsupportedCountries: null
                  - code: aimode
                    unsupportedCountries: null
                  - code: aio
                    unsupportedCountries: null
        '401':
          description: X-API-Key is missing or invalid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: >-
            This endpoint sits behind the same KeyID based RPM/concurrency
            middleware chain as POST /v1/collect (see `cmd/api/main.go`, the
            `authed` group covers both routes). The daily quota/cost budget (422)
            does not apply here, that is specific to `POST /v1/collect` which
            makes a real upstream call.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                error:
                  code: rate_limited
                  message: "Request rate limit exceeded, please try again later."

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        A tenant API key issued with `prompter/cmd/tenantinit -name <name>`
        (prefixed with `pr_`, base32-encoded from 24 bytes of cryptographic
        randomness). The key is written to stdout only at the moment it is
        created; the server stores only its SHA-256 hash and the raw value
        cannot be recovered (if lost, run `tenantinit` again to issue a new one).

  schemas:
    Platform:
      type: string
      description: |
        The nine fixed platform codes that have a real integration (see
        `domain.AllPlatforms`). `"copilot"` is deliberately not in this list: it
        is the only platform with no real access and is never added to the
        registry.
      enum:
        - chatgpt
        - claude
        - gemini
        - deepseek
        - grok
        - metaai
        - perplexity
        - aimode
        - aio
      x-enum-descriptions:
        chatgpt: "OpenAI Chat Completions (llmclient/openai.go)"
        claude: "Anthropic Messages API (llmclient/anthropic.go)"
        gemini: "Google Generative Language API v1beta (llmclient/gemini.go)"
        deepseek: "DeepSeek Chat Completions, OpenAI compatible schema (llmclient/deepseek.go)"
        grok: "xAI Chat Completions, OpenAI compatible schema (llmclient/grok.go)"
        metaai: "Meta's official Model API / Muse model family (llmclient/metaai.go)"
        perplexity: "Perplexity Sonar chat/completions, native citations/search_results (provider_perplexity.go)"
        aimode: "Google AI Mode, via DataForSEO google/ai_mode/live/advanced (provider_dataforseo_ai.go)"
        aio: "Google AI Overviews, via DataForSEO google/organic/live/advanced + load_async_ai_overview (provider_dataforseo_ai.go)"

    CollectRequest:
      type: object
      required: [platform, promptText]
      properties:
        platform:
          $ref: '#/components/schemas/Platform'
        promptText:
          type: string
          minLength: 1
          description: The query text to ask the platform. Must not be empty (`domain.Query.Validate`).
          example: "which dishwasher detergent is best, briefly"
        countryCode:
          type: string
          description: >-
            ISO country code (for example `"tr"`, `"us"`). Used both to localize
            the system instruction (for example "answer as if for a user in the
            US market") and to check whether the platform is supported in that
            country (all platforms are currently unrestricted, see the
            `registry.go` comment, the country restrictions are deliberately
            commented out).
          example: us
        language:
          type: string
          description: >-
            Language code. Passed directly to the `language_code` request field
            only for the `aimode`/`aio` (DataForSEO) platforms; other platforms
            ignore it (localization is embedded into the system instruction from
            `countryCode`).
          example: en
        timeoutMs:
          type: integer
          minimum: 1
          description: >-
            Upper bound for the request including retries (see the "Timeouts"
            section above), in milliseconds. If `<=0` or omitted, all platforms
            fall back to the same default (25000 ms).
          example: 25000
          default: 25000

    Citation:
      type: object
      properties:
        url:
          type: string
          format: uri
        title:
          type: string
          description: >-
            May be empty for some providers (for example Perplexity's older flat
            `citations` URL array, the fallback path when `search_results` is
            absent).
        position:
          type: integer
          minimum: 1
          description: >-
            1-based ordering position, the provider's own citation/reference/
            search-result order. This is not the same coordinate system as
            `TextLink.position` (one is the provider's citation order, the other
            is the order of appearance in the answer text), so comparing them is
            meaningless, relate them by URL instead.

    TextLink:
      type: object
      description: >-
        A link observed in the answer text (currently only Markdown
        `[label](url)` syntax is captured, plain URLs or HTML links are not). A
        URL appearing here is not proof that the URL was actually used as a
        source: the model may have written it from its own knowledge, or the
        provider's own render pipeline may have embedded it in the text. The same
        URL may also appear in the `citations` array; the two are deliberately
        not filtered or merged (the collector carries the raw observation
        losslessly, interpretation and dedupe are the downstream's job).
      properties:
        url:
          type: string
          format: uri
        label:
          type: string
          description: The link's display text. This is not the equivalent of `Citation.title` (the source title).
        position:
          type: integer
          minimum: 1
          description: >-
            1-based ordering position, the order of appearance among links
            extracted from the answer text. This is not the same coordinate
            system as `Citation.position` (see the Citation.position
            description).

    CollectResult:
      type: object
      properties:
        rawText:
          type: string
          description: >-
            The platform's raw, unprocessed answer text. For `aimode`/`aio` it
            returns an empty string if the model produced no AI summary for this
            query (not an error, see the honesty note above).
        rawHtml:
          type: string
          description: Not populated by any provider yet, always an empty string. Kept as part of the contract.
        citations:
          type: array
          items:
            $ref: '#/components/schemas/Citation'
          description: >-
            Sources the provider explicitly returns as native citation/reference/
            search-result metadata (Perplexity/DataForSEO). For the six LLM based
            platforms (chatgpt/claude/gemini/deepseek/grok/metaai) this array is
            always empty: they have no native citation mechanism, and links in
            the answer text go to `textLinks` (see the `TextLink` schema). Never
            fabricated: if the provider returns no sources this is an empty array
            (honesty principle), never `null`.
        textLinks:
          type: array
          items:
            $ref: '#/components/schemas/TextLink'
          description: >-
            Links observed in the answer text (see the `TextLink` schema). May be
            an empty array, never `null` (the same honesty and contract principle
            as `citations`).
        collectedAt:
          type: string
          format: date-time
          description: The server's (prompter's) UTC timestamp, not the upstream's own timestamp.

    PlatformInfo:
      type: object
      properties:
        code:
          $ref: '#/components/schemas/Platform'
        unsupportedCountries:
          type: array
          nullable: true
          items:
            type: string
          description: >-
            Country codes (lowercase) where this platform cannot be used. All
            platforms are currently unrestricted (see the `registry.go` comment),
            so this field is always an empty array or `null` (it varies by
            provider type: `LLMProvider`/`UnconfiguredProvider` return an empty
            array, `PerplexityProvider`/DataForSEO providers return `null`, both
            mean "no restriction" and the client should treat them the same). The
            contract keeps this field so it can stay unchanged if a market
            restriction is added later.

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: >-
                One of the `apperr.Code` constants. From prompter's Ask/registry
                flow, only `validation_failed`/`not_found`/`unauthorized`/
                `internal`/`unavailable` are actually reachable (the remaining
                codes are the general vocabulary of the `apperr` package, which
                prompter itself does not produce but are kept in the schema for
                possible future use).
              enum:
                - validation_failed
                - unauthorized
                - forbidden
                - plan_required
                - not_found
                - conflict
                - duplicate
                - quota_exceeded
                - rate_limited
                - unavailable
                - internal
            message:
              type: string
              description: Real and descriptive on 4xx; on 5xx (500/503) always overwritten with the fixed string "An unexpected error occurred."
            details:
              type: object
              additionalProperties: true
              description: Always empty (nil) on 5xx. Only populated on some 4xx errors (currently none in this service).
