jeo-code 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.5] - 2026-07-08
10
+ _"딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error._
11
+
12
+ ### Added
13
+ - **Anthropic server-side fallback for claude-fable-5** (`src/ai/providers/anthropic.ts`) — API-key calls to `claude-fable-5` (direct api.anthropic.com, no custom baseUrl) now carry `fallbacks: [{model: "claude-opus-4-8"}]` plus the `server-side-fallback-2026-06-01` beta header. Any safety-classifier decline (`reasoning_extraction`, `cyber`, `bio`, `frontier_llm` — not just the one that triggered this) is retried by Anthropic against Opus 4.8 inside the same request/stream; most refusals now resolve transparently and never reach jeo's engine at all. Scoped conservatively: OAuth credentials, a custom `baseUrl` (third-party Anthropic-compatible hosts), and `claude-mythos-5` (invite-only; fallback support unconfirmed for it) are unaffected and keep today's reactive-only recovery. `JEO_ANTHROPIC_FALLBACK=0` opts out.
14
+ - **Fail-safe for accounts without the beta**: a 400 naming "fallback" (unsupported param/beta on that account or endpoint) retries once with the fallback request fields stripped, so a non-GA account degrades silently to prior behavior instead of a hard error.
15
+ - **Reasoning artifacts tagged with the model that ACTUALLY served the turn** (not the originally-requested one) — both `call()` (from the response's top-level `model`) and `stream()` (from `message_start.message.model`, updated again from a mid-stream `fallback` content block's `to.model`). A fallback-served turn's thinking blocks are now correctly attributed to Opus 4.8, so a later same-model replay check (`anthropicNativizable`) neither wrongly nativizes nor wrongly skips them.
16
+
17
+ ### Verified
18
+ Full suite 2760/2760 pass, typecheck clean. Live-mocked-fetch smoke tests confirmed all 6 request/response scenarios (fable-5+api_key qualifies; sonnet-5/mythos-5/OAuth/custom-baseUrl/env-opt-out don't; served-model tagging on both call() and stream(); the 400-fallback-unsupported fail-safe; a refusal that survives the fallback path still throws and classifies exactly as before). Tester added 7 regression tests; all 6 mutation-verified (each independently reddens its own test when reverted).
19
+
20
+ ## [0.8.4] - 2026-07-08
21
+ _Follow-up to the 0.8.3 `jeo --tmux` E2E verification, which surfaced two real bugs: (1) the fake `test-anthropic-key` fixture found sitting in the user's REAL `~/.jeo/config.json` turned out to be reproducible — jeo's own test suite could silently write to the real config with no guard, and a prior session's routing tests had done exactly that; (2) the E2E run's TUI showed `'antigravity/gemini-pro-agent' is not in the live antigravity catalog` for a model that WAS live — traced to the note firing on a FAILED provider discovery (timeout/expired OAuth) rather than a genuinely absent model._
22
+
23
+ ### Fixed
24
+ - **Config writes are hermetic under `bun test`** (`src/agent/state.ts`) — `saveGlobalConfig` now throws if `NODE_ENV === "test"` and `JEO_CONFIG_DIR` is unset, refusing to touch the real `~/.jeo`. Root-caused three leaky subprocess spawns that inherited `process.env` without overriding `JEO_CONFIG_DIR` (`test/smoke.test.ts`, `test/launch-oneshot-slash.test.ts`, `test/mcp.test.ts`) — one of these (or an equivalent uncommitted test at the time) is how the real config's `providers.anthropic` got clobbered with the routing suite's `"test-anthropic-key"` fixture, silently breaking every real Anthropic call afterwards. All three spawns now sandbox `JEO_CONFIG_DIR` to a temp dir. The corrupted value has been removed from the live config (backed up).
25
+ - **"Not in the live catalog" note no longer fires on a FAILED discovery** (`src/tui/components/config-panel.ts`, `src/commands/launch/model-slash.ts`, `src/commands/launch/agents-slash.ts`) — `liveModelKnown(results, model)` treated "model absent from EVERY successful listing" and "that provider's listing itself failed" as the same case, so an expired/timed-out OAuth token flagged a perfectly valid, live-catalog model as invalid (reproduced live: `antigravity/gemini-pro-agent`, which the same session's `fetchAvailableModels` probe confirmed WAS live). Replaced with `liveModelMissing(results, provider, model)`, scoped to the pinned model's OWN provider and true only when that provider's listing `ok === true` with a non-empty list — a failed/absent/empty listing now correctly proves nothing and stays silent.
26
+
27
+ ### Verified
28
+ Full suite 2743/2743 pass, typecheck clean. Live-verified both fixes: `saveGlobalConfig` throws the exact guard message under `NODE_ENV=test` without `JEO_CONFIG_DIR`; `liveModelMissing` returns `false` for a model present in a successful listing, `true` for one genuinely absent from a successful listing, and `false` (not a false positive) when the provider's own discovery failed — reproducing and closing the exact 0.8.3 E2E finding.
29
+
9
30
  ## [0.8.3] - 2026-07-08
10
31
  _"잘못된 모델 설정하는 경우가 있는데" — the static Antigravity catalog had drifted badly from the LIVE Cloud Code Assist agent backend, and several current wire ids actively LIE about their tier: `gemini-3-flash-agent` is really "Gemini 3.5 Flash (High)" (the flagship agent model, which the name-suffix heuristic filed under the SMALL flash tier), `gemini-pro-agent` is really "Gemini 3.1 Pro (High)" (the code-agent model), and `gemini-3.1-pro-high` is DEPRECATED by the backend in favor of `gemini-pro-agent`. Probed the real `fetchAvailableModels` response (displayNames + agentModelSorts + deprecatedModelIds) and made it the single source of truth._
11
32
 
package/README.ja.md CHANGED
@@ -262,11 +262,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
262
262
  ## 変更履歴 (Changelog)
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
266
+ - **[0.8.4]** (2026-07-08) — Follow-up to the 0.8.3 `jeo --tmux` E2E verification, which surfaced two real bugs: (1) the fake `test-anthropic-key` fixture found sitting in the user's REAL `~/.jeo/config.json` turned out to be reproducible — jeo's own test suite could silently write to the real config with no guard, and a prior session's routing tests had done exactly that; (2) the E2E run's TUI showed `'antigravity/gemini-pro-agent' is not in the live antigravity catalog` for a model that WAS live — traced to the note firing on a FAILED provider discovery (timeout/expired OAuth) rather than a genuinely absent model.
265
267
  - **[0.8.3]** (2026-07-08) — "잘못된 모델 설정하는 경우가 있는데" — the static Antigravity catalog had drifted badly from the LIVE Cloud Code Assist agent backend, and several current wire ids actively LIE about their tier: `gemini-3-flash-agent` is really "Gemini 3.5 Flash (High)" (the flagship agent model, which the name-suffix heuristic filed under the SMALL flash tier), `gemini-pro-agent` is really "Gemini 3.1 Pro (High)" (the code-agent model), and `gemini-3.1-pro-high` is DEPRECATED by the backend in favor of `gemini-pro-agent`. Probed the real `fetchAvailableModels` response (displayNames + agentModelSorts + deprecatedModelIds) and made it the single source of truth.
266
268
  - **[0.8.2]** (2026-07-08) — "프롬프트라우팅이 api 입력을 요구하면서 동작안해" — prompt routing kept demanding an API key even when the user was already logged in via OAuth (GPT showed the same symptom). Root cause: every routing gate was PROVIDER-level ("any stored credential exists") while OAuth credentials are MODEL-level (ChatGPT OAuth serves only Codex ids, Kimi OAuth only the Kimi Code catalog, gemini OAuth nothing at all since the Cloud Code Assist masquerade was removed) — so routing happily picked models the stored login could never serve, and the turn then failed at call time asking for `<PROVIDER>_API_KEY`. Also restored a missing export that had broken `bun run typecheck` on HEAD.
267
269
  - **[0.8.1]** (2026-07-08) — Fix-forward for a functional regression this same session shipped in v0.8.0. This repo's working tree is shared with a concurrent interactive session; `git add`-ing `src/ai/provider-status.ts` for v0.8.0's routing-veto-gate fix also swept up that session's already-modified, uncommitted copy of the SAME file (containing an unrelated design change: gemini OAuth alone no longer serving `google/gemini-*` models) without its companion piece in `src/auth/flows/index.ts` — so v0.8.0 shipped with a test suite (`provider-status.test.ts`) asserting behavior whose supporting data flag never landed. CI caught this immediately post-release: 2 failing tests on the exact tagged commit. Investigated, confirmed the missing piece was real (and independently observable live via `jeo doctor` before this session had made ANY of its own edits — not a stray/speculative change), and completed it.
268
- - **[0.8.0]** (2026-07-08) — Root-caused a genuine reliability bug in the routing veto gate ("라우트가 동작후 api 키인증오류가나오는데 근본적인 원인 개선해"), then regenerated the animated README mascot to visualize the smart cross-provider routing this release cycle has been building: prompt routing that finds the cheapest usable path, with a "money saver" visual motif.
269
- - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -263,11 +263,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
263
263
  ## 변경 이력 (Changelog)
264
264
 
265
265
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
266
+ - **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
267
+ - **[0.8.4]** (2026-07-08) — Follow-up to the 0.8.3 `jeo --tmux` E2E verification, which surfaced two real bugs: (1) the fake `test-anthropic-key` fixture found sitting in the user's REAL `~/.jeo/config.json` turned out to be reproducible — jeo's own test suite could silently write to the real config with no guard, and a prior session's routing tests had done exactly that; (2) the E2E run's TUI showed `'antigravity/gemini-pro-agent' is not in the live antigravity catalog` for a model that WAS live — traced to the note firing on a FAILED provider discovery (timeout/expired OAuth) rather than a genuinely absent model.
266
268
  - **[0.8.3]** (2026-07-08) — "잘못된 모델 설정하는 경우가 있는데" — the static Antigravity catalog had drifted badly from the LIVE Cloud Code Assist agent backend, and several current wire ids actively LIE about their tier: `gemini-3-flash-agent` is really "Gemini 3.5 Flash (High)" (the flagship agent model, which the name-suffix heuristic filed under the SMALL flash tier), `gemini-pro-agent` is really "Gemini 3.1 Pro (High)" (the code-agent model), and `gemini-3.1-pro-high` is DEPRECATED by the backend in favor of `gemini-pro-agent`. Probed the real `fetchAvailableModels` response (displayNames + agentModelSorts + deprecatedModelIds) and made it the single source of truth.
267
269
  - **[0.8.2]** (2026-07-08) — "프롬프트라우팅이 api 입력을 요구하면서 동작안해" — prompt routing kept demanding an API key even when the user was already logged in via OAuth (GPT showed the same symptom). Root cause: every routing gate was PROVIDER-level ("any stored credential exists") while OAuth credentials are MODEL-level (ChatGPT OAuth serves only Codex ids, Kimi OAuth only the Kimi Code catalog, gemini OAuth nothing at all since the Cloud Code Assist masquerade was removed) — so routing happily picked models the stored login could never serve, and the turn then failed at call time asking for `<PROVIDER>_API_KEY`. Also restored a missing export that had broken `bun run typecheck` on HEAD.
268
270
  - **[0.8.1]** (2026-07-08) — Fix-forward for a functional regression this same session shipped in v0.8.0. This repo's working tree is shared with a concurrent interactive session; `git add`-ing `src/ai/provider-status.ts` for v0.8.0's routing-veto-gate fix also swept up that session's already-modified, uncommitted copy of the SAME file (containing an unrelated design change: gemini OAuth alone no longer serving `google/gemini-*` models) without its companion piece in `src/auth/flows/index.ts` — so v0.8.0 shipped with a test suite (`provider-status.test.ts`) asserting behavior whose supporting data flag never landed. CI caught this immediately post-release: 2 failing tests on the exact tagged commit. Investigated, confirmed the missing piece was real (and independently observable live via `jeo doctor` before this session had made ANY of its own edits — not a stray/speculative change), and completed it.
269
- - **[0.8.0]** (2026-07-08) — Root-caused a genuine reliability bug in the routing veto gate ("라우트가 동작후 api 키인증오류가나오는데 근본적인 원인 개선해"), then regenerated the animated README mascot to visualize the smart cross-provider routing this release cycle has been building: prompt routing that finds the cheapest usable path, with a "money saver" visual motif.
270
- - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
271
271
 
272
272
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
273
273
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -265,11 +265,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
265
265
  ## Changelog
266
266
 
267
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
269
+ - **[0.8.4]** (2026-07-08) — Follow-up to the 0.8.3 `jeo --tmux` E2E verification, which surfaced two real bugs: (1) the fake `test-anthropic-key` fixture found sitting in the user's REAL `~/.jeo/config.json` turned out to be reproducible — jeo's own test suite could silently write to the real config with no guard, and a prior session's routing tests had done exactly that; (2) the E2E run's TUI showed `'antigravity/gemini-pro-agent' is not in the live antigravity catalog` for a model that WAS live — traced to the note firing on a FAILED provider discovery (timeout/expired OAuth) rather than a genuinely absent model.
268
270
  - **[0.8.3]** (2026-07-08) — "잘못된 모델 설정하는 경우가 있는데" — the static Antigravity catalog had drifted badly from the LIVE Cloud Code Assist agent backend, and several current wire ids actively LIE about their tier: `gemini-3-flash-agent` is really "Gemini 3.5 Flash (High)" (the flagship agent model, which the name-suffix heuristic filed under the SMALL flash tier), `gemini-pro-agent` is really "Gemini 3.1 Pro (High)" (the code-agent model), and `gemini-3.1-pro-high` is DEPRECATED by the backend in favor of `gemini-pro-agent`. Probed the real `fetchAvailableModels` response (displayNames + agentModelSorts + deprecatedModelIds) and made it the single source of truth.
269
271
  - **[0.8.2]** (2026-07-08) — "프롬프트라우팅이 api 입력을 요구하면서 동작안해" — prompt routing kept demanding an API key even when the user was already logged in via OAuth (GPT showed the same symptom). Root cause: every routing gate was PROVIDER-level ("any stored credential exists") while OAuth credentials are MODEL-level (ChatGPT OAuth serves only Codex ids, Kimi OAuth only the Kimi Code catalog, gemini OAuth nothing at all since the Cloud Code Assist masquerade was removed) — so routing happily picked models the stored login could never serve, and the turn then failed at call time asking for `<PROVIDER>_API_KEY`. Also restored a missing export that had broken `bun run typecheck` on HEAD.
270
272
  - **[0.8.1]** (2026-07-08) — Fix-forward for a functional regression this same session shipped in v0.8.0. This repo's working tree is shared with a concurrent interactive session; `git add`-ing `src/ai/provider-status.ts` for v0.8.0's routing-veto-gate fix also swept up that session's already-modified, uncommitted copy of the SAME file (containing an unrelated design change: gemini OAuth alone no longer serving `google/gemini-*` models) without its companion piece in `src/auth/flows/index.ts` — so v0.8.0 shipped with a test suite (`provider-status.test.ts`) asserting behavior whose supporting data flag never landed. CI caught this immediately post-release: 2 failing tests on the exact tagged commit. Investigated, confirmed the missing piece was real (and independently observable live via `jeo doctor` before this session had made ANY of its own edits — not a stray/speculative change), and completed it.
271
- - **[0.8.0]** (2026-07-08) — Root-caused a genuine reliability bug in the routing veto gate ("라우트가 동작후 api 키인증오류가나오는데 근본적인 원인 개선해"), then regenerated the animated README mascot to visualize the smart cross-provider routing this release cycle has been building: prompt routing that finds the cheapest usable path, with a "money saver" visual motif.
272
- - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
273
273
 
274
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
275
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -262,11 +262,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
262
262
  ## 更新日志 (Changelog)
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
266
+ - **[0.8.4]** (2026-07-08) — Follow-up to the 0.8.3 `jeo --tmux` E2E verification, which surfaced two real bugs: (1) the fake `test-anthropic-key` fixture found sitting in the user's REAL `~/.jeo/config.json` turned out to be reproducible — jeo's own test suite could silently write to the real config with no guard, and a prior session's routing tests had done exactly that; (2) the E2E run's TUI showed `'antigravity/gemini-pro-agent' is not in the live antigravity catalog` for a model that WAS live — traced to the note firing on a FAILED provider discovery (timeout/expired OAuth) rather than a genuinely absent model.
265
267
  - **[0.8.3]** (2026-07-08) — "잘못된 모델 설정하는 경우가 있는데" — the static Antigravity catalog had drifted badly from the LIVE Cloud Code Assist agent backend, and several current wire ids actively LIE about their tier: `gemini-3-flash-agent` is really "Gemini 3.5 Flash (High)" (the flagship agent model, which the name-suffix heuristic filed under the SMALL flash tier), `gemini-pro-agent` is really "Gemini 3.1 Pro (High)" (the code-agent model), and `gemini-3.1-pro-high` is DEPRECATED by the backend in favor of `gemini-pro-agent`. Probed the real `fetchAvailableModels` response (displayNames + agentModelSorts + deprecatedModelIds) and made it the single source of truth.
266
268
  - **[0.8.2]** (2026-07-08) — "프롬프트라우팅이 api 입력을 요구하면서 동작안해" — prompt routing kept demanding an API key even when the user was already logged in via OAuth (GPT showed the same symptom). Root cause: every routing gate was PROVIDER-level ("any stored credential exists") while OAuth credentials are MODEL-level (ChatGPT OAuth serves only Codex ids, Kimi OAuth only the Kimi Code catalog, gemini OAuth nothing at all since the Cloud Code Assist masquerade was removed) — so routing happily picked models the stored login could never serve, and the turn then failed at call time asking for `<PROVIDER>_API_KEY`. Also restored a missing export that had broken `bun run typecheck` on HEAD.
267
269
  - **[0.8.1]** (2026-07-08) — Fix-forward for a functional regression this same session shipped in v0.8.0. This repo's working tree is shared with a concurrent interactive session; `git add`-ing `src/ai/provider-status.ts` for v0.8.0's routing-veto-gate fix also swept up that session's already-modified, uncommitted copy of the SAME file (containing an unrelated design change: gemini OAuth alone no longer serving `google/gemini-*` models) without its companion piece in `src/auth/flows/index.ts` — so v0.8.0 shipped with a test suite (`provider-status.test.ts`) asserting behavior whose supporting data flag never landed. CI caught this immediately post-release: 2 failing tests on the exact tagged commit. Investigated, confirmed the missing piece was real (and independently observable live via `jeo doctor` before this session had made ANY of its own edits — not a stray/speculative change), and completed it.
268
- - **[0.8.0]** (2026-07-08) — Root-caused a genuine reliability bug in the routing veto gate ("라우트가 동작후 api 키인증오류가나오는데 근본적인 원인 개선해"), then regenerated the animated README mascot to visualize the smart cross-provider routing this release cycle has been building: prompt routing that finds the cheapest usable path, with a "money saver" visual motif.
269
- - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -789,6 +789,15 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
789
789
  // OTHER refusal shape (bare stop_reason=refusal, finish_reason=content_filter,
790
790
  // Gemini finishReason=SAFETY/etc.) genuinely can clear as a rolling classifier
791
791
  // resets, so those still get the unbounded capped backoff below.
792
+ // NOTE (this rung reached at all is now the SECOND line of defense): a
793
+ // claude-fable-5 API-key call already carries server-side `fallbacks` to
794
+ // claude-opus-4-8 (see anthropicFallbackModels/postAnthropic's
795
+ // isFallbackUnsupportedError in providers/anthropic.ts) — Anthropic itself
796
+ // retries the SAME request against a different model before returning any
797
+ // error, so most reasoning_extraction/cyber/bio/frontier_llm declines never
798
+ // reach this ladder at all. This rung remains correct for: non-fable models,
799
+ // OAuth credentials, a custom baseUrl, or an account without the fallback
800
+ // beta (none of which carry the proactive fallback).
792
801
  const category = /Refusal \(\w+\)/i.test((err as Error)?.message ?? String(err));
793
802
  if (category) {
794
803
  return finish({ done: false, steps: step, doneReason: `Error: ${friendlyProviderError(err)}` });
@@ -410,6 +410,19 @@ export async function readGlobalConfig(): Promise<Config> {
410
410
 
411
411
  export async function saveGlobalConfig(config: Config): Promise<void> {
412
412
  const dir = globalConfigDir();
413
+ // Hermeticity guard: under `bun test` (Bun sets NODE_ENV=test, inherited by
414
+ // spawned CLI subprocesses via env spreads) a config WRITE may only target an
415
+ // explicitly sandboxed JEO_CONFIG_DIR — never the real ~/.jeo. A leaky test
416
+ // once overwrote the user's real providers.anthropic with the routing suite's
417
+ // "test-anthropic-key" fixture (~/.jeo/config.json.bak.test-clobber-20260702),
418
+ // silently killing every real Anthropic call afterwards. Reads stay unguarded
419
+ // (harmless); only the destructive path hard-fails, so a leak is caught in CI
420
+ // as a loud test error instead of corrupting the developer's machine.
421
+ if (process.env.NODE_ENV === "test" && !jeoEnv("CONFIG_DIR")) {
422
+ throw new Error(
423
+ "saveGlobalConfig: refusing to write the real ~/.jeo config under bun test — set JEO_CONFIG_DIR to a temp dir in the test (see test/config-save.test.ts's pattern).",
424
+ );
425
+ }
413
426
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
414
427
  const target = globalConfigPath();
415
428
  const tmpPath = `${target}.${Math.random().toString(36).slice(2)}.tmp`;
@@ -5,6 +5,7 @@ import { readSse } from "../sse";
5
5
  import { ProviderHttpError, parseRetryAfter, parseRetryFromBody, providerHttpError } from "./errors";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
7
  import { sanitizeJsonStrings } from "../../util/sanitize-json";
8
+ import { jeoEnv } from "../../util/env";
8
9
 
9
10
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
10
11
 
@@ -127,6 +128,28 @@ function parseAnthropicVersion(model: string): { kind: "opus" | "sonnet" | "haik
127
128
  return { kind: m[1] as "opus" | "sonnet" | "haiku" | "fable" | "mythos", major: Number(m[2]), minor: m[3] !== undefined ? Number(m[3]) : 0 };
128
129
  }
129
130
 
131
+ /** Server-side fallback (docs.claude.com/en/docs/build-with-claude/refusals-and-fallback):
132
+ * name up to 3 fallback models on the request and Anthropic retries a safety-classifier
133
+ * DECLINE (any `stop_details.category` — reasoning_extraction, cyber, bio, frontier_llm)
134
+ * against the next model in the SAME request/stream, before jeo's engine ever sees an
135
+ * error. Field case: fable-5 turns replaying thinking-block history tripped
136
+ * `reasoning_extraction` and re-refused through the whole engine ladder (context reset,
137
+ * artifact strip, guidance strip) because every rung resent the SAME model. Scoped to
138
+ * `claude-fable-5` (the model this beta's docs are written against — Mythos-5's
139
+ * invite-only status makes its fallback support unconfirmed) on a DIRECT api.anthropic.com
140
+ * API-key call only: the beta's request/response shape is undocumented for the
141
+ * Claude-Code OAuth cloak or a custom baseUrl (Anthropic-compatible third-party hosts),
142
+ * so those paths keep today's reactive (post-refusal) recovery unchanged.
143
+ * `JEO_ANTHROPIC_FALLBACK=0` opts out entirely. */
144
+ const FALLBACK_BETA = "server-side-fallback-2026-06-01";
145
+ function anthropicFallbackModels(model: string, credential: Credential, baseUrl: string | undefined): string[] {
146
+ if (jeoEnv("ANTHROPIC_FALLBACK") === "0") return [];
147
+ if (credential.kind !== "api_key" || baseUrl) return [];
148
+ const v = parseAnthropicVersion(model);
149
+ if (!v || v.kind !== "fable") return [];
150
+ return ["claude-opus-4-8"];
151
+ }
152
+
130
153
  /** Adaptive thinking `display` is supported starting with Opus 4.7. Without it, Opus 4.7/4.8
131
154
  * OMIT thinking content entirely (tokens billed, signature present, but zero visible thought —
132
155
  * the "reasoning doesn't show" bug). Older adaptive models (Opus 4.6, Sonnet 4.6+) reject the
@@ -230,6 +253,7 @@ export function anthropicPayload(
230
253
  includeTemperature: boolean,
231
254
  credential: Credential = { kind: "none", provider: "anthropic" },
232
255
  stripArtifacts = false,
256
+ disableFallback = false,
233
257
  ): string {
234
258
  const model = stripAnthropicPrefix(options.model);
235
259
  const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
@@ -303,6 +327,10 @@ export function anthropicPayload(
303
327
  payload.tool_choice = { type: "auto" };
304
328
  }
305
329
  if (stream) payload.stream = true;
330
+ if (!disableFallback) {
331
+ const fallbacks = anthropicFallbackModels(model, credential, options.baseUrl);
332
+ if (fallbacks.length) payload.fallbacks = fallbacks.map(m => ({ model: m }));
333
+ }
306
334
  const system = anthropicSystemBlocks(systemPrompt, model, credential, payload, options.baseUrl);
307
335
  if (system) payload.system = system;
308
336
  return JSON.stringify(sanitizeJsonStrings(payload));
@@ -315,13 +343,14 @@ export function anthropicRequest(
315
343
  stream: boolean,
316
344
  includeTemperature: boolean,
317
345
  stripArtifacts = false,
346
+ disableFallback = false,
318
347
  ): { url: string; headers: Record<string, string>; body: string } {
319
348
  return {
320
349
  // Anthropic-compatible providers (z.ai, MiniMax, …) accept the Messages wire
321
350
  // format at their own host; an explicit baseUrl pins `${base}/v1/messages`.
322
351
  url: options.baseUrl ? `${options.baseUrl.replace(/\/$/, "")}/v1/messages` : ANTHROPIC_URL,
323
- headers: { ...headersFor(credential, stream, stripAnthropicPrefix(options.model), options.baseUrl), ...options.extraHeaders },
324
- body: anthropicPayload(messages, options, stream, includeTemperature, credential, stripArtifacts),
352
+ headers: { ...headersFor(credential, stream, stripAnthropicPrefix(options.model), options.baseUrl, disableFallback), ...options.extraHeaders },
353
+ body: anthropicPayload(messages, options, stream, includeTemperature, credential, stripArtifacts, disableFallback),
325
354
  };
326
355
  }
327
356
 
@@ -336,6 +365,16 @@ function isReasoningArtifactError(status: number, detail: string): boolean {
336
365
  return status === 400 && /thinking|signature|redacted_thinking/i.test(detail);
337
366
  }
338
367
 
368
+ /** A 400 naming "fallback" means the account/endpoint rejected the `fallbacks`
369
+ * param or its beta header — not GA yet, a non-Claude-API Anthropic-compatible
370
+ * host, OR (per the docs) the named fallback model can't satisfy a feature THIS
371
+ * request uses ("rejects the request up front"). Retry once with fallback
372
+ * disabled entirely so the turn survives on today's reactive-recovery path
373
+ * instead of dying on an unrelated 400. */
374
+ function isFallbackUnsupportedError(status: number, detail: string): boolean {
375
+ return status === 400 && /fallback/i.test(detail);
376
+ }
377
+
339
378
  /** gjc parity (getSafeAnthropicHeaderEvidence): fold Anthropic's rate-limit response
340
379
  * headers into a 429's error detail so the retry classifier can see them. The decisive
341
380
  * usage-exhaustion signal (`anthropic-ratelimit-unified-overage-disabled-reason:
@@ -358,8 +397,8 @@ async function postAnthropic(
358
397
  credential: Credential,
359
398
  stream: boolean,
360
399
  ): Promise<Response> {
361
- const send = (includeTemperature: boolean, stripArtifacts = false) => {
362
- const { url, headers, body } = anthropicRequest(messages, options, credential, stream, includeTemperature, stripArtifacts);
400
+ const send = (includeTemperature: boolean, stripArtifacts = false, disableFallback = false) => {
401
+ const { url, headers, body } = anthropicRequest(messages, options, credential, stream, includeTemperature, stripArtifacts, disableFallback);
363
402
  return fetch(url, { method: "POST", headers, body, signal: options.signal });
364
403
  };
365
404
 
@@ -378,6 +417,13 @@ async function postAnthropic(
378
417
  if (response.ok) return response;
379
418
  throw await providerHttpError("Anthropic", response, stream ? "(stream)" : undefined);
380
419
  }
420
+ // Fail-safe: the account/endpoint doesn't support server-side fallback (or the
421
+ // named fallback model rejected THIS request's shape) → retry once without it.
422
+ if (isFallbackUnsupportedError(response.status, detail)) {
423
+ response = await send(true, false, true);
424
+ if (response.ok) return response;
425
+ throw await providerHttpError("Anthropic", response, stream ? "(stream)" : undefined);
426
+ }
381
427
 
382
428
  // 429s carry their rate-limit headers in the DETAIL so isUsageLimitError /
383
429
  // friendlyProviderError can classify on the full evidence (gjc parity).
@@ -425,14 +471,21 @@ export const anthropicAdapter: ProviderAdapter = {
425
471
  supportsNativeTools: true,
426
472
  async call(messages, options, credential) {
427
473
  const response = await postAnthropic(messages, options, credential, false);
428
- const result = (await response.json()) as { content: { type: string; text?: string; name?: string; input?: unknown; thinking?: string; signature?: string; data?: string }[]; stop_reason?: string; stop_details?: { category?: string }; usage?: AnthropicUsage };
474
+ const result = (await response.json()) as { model?: string; content: { type: string; text?: string; name?: string; input?: unknown; thinking?: string; signature?: string; data?: string }[]; stop_reason?: string; stop_details?: { category?: string }; usage?: AnthropicUsage };
429
475
  if (result.usage) options.onUsage?.({ inputTokens: totalInputTokens(result.usage), outputTokens: result.usage.output_tokens });
476
+ // Tag with the model that ACTUALLY produced the content — on a server-side
477
+ // fallback (see anthropicFallbackModels), `result.model` names the fallback
478
+ // model, not `options.model` (the originally requested one). Stamping the
479
+ // wrong model would make a later same-`options.model` turn wrongly nativize
480
+ // (or wrongly SKIP nativizing) this artifact — `anthropicNativizable` gates
481
+ // strictly on an exact model match, so an honest tag is required either way.
482
+ const servedModel = result.model ?? options.model;
430
483
  // Capture thinking/redacted blocks as replay artifacts (parity with the stream path).
431
484
  for (const c of result.content) {
432
485
  if (c.type === "thinking" && (c.thinking || c.signature)) {
433
- options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, text: c.thinking || undefined, signature: c.signature });
486
+ options.onReasoningArtifact?.({ provider: "anthropic", model: servedModel, text: c.thinking || undefined, signature: c.signature });
434
487
  } else if (c.type === "redacted_thinking" && c.data) {
435
- options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, redacted: c.data });
488
+ options.onReasoningArtifact?.({ provider: "anthropic", model: servedModel, redacted: c.data });
436
489
  }
437
490
  }
438
491
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
@@ -453,6 +506,12 @@ export const anthropicAdapter: ProviderAdapter = {
453
506
  let yieldedAny = false;
454
507
  let stopReason: string | undefined;
455
508
  let stopCategory: string | undefined;
509
+ // The model that actually served the response — starts at the requested model,
510
+ // updated from `message_start.message.model` and, on a mid-stream server-side
511
+ // fallback (see anthropicFallbackModels), from the `fallback` block's `to.model`.
512
+ // Reasoning artifacts are tagged with THIS, not `options.model` — see call()'s
513
+ // matching comment for why an honest tag is required either way.
514
+ let servedModel = options.model;
456
515
  // Native tool_use streams as content_block_start (name) + input_json_delta fragments,
457
516
  // never as text_delta — accumulate per block index, then re-serialize to canonical
458
517
  // JSON and yield it once at the end (concatenation still equals call()).
@@ -465,9 +524,9 @@ export const anthropicAdapter: ProviderAdapter = {
465
524
  let evt: {
466
525
  type?: string;
467
526
  index?: number;
468
- content_block?: { type?: string; name?: string; data?: string };
527
+ content_block?: { type?: string; name?: string; data?: string; to?: { model?: string } };
469
528
  delta?: { type?: string; text?: string; partial_json?: string; thinking?: string; signature?: string; stop_reason?: string; stop_details?: { category?: string } };
470
- message?: { usage?: AnthropicUsage; stop_reason?: string; stop_details?: { category?: string } };
529
+ message?: { model?: string; usage?: AnthropicUsage; stop_reason?: string; stop_details?: { category?: string } };
471
530
  usage?: { output_tokens?: number };
472
531
  };
473
532
  try {
@@ -477,6 +536,18 @@ export const anthropicAdapter: ProviderAdapter = {
477
536
  }
478
537
  if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use" && typeof evt.index === "number") {
479
538
  toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", args: "" });
539
+ } else if (evt.type === "content_block_start" && evt.content_block?.type === "fallback") {
540
+ // A safety-classifier decline handed off mid-stream (docs.claude.com's "When the
541
+ // decline happens mid-output"): an ordinary content_block_start/stop pair with no
542
+ // deltas, marking the boundary. Everything accumulated so far was produced by the
543
+ // DECLINING model and must be dropped on replay (thinking/redacted_thinking/
544
+ // client-side tool_use before the boundary) — best-effort here since a
545
+ // redacted_thinking block streams its artifact immediately (see below) and can't
546
+ // be un-sent; the common case (decline BEFORE any output — the bug this fixes)
547
+ // has nothing accumulated yet, so this branch is rarely reached with state to drop.
548
+ toolBlocks.clear();
549
+ thinkBlocks.clear();
550
+ if (evt.content_block.to?.model) servedModel = evt.content_block.to.model;
480
551
  } else if (evt.type === "content_block_start" && evt.content_block?.type === "thinking" && typeof evt.index === "number") {
481
552
  thinkBlocks.set(evt.index, { text: "" });
482
553
  // Signal the thinking phase started so the UI shows a live "thinking" indicator
@@ -485,7 +556,7 @@ export const anthropicAdapter: ProviderAdapter = {
485
556
  } else if (evt.type === "content_block_start" && evt.content_block?.type === "redacted_thinking" && evt.content_block.data) {
486
557
  // Redacted thinking carries opaque `data` directly (no deltas) — emit immediately.
487
558
  options.onReasoningStart?.();
488
- options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, redacted: evt.content_block.data });
559
+ options.onReasoningArtifact?.({ provider: "anthropic", model: servedModel, redacted: evt.content_block.data });
489
560
  } else if (evt.type === "content_block_delta" && evt.delta?.type === "input_json_delta" && typeof evt.index === "number") {
490
561
  const b = toolBlocks.get(evt.index);
491
562
  if (b) b.args += evt.delta.partial_json ?? "";
@@ -504,6 +575,7 @@ export const anthropicAdapter: ProviderAdapter = {
504
575
  tb.signature = (tb.signature ?? "") + evt.delta.signature;
505
576
  thinkBlocks.set(evt.index, tb);
506
577
  } else if (evt.type === "message_start" && evt.message) {
578
+ if (evt.message.model) servedModel = evt.message.model;
507
579
  if (evt.message.stop_reason) stopReason = evt.message.stop_reason;
508
580
  if (evt.message.stop_details?.category) stopCategory = evt.message.stop_details.category;
509
581
  if (evt.message.usage) {
@@ -522,7 +594,7 @@ export const anthropicAdapter: ProviderAdapter = {
522
594
  for (const tb of thinkBlocks.values()) {
523
595
  if (tb.text || tb.signature) {
524
596
  yieldedAny = true;
525
- options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, text: tb.text || undefined, signature: tb.signature });
597
+ options.onReasoningArtifact?.({ provider: "anthropic", model: servedModel, text: tb.text || undefined, signature: tb.signature });
526
598
  }
527
599
  }
528
600
  const envelope = serializeAccumulatedToolCalls(toolBlocks);
@@ -581,7 +653,7 @@ function claudeCodeOAuthHeaders(stream: boolean, model: string): Record<string,
581
653
  };
582
654
  }
583
655
 
584
- function headersFor(credential: Credential, stream: boolean, model: string, baseUrl?: string): Record<string, string> {
656
+ function headersFor(credential: Credential, stream: boolean, model: string, baseUrl?: string, disableFallback = false): Record<string, string> {
585
657
  if (credential.kind === "oauth") {
586
658
  // OAuth against a NON-Anthropic base (Kimi Code, …): plain bearer, no Claude-Code
587
659
  // cloaking headers/betas. gjc parity: buildAnthropicHeaders' generic-bearer branch.
@@ -601,12 +673,14 @@ function headersFor(credential: Credential, stream: boolean, model: string, base
601
673
  };
602
674
  }
603
675
  if (credential.kind === "api_key") {
676
+ const betas = ANTHROPIC_API_KEY_BETA.slice();
677
+ if (!disableFallback && anthropicFallbackModels(model, credential, baseUrl).length) betas.push(FALLBACK_BETA);
604
678
  return {
605
679
  accept: stream ? "text/event-stream" : "application/json",
606
680
  "content-type": "application/json",
607
681
  "x-api-key": credential.token,
608
682
  "anthropic-version": "2023-06-01",
609
- "anthropic-beta": anthropicBetaHeader(ANTHROPIC_API_KEY_BETA, model),
683
+ "anthropic-beta": anthropicBetaHeader(betas, model),
610
684
  };
611
685
  }
612
686
  throw new Error("anthropic adapter requires a credential");
@@ -35,7 +35,7 @@ import {
35
35
  } from "../../agent/subagents";
36
36
  import {
37
37
  formatModelLine,
38
- liveModelKnown,
38
+ liveModelMissing,
39
39
  formatPickListWithCapabilities,
40
40
  formatAgentsPanel,
41
41
  formatAgentDetail,
@@ -324,8 +324,10 @@ export async function runAgentsSlash(input: string, ctx: AgentsSlashCtx): Promis
324
324
  const { provider } = await describeModel(chosenModel);
325
325
  console.log(`${role.title} model set to ${chosenModel} (${provider}) — saved to ~/.jeo/config.json`);
326
326
  const live = await getLiveModels();
327
- if (!liveModelKnown(live, chosenModel)) {
328
- console.log(` (note: '${chosenModel}' is not in any live model list — verify it is valid for ${provider})`);
327
+ // Honest-note gate: only flag the id when ITS provider's live listing
328
+ // succeeded without it (see liveModelMissing) — never on discovery failure.
329
+ if (liveModelMissing(live, provider, chosenModel)) {
330
+ console.log(` (note: '${chosenModel}' is not in the live ${provider} model list — verify it is valid)`);
329
331
  }
330
332
  return result();
331
333
  }
@@ -24,7 +24,7 @@ import {
24
24
  } from "../../ai";
25
25
  import type { ProviderModelsResult, PickEntry, ProviderName, ThinkLevel } from "../../ai";
26
26
  import { getSubagentRole, resolveSubagentModel, withSubagentSetting } from "../../agent/subagents";
27
- import { formatModelLine, liveModelKnown, formatPickListWithCapabilities, formatCapabilityLine } from "../../tui/components/config-panel";
27
+ import { formatModelLine, liveModelMissing, formatPickListWithCapabilities, formatCapabilityLine } from "../../tui/components/config-panel";
28
28
  import { isThinkingLevel } from "./flags";
29
29
 
30
30
  // Antigravity with ANY Google OAuth (own login or the gemini-cli fallback) stays
@@ -268,7 +268,10 @@ export async function runModelSlash(input: string, ctx: ModelSlashCtx): Promise<
268
268
  console.log(` ! ChatGPT OAuth serves only Codex models (${CODEX_MODELS.join(", ")}); '${resolved}' will be rejected at runtime — pick one of those, or set OPENAI_API_KEY / OPENAI_BASE_URL.`);
269
269
  }
270
270
  }
271
- if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
271
+ // Only claim "not in the live catalog" when that provider's live listing
272
+ // actually SUCCEEDED and the id is absent — a failed/timed-out discovery
273
+ // (expired OAuth, network) must not smear a perfectly valid pick.
274
+ if (arg && liveModelsCache && resolved === label && liveModelMissing(liveModelsCache, provider, resolved)) {
272
275
  console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model to pick a valid id)`);
273
276
  }
274
277
  const meta = catalogMetadata(resolved);
@@ -62,8 +62,6 @@ import {
62
62
  formatAgentsPanel,
63
63
  formatAgentDetail,
64
64
  formatConfigPanel,
65
-
66
- liveModelKnown,
67
65
  formatPickListWithCapabilities,
68
66
  formatCapabilityLine,
69
67
  } from "../tui/components/config-panel";
@@ -11,6 +11,7 @@ import type { ProviderModelsResult } from "../../ai/model-discovery";
11
11
  import type { PickEntry } from "../../ai/model-picker";
12
12
  import type { CatalogModel } from "../../ai/model-catalog";
13
13
  import type { EnrichedModel } from "../../ai/model-enrich";
14
+ import type { ProviderName } from "../../ai/types";
14
15
  import { catalogMetadata, formatTokens, companyLabel } from "../../ai/model-catalog";
15
16
 
16
17
  /** A single "Model: alias → resolved (provider)" status line. */
@@ -155,9 +156,13 @@ export function formatLiveModels(
155
156
  return lines;
156
157
  }
157
158
 
158
- /** True when `model` appears in a provider's discovered list (exact match). */
159
- export function liveModelKnown(results: ProviderModelsResult[], model: string): boolean {
160
- return results.some(r => r.ok && r.models.includes(model));
159
+ /** True ONLY when `provider`'s live listing SUCCEEDED and `model` is absent from
160
+ * it — the one state where "not in the live catalog" is an honest claim. A
161
+ * failed/timed-out/absent listing proves nothing about the model (the note it
162
+ * gates was firing on discovery failures, flagging perfectly valid ids). */
163
+ export function liveModelMissing(results: ProviderModelsResult[], provider: ProviderName, model: string): boolean {
164
+ const r = results.find(x => x.provider === provider);
165
+ return !!r && r.ok && r.models.length > 0 && !r.models.includes(model);
161
166
  }
162
167
 
163
168
  /**