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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.54",
3
+ "version": "0.7.56",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -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 static tier→model map: `resolveTierModel` computes inline off
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, and an
21
- * unconfigured role falls through to `defaultModel` exactly like every other
22
- * role-tier consumer in this codebase (see `resolveRoleModel`).
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
- function resolveTierModel(tier: PromptTier, config: Pick<Config, "defaultModel" | "roles" | "routing">): string {
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: Pick<Config, "defaultModel" | "roles" | "routing">): ThinkLevel | undefined {
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: Pick<Config, "defaultModel" | "roles" | "routing">,
309
+ config: RoutingConfig,
228
310
  opts: RoutePromptOptions = {},
229
311
  ): Promise<RouteDecision | null> {
230
312
  try {
@@ -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
- export function thinkingHeader(elapsedMs: number | undefined, unicode: boolean): string {
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
- return `${diamond} thinking${secs ? ` · ${secs}` : ""}`;
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.footer.model}) (${elapsed}s)…`;
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.footer.model}) — reasoning, no token stream yet (${elapsed}s)…`;
984
- return `calling model (${this.footer.model}) (${elapsed}s)…`;
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 a running timer ("Thinking · Ns").
1571
- // Cache key stays the constant "Thinking" so the per-frame wrap memo is unaffected.
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 ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
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 ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
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: `${this.footer.model}${this.footer.provider ? ` (${this.footer.provider})` : ""}`,
1668
+ model: this.modelProviderLabel(),
1649
1669
  thinking: this.thinkingLevel,
1650
1670
  routedTier: this.routedTier,
1651
1671
  branch: this.footer.branch,