jeo-code 0.8.15 → 0.8.17

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,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.17] - 2026-07-09
10
+ _"서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns._
11
+
12
+ ### Fixed
13
+ - **README `/route on` semantics corrected** (`README.md`, `README.ko.md`, `README.ja.md`, `README.zh.md`) — the caution block previously said the model pin releases "until `/route on` with no pin active", which has the mechanism backwards: `/route on` (`src/commands/launch/route-slash.ts:93-98`) sets `sessionRouteOverride = true`, which *outranks* an active `sessionModel` pin without clearing it — the pin reasserts itself the instant `/route off` runs (confirmed by the existing `test/launch-prompt-routing.test.ts` "/route on then /route off" regression). Only `/model auto` actually clears the pin (`sessionModel = undefined`).
14
+ - **README tier-fallback claim scoped correctly** — the caution block previously implied any tier missing a `roles.*` entry falls back to `defaultModel` on every turn. `resolveTierModel` (`src/agent/prompt-router.ts:464-472`) only guarantees that for the `standard` tier; `high` and `complex` first run a live credentialed-model scan (`strongestMidTierCredentialed` / `strongestCredentialed`) before falling back, so they can still resolve to a different model each turn even when unconfigured. Reworded in all 4 languages; also fixed a KO/JA translation drift that narrowed "a tier" to "상위 티어"/"上位ティア" (upper tier), which had no counterpart in the EN/ZH text.
15
+
16
+ ### Added
17
+ - **Mid-session `/model <name>` slash-command lock + persistence coverage** (`test/launch-prompt-routing.test.ts`) — 3 new tests proving the pin set by literally typing `/model <name>` (not just the `--model` CLI flag) holds on the very next turn, persists across 3+ consecutive subsequent turns with no further commands, and correctly cycles through pin → `/model auto` release → re-pin to a different model.
18
+ - **Tier-resolution freshness + scoping coverage** (`test/prompt-router-tiers.test.ts`) — 2 new tests proving `resolveTierModel` has no cross-call caching (interleaved trivial/complex/trivial calls never bleed into each other) and asserting the standard/high/complex fallback-boundary contrast in one place (`standard` and `high` fall through to `defaultModel` on an OpenAI-only fixture with no mid-class model, `complex` does not) as a regression guard against this exact scope-of-claim mistake recurring silently.
19
+
20
+ ### Verified
21
+ - `bun test test/launch-prompt-routing.test.ts` — 38 pass / 0 fail (35 pre-existing + 3 new).
22
+ - `bun test test/prompt-router-tiers.test.ts test/prompt-router.test.ts` — 86 pass / 0 fail (84 pre-existing + 2 new).
23
+ - Full `bun test` — 2877 pass / 0 fail across 290 files.
24
+ - `bun run typecheck` — no errors.
25
+ - Live PTY re-verification (`bun src/cli.ts`, isolated `JEO_CONFIG_DIR`, real Anthropic credentials): trivial prompt → routed to `roles.smol`; `/model claude-sonnet-4-6` → next trivial prompt stayed pinned (no `[route]` notice, confirmed via raw byte-stream regex search, not just screen snapshot); `/model auto` → next trivial prompt re-routed to `roles.smol` again. Closes the one claim that was previously only inferred from a code comment.
26
+
27
+ ## [0.8.16] - 2026-07-09
28
+ _"jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind._
29
+
30
+ ### Added
31
+ - **`/computer [status|on|off]` slash command** (`src/commands/launch/computer-slash.ts`, `src/commands/launch.ts`, `src/tui/components/slash.ts`) — mirrors `/route`'s session-local-override pattern. `executeComputerAction` (`src/commands/computer.ts`) now accepts an `opts.enabledOverride` that wins over `config.computer.enabled` when set, and forwards through batched sub-actions. `/computer on|off` sets a REPL-local override for the current session only (never persisted); `/computer status` reports the effective on/off state and reminds the caller that the fail-closed `computerSupervisor` kill-switch/heartbeat gate still applies independently to every non-read-only action.
32
+ - **New mascot artwork** (`assets/character-v2.png`) — a second character illustration (god-tibo-imagen / `gti`, same crimson-shell/cyan-magenta-glasses/purple-DNA-robe identity as `character.png`) depicting the mascot piloting the desktop-automation control panel while juggling provider-routing nodes, tying the two headline features (prompt routing, computer use) into one image.
33
+
34
+ ### Verified
35
+ - `bun run typecheck` — no errors.
36
+ - `bun test test/computer-slash.test.ts test/computer.test.ts test/computer-supervisor.test.ts test/engine-computer-wiring.test.ts` — 25 pass / 0 fail (new `/computer` subcommand coverage + `enabledOverride` true/false end-to-end execution cases).
37
+ - `bun test test/slash.test.ts` — 30 pass / 0 fail (palette prefix-match fixture updated for the new `/computer` entry).
38
+ - Full `bun test` — 2843 pass / 1 pre-existing unrelated flake (`reachability veto: ...unreachable ollama model...`), 0 fail introduced by this change.
39
+
40
+
9
41
  ## [0.8.15] - 2026-07-09
10
42
  _"api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed._
11
43
 
package/README.ja.md CHANGED
@@ -90,6 +90,9 @@ jeo --tmux # 独立した tmux セッションで実行
90
90
  | `/theme` · `/config` · `/help` | テーマ、ランタイム設定、ヘルプ |
91
91
  | `jeo autopilot status` | スコア方向、keep/revert 数、次アクションを示す ratchet ステータスフィールド |
92
92
 
93
+ > [!CAUTION]
94
+ > **`/model <name>` で特定モデルを手動指定すると、そのセッション中ルーティングが固定されます。** プロンプトルーティング(`/route`)はモデルが手動固定されていない間のみ毎ターン再評価されます。`/model <name>` で特定モデルを選ぶとその選択が固定され、`/model auto`(ピンを完全に解除)を実行するか、`/route on`(ピンを消さずに優先度だけ上げる — `/route off` した瞬間ピンが復活)を実行するまでルーティングは切り替わりません。`roles.*` 未設定時に `defaultModel` へ確定フォールバックするのは `standard` ティアのみで、`high`/`complex` ティアはフォールバック前にライブでクレデンシャル済みの最強モデルを先に探索するため、未設定でも毎ターン別モデルに着地することがあります。
95
+
93
96
  ## Spec-first ワークフロー
94
97
 
95
98
  要件 → プラン → 承認 → 実行 → 検証が `.jeo/state/` で繋がり、各ハンドオフに**ブロック可能な本物のゲート**があります:
@@ -262,11 +265,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
262
265
  ## 変更履歴 (Changelog)
263
266
 
264
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
269
+ - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
265
270
  - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
266
271
  - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
267
272
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
268
- - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
269
- - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
270
273
 
271
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
275
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -91,6 +91,9 @@ jeo --tmux # 독립 tmux 세션에서 실행
91
91
  | `/theme` · `/config` · `/help` | 테마, 런타임 설정, 도움말 |
92
92
  | `jeo autopilot status` | 점수 방향, keep/revert 횟수, 다음 액션을 보여주는 ratchet 상태 필드 |
93
93
 
94
+ > [!CAUTION]
95
+ > **`/model <name>`으로 특정 모델을 수동 지정하면 그 세션 동안 라우팅이 고정됩니다.** 프롬프트 라우팅(`/route`)은 모델이 수동으로 고정되지 않은 동안만 매 턴 재평가됩니다. `/model <name>`으로 특정 모델을 선택하면 그 선택이 그대로 고정되며, `/model auto`(핀을 완전히 해제)를 실행하거나 `/route on`(핀을 지우지 않고 그보다 우선순위를 높임 — `/route off` 하는 즉시 핀이 다시 살아남)을 실행하기 전까지는 라우팅이 다시 전환되지 않습니다. `roles.*` 항목 미설정 시 `defaultModel`로 확정 폴백되는 건 `standard` 티어뿐이며, `high`/`complex` 티어는 폴백 전에 실시간으로 크레덴셜된 가장 강력한 모델을 먼저 탐색하므로, 설정이 없어도 매 턴 다른 모델로 갈 수 있습니다.
96
+
94
97
  ## Spec-first 워크플로
95
98
 
96
99
  요구사항 → 플랜 → 승인 → 실행 → 검증이 `.jeo/state/`로 이어지며, 모든 핸드오프에 **차단 가능한 진짜 게이트**가 있습니다:
@@ -263,11 +266,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
263
266
  ## 변경 이력 (Changelog)
264
267
 
265
268
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
269
+ - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
270
+ - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
266
271
  - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
267
272
  - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
268
273
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
269
- - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
270
- - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
271
274
 
272
275
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
273
276
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -17,8 +17,10 @@
17
17
 
18
18
  <p align="center">
19
19
  <img src="assets/character.gif" alt="animated jeo-code red crayfish mascot smart-routing a prompt to the cheapest provider and saving coins" width="320" />
20
+ <img src="assets/character-v2.png" alt="jeo-code red crayfish mascot piloting the computer-use desktop-automation control panel while juggling prompt-routing provider nodes" width="320" />
20
21
  </p>
21
22
 
23
+
22
24
  <p align="center">
23
25
  <b>English</b> ·
24
26
  <a href="README.ko.md">한국어</a> ·
@@ -38,7 +40,10 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
38
40
 
39
41
  ## Highlights
40
42
 
43
+ - **Prompt routing (cost-aware, credential-aware)** — every turn can auto-route to a tier-appropriate model among only the providers your configured credentials actually serve (`/route [status|on|off|why]`), with a live equivalent-model fallback whenever a routed provider is rate-limited, unauthenticated, unreachable, or silently times out — see `/route why` for the last decision.
44
+ - **Computer use (desktop automation)** — a fail-closed `computer` tool (screenshot/click/type/scroll/drag/batch) gated by both a config flag and an independent kill-switch/heartbeat supervisor; toggle it for the current session with `/computer [status|on|off]` without touching `~/.jeo/config.json`.
41
45
  - **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY`; if a configured route points at an unready provider, jeo switches to an equivalent credentialed tier model before falling back to the default.
46
+
42
47
  - **Edit integrity** — read output carries content anchors (`42ab|`); anchored edits are verified against the current file, re-mapped when lines shifted, and rejected with fresh content instead of corrupting.
43
48
  - **Self-correcting verification loop** — configure a post-edit hook (tsc / eslint / tests) and the agent *sees* the diagnostics and fixes them in-loop; a red hook blocks `done` until resolved.
44
49
  - **Real gates, no theater** — `ralplan` consensus is a repo-grounded critic subagent whose `[OKAY]` verdict is persisted and *required* by `jeo approve`; `ultragoal` reports honestly (a suite run is a global signal, never fabricated per-criterion passes).
@@ -91,6 +96,9 @@ Inside the `jeo` REPL (Tab autocompletes; `/` opens the palette).
91
96
  | `/theme` · `/config` · `/help` | Theme, runtime config, help |
92
97
  | `jeo autopilot status` | Ratchet status field with score direction, keep/revert counts, and next action |
93
98
 
99
+ > [!CAUTION]
100
+ > **`/model <name>` locks routing for the rest of the session.** Prompt routing (`/route`) only re-evaluates per turn while no model is manually pinned. Picking a specific model via `/model <name>` freezes that choice — routing will *not* switch away from it again until you run `/model auto` (which clears the pin), or `/route on` (which *outranks* an active pin without clearing it — the pin reasserts itself the moment you run `/route off`). Missing a `roles.*` entry only guarantees a `defaultModel` fallback on the `standard` tier; the `high`/`complex` tiers first scan for the strongest live-credentialed model before falling back, so they can still land on a different model each turn even when unconfigured.
101
+
94
102
  ## Spec-first workflow
95
103
 
96
104
  Requirements → plan → approval → execution → verification, carried through `.jeo/state/` with **real, blocking gates** at every handoff:
@@ -265,11 +273,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
265
273
  ## Changelog
266
274
 
267
275
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
276
+ - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
277
+ - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
268
278
  - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
269
279
  - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
270
280
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
271
- - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
272
- - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
273
281
 
274
282
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
283
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -90,6 +90,9 @@ jeo --tmux # 在独立 tmux 会话中运行
90
90
  | `/theme` · `/config` · `/help` | 主题、运行时配置、帮助 |
91
91
  | `jeo autopilot status` | 显示分数方向、keep/revert 次数和下一步动作的 ratchet 状态字段 |
92
92
 
93
+ > [!CAUTION]
94
+ > **通过 `/model <name>` 手动指定模型后,路由会在本会话内保持锁定。** Prompt routing(`/route`)只在没有手动锁定模型时才会逐轮重新评估。用 `/model <name>` 选定具体模型后,该选择会被锁定 — 直到你运行 `/model auto`(彻底解除锁定),或运行 `/route on`(不会清除锁定,只是优先级更高 — 一旦运行 `/route off`,锁定会立刻恢复)之前,路由都不会再切换。未配置 `roles.*` 条目时,只有 `standard` 层级会确定性地退回到 `defaultModel`;`high`/`complex` 层级会先实时扫描已配置凭证中最强的可用模型再决定是否回退,所以即使未配置,也可能每轮落到不同的模型上。
95
+
93
96
  ## Spec-first 工作流
94
97
 
95
98
  需求 → 计划 → 批准 → 执行 → 验证,经由 `.jeo/state/` 串联,每次交接都有**可阻断的真实门禁**:
@@ -262,11 +265,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
262
265
  ## 更新日志 (Changelog)
263
266
 
264
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
269
+ - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
265
270
  - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
266
271
  - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
267
272
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
268
- - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
269
- - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
270
273
 
271
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
275
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.15",
3
+ "version": "0.8.17",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -58,6 +58,8 @@ import type { AuthProvider } from "../auth";
58
58
 
59
59
  export type PromptTier = "trivial" | "standard" | "high" | "complex";
60
60
 
61
+ export const PROMPT_TIERS = ["trivial", "standard", "high", "complex"] as const satisfies readonly PromptTier[];
62
+
61
63
  export interface HeuristicResult {
62
64
  tier: PromptTier;
63
65
  confidence: number; // 0..1
@@ -93,7 +95,7 @@ const RE_CAUSAL_KR = /(왜\s|근본\s*원인|이유가|어떻게|이유|어째
93
95
  const RE_TRIVIAL_QUESTION_WORD = /\b(what|who|when|where|which)\b/i;
94
96
  const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s|인가요|있나요|냐|음\?|인가)/;
95
97
  const RE_CODE_FENCE = /```/;
96
- const RE_CODE_SNIPPET = /(?:const|let|var|import|export|function|class|if\s*\(|try\s*{|=>|\{\s*[\w\d_]+\s*\})/;
98
+ const RE_CODE_SNIPPET = /(?:\b(?:const|let|var)\s+\w+\s*=|\bimport\s+.*?\s+from\s+['"`\.\/]|\bexport\s+(?:const|let|var|function|class|default)\b|\b(?:function|class)\s+\w+|if\s*\(|try\s*{|=>|\{\s*[\w\d_]+\s*\})/;
97
99
  const RE_PATH_TOKEN = /(?:^|[\s"'`(])@?(?:\.{0,2}[\w-]*[\/\\])+[\w-]+(?:\.[\w-]+)*(?:\.[A-Za-z]\w{0,7}|[\/\\])\b/g;
98
100
  const RE_CLI_COMMAND = /(?:^|\s)(git|npm|bun|docker-compose|pip|cargo)\s+[a-z-_]+/i;
99
101
  const RE_DEEP_KEYWORDS_EN = /\b(design|architecture|refactor|debug|deep\s*dive|investigate|diagnose|redesign)\b/i;
@@ -118,12 +120,12 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
118
120
  signals.push("short-question");
119
121
  }
120
122
 
121
- // trivial signal 2: no code fence AND no code snippet AND no file path mention AND no CLI command.
123
+ // trivial signal 2: no code fence AND no code snippet AND no file path mention.
122
124
  const hasCodeFence = RE_CODE_FENCE.test(trimmed);
123
125
  const hasCodeSnippet = RE_CODE_SNIPPET.test(trimmed);
124
126
  const hasCliCommand = RE_CLI_COMMAND.test(trimmed);
125
127
  const pathMatches = [...trimmed.matchAll(RE_PATH_TOKEN)];
126
- if (!hasCodeFence && !hasCodeSnippet && !hasCliCommand && pathMatches.length === 0) {
128
+ if (!hasCodeFence && !hasCodeSnippet && pathMatches.length === 0) {
127
129
  trivialScore++;
128
130
  signals.push("no-code-no-path");
129
131
  }
@@ -261,6 +263,24 @@ function sizeClassFor(model: CatalogModel): "small" | "mid" | "large" | null {
261
263
  if (segments.includes("sonnet") || segments.includes("pro")) return "mid";
262
264
  return null;
263
265
  }
266
+ const SIZE_CLASS_TO_TIER: Record<"small" | "mid" | "large", PromptTier> = { small: "trivial", mid: "standard", large: "complex" };
267
+
268
+ /** Infers the `PromptTier` whose equivalence pool matches `model`'s size class — lets a
269
+ * NON-ROUTED turn (routing disabled, or the user pinned a model via `/model`) reuse the
270
+ * same cross-provider equivalent-pool fallback (`tierModelPool`/`equivalentRouteFallback`
271
+ * in launch.ts) that a routed turn already gets on a usage-limit/rate-limit/credential
272
+ * failure — without silently downgrading a pinned flagship (e.g. opus) to a cheap
273
+ * trivial-tier substitute, or promoting a pinned cheap model to an expensive one. `null`
274
+ * sizeClass (no size-tier suffix — gpt-5.5/o3/grok-4.3/kimi-* / glm-* / …) and an
275
+ * uncatalogued model id both default to "standard" (the mid pool) as a reasonable
276
+ * middle ground rather than guessing complex/trivial. */
277
+ export function inferTierForModel(model: string): PromptTier {
278
+ const meta = catalogMetadata(model);
279
+ if (!meta) return "standard";
280
+ const cls = sizeClassFor(meta);
281
+ return cls ? SIZE_CLASS_TO_TIER[cls] : "standard";
282
+ }
283
+
264
284
 
265
285
  const TIER_TO_SIZE_CLASS: Record<PromptTier, "small" | "mid" | "large"> = { trivial: "small", standard: "mid", high: "mid", complex: "large" };
266
286
 
@@ -455,9 +475,30 @@ function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLeve
455
475
  return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
456
476
  }
457
477
 
458
- const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "high", "complex"];
478
+ /** Apply one interactive `/model` pick to a PromptRouter tier without touching the
479
+ * global model registry. The write enables routing because a configured tier is
480
+ * otherwise invisible until `routing.enabled` is set. */
481
+ export function withRoutingTierSetting(
482
+ config: Pick<Config, "routing">,
483
+ tier: PromptTier,
484
+ patch: { model: string; thinking?: ThinkLevel },
485
+ ): NonNullable<Config["routing"]> {
486
+ return {
487
+ ...(config.routing ?? {}),
488
+ enabled: true,
489
+ tiers: {
490
+ ...(config.routing?.tiers ?? {}),
491
+ [tier]: {
492
+ ...(config.routing?.tiers?.[tier] ?? {}),
493
+ model: patch.model,
494
+ thinking: patch.thinking,
495
+ },
496
+ },
497
+ };
498
+ }
499
+
459
500
  function isPromptTier(value: unknown): value is PromptTier {
460
- return typeof value === "string" && (VALID_TIERS as readonly string[]).includes(value);
501
+ return typeof value === "string" && (PROMPT_TIERS as readonly string[]).includes(value);
461
502
  }
462
503
 
463
504
  /** Hard cap on how much of the prompt is embedded in the escalation classifier call —
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Routing decision history tracker — maintains a bounded FIFO queue of recent
3
+ * routing decisions for `/route history` visibility and performance analysis.
4
+ *
5
+ * Session-scoped (one per REPL), not persisted to config.
6
+ */
7
+
8
+ import type { RouteDecision } from "./prompt-router";
9
+
10
+ export interface RouteHistoryEntry extends RouteDecision {
11
+ timestamp: number; // Date.now()
12
+ turnNumber: number; // 1-indexed turn in this session
13
+ }
14
+
15
+ /**
16
+ * Bounded FIFO queue of routing decisions. Oldest entries are dropped when
17
+ * the queue exceeds `maxSize`.
18
+ */
19
+ export class RouteHistory {
20
+ private entries: RouteHistoryEntry[] = [];
21
+ private turnNumber = 0;
22
+ readonly maxSize: number;
23
+
24
+ constructor(maxSize: number = 10) {
25
+ this.maxSize = Math.max(1, maxSize);
26
+ }
27
+
28
+ /**
29
+ * Record a routing decision. Increments the turn counter and adds the entry
30
+ * to the queue, dropping the oldest if necessary.
31
+ */
32
+ add(decision: RouteDecision): void {
33
+ this.turnNumber++;
34
+ const entry: RouteHistoryEntry = {
35
+ ...decision,
36
+ timestamp: Date.now(),
37
+ turnNumber: this.turnNumber,
38
+ };
39
+ this.entries.push(entry);
40
+ if (this.entries.length > this.maxSize) {
41
+ this.entries.shift();
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get all recorded entries in chronological order (oldest first).
47
+ */
48
+ getAll(): RouteHistoryEntry[] {
49
+ return [...this.entries];
50
+ }
51
+
52
+ /**
53
+ * Get the most recent entry, if any.
54
+ */
55
+ getLast(): RouteHistoryEntry | undefined {
56
+ return this.entries[this.entries.length - 1];
57
+ }
58
+
59
+ /**
60
+ * Get entries for a specific model.
61
+ */
62
+ getByModel(model: string): RouteHistoryEntry[] {
63
+ return this.entries.filter((e) => e.model === model);
64
+ }
65
+
66
+ /**
67
+ * Get entries for a specific tier.
68
+ */
69
+ getByTier(tier: string): RouteHistoryEntry[] {
70
+ return this.entries.filter((e) => e.tier === tier);
71
+ }
72
+
73
+ /**
74
+ * Compute statistics over the history.
75
+ */
76
+ getStats(): {
77
+ totalDecisions: number;
78
+ modelFrequency: Record<string, number>;
79
+ tierFrequency: Record<string, number>;
80
+ averageConfidence: number;
81
+ } {
82
+ const modelFreq: Record<string, number> = {};
83
+ const tierFreq: Record<string, number> = {};
84
+ let totalConfidence = 0;
85
+
86
+ for (const entry of this.entries) {
87
+ modelFreq[entry.model] = (modelFreq[entry.model] ?? 0) + 1;
88
+ tierFreq[entry.tier] = (tierFreq[entry.tier] ?? 0) + 1;
89
+ totalConfidence += entry.confidence;
90
+ }
91
+
92
+ return {
93
+ totalDecisions: this.entries.length,
94
+ modelFrequency: modelFreq,
95
+ tierFrequency: tierFreq,
96
+ averageConfidence: this.entries.length > 0 ? totalConfidence / this.entries.length : 0,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Clear all entries.
102
+ */
103
+ clear(): void {
104
+ this.entries = [];
105
+ this.turnNumber = 0;
106
+ }
107
+ }
@@ -16,6 +16,9 @@ import architectPrompt from "../prompts/agents/architect.md" with { type: "text"
16
16
  import criticPrompt from "../prompts/agents/critic.md" with { type: "text" };
17
17
  import executorPrompt from "../prompts/agents/executor.md" with { type: "text" };
18
18
  import plannerPrompt from "../prompts/agents/planner.md" with { type: "text" };
19
+ import type { PromptTier } from "./prompt-router";
20
+
21
+ const PROMPT_ROUTE_TIERS = ["trivial", "standard", "high", "complex"] as const satisfies readonly PromptTier[];
19
22
 
20
23
  export interface SubagentRole {
21
24
  /** Stable id used in config + `/agents <id>`. */
@@ -362,9 +365,9 @@ export function clearSubagentSetting(config: Pick<Config, "subagents">, roleId:
362
365
  return subs;
363
366
  }
364
367
 
365
- /** One pickable apply-target: the global default or a subagent role. */
368
+ /** One pickable apply-target: the global default, a prompt-routing tier, or a subagent role. */
366
369
  export interface ApplyTargetChoice {
367
- /** "default" or a subagent role id. */
370
+ /** "default", "routing:<tier>", or a subagent role id. */
368
371
  value: string;
369
372
  label: string;
370
373
  /** Right-aligned hint: the target's CURRENT model (so the picker doubles as a viewer). */
@@ -378,18 +381,32 @@ export interface ApplyTargetChoice {
378
381
  * read-and-change panel for existing role assignments. Pure — testable.
379
382
  */
380
383
  export function applyTargetChoices(
381
- config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">,
384
+ config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel" | "routing" | "roles"> &
385
+ Partial<Pick<Config, "providers" | "oauth" | "openaiBaseUrl">>,
382
386
  ): ApplyTargetChoice[] {
383
387
  const roleThink = (id: string): string => {
384
388
  const t = resolveSubagentThinking(id, config);
385
389
  return t ? ` (${t})` : " (inherit)";
386
390
  };
391
+ const routeThink = (tier: PromptTier): string => {
392
+ const t = config.routing?.tiers?.[tier]?.thinking;
393
+ return t ? ` (${t})` : " (auto thinking)";
394
+ };
395
+ // Lazy load keeps the existing subagents→prompt-router dependency from
396
+ // becoming an eager module cycle; resolveSubagentModel uses the same pattern.
397
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
398
+ const { resolveTierModel } = require("./prompt-router");
387
399
  return [
388
400
  {
389
401
  value: "default",
390
402
  label: "default — every session",
391
403
  hint: `${config.defaultModel} (${config.thinkingLevel ?? "medium"})`,
392
404
  },
405
+ ...PROMPT_ROUTE_TIERS.map(tier => ({
406
+ value: `routing:${tier}`,
407
+ label: `route ${tier} — PromptRouter tier`,
408
+ hint: `${config.routing?.tiers?.[tier]?.model ?? resolveTierModel(tier, config)}${config.routing?.tiers?.[tier]?.model ? "" : " (auto)"}${routeThink(tier)}`,
409
+ })),
393
410
  ...allSubagentRoles(config).map(role => ({
394
411
  value: role.id,
395
412
  label: `subagent ${role.id} — ${role.title}`,
@@ -1,5 +1,7 @@
1
1
  import type { ProviderAdapter, CallOptions, ProviderName } from "../types";
2
2
  import { anthropicAdapter } from "./anthropic";
3
+ import { relabelProviderError } from "./errors";
4
+ import { companyLabel } from "../model-catalog";
3
5
 
4
6
  /**
5
7
  * Factory for Anthropic-Messages-compatible providers (z.ai, MiniMax, …). They speak
@@ -11,17 +13,32 @@ import { anthropicAdapter } from "./anthropic";
11
13
  */
12
14
  export function makeAnthropicCompatibleAdapter(opts: { name: ProviderName; baseUrl: string }): ProviderAdapter {
13
15
  const prefix = `${opts.name}/`;
16
+ const label = companyLabel(opts.name);
14
17
  const prep = (o: CallOptions): CallOptions => ({
15
18
  ...o,
16
19
  model: o.model.startsWith(prefix) ? o.model.slice(prefix.length) : o.model,
17
20
  baseUrl: o.baseUrl ?? opts.baseUrl,
18
21
  });
22
+ // `anthropicAdapter` hardcodes "Anthropic" as the provider label on every thrown
23
+ // ProviderHttpError/ProviderStreamError — relabel to the REAL backend (e.g. "Tencent",
24
+ // "z.ai") so friendlyProviderError/the fallback classifier/the user all see the true
25
+ // account that needs attention (auth, billing, rate limit) instead of Anthropic's.
19
26
  return {
20
27
  name: opts.name,
21
28
  supportsNativeTools: anthropicAdapter.supportsNativeTools,
22
- call: (messages, options, credential) => anthropicAdapter.call(messages, prep(options), credential),
29
+ call: async (messages, options, credential) => {
30
+ try {
31
+ return await anthropicAdapter.call(messages, prep(options), credential);
32
+ } catch (err) {
33
+ throw relabelProviderError(err, label);
34
+ }
35
+ },
23
36
  async *stream(messages, options, credential) {
24
- yield* anthropicAdapter.stream!(messages, prep(options), credential);
37
+ try {
38
+ yield* anthropicAdapter.stream!(messages, prep(options), credential);
39
+ } catch (err) {
40
+ throw relabelProviderError(err, label);
41
+ }
25
42
  },
26
43
  };
27
44
  }
@@ -12,12 +12,20 @@ export class ProviderHttpError extends Error {
12
12
  readonly provider: string;
13
13
  /** Server-directed backoff from a `Retry-After` header, in ms (if present). */
14
14
  readonly retryAfterMs?: number;
15
+ /** Raw response body (or synthesized detail text) — kept separately so a caller can
16
+ * reconstruct this error under a DIFFERENT provider label (see `relabelProviderError`)
17
+ * without re-parsing it back out of the composed `.message`. */
18
+ readonly detail: string;
19
+ /** Optional context suffix (e.g. "(stream)") — see `detail`'s rationale. */
20
+ readonly context?: string;
15
21
  constructor(provider: string, status: number, detail: string, context?: string, retryAfterMs?: number) {
16
22
  super(`${provider} request failed (HTTP ${status})${context ? ` ${context}` : ""}: ${detail}`);
17
23
  this.name = "ProviderHttpError";
18
24
  this.status = status;
19
25
  this.provider = provider;
20
26
  this.retryAfterMs = retryAfterMs;
27
+ this.detail = detail;
28
+ this.context = context;
21
29
  }
22
30
  }
23
31
 
@@ -39,6 +47,10 @@ export class ProviderStreamError extends Error {
39
47
  readonly status: number;
40
48
  readonly provider: string;
41
49
  readonly code?: string;
50
+ /** Raw stream-error message (pre-composition) — see `ProviderHttpError.detail`'s
51
+ * rationale; lets `relabelProviderError` reconstruct this error under a different
52
+ * provider label without re-parsing `.message`. */
53
+ readonly rawMessage: string;
42
54
  /** `explicitStatus` lets a caller pass an ALREADY-numeric provider status (e.g. Google's
43
55
  * `error.code` on a `google.rpc.Status` envelope — 429/500/503) instead of relying on the
44
56
  * OpenAI-specific `code === "rate_limit_exceeded"` string heuristic below, which only
@@ -49,9 +61,31 @@ export class ProviderStreamError extends Error {
49
61
  this.status = explicitStatus ?? (code === "rate_limit_exceeded" ? 429 : 500);
50
62
  this.provider = provider;
51
63
  this.code = code;
64
+ this.rawMessage = message;
52
65
  }
53
66
  }
54
67
 
68
+ /** Rebuild `err` under a DIFFERENT provider label — both `.provider` and the composed
69
+ * `.message` are corrected, not just the field (a caller matching on message text, or
70
+ * a user reading the raw error, must see the TRUE provider either way). Used by the
71
+ * Anthropic/OpenAI-compatible adapter factories (`makeAnthropicCompatibleAdapter`,
72
+ * `makeOpenAICompatibleAdapter`): they delegate to `anthropicAdapter`/`openaiAdapter`,
73
+ * which hardcode "Anthropic"/"OpenAI" at their `ProviderHttpError`/`ProviderStreamError`
74
+ * construction sites — so a groq/tencent/zai/deepseek/… failure previously surfaced as
75
+ * "Anthropic rejected the credential" or "OpenAI requires billing", sending the user to
76
+ * fix the WRONG account. Non-`ProviderHttpError`/`ProviderStreamError` errors (bare
77
+ * `Error`, network failures, etc.) pass through unchanged — those never carried a
78
+ * hardcoded label to begin with. */
79
+ export function relabelProviderError(err: unknown, provider: string): unknown {
80
+ if (err instanceof ProviderHttpError) {
81
+ return new ProviderHttpError(provider, err.status, err.detail, err.context, err.retryAfterMs);
82
+ }
83
+ if (err instanceof ProviderStreamError) {
84
+ return new ProviderStreamError(provider, err.rawMessage, err.code, err.status);
85
+ }
86
+ return err;
87
+ }
88
+
55
89
  /**
56
90
  * Parse a `Retry-After` header into ms. Supports the delta-seconds form
57
91
  * (`"5"`) and the HTTP-date form (`"Wed, 21 Oct 2025 07:28:00 GMT"`).
@@ -3,6 +3,8 @@ import { getKimiCommonHeaders } from "../../auth/flows/kimi";
3
3
  import type { CallOptions, ProviderAdapter } from "../types";
4
4
  import { anthropicAdapter } from "./anthropic";
5
5
  import { makeOpenAICompatibleAdapter } from "./openai-compatible";
6
+ import { relabelProviderError } from "./errors";
7
+ import { companyLabel } from "../model-catalog";
6
8
 
7
9
  /**
8
10
  * Kimi (Moonshot) — two credential-dependent backends (gjc parity):
@@ -18,6 +20,7 @@ export const KIMI_BASE_URL = "https://api.moonshot.ai/v1";
18
20
  export const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
19
21
 
20
22
  const openaiCompatKimi = makeOpenAICompatibleAdapter({ name: "kimi", baseUrl: KIMI_BASE_URL });
23
+ const KIMI_LABEL = companyLabel("kimi");
21
24
 
22
25
  /** Route the `kimi/` prefix + OAuth base/headers onto the anthropic adapter. */
23
26
  // ponytail: model ids pass through untranslated — the moonshot catalog ids (kimi-latest, …)
@@ -33,19 +36,31 @@ function prepOAuth(o: CallOptions): CallOptions {
33
36
  }
34
37
 
35
38
  /** Dispatch on credential kind: OAuth → Kimi Code Anthropic endpoint; else the
36
- * original OpenAI-compatible moonshot adapter (API-key behavior unchanged). */
39
+ * original OpenAI-compatible moonshot adapter (API-key behavior unchanged).
40
+ * The OAuth path delegates to `anthropicAdapter`, which hardcodes "Anthropic" at
41
+ * its `ProviderHttpError`/`ProviderStreamError` construction sites — relabel to
42
+ * "Moonshot" (same fix as `makeAnthropicCompatibleAdapter`/`makeOpenAICompatibleAdapter`)
43
+ * so a Kimi Code failure never sends the user to fix their Anthropic account. */
37
44
  export const kimiAdapter: ProviderAdapter = {
38
45
  name: "kimi",
39
46
  supportsNativeTools: openaiCompatKimi.supportsNativeTools,
40
- call: (messages, options, credential) =>
41
- credential.kind === "oauth"
42
- ? anthropicAdapter.call(messages, prepOAuth(options), credential)
43
- : openaiCompatKimi.call(messages, options, credential),
47
+ call: async (messages, options, credential) => {
48
+ if (credential.kind !== "oauth") return openaiCompatKimi.call(messages, options, credential);
49
+ try {
50
+ return await anthropicAdapter.call(messages, prepOAuth(options), credential);
51
+ } catch (err) {
52
+ throw relabelProviderError(err, KIMI_LABEL);
53
+ }
54
+ },
44
55
  async *stream(messages, options, credential) {
45
- if (credential.kind === "oauth") {
46
- yield* anthropicAdapter.stream!(messages, prepOAuth(options), credential);
47
- } else {
56
+ if (credential.kind !== "oauth") {
48
57
  yield* openaiCompatKimi.stream!(messages, options, credential);
58
+ return;
59
+ }
60
+ try {
61
+ yield* anthropicAdapter.stream!(messages, prepOAuth(options), credential);
62
+ } catch (err) {
63
+ throw relabelProviderError(err, KIMI_LABEL);
49
64
  }
50
65
  },
51
66
  };