jeo-code 0.7.56 → 0.7.58
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 +29 -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/config-schema.ts +14 -0
- package/src/agent/context-files.ts +25 -0
- package/src/agent/engine.ts +4 -2
- package/src/agent/prompt-router.ts +211 -34
- package/src/agent/state.ts +22 -1
- package/src/agent/subagents.ts +37 -3
- package/src/agent/tools.ts +169 -0
- package/src/ai/model-catalog-compat.ts +20 -4
- package/src/ai/model-catalog.ts +117 -83
- package/src/ai/model-discovery.ts +5 -1
- package/src/ai/model-manager.ts +17 -4
- package/src/auth/callback-server.ts +13 -3
- package/src/commands/doctor.ts +32 -6
- package/src/commands/launch.ts +1 -1
- package/src/tui/app.ts +2 -2
- package/src/tui/components/status.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ 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.58] - 2026-07-07
|
|
10
|
+
_Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`CatalogModel.releaseDate`** (`"YYYY-MM"`) on every catalog entry — deep-research sourced from each provider's own announcement (Anthropic/OpenAI/Google/xAI/Moonshot direct web_search per family; Tencent-hosted rebrands (DeepSeek/MiniMax/Zhipu GLM/Hunyuan) traced to the ORIGINAL developer's release, not Tencent's hosting-catalog appearance date). `gpt-5.4-mini` was never added to the catalog — confirmed absent both before and after this pass, per explicit request.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **`MODEL_CATALOG` reordered newest-first within every provider block** for human readability (routing correctness never depended on array order — the ranking functions below now sort/tiebreak explicitly).
|
|
17
|
+
- **Recency tiebreak replaces the arbitrary alphabetical one** in `cheapestCredentialed`/`strongestCredentialed`/`compareStrengthAscending` (`src/agent/prompt-router.ts`) — on an exact capability/cost tie, the NEWER `releaseDate` now wins (a missing date always sorts as oldest, so an unconfirmed date can never silently outrank a verified-newer model). Fixes the confirmed opus-4-6/4-7/4-8 bug above.
|
|
18
|
+
- **`catalogForProvider`'s secondary sort** (`src/ai/model-catalog-compat.ts`) now orders by `releaseDate` (newest first) instead of alphabetical id — this is the actual list order `/model`'s live picker and `jeo setup` show a user, so "정렬" now reaches the real user-facing surface, not just the raw catalog array.
|
|
19
|
+
- **`RECOMMENDED` provider defaults refreshed** to the current (2026-07) general-availability flagship per provider: `claude-sonnet-5` (was `claude-sonnet-4-6`), `gpt-5.5` (was `gpt-4o`), `gemini-3-flash` (was `gemini-2.0-flash`), `antigravity/gpt-5.5` (was `antigravity/gemini-3-pro-low`).
|
|
20
|
+
|
|
21
|
+
### Verified
|
|
22
|
+
- `bun run typecheck` clean; full suite 2710/2710 pass across 281 files (1 pre-existing unrelated flaky test — `session.test.ts`'s global-`JSON.parse`-mock count assertion, confirmed passing in isolation and on rerun — not a regression from this change).
|
|
23
|
+
- Live-executed the exact reported bug before AND after the fix: `strongestCredentialed(anthropicOnlyConfig, m => m.canonical.startsWith("claude-opus"))` returned `claude-opus-4-6` (oldest) before, `claude-opus-4-8` (newest, correct) after.
|
|
24
|
+
- Re-applied `routing.crossProviderPool: true` to the live user config (a concurrent interactive session had reset it via an unrelated `/model` command mid-turn) and reconfirmed via `jeo doctor`: all 4 tiers correctly resolve through the cross-provider pool across anthropic/gemini/antigravity.
|
|
25
|
+
|
|
26
|
+
## [0.7.57] - 2026-07-07
|
|
27
|
+
_"동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in._
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`routing.crossProviderPool`** (opt-in, default off) — session-STABLE selection across every credentialed provider's EQUIVALENT-class model for a tier (`sizeClassFor`: provider-declared size suffixes — haiku/flash/mini=small, sonnet/pro=mid, opus/fable=large — computed live off `MODEL_CATALOG`, with a strength-tercile fallback for the ~50% of the catalog with no size-suffix naming). The same session always resolves to the same model (preserves provider-side prompt-cache warmth turn-to-turn); different sessions spread across different providers. Purely additive — unset, every existing config's behavior is byte-identical to v0.7.56.
|
|
31
|
+
- **`CatalogModel.limitedAvailability`** — a real bug this work surfaced: invite-only models (`claude-mythos-5`) were reachable by auto-select/pool even though a provider credential doesn't imply access to that specific model. Now excluded from all auto-select paths; still targetable explicitly by id for approved accounts.
|
|
32
|
+
- **`jeo doctor` routing preview** extended with an `"auto-selected: cross-provider pool"` source label when `crossProviderPool` resolved the shown model.
|
|
33
|
+
|
|
34
|
+
### Verified
|
|
35
|
+
- `bun run typecheck` clean; full suite 2692/2692 pass across 280 files.
|
|
36
|
+
- End-to-end against the real 4-provider production config: `tierModelPool("trivial")` spans gemini/openai/anthropic/antigravity; 5 distinct sessions distribute across 3 different providers while each individual session stays stable across repeated turns.
|
|
37
|
+
|
|
9
38
|
## [0.7.56] - 2026-07-07
|
|
10
39
|
_"서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — 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
40
|
|
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.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
|
|
263
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
262
264
|
- **[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
265
|
- **[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.
|
|
264
266
|
- **[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.
|
|
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.
|
|
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.
|
|
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.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
|
|
264
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
263
265
|
- **[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
266
|
- **[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.
|
|
265
267
|
- **[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.
|
|
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.
|
|
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.
|
|
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.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
|
|
268
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
267
269
|
- **[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
270
|
- **[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.
|
|
269
271
|
- **[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.
|
|
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.
|
|
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.
|
|
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.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
|
|
263
|
+
- **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
|
|
262
264
|
- **[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
265
|
- **[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.
|
|
264
266
|
- **[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.
|
|
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.
|
|
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.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -186,6 +186,9 @@ export const ConfigSchema = z
|
|
|
186
186
|
roles: z
|
|
187
187
|
.object({
|
|
188
188
|
smol: z.string().optional(),
|
|
189
|
+
medium: z.string().optional(),
|
|
190
|
+
high: z.string().optional(),
|
|
191
|
+
xhigh: z.string().optional(),
|
|
189
192
|
slow: z.string().optional(),
|
|
190
193
|
plan: z.string().optional(),
|
|
191
194
|
})
|
|
@@ -203,9 +206,11 @@ export const ConfigSchema = z
|
|
|
203
206
|
.object({
|
|
204
207
|
trivial: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
205
208
|
standard: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
209
|
+
high: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
206
210
|
complex: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
207
211
|
})
|
|
208
212
|
.optional(),
|
|
213
|
+
crossProviderPool: z.boolean().optional(),
|
|
209
214
|
})
|
|
210
215
|
.optional(),
|
|
211
216
|
gitAutoCommit: z.boolean().optional(),
|
|
@@ -320,6 +325,15 @@ export function parseConfig(raw: unknown): { ok: true; config: ValidatedConfig }
|
|
|
320
325
|
if (config.roles.smol) {
|
|
321
326
|
config.roles.smol = normalizeModelId(config.roles.smol, aliases);
|
|
322
327
|
}
|
|
328
|
+
if (config.roles.medium) {
|
|
329
|
+
config.roles.medium = normalizeModelId(config.roles.medium, aliases);
|
|
330
|
+
}
|
|
331
|
+
if (config.roles.high) {
|
|
332
|
+
config.roles.high = normalizeModelId(config.roles.high, aliases);
|
|
333
|
+
}
|
|
334
|
+
if (config.roles.xhigh) {
|
|
335
|
+
config.roles.xhigh = normalizeModelId(config.roles.xhigh, aliases);
|
|
336
|
+
}
|
|
323
337
|
if (config.roles.slow) {
|
|
324
338
|
config.roles.slow = normalizeModelId(config.roles.slow, aliases);
|
|
325
339
|
}
|
|
@@ -264,6 +264,31 @@ export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectCo
|
|
|
264
264
|
const home = process.env.HOME ? path.resolve(process.env.HOME) : null;
|
|
265
265
|
const collectedItems: ContextItem[] = [];
|
|
266
266
|
|
|
267
|
+
// 0. User-global context files (~/.jeo/agent/AGENTS.md) — lowest precedence.
|
|
268
|
+
// Native user files come first in the result so project files later take precedence.
|
|
269
|
+
// Only jeo's own user config applies; foreign-provider user files are excluded (e.g.
|
|
270
|
+
// ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md stay out — only ~/.jeo/* is loaded).
|
|
271
|
+
//
|
|
272
|
+
// This is optional: if the user file doesn't exist or is unreadable, we silently skip.
|
|
273
|
+
// Symlinks are followed (fs.stat resolves them), so ~/.jeo/agent/AGENTS.md can be a link.
|
|
274
|
+
if (home) {
|
|
275
|
+
const userAgentFile = path.join(home, ".jeo", "agent", "AGENTS.md");
|
|
276
|
+
try {
|
|
277
|
+
const stat = await fs.stat(userAgentFile);
|
|
278
|
+
// Check: must be a regular file (not directory) and non-empty.
|
|
279
|
+
// fs.stat follows symlinks, so this works transparently with linked files.
|
|
280
|
+
if (stat.isFile() && stat.size > 0) {
|
|
281
|
+
const file = await readContextFile(userAgentFile, "~/.jeo/agent/AGENTS.md", BASE_CONTEXT_CHARS - baseChars);
|
|
282
|
+
if (file) {
|
|
283
|
+
result.push(file);
|
|
284
|
+
baseChars += file.content.length;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// User-global file not found, unreadable, or permission denied; silently continue.
|
|
289
|
+
// User config is optional; its absence is not an error condition.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
267
292
|
// 1. CWD 및 부모 walk
|
|
268
293
|
let curr = resolvedCwd;
|
|
269
294
|
let distance = 0;
|
package/src/agent/engine.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as path from "node:path";
|
|
|
12
12
|
import type { Message } from "./loop";
|
|
13
13
|
import { extractJsonObject } from "./json";
|
|
14
14
|
import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
|
|
15
|
-
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
|
|
15
|
+
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, bisectTool, type ToolResult } from "./tools";
|
|
16
16
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
17
17
|
import { executeComputerAction } from "../commands/computer";
|
|
18
18
|
import { computerSupervisor } from "./computer-supervisor";
|
|
@@ -105,6 +105,7 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
|
|
|
105
105
|
lsp_rename: createLspRenameTool(),
|
|
106
106
|
debug: createDebugTool(),
|
|
107
107
|
browser: createBrowserTool(),
|
|
108
|
+
bisect: (a, cwd) => bisectTool(a.good, a.bad, a.run, !!a.invert, a.maxSteps, a.stepTimeoutMs, cwd),
|
|
108
109
|
};
|
|
109
110
|
/** Tool-protocol description injected into the system prompt. */
|
|
110
111
|
export const TOOL_PROTOCOL = [
|
|
@@ -126,7 +127,8 @@ export const TOOL_PROTOCOL = [
|
|
|
126
127
|
"15. lsp_rename {file, line, symbol?, new_name, apply?} — cross-file TypeScript/JavaScript rename (apply defaults true; apply:false previews only)",
|
|
127
128
|
"16. debug {action, ...} — Node.js debugging (launch/set_breakpoint/continue/step_over/step_in/step_out/pause/evaluate/stack_trace/scopes/variables/threads/output/terminate); one active session, 'launch' spawns 'node --inspect-brk' and pauses at start",
|
|
128
129
|
"17. browser {action:\"open\"|\"close\"|\"run\"|\"act\", name?, url?, actions?} — headless Chromium automation (Playwright); 'act' runs structured steps (navigate/click/type/fill/select/press/scroll/back/wait/observe/extract/screenshot) addressing elements by numeric id from 'observe' or a CSS selector; 'run' executes JS with page/browser/tab/display/assert/wait in scope",
|
|
129
|
-
"18.
|
|
130
|
+
"18. bisect {good, bad?, run, invert?, maxSteps?, stepTimeoutMs?} — binary-search git history to find the commit that introduced a regression; 'good' is a known-good commit, 'bad' defaults to HEAD, 'run' is a test command (exit 0=good, non-zero=bad, 125=skip), 'invert' finds a FIX instead of a regression",
|
|
131
|
+
"19. done {reason?} — call when the task is fully implemented AND verified",
|
|
130
132
|
"",
|
|
131
133
|
"Reply with STRICT JSON only — no code fences. You MAY include an optional leading",
|
|
132
134
|
'"reasoning" string (one short sentence on your plan) before "tool":',
|
|
@@ -18,17 +18,34 @@
|
|
|
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
20
|
* elsewhere) gets working routing with zero additional config. An unconfigured
|
|
21
|
-
* trivial/complex tier does NOT simply collapse to `defaultModel` (that
|
|
22
|
-
* defeat cross-provider routing for every user who hasn't hand-tuned
|
|
23
|
-
* `routing.tiers`) — it auto-selects the cheapest (trivial) /
|
|
24
|
-
* (complex) model jeo has a stored
|
|
25
|
-
* `MODEL_CATALOG`/`pricing.ts` on every
|
|
26
|
-
* catalog drift: when jeo ships a
|
|
27
|
-
* picks it up on the next call
|
|
28
|
-
* hand-maintained tier→model table to
|
|
29
|
-
* exception: it always falls through to
|
|
30
|
-
* configured, since routine day-to-day work
|
|
31
|
-
* deliberately chosen default rather than being
|
|
21
|
+
* trivial/high/complex tier does NOT simply collapse to `defaultModel` (that
|
|
22
|
+
* would defeat cross-provider routing for every user who hasn't hand-tuned
|
|
23
|
+
* `routing.tiers`) — it auto-selects the cheapest (trivial) / a strong
|
|
24
|
+
* mid-class model (high) / the most capable (complex) model jeo has a stored
|
|
25
|
+
* credential for, computed LIVE off `MODEL_CATALOG`/`pricing.ts` on every
|
|
26
|
+
* call. This is the "long-term" fix for catalog drift: when jeo ships a
|
|
27
|
+
* new/cheaper/stronger model, auto-select picks it up on the next call
|
|
28
|
+
* automatically — no user config edit, no hand-maintained tier→model table to
|
|
29
|
+
* go stale. "standard" is the one exception: it always falls through to
|
|
30
|
+
* `defaultModel` unless explicitly configured, since routine day-to-day work
|
|
31
|
+
* should stay on the user's deliberately chosen default rather than being
|
|
32
|
+
* silently reassigned (unless `routing.crossProviderPool` opts it into the
|
|
33
|
+
* session-stable cross-provider pool below).
|
|
34
|
+
*
|
|
35
|
+
* Four content-based tiers map onto the four `roles.*` price points
|
|
36
|
+
* (smol/medium/high/xhigh) so automatic routing can actually reach all of
|
|
37
|
+
* them: `trivial`→smol, `standard`→medium, `high`→roles.high (a single
|
|
38
|
+
* borderline-complex signal — e.g. one deep-work keyword alone — no longer
|
|
39
|
+
* jumps straight to the most expensive `complex`/xhigh tier), `complex`→xhigh.
|
|
40
|
+
*
|
|
41
|
+
* `routing.crossProviderPool` (opt-in, default off) activates
|
|
42
|
+
* `tierModelPool`/`selectFromPool` for the `standard`/`high` tiers: instead of
|
|
43
|
+
* a single deterministic pick, a SESSION-STABLE hash spreads different
|
|
44
|
+
* sessions across every credentialed provider's equivalent-class model, so
|
|
45
|
+
* jeo actually captures different providers' respective strengths on
|
|
46
|
+
* comparably-priced work instead of converging every session onto one
|
|
47
|
+
* provider. Off by default to preserve the "safe no-op absent configuration"
|
|
48
|
+
* contract for users who never opted in.
|
|
32
49
|
*/
|
|
33
50
|
import { callLlm } from "./loop";
|
|
34
51
|
import { resolveRoleModel } from "../ai/model-manager";
|
|
@@ -40,7 +57,7 @@ import type { ThinkLevel } from "../ai/model-catalog";
|
|
|
40
57
|
import type { ProviderName } from "../ai/types";
|
|
41
58
|
import type { AuthProvider } from "../auth";
|
|
42
59
|
|
|
43
|
-
export type PromptTier = "trivial" | "standard" | "complex";
|
|
60
|
+
export type PromptTier = "trivial" | "standard" | "high" | "complex";
|
|
44
61
|
|
|
45
62
|
export interface HeuristicResult {
|
|
46
63
|
tier: PromptTier;
|
|
@@ -62,6 +79,12 @@ export interface RouteDecision {
|
|
|
62
79
|
export interface RoutePromptOptions {
|
|
63
80
|
hasImages?: boolean;
|
|
64
81
|
signal?: AbortSignal;
|
|
82
|
+
/** Threads through to `selectFromPool` for session-stable cross-provider pool
|
|
83
|
+
* selection — the same session always lands on the same pool member (keeps
|
|
84
|
+
* provider-side prompt caching warm turn-to-turn), while different sessions
|
|
85
|
+
* spread across the pool. Omitted (e.g. a one-shot `jeo chat` call) ->
|
|
86
|
+
* deterministic index 0. */
|
|
87
|
+
sessionId?: string;
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
// --- Frozen, bilingual-validated classification signals (do not re-derive). ---
|
|
@@ -143,7 +166,10 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
|
143
166
|
return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
|
|
144
167
|
}
|
|
145
168
|
if (complexScore >= 2) return { tier: "complex", confidence: 0.85, signals };
|
|
146
|
-
|
|
169
|
+
// A single complex signal alone is treated as "high" (elevated but not full complex) —
|
|
170
|
+
// price efficiency: one weak/borderline signal must not jump straight to the priciest
|
|
171
|
+
// xhigh tier (that's reserved for >=2 corroborating complex signals).
|
|
172
|
+
if (complexScore === 1) return { tier: "high", confidence: 0.65, signals };
|
|
147
173
|
if (trivialScore >= 2) return { tier: "trivial", confidence: 0.85, signals };
|
|
148
174
|
if (trivialScore === 1) return { tier: "trivial", confidence: 0.65, signals };
|
|
149
175
|
return { tier: "standard", confidence: 0.9, signals }; // no signals at all -> common case, high confidence
|
|
@@ -185,12 +211,114 @@ function isCloudProvider(p: ProviderName): p is AuthProvider {
|
|
|
185
211
|
return p !== "ollama" && p !== "lmstudio";
|
|
186
212
|
}
|
|
187
213
|
|
|
214
|
+
/** Size class jeo infers for a catalog model, used to group EQUIVALENT models
|
|
215
|
+
* across DIFFERENT providers for pool-based routing (see `tierModelPool`) — the
|
|
216
|
+
* cross-provider counterpart to `cheapestCredentialed`/`strongestCredentialed`'s
|
|
217
|
+
* single-winner selection. Matches PROVIDER-DECLARED size-tier suffixes
|
|
218
|
+
* (Anthropic's haiku/sonnet/opus, Google's flash/pro, OpenAI's mini) against
|
|
219
|
+
* hyphen/dot-delimited SEGMENTS of the canonical id — NOT a raw substring match,
|
|
220
|
+
* which would false-positive on "gemini" containing "mini" as a substring.
|
|
221
|
+
* `null` for ids with no size-tier suffix at all (gpt-5.5, o3, grok-4.3, kimi-*,
|
|
222
|
+
* glm-*, deepseek-*, minimax-* — providers whose naming doesn't encode size);
|
|
223
|
+
* `tierModelPool` falls those back to a strength-tercile split instead. */
|
|
224
|
+
function sizeClassFor(canonical: string): "small" | "mid" | "large" | null {
|
|
225
|
+
const id = canonical.toLowerCase();
|
|
226
|
+
const bare = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
|
|
227
|
+
const segments = bare.split(/[-.]/);
|
|
228
|
+
if (segments.includes("haiku") || segments.includes("flash") || segments.includes("mini") || segments.includes("nano")) return "small";
|
|
229
|
+
if (segments.includes("opus") || segments.includes("ultra") || segments.includes("fable") || segments.includes("mythos")) return "large";
|
|
230
|
+
if (segments.includes("sonnet") || segments.includes("pro")) return "mid";
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const TIER_TO_SIZE_CLASS: Record<PromptTier, "small" | "mid" | "large"> = { trivial: "small", standard: "mid", high: "mid", complex: "large" };
|
|
235
|
+
|
|
236
|
+
/** Compares `releaseDate` ("YYYY-MM", lexicographically sortable). `>0` when `a`
|
|
237
|
+
* is NEWER than `b`. A missing date sorts as OLDEST on that side — an
|
|
238
|
+
* unconfirmed date can never silently outrank a verified-newer model. Shared
|
|
239
|
+
* recency tiebreak for `compareStrengthAscending`/`cheapestCredentialed`/
|
|
240
|
+
* `strongestCredentialed`, replacing the arbitrary alphabetical canonical-id
|
|
241
|
+
* tiebreak those used before (which, on a full capability tie, picked the
|
|
242
|
+
* OLDEST id alphabetically — e.g. `claude-opus-4-6` beating `claude-opus-4-8`
|
|
243
|
+
* as "strongest" purely because "4-6" < "4-8" as a string). */
|
|
244
|
+
function compareRecency(a: CatalogModel, b: CatalogModel): number {
|
|
245
|
+
if (a.releaseDate === b.releaseDate) return 0;
|
|
246
|
+
if (!a.releaseDate) return -1;
|
|
247
|
+
if (!b.releaseDate) return 1;
|
|
248
|
+
return a.releaseDate < b.releaseDate ? -1 : 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Multi-key strength comparator (ascending: weakest first) — same ranking signals
|
|
252
|
+
* as `strongestCredentialed`'s single-winner tiebreak, reused here to bucket
|
|
253
|
+
* UNCLASSIFIED (no size-suffix) models into a tercile fallback. */
|
|
254
|
+
function compareStrengthAscending(a: CatalogModel, b: CatalogModel): number {
|
|
255
|
+
const xhighA = a.thinking.includes("xhigh") ? 1 : 0;
|
|
256
|
+
const xhighB = b.thinking.includes("xhigh") ? 1 : 0;
|
|
257
|
+
if (xhighA !== xhighB) return xhighA - xhighB;
|
|
258
|
+
if (a.maxOutputTokens !== b.maxOutputTokens) return a.maxOutputTokens - b.maxOutputTokens;
|
|
259
|
+
if (a.contextTokens !== b.contextTokens) return a.contextTokens - b.contextTokens;
|
|
260
|
+
const recency = compareRecency(a, b);
|
|
261
|
+
if (recency !== 0) return recency;
|
|
262
|
+
return a.canonical < b.canonical ? -1 : a.canonical > b.canonical ? 1 : 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Cross-provider EQUIVALENCE pool for `tier` — every credentialed cloud model
|
|
266
|
+
* jeo classifies as the same size class (trivial→small, standard→mid,
|
|
267
|
+
* complex→large), computed LIVE off `MODEL_CATALOG` so it stays correct as the
|
|
268
|
+
* catalog evolves. Models with no size-tier-suffix (gpt-5.5, o3, grok-4.3,
|
|
269
|
+
* kimi-*, glm-*, …) fall back to a strength-tercile bucket instead of being
|
|
270
|
+
* silently excluded from every pool. Returns `[]` when zero credentialed
|
|
271
|
+
* models qualify (caller falls back to `defaultModel`). Sorted by canonical id
|
|
272
|
+
* for deterministic pool ordering (required by `selectFromPool`'s index math). */
|
|
273
|
+
export function tierModelPool(tier: PromptTier, config: RoutingConfig): string[] {
|
|
274
|
+
const targetClass = TIER_TO_SIZE_CLASS[tier];
|
|
275
|
+
const credentialed = MODEL_CATALOG.filter(m => isCloudProvider(m.provider) && !m.limitedAvailability && (config.providers?.[m.provider] || config.oauth?.[m.provider]));
|
|
276
|
+
|
|
277
|
+
const suffixPool = credentialed.filter(m => sizeClassFor(m.canonical) === targetClass);
|
|
278
|
+
|
|
279
|
+
const unclassified = credentialed.filter(m => sizeClassFor(m.canonical) === null).sort(compareStrengthAscending);
|
|
280
|
+
const third = Math.ceil(unclassified.length / 3);
|
|
281
|
+
// "high" shares "standard"'s unclassified-model tercile share: the catalog's
|
|
282
|
+
// size-suffix classification only has 3 real buckets (small/mid/large — see
|
|
283
|
+
// sizeClassFor), so there is no distinct 4th tercile to carve out for "high".
|
|
284
|
+
// resolveTierModel's "high" branch does not currently rely on this tercile
|
|
285
|
+
// share (it prefers a deterministic strongest-mid-class pick — see
|
|
286
|
+
// strongestMidTierCredentialed below); kept here only so this `Record<PromptTier,…>`
|
|
287
|
+
// stays exhaustive for external callers of `tierModelPool("high", …)`.
|
|
288
|
+
const tercileByTier: Record<PromptTier, CatalogModel[]> = {
|
|
289
|
+
trivial: unclassified.slice(0, third),
|
|
290
|
+
standard: unclassified.slice(third, third * 2),
|
|
291
|
+
high: unclassified.slice(third, third * 2),
|
|
292
|
+
complex: unclassified.slice(third * 2),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const pool = [...suffixPool, ...tercileByTier[tier]];
|
|
296
|
+
return pool.map(m => m.canonical).sort();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Deterministic, SESSION-STABLE pick from `pool` — the same `sessionId` always
|
|
300
|
+
* resolves to the same index within one pool (so a session's provider-side
|
|
301
|
+
* prompt cache stays warm turn-to-turn, per this file's `deriveCacheSessionKey`
|
|
302
|
+
* cache-correlation contract), while DIFFERENT sessions spread across the pool's
|
|
303
|
+
* providers (the actual cross-provider distribution this feature exists for).
|
|
304
|
+
* No `sessionId` (e.g. a one-shot `jeo chat` call) deterministically picks index
|
|
305
|
+
* 0 (the pool is canonical-id-sorted, so this is still stable, just unvaried). */
|
|
306
|
+
export function selectFromPool(pool: readonly string[], sessionId: string | undefined): string {
|
|
307
|
+
if (pool.length === 0) throw new Error("selectFromPool: empty pool");
|
|
308
|
+
if (pool.length === 1 || !sessionId) return pool[0];
|
|
309
|
+
let h = 0x811c9dc5 | 0;
|
|
310
|
+
for (let i = 0; i < sessionId.length; i++) h = Math.imul(h ^ sessionId.charCodeAt(i), 0x01000193);
|
|
311
|
+
return pool[(h >>> 0) % pool.length];
|
|
312
|
+
}
|
|
313
|
+
|
|
188
314
|
/** Cheapest cloud model jeo has a credential for, computed LIVE off
|
|
189
315
|
* `MODEL_CATALOG`/`priceForModel` — picks up new/repriced catalog entries
|
|
190
316
|
* automatically, never a hand-maintained id. `null` when no credentialed
|
|
191
|
-
* model has a known price (caller falls back to `defaultModel`). Tiebreak
|
|
192
|
-
*
|
|
193
|
-
|
|
317
|
+
* model has a known price (caller falls back to `defaultModel`). Tiebreak on
|
|
318
|
+
* an exact cost tie: NEWER `releaseDate` wins (a same-priced newer model is
|
|
319
|
+
* likely a refined successor), then canonical id (deterministic fallback
|
|
320
|
+
* when recency also ties or is unconfirmed on both sides). */
|
|
321
|
+
export function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
194
322
|
let best: CatalogModel | null = null;
|
|
195
323
|
let bestCost = Infinity;
|
|
196
324
|
for (const m of MODEL_CATALOG) {
|
|
@@ -199,13 +327,16 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
|
199
327
|
// wins (unlike provider-status.ts's `configuredCredential` precedence). The
|
|
200
328
|
// real veto gate in launch.ts's `runTurn` re-verifies full readiness (including
|
|
201
329
|
// live OAuth expiry) before a turn actually uses the model.
|
|
202
|
-
if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
330
|
+
if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
203
331
|
const price = priceForModel(m.canonical);
|
|
204
332
|
if (!price) continue;
|
|
205
333
|
const cost = price.inPerM + price.outPerM;
|
|
206
|
-
if (cost < bestCost
|
|
334
|
+
if (cost < bestCost) {
|
|
207
335
|
best = m;
|
|
208
336
|
bestCost = cost;
|
|
337
|
+
} else if (cost === bestCost && best) {
|
|
338
|
+
const recency = compareRecency(m, best);
|
|
339
|
+
if (recency > 0 || (recency === 0 && m.canonical < best.canonical)) best = m;
|
|
209
340
|
}
|
|
210
341
|
}
|
|
211
342
|
return best?.canonical ?? null;
|
|
@@ -213,46 +344,91 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
|
213
344
|
|
|
214
345
|
/** Most capable cloud model jeo has a credential for, computed LIVE off
|
|
215
346
|
* `MODEL_CATALOG` — ranks by full (xhigh) thinking support first, then max
|
|
216
|
-
* output tokens, then context window,
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
347
|
+
* output tokens, then context window, then NEWER `releaseDate` (a same-specs
|
|
348
|
+
* model with a later release date is presumptively a refined successor — e.g.
|
|
349
|
+
* `claude-opus-4-8` over `claude-opus-4-6`, which the OLD alphabetical
|
|
350
|
+
* canonical-id tiebreak got backwards, since `"4-6" < "4-8"` picked the
|
|
351
|
+
* OLDEST opus as "strongest"), then canonical id (final deterministic
|
|
352
|
+
* fallback). `null` when no credentialed model qualifies (caller falls back
|
|
353
|
+
* to `defaultModel`). Excludes `limitedAvailability` models (e.g.
|
|
354
|
+
* `claude-mythos-5`) — a provider credential doesn't imply access to a
|
|
355
|
+
* specific invite-only model; explicit `/model`/`routing.tiers` can still
|
|
356
|
+
* target one by id for approved accounts. */
|
|
357
|
+
export function strongestCredentialed(
|
|
358
|
+
config: RoutingConfig,
|
|
359
|
+
filter?: (m: CatalogModel) => boolean
|
|
360
|
+
): string | null {
|
|
223
361
|
let best: CatalogModel | null = null;
|
|
224
362
|
for (const m of MODEL_CATALOG) {
|
|
225
363
|
// "ANY stored credential" is enough (see cheapestCredentialed's comment above).
|
|
226
|
-
if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
364
|
+
if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
|
|
365
|
+
if (filter && !filter(m)) continue;
|
|
227
366
|
if (!best) { best = m; continue; }
|
|
228
367
|
const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
|
|
229
368
|
const xhighBest = best.thinking.includes("xhigh") ? 1 : 0;
|
|
230
369
|
if (xhighM !== xhighBest) { if (xhighM > xhighBest) best = m; continue; }
|
|
231
370
|
if (m.maxOutputTokens !== best.maxOutputTokens) { if (m.maxOutputTokens > best.maxOutputTokens) best = m; continue; }
|
|
232
371
|
if (m.contextTokens !== best.contextTokens) { if (m.contextTokens > best.contextTokens) best = m; continue; }
|
|
372
|
+
const recency = compareRecency(m, best);
|
|
373
|
+
if (recency !== 0) { if (recency > 0) best = m; continue; }
|
|
233
374
|
if (m.canonical < best.canonical) best = m;
|
|
234
375
|
}
|
|
235
376
|
return best?.canonical ?? null;
|
|
236
377
|
}
|
|
237
378
|
|
|
379
|
+
/** Strongest credentialed model restricted to the SAME "mid" size class
|
|
380
|
+
* `tierModelPool` uses for `standard` (sonnet/pro-suffixed families) — the
|
|
381
|
+
* auto-select fallback for the `high` tier. Deliberately narrower than
|
|
382
|
+
* `strongestCredentialed`'s catalog-wide search: an unrestricted search would
|
|
383
|
+
* frequently tie with (or even prefer) `complex`'s own pick, since several
|
|
384
|
+
* "unclassified" frontier ids (gpt-5.5, grok-4.3, …) publish flagship-tier
|
|
385
|
+
* specs under a name with no size-suffix at all and would otherwise win over
|
|
386
|
+
* a genuinely mid-class model like `claude-sonnet-4-6`. `null` when no
|
|
387
|
+
* mid-class-suffixed model is credentialed (caller falls through to
|
|
388
|
+
* `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
|
|
389
|
+
function strongestMidTierCredentialed(config: RoutingConfig): string | null {
|
|
390
|
+
const midClass = MODEL_CATALOG.filter(
|
|
391
|
+
m => isCloudProvider(m.provider) && !m.limitedAvailability && sizeClassFor(m.canonical) === "mid" && (config.providers?.[m.provider] || config.oauth?.[m.provider]),
|
|
392
|
+
);
|
|
393
|
+
if (midClass.length === 0) return null;
|
|
394
|
+
return midClass.sort(compareStrengthAscending).at(-1)!.canonical;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** `routing.crossProviderPool` (opt-in, default off) fallback for ALL FOUR tiers:
|
|
398
|
+
* a SESSION-STABLE pick from `tierModelPool(tier, config)` instead of each
|
|
399
|
+
* tier's deterministic single-winner pick (`cheapestCredentialed`/
|
|
400
|
+
* `strongestMidTierCredentialed`/`strongestCredentialed`). Off (or an empty
|
|
401
|
+
* pool) returns `null` so the caller's next fallback applies unchanged — this
|
|
402
|
+
* is purely additive: a user who has not set the flag keeps v0.7.56's exact
|
|
403
|
+
* single-winner behavior; setting it distributes qualifying tiers across
|
|
404
|
+
* every credentialed provider's equivalent-class model instead of always
|
|
405
|
+
* resolving to the one deterministic winner. */
|
|
406
|
+
function crossProviderPoolPick(tier: PromptTier, config: RoutingConfig, sessionId: string | undefined): string | null {
|
|
407
|
+
if (!config.routing?.crossProviderPool) return null;
|
|
408
|
+
const pool = tierModelPool(tier, config);
|
|
409
|
+
return pool.length > 0 ? selectFromPool(pool, sessionId) : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
238
412
|
// --- Tier -> model/thinking resolution (inline off config.roles/config.routing.tiers; no static map). ---
|
|
239
413
|
|
|
240
414
|
/** Exported for `jeo doctor`'s routing-preview diagnostic (same resolution the real
|
|
241
415
|
* `routePrompt` uses — no duplicated logic between "what WILL routing pick" and
|
|
242
416
|
* "what does doctor SHOW the user routing will pick"). */
|
|
243
|
-
export function resolveTierModel(tier: PromptTier, config: RoutingConfig): string {
|
|
417
|
+
export function resolveTierModel(tier: PromptTier, config: RoutingConfig, sessionId?: string): string {
|
|
244
418
|
const configured = config.routing?.tiers?.[tier]?.model;
|
|
245
419
|
if (configured) return configured;
|
|
246
|
-
if (tier === "trivial") return config.roles?.smol || cheapestCredentialed(config) || config.defaultModel;
|
|
247
|
-
if (tier === "
|
|
248
|
-
return config.
|
|
420
|
+
if (tier === "trivial") return config.roles?.smol || crossProviderPoolPick(tier, config, sessionId) || cheapestCredentialed(config) || config.defaultModel;
|
|
421
|
+
if (tier === "standard") return config.roles?.medium || config.roles?.high || crossProviderPoolPick(tier, config, sessionId) || config.defaultModel;
|
|
422
|
+
if (tier === "high") return config.roles?.high || config.roles?.medium || crossProviderPoolPick(tier, config, sessionId) || strongestMidTierCredentialed(config) || config.defaultModel;
|
|
423
|
+
if (tier === "complex") return config.roles?.xhigh || config.roles?.slow || crossProviderPoolPick(tier, config, sessionId) || strongestCredentialed(config) || config.defaultModel;
|
|
424
|
+
return config.defaultModel;
|
|
249
425
|
}
|
|
250
426
|
|
|
251
427
|
function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLevel | undefined {
|
|
252
428
|
return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
|
|
253
429
|
}
|
|
254
430
|
|
|
255
|
-
const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "complex"];
|
|
431
|
+
const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "high", "complex"];
|
|
256
432
|
function isPromptTier(value: unknown): value is PromptTier {
|
|
257
433
|
return typeof value === "string" && (VALID_TIERS as readonly string[]).includes(value);
|
|
258
434
|
}
|
|
@@ -266,8 +442,9 @@ function buildClassifierPrompt(prompt: string): string {
|
|
|
266
442
|
return (
|
|
267
443
|
`Classify the complexity of the following user request for a coding assistant into exactly one ` +
|
|
268
444
|
`tier: "trivial" (a quick factual lookup, no repo work), "standard" (routine coding/editing work), ` +
|
|
269
|
-
`
|
|
270
|
-
`
|
|
445
|
+
`"high" (a single moderately complex change — one non-trivial refactor/debug in one area, not yet ` +
|
|
446
|
+
`multi-file or architectural), or "complex" (multi-file, architectural, or root-cause debugging work). ` +
|
|
447
|
+
`Respond with ONLY a JSON object of the shape {"tier":"trivial"|"standard"|"high"|"complex"}.\n\n` +
|
|
271
448
|
`Request:\n${capped}`
|
|
272
449
|
);
|
|
273
450
|
}
|
|
@@ -336,7 +513,7 @@ export async function routePrompt(
|
|
|
336
513
|
}
|
|
337
514
|
}
|
|
338
515
|
|
|
339
|
-
const model = resolveTierModel(tier, config);
|
|
516
|
+
const model = resolveTierModel(tier, config, opts.sessionId);
|
|
340
517
|
if (opts.hasImages && catalogMetadata(model)?.images === false) return null;
|
|
341
518
|
|
|
342
519
|
const decision: RouteDecision = {
|