# 模型目錄的 source-of-truth 是 models.dev,不是 Pi 內建表 模型目錄(哪些模型是免費的、叫什麼名字、上下文多大)由 `https://models.dev/api.json` 的 `opencode.models` 決定,執行時抓取、 本地快取;Zen 自己的 `/models` 是最後一道可用性閘門。Pi 內建 `opencode` 表是「已知通道覆蓋表」與離線兜底基線。 `docs/spec-v0.2.md` 的目錄決策以本文件為準。 **Status**: accepted **Scope clarification (2026-10-15)**: 本決策管理正式插件的模型目錄。相容性驗證的官方動態目標來源依 [ADR-0004](0004-official-dynamic-compat-targets.md);該決策亦更新下文關於官方端點資料的歷史前提。 **Context** 若以 Pi 內建表作為唯一的目錄來源:宿主以 peer 依賴鎖定 `pi-ai` 版本,插件無法隨上游 升級,於是 Zen 每新增一個免費模型,就要手工補一條記錄(實務上發生過兩輪:先補 5 條, 再手動刪掉 1 條)。同時 models.dev 已發布的 metadata(名稱、上下文/輸出上限、 attachment、reasoning、status)完全取不到,只能靠猜。 `status: "deprecated"` 的語義有歧義:在 opencode 這家 provider 上, `deepseek-v4-flash-free`(免費層已結束、實測呼叫失敗)與 `muse-spark-1.2-contributor-free`/`mimo-v2.5-free`(實測可用)被寫成同一個值。所以 可見性由實測探針決定,而不是 `status`(見下「可見性判據」)。 **Considered Options** - **維持 Pi 內建表+手工補條(否決)**:來源不變,但每個新模型都要人 動,且 metadata 仍缺;已實際發生兩輪,成本證明它不成立。 - **建置期快照(否決)**:發版時用腳本生成一份 models.dev 快照隨包發 布。啟動零開銷,但「跟隨」等於「等下次發版」——使用者看到的仍然過期, 只是把過期責任從人手上換到發版流程上。 - **models.dev 執行時拉取+快取(採用)**:真正跟得上上游;代價是首次 需下載整份 `api.json`(models.dev 未提供 per-provider 端 點),必須有離線兜底。 - **Pi 內建表升版(否決)**:最乾淨,但被 DSH 的 peer 依賴鎖死,不是 插件能自己決定的。 **決策細節** - 免費判定:`cost` 全零。目錄 membership:`status ∈ {active, deprecated}`。 - 可用性閘門:與 Zen `/models` 在列求交集;Zen 抓取失敗時保持現有清單, 不收窄。交集只算一次,模型選單與詳情頁讀同一份快照。 - 通道推導三級:Pi 內建表命中 → models.dev 訊號(有 `interleaved` 或 `reasoning_options=[{type:"toggle"}]` 為 completions;`effort` 為 responses)→ 兜底 completions(依據 `provider.npm = "@ai-sdk/openai-compatible"`)。已對 7 個已知模型驗證 7/7 吻合。 - **能力欄位一律取自 models.dev,不繼承模板**:識圖 ← `modalities.input` (pi-ai 只認 text/image,audio/video/pdf 被丟棄而非原樣透傳)、上下文 ← `limit.context`、最大輸出 ← `limit.output`、思考等級 ← `reasoning_options[type=effort].values` → pi-ai 的 `thinkingLevelMap`。 模板是固定一筆記錄,所以 `thinkingLevelMap` 兩個分支都顯式賦值:繼承別人的 等級表會供應本模型不支援的等級,而 host 對此報 `UNSUPPORTED_REASONING_EFFORT`,不是夾取值。 models.dev 沒發布等級時(`toggle`、空陣列)**不宣稱**——pi-ai 沒有「只有開」的 等級名,亂猜會得到上游拒絕的等級。 `off` 保持可選(使用者要求保留):它表示「不送推理參數」,由請求路徑的 onPayload 守衛保證——pi-ai 在沒選等級時會送 `reasoning: { effort: "none" }`、 顯式選 off 時會送 `effort: "off"`,兩者都是 pi-ai 內建紀錄和 OpenCode 不會 送的值,守衛把它們還原成「沒有 reasoning 物件」,也就是 "Default" 送出的 形狀。真的等級原樣通過。 這條修掉一個實際缺陷:插件對 muse-spark 預設 `xhigh`,但派生記錄沒有等級表, pi-ai 的可用集合止於 `high`,請求被靜默降級。 - 快取:`$DSH_HOME/dsh-opencode-free/catalog.json`,只存 models 段(不存 provider 包裝層),臨時檔+rename 原子寫,附 ETag 做條件重驗證。 - 失效:讀目錄時若快取超過 24h 則背景重拉,不阻塞當次請求,不設定時器 (與 v0.2「不做背景自動刷新」的決定一致;探針是唯一的例外,見下)。 - 降級:無網路、無快取、畸形回應 → 退回 Pi 內建免費集。任何失敗都不得 讓模型選單變空。 **可見性判據** `status` 只決定「是否在目錄內」,可見性由實測決定(免費且 `status ≠ deprecated` 不等於可用): - **探測範圍是「使用者開著顯示的模型」,不是目錄全集。** 一輪花的是共享匿名桶,替 使用者去測他已經關掉的模型,是花一份沒有人會讀的答案,代價由他會看的那些模型承 擔。`hidden` 即時讀取,所以探測範圍與模型選擇器共用同一個 `isHidden`,不可能分 叉。代價說清楚:被關掉的模型如果本來是死的,不會被發現——但它本來就不可見;等 使用者打開它,下一輪就會探(targets 每輪按即時開關重算)。 - 每日至多一輪探針,對範圍內的模型按順序各發一次最小請求:`maxTokens: 1024`、 每模型 15s 超時、帶 `read`+`bash` 工具名。1024 是推理模型的地板——512 不足以讓 「先思考再回答」的模型吐出任何文字(預算的取捨見下「探針預算與併發」);15s 與 9router 的每模型上限同值,因為整輪循序,單一上限就是整輪最壞情況的乘數。匿名層 沒有這兩個工具名就一律 403(見 `docs/reverse-engineering.md` §8),所以 §8 的准入 工具由 `enforceAnonymousTools` 在 payload 邊界**重新斷言**,不假設上游四層都做 過:探針送出的 body 若沒有 `tools`,上游會對所有模型回 403。 - 三態結論:`ok`(有非空文字回覆)/`dead`(**只接受正信號**:404、410, 或報文明確指此模型不存在/已下線)/`inconclusive`(被閘、額度用完、 key 失效、5xx、傳輸異常、空回覆)。`dead` 是唯一會讓模型離開選單的結論, 且預設不成立——單一被閘的 IP 會讓所有模型同時 403,若這算 `dead`, 整份清單會清空。 - `inconclusive` 永不落盤、可見性逐字不變,只留一個「本輪不可信」旗標給 面板。`dead` 落盤到同一個快取檔,重啟後仍然生效。 - **判定為 dead 的模型直接不在清單裡**,不另設一份「不可用」名單:那樣的 第二份清單會與選單不一致、會過期、也沒有任何好處。代價是無法從卡片看出 某個模型為什麼消失——這是刻意接受的取捨。 - **dead 判定是最終的**:一旦判定,後續輪次不再探這個模型。理由是成本——每天 為同一個已經有答案的問題再花一次額度,是沒有回報的額度支出。Zen key 不改變這件事:**key 改變的是額度,不是模型清單**,有沒有 key 服務的模型是同一份,被拒絕的模型帶 key 也一樣被拒絕。目錄 (`current().models`)仍保留 dead 条目,這是必要而非整潔問題:`adopt` 會剪掉目錄裡已不存在的模型的判定,若把 dead 模型真的從 `models` 移除, 它下次同步就會失去判定、當成新候選回來、再被探到同樣的答案。唯一的回頭路 是手動刪除 `$DSH_HOME/dsh-opencode-free/catalog.json`(用途是上游把模型 加回來或修好通道時重新判定),README 有寫。 - 面板另有「立即探測」按鈕繞過每日限制;使用者不點也會在讀取目錄時自動觸 發一輪。 - **探測進行中面板切到即時回報**:`GET /api/probe` 回傳當輪進度(`running/done/ total/current/results/targets`),卡片輪詢它來畫進度藥丸與每行徽標(綠+毫秒/紅 /藍轉圈/灰等待)。進度本身不參與任何判定;`POST /api/probe` 等整輪跑完才回最終 快照。 - **輪次報告會落盤。** `lastRound` 隨判定一起寫進 `catalog.json`:只有計數、每行結論 與該輪範圍(targets),不含任何請求內容。理由與判定持久化相同——記憶體不跨重啟, 重啟後面板掛載時讀到空報告,整塊進度區空白,看起來像「進度展示被刪了」,而重啟 恰恰是它消失的原因。`readLastRound` 防禦式解析:損壞或缺失就當作沒有報告,報告是 便利不是判定,絕不影響可見性;版本號不變、欄位可選,沒有這個欄位的快取照常讀。 **邊界**:`inconclusive` 不落盤、可見性逐字不變——落盤的是「那一輪發生了什麼」, 不是「這個模型是什麼結論」。 - **dead 只有在問完所有通道後才算數**:判定 `dead` 的前提是兩個通道都拒絕。`catalog.json` 裡 `probes[id]` 帶 `swept` 標記,表示該判定是問完所有通道後的結論;沒有 `swept` 標記的判定一律**重測一次**——那些正是可能把「通道選錯」當成「模型死了」的判定,而 永久判定遇上路由錯誤,就會讓一個能用的模型永遠消失在清單裡。重測後答得出來就回 清單;仍然拒絕才補上 `swept`,從此不再問。這是一次性的復原代價,不是每輪的。 - **拒絕不是模型的結論**:`anon-gated`/`quota-exhausted`/`bad-key` 是呼叫端 的性質,兩個通道說法相同——那輪其實什麼都沒學到。面板因此絕不把這三種畫成 紅色行徽標:那些行是灰色「未測到」,另有一行橫幅說明本輪沒能測到、且這不是 該模型的結論。統計也分開算(成功/失敗/未測到),免得一個被閘的窗口把整張 清單染紅,讓一個在 DSH 裡好好的模型戴著失敗徽標。橫幅必須寫明**那一輪的時間**, 否則留存的拒絕看起來像現狀——「昨天被閘了五分鐘」會被讀成「探針說這些模型不行」。 - **通道失配不會殺模型**:Zen 不公開某個模型跑在哪個端點上,9router 只能硬編碼 (Muse Spark 一組、`union-alpha` 一組,再加 family 正則),這邊則是從 models.dev 的記錄推斷。推錯一次,上游回的就是「model not supported」——和死模型同一句話, 於是一次路由錯誤會把一個活著的模型**永久**移出選單。所以推錯的通道不足以定罪: 探測會依序問完 chat / responses 兩個通道,只有全部拒絕才允許判 `dead`。命中的通道照樣一個請求,額外請求只花在已經失敗的模型上。 - 被閘(403 FreeTierError)、限額(429)、key 無效是**呼叫端**的性質,兩個通道 說法相同,重試只會白花正在枯竭的額度,所以這三種只問一次。 - 多通道結論取**最強**而非**最後**:三次會看到三種不同的失敗,最後一次不一定最真, 而「確定沒有」不能被後來的「沒結論」蓋掉——那是唯一會移除模型的判定。 - **每輪先讀 Zen 清單。** 開頭先打一次 `GET /zen/v1/models`(帶 `Bearer public` + 版本化 opencode UA, 5 秒超時)。Zen 不再列出的模型**不花任何推理額度**就被移出清單——這是它又快 又準的原因:清單 GET 不花額度,所以問多便宜;完成度要花,就只花在清單答不了 的問題上。9router 對 free provider 的連線測試就是這一個請求(`valid = res.ok`)。 - **清單缺席 ≠ 永久判定**:Zen 不列出只改「閘」(每次輪次重算,模型回來就回來), 不寫進持久化的 `dead`。`dead` 仍留給「Zen 有列但問不動」——那必須真的去問, 成本高所以才值得永久記住。混為一談就是「模型回來了卻被永久 suppress」。 - 清單 GET 失敗(null)時**什麼都不縮**:照舊全輪探測,且不產生任何移除報告。一次 網路抖動不能清空選單。 - **失敗必須帶原因**:`results[x]` 的失敗項除 `ms` 外還有 `code`(`dead`/ `not-listed`/`timeout`/`transport`/`anon-gated`/`quota-exhausted`/ `bad-key`/`unknown`/`error`)與 `http`(0 = 從未收到任何回應)。`code` 由探測層 產生、面板自己翻字,中文診斷不會漏到英文介面;只寫「失敗」的紅色徽標就是 這個欄位要防的東西。`dead` 額外算一條:它同時移除該行,徽標永遠讀不到,所以 戰績要另外點名。 - **結束即留存**:一輪結束不抹掉讀數——藥丸轉為帶統計的常駐徽章(成功/失敗/ 下架),每行同時顯示結果與能力徽標,本輪未輪到的行明說「本輪未探測」而非留 白。面板掛載時也讀一次進度:剛跑完的那輪是使用者還沒看過的報告,正在跑的那 輪則接著顯示。`POST` 結束後會重讀一次進度,讓戰績對齊真正落地的結果,而不是 靠最後一次輪詢猜。 - 面板每行顯示能力徽標(`capabilities: [{id, image, thinking}]`):有圖片輸入的有視覺 徽標,有發布思考等級的顯示最高等級;沒發布的不顯示,不猜。`thinking` 取等級表裡字 串值最高的,非 `off`,無表則 null。能力徽標全程在場(輪次進行中不讓位:收起來會讓 每一行恰好在讀者等答案的那段時間裡失去身份;探測徽標是追加的,不佔位)。 - **面板行序與行徽標。**順序只有一個主人:目錄在 `derive()`/離線兜底那裡就按 id 字母序排好(`byId`,用普通比較而非 `localeCompare`——id 是小寫 ASCII,ICU 會依宿主 locale 把 `kimi-k2.5-free` 與 `kimi-k2-5-free` 排成不同結果,一個順序要取決於機器就不是能依賴的順序), snapshot、visible、選擇器全部繼承同一個順序;面板**只分組不重排**:打開的模型 在前、關掉的在後,各組維持目錄的字母序——看這塊面板的人是在找「哪個能用」。能 力徽標全程在場(輪次進行中不讓位:收起來會讓每一行恰好在讀者等答案的那段時間 裡失去身份,結束後又因與模型無關的原因變回原樣;探測徽標是追加的,不佔位)。範 圍外的行完全不戴探測徽標也不變暗:只有伺服器知道本輪範圍,所以 `targets` 隨進度 一起下發,面板才分得出「排隊」與「根本不在這一輪」——把使用者自己關掉的模型宣布 成「等待中」,是承諾一輪永遠不會走到它的探測。 - **失敗項另有兩個欄位**:`results[x]` 除 `code`/`http` 外還有 `removed`(這一輪真 的移除了模型,還是只是複核一條既有的 `dead`)與 `marker`(`anon-gated` 折起的 三種上游條件裡命中哪一種:`FreeTierError`/`MissingSessionID`/只能由 OpenCode 客戶端發出)。兩者都是為了「留下的拒絕不許看起來像現狀」:藥丸把「本輪下架」與 「複核仍不可用」分開算,橫幅除了那一輪的時間還寫明是哪一種閘門拒絕——因為 `inconclusive` 不落盤、報文是這件事唯一存在過的地方,錯過那一輪就再也答不出來。 - 代價要講清楚:這是每天對共享的匿名額度桶多打 N 次請求(N = 範圍內的模型數), 已在 README 與 Cost Statement 披露。順序發送是刻意的限流手段。 **與 pi-ai 版本的關係。** Pi 內建表不是目錄來源,與 pi-ai 版本無關:免費清單來自 models.dev,pi-ai 內建表只是通道覆蓋表與離線兜底,所以 pi-ai 改名內建記錄(例如 `mimo-v2.5-free` → `mimo-v2.6-flash-free`)不會讓清單變動。「Pi 內建表升版」之所以被 否決,是因為它需要宿主放行新版 pi-ai,那不是插件能自己決定的。插件使用 pi-ai 0.87 的 transcript 模型,見 `docs/reverse-engineering.md` §9。peer 精確釘住單一 DSH 版本, **不**用聯集:不同 DSH 版本的 context 到達 provider 的方式不同(只有 `normalizeContext()` 的產物能被 provider 讀到),聯集會宣告只觀察過其中一種的相容性。 ## 探針預算、併發與新鮮度 以下各節與「不做」清單與上面的 source-of-truth 決策同級,推翻它們的條件都寫在各自小節裡。 ### 探針預算與併發 - **探針預算是 1024,理由是答案品質,不是避免假 `dead`。** 一個容易想到的理由是:「`ok` 要求有非空文字,推理模型吐字前先思考 64+ token,所以小預算會讓空回復被讀成 `dead`」。在三態設計下這個理由不成立——空文字落進 `inconclusive`,而 `inconclusive` 的模型**照樣留在選單裡**;`dead` 只認正信號錯誤(`404`/`model is unavailable`/`not supported`/`not found`/`retired`),沒有任何一條 `dead` 來自「沒吐字」。既然兩種狀態對可見性的影響相同,預算大小與假 `dead` 無關,只剩答案品質: A/B(`scripts/probe-ab.mjs`,1024 對 16,33 個模型 × 兩輪,132 個請求)測的是 **output token**,而**答案品質**先於 token 被檢查——兩輪一致: ``` muse-spark-1.2 158 → 無 ok → inconclusive muse-spark-1.3 69 → 無 ok → inconclusive (第一輪) muse-spark-1.2 236 → 無 ok → inconclusive muse-spark-1.3 160 → 無 ok → inconclusive (第二輪) ``` token 中位數看起來從 48 掉到 16(三倍),**但那兩個最貴的模型正是因為答不出來才掉出樣本**。較小的上限不是讓貴的模型變便宜,而是**讓最貴的兩個模型乾脆不回答**;只看 token 會得到一個「省了 3 倍」的假結論。用答案換 token 不叫省,所以腳本先查品質、再查 token。預算因此取 1024。 - **探針不引入併發。** 每模型約 0.5s,十個模型整輪約 5s;最壞情況(每個都吃滿 15s 逾時)才是 150s,而那不是常態。競品的 `concurrency = 2` 有它的論證(寬突發會 throttle 到正在被探的那個使用者),但它們的預算只有 16 token,這裡是 1024,兩者不是同一個量級。**重來條件**:輪次經常吃滿逾時路徑,或連續數輪 429/403 明顯上升。 ### 新鮮度:兩條獨立的軸 - **Zen 閘門有自己的過期軸。** 若 `zenIds` 只由每日探針輪次更新(被 `probedToday()` 擋住),Zen 下架一個免費模型後,**使用者最長 24 小時後才看不到**。而 `GET /zen/v1/models` 不花推理額度(見上),所以它該比目錄問得更勤。`GATE_TTL_MS = 30min`,**不設新定時器**(見上「失效」),只在既有的惰性路徑上加第二條軸;`ensureFresh()` 同時啟動兩者且互不等待——閘門壞不能拖慢目錄,整份下載也不能拖慢一個 GET。 - **`zenIds` 只有單一寫入者**(`refreshGate()`,single-flight)。若 `runProbeRound`、`applyZenGate`、宿主 refresh 各自寫入,兩條節奏並行時後完成的會靜默蓋掉另一個,而過期的那一份贏了這場競爭,看起來就跟上游變了一樣。 - **`gateCheckedAt` 不落盤**:開機視為未問過,每次開機一個 GET,換來零 schema 變更與自動自癒。 - **目錄 TTL 自適應。** 實測:`If-None-Match` 回 **304、0 位元組**,服務端發 `ETag` 與 `Cache-Control: public, max-age=0, must-revalidate`。所以重驗的常態成本≈0,整份檔案只在 models.dev 真的變更時才傳。於是 24h 對我們是**過度保守**:上一次重驗是 304 就用 6h,**是完整下載就退回 24h**。縮短窗口是靠觀察到的行為賺來的,行為一停就自動撤銷。**這個論證三個競品都做不到**——它們都不發條件標頭,對它們而言縮短窗口真的是重下載。 - **空列表不收窄**(`refreshGate` 與 `applyZenGate` 各自都擋)。這不與 `fetchZenModelIds` 的既有防線重複:來源已經把空 body 收成 `null`,但這個接縫也會被宿主 refresh 與測試填,沒有任何上游回答可以讓使用者的選單變空。 - **`gateStale()` 把「本輪從未問過」按定義當過期**,而不是靠算術:0 在小刻度下算出來是新鮮的,而那正是還欠著一個真問題的情況。 ### 盲區只報,不補 - **量測(2026-09-30)**:Zen 的 `/zen/v1/models` 回 200、7,069 位元組、**84 個模型**,其中 id 形如 `*-free` 的 **11 個**;單條記錄的欄位只有 `id`/`object`/`created`/`owned_by`,**沒有任何 metadata**。models.dev 的 `opencode` 段有 **114 個模型**、cost 全零 **34 個**;兩者交集展示 **10 個**,另有 **24 個** models.dev 判免費但 Zen 不在列(被閘門正確收掉)。 - **`jev-1.13-free` 是該次量測中的盲區實例**:Zen 正在免費層供應,但 models.dev 全庫 225 個 provider 裡根本沒有它。這是交集架構的結構性盲區,三個競品完全相同。 - **只報不補。** 讓這些模型可用需要 `api`(通道)與 `limit.context`/`limit.output`,而這兩項**手上沒有任何來源能拿到**:Zen 的列表只有上面那四個欄位,而拿帶 `-free` 後綴的 id 去比對其他 provider 的記錄是猜測。猜錯的數字比保守的數字更糟——因為一個被宣稱的上下文長度會被**拿來用**。所以面板只報這個事實(`unknownFree`),不把它變成可選項。 - 該判定**依賴 id 正則**(未知模型沒有 cost 可讀),所以它只是啟發式診斷,**絕不參與可見性**,面板文案裡也說明了。 ### `catalogTemplate()` 的釘選 `catalogTemplate()` 不依賴單一記錄名稱:它依序找 `TEMPLATE_IDS`(`mimo-v2.6-flash-free`、`mimo-v2.5-free`),因為 pi-ai 會改名內建記錄——一個只釘舊名的 `find` 什麼都匹配不到,每個呼叫端就靜默落到自己的後備,例如 `builtinFreeModels()[0]`,也就是 **`big-pickle`**,一個 Zen 根本不在供的模型。這不會壞,原因是:**模板只提供 identity,通道來自 `knownApis` 推斷,能力欄位讀 models.dev 而非繼承**。但一個靜默失配的釘選是給下一個讀者的陷阱,所以後備放在函式裡:決定性,而且優先挑免費層真的在供的記錄。 ### 「不做」清單 每條都寫明推翻條件——一條不可證偽的偏好不是決策,是意見: | 不做 | 真實依據 | 什麼證據會推翻它 | |---|---|---| | 把未知免費模型補進選擇器 | `api` 與 `limit.context`/`limit.output` **無任何可用來源**;跨 provider 猜測比保守值更糟 | 出現帶 per-model metadata 的 Zen 端點,或 Zen id 能無歧義對應到 models.dev 的某條記錄 | | 探針併發 >1 | 實測約 0.5s/模型,整輪約 5s,**沒有一個量測支援的問題** | 輪次經常吃滿逾時路徑,或連續數輪 429/403 明顯上升 | | 縮減探針預算 | **實測兩次否決**:16 token 下 muse-spark 1.2/1.3 由 `ok` 掉成 `inconclusive`;**中間值 256 更差**——同預算下 muse-spark-1.3 的 usage 在 69/120/158/160/214/236 之間波動,而 256 反而比 1024 更貴(120→214)更慢(2.8s→5.6s)。可調的量不是要省的量 | 找到一個能同時保住答案品質、又真的降低 token 的上限(需要先證明這些模型的 usage 可預測,而六次觀測已否定) | | HTTP Range 切片 models.dev | 位元組偏移隨上游任意編輯漂移,失敗退回全量;複雜度與脆弱性高於一次首裝 | models.dev 固定各 provider 段偏移,或提供穩定分片端點 | | 引入第三方後端 | 隱私上等於把使用行為上報給一個個人站點;且該倉庫無 `src/`、`package.json` 指向不存在的 `scripts/build.mjs`,**全新 clone 無法建置** | 出現我們自己部署與可控的最小轉發端點 | | 硬編碼能力表 / 路由前綴 | 不是「硬編碼是債」(三家都硬編碼且能跑):**推斷 + 一次性通道掃描在成本與正確性上都支配硬編碼表**——推錯一次的代價是一個模型一次多餘請求,`swept` 標記使其後不再犯;硬編碼表推錯則永遠錯 | 觀察到同一模型被反覆推錯通道,且 `swept` 保護未生效 | | 冷啟動重試階梯 | `stale()` 在 `updatedAt=0` 時恆為真,且 `ensureFresh()` 掛在每次 `getModels()` 上,重試本來就存在 | 單次會話內不再觸發讀取,導致兜底基線持續整輪不刷新 | | 改動 source-of-truth(找 per-provider 端點) | 復驗:`/api/opencode.json` → 302;`/api.json?provider=opencode` → 200 **整份 5,264,597 位元組**(參數被忽略) | models.dev 真的上線 per-provider 端點 | **Consequences** - 好處:新增免費模型自動出現;名稱與上限來自上游,不必手抄;免費層結束 的模型會自己消失,不必等上游修 `status`。 - 代價:`$DSH_HOME` 下有一份插件私有快取;首次同步要下載整份目錄;每天 一輪探針請求。 - 讀取目錄時每 30 分鐘多一個 Zen 列表 GET(不花推理額 度),換來下架模型在分鐘級內離開選單,而不是最長 24 小時後;條件重驗成立時目錄 每 6 小時重驗一次(實測 304、0 位元組)。**但 6 小時窗口只在長駐進程內 成立**:`lastFetchWasNotModified` 是進程內狀態、不落盤,而 `stale()` 在 暖讀落定前必定為真,所以每次開機第一次讀都會重下並回到 24 小時。也就是 說「六小時」描述的是不重啟的連續使用,不是跨重啟的保證。 - 監控:`scripts/reverify.sh` 仍是上游行為的漂移偵測器;models.dev 改 schema 時症狀為「清單退回兜底基線」,由 catalog 的失敗降級路徑吸收。 **不存在的機制**(刻意不保留相容分支) - `scripts/gen-client-models.mjs`(建置期把清單內聯進前端) - `package.json` 的 `prebuild` hook - `src/client.js` 的內聯 `MODELS` 常量(前端讀宿主端點) - `src/zen-provider.ts` 的手寫 synthetic 列表 - 「`status ≠ deprecated` 才可見」規則(由上面的探針判據決定可見性; `grep -c 'isActive(record)' src/catalog.ts` = 0) - 面板「上游停止維護/已排除」灰字與 `excluded` 欄位(判定為 dead 的模型直接不在 清單裡,不另設名單去列舉它們;`grep -c 'opf-excluded' src/client.js` = 0)