jeo-code 0.7.54 → 0.7.56
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 +23 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/prompt-router.ts +92 -10
- package/src/commands/doctor.ts +23 -1
- package/src/tui/app.ts +32 -12
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ 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.7.56] - 2026-07-07
|
|
10
|
+
_"서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Cross-provider tier auto-select** (`src/agent/prompt-router.ts`) — an unconfigured `trivial` tier now auto-selects the CHEAPEST model jeo has a stored credential for; an unconfigured `complex` tier auto-selects the most CAPABLE one (full/xhigh thinking support, then max output tokens, then context window). Both are computed LIVE off `MODEL_CATALOG`/`pricing.ts` on every call — a new/repriced/stronger catalog entry is picked up automatically on the next turn, with zero user config edit and no hand-maintained id table to go stale. `standard` is unchanged (always `defaultModel` unless explicitly configured — routine work stays on the user's deliberate default). Precedence unchanged and fully backward-compatible: explicit `routing.tiers.*.model` > legacy `roles.smol`/`roles.slow` > auto-select > `defaultModel`. Verified end-to-end against the real 4-provider production config: `trivial` → `gemini-2.0-flash` (gemini, cheapest), `complex` → `claude-fable-5` (anthropic, strongest available — matches current external SWE-Bench Pro agentic-benchmark leadership).
|
|
14
|
+
- **`jeo doctor` routing preview** (`--json`'s `routing.preview` / human output's "Routing preview" block) — surfaces what each tier ACTUALLY resolves to right now (model, provider, and source: `configured` / legacy role / `auto-selected: cheapest credentialed` / `auto-selected: strongest credentialed` / `defaultModel`), so the cross-provider split is visible without reading source. Purely informational, same as the existing routing notes — never affects `ready`/`--strict`.
|
|
15
|
+
|
|
16
|
+
### Verified
|
|
17
|
+
- `bun run typecheck` clean; full suite 2684/2684 pass across 280 files (+9 new tests: 7 in `prompt-router.test.ts` covering cheapest/strongest selection, single-provider constraint, explicit-config precedence, legacy-role precedence, zero-credential fallback, and OAuth-credential recognition; 2 in `doctor.test.ts` proving the preview surfaces a real cross-provider split in both `--json` and human output).
|
|
18
|
+
- A genuine tie case was found and deliberately left unresolved rather than "fixed" with a regression: `gemini-2.5-flash` vs `gemini-2.5-pro` have identical catalogued thinking/output/context metadata, so the canonical-id tiebreak (ascending, chosen specifically to never prefer a limited-availability sibling like `claude-mythos-5` over the widely-available `claude-fable-5`) picks `flash`. A price-descending tiebreak was prototyped and rejected: it fixed the synthetic gemini-only tie but broke the real 4-provider production case by preferring `claude-opus-4-6` (uncatalogued price, falls to a generic-family price higher than `claude-fable-5`'s dedicated price) over `claude-fable-5` — the actual current agentic-benchmark leader per external verification. Documented as a known limitation rather than silently traded for a worse regression.
|
|
19
|
+
|
|
20
|
+
## [0.7.55] - 2026-07-07
|
|
21
|
+
_Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end._
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **`modelProviderLabel()` helper** (`src/tui/app.ts`) — DRY "model (provider)" formatting shared with the existing `renderModelBar`/status-bar convention, now threaded through all three Thinking-related surfaces: the live `currentActivity()` status line ("reasoning (…)"/"calling model (…)"), the live streaming Thinking block header, and the live placeholder Thinking block (signature-only reasoning models like opus-4-8 that stream no thought text).
|
|
25
|
+
- **`thinkingHeader()` optional 3rd param** (`modelLabel?: string`) for the persisted (scrollback-committed) Thinking header — fully backward-compatible; existing 2-arg call sites are unchanged and produce byte-identical output.
|
|
26
|
+
|
|
27
|
+
### Verified
|
|
28
|
+
- `bun run typecheck` clean; full suite 2675/2675 pass across 280 files (+3 new tests: `thinkingHeader` 3-arg unit test, a real `LaunchTui` persisted-header integration test, and a live-frame streaming-block test).
|
|
29
|
+
- End-to-end proof with a real `LaunchTui` instance (not just the pure function) across a cross-provider two-turn session: turn 1 (claude-haiku-4-5/anthropic) and turn 2 (gemini-2.5-pro/gemini) each show their own correct model+provider in both the live and persisted Thinking headers, confirming the label updates turn-to-turn rather than being fixed at session start.
|
|
30
|
+
- A pre-existing test asserting the OLD bare `"thinking · Ns"` format (`streaming-reasoning.test.ts`) was updated to the new model-labeled format — an intentional format change, not a loosened assertion.
|
|
31
|
+
|
|
9
32
|
## [0.7.54] - 2026-07-06
|
|
10
33
|
_Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead._
|
|
11
34
|
|
package/README.ja.md
CHANGED
|
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
259
259
|
## 変更履歴 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
263
|
+
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
262
264
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
263
265
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
264
266
|
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
265
|
-
- **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
|
|
266
|
-
- **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -260,11 +260,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
260
260
|
## 변경 이력 (Changelog)
|
|
261
261
|
|
|
262
262
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
263
|
+
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
264
|
+
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
263
265
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
264
266
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
265
267
|
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
266
|
-
- **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
|
|
267
|
-
- **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
|
|
268
268
|
|
|
269
269
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
270
270
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -264,11 +264,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
264
264
|
## Changelog
|
|
265
265
|
|
|
266
266
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
267
|
+
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
268
|
+
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
267
269
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
268
270
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
269
271
|
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
270
|
-
- **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
|
|
271
|
-
- **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
|
|
272
272
|
|
|
273
273
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
274
274
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
259
259
|
## 更新日志 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
|
|
263
|
+
- **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
|
|
262
264
|
- **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
|
|
263
265
|
- **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
|
|
264
266
|
- **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
|
|
265
|
-
- **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
|
|
266
|
-
- **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -14,19 +14,31 @@
|
|
|
14
14
|
* failures and falls back to a safe default, and the top-level export wraps
|
|
15
15
|
* everything again as a final backstop.
|
|
16
16
|
*
|
|
17
|
-
* Why no
|
|
17
|
+
* Why no STATIC tier→model map: `resolveTierModel` computes inline off
|
|
18
18
|
* `config.roles`/`config.routing.tiers` so a user who already configured
|
|
19
19
|
* `roles.smol`/`roles.slow` (for `--smol`/`--slow` role-tier resolution
|
|
20
|
-
* elsewhere) gets working routing with zero additional config
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* elsewhere) gets working routing with zero additional config. An unconfigured
|
|
21
|
+
* trivial/complex tier does NOT simply collapse to `defaultModel` (that would
|
|
22
|
+
* defeat cross-provider routing for every user who hasn't hand-tuned
|
|
23
|
+
* `routing.tiers`) — it auto-selects the cheapest (trivial) / most capable
|
|
24
|
+
* (complex) model jeo has a stored credential for, computed LIVE off
|
|
25
|
+
* `MODEL_CATALOG`/`pricing.ts` on every call. This is the "long-term" fix for
|
|
26
|
+
* catalog drift: when jeo ships a new/cheaper/stronger model, auto-select
|
|
27
|
+
* picks it up on the next call automatically — no user config edit, no
|
|
28
|
+
* hand-maintained tier→model table to go stale. "standard" is the one
|
|
29
|
+
* exception: it always falls through to `defaultModel` unless explicitly
|
|
30
|
+
* configured, since routine day-to-day work should stay on the user's
|
|
31
|
+
* deliberately chosen default rather than being silently reassigned.
|
|
23
32
|
*/
|
|
24
33
|
import { callLlm } from "./loop";
|
|
25
34
|
import { resolveRoleModel } from "../ai/model-manager";
|
|
26
|
-
import { catalogMetadata } from "../ai/model-catalog";
|
|
35
|
+
import { catalogMetadata, MODEL_CATALOG, type CatalogModel } from "../ai/model-catalog";
|
|
36
|
+
import { priceForModel } from "../ai/pricing";
|
|
27
37
|
import { tryExtractJsonObject } from "./json";
|
|
28
38
|
import type { Config } from "./state";
|
|
29
39
|
import type { ThinkLevel } from "../ai/model-catalog";
|
|
40
|
+
import type { ProviderName } from "../ai/types";
|
|
41
|
+
import type { AuthProvider } from "../auth";
|
|
30
42
|
|
|
31
43
|
export type PromptTier = "trivial" | "standard" | "complex";
|
|
32
44
|
|
|
@@ -156,17 +168,87 @@ export function resetPromptRouterWarnings(): void {
|
|
|
156
168
|
warnedKeys.clear();
|
|
157
169
|
}
|
|
158
170
|
|
|
171
|
+
/** `resolveTierModel`'s config requirement: `providers`/`oauth` are OPTIONAL (not
|
|
172
|
+
* just a `Pick`) so callers/tests that never touch credentials (e.g. this file's
|
|
173
|
+
* own tests, which only exercise heuristic/escalation logic) keep compiling
|
|
174
|
+
* unchanged — omitting the keys entirely still satisfies this type, and
|
|
175
|
+
* `config.providers?.[p]` / `config.oauth?.[p]` are safe on the resulting
|
|
176
|
+
* `undefined`. Production callers (`readGlobalConfig()`'s real `Config`) always
|
|
177
|
+
* provide both. */
|
|
178
|
+
export type RoutingConfig = Pick<Config, "defaultModel" | "roles" | "routing"> &
|
|
179
|
+
Partial<Pick<Config, "providers" | "oauth">>;
|
|
180
|
+
|
|
181
|
+
/** Cloud providers eligible for cross-provider auto-select (excludes local
|
|
182
|
+
* ollama/lmstudio, which are never a sensible "route to the cheapest/strongest"
|
|
183
|
+
* target — a local model's cost/capability isn't comparable to a hosted one). */
|
|
184
|
+
function isCloudProvider(p: ProviderName): p is AuthProvider {
|
|
185
|
+
return p !== "ollama" && p !== "lmstudio";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Cheapest cloud model jeo has a credential for, computed LIVE off
|
|
189
|
+
* `MODEL_CATALOG`/`priceForModel` — picks up new/repriced catalog entries
|
|
190
|
+
* automatically, never a hand-maintained id. `null` when no credentialed
|
|
191
|
+
* model has a known price (caller falls back to `defaultModel`). Tiebreak:
|
|
192
|
+
* canonical id (deterministic across otherwise-equal candidates). */
|
|
193
|
+
function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
194
|
+
let best: CatalogModel | null = null;
|
|
195
|
+
let bestCost = Infinity;
|
|
196
|
+
for (const m of MODEL_CATALOG) {
|
|
197
|
+
// "ANY stored credential" (API key OR OAuth) is enough here — auto-select only
|
|
198
|
+
// needs "can this provider serve a request at all", not WHICH credential kind
|
|
199
|
+
// wins (unlike provider-status.ts's `configuredCredential` precedence). The
|
|
200
|
+
// real veto gate in launch.ts's `runTurn` re-verifies full readiness (including
|
|
201
|
+
// live OAuth expiry) before a turn actually uses the model.
|
|
202
|
+
if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
203
|
+
const price = priceForModel(m.canonical);
|
|
204
|
+
if (!price) continue;
|
|
205
|
+
const cost = price.inPerM + price.outPerM;
|
|
206
|
+
if (cost < bestCost || (cost === bestCost && best && m.canonical < best.canonical)) {
|
|
207
|
+
best = m;
|
|
208
|
+
bestCost = cost;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return best?.canonical ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Most capable cloud model jeo has a credential for, computed LIVE off
|
|
215
|
+
* `MODEL_CATALOG` — ranks by full (xhigh) thinking support first, then max
|
|
216
|
+
* output tokens, then context window, so a newly catalogued frontier model
|
|
217
|
+
* is picked up automatically without a hand-maintained id. `null` when no
|
|
218
|
+
* credentialed model qualifies (caller falls back to `defaultModel`).
|
|
219
|
+
* Tiebreak: canonical id (deterministic; also happens to prefer widely-
|
|
220
|
+
* available ids like `claude-fable-5` alphabetically ahead of limited-
|
|
221
|
+
* availability siblings such as `claude-mythos-5`). */
|
|
222
|
+
function strongestCredentialed(config: RoutingConfig): string | null {
|
|
223
|
+
let best: CatalogModel | null = null;
|
|
224
|
+
for (const m of MODEL_CATALOG) {
|
|
225
|
+
// "ANY stored credential" is enough (see cheapestCredentialed's comment above).
|
|
226
|
+
if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
227
|
+
if (!best) { best = m; continue; }
|
|
228
|
+
const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
|
|
229
|
+
const xhighBest = best.thinking.includes("xhigh") ? 1 : 0;
|
|
230
|
+
if (xhighM !== xhighBest) { if (xhighM > xhighBest) best = m; continue; }
|
|
231
|
+
if (m.maxOutputTokens !== best.maxOutputTokens) { if (m.maxOutputTokens > best.maxOutputTokens) best = m; continue; }
|
|
232
|
+
if (m.contextTokens !== best.contextTokens) { if (m.contextTokens > best.contextTokens) best = m; continue; }
|
|
233
|
+
if (m.canonical < best.canonical) best = m;
|
|
234
|
+
}
|
|
235
|
+
return best?.canonical ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
159
238
|
// --- Tier -> model/thinking resolution (inline off config.roles/config.routing.tiers; no static map). ---
|
|
160
239
|
|
|
161
|
-
|
|
240
|
+
/** Exported for `jeo doctor`'s routing-preview diagnostic (same resolution the real
|
|
241
|
+
* `routePrompt` uses — no duplicated logic between "what WILL routing pick" and
|
|
242
|
+
* "what does doctor SHOW the user routing will pick"). */
|
|
243
|
+
export function resolveTierModel(tier: PromptTier, config: RoutingConfig): string {
|
|
162
244
|
const configured = config.routing?.tiers?.[tier]?.model;
|
|
163
245
|
if (configured) return configured;
|
|
164
|
-
if (tier === "trivial") return config.roles?.smol || config.defaultModel;
|
|
165
|
-
if (tier === "complex") return config.roles?.slow || config.defaultModel;
|
|
246
|
+
if (tier === "trivial") return config.roles?.smol || cheapestCredentialed(config) || config.defaultModel;
|
|
247
|
+
if (tier === "complex") return config.roles?.slow || strongestCredentialed(config) || config.defaultModel;
|
|
166
248
|
return config.defaultModel; // "standard" always falls through to defaultModel unless explicitly configured
|
|
167
249
|
}
|
|
168
250
|
|
|
169
|
-
function resolveTierThinking(tier: PromptTier, config:
|
|
251
|
+
function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLevel | undefined {
|
|
170
252
|
return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
|
|
171
253
|
}
|
|
172
254
|
|
|
@@ -224,7 +306,7 @@ const SMOL_UNCONFIGURED_WARNING_KEY = "prompt-router:smol-unconfigured";
|
|
|
224
306
|
*/
|
|
225
307
|
export async function routePrompt(
|
|
226
308
|
prompt: string,
|
|
227
|
-
config:
|
|
309
|
+
config: RoutingConfig,
|
|
228
310
|
opts: RoutePromptOptions = {},
|
|
229
311
|
): Promise<RouteDecision | null> {
|
|
230
312
|
try {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -8,6 +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 } from "../agent/prompt-router";
|
|
11
12
|
|
|
12
13
|
interface ProbeResult {
|
|
13
14
|
status: "ok" | "fail" | "skipped";
|
|
@@ -211,6 +212,23 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
// Routing PREVIEW (long-term visibility for cross-provider auto-select, v0.7.56):
|
|
216
|
+
// for each tier, show what resolveTierModel() ACTUALLY resolves to right now —
|
|
217
|
+
// explicitly configured, legacy role-tier, auto-selected (cheapest/strongest
|
|
218
|
+
// credentialed, computed live off MODEL_CATALOG), or the defaultModel fallback
|
|
219
|
+
// (no credentialed alternative, or "standard" which never auto-selects). Purely
|
|
220
|
+
// informational — never affects ready/--strict, same contract as routingNotes.
|
|
221
|
+
const routingPreview: { tier: string; model: string; provider: string; source: string }[] = [];
|
|
222
|
+
if (routingEnabled) {
|
|
223
|
+
for (const tier of ["trivial", "standard", "complex"] as const) {
|
|
224
|
+
const explicit = config.routing?.tiers?.[tier]?.model || (tier === "trivial" ? config.roles?.smol : tier === "complex" ? config.roles?.slow : undefined);
|
|
225
|
+
const model = resolveTierModel(tier, config);
|
|
226
|
+
const provider = resolveProvider(model);
|
|
227
|
+
const source = explicit ? "configured" : model === config.defaultModel ? "defaultModel" : tier === "trivial" ? "auto-selected: cheapest credentialed" : "auto-selected: strongest credentialed";
|
|
228
|
+
routingPreview.push({ tier, model, provider, source });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
214
232
|
|
|
215
233
|
// --- Gather (probes run concurrently → ~1× the slowest timeout, not N×) ---
|
|
216
234
|
const probes: { name: string; credKind: string; result: ProbeResult }[] = [];
|
|
@@ -277,7 +295,7 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
277
295
|
})),
|
|
278
296
|
oauth: oauthHealth,
|
|
279
297
|
ready,
|
|
280
|
-
...(routingEnabled ? { routing: { enabled: true, smolConfigured, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
|
|
298
|
+
...(routingEnabled ? { routing: { enabled: true, smolConfigured, preview: routingPreview, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
|
|
281
299
|
|
|
282
300
|
};
|
|
283
301
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -327,6 +345,10 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
327
345
|
} else {
|
|
328
346
|
console.log(`${chalk.green("[routing]")} enabled, roles.smol configured — LLM escalation available on ambiguous prompts.`);
|
|
329
347
|
}
|
|
348
|
+
console.log("Routing preview (what each tier resolves to right now):");
|
|
349
|
+
for (const p of routingPreview) {
|
|
350
|
+
console.log(` ${p.tier.padEnd(9)} → ${p.model} (${p.provider}) ${chalk.dim(`[${p.source}]`)}`);
|
|
351
|
+
}
|
|
330
352
|
|
|
331
353
|
console.log("");
|
|
332
354
|
}
|
package/src/tui/app.ts
CHANGED
|
@@ -158,11 +158,17 @@ export function clipReasoningLines(text: string, cap = THINKING_COMMIT_MAX_LINES
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/** gjc-style "thought for Ns" header for a committed/streaming Thinking block. Omits the
|
|
161
|
-
* duration when no step start is known (e.g. resumed/exported records).
|
|
162
|
-
|
|
161
|
+
* duration when no step start is known (e.g. resumed/exported records). The optional
|
|
162
|
+
* `modelLabel` (e.g. "gpt-4o-mini (openai)", built by `modelProviderLabel()`) names
|
|
163
|
+
* which routed model actually produced this committed thought — a cross-provider
|
|
164
|
+
* routed turn's scrollback record otherwise gives no way to tell, after the fact,
|
|
165
|
+
* which provider reasoned about which step. Omitted entirely when absent (e.g. the
|
|
166
|
+
* 2-arg legacy call, or a resumed/exported record with no model context). */
|
|
167
|
+
export function thinkingHeader(elapsedMs: number | undefined, unicode: boolean, modelLabel?: string): string {
|
|
163
168
|
const diamond = unicode ? "◇" : "*";
|
|
164
169
|
const secs = elapsedMs !== undefined && elapsedMs >= 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : null;
|
|
165
|
-
|
|
170
|
+
const model = modelLabel ? ` · ${modelLabel}` : "";
|
|
171
|
+
return `${diamond} thinking${model}${secs ? ` · ${secs}` : ""}`;
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
/** Status animation palette while a tool/process runs (background verification): an
|
|
@@ -553,8 +559,10 @@ export class LaunchTui {
|
|
|
553
559
|
const style = (prose: string) => prose.split("\n").map(styleThought).join("\n");
|
|
554
560
|
const parts: string[] = [this.agentLabel()];
|
|
555
561
|
// gjc "thought for Ns" header: step-start → commit ≈ the model's think+gen time.
|
|
562
|
+
// Names the routed model/provider so a cross-provider routed turn's scrollback
|
|
563
|
+
// record stays legible after the fact, not just live in the model bar.
|
|
556
564
|
const elapsedMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
557
|
-
const header = thinkingHeader(elapsedMs, this.unicode);
|
|
565
|
+
const header = thinkingHeader(elapsedMs, this.unicode, this.modelProviderLabel());
|
|
558
566
|
parts.push(this.theme.color ? chalk.dim(header) : header);
|
|
559
567
|
if (willFlushThought) {
|
|
560
568
|
this.flushedThought = this.streamingThought;
|
|
@@ -943,6 +951,15 @@ export class LaunchTui {
|
|
|
943
951
|
* Replaces the per-tick cycling status text so the line shows genuine content (thinking
|
|
944
952
|
* about a real file/step) instead of churning decorative messages every 120ms.
|
|
945
953
|
*/
|
|
954
|
+
/** `model (provider)` — the same convention `renderModelBar` uses for the persistent
|
|
955
|
+
* status bar, reused here so the live/persisted "thinking" surfaces name which
|
|
956
|
+
* routed model+provider is actually reasoning right now (not just the bare model id,
|
|
957
|
+
* which alone doesn't distinguish e.g. a `gpt-4o-mini` served via openai vs. a
|
|
958
|
+
* same-named alias on an OpenAI-compatible provider). */
|
|
959
|
+
private modelProviderLabel(): string {
|
|
960
|
+
return `${this.footer.model}${this.footer.provider ? ` (${this.footer.provider})` : ""}`;
|
|
961
|
+
}
|
|
962
|
+
|
|
946
963
|
private currentActivity(): string {
|
|
947
964
|
const running = this.tools.currentTool();
|
|
948
965
|
// An in-flight tool's real target beats the workflow phase banner (gjc-style:
|
|
@@ -974,14 +991,14 @@ export class LaunchTui {
|
|
|
974
991
|
if (this.retryNotice) return `${this.retryNotice} (${elapsed}s)`;
|
|
975
992
|
// Reasoning is streaming → the live thought block already shows it; label the row.
|
|
976
993
|
if (this.streamingThought.trim() || this.streamingReasoning.trim()) {
|
|
977
|
-
return `reasoning (${this.
|
|
994
|
+
return `reasoning (${this.modelProviderLabel()}) (${elapsed}s)…`;
|
|
978
995
|
}
|
|
979
996
|
// No tokens after a few seconds: the model is almost certainly reasoning
|
|
980
997
|
// server-side (e.g. OpenAI hidden reasoning), NOT hung — say so instead of a
|
|
981
998
|
// frozen "calling model …" so a long silent wait still reads as progress.
|
|
982
999
|
const waited = this.currentStepStartedAt ? (Date.now() - this.currentStepStartedAt) / 1000 : 0;
|
|
983
|
-
if (waited >= 8) return `thinking (${this.
|
|
984
|
-
return `calling model (${this.
|
|
1000
|
+
if (waited >= 8) return `thinking (${this.modelProviderLabel()}) — reasoning, no token stream yet (${elapsed}s)…`;
|
|
1001
|
+
return `calling model (${this.modelProviderLabel()}) (${elapsed}s)…`;
|
|
985
1002
|
}
|
|
986
1003
|
if (running) {
|
|
987
1004
|
const last = this.forgeSummaries[this.forgeSummaries.length - 1];
|
|
@@ -1567,17 +1584,20 @@ export class LaunchTui {
|
|
|
1567
1584
|
// rectangle, so a short trace leaves no padded "hole" and a short terminal is spared.
|
|
1568
1585
|
const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
|
|
1569
1586
|
if (isThinking && liveThink) {
|
|
1570
|
-
// gjc-parity: the Thinking block label carries
|
|
1571
|
-
//
|
|
1587
|
+
// gjc-parity: the Thinking block label carries the routed model/provider and a
|
|
1588
|
+
// running timer ("Thinking · gpt-4o-mini (openai) · Ns") — names which model is
|
|
1589
|
+
// actually reasoning right now, so a cross-provider routed turn is legible mid-turn,
|
|
1590
|
+
// not just after the fact in the status bar. Cache key stays the constant "Thinking"
|
|
1591
|
+
// so the per-frame wrap memo is unaffected by the model/provider text.
|
|
1572
1592
|
const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
1573
|
-
const liveLabel = liveMs !== undefined ? `
|
|
1593
|
+
const liveLabel = `Thinking · ${this.modelProviderLabel()}${liveMs !== undefined ? ` · ${(liveMs / 1000).toFixed(1)}s` : ""}`;
|
|
1574
1594
|
tail.push(...this.renderLiveBlock(liveLabel, liveThink, cols, rows, 6, "Thinking"));
|
|
1575
1595
|
} else if (isThinking && this.thinkingActive) {
|
|
1576
1596
|
// Signature-only reasoning models (opus-4-7/4-8) open a thinking block but stream no
|
|
1577
1597
|
// thought text — show a live placeholder so the wait reads as active thinking, not a
|
|
1578
1598
|
// frozen screen. Replaced the instant any real thought/answer text streams (branch above).
|
|
1579
1599
|
const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
1580
|
-
const liveLabel = liveMs !== undefined ? `
|
|
1600
|
+
const liveLabel = `Thinking · ${this.modelProviderLabel()}${liveMs !== undefined ? ` · ${(liveMs / 1000).toFixed(1)}s` : ""}`;
|
|
1581
1601
|
tail.push(...this.renderLiveBlock(liveLabel, "(thinking…)", cols, rows, 6, "Thinking"));
|
|
1582
1602
|
}
|
|
1583
1603
|
|
|
@@ -1645,7 +1665,7 @@ export class LaunchTui {
|
|
|
1645
1665
|
? Math.round((this.footer.contextUsedTokens / this.footer.contextMaxTokens) * 100)
|
|
1646
1666
|
: undefined;
|
|
1647
1667
|
return renderStatusBar({
|
|
1648
|
-
model:
|
|
1668
|
+
model: this.modelProviderLabel(),
|
|
1649
1669
|
thinking: this.thinkingLevel,
|
|
1650
1670
|
routedTier: this.routedTier,
|
|
1651
1671
|
branch: this.footer.branch,
|