---
title: "AI 花費控管 — 討論稿 (FR-122.12-D)"
brand: "Guidant AI · **FR-122** AI 花費控管"
eyebrow: "FR-122.12-D · CM-2339 · 討論稿 · 2026-09-29"
h1: "原廠金鑰的 AI 花費：每租戶、每人一個美金額度，超過只擋 AI"
lede: "第一階段已把每次 AI 呼叫花多少美金記進 `ai_call_log`。這一稿講怎麼「管錢」：租戶與使用者各一個美金額度（日或月）、每分鐘次數改由閘道統一管、一頁看時間區間內的花費。**全部已裁**：9 項照寫，D-a、D-f～D-j 決策者 2026-09-29 裁定全照稿內建議。定案已落 design.md §3.2、§5.18、§6.3、§7.3。"
chips: [
  {text: "已裁：美金計、不做點數", kind: ok},
  {text: "已裁：額度直接加總紀錄表", kind: ok},
  {text: "已裁：license 這版只預留", kind: ok},
  {text: "D-a、D-f～D-j 照建議定案", kind: ok},
  {text: "定案見 design.md §5.18", kind: plain}
]
footer: "FR-122.12-D 花費控管討論稿 · 盤點來源：jedi-ai-gateway ports.py／gateway_service.py、core/plugins/ai_gateway.py／ai_bot.py／ai_dashboard.py、common/util/redis_rate_limiter.py、app/system_config/service/ai_provider_key_resolver.py、app/ai_call_log、scripts/sql/2026-09-28-fr122-ai-call-log.sql、FE AiServiceConfigForm.vue"
---

## 先看 mockup {#mockup nav="mockup"}

**[開啟 mockup：設定區塊／AI 用量頁／額度用完時的畫面](mockups/cost-quota.html)**（深色主題、配色照 FE design tokens，三個分頁切換）

## 分工概述（30 秒版） {#overview nav="30 秒版"}

**一句話**：用原廠金鑰打 AI 的花費，每個租戶一天（或一個月）最多花多少美金、每個人最多花多少美金、每分鐘最多送幾次，超過就只擋 AI；另外給一頁看「這段期間花了多少、誰花的」。

| 階段（子需求） | 做什麼 | 產出 | 完成怎麼判定（決策者親手檢查法） |
|---|---|---|---|
| **FR-122.12 額度與次數** | 閘道每次打 AI 前先算「本期已花多少」，超過就擋；每分鐘次數從小幫手、儀表板各自的計數搬進閘道統一算，分類也一起管 | 閘道套件的額度檢查點改版、主專案的額度實作、分類送出前預估 | 在資料庫把自己的每人額度改成 0.01 美金 → 小幫手問兩句，第二句回「今天額度已用完」；儀表板、分類同樣被擋；打開既有儀表板與分類結果照常看得到 |
| **FR-122.13 設定頁** | 「AI 服務設定」頁加一塊「AI 使用額度」：兩個金額＋週期、每分鐘次數、本期已用進度條；AI 功能頁到 80% 出黃條 | 設定 API＋前端區塊＋黃條 | 用租戶管理員登入，把租戶額度設 0.05 美金／日並儲存 → 重新整理數字還在；用小幫手聊到進度條過 80% → 小幫手視窗頂端出現黃條 |
| **FR-122.14 用量頁** | 新一頁「AI 用量」：選期間、依租戶／使用者／功能／模型／日期分組看花費，可點進現有的呼叫紀錄 | 彙總 API＋前端頁 | 選「本月、依使用者」→ 每人一列金額；把各列加起來＝頁頂總額；點某人「明細」跳到呼叫紀錄且只剩他的紀錄 |

**依賴**：.12 是地基（額度怎麼算、存在哪）；.13 的進度條與 .14 的彙總都讀 .12 定下的設定與算法，可在 .12 的設定格式定案後平行開工。決策者功課：裁 D-a 預設數字（其他待裁項都有建議，照建議即可開工）。

## 已裁（2026-09-29，不再討論） {#decided nav="已裁"}

::: {.callout .decided}
**額度單位＝美金，推翻 09-28 的「點數」**

每筆呼叫的 `estimated_usd`（閘道算好的美金估價）已落在 `ai_call_log`，直接拿來加總。不做點數換算表。
:::

::: {.callout .decided}
**兩層額度，任一層超就擋**

每租戶一個上限＋每使用者一個上限（全租戶同一個數，不逐人設）。與 LiteLLM、Bifrost 同做法。每個上限＝「金額＋週期」，週期下拉（日／月），預設「日」。
:::

::: {.callout .decided}
**週期邊界：日曆日、伺服器本地時區、零點重算；月＝日曆月**

不做「滾動 24 小時」。伺服器時區是安裝時的 `TZ`（預設 `Asia/Taipei`，見 `docker/production/docker-compose.yml`）。理由：畫面上「明天 00:00 恢復」一句話講得清楚，滾動制要講「最早那筆滿 24 小時後逐步恢復」，使用者看不懂。
:::

::: {.callout .decided}
**額度直接加總 `ai_call_log`，不另建 Redis 計數器**

每次呼叫前 `SUM(estimated_usd)`（只算原廠金鑰、當期範圍）。理由與反悔條件見[資料模型 §算法](#model)。**每分鐘次數**維持 Redis（沿用既有 `RedisRateLimiter`），搬進閘道。
:::

::: {.callout .decided}
**幾個請求同時進來、超過一點點可以接受**

加總後才送 AI，同一瞬間多筆都看到「還沒超」就都會放行。接受，不上鎖。
:::

::: {.callout .decided}
**證據分類送出前先預估，明顯不夠就整批先擋**

用該租戶過去分類每份的平均花費 × 份數估算，明顯超過剩餘額度就不讓送出並說明（見[預估](#precheck)）。
:::

::: {.callout .decided}
**設定放在「AI 服務設定」頁，每租戶可不同，ROOT 那筆＝全系統預設**

租戶管理員設自己租戶；租戶沒設的欄位繼承 ROOT（tenant 1）那筆。預設要保守，不是「沒設就無上限」。
:::

::: {.callout .decided}
**license 額度這版不做、只預留**

落地版客戶可以自帶 API 金鑰，額度只管原廠金鑰的試用用量，不需要原廠用授權檔鎖。第一版設定存 `system_configs`；日後 SaaS 版由 license `limits` 當「上限的上限」，欄位名現在先對齊（見[資料模型](#model)）。
:::

::: {.callout .decided}
**沿用不變**

只算走原廠鑰的用量、客戶自帶金鑰不計；超上限只擋 AI 呼叫，其他功能與歷史結果照常；80% 告警只在畫面（落地版沒有 email）；花費頁獨立一頁，系統管理員看全部、租戶管理員看自己。
:::

## 市面方案盤點 {#market nav="市面方案"}

| 方案 | 額度怎麼定 | 分幾層 | 超了怎樣 | 我們能不能直接用 |
|---|---|---|---|---|
| **LiteLLM proxy** | `max_budget`（美金）＋`budget_duration`（如 `1d`、`30d`）；另有 `rpm_limit`（每分鐘次數） | User／Team／Key 三層，**任一超就擋** | 擋下；另有 `soft_budget` 軟上限只告警 | **不能直接用**：預算功能只在 proxy 模式，我們依 D9 用 library 形式（行程內 `import litellm`），沒有 proxy 那層；而且它不認得「原廠鑰／客戶鑰」之分 |
| **Bifrost** | `max_limit`＋`reset_duration`，可對齊日曆日 | Customer／Team／VirtualKey／Provider 四層 | 額度超回 **402**、次數超回 **429** 帶 `Retry-After` | 同上，是另一個閘道服務；要多一個容器，且與我們自己的守門、稽核管線重疊 |

**抄什麼**：「金額＋週期」的欄位形狀、多層任一超就擋、次數用 429＋還要等幾秒、軟上限只告警不擋。**不抄什麼**：多一個 proxy 服務。我們已有自己的閘道與 `ai_call_log`，額度是在閘道上多一道檢查，不是再疊一層。

來源：<https://docs.litellm.ai/docs/proxy/users>、<https://docs.litellm.ai/docs/proxy/team_budgets>、<https://docs.getbifrost.ai/features/governance/budget-and-limits>

## 現況盤點（可複用 vs 缺口） {#current nav="現況"}

以下每一列都已開檔核對。

| 元件 | 現況 | 本案動作 |
|---|---|---|
| 閘道的額度檢查點 `IQuotaCounter`（`jedi-ai-gateway/domain/ports.py:44`） | 有 `check(tenant_id, feature)` 與 `consume(...)`，主專案接的是不限額的 `UnlimitedQuota` | **可複用但要改介面**：①`check` 只收租戶，**拿不到使用者**，每人額度做不了；②`check` 在管線第 0 步（`gateway_service.py:126`），**早於第 ③ 步解析金鑰**，那時還不知道這次走原廠鑰還是客戶鑰，客戶自帶金鑰的租戶用完原廠額度也會被擋。見[呼叫時序](#flow) |
| 擋下的例外 `GatewayBlockedError.QUOTA` | 已有這個原因代碼；小幫手已有對應訊息「本期 AI 使用額度已用完」（`ai_bot_service.py:55`） | 複用；另加 `RATE_LIMIT` 原因代碼 |
| 每分鐘次數：小幫手 | 套件寫死 `DEFAULT_RATE_LIMIT_PER_MINUTE = 10`（`ai_bot_service.py:44`），每人一分鐘 10 次 | 退場，改由閘道管 |
| 每分鐘次數：儀表板 | 套件寫死 **3 次**（`jedi_ai_dashboard/plugin/contract.py:24`，不是 10；一次生成打兩次 AI 所以較嚴） | 退場，改由閘道管 |
| 每分鐘次數：分類 | 沒有限制；一批並行 4 份（`gateway_classifier.py DEFAULT_CONCURRENCY = 4`），DEV 實測 63 份跑 3.5 分鐘，約每分鐘 18 份 | 納入閘道；每分鐘上限會拉長大批分類的時間，見 D-a |
| 計次實作 `RedisRateLimiter`（`common/util/redis_rate_limiter.py`） | 固定視窗 Redis 計數，Redis 掛掉就放行；兩支 AI 套件的插槽共用 | **原樣複用**，改接到閘道 |
| 429 回應（`core/app_factory.py:483`） | 兩支套件的「太頻繁」例外轉 429 帶 `retry_after` | 保留；閘道擋下的次數超過走各功能既有的「被擋」通道（見[回應](#response)） |
| 設定表 `system_configs` | 每租戶一列一組 `group/key`；`AI_PROVIDER_CONFIG` 群組**鎖平台管理員**（`AI_PROVIDER_CONFIG_PLATFORM_ADMIN_ONLY = True`），同群組已有 `CALL_LOG_RETENTION`、`AI_CALL_LOG_RETENTION_DAYS` 兩個 key | **額度不能放進 `AI_PROVIDER_CONFIG` 群組**——整組鎖平台管理員，租戶管理員會改不到。另開群組 `AI_QUOTA` |
| 租戶→ROOT 讀法 | `read_tenant_config_value(tenant_id, group, key)`（繞 RLS、顯式指定租戶），`resolve_retention()` 已是「租戶→ROOT→預設」的現成樣板 | 照抄這個樣板，不另寫讀法 |
| 新租戶抄設定 `tenant_storage_config_seeder._INHERITED_CONFIGS` | 建租戶時把 ROOT 的 `STORAGE_CONFIG`、`AI_PROVIDER_CONFIG` 複製一份給新租戶 | 🔴 **額度不可加進這張清單**：抄了就變成「建租戶當下的快照」，之後改 ROOT 預設不會帶到舊租戶，與「沒設就繼承」相反 |
| `ai_call_log` | 有 `tenant_id`、`user_id`、`feature`、`model`、`key_source`、`estimated_usd`、`status`、`created_at`；索引已有 `(tenant_id, created_at DESC)` 與 `(user_id, created_at DESC)` | 加總用現成欄位；**`(tenant_id, created_at)` 索引已存在**（`scripts/sql/2026-09-28-fr122-ai-call-log.sql:65`），不必再加 |
| AI 呼叫紀錄頁 | 已上線，能力點 `ai-call-log.read`（租戶管理員也有），`AiCallLogQueryEntity` 可篩 feature／status／user／日期 | 用量頁複用這顆能力點；下鑽直接帶條件跳過去 |
| license `limits`（`common/authz/license.py`） | 現只用 `max_sub_tenants` | 這版不動，只對齊欄位名預留 |

**DEV 實測**（本機 `guidant_ai_dev`，356 筆）：原廠鑰成功呼叫 306 筆全有美金估價；失敗 3 筆、被擋 47 筆估價為空（沒真的打到 AI，本來就不該計錢）。每次平均：小幫手 0.0015、儀表板挑圖 0.005＋排版 0.028（一次生成約 0.034）、分類每份 0.016 美金。今天花最多的一位測試帳號一天 3.68 美金。加總查詢實測走索引 0.1 毫秒。

## 資料模型草案 {#model nav="資料模型"}

### 設定存哪

`system_configs` 新群組 **`AI_QUOTA`**，兩個 key：

| group / key | 誰能改 | 內容 |
|---|---|---|
| `AI_QUOTA` / `CONFIG` | 租戶管理員改自己租戶；平台管理員改 ROOT（＝全系統預設） | 這個租戶實際生效的額度 |
| `AI_QUOTA` / `CEILING` | **只有平台管理員**（D-h） | 平台給這個租戶的上限；沒填＝上限就是 ROOT 的 `CONFIG` |

`CONFIG` 的值（JSONB）：

```json
{
  "tenant_budget": {"usd": 5.00, "period": "day"},
  "user_budget":   {"usd": 1.00, "period": "day"},
  "user_rpm": 20,
  "warn_ratio": 0.8
}
```

- `period` 只能是 `day`／`month`。
- `usd` 填 `0` ＝這一層不准用原廠鑰；**不提供「無上限」**（已裁：預設要保守）。平台管理員要給某租戶很大的額度，就填一個大數字。
- `warn_ratio` 先寫死 0.8，不在畫面上開。

🔵 **預留（日後 SaaS）**：license `limits` 將帶同一個形狀 `{"ai_quota": {"tenant_budget": …, "user_budget": …, "user_rpm": …}}`，當作「上限的上限」——生效值＝min(授權檔, `CEILING`, `CONFIG`)。這版不讀 license，只把欄位名定成一樣，日後接上不必轉換。

### 繼承規則

```{.mermaid cap="圖 1 — 設定繼承：每個欄位各自往上找；租戶只能調低"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TB
  CODE["程式內建預設<br/>（ROOT 也沒設時的最後防線）"] --> ROOT
  ROOT["ROOT（tenant 1）AI_QUOTA/CONFIG<br/>＝全系統預設"] --> CEIL
  CEIL["該租戶 AI_QUOTA/CEILING<br/>平台管理員設；沒填＝ROOT 的值"] --> TEN
  TEN["該租戶 AI_QUOTA/CONFIG<br/>租戶管理員設；沒填＝上限值"] --> EFF
  EFF["生效額度<br/>＝min(上限, 租戶設定)"] --> T1["租戶層：本租戶全部人加總"]
  EFF --> U1["使用者層：每個人各自算<br/>（同一個數，不逐人設）"]
  EFF --> R1["每分鐘次數：每個人各自算"]
  LIC["license limits.ai_quota<br/>（預留，這版不讀）"] -. 日後 SaaS .-> CEIL
```

- **逐欄位**往上找：租戶只填了租戶額度，每人額度與每分鐘次數照樣繼承上一層。
- 讀法照抄 `resolve_retention()`（租戶→ROOT→預設），用 `read_tenant_config_value`。
- 子租戶：只往 ROOT 繼承，不往父租戶繼承、也不把子租戶花費算進父租戶（D-i）。

### 算法：每次呼叫前加總（已裁）

```sql
-- 租戶層（使用者層多一個 AND user_id = :uid）
SELECT COALESCE(SUM(estimated_usd), 0)
  FROM public.ai_call_log
 WHERE tenant_id = :tid
   AND key_source IN ('root' /* , 'env' 見 D-j */)
   AND created_at >= :period_start;   -- 本地零點或月初，Python 端依 TZ 算好再傳入
```

- **為什麼可行**：索引 `(tenant_id, created_at DESC)` 已在，一天的量是幾百筆等級，實測 0.1 毫秒；每次 AI 呼叫本身要數秒，多這一句看不出來。
- **為什麼不用 Redis 計數器**：計數器要處理「零點歸零」「和紀錄表對不起來」「Redis 重啟歸零＝額度被重置」三件事，換來的只是省一句已經很快的查詢。
- 🔴 **期間起點在 Python 端算好再傳**，不要寫 `date_trunc('day', now())`：那是資料庫連線的時區，與應用容器的 `TZ` 不一定一樣，兩邊差 8 小時的症狀是「每天早上 8 點才歸零」，而且不會報錯。
- 讀法走「顯式指定租戶、繞 RLS」的 infra 查詢（與 `read_tenant_config_value` 同一個樣板），因為分類的背景工作沒有請求脈絡。
- 🔴 **紀錄保留天數與月額度的交互**：`AI_CALL_LOG_RETENTION_DAYS` 若被設成 31 天以下，月初的紀錄會先被清掉，月額度就少算。設定頁要擋「週期選月、保留天數 < 31」。
- **反悔條件**：單一租戶日呼叫量上萬筆，或 SaaS 版多租戶共用一個資料庫、加總開始拖慢呼叫時，在 `IQuotaCounter` 的實作層換成 Redis 計數器＋每日對帳，**閘道介面不動**。

### 閘道介面要改的地方（碰 `jedi-ai-gateway` 套件）

| 改動 | 為什麼 |
|---|---|
| `IQuotaCounter.check(ctx: CallContext, feature, key_source)`，取代只收 `tenant_id` | 要拿到 `user_id` 才能算每人額度；要知道這次走哪把鑰才能「客戶自帶鑰不計也不擋」 |
| 額度檢查從第 0 步移到第 ③ 步（解析金鑰）之後 | 第 0 步還不知道金鑰來源 |
| 新增 `IRateLimiter` port 與 `GatewayBlockedError.RATE_LIMIT`（帶 `retry_after`） | 每分鐘次數搬進閘道；插槽形狀與現有 `RedisRateLimiter.acquire(key, limit, window)` 一樣，主專案原樣接上 |
| 規則檔每個 feature 加 `counts_rate: true/false` | 儀表板一次生成打兩次 AI（挑圖＋排版），只算挑圖那次，才對得上「生成一次＝1 次」 |
| `consume()` 在加總法下不做事 | 花費由稽核落表那一筆就是來源；保留介面給日後換 Redis 用 |

依 CLAUDE.md 外部套件規範，這張改動要在實作卡的計畫裡列明、等決策者點頭；**其他 consumer 只有主專案自己**（三支 AI 功能都經主專案接線），風險低。

## 呼叫時序 {#flow nav="呼叫時序"}

```{.mermaid cap="圖 2 — 一次 AI 呼叫：先算次數、解析金鑰、再算額度；落表後下次加總就算得到"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
  participant F as 功能（小幫手／儀表板／分類）
  participant G as 閘道
  participant R as Redis 計次
  participant Q as 額度檢查（主專案）
  participant DB as ai_call_log
  participant AI as AI 供應商
  F->>G: complete(請求, 誰, 哪個功能)
  G->>R: 這個人這分鐘第幾次？（counts_rate 的功能才算）
  alt 超過每分鐘次數
    R-->>G: 還要等 N 秒
    G-->>F: 擋下 RATE_LIMIT（背景分類：等 N 秒後自動重試）
  end
  G->>G: ① 入口守門 ② 組訊息 ③ 解析金鑰（得知原廠鑰／客戶鑰）
  alt 客戶自帶金鑰
    Note over G,Q: 不查額度、不計
  else 原廠金鑰
    G->>Q: check(誰, 功能, 金鑰來源)
    Q->>DB: 加總本期 租戶花費、本人花費
    alt 任一層 ≥ 額度
      Q-->>G: 擋下 QUOTA（附哪一層、何時恢復）
      G->>DB: 記一筆 status=blocked
      G-->>F: 擋下，只擋這次 AI；其他功能照常
    end
  end
  G->>AI: ④ 送出
  AI-->>G: 回覆＋用量
  G->>G: ⑤ 出口守門
  G->>DB: ⑥ 記一筆（含 estimated_usd）
  G-->>F: 結果
  Note over F,DB: 80% 告警不在這條路上算——畫面讀「本期已用」時才比（見 80% 告警）
```

## 每分鐘次數搬進閘道 {#rate nav="次數"}

| 功能 | 現在 | 之後 |
|---|---|---|
| 小幫手 | 套件內每人 10 次／分，主專案 `core/plugins/ai_bot.py:93` 傳 `RedisRateLimiter()` 進去 | 主專案**不再傳** `rate_limiter`（套件在沒接時本來就不限），改由閘道算。套件程式不必改 |
| 儀表板 | 套件內每人 3 次／分，`core/plugins/ai_dashboard.py:97` 同上 | 同上；閘道只算「挑圖」那次，排版不算 |
| 分類 | 不限 | 閘道每份檔算 1 次；**在背景工作行程被擋時不報錯，等 N 秒後重試**（閘道看 `executor=worker` 決定等待，最多等一個視窗） |
| 資源庫的租戶級節流（`assert_within_tenant_limit`，`resource_library_app_service.py:470`） | 另一件事（防灌爆框架解析），不打 AI | 不動 |

計數的 key 用 `ai_rate:{tenant_id}:{user_id}`，數值取生效設定的 `user_rpm`。Redis 掛掉照現有做法放行並記 warning。

**兩個套件內寫死的上限值**（10、3）退場後變成沒人用的預設值，留在套件裡不影響行為；下次動這兩支套件時順手刪掉。

## 額度超／次數超的回應與畫面 {#response nav="回應"}

三支功能收到閘道擋下時，**本來就各有「被擋」的呈現通道**，不必新造：

| 功能 | 現有被擋通道（已開檔核對） | 額度用完 | 次數超過 |
|---|---|---|---|
| 小幫手 | 回 200，`ChatReply.blocked=原因`，`reply` 是給人看的句子（`ai_bot_service.py:207`） | 聊天泡泡：「你今天的 AI 額度（1.00 美金）已用完，明天 00:00 恢復。」 | 聊天泡泡：「送出太頻繁，請 23 秒後再試。」 |
| 儀表板 | `AIGenerationResult(success=False, error_kind=BLOCKED, message=exc.reply)`（`ai_dashboard_app_service.py:348`） | 生成區塊顯示同樣句子 | 同左 |
| 分類 | 每份檔的 `row["error"]` 記下錯誤繼續下一份（`gateway_classifier.py:231`） | 分類跑到一半用完：已分類的保留，**後面的檔不再逐份打閘道**（每份都會被擋又多一筆紀錄），直接標「額度用完未分類」，明天可重跑 | 背景等待重試，使用者看不到 |
| 分類送出前預估 | 無（同步 API） | 明顯不夠 → 整批不受理，回錯誤碼（D-f） | — |

句子由閘道放進 `GatewayBlockedError.reply`，兩支 AI 套件都已經「有 `reply` 就原樣回」，所以**套件不必改**就能講清楚是哪一層、何時恢復。

## 80% 告警怎麼算、怎麼顯示 {#warn nav="80% 告警"}

- **不存告警事件、不另算**：畫面要顯示時，呼叫一支「我目前的額度」API，回傳租戶層與本人層的「已用／額度／重置時間」，前端自己比 80%。
- **出現在哪**：小幫手視窗頂端、動態儀表板生成區、證據分類送出鈕旁，黃條一行字（mockup 第③頁）。租戶管理員多一個「調整額度」連結。
- **何時重讀**：進頁面時一次、每次 AI 呼叫完一次。不輪詢。
- 客戶自帶金鑰的租戶：API 回「不計額度」，畫面不顯示黃條也不顯示進度條。

## 分類送出前預估 {#precheck nav="送出前預估"}

- **估法**：該租戶近 30 天分類每份的平均花費 × 這批份數；租戶沒有歷史就用全系統平均；再沒有就用常數 0.02 美金／份。
- **「明顯不夠」**＝預估 > 剩餘額度 × 1.5（租戶層、本人層都比）。差一點的照送，跑到一半用完照[上面](#response)處理。1.5 這個數字放進 D-a 一起裁。
- **擋下時的說法**：「這批 67 份預估約 1.05 美金，你今天只剩 0.40 美金，請減少到約 25 份以內或明天再送。」（mockup 第③頁）

## 用量頁 {#usage nav="用量頁"}

| 項目 | 內容 |
|---|---|
| 篩選 | 期間（本日／本週／本月／自訂）、分組（租戶／使用者／功能／模型／日期）、金鑰來源（原廠鑰／全部，預設原廠鑰）、租戶（**只有平台管理員**看得到這個篩選） |
| 頂部三格 | 花費（美金）、呼叫次數、被擋下次數（額度＋次數） |
| 表格欄 | 分組名稱、呼叫次數、輸入 token、輸出 token、花費、占比、「明細 →」 |
| 下鑽 | 帶同樣期間與條件跳到現有「AI 呼叫紀錄」頁 |
| 匯出 | 沿用紀錄頁的 xlsx 匯出做法（`ai_call_log_export.py`） |
| 權限 | 沿用 `ai-call-log.read`；租戶管理員靠 RLS 只看到自己租戶，平台管理員看到全部 |
| 查詢 | `GROUP BY` 分組欄位 over `ai_call_log`，期間與租戶走既有索引 |

- 使用者分組要同時回 `user_name`（暱稱），照 CLAUDE.md 審計欄位規範在 app service 批次查。
- 功能分組把 `ai-dashboard.select`／`ai-dashboard.layout` 合成一列「動態儀表板」，可展開看兩段。

## 設定頁 mockup 說明 {#settings nav="設定頁"}

見 [mockup 第①頁](mockups/cost-quota.html)。重點：

- 放在現有「AI 服務設定」頁（`AiServiceConfigForm.vue`，253 行，加一塊不會超過 800 行上限；建議新區塊拆成獨立子元件）。
- 🔴 **頁面權限要拆開**：現在整頁鎖平台管理員（`AI_SERVICE_CONFIG_PLATFORM_ADMIN_ONLY`，路由、選單、頁面三處同引）。額度區塊要讓租戶管理員進得來、但金鑰區塊仍只有平台管理員看得到——路由與選單改成「有 `storage-config.read` 就進得來」，頁內金鑰區塊照舊用那個旗標藏起來。
- 沒填的欄位用灰字顯示繼承來的值；「還原為系統預設」＝清掉本租戶這一列。
- 輸入超過上限時，存檔被擋並說明「平台給本租戶的上限是 20.00 美金／日」。
- 用 PrimeVue `InputNumber`（兩位小數）＋ `Dropdown`（日／月）＋ `ProgressBar`；色彩用 design tokens，80% 以上用 `--color-orange`、滿了用 `--color-red`。

## 待裁（2026-09-29 已全部照建議裁定） {#pending nav="待裁"}

以下各項決策者已裁定照「我的建議」走，保留原文供查理由。原列的 D-b（license 關係）、D-c（加總或計數器）、D-d（週期邊界）、D-e（分類預估）已由決策者裁定，見[已裁](#decided)。

::: {#d-a .callout .pending}
**D-a 預設值**

ROOT 那筆出廠時的數字（租戶沒設就用它）：

| 項目 | 建議 | 換算成實際用量（DEV 實測單價） |
|---|---|---|
| 租戶額度 | **5 美金／日** | 小幫手約 3,300 句，或儀表板約 145 次，或分類約 310 份 |
| 每人額度 | **1 美金／日** | 小幫手約 660 句，或儀表板約 29 次，或分類約 62 份 |
| 每人每分鐘次數 | **20 次** | 小幫手 10 次太緊不會碰到；分類現在一分鐘約 18 份，設 10 會讓 67 份那批從 3.5 分鐘拉長到約 7 分鐘 |
| 分類預估「明顯不夠」倍數 | **1.5 倍** | — |

我的建議：照上表。卡上原提的 10 次／分鐘會拖慢分類一倍，改 20；若寧可慢也要保守，就維持 10。
:::

::: {#d-f .callout .pending}
**D-f 額度用完回什麼狀態碼**

- **A（建議）** 小幫手與儀表板沿用現有「被擋」通道（200 內帶 `blocked=quota` 與說明句）；只有「分類送出前預估不夠」這支同步 API 回錯誤，用 **412 前置條件不滿足**＋新錯誤碼 `GRC_412xxx`（照專案命名規則）。
- B 仿 Bifrost 回 **402**：專案例外類別表沒有 402，`jedi_common` 也沒有對應類別，要新造；而小幫手與儀表板的被擋本來就不是 HTTP 錯誤，改成 402 等於改兩支套件的回應契約。

我的建議：A。
:::

::: {#d-g .callout .pending}
**D-g 用量頁要不要跟現有「AI 呼叫紀錄」頁合併**

- **A（建議）** 合併成一頁兩分頁：「用量彙總」（新）＋「呼叫明細」（現有頁原封搬進來）；下鑽就是切分頁並帶條件。選單只有一個入口「AI 用量」。
- B 各自一頁：選單多一項，下鑽要跨頁帶參數。

我的建議：A。兩者同一顆能力點、同一張表，讀者要的是「先看總數、再點進去看是哪幾筆」。
:::

::: {#d-h .callout .pending}
**D-h 租戶管理員能不能把額度調高過系統預設**（新增）

租戶管理員自己設額度，但花的是原廠的錢。不設上限的話，租戶管理員填 1,000 美金／日就等於沒有額度。

- **A（建議）** 租戶只能在「上限」以內調整；上限預設＝ROOT 的系統預設，平台管理員可以對個別租戶另設上限（`AI_QUOTA/CEILING`，只有平台管理員能改）。日後 SaaS 的 license 額度接在同一個位置。
- B 租戶愛設多少設多少：最簡單，但額度形同虛設。
- C 只有平台管理員能設，租戶管理員只能看：與已裁「租戶管理員設自己租戶」衝突。

我的建議：A。
:::

::: {#d-i .callout .pending}
**D-i 子租戶怎麼算**（新增）

- **A（建議）** 每個租戶各算各的：子租戶的設定只往 ROOT 繼承，花費也不加到父租戶頭上。與現有金鑰、保留天數的「租戶→ROOT」讀法一致。
- B 父租戶額度涵蓋全部子租戶加總：要數整棵樹（`count_descendants` 那類查法），加總查詢變重，且子租戶管理員看不到兄弟租戶花了多少，無從理解為什麼被擋。

我的建議：A；若日後要「整個客戶一個總額度」，在 SaaS 的 license 額度那層做。
:::

::: {#d-j .callout .pending}
**D-j 金鑰來源是「環境變數」的算不算原廠鑰**（新增）

金鑰解析有三層：租戶自己的、ROOT 的（`root`）、環境變數的（`env`）。環境變數那把也是平台方的鑰，只是沒填在畫面上。

- **A（建議）** `root` 與 `env` 都計入額度；只有 `tenant`（客戶自己填的）不計。
- B 只算 `root`：部署時用環境變數放原廠鑰的機器就完全沒有額度。

我的建議：A。
:::

## 拆卡草案 {#split nav="拆卡草案"}

| 子需求 | 範圍 | 依賴 | 驗收條件 |
|---|---|---|---|
| **FR-122.12 額度與次數** | ①閘道套件：`IQuotaCounter.check` 改收 `CallContext`＋金鑰來源、檢查移到第 ③ 步後、新增 `IRateLimiter` port 與 `RATE_LIMIT` 原因、規則檔 `counts_rate`、背景工作被次數擋時等待重試（要決策者先點頭動套件）②主專案：`AI_QUOTA` 設定讀取（租戶→上限→ROOT→內建）、加總查詢、`QuotaCounter` 與次數實作接線、兩支 AI 套件不再傳 `rate_limiter` ③分類：送出前預估、用完後剩餘檔標「額度用完未分類」 | 無 | ①每人額度設 0.01 → 小幫手第二句被擋、說明「明天 00:00 恢復」、`ai_call_log` 多一筆 `status=blocked`；②同租戶改用自己的金鑰 → 不被擋、不計入；③每分鐘次數設 2 → 第 3 句回「請 N 秒後再試」；④儀表板生成一次只算 1 次；⑤分類 67 份、額度只夠 10 份 → 送出前被擋並說明；額度夠 60 份時送出，約 60 份有結果、其餘標「額度用完未分類」，且 `ai_call_log` 沒有 7 筆以上的連續被擋紀錄；⑥把伺服器 `TZ` 設 `Asia/Taipei`，23:59 用完、00:00 後可再用 |
| **FR-122.13 設定頁** | ①BE：`AI_QUOTA` 讀寫 API（`CONFIG` 租戶管理員、`CEILING` 平台管理員；存檔檢查不超過上限、月週期時保留天數 ≥ 31）、「我目前的額度」API ②FE：AI 服務設定頁拆權限（額度區塊租戶管理員可進、金鑰區塊仍鎖平台）、額度區塊子元件、三支 AI 功能頁 80% 黃條 ③不把 `AI_QUOTA` 加進新租戶抄設定清單 | .12 的設定格式 | ①租戶管理員看得到額度區塊、看不到金鑰區塊；②填超過上限存不進去並說明；③清空欄位顯示灰字繼承值；④聊到 80% 以上三支功能頁都出黃條；⑤新建一個租戶，查 `system_configs` 沒有它的 `AI_QUOTA` 列，但畫面顯示系統預設 |
| **FR-122.14 用量頁** | ①BE：彙總 API（期間、分組、金鑰來源、租戶篩選限平台管理員；使用者分組補暱稱）②FE：「AI 用量」頁，依 D-g 與呼叫紀錄合併成兩分頁；匯出 | .12 | ①本月依使用者分組，各列加總＝頂部總額；②租戶管理員看不到其他租戶、也看不到租戶篩選；③點「明細」切到呼叫明細並只剩該人該期間；④金鑰來源切「全部」數字變大（含客戶自帶鑰） |

## design.md 改寫草稿 {#design-draft nav="design 改寫草稿"}

以下是裁完後要落進 `design.md` 的文字，**本稿不改 design.md 本體**，由下一棒照抄並依最終裁示調整。

### §3.2 成本（第三階段）— 取代原表

| 題目 | 定案 | 理由 | 被排除方案 |
|---|---|---|---|
| 計量單位 | **美金**，直接加總 `ai_call_log.estimated_usd` | 每筆已有估價，不必再維護換算表；與業界閘道同單位 | 點數換算表（09-28 原裁）：多一張要維護的表，且與實際花費脫鉤 |
| 額度層級 | 每租戶一個＋每使用者一個（全租戶同數），**任一超就擋**；各為「金額＋週期（日／月，預設日）」 | 與 LiteLLM、Bifrost 同做法；一個人用光全租戶額度的情況由每人額度擋 | 只有租戶層：一人可吃光全租戶；逐人設：管理負擔大 |
| 週期邊界 | 日曆日、伺服器本地時區（`TZ`）零點重算；月＝日曆月 | 「明天 00:00 恢復」一句話講得清楚 | 滾動 24 小時：使用者看不懂何時恢復 |
| 算法 | 每次呼叫前 `SUM` 本期原廠鑰花費；並發時超一點點接受 | 已有索引、一天幾百筆等級，查詢 0.1 毫秒；免處理計數器歸零與對帳 | Redis 計數器＋對帳：多三個出錯點。反悔條件：日量上萬或 SaaS 共用 DB 時在 `IQuotaCounter` 實作層換，介面不動 |
| 每分鐘次數 | 閘道統一管（Redis 固定視窗），每人一個數、每租戶可設；分類一份＝1 次、儀表板生成一次＝1 次 | 原本兩支功能各算、分類沒管 | 維持各功能自己算：分類仍無上限 |
| 設定位置 | `system_configs` 群組 `AI_QUOTA`；ROOT 那筆＝系統預設；租戶管理員在上限內設自己租戶 | 每租戶可不同、沿用既有租戶→ROOT 讀法 | 放進 `AI_PROVIDER_CONFIG` 群組：該群組鎖平台管理員 |
| 原廠鑰定位 | **試用額度**：有額度，用完引導客戶改自帶金鑰 | 不必先做簽發站「加購額度」 | 原廠鑰無上限：燒錢無底 |
| 硬上限到了 | **只擋 AI 呼叫**，功能本身與歷史結果照常 | 既有儀表板與分類結果仍可看 | 整個模組停用 |

共同前提：第一版設定存 `system_configs`，**license `limits` 預留**（欄位名 `ai_quota` 已對齊，日後 SaaS 當上限的上限）；只計原廠鑰（`key_source` 為 `root`／`env`，依 D-j）用量，客戶自帶金鑰不計也不擋；80% 告警只在畫面；分類批次送出前用歷史平均預估、明顯不夠整批先擋。

### §6.3 第三階段：成本 — 取代原表

| 子需求 | 範圍 |
|---|---|
| FR-122.12 額度與次數 | 閘道 `IQuotaCounter` 改收呼叫脈絡與金鑰來源、移到解析金鑰之後；新增 `IRateLimiter`；主專案加總式額度實作與 `AI_QUOTA` 讀取；兩支 AI 套件的次數限制退場；分類送出前預估 |
| FR-122.13 設定頁 | AI 服務設定頁加額度區塊（租戶管理員可設、平台管理員設上限）；「我目前的額度」API；三支 AI 功能頁 80% 黃條 |
| FR-122.14 用量頁 | 依 `ai_call_log` 彙總的「AI 用量」頁，與呼叫紀錄頁合併兩分頁 |

### §7.3 第三階段驗收 — 取代原表

| # | 動作 | 預期 |
|---|---|---|
| 1 | 把自己的每人額度設很低，用小幫手聊到 80% | 小幫手視窗出現黃條 |
| 2 | 繼續聊到用滿 | 回「今天額度已用完，明天 00:00 恢復」；儀表板與分類也被擋；既有儀表板與分類結果照常看得到 |
| 3 | 同租戶改填自己的金鑰再聊 | 不被擋，用量頁「原廠鑰」數字不增加 |
| 4 | 每分鐘次數設 2，連送 3 句 | 第 3 句回「請 N 秒後再試」 |
| 5 | 送一批明顯超過剩餘額度的分類 | 送出前被擋並說明可送幾份 |
| 6 | 用量頁選本月、依使用者 | 各列加總等於頂部總額，點明細只剩那個人的紀錄 |
