openapi: 3.1.0

# Tare —— 对外接口描述文件
#
# ⚠️ 这份是**手工维护**的，覆盖范围严格限定为对客户开放的那几组接口。
#    服务自身用 springdoc 生成的那一份（/v3/api-docs）**不对外提供**：它会把控制台、
#    运营、健康检查等内部接口一并暴露，而那些不是对外契约，也不在网关上开放。
#    改了对外接口就要改这里，判据很简单：**docs/tare 那份文档里出现的接口，这里必须有。**
#
# ⚠️ **控制台接口不在这份文件里**（渠道、路由、进价、告警那些走登录态）。
#    进价上的 `recorded_at`（「这个价什么时候录的」）属于控制台那一片，说明在
#    https://www.jamerly.ai/docs/tare 的「进价」一节。要用 key 认证读它，需要先把它加进
#    /v1/integration 的白名单，再补进这份文件。
#
# ⚠️ 两种响应形状：
#    · 模型端点与 key/models/generation 这几组是 **OpenAI/OpenRouter 形状**，
#      错误体是 {"error":{...}}；
#    · /v1/integration/* 是 **ApiResponse 形状** {"code","message","data","success"}，
#      因为它的调用方是 ERP 和财务系统，那套形状是当初就定下的对外契约。
#    合并成一种会破坏其中一边已经写死的客户端。

info:
  title: Tare API
  version: '2026-08-22.4'
  summary: 一个 OpenAI 兼容的模型网关，外加子钥签发与对账接口
  # ⚠️ 上面那个链接要写成 markdown 形式，不能用裸 URL：裸 URL 后面紧跟中文括号时，
  #    渲染器的自动链接会把后面的中文一起吃进地址里，点出去是 404。
  description: |
    完整的说明性文档在 [www.jamerly.ai/docs/tare](https://www.jamerly.ai/docs/tare)（中英双语）。
    ⚠️ `2026-08-22.4`：`.3` 曾以**缺两段**的形式发布过（chat 端点与
    `Generation.upstream_inference_cost` 关于「转售不返回上游成本」的说明在发布流程中
    丢失）。行为本身从 0.1.13 起就是这样，丢的只是文字。拿过 `.3` 的话请重新拉一份。

    这份文件只描述
    接口形状，**不重复文档里的那些判断依据**（为什么额度用尽是 403、为什么流式末帧
    两种方言不一致等等）——接入前请两份一起读。

    ## 鉴权

    全部接口都用 `Authorization: Bearer <key>`。三类 key 权限互不重叠，用错类型返回明确的
    拒绝，而不是含糊的 401：

    | key | 能做什么 |
    |---|---|
    | 推理 key | 调模型（`/v1/chat/completions`、`/v1/embeddings`、`/v1/models`、`/v1/generation`） |
    | 母钥 | 管子钥（`/v1/keys/*`），**不能调模型** |
    | 对账 key | 读账（`/v1/integration/*`），**不能调模型** |

    ## 请求头

    除了 `Authorization`（和流式的 `Accept: text/event-stream`），**不要求任何请求头**。

    ⚠️ **未识别的请求头一律忽略，不会因此拒绝请求，也不会转发给上游。** 发往上游的请求头
    由我们重建（凭据、`Content-Type`、方言要求的那几个），**你带的头不会出现在上游那一侧**。
    OpenRouter 的归因头 `HTTP-Referer` / `X-Title` 属于这一类：带上无害，但不会到达任何
    地方，也不影响路由、计费和限流。

    归因请改用 `usage_label`（请求体内，原样落进用量记录）和子钥本身。

    ## 时间与时区

    所有时间戳都是 ISO 8601。⚠️ **不带偏移量的那些（`2026-08-21T17:30:00`）按
    `Asia/Shanghai`（UTC+8）解释** —— 它们是服务端本地时间。账期也按这个时区切，
    `/v1/integration/whoami` 会返回当时使用的时区，**以该字段为准，不要硬编码**。

    ## 两种响应形状

    | 接口 | 形状 | 失败时 |
    |---|---|---|
    | `/v1/chat/completions`、`/v1/embeddings`、`/v1/keys/*`、`/v1/models`、`/v1/generation` | OpenAI：`{"error":{...}}` | **HTTP 非 2xx** |
    | `/v1/integration/*` | ApiResponse：`{"code","message","data","success"}` | **HTTP 非 2xx，body 仍是 ApiResponse**（不是 200 + success:false） |
  contact:
    name: Jamerly
    url: https://www.jamerly.ai/docs/tare
  license:
    # ⚠️ 这不是开源产物，别为了消掉 linter 的警告硬套一个 MIT 之类的许可 ——
    # 那会让拿到文件的人以为接口实现也是开源的。
    name: Proprietary — provided to Jamerly customers under contract
    url: https://www.jamerly.ai/docs/tare

servers:
  - url: https://tare.jamerly.ai/v1
    description: 生产

security:
  - bearerAuth: []

tags:
  - name: Chat
    description: OpenAI 兼容的模型端点
  - name: Keys
    description: 母钥签发与管理子钥
  - name: Catalog
    description: 模型目录与单次调用回查
  - name: Content
    description: 调用内容的留存开关与查看授权（ApiResponse 形状）
  - name: Reconciliation
    description: 给机器用的账务接口（ApiResponse 形状）

paths:

  /chat/completions:
    post:
      tags: [Chat]
      operationId: createChatCompletion
      summary: 发起一次模型调用（OpenAI 兼容）
      description: |
        请求体**原样透传**：未识别的字段照发给上游，这是 `cache_control`、`reasoning`、
        `plugins` 这类字段能工作的原因。我们只改动四处：模型名换成上游认的、`stream`
        固定为本次尝试的模式、流式强制 `stream_options.include_usage=true`、剥除归因字段。

        ⚠️ **`usage.cost` 是「花了你多少」。** 走平台渠道（token 的钱由我们垫）时，回包中
        **不含 `usage.cost_details`** —— 那一格是上游向我们收取的金额。BYOK 下保留：
        那是你自己上游账单上的钱，也正是成本归因需要的数据。
        （2026-08-22 之前两种模式都返回了；已修。）
        算不出来时 `cost` 整格不出现，而不是 0 —— 「该模型尚未定价」也算算不出来。

        ⚠️ **两种上游方言的流式末帧形状不一致**（OpenAI 兼容是 `choices: []`，Anthropic 是
        `choices` 有一项而 `delta` 为空）。按帧渲染时判据写成「`choices` 为空 **或** delta
        无内容 → 跳过渲染，只取 `usage`」。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, messages]
              additionalProperties: true
              properties:
                model:
                  type: string
                  description: 平台侧模型名，取自 `GET /models` 的 `id`
                  examples: ['GLM 5.1']
                messages:
                  type: array
                  items:
                    type: object
                    required: [role]
                    properties:
                      role: { type: string, enum: [system, user, assistant, tool] }
                      content: {}
                stream: { type: boolean, default: false }
                max_tokens: { type: integer }
                temperature: { type: number }
                usage_label:
                  type: string
                  description: 你自己的归因标签，会原样落进用量记录，基数不设上限
      responses:
        '200':
          description: |
            成功。非流式返回一个 completion 对象；`stream: true` 时是 SSE
            （`text/event-stream`），以 `data: [DONE]` 结束。
          headers:
            X-Generation-Id:
              description: 本次调用的对外编号，`gen-` 开头；用它回查成本
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatCompletion' }
            text/event-stream:
              schema:
                type: string
                description: "每行一个 `data: {chunk}`，末帧带 `usage`"
        '401': { $ref: '#/components/responses/OpenAIError' }
        '402':
          description: |
            **账号欠费，且只有这一种含义。** 额度用尽是 403，不在这里。
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: |
            治理阈值挡下：key 的累计额度用尽、或账号月度预算到顶。
            按 `metadata.refusal_reason` 区分，见该字段的说明。
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: |
            **这个模型没有配置路由**（不是余额问题）。改模型名、加一条路由，或配一条兜底。
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: 超过每分钟请求数
          headers:
            Retry-After:
              description: 建议等待的秒数
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: 有路由，但候选渠道全在失败。**重试即可，这不是配置问题。**
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /embeddings:
    post:
      tags: [Chat]
      operationId: createEmbedding
      summary: 把文本转成向量（OpenAI 兼容）
      description: |
        请求体**原样透传**，回包是**上游原文**加一格 `usage.cost`。

        ⚠️ **模型名不做映射。** chat 那边写的是我们这边的模型名（取自 `GET /models`），
        这里写的是**上游自己的名字**（`text-embedding-3-small` 这一类）——
        embeddings 不走模型路由表，所以没有那层映射，回包里的模型名同样不改写。

        ⚠️ **打到哪条渠道：你自己的渠道优先（显式配的 > 按方言推出来的），
        都没有才走平台那条被指定的渠道。**
        两者的计费不同（自己的渠道只记账，平台渠道按售价扣余额），所以**不会**在两者
        之间自动故障转移 —— 一次静默的回退等于在你没同意的情况下开始花钱。

        没有流式。`gen-` 编号走响应头，用法和 chat 一致。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, input]
              additionalProperties: true
              properties:
                model:
                  type: string
                  description: |
                    **上游的模型名**（不是 `GET /models` 里的 `id`）。
                    ⚠️ embedding 模型**不会出现在 `GET /models`** —— 那份目录来自上游的
                    模型目录，而上游的目录里没有 embedding 模型。请按上游文档写。
                  examples: ['text-embedding-3-small']
                input:
                  description: 字符串或字符串数组
                  oneOf:
                    - { type: string }
                    - { type: array, items: { type: string } }
                dimensions: { type: integer }
                encoding_format: { type: string, enum: [float, base64] }
                usage_label:
                  type: string
                  description: 你自己的归因标签，会原样落进用量记录
      responses:
        '200':
          description: OK
          headers:
            X-Generation-Id:
              description: 本次调用的对外编号，`gen-` 开头
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmbeddingList' }
        '400': { $ref: '#/components/responses/OpenAIError' }
        '401': { $ref: '#/components/responses/OpenAIError' }
        '402': { $ref: '#/components/responses/OpenAIError' }
        '403': { $ref: '#/components/responses/OpenAIError' }
        '404':
          description: |
            **一条能做 embeddings 的渠道都没有。** 这是配置问题，改模型名没有用 ——
            配一条 OpenAI 兼容的渠道，或者让我方给你的账号打开平台渠道。
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502': { $ref: '#/components/responses/OpenAIError' }

  /models:
    get:
      tags: [Catalog]
      operationId: listModels
      summary: 这个账号能用的模型和价格
      description: |
        **带上推理 key，目录里就包含你自己配的模型**：你在自己渠道上配的那些 ∪ 平台的那些，
        同名时以你自己那条为准 —— 和真正调用时的路由口径完全一致。

        **凭据是可选的**：不带任何请求头时返回平台目录（客户端确实这么用，这条路一直保留）。
        但带了就必须是真的 —— 无效或过期的 key 返回 `401`，不会静默给你一份
        「少了你自己模型」的目录。

        **推理 key 和母钥都可以**。母钥没有绑 Router，它拿到的是**账号默认那套路由**的目录；
        要看某一把绑了别的 Router 的子钥能用什么，就拿那把子钥来调。
        对账 key 返回 `403`（它碰不到模型这一片）。

        ⚠️ 你自己渠道上的模型，`pricing.prompt` / `completion` 是 `0`：那些 token 你直接付给
        上游，不经我们的价目表。真实成本读回包的 `usage.cost`。
      security:
        - bearerAuth: []
        - {}
      responses:
        '401': { $ref: '#/components/responses/OpenAIError' }
        '403': { $ref: '#/components/responses/OpenAIError' }
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Model' }

  /generation:
    get:
      tags: [Catalog]
      operationId: getGeneration
      summary: 按编号回查一次调用的真实成本
      description: |
        ⚠️ **404 是正常路径**，不是错误：用量记录在响应之后才写入。非流式通常 1 秒内可查，
        流式以流结束为准；中途断开的流需事后向上游回查，可能到分钟级。
        请把 404 当作「稍后再试」，退避几秒后重试。
      parameters:
        - name: id
          in: query
          required: true
          schema: { type: string }
          description: 响应头 `X-Generation-Id` 或回包 `id` 里的 `gen-…`
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Generation' }
        '404':
          description: 尚不可查（或不属于你的调用）。按「稍后再试」处理
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /keys:
    post:
      tags: [Keys]
      operationId: createKey
      summary: 开一把子钥（母钥调用）
      description: |
        **完整明文只在创建时返回一次。**带 `external_id` 时同一个编号重复创建是**幂等**的：
        返回已存在的那把，HTTP 状态码是 `200` 而不是 `201`。

        ⚠️ 幂等重放只在**创建后 5 分钟内**能拿回明文。超过窗口 `key` 是 `null`，
        并附 `key_unavailable_reason`（**枚举值**）和 `key_unavailable_message`（文案会变）。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: 用途说明，由你填写；原样进入账单归因
                limit:
                  type: [number, 'null']
                  description: '**累计**消费上限（美元）。不传 = 不限额'
                external_id:
                  type: string
                  description: 你自己的编号，用来做创建幂等；唯一性是 (账号, external_id)
                include_byok_in_limit:
                  type: boolean
                  default: true
                  description: BYOK 渠道的消耗算不算进这把 key 的上限
                expires_at:
                  type: string
                  description: 形如 `2027-01-01T00:00:00`
      responses:
        '201':
          description: 新建成功
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KeyCreated' }
        '200':
          description: 幂等命中，返回已存在的那把（**没有新建任何东西**）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KeyReplay' }
        '400': { $ref: '#/components/responses/OpenAIError' }
        '401': { $ref: '#/components/responses/OpenAIError' }
    get:
      tags: [Keys]
      operationId: listKeys
      summary: 列出这个账号下的子钥
      description: |
        游标翻页。⚠️ 游标是 id 不是 offset：翻页途中若有新 key 创建，offset 会让某一把被
        跳过或重复出现。**账号下的子钥总数不设上限**，单次返回的行数有上限。
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 200 }
          description: 单页行数。**超过 200 不报错，会被压到 200**
        - name: after
          in: query
          schema: { type: integer, format: int64 }
          description: 上一页返回的 `next_cursor`，首页不传
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Key' }
                  next_cursor:
                    type: [integer, 'null']
                    description: '**在顶层**，与 `data` 平级。`null` = 没有下一页'
        '401': { $ref: '#/components/responses/OpenAIError' }

  /keys/{hash}:
    parameters:
      - name: hash
        in: path
        required: true
        schema: { type: string }
        description: 创建时返回的 `data.hash`，这把 key 的稳定标识
    get:
      tags: [Keys]
      operationId: getKey
      summary: 查一把子钥的额度与用量
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Key' }
        '404': { $ref: '#/components/responses/OpenAIError' }
    patch:
      tags: [Keys]
      operationId: updateKey
      summary: 改额度 / 停用 / 恢复
      description: |
        ⚠️ **解封用 `{"disabled": false}`，不要用同一个 `external_id` 再开一把**：
        `external_id` 的唯一性**包含已停用的 key**，一个编号永久对应一把 key。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KeyPatch' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Key' }
        '404': { $ref: '#/components/responses/OpenAIError' }

  /keys/external/{externalId}:
    parameters:
      - name: externalId
        in: path
        required: true
        schema: { type: string }
        description: 你自己的编号
    get:
      tags: [Keys]
      operationId: getKeyByExternalId
      summary: 按你自己的编号查子钥
      description: 有了它就不必在你那边维护一张 `external_id → hash` 的映射表。
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Key' }
        '404': { $ref: '#/components/responses/OpenAIError' }
    patch:
      tags: [Keys]
      operationId: updateKeyByExternalId
      summary: 按你自己的编号改额度 / 停用 / 恢复
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KeyPatch' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Key' }
        '404': { $ref: '#/components/responses/OpenAIError' }

  /content-settings:
    get:
      tags: [Content]
      operationId: getContentSettings
      summary: 调用内容存不存、留多久、我们能不能看
      description: |
        这一组是**留存开关本身**（控制台上叫 Call Traces）。默认**不存**：关闭时
        `request_content` / `response_content` 两列为空 —— 是没有存，不是存了不给看。
      parameters:
        - name: productLine
          in: query
          schema: { type: string }
          description: |
            指定产品线；不传即账号级。
            ⚠️ **只能放在 GET 的 query 上**，写操作一律放在 body：前置网关会丢弃 POST/PUT 的
            query string，写在 query 上不报错也不生效。
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContentSettings' }
        '401': { $ref: '#/components/responses/ApiResponseError' }
    put:
      tags: [Content]
      operationId: updateContentSettings
      summary: 开关保存、改留存期
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean }
                retentionDays:
                  type: integer
                  minimum: 1
                  maximum: 365
                  description: 默认 30；超出 1–365 返回 400
                productLine: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContentSettings' }
        '400': { $ref: '#/components/responses/ApiResponseError' }
        '401': { $ref: '#/components/responses/ApiResponseError' }

  /content-settings/grants:
    post:
      tags: [Content]
      operationId: grantContentAccess
      summary: 授权我们查看一段时间
      description: |
        ⚠️ **没有「永久授权」这个选项**，时长 1 小时 – 30 天：无时限的授权会在排查结束后
        一直留存。授权期内每次读取都会记录审计。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                hours:
                  type: integer
                  minimum: 1
                  maximum: 720
                  default: 24
                reason: { type: string, description: 给审计看的理由 }
                productLine: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/OperatorAccess' }
        '400': { $ref: '#/components/responses/ApiResponseError' }
        '401': { $ref: '#/components/responses/ApiResponseError' }

  /content-settings/grants/revoke:
    post:
      tags: [Content]
      operationId: revokeContentAccess
      summary: 提前收回授权
      description: 到期的授权自动失效，无需处理。
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                productLine: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/OperatorAccess' }
        '401': { $ref: '#/components/responses/ApiResponseError' }

  /integration/whoami:
    get:
      tags: [Reconciliation]
      operationId: integrationWhoami
      summary: 这把对账 key 是谁的、当前账期、能访问哪些资源
      description: 接入时从这里开始，否则「空数组」与「鉴权失败」无法区分。
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          userId: { type: integer, format: int64 }
                          timezone:
                            type: string
                            description: '**账期是按这个时区切的**，两边不一致会永远对不平'
                          currentPeriod: { type: string, examples: ['2026-08'] }
                          resources:
                            type: array
                            items: { type: string }
        '401':
          description: |
            key 无效，或**用的不是对账 key**。三类 key 权限互不重叠，这是刻意的：对账 key 会
            流转到财务系统和工单里，不能同时具备消费能力。
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiResponse' }

  /integration/reconciliation:
    get:
      tags: [Reconciliation]
      operationId: integrationReconciliation
      summary: 一期的勾稽：期初 + 本期变动 = 期末
      parameters:
        - name: period
          in: query
          schema: { type: string, examples: ['2026-07'] }
          description: '`YYYY-MM`，默认**上一期**（当月还在累加）'
      responses:
        '200': { $ref: '#/components/responses/ApiResponseOk' }
        '400': { $ref: '#/components/responses/ApiResponseError' }

  /integration/usage/daily:
    get:
      tags: [Reconciliation]
      operationId: integrationUsageDaily
      summary: 按天的用量汇总
      description: |
        读取夜间汇总表，**当天部分为实时计算**。⚠️ 单次查询窗口上限 **100 天**，超出返回
        明确的错误，不做静默截断。
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
          description: 含，默认本期第一天
        - name: to
          in: query
          schema: { type: string, format: date }
          description: 含，默认今天
        - name: apiKeyId
          in: query
          schema: { type: integer, format: int64 }
          description: 只看某一把 key
      responses:
        '200': { $ref: '#/components/responses/ApiResponseOk' }
        '400': { $ref: '#/components/responses/ApiResponseError' }

  /integration/balance:
    get:
      tags: [Reconciliation]
      operationId: integrationBalance
      summary: 余额、授信、可用额度
      description: |
        ⚠️ **账号级**：同一账号下所有 key 共享一个钱包。要限制单把 key 的消费用母钥的
        `limit`，那是另一套机制。
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Balance' }
        '401': { $ref: '#/components/responses/ApiResponseError' }

  /integration/transactions:
    get:
      tags: [Reconciliation]
      operationId: integrationTransactions
      summary: 余额流水，分页
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Movements' }
        '401': { $ref: '#/components/responses/ApiResponseError' }

  /integration/statements:
    get:
      tags: [Reconciliation]
      operationId: integrationStatements
      summary: 已锁定的账单列表，最近的在前
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Statement' }
        '401': { $ref: '#/components/responses/ApiResponseError' }

  /integration/attribution:
    get:
      tags: [Reconciliation]
      operationId: integrationAttribution
      summary: 成本归因：按模型、渠道、标签拆开
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Attribution' }
        '401': { $ref: '#/components/responses/ApiResponseError' }

  /integration/statements/{period}:
    get:
      tags: [Reconciliation]
      operationId: integrationStatementDetail
      summary: 某一期的账单详情
      description: |
        ⚠️ **已锁定的账单不会被改写。** 迟到的更正计入**下一期**，该行会注明来自哪个月。
        如果你的 ERP 预期「更正到达后上个月的数字会变」，那不会发生。
      parameters:
        - name: period
          in: path
          required: true
          schema: { type: string, examples: ['2026-07'] }
      responses:
        '200': { $ref: '#/components/responses/ApiResponseOk' }
        '404': { $ref: '#/components/responses/ApiResponseError' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer <key>`"

  responses:
    OpenAIError:
      description: OpenAI 形状的错误体
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ApiResponseOk:
      description: OK（ApiResponse 形状）
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiResponse' }
    ApiResponseError:
      description: "失败（ApiResponse 形状，`success: false`）"
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiResponse' }

  schemas:

    Error:
      type: object
      description: |
        ⚠️ **`type` 与 `metadata.error_type` 同值、刻意重复**：OpenAI SDK 读前者，
        OpenRouter 一系读后者。只给一个时，另一边的客户端拿到 `undefined` —— 不报错，
        只是把一个明确的拒绝显示成「未知错误」。
      properties:
        error:
          type: object
          required: [code, message, type]
          properties:
            code:
              type: integer
              description: '**始终等于 HTTP 状态码。**按它分支是安全的'
            message: { type: string }
            type:
              type: string
              description: OpenAI SDK 读该字段
              examples: [token_limit_exceeded]
            metadata:
              type: object
              properties:
                error_type:
                  type: string
                  description: 与 `type` 同值
                refusal_reason:
                  type: string
                  enum: [key_credit_exhausted, budget_exhausted, account_balance_insufficient]
                  description: |
                    只在三种「余额不足」时出现，**用它决定向终端用户展示什么**：
                    · `key_credit_exhausted` —— 这把 key 的额度用尽（403），充值可解决；
                    · `budget_exhausted` —— 账号月度预算到顶（403），**与终端用户无关**；
                    · `account_balance_insufficient` —— 账号欠费（402），**同样与终端用户无关**。
                    后两种若说成「你的额度用完了」，用户会为与自己无关的事付费。
                provider_code:
                  type: string
                  description: 上游给的原始码，透传，可能没有

    ApiResponse:
      type: object
      required: [code, message, success]
      description: |
        对账那一组的信封。⚠️ 经由网关访问时，网关**不拆上游信封**，业务数据会嵌两层
        （`data.data`）；直连 `tare.jamerly.ai` 时是一层。
      properties:
        code: { type: integer }
        message: { type: string }
        success: { type: boolean }
        data: {}

    ChatCompletion:
      type: object
      description: OpenAI 形状，外加 `usage.cost`。
      required: [id, object, created, model, choices]
      properties:
        id: { type: string, description: '`gen-` 开头的对外编号' }
        object: { type: string, examples: [chat.completion] }
        created: { type: integer }
        model:
          type: string
          description: '**始终是你请求的那个名字**，故障转移对调用方不可见'
        provider: { type: string }
        choices:
          type: array
          items: {}
        usage:
          type: object
          properties:
            prompt_tokens: { type: integer }
            completion_tokens: { type: integer }
            total_tokens: { type: integer }
            cost:
              type: number
              description: |
                这次调用的**成本**（归因口径），单位美元。
                · 平台模型：等于我们的售价；
                · **BYOK：上游进价 + 服务费**（BYOK 的 token 你直接付给供应商，
                  给你一个 0 会让你的成本报表显示这个月免费用了几百万 token）。
                ⚠️ **无法计算时该字段不出现**，而不是 0：假的 0 会被当真。
                ⚠️ **流式的末帧同样带 cost**（末帧才有 usage）。
            is_byok: { type: boolean }

    Model:
      type: object
      # context_length 与 architecture 拿不到上游目录时会缺 —— 刻意不进 required。
      required: [id, name, pricing]
      properties:
        id:
          type: string
          description: '**平台侧模型名**，用它发起调用'
        name: { type: string }
        description:
          type: string
          description: 来自上游目录的模型说明；**不可用时该字段不出现**
        context_length:
          type: integer
          description: 来自上游目录；**不可用时该字段不出现**（不要当成 0）
        architecture:
          type: object
          description: |
            来自上游目录。⚠️ 自建和本地模型没有目录条目，会兜底成纯文本：按
            `input_modalities` 含 `image` 过滤模型时，这类模型会被筛掉。
          properties:
            modality: { type: string }
            input_modalities: { type: array, items: { type: string } }
            output_modalities: { type: array, items: { type: string } }
        reasoning:
          type: object
          description: |
            这个模型的思考能力，来自上游目录，客户端据此决定是否展示「思考强度」选择器、
            展示哪几档。

            ⚠️ **不可用时整个字段不出现，且不给默认值**（与 `architecture` 相反）。
            编造默认值等于代替上游做出未经确认的承诺：用户选了上游不认的强度，换回一个 400，
            看起来却像模型故障。
          properties:
            mandatory:
              type: boolean
              description: 强制思考，无法关闭
            default_enabled: { type: boolean }
            supported_efforts:
              type: array
              items: { type: string }
              description: '⚠️ 取值**由上游决定**，不限于这三档；按字段渲染，不要写死'
            default_effort: { type: string }
        pricing:
          type: object
          description: '**每 token** 的字符串（不是每百万），**币种为美元**'
          properties:
            prompt: { type: string }
            completion: { type: string }
            input_cache_read:
              type: string
              description: '缓存读，同样每 token'
            input_cache_write:
              type: string
              description: '缓存写，同样每 token'
            request:
              type: string
              description: '**恒为 `0`**：我们不按次收费，只按 token'
            image:
              type: string
              description: |
                ⚠️ **恒为 `0`，但图片不是免费的。** 价目表只有 token 四档
                （输入 / 输出 / 缓存读 / 缓存写），没有「每张图」这一档；带图的调用
                按上游报的 token 与成本计价。**不要用该字段估算图片成本**。

    EmbeddingList:
      type: object
      required: [object, data, model, usage]
      properties:
        object: { type: string, examples: [list] }
        model:
          type: string
          description: '**上游原样返回的名字**，我们不改写'
        data:
          type: array
          items:
            type: object
            required: [object, index, embedding]
            properties:
              object: { type: string, examples: [embedding] }
              index: { type: integer }
              embedding:
                type: array
                items: { type: number }
                description: '`encoding_format: base64` 时这一格是字符串，由上游决定'
        usage:
          type: object
          properties:
            prompt_tokens: { type: integer }
            total_tokens: { type: integer }
            cost:
              type: number
              description: |
                这次**花了你多少**，口径和 chat 完全一致。
                ⚠️ **算不出来时这一格不出现，而不是 0** —— 「这个模型还没定价」也算
                算不出来（判据是上游确实收了我们钱而我们算出 0）。
                出现的 `0` 因此是**真的 0**，不是「不知道」。
                ⚠️ 走平台渠道时回包里**不会**有 `usage.cost_details` —— 那一格是上游
                向我们收的钱。BYOK 下它保留：那是你自己上游账单上的钱。
                embeddings 没有输出 token，所以只有输入这一档参与计价。

    Generation:
      type: object
      required: [id, model, created_at, tokens_prompt, tokens_completion, total_cost, usage, is_byok]
      properties:
        id: { type: string }
        model: { type: string }
        provider_name: { type: string }
        created_at:
          type: string
          format: date-time
          description: '⚠️ 不带偏移量，按 `Asia/Shanghai`（UTC+8）解释'
        tokens_prompt: { type: integer }
        tokens_completion: { type: integer }
        native_tokens_prompt: { type: integer }
        native_tokens_completion: { type: integer }
        total_cost:
          type: number
          description: 向你收取的金额
        usage:
          type: number
          description: 与 `total_cost` 同值（OpenRouter 兼容）
        upstream_inference_cost:
          type: number
          description: |
            上游向我们收取的金额；**仅 BYOK 调用有** —— 走平台渠道（转售）时这一格
            不出现，那是我们的进价。
            ⚠️ 2026-08-22 之前实现漏判了这一条，转售调用上也返回了；已修。
        is_byok: { type: boolean }
        cancelled: { type: boolean }

    Key:
      type: object
      # ⚠️ 只有**确定存在**的字段进 required。`external_id` 刻意不在里面 ——
      # 从没设过时整格不出现，这正是需要客户端处理的那种情况。
      required: [hash, name, label, usage, disabled, include_byok_in_limit, created_at, updated_at]
      properties:
        hash:
          type: string
          description: '**稳定标识**，之后所有针对这把 key 的操作都用它'
        name: { type: string }
        label: { type: string, description: 前缀 + 省略号，给人看的 }
        limit:
          type: [number, 'null']
          description: '累计上限；**`null` = 不限额，不是 0**'
        limit_remaining: { type: [number, 'null'] }
        usage: { type: number }
        disabled: { type: boolean }
        include_byok_in_limit: { type: boolean }
        external_id:
          type: string
          description: 你自己的编号；**从未设置时该字段整个不出现**
        byok_usage: { type: number, description: 目前恒为 0，保留 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        expires_at:
          type: [string, 'null']
          format: date-time
          description: '⚠️ 不带偏移量，按 `Asia/Shanghai`（UTC+8）解释'

    Balance:
      type: object
      required: [currency, balance, available]
      properties:
        currency: { type: string, examples: [USD] }
        balance: { type: number, description: 当前余额 }
        creditLimit: { type: number, description: 授信额度 }
        available: { type: number, description: 可用 = 余额 + 授信 }
        lifetimeCharged: { type: number, description: 累计消费 }
        lifetimeCredited: { type: number, description: 累计充值 }
        periods:
          type: array
          description: 按期的小计
          items:
            type: object
            required: [period]
            properties:
              period: { type: string, examples: ['2026-07'] }
              charged: { type: number }
              credited: { type: number }

    Movements:
      type: object
      required: [content, total, page, size]
      properties:
        content:
          type: array
          items: { $ref: '#/components/schemas/Movement' }
        total: { type: integer, format: int64 }
        page: { type: integer }
        size: { type: integer }

    Movement:
      type: object
      required: [id, at, amount, direction]
      properties:
        id: { type: integer, format: int64 }
        at:
          type: string
          format: date-time
          description: '⚠️ 不带偏移量时按 `Asia/Shanghai`（UTC+8）解释'
        category: { type: string }
        type: { type: string }
        direction: { type: string, enum: [IN, OUT] }
        amount: { type: number }

    Statement:
      type: object
      required: [period]
      properties:
        period: { type: string, examples: ['2026-07'] }
        closed:
          type: boolean
          description: '**已锁定的账单不会被改写**，迟到的更正进下一期'
        charged: { type: number }
        lines:
          type: array
          description: 每个模型一行；补差行会写明自己来自哪个月
          items: { type: object, additionalProperties: true }

    Attribution:
      type: object
      description: |
        按模型 / 渠道 / 自定义标签拆分的花费。⚠️ **覆盖率与金额一并给出**：
        「本月成本 X（覆盖 62%）」中的 62% 表示还有 38% 的调用没有成本数据——不是它们
        不花钱，而是我们不知道花了多少。用未覆盖全的数字算毛利会偏乐观。
      additionalProperties: true

    ContentSettings:
      type: object
      required: [enabled, retentionDays]
      properties:
        enabled: { type: boolean, description: '**默认 false** —— 默认不存' }
        retentionDays: { type: integer, description: 默认 30，可配 1–365 }
        productLine: { type: string }
        operatorAccess: { $ref: '#/components/schemas/OperatorAccess' }

    OperatorAccess:
      type: object
      description: 我们当前是否可以查看这个账号的调用内容。
      properties:
        granted: { type: boolean }
        expiresAt:
          type: [string, 'null']
          format: date-time
          description: 授权到什么时候；没有授权时为 null
        reason: { type: string }

    KeyPatch:
      type: object
      properties:
        limit:
          type: [number, 'null']
          description: 改累计上限；`null` = 改成不限额
        name: { type: string }
        disabled:
          type: boolean
          description: '`true` 停用、`false` 恢复'
        include_byok_in_limit: { type: boolean }
        expires_at:
          type: [string, 'null']
          format: date-time
          description: |
            改到期时间；**显式 `null` = 取消到期**（与 `limit` 字段语义一致）。
            ⚠️ 到期的 key 调模型返回 **401**，与被吊销的表现完全一致：区分二者等于向持有无效
            凭据的人确认「这把 key 曾经存在」。
            ⚠️ 到期**不会**把 `disabled` 置为 `true`，该字段只表示「被停用」，判断到期请读
            `expires_at`。

    KeyCreated:
      type: object
      required: [key, data]
      properties:
        key:
          type: string
          description: '**完整明文，只在这一次出现。**'
        data: { $ref: '#/components/schemas/Key' }

    KeyReplay:
      type: object
      description: 幂等命中的返回。窗口内带明文，窗口外 `key` 为 `null`。
      # key 一定出现（可能是 null）；那两个 unavailable 字段只在窗口外出现。
      required: [key, data]
      properties:
        key:
          type: [string, 'null']
          description: 创建后 5 分钟内才有
        key_unavailable_reason:
          type: string
          enum: [plaintext_window_expired]
          description: |
            **可编程的取值**，目前只有这一个；新增会提前通知。**按它分支。**
        key_unavailable_message:
          type: string
          description: '面向人的文案。**措辞会变，不要用于判断。**'
        data: { $ref: '#/components/schemas/Key' }
