jeo-code 0.8.4 → 0.8.7
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 +32 -0
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +1 -1
- package/src/agent/engine.ts +9 -0
- package/src/agent/prompt-router.ts +23 -9
- package/src/ai/providers/anthropic.ts +87 -13
- package/src/commands/doctor.ts +18 -10
- package/src/commands/launch/model-slash.ts +28 -3
- package/src/commands/launch/route-slash.ts +14 -0
- package/src/commands/launch.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ 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.7] - 2026-07-08
|
|
10
|
+
_Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Restored the dropped closing brace** in `test/launch-prompt-routing.test.ts`'s "servability veto: does NOT fire for a Codex id..." test — it was missing `});` for both the `withOpenAiEnvCleared` callback and the outer `test()`, which swallowed the next test's `test(...)` opener into the same block and produced a hard parse error (`error: Unexpected end of file`) under `bun test`.
|
|
14
|
+
|
|
15
|
+
### Verified
|
|
16
|
+
Full suite 2779/2779 pass, typecheck clean — run directly against the working tree immediately after the fix, with no stash operation between fix and verification this time (the actual root cause of v0.8.6 shipping broken).
|
|
17
|
+
|
|
18
|
+
## [0.8.6] - 2026-07-08
|
|
19
|
+
_Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier._
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **`/route status` pin visibility** (`src/commands/launch/route-slash.ts`) — a new `pinnedModel` field on `RouteSlashCtx` (threaded from `sessionModel` in `launch.ts`) makes an active session pin print `note: model pinned to '<model>' this session — routing will not evaluate any prompt until the pin is cleared (/model auto)` instead of a bare, misleading `routing: on`. Purely additive — the actual routing gate (`!sessionModel` in `runTurn`) is unchanged.
|
|
23
|
+
- **`/model auto` (alias `/model clear`)** (`src/commands/launch/model-slash.ts`, `src/commands/launch.ts`) — releases an explicit `--model`/`/model <id>` pin, resuming per-turn `PromptRouter` evaluation for the rest of the session (subject to `routing.enabled`). `ModelSlashResult.sessionModel` is now 3-state (`undefined`=unchanged, `string`=new pin, `null`=pin explicitly cleared); `launch.ts` converts `null` to `undefined` so `runTurn`'s routing gate reopens. The "explicit pin always wins" contract itself is untouched — this only adds a way to release it.
|
|
24
|
+
- **`routePrompt` escalation fallback when `roles.smol` is unconfigured** (`src/agent/prompt-router.ts`) — instead of unconditionally skipping LLM escalation for ambiguous prompts, `routePrompt` now falls back to `cheapestCredentialed(config)` (the same live `MODEL_CATALOG` lookup trivial-tier auto-select already uses) as the classifier model. Escalation is only skipped (heuristic-only tier, one-time warning) when that fallback also resolves to `defaultModel` or nothing qualifies at all.
|
|
25
|
+
- **`jeo doctor` routing note synced to the same fallback** (`src/commands/doctor.ts`) — the "roles.smol is unset" note now checks `cheapestCredentialed(config)` before firing, so it stops claiming escalation "will never fire" when a real fallback classifier exists.
|
|
26
|
+
|
|
27
|
+
### Verified
|
|
28
|
+
Full suite 2778/2778 pass, typecheck clean. 10 new tests (`model-slash` ×4, `route-slash` ×2, `prompt-router` ×2, `launch-prompt-routing` ×2). Live tmux smoke test on the real interactive binary: `/route status` → pin note shown → `/model auto` → "Model pin (...) cleared" notice → `/route status` again → note gone, `routing: on (this session)` only.
|
|
29
|
+
|
|
30
|
+
## [0.8.5] - 2026-07-08
|
|
31
|
+
_"딥리서치 다음과같이 페이블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._
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
- **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.
|
|
37
|
+
|
|
38
|
+
### Verified
|
|
39
|
+
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).
|
|
40
|
+
|
|
9
41
|
## [0.8.4] - 2026-07-08
|
|
10
42
|
_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._
|
|
11
43
|
|
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.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
266
|
+
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
267
|
+
- **[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.
|
|
265
268
|
- **[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
269
|
- **[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
|
-
- **[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
|
-
- **[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
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.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
267
|
+
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
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.
|
|
266
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.
|
|
267
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.
|
|
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.
|
|
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.
|
|
270
|
-
- **[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.
|
|
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.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
269
|
+
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
270
|
+
- **[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.
|
|
268
271
|
- **[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.
|
|
269
272
|
- **[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.
|
|
270
|
-
- **[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.
|
|
271
|
-
- **[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.
|
|
272
|
-
- **[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.
|
|
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.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
266
|
+
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
267
|
+
- **[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.
|
|
265
268
|
- **[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
269
|
- **[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
|
-
- **[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
|
-
- **[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
270
|
|
|
271
271
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
272
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -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)}` });
|
|
@@ -515,16 +515,30 @@ export async function routePrompt(
|
|
|
515
515
|
|
|
516
516
|
if (heuristic.confidence < threshold) {
|
|
517
517
|
const smolModel = resolveRoleModel("smol", config);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
// escalation, keep the heuristic result, and surface a one-time warning.
|
|
522
|
-
warning = warnOnce(
|
|
523
|
-
SMOL_UNCONFIGURED_WARNING_KEY,
|
|
524
|
-
"[route] confidence below threshold but roles.smol is not configured — skipping LLM escalation, using heuristic tier (set roles.smol to enable escalation)",
|
|
525
|
-
);
|
|
518
|
+
let classifierModel: string | undefined;
|
|
519
|
+
if (smolModel !== config.defaultModel) {
|
|
520
|
+
classifierModel = smolModel;
|
|
526
521
|
} else {
|
|
527
|
-
|
|
522
|
+
// roles.smol not configured -> "escalate to a cheap model" would otherwise call
|
|
523
|
+
// defaultModel (expensive) — the exact paradox the design doc identifies. Rather
|
|
524
|
+
// than giving up outright, fall back to the cheapest CREDENTIALED catalog model
|
|
525
|
+
// (same live `MODEL_CATALOG` lookup `resolveTierModel`'s trivial-tier auto-select
|
|
526
|
+
// already uses) so an ambiguous per-turn prompt still gets a real LLM classification
|
|
527
|
+
// pass whenever a genuinely cheaper option exists. Only skip escalation entirely
|
|
528
|
+
// (heuristic-only, one-time warning) when even that fallback resolves to
|
|
529
|
+
// `defaultModel` itself (nothing cheaper is credentialed) or nothing qualifies at all.
|
|
530
|
+
const fallback = cheapestCredentialed(config);
|
|
531
|
+
if (fallback && fallback !== config.defaultModel) {
|
|
532
|
+
classifierModel = fallback;
|
|
533
|
+
} else {
|
|
534
|
+
warning = warnOnce(
|
|
535
|
+
SMOL_UNCONFIGURED_WARNING_KEY,
|
|
536
|
+
"[route] confidence below threshold but roles.smol is not configured and no cheaper credentialed model was found — skipping LLM escalation, using heuristic tier (set roles.smol to enable escalation)",
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (classifierModel) {
|
|
541
|
+
const escalated = await escalateToLlm(prompt, classifierModel, opts.signal);
|
|
528
542
|
if (escalated) {
|
|
529
543
|
tier = escalated;
|
|
530
544
|
source = "llm";
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
683
|
+
"anthropic-beta": anthropicBetaHeader(betas, model),
|
|
610
684
|
};
|
|
611
685
|
}
|
|
612
686
|
throw new Error("anthropic adapter requires a credential");
|
package/src/commands/doctor.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { meter } from "../tui/components/meter";
|
|
|
8
8
|
import { size } from "../tui/terminal";
|
|
9
9
|
import chalk from "chalk";
|
|
10
10
|
import { extractChatgptAccountId, CODEX_RESPONSES_URL } from "../ai/providers/openai-responses";
|
|
11
|
-
import { resolveTierModel, tierModelPool } from "../agent/prompt-router";
|
|
11
|
+
import { resolveTierModel, tierModelPool, cheapestCredentialed } from "../agent/prompt-router";
|
|
12
12
|
|
|
13
13
|
interface ProbeResult {
|
|
14
14
|
status: "ok" | "fail" | "skipped";
|
|
@@ -173,20 +173,28 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
173
173
|
const ollamaBase = config.ollamaBaseUrl ?? "http://localhost:11434";
|
|
174
174
|
|
|
175
175
|
// Routing diagnostic (design doc §7 risk #2): routing.enabled without roles.smol
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
176
|
+
// used to mean routePrompt()'s LLM-escalation path silently never fired. It no
|
|
177
|
+
// longer unconditionally gives up — it now falls back to the cheapest CREDENTIALED
|
|
178
|
+
// catalog model as the ambiguous-prompt classifier (same live MODEL_CATALOG lookup
|
|
179
|
+
// resolveTierModel's trivial-tier auto-select already uses), and only skips
|
|
180
|
+
// escalation entirely when that fallback ALSO resolves to defaultModel (nothing
|
|
181
|
+
// cheaper is credentialed) or nothing qualifies at all. Mirror that exact condition
|
|
182
|
+
// here so this note stays accurate instead of unconditionally claiming escalation
|
|
183
|
+
// "will never fire" — see prompt-router.ts's warnOnce/SMOL_UNCONFIGURED_WARNING_KEY
|
|
184
|
+
// for the matching runtime-side guard. Surface it here proactively, at onboarding
|
|
185
|
+
// time, instead of only mid-session on the first qualifying turn.
|
|
182
186
|
const routingEnabled = !!config.routing?.enabled;
|
|
183
187
|
const smolConfigured = !!config.roles?.smol;
|
|
184
188
|
const routingNotes: string[] = [];
|
|
185
189
|
if (routingEnabled && !smolConfigured) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
const escalationFallback = cheapestCredentialed(config);
|
|
191
|
+
if (!escalationFallback || escalationFallback === config.defaultModel) {
|
|
192
|
+
routingNotes.push(
|
|
193
|
+
"routing is enabled but roles.smol is unset — LLM escalation for ambiguous prompts will never fire; heuristic-only tier resolution applies every turn. Set roles.smol to enable escalation.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
189
196
|
}
|
|
197
|
+
|
|
190
198
|
// Proactive credential-readiness check for every model routing could actually pick —
|
|
191
199
|
// mirrors launch.ts's runTurn per-turn veto gate (v0.7.51) but surfaced at onboarding/
|
|
192
200
|
// doctor time instead of only reactively on a session's first qualifying turn. Catches
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `/model` slash-command handler extracted from launch.ts.
|
|
3
3
|
*
|
|
4
|
-
* Handles `/model [id|#N|save [id]|thinking <level>|subagent <role> [model|#N]]`
|
|
4
|
+
* Handles `/model [id|#N|auto|clear|save [id]|thinking <level>|subagent <role> [model|#N]]`
|
|
5
5
|
* (default: interactive live-model picker on a TTY, else prints the current
|
|
6
6
|
* model + a numbered pick list). This block shares mutable REPL state with
|
|
7
|
+
|
|
7
8
|
* still-inline commands (`/agents`, `/roles`, `/provider` also read/write
|
|
8
9
|
* `lastPickIndex`; `/thinking` reads/writes `sessionThinking`), so the caller
|
|
9
10
|
* passes the current values in via an explicit context object and reads back
|
|
@@ -60,14 +61,18 @@ export interface ModelSlashCtx {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export interface ModelSlashResult {
|
|
63
|
-
/** Present only when changed.
|
|
64
|
-
|
|
64
|
+
/** Present only when changed. `null` means the pin was explicitly CLEARED
|
|
65
|
+
* (`/model auto`/`/model clear`) — distinct from `undefined` ("unchanged"),
|
|
66
|
+
* since `sessionModel` itself must become `undefined` in the caller so
|
|
67
|
+
* `runTurn`'s `!sessionModel` routing gate re-opens for the rest of the session. */
|
|
68
|
+
sessionModel?: string | null;
|
|
65
69
|
/** Present only when changed. */
|
|
66
70
|
sessionThinking?: ThinkLevel;
|
|
67
71
|
/** Present only when the block builds/refreshes a pick list. */
|
|
68
72
|
lastPickIndex?: PickEntry[];
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
|
|
71
76
|
/**
|
|
72
77
|
* Handle `/model [id|#N|save|thinking <level>|subagent <role> ...]`. Extracted
|
|
73
78
|
* verbatim from launch.ts's inline REPL branch — the caller keeps the
|
|
@@ -91,6 +96,26 @@ export async function runModelSlash(input: string, ctx: ModelSlashCtx): Promise<
|
|
|
91
96
|
});
|
|
92
97
|
|
|
93
98
|
let arg = input.substring(6).trim();
|
|
99
|
+
// `/model auto` (or `/model clear`) releases an explicit pin (set by a prior
|
|
100
|
+
// `--model`/`/model <id>`), resuming per-turn PromptRouter evaluation for the
|
|
101
|
+
// rest of this session (subject to routing.enabled) — the release valve for
|
|
102
|
+
// the pin-always-wins contract, which otherwise gives no way back to routed
|
|
103
|
+
// mode short of restarting `jeo launch` with no `--model`. Distinct from
|
|
104
|
+
// `/model save`, which PERSISTS a pin; this clears the session-local one only
|
|
105
|
+
// (never touches ~/.jeo/config.json's defaultModel).
|
|
106
|
+
if (arg === "auto" || arg === "clear") {
|
|
107
|
+
if (!sessionModel) {
|
|
108
|
+
console.log("No model pin is active this session — routing already evaluates every turn (if routing.enabled).");
|
|
109
|
+
return result();
|
|
110
|
+
}
|
|
111
|
+
console.log(`Model pin ('${sessionModel}') cleared — routing resumes evaluating every turn (if routing.enabled); falls back to '${defaultModel}' whenever routing is off or produces no decision.`);
|
|
112
|
+
return {
|
|
113
|
+
sessionModel: null,
|
|
114
|
+
...(sessionThinkingChanged ? { sessionThinking } : {}),
|
|
115
|
+
...(lastPickIndexChanged ? { lastPickIndex } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
// `/model save [id]` → persist the (session or given) model as the config default.
|
|
95
120
|
if (arg === "save" || arg.startsWith("save ")) {
|
|
96
121
|
let toSave = arg.slice(4).trim();
|
|
@@ -23,8 +23,14 @@ export interface RouteSlashCtx {
|
|
|
23
23
|
/** `turnConfig.routing?.enabled ?? false`, read fresh by the caller. */
|
|
24
24
|
routingConfigEnabled: boolean;
|
|
25
25
|
lastRouteDecision: RouteDecision | { note: string } | null;
|
|
26
|
+
/** The session's explicit model pin (`sessionModel`, from `--model`/`/model`),
|
|
27
|
+
* if any. Threaded through purely for `/route status`'s visibility note below —
|
|
28
|
+
* never changes routing's actual gating logic (`!sessionModel` in launch.ts's
|
|
29
|
+
* `runTurn` stays the single source of truth for whether routing engages). */
|
|
30
|
+
pinnedModel?: string;
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
|
|
28
34
|
export interface RouteSlashResult {
|
|
29
35
|
/** Present only when changed by "on"/"off". */
|
|
30
36
|
sessionRouteOverride?: boolean | undefined;
|
|
@@ -69,9 +75,17 @@ export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResu
|
|
|
69
75
|
if (isRealDecision(ctx.lastRouteDecision)) {
|
|
70
76
|
lines.push(`last decision: ${ctx.lastRouteDecision.tier} → ${ctx.lastRouteDecision.model} (${ctx.lastRouteDecision.source}: ${ctx.lastRouteDecision.signals.join(", ") || "none"}, confidence ${ctx.lastRouteDecision.confidence.toFixed(2)})`);
|
|
71
77
|
}
|
|
78
|
+
// A model pin (`--model`/`/model`) unconditionally blocks routing regardless of
|
|
79
|
+
// this session's on/off toggle — surface that explicitly here, since "routing: on"
|
|
80
|
+
// read alone is misleading if it will never actually evaluate a prompt. Use
|
|
81
|
+
// /model auto to release the pin and let routing resume.
|
|
82
|
+
if (ctx.pinnedModel) {
|
|
83
|
+
lines.push(`note: model pinned to '${ctx.pinnedModel}' this session — routing will not evaluate any prompt until the pin is cleared (/model auto)`);
|
|
84
|
+
}
|
|
72
85
|
return { lines };
|
|
73
86
|
}
|
|
74
87
|
|
|
88
|
+
|
|
75
89
|
if (sub === "on") {
|
|
76
90
|
return { sessionRouteOverride: true, lines: ["routing: on (this session)"] };
|
|
77
91
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -4166,7 +4166,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4166
4166
|
isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
|
|
4167
4167
|
getLiveModels, applyPickedModelWithTarget, persistSessionModel, pickLiveProviderModel,
|
|
4168
4168
|
});
|
|
4169
|
-
|
|
4169
|
+
// `null` (from `/model auto`/`/model clear`) means "release the pin", NOT
|
|
4170
|
+
// "unchanged" — `sessionModel` must become `undefined` so `runTurn`'s
|
|
4171
|
+
// `!sessionModel` routing gate re-opens for the rest of this session.
|
|
4172
|
+
if (modelResult.sessionModel !== undefined) sessionModel = modelResult.sessionModel === null ? undefined : modelResult.sessionModel;
|
|
4170
4173
|
if (modelResult.sessionThinking !== undefined) sessionThinking = modelResult.sessionThinking;
|
|
4171
4174
|
if (modelResult.lastPickIndex !== undefined) lastPickIndex = modelResult.lastPickIndex;
|
|
4172
4175
|
continue;
|
|
@@ -4177,6 +4180,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4177
4180
|
sessionRouteOverride,
|
|
4178
4181
|
routingConfigEnabled: !!cfgNow.routing?.enabled,
|
|
4179
4182
|
lastRouteDecision,
|
|
4183
|
+
pinnedModel: sessionModel,
|
|
4180
4184
|
});
|
|
4181
4185
|
if (routeResult.sessionRouteOverride !== undefined) sessionRouteOverride = routeResult.sessionRouteOverride;
|
|
4182
4186
|
for (const line of routeResult.lines) console.log(line);
|