iT邦幫忙

1

usage - 把 AI 配額釘在選單列,以及那些「長得很像」的開發地雷

  • 分享至 

  • xImage
  •  

我剛剛花了整整四十分鐘,幫手邊的程式碼進行大翻修,正當我思緒正順、準備看它幫我把最後一塊拼圖拼上時,我的 AI 寫程式助手 Claude 突然無預警罷工了——它淡淡地彈出一個通知,說我這五小時的「扣打(使用額度)」已經全部用光。

https://ithelp.ithome.com.tw/upload/images/20260807/20183178P5qrRkBGJV.png

就像是你去修車,引擎都已經拆得七零八落了,修車工具卻突然被一把沒收。然後順便吐嘈你一下您是要升級專案呢?還是等待 5 小時重置時間的到來?當時整個軟體還處於跑不動的半殘狀態,我的零件拆到一半卡在半空中,而 Claude 就這樣兩手一攤,直接裝死。

最讓我崩潰的不是它有限制,而是它「快沒電了」卻完全不會主動提醒!雖然你確實可以自己登入 Claude 的官方網頁去查剩餘用量,但這方式實在太不 AI 了。你想想,當你思緒正在瘋狂飆速、全心解決問題的時候,誰會有那個美國時間特地中斷工作、使用指令或打開網頁,還要傻傻地狂按 F5 重新整理,只為了盯著那個額度數字看?

結果就是,因為沒有常駐在眼前的進度條,也沒有像手機電池那樣的百分比可以看,這套工具總是在你靈感最流暢、最不想被打斷的節骨眼,毫無防備地讓你等待五小時的到來。

所以我寫了 usage。它只做一件事(喔!他現在已經可以做很多事了):把 Claude Code、Codex、Antigravity 的剩餘配額,像電池百分比那樣釘在 macOS 選單列的角落(右上角),Windows 則放在系統匣(右下角時鐘日期旁邊叫系統匣?)。不用下指令,不用開網頁。

專案資訊

  • 專案名稱: usage (偉大的專案始終來自於一個小小的問題,喔~你還想偉大?)
  • 簡介: macOS 選單列/Windows 系統匣的 AI 配額監控工具。顯示 Claude Code、Codex、Antigravity 的五小時與每週配額、token 用量與成本,並可產出離線 HTML 分析報告,還有一堆AI密技(就像小時候玩任天堂一樣一堆密技!)
  • 開發狀態: 已上線持續更新。2026 年 5 月 17 日開專案,目前 v0.29.19,280 stars,AGPL-3.0。

必要連結

  • GitHub:https://github.com/aqua5230/usage
  • 官網:https://aqua5230.github.io/usage/
  • 安裝(macOS):brew install --cask aqua5230/usage/usage

https://ithelp.ithome.com.tw/upload/images/20260806/201831780m5raqkmRq.png

下面講五個我真的翻車的地方,它們有個共同點:每一個都像前女友在翻舊帳,拿著你以為早就解決的問題,出來提醒你問題根本沒解決。

一、開發的第一天,我給自己定了一條死規矩

同類工具多半走這條路:串接官方的用量 API,發送請求定時去問,定時去問「我用了多少」。

我不想這樣做,理由很實際。查詢用量這件事本身不該花錢。如果看一次額度就要打一次 API,那我看得越勤,額度掉得越快,這在邏輯上就講不通。一個幫你盯著額度的工具,不該同時是消耗額度的來源,(解決問題的同時不該同時增加問題),而且我在用claude的時候它額度很貴,如果光是看額度就燒掉一堆 token ? 這不符合我們台灣人刻苦耐勞(喔~不,是勤儉持家!)的精神!

所以第一天就定死一條原則:不呼叫任何 LLM API。看配額這件事,永遠不會消耗你的 token。

注意這條原則講的是 LLM API,不是「完全不連網」。這個分寸後來被現實逼著校準了好幾次,第七個坑就是在講它。

二、技術架構

先把地圖攤開,後面講坑的時候比較好對位置。

  • 語言: Python 3.13,測試用 pytest,77 個測試檔
  • macOS 前端: PyObjC,用 NSStatusItem 做選單列、WKWebView 做彈出面板
  • Windows 前端: pystray 做系統匣
  • 面板: 11 個 HTML 面板,其中 10 個是配額的視覺主題
  • 打包: macOS 用 py2app,Windows 用 pyinstaller
  • 在地化: 繁中、簡中、英、日、韓五語

這架構裡最要命、但也最保命的決定,就是畫了一條楚河漢界:負責算數學的資料層,絕對不准去碰畫介面的 GUI。

就是把「大腦」跟「臉」分開。

menubar.py 是 Mac 專用的臉(走 PyObjC),wintray.py 是 Windows 專用的臉(走 pystray)。這兩張臉一套講 Objective-C 的規矩,一套講Win32 的規矩,彼此根本無法溝通,但沒關係,因為它們背後去讀檔、解析、算 Token 的「大腦」是同一顆。

正因為當初切了這條線,後來我想生出 Windows 版的時候,我完全不需要把整個 App 打掉重練。底層算數學的邏輯原封不動,我只要專心搞定wintray.py 這層視窗就好。整個 Windows 移植工作,就從「重寫一個 App」瞬間縮小成「重寫一層視窗」。

三、Claude Code 給你答案,Codex 要你自己算

原本我以為「讀本機檔案」是一件事。做下去才發現是兩件(而且是那種你以為是雙胞胎、結果連血型都不一樣的兩件)。

Claude Code 有 hook,但有歷史包袱

這邊算我運氣好。Claude Code 有狀態列 hook(掛鉤),這東西就像是你在別人的程式裡預留一個插座,事情發生時,對方會主動來呼叫你。

差別在方向。沒有 hook,你得自己一直去敲門問「現在幾 % 了?」。有 hook,對方有變動時就會主動通知你。

Claude Code 每次更新狀態列,就會去執行你註冊的那支腳本,把當前用量從 stdin(標準輸入,程式的入口管道)直接倒給你。

所以 usage 就裝一支這種腳本,它只做一件事:把收到的用量寫成一個 JSON 檔。app 完全不用管 Claude Code 內部怎麼運作,只要盯著那個檔案看就好。

但這個寫檔的動作有個致命的時間差。hook 在寫檔,app 在讀檔,兩支程式各跑各的,誰也不知道對方此刻在幹嘛。

如果 hook 直接打開正式檔覆蓋,寫到一半、app 剛好跑來讀,那它就會讀到半截殘破的 JSON,解析失敗,然後畫面閃一下空白給你看。

解法叫原子寫入。原子(atomic)的意思是不可分割,要嘛全發生,要嘛完全沒發生。

我分三步來做。第一步,用 mkstemp 在同一個資料夾開一個暫存檔。第二步,把完整內容寫進那個暫存檔。第三步,用 os.replace 把暫存檔改名、蓋掉正式檔。

第一步強調在同一個資料夾,是因為只有在同一個檔案系統上,第三步才是作業系統保證的原子操作。

這樣一來,讀檔的 app 就只會看到完整的舊版,或是完整的新版。中間那個尷尬的半成品根本不存在。就像別在客人桌上現組三明治,去廚房組好整盤,再一次把舊盤子換掉。

麻煩的是歷史包袱。這個檔案的路徑跟格式我換過好幾次,而使用者是不會跟著你升級的。他哪天裝的版本,就一直用那個版本。

也就是說,你機器上早就不存在的舊路徑,在別人機器上還好端端活著。因此讀取邏輯不能只認一個路徑,要由新到舊排一串降級鏈,依序試、讀到就停:

STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
LEGACY_STATUS_FILE = os.path.expanduser("~/.claude/usag-status.json")
TT_STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
CLAUDE_JSON_FILE = os.path.expanduser("~/.claude.json")

這四層裡,第 1 層是現在的正式檔。第 2、3 層是我以前取過的舊名字。

第 4 層 ~/.claude.json 是保底,撈的是 Claude Code 自己順手快取的 cachedUsageUtilization,這數字不保證新鮮,可能有點餿。但我寧可給一個可能有點餿的數字,也不要開天窗給使用者看空白。

處理這串舊檔,有一條我給自己立的鐵則:舊路徑只准讀,不准寫回去。

舊檔本來就有一個寫入者,也就是使用者機器上那個沒升級的舊版 app,它的 hook 還活著,還在寫那個舊路徑。

新版一旦順手寫回舊路徑,那個檔案就多了第二個寫入者,而且兩邊格式還不一樣。

想像一個超常見的具體場景:使用者同時裝了新舊兩版(一個 Homebrew、一個手動下載)。兩個寫入者就會互相蓋掉對方,誰後寫誰贏。

於是讀出來的數字就在兩種格式之間跳,像在玩大老二。使用者不會跟你說「格式衝突」,他只會說「怪怪的」,你就只能通靈了。

Codex 有 hook,但它不會告訴你用量

Codex 也有 hook 機制,usage 也在用(裝在 ~/.codex/hooks.json,管的是開新對話時接續進度)。但很可惜,它沒有回報用量的 hook。想知道現在用了多少?自己去它留下的檔案裡挖吧:

  • ~/.codex/sessions/~/.codex/archived_sessions/ 底下的 JSONL(一種每行都是獨立 JSON 資料的記錄檔),逐筆事件記錄
  • ~/.codex/state_5.sqlite,thread 對應表
  • ~/.codex/logs_2.sqlite,另一份紀錄,token 用量跟 rate limit(API 呼叫頻率限制)都從這裡撈

用量得自己從 JSONL 一行一行慢慢累加。這部分還算單純,勞力活而已。

真正麻煩的是成本:JSONL 記了 token 數,卻沒有可靠地記下這些 token 到底是哪個模型燒掉的。

這有什麼關係?因為不同模型單價差很多。只要模型認錯人,成本就整個歪給你看。把便宜的算成貴的,帳單瞬間膨脹,嚇死人。

模型資訊躺在 state_5.sqlite 的 thread 表裡。

於是 codex_loader.py 得做一件有點危險的事:用唯讀模式,去打開一個「別的程式正在使用中」的 SQLite(一種把整個資料庫存在單一檔案裡的輕量資料庫)。

def _readonly_sqlite_uri(path: Path) -> str:
    """Return a read-only SQLite URI that also accepts Windows drive paths."""
    return f"{path.resolve().as_uri()}?mode=ro"


with closing(sqlite3.connect(_readonly_sqlite_uri(STATE_DB), uri=True)) as conn:
    rows = conn.execute(
        "SELECT id, model, cwd FROM threads",
    ).fetchall()

mode=ro 這三個字不是我在裝有禮貌,那是在保命。

為什麼?因為那個資料庫是人家 Codex 的,它隨時可能正在寫入。

現在的 SQLite 多半跑在一種叫 WAL(Write-Ahead Logging,寫入先進日誌檔再合併回主檔)的模式。這就像是餐廳服務生接到點單,會先快速記在口袋裡的便條紙(日誌),有空時再謄進櫃台的大帳本(主檔)。

我們要是用預設的讀寫模式硬連進去,系統就會以為我們也是來記帳的服務生,於是被迫參與到它的鎖與日誌狀態裡,跟人家搶筆。

最壞的情況是,使用者的 Codex 寫入失敗、甚至整個資料庫毀損。你想想,一個「幫你看用量」的小工具,結果把使用者真正在賺錢的 AI 工具給搞壞了?這種事絕對不能發生。

所以,整條 Codex 的讀取解析路徑,從頭到尾一個字都沒有寫入。

順帶一提,as_uri() 那步也不是裝飾用的。Windows 路徑長得像 C:\Users\...,直接串成 URI 會炸,as_uri() 會乖乖處理成 file:///C:/Users/...。這是我移植到 Windows 才含淚發現的。

我差點把它們合併掉

專案裡有兩組 Claude 解析器、兩組 Codex 解析器。四個檔案,職責看起來高度重疊,簡直是在挑戰工程師的強迫症。我兩次想把它們收成一組,兩次都在逐行比對之後默默放棄。

為什麼放棄?差別在資料形狀。

codex_loader.py 是每個 token 計數事件一筆。它算的是增量,因為選單列要的是即時的燃燒率跟時間序列。

adapters/codex.py 則是整個 session 一筆。它取的是最後的累計總量,因為報告要的是彙總。

增量跟總量,這是天生矛盾的需求。

如果硬要把兩組程式合併,總有一邊得妥協:它得在讀完資料之後,再依照對方的格式換算一次。也就是說,它的換算依據,完全綁死在另一邊的資料格式上。

這就像是兩家公司共用一套匯率表,其中一家私下把美金改回報成英鎊,卻沒通知另一家。

結果就是,只要有一邊偷偷改了格式,另一邊就會在「完全沒被改到」的情況下,默默算出錯的數字。程式不會拋例外,不會有紅字警告,就是安靜地、面帶微笑地給你一個錯的值(這才是最可怕的)。

所以最後我選擇讓它們各自活著。並且,我把「這兩組是刻意分開的、不要合併」寫成專案規範的一條,附上理由。這樣以後不管是我自己、別人、還是哪個手癢的 AI 想順手重構,都會先讀到這條。

在會誘惑人重構的地方留一條寫明理由的禁令,比留一個漂亮的抽象有用多了。

四、有一種 token 不該算進燃燒率

usage 會判斷你的燃燒率,用 tokens/min 分成 Idle、Normal、Active、Heavy 四級,選單列的文案跟表現都看這個等級在演。

第一版算法很直覺:區間內的 token 加總,除以經過的分鐘數。國小數學,能有什麼問題?

結果所有重度使用者都被永久標成 Heavy,而且死都降不下來。停手十分鐘去泡咖啡、發個呆,回來一看——還是 Heavy。它認定你在燒,你人不在也照燒。

兇手是 cache_read_tokens

現在的 LLM API 都有 prompt caching(提示詞快取,能記住剛送過的上下文)。同一段 context 重複送出時,命中快取的部分計費極低。

而 AI coding 工具的工作模式,說穿了就是每一輪把整個對話 context 重送一次(是的,它每次都把整本書從頭念給模型聽)。

所以一段長對話,光 cache_read_tokens 就能累積到幾百萬,但那些 token 幾乎沒有真的消耗你的額度。

把它算進燃燒率,等於在跟使用者說「你重讀了自己的對話紀錄,所以你是重度使用者」。這判斷根本沒有意義,它反映的只是你的對話有多長而已。

修法是在 entry 這層多開一個出口,讓算燃燒率的人拿到的預設值就是對的:

@property
def active_tokens(self) -> int:
    return self.input_tokens + self.output_tokens + self.cache_creation_tokens

cache_creation_tokens 留著,cache_read_tokens 不算。因為建立快取是真的在花錢,讀取才是便宜的那個。creationread,差一個字,意思完全相反。

cache_read_tokens 本身沒有被消失,總量顯示還是要靠它。被換掉的只有燃燒率的分子。)

上層直接用 active_tokens

active_tokens = sum(entry.active_tokens for entry in entries)
elapsed_seconds = (entries[-1].timestamp - entries[0].timestamp).total_seconds()
elapsed_minutes = max(elapsed_seconds / 60.0, 1.0)
burn_rate = active_tokens / min(elapsed_minutes, 60.0)

在這段算式裡,max(..., 1.0) 是第一道防線。它的基本作用是防除以零,但更重要的是,它能防極短區間把數字爆到天上去。

min(..., 60.0) 則是第二道防線。它的任務是不讓你長時間掛機把分母拉到無限大、把燃燒率壓成零。

為什麼要這樣限制?因為我們要看的是「最近一小時的節奏」,不是「開機以來的平均」。畢竟,那個開機以來的平均數字,對決策一點幫助都沒有。

門檻則是拿真實使用紀錄回頭校準的。舊值是 50/250/1000,用 72 小時的實測資料重算之後改成:

BURN_RATE_THRESH_NORMAL = 500.0   # tokens/min
BURN_RATE_THRESH_ACTIVE = 2500.0
BURN_RATE_THRESH_HEAVY = 6000.0

整整差了一個數量級。這條後來寫進專案的 CLAUDE.md,理由只有一句:不剔掉 cache_read 的話,重度使用者會被永遠鎖在 Heavy。

五、壞掉的快取檔,有一種不能丟進隔離區

為了不要每次啟動都重掃幾百個 JSONL(那開機速度會慢到讓人想砸電腦),usage 會把解析結果寫成磁碟快取。而快取檔總有壞掉的一天:編碼壞了、JSON 被截斷了、內容根本不是預期的結構。

最直覺的處理是讀不出來就刪掉重算。反正只是快取嘛,刪了又不會怎樣。

我一開始就是這樣寫的。然後我就再也查不出快取到底為什麼會壞了。

因為證據被我自己的錯誤處理吃掉了。使用者說「偶爾會卡一下」,我請他把壞掉的檔案給我看看,他說沒有啊,資料夾裡很乾淨。當然乾淨啊!程式每次都幫他刪得一乾二淨,比掃地機器人還勤勞。

所以我加了一層隔離區。壞檔不直接刪,先搬到 ~/.usage/quarantine/,把命案現場保留下來。

但真正容易寫錯的是下一步。看這兩個分支的差別:

if not isinstance(payload, dict):
    quarantine(path, "not-a-mapping")
    path.unlink(missing_ok=True)
    return None
if payload.get("schema_version") != schema_version:
    path.unlink(missing_ok=True)
    return None

兩個分支的結局一模一樣,都是「這個快取不能用、刪掉」。但只有第一個會先送進隔離區。

為什麼?因為 schema_version 對不上根本不算壞掉,那是正常的版本升級啊。每次我改了快取格式發一版,所有使用者的舊快取都會對不上——這是預期中的事,不是意外。

而隔離區是有預算的,只留最新的 10 個檔,每個上限 5MB。不然一個「診斷用」的機制會反過來把使用者的硬碟塞爆,那就本末倒置了。

假設當初我沒把這兩種情況分開、一律送隔離區,會發生什麼事?每一次版本升級,每個使用者的隔離區都會被一批「其實完全正常」的舊快取灌滿,10 個名額一次洗掉,真正壞掉的那個關鍵證物反而被擠出去。防禦機制親手毀掉它要保護的東西,完美的自殺式設計。

兩個分支長得幾乎一樣,處理方式卻必須完全相反。而且最陰險的是——寫錯的那個版本,不會有任何錯誤訊息告訴你它錯了。

六、拆完的檔案會自己長回來

menubar.py 是 macOS 那側的主控檔案,PyObjC 寫的。它會長大,這幾乎是宇宙定律。每加一個選單項、一個面板、一個通知,最順手的位置永遠是「啊就再加在這個檔案裡就好了嘛」。

它被我拆過兩次,兩次都長回來,最高峰到過 2485 行。像那種除完草隔兩週又長回來的院子。

第二次長回來之後我才願意承認:問題不在我不知道要拆,而在「檔案不要太大」這條規範,一直以來只靠我的自律在維持。而自律這種東西,在趕著出版本的時候,永遠是第一個被丟出車外的。

所以我寫了一支 CI 腳本,把規範從「道德勸說」變成「會讓 build 直接失敗」的硬性檢查:

"""Fail CI when a file guarded by a growth policy creeps past its ceiling.

`menubar.py` has been split apart twice and grown back both times, because the
policy in CLAUDE.md was enforced by nothing but good intentions. Lower a ceiling
whenever a cut lands; never raise one to make CI green.
"""

CEILINGS = {
    "menubar.py": 1242,
}

規則只有一條:天花板只能降,不能升。每砍一刀、把一塊功能搬進獨立模組,就把天花板降到當下的行數。想加新功能又超過?那就先去拆,不然 CI 給你亮紅燈,你哪裡都去不了。

那句 enforced by nothing but good intentions(全靠一片好意在維持)是特地寫給未來的自己看的。因為我很清楚,半年後我一定會忘記為什麼有這個奇怪的檢查,然後想說「這限制好煩喔,改大一點好了」——不,你不可以。

前後十刀下來,2485 行變成現在的 1242 行,最近兩刀是從 1934 砍到 1582、再砍到 1242。

拆的過程有個 PyObjC 特有的陷阱值得記一下:selector 不能亂搬。

selector 是 Objective-C 用來指涉某個方法的名字,PyObjC 的按鈕跟選單項就是靠它綁定回呼(按鈕點擊後觸發的動作)的。

這就像是公司對外的聯絡窗口。你可以把背後實際做事的部門搬到別的樓層,但那個掛名的窗口必須留在原來的座位上接電話。

所以那個方法必須留在原本的 NSObject(Objective-C 的基礎類別)子類上。

你可以把計算邏輯搬去別的模組,但被 selector 指到的那個方法本身動不得。否則要等到執行期才會炸,而且錯誤訊息完全看不出跟搬移有關(你會找到懷疑人生)。

所以每一刀的形狀都一樣:selector 留在原地站崗,它呼叫的實作搬出去。

拆出來的葉模組裡有一整批是面板。同一份資料可以套上很不一樣的視覺:經典卡片、數字雨、復古報紙、午夜水族箱、黑洞。這件事做得到,完全是因為資料層跟顯示層在前面就拆乾淨了。面板只是 HTML,拿到的是同一包算好的數字。

https://ithelp.ithome.com.tw/upload/images/20260806/20183178W94mp9BXs3.png

七、那條原則後來被現實校準了

回到第一節那條原則:不呼叫任何 LLM API。

Claude Code 跟 Codex 都做得到,因為它們的用量本來就乖乖寫在你的磁碟上,不連網也讀得到。我一度以為整個專案都可以維持這個漂亮的形狀。

然後我加了 Antigravity 支援,事情就不一樣了。

Antigravity 的配額不在你的磁碟上。它沒有把用量寫成本機檔案,唯一的來源是 Google 的配額端點。所以我只有兩條路:要嘛不支援,要嘛承認這一塊就是得連網。

最後的做法是:讀 Antigravity CLI 已經存在你機器上的那份登入憑證(macOS 在 Keychain、Windows 在認證管理員,唯讀,不改它),需要時換一張新的 access token,然後去問配額。老實說,這跟第一節那個「我才不要這樣做」的路,形狀確實有點像(打臉來得真快)。

那差別到底在哪?差別是它不是 LLM API。問配額不會消耗你的 token,這跟你自己在 Antigravity 裡打一次查詢指令是同一件事。原則沒有被打破,被打破的是我對「本機」這兩個字的想像。

所以現在專案的對外說法是分開講的:不呼叫任何 LLM API 是原則,Claude Code 跟 Codex 完全不需連網是事實,Antigravity 得走網路也是事實。SECURITY.md 裡直接把每一個對外連線的用途、端點跟時機列成一張表攤開來,讓人可以自己核對,而不是叫人相信我一句「我沒有連網啦」。

一句宣稱,只要有一個反例就整句崩掉。一張清單不會,它只會多一列而已。

收尾

回頭看,這五個坑其實是同一件事的五種樣子:我把兩個很像的東西,當成了同一件。讀本機檔案跟讀本機檔案不一樣,token 跟 token 不一樣,壞掉的快取跟過期的快取不一樣,拆好的檔案跟會長回來的檔案不一樣,連「本機」這兩個字自己都不一樣。

而每次修好,靠的從來就不是什麼更聰明的抽象。做法很土:在原本只有一條路的地方,老實加一個分支,然後把「為什麼要有這個分支」寫進註解、寫進規範、寫進 CI,讓下一個想把它合併掉的人(很可能就是三個月後的我)先讀到理由。

寫程式最難的不是想出通用解,是看出兩個長得一樣的東西其實不一樣。


GitHub:https://github.com/aqua5230/usage
官網:https://aqua5230.github.io/usage/

Issue 跟 PR 都歡迎。


*提醒邦友,使用第三方服務/API 時,請務必評估資安風險與隱私保護
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言