jeo-code 0.8.13 → 0.8.15
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/prompt-router.ts +32 -24
- package/src/ai/model-catalog.ts +116 -0
- package/src/ai/model-discovery.ts +5 -4
- package/src/commands/launch.ts +35 -16
- package/src/tui/app.ts +10 -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.8.15] - 2026-07-09
|
|
10
|
+
_"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
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **API-authenticated live model catalogs now feed routing** (`src/ai/model-catalog.ts`, `src/ai/model-discovery.ts`, `src/agent/prompt-router.ts`) — successful provider `/models` responses are recorded in a session-lifetime live-provider supplement separate from the OpenAI OAuth Codex allow-list. API-key OpenAI and OpenAI-compatible clouds (Groq, OpenRouter, custom base URLs, etc.) can now contribute live model rows to `tierModelPool()`, `cheapestCredentialed()`, and `strongestCredentialed()` without waiting for a static release.
|
|
14
|
+
- **Custom OpenAI base URLs no longer auto-select unrelated public OpenAI rows** (`src/agent/prompt-router.ts`) — when `openaiBaseUrl` is configured, routing excludes static public OpenAI catalog rows unless the current base URL's own `/models` response observed that id. Explicit user pins still work as manual overrides, but automatic routing now only chooses models the configured endpoint actually exposed.
|
|
15
|
+
- **Co-resident TUI instances no longer trip listener-leak warnings** (`src/tui/app.ts`, `test/tui-app.test.ts`) — resize/SIGCONT listener ceilings are raised only to the observed in-process TUI fan-out, and the backpressure test now calls `finish()` so its cleanup path matches production.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **API-auth model regression coverage** (`test/model-discovery.test.ts`, `test/prompt-router.test.ts`) — added tests proving API-key OpenAI discovery records live routing rows without widening `isCodexModel()`, custom base URLs do not inherit public `gpt-*` pools before discovery, recorded custom-base models route as `openai/...`, and OpenAI-compatible provider models enter pools under their provider prefix.
|
|
19
|
+
|
|
20
|
+
### Verified
|
|
21
|
+
- Focused model/routing suite: `bun test test/model-discovery.test.ts test/prompt-router.test.ts test/model-manager.test.ts` — 132 pass / 0 fail.
|
|
22
|
+
- Typecheck: `bun run typecheck` — pass.
|
|
23
|
+
- Full suite: `bun test` — 2814 pass / 0 fail.
|
|
24
|
+
- Package tarball smoke: `npm pack --dry-run` — produced `jeo-code-0.8.15.tgz`.
|
|
25
|
+
|
|
26
|
+
## [0.8.14] - 2026-07-09
|
|
27
|
+
_"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._
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **Post-call routing recovery now tries equivalent tier fallbacks** (`src/commands/launch.ts`) — after a routed model ends without a usable answer for recoverable reasons (`returned no content`, rate/usage limit, model unavailable/not found, or agent protocol dead-end), jeo now walks the same `tierModelPool()` equivalent model set used by the pre-call veto path, skips models already attempted this turn, preserves per-model cache keys, emits a `[route]` notice, updates `/route why` with the actual replacement, and retries the turn. Deterministic budget/safety cases (`max_output_tokens`, `finish_reason=length`, content filter/refusal/safety categories) intentionally do not reroute.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Post-call reroute regression coverage** (`test/launch-prompt-routing.test.ts`) — added tests for plain OpenAI no-content recovery to a same-tier fallback, continued traversal when the first fallback also fails recoverably, and no reroute for deterministic output-budget no-content.
|
|
34
|
+
|
|
35
|
+
### Verified
|
|
36
|
+
- Focused routing suite: `bun test test/launch-prompt-routing.test.ts` — 26 pass / 0 fail.
|
|
37
|
+
|
|
9
38
|
## [0.8.13] - 2026-07-09
|
|
10
39
|
_"테스트검증 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._
|
|
11
40
|
|
package/README.ja.md
CHANGED
|
@@ -262,11 +262,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
262
262
|
## 変更履歴 (Changelog)
|
|
263
263
|
|
|
264
264
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
265
|
+
- **[0.8.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
|
+
- **[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.
|
|
265
267
|
- **[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.
|
|
266
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.
|
|
267
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.
|
|
268
|
-
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
269
|
-
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
270
270
|
|
|
271
271
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
272
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -263,11 +263,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
263
263
|
## 변경 이력 (Changelog)
|
|
264
264
|
|
|
265
265
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
266
|
+
- **[0.8.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
|
+
- **[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.
|
|
266
268
|
- **[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.
|
|
267
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.
|
|
268
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.
|
|
269
|
-
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
270
|
-
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
271
271
|
|
|
272
272
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
273
273
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -265,11 +265,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
265
265
|
## Changelog
|
|
266
266
|
|
|
267
267
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
268
|
+
- **[0.8.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
|
+
- **[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
270
|
- **[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
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.
|
|
270
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.
|
|
271
|
-
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
272
|
-
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
273
273
|
|
|
274
274
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
275
275
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -262,11 +262,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
262
262
|
## 更新日志 (Changelog)
|
|
263
263
|
|
|
264
264
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
265
|
+
- **[0.8.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
|
+
- **[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.
|
|
265
267
|
- **[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.
|
|
266
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.
|
|
267
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.
|
|
268
|
-
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
269
|
-
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
270
270
|
|
|
271
271
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
272
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -49,11 +49,10 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { callLlm } from "./loop";
|
|
51
51
|
import { resolveRoleModel, modelServableWithConfig } from "../ai/model-manager";
|
|
52
|
-
import { catalogMetadata, MODEL_CATALOG, compareReleaseDate, type CatalogModel } from "../ai/model-catalog";
|
|
52
|
+
import { catalogMetadata, MODEL_CATALOG, compareReleaseDate, liveProviderCatalogModels, isLiveProviderModel, type CatalogModel, type ThinkLevel } from "../ai/model-catalog";
|
|
53
53
|
import { priceForModel } from "../ai/pricing";
|
|
54
54
|
import { tryExtractJsonObject } from "./json";
|
|
55
55
|
import type { Config } from "./state";
|
|
56
|
-
import type { ThinkLevel } from "../ai/model-catalog";
|
|
57
56
|
import type { ProviderName } from "../ai/types";
|
|
58
57
|
import type { AuthProvider } from "../auth";
|
|
59
58
|
|
|
@@ -90,24 +89,17 @@ export interface RoutePromptOptions {
|
|
|
90
89
|
// --- Frozen, bilingual-validated classification signals (do not re-derive). ---
|
|
91
90
|
|
|
92
91
|
const RE_CAUSAL = /\b(why|how|root cause|how come)\b/i;
|
|
93
|
-
const RE_CAUSAL_KR = /(왜\s|근본\s
|
|
92
|
+
const RE_CAUSAL_KR = /(왜\s|근본\s*원인|이유가|어떻게|이유|어째서|원인|해결책|해결방안)/;
|
|
94
93
|
const RE_TRIVIAL_QUESTION_WORD = /\b(what|who|when|where|which)\b/i;
|
|
95
|
-
const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s
|
|
94
|
+
const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s|인가요|있나요|냐|음\?|인가)/;
|
|
96
95
|
const RE_CODE_FENCE = /```/;
|
|
97
|
-
const
|
|
96
|
+
const RE_CODE_SNIPPET = /(?:const|let|var|import|export|function|class|if\s*\(|try\s*{|=>|\{\s*[\w\d_]+\s*\})/;
|
|
97
|
+
const RE_PATH_TOKEN = /(?:^|[\s"'`(])@?(?:\.{0,2}[\w-]*[\/\\])+[\w-]+(?:\.[\w-]+)*(?:\.[A-Za-z]\w{0,7}|[\/\\])\b/g;
|
|
98
|
+
const RE_CLI_COMMAND = /(?:^|\s)(git|npm|bun|docker-compose|pip|cargo)\s+[a-z-_]+/i;
|
|
98
99
|
const RE_DEEP_KEYWORDS_EN = /\b(design|architecture|refactor|debug|deep\s*dive|investigate|diagnose|redesign)\b/i;
|
|
99
100
|
const RE_DEEP_KEYWORDS_KR = /(설계|아키텍처|리팩터|디버그|딥\s*다이브|딥다이브|진단)/;
|
|
100
|
-
const RE_SENTENCE_BOUNDARY = /[.!?][ \n]|[
|
|
101
|
+
const RE_SENTENCE_BOUNDARY = /[.!?][ \n]|[.!?.]$/g;
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
-
* Pure, synchronous, zero-I/O prompt classification. Every branch and threshold
|
|
104
|
-
* here is frozen per the PromptRouter contract (validated against a 17+-case
|
|
105
|
-
* bilingual corpus) — in particular: a causal ("why"/"how"/"왜"/"어떻게") question
|
|
106
|
-
* is NEVER treated as trivial just because it's short and question-shaped (a
|
|
107
|
-
* debugging question is the opposite of trivial), and the path-token regex is
|
|
108
|
-
* anchored so numeric fractions like "3/4.5" or "10/10" never false-positive as
|
|
109
|
-
* a file path (the trailing segment must end in a letter-led extension).
|
|
110
|
-
*/
|
|
111
103
|
export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
112
104
|
const trimmed = prompt.trim();
|
|
113
105
|
const signals: string[] = [];
|
|
@@ -126,14 +118,22 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
|
126
118
|
signals.push("short-question");
|
|
127
119
|
}
|
|
128
120
|
|
|
129
|
-
// trivial signal 2: no code fence AND no file path mention.
|
|
121
|
+
// trivial signal 2: no code fence AND no code snippet AND no file path mention AND no CLI command.
|
|
130
122
|
const hasCodeFence = RE_CODE_FENCE.test(trimmed);
|
|
123
|
+
const hasCodeSnippet = RE_CODE_SNIPPET.test(trimmed);
|
|
124
|
+
const hasCliCommand = RE_CLI_COMMAND.test(trimmed);
|
|
131
125
|
const pathMatches = [...trimmed.matchAll(RE_PATH_TOKEN)];
|
|
132
|
-
if (!hasCodeFence && pathMatches.length === 0) {
|
|
126
|
+
if (!hasCodeFence && !hasCodeSnippet && !hasCliCommand && pathMatches.length === 0) {
|
|
133
127
|
trivialScore++;
|
|
134
128
|
signals.push("no-code-no-path");
|
|
135
129
|
}
|
|
136
130
|
|
|
131
|
+
// trivial signal 3: explicit CLI command (usually simple lookup/execution)
|
|
132
|
+
if (hasCliCommand) {
|
|
133
|
+
trivialScore++;
|
|
134
|
+
signals.push("cli-command");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
137
|
// complex signal 1: deep-work keyword.
|
|
138
138
|
if (RE_DEEP_KEYWORDS_EN.test(trimmed) || RE_DEEP_KEYWORDS_KR.test(trimmed)) {
|
|
139
139
|
complexScore++;
|
|
@@ -160,7 +160,6 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
|
160
160
|
complexScore++;
|
|
161
161
|
signals.push("multi-file");
|
|
162
162
|
}
|
|
163
|
-
|
|
164
163
|
// Tier + confidence resolution (frozen thresholds).
|
|
165
164
|
if (trivialScore > 0 && complexScore > 0) {
|
|
166
165
|
return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
|
|
@@ -202,7 +201,7 @@ export function resetPromptRouterWarnings(): void {
|
|
|
202
201
|
* `undefined`. Production callers (`readGlobalConfig()`'s real `Config`) always
|
|
203
202
|
* provide both. */
|
|
204
203
|
export type RoutingConfig = Pick<Config, "defaultModel" | "roles" | "routing"> &
|
|
205
|
-
Partial<Pick<Config, "providers" | "oauth">>;
|
|
204
|
+
Partial<Pick<Config, "providers" | "oauth" | "openaiBaseUrl">>;
|
|
206
205
|
|
|
207
206
|
/** Cloud providers eligible for cross-provider auto-select (excludes local
|
|
208
207
|
* ollama/lmstudio, which are never a sensible "route to the cheapest/strongest"
|
|
@@ -221,10 +220,19 @@ function hasAntigravityOauth(config: RoutingConfig): boolean {
|
|
|
221
220
|
return !!(config.oauth?.antigravity || config.oauth?.gemini);
|
|
222
221
|
}
|
|
223
222
|
|
|
223
|
+
function routingCatalog(config: RoutingConfig): CatalogModel[] {
|
|
224
|
+
const rows = [...MODEL_CATALOG, ...liveProviderCatalogModels(config)];
|
|
225
|
+
const eligible = rows.filter(m => isAutoSelectCandidate(m, config));
|
|
226
|
+
const byId = new Map<string, CatalogModel>();
|
|
227
|
+
for (const row of eligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
|
|
228
|
+
return [...byId.values()];
|
|
229
|
+
}
|
|
230
|
+
|
|
224
231
|
function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
|
|
225
232
|
// Shared model-LEVEL credential gate (the full auth matrix is documented on
|
|
226
233
|
// modelServableWithConfig; keep aligned with resolveCall).
|
|
227
234
|
if (!isCloudProvider(m.provider) || m.limitedAvailability || !modelServableWithConfig(m.provider, m.canonical, config)) return false;
|
|
235
|
+
if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
|
|
228
236
|
if (hasAntigravityOauth(config) && m.provider === "gemini") return false;
|
|
229
237
|
return true;
|
|
230
238
|
}
|
|
@@ -280,7 +288,7 @@ function compareStrengthAscending(a: CatalogModel, b: CatalogModel): number {
|
|
|
280
288
|
* for deterministic pool ordering (required by `selectFromPool`'s index math). */
|
|
281
289
|
export function tierModelPool(tier: PromptTier, config: RoutingConfig): string[] {
|
|
282
290
|
const targetClass = TIER_TO_SIZE_CLASS[tier];
|
|
283
|
-
const credentialed =
|
|
291
|
+
const credentialed = routingCatalog(config);
|
|
284
292
|
|
|
285
293
|
const suffixPool = credentialed.filter(m => sizeClassFor(m) === targetClass);
|
|
286
294
|
|
|
@@ -329,7 +337,7 @@ export function selectFromPool(pool: readonly string[], sessionId: string | unde
|
|
|
329
337
|
export function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
330
338
|
let best: CatalogModel | null = null;
|
|
331
339
|
let bestCost = Infinity;
|
|
332
|
-
for (const m of
|
|
340
|
+
for (const m of routingCatalog(config)) {
|
|
333
341
|
// Candidate eligibility is centralized in `isAutoSelectCandidate`: it keeps
|
|
334
342
|
// OAuth-backed Antigravity routing on provider-qualified 3.1+ Gemini rows and
|
|
335
343
|
// prevents public `gemini` catalog rows from winning when Antigravity OAuth is
|
|
@@ -377,7 +385,7 @@ export function strongestCredentialed(
|
|
|
377
385
|
filter?: (m: CatalogModel) => boolean
|
|
378
386
|
): string | null {
|
|
379
387
|
let best: CatalogModel | null = null;
|
|
380
|
-
for (const m of
|
|
388
|
+
for (const m of routingCatalog(config)) {
|
|
381
389
|
// Candidate eligibility is centralized in `isAutoSelectCandidate` (see
|
|
382
390
|
// cheapestCredentialed above for the Antigravity/Gemini OAuth rationale).
|
|
383
391
|
if (!isAutoSelectCandidate(m, config)) continue;
|
|
@@ -406,8 +414,8 @@ export function strongestCredentialed(
|
|
|
406
414
|
* mid-class-suffixed model is credentialed (caller falls through to
|
|
407
415
|
* `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
|
|
408
416
|
function strongestMidTierCredentialed(config: RoutingConfig): string | null {
|
|
409
|
-
const midClass =
|
|
410
|
-
m =>
|
|
417
|
+
const midClass = routingCatalog(config).filter(
|
|
418
|
+
m => sizeClassFor(m) === "mid",
|
|
411
419
|
);
|
|
412
420
|
if (midClass.length === 0) return null;
|
|
413
421
|
return midClass.sort(compareStrengthAscending).at(-1)!.canonical;
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -222,6 +222,122 @@ export function resetLiveCodexModels(): void {
|
|
|
222
222
|
liveCodexModels.clear();
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/** Live provider model ids observed from authenticated/keyless `/models` endpoints during
|
|
226
|
+
* this process. Unlike `liveCodexModels`, this is NOT an OAuth allow-list; it is a
|
|
227
|
+
* routing supplement so API-key and OpenAI-compatible catalogs discovered at runtime
|
|
228
|
+
* can participate in prompt routing without waiting for a static release. */
|
|
229
|
+
type LiveProviderModelSource = "oauth" | "api_key" | "keyless" | "none";
|
|
230
|
+
interface LiveProviderModelRecord {
|
|
231
|
+
row: CatalogModel;
|
|
232
|
+
source: LiveProviderModelSource;
|
|
233
|
+
baseUrl?: string;
|
|
234
|
+
}
|
|
235
|
+
const liveProviderModels = new Map<string, LiveProviderModelRecord>();
|
|
236
|
+
|
|
237
|
+
function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
|
|
238
|
+
return baseUrl?.replace(/\/+$/, "");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function stripProviderPrefix(provider: ProviderName, id: string): string {
|
|
242
|
+
const prefix = `${provider}/`;
|
|
243
|
+
if (id.startsWith(prefix)) return id.slice(prefix.length);
|
|
244
|
+
if (provider === "openai" && id.startsWith("openai/")) return id.slice("openai/".length);
|
|
245
|
+
if (provider === "gemini" && id.startsWith("google/")) return id.slice("google/".length);
|
|
246
|
+
return id;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function liveCanonicalId(provider: ProviderName, id: string): string {
|
|
250
|
+
const trimmed = id.trim();
|
|
251
|
+
if (!trimmed) return trimmed;
|
|
252
|
+
if (trimmed.startsWith(`${provider}/`)) return trimmed;
|
|
253
|
+
if (provider === "openai") {
|
|
254
|
+
const lower = trimmed.toLowerCase();
|
|
255
|
+
// Official OpenAI-style ids already route to OpenAI by heuristic. Custom
|
|
256
|
+
// OpenAI-compatible deployments (Azure/local/proxy) often use arbitrary names;
|
|
257
|
+
// qualify those so routing sends them back to the endpoint that listed them.
|
|
258
|
+
return lower.includes("gpt") || /^o\d/.test(lower) || lower.includes("chatgpt")
|
|
259
|
+
? trimmed
|
|
260
|
+
: `openai/${trimmed}`;
|
|
261
|
+
}
|
|
262
|
+
if (provider === "xai" || provider === "kimi") return trimmed;
|
|
263
|
+
return openaiCompatDef(provider) || provider === "ollama" || provider === "lmstudio" || provider === "antigravity"
|
|
264
|
+
? `${provider}/${trimmed}`
|
|
265
|
+
: trimmed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function liveModelRecordKey(provider: ProviderName, canonical: string, baseUrl: string | undefined): string {
|
|
269
|
+
return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${canonical}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function liveModelFallbackRow(provider: ProviderName, canonical: string, providerModel: string): CatalogModel {
|
|
273
|
+
const inferred = inferCatalogMetadata(canonical) ?? inferCatalogMetadata(providerModel);
|
|
274
|
+
return {
|
|
275
|
+
canonical,
|
|
276
|
+
provider,
|
|
277
|
+
providerModel,
|
|
278
|
+
contextTokens: inferred?.contextTokens ?? 128_000,
|
|
279
|
+
maxOutputTokens: inferred?.maxOutputTokens ?? 16_384,
|
|
280
|
+
thinking: inferred?.thinking ?? [],
|
|
281
|
+
images: inferred?.images ?? true,
|
|
282
|
+
company: inferred?.company ?? companyLabel(provider),
|
|
283
|
+
releaseDate: inferred?.releaseDate,
|
|
284
|
+
sizeClass: inferred?.sizeClass,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Record live ids returned by a provider's own model-list endpoint. Additive only:
|
|
289
|
+
* transient partial responses must not evict an id observed earlier this session. */
|
|
290
|
+
export function recordLiveProviderModels(
|
|
291
|
+
provider: ProviderName,
|
|
292
|
+
ids: readonly string[],
|
|
293
|
+
opts: { source?: LiveProviderModelSource; baseUrl?: string } = {},
|
|
294
|
+
): void {
|
|
295
|
+
const baseUrl = normalizeBaseUrl(opts.baseUrl);
|
|
296
|
+
for (const id of ids) {
|
|
297
|
+
const canonical = liveCanonicalId(provider, id);
|
|
298
|
+
if (!canonical) continue;
|
|
299
|
+
const providerModel = stripProviderPrefix(provider, canonical);
|
|
300
|
+
const known = findCatalogModel(canonical) ?? findCatalogModel(providerModel);
|
|
301
|
+
const row = known
|
|
302
|
+
? { ...known, canonical, provider, providerModel }
|
|
303
|
+
: liveModelFallbackRow(provider, canonical, providerModel);
|
|
304
|
+
liveProviderModels.set(liveModelRecordKey(provider, canonical, baseUrl), {
|
|
305
|
+
row,
|
|
306
|
+
source: opts.source ?? "none",
|
|
307
|
+
baseUrl,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function liveRecordMatchesConfig(record: LiveProviderModelRecord, config?: { openaiBaseUrl?: string }): boolean {
|
|
313
|
+
if (record.row.provider !== "openai") return true;
|
|
314
|
+
const wanted = normalizeBaseUrl(config?.openaiBaseUrl);
|
|
315
|
+
if (wanted || record.baseUrl) return wanted === record.baseUrl;
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Live-discovered catalog rows usable for routing under the current config. */
|
|
320
|
+
export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }): CatalogModel[] {
|
|
321
|
+
return [...liveProviderModels.values()]
|
|
322
|
+
.filter(record => liveRecordMatchesConfig(record, config))
|
|
323
|
+
.map(record => record.row);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** True when a model came from the current provider/base URL's live model list. */
|
|
327
|
+
export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string }): boolean {
|
|
328
|
+
const candidates = new Set([model, liveCanonicalId(provider, model), stripProviderPrefix(provider, model)]);
|
|
329
|
+
return [...liveProviderModels.values()].some(record =>
|
|
330
|
+
record.row.provider === provider &&
|
|
331
|
+
liveRecordMatchesConfig(record, config) &&
|
|
332
|
+
(candidates.has(record.row.canonical) || candidates.has(record.row.providerModel))
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Test-only: clear live provider model supplements. */
|
|
337
|
+
export function resetLiveProviderModels(): void {
|
|
338
|
+
liveProviderModels.clear();
|
|
339
|
+
}
|
|
340
|
+
|
|
225
341
|
/**
|
|
226
342
|
* Model ids the Kimi Code OAuth backend (api.kimi.com/coding, Anthropic Messages
|
|
227
343
|
* format) actually serves. The subscription endpoint rejects Moonshot API-platform
|
|
@@ -11,7 +11,7 @@ import { readGlobalConfig, type Config } from "../agent/state";
|
|
|
11
11
|
import { resolveCredential, type AuthProvider, type Credential } from "../auth";
|
|
12
12
|
import type { ProviderName } from "./types";
|
|
13
13
|
import { PROVIDER_NAMES } from "./provider-status";
|
|
14
|
-
import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS, recordLiveCodexModels } from "./model-catalog";
|
|
14
|
+
import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS, recordLiveCodexModels, recordLiveProviderModels } from "./model-catalog";
|
|
15
15
|
import { extractChatgptAccountId } from "./providers/openai-responses";
|
|
16
16
|
import { openaiCompatDef } from "./providers/openai-compatible-catalog";
|
|
17
17
|
|
|
@@ -405,9 +405,10 @@ export async function listProviderModels(
|
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
const models = [...new Set(ids)].sort().slice(0, limit);
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
408
|
+
// Record live ids for routing supplements. For OpenAI OAuth, keep the
|
|
409
|
+
// existing Codex allow-list widening separate: API-key/custom-base discovery
|
|
410
|
+
// must not make OAuth Codex calls accept unrelated API models.
|
|
411
|
+
recordLiveProviderModels(provider, models, { source, baseUrl: opts.baseUrl });
|
|
411
412
|
if (provider === "openai" && source === "oauth") recordLiveCodexModels(models);
|
|
412
413
|
return { provider, models, ok: true, source };
|
|
413
414
|
} catch (err) {
|
package/src/commands/launch.ts
CHANGED
|
@@ -1323,31 +1323,50 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1323
1323
|
});
|
|
1324
1324
|
const routeFailureReason = (doneReason: unknown): string | null => {
|
|
1325
1325
|
const message = String(doneReason ?? "");
|
|
1326
|
-
if (!message) return
|
|
1326
|
+
if (!message) return "stopped without a final answer";
|
|
1327
1327
|
const asError = new Error(message);
|
|
1328
1328
|
if (isRateLimitError(asError)) return "hit a rate limit";
|
|
1329
1329
|
if (isUsageLimitError(asError)) return "hit a usage/quota limit";
|
|
1330
1330
|
if (/does not recognize the requested model|model .*?(?:not found|not available|unavailable|unsupported|retired|gated)|\b(?:400|404)\b/i.test(message)) {
|
|
1331
1331
|
return "is unavailable";
|
|
1332
1332
|
}
|
|
1333
|
+
// Treat plain empty completions and protocol dead-ends as continuity failures:
|
|
1334
|
+
// the routed model did not produce an answer the agent can commit. Do NOT
|
|
1335
|
+
// reroute deterministic policy/budget errors — switching models must not be
|
|
1336
|
+
// a safety bypass or a substitute for raising output/context budgets.
|
|
1337
|
+
if (/returned no content/i.test(message)) {
|
|
1338
|
+
if (/max[_ -]?(?:output[_ -]?)?tokens|finish_reason=length|done_reason=length|stop_reason=max_tokens|content_filter|refusal|safety|prohibited_content|blocklist|recitation|spii|reasoning_extraction/i.test(message)) {
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
return "returned no content";
|
|
1342
|
+
}
|
|
1343
|
+
if (/no valid tool call|without signaling done|consecutive failing tool calls|consecutive failing tool steps|repeating the same tool call|cycling through the same/i.test(message)) {
|
|
1344
|
+
return "did not produce a usable agent response";
|
|
1345
|
+
}
|
|
1333
1346
|
return null;
|
|
1334
1347
|
};
|
|
1335
1348
|
result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1349
|
+
const routeFallbackMaxRaw = Number(jeoEnv("ROUTE_FALLBACK_MAX_ATTEMPTS") ?? NaN);
|
|
1350
|
+
const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
|
|
1351
|
+
? Math.floor(routeFallbackMaxRaw)
|
|
1352
|
+
: 3;
|
|
1353
|
+
const attemptedRouteModels = new Set<string>([activeModel]);
|
|
1354
|
+
for (let routeFallbackAttempt = 0; routed && routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
|
|
1355
|
+
const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
|
|
1356
|
+
if (!routedFailureReason) break;
|
|
1357
|
+
const fallback = await equivalentRouteFallback(routed, routedFailureReason, [...attemptedRouteModels]);
|
|
1358
|
+
if (!fallback) break;
|
|
1359
|
+
const previousModel = activeModel;
|
|
1360
|
+
attemptedRouteModels.add(fallback);
|
|
1361
|
+
activeModel = fallback;
|
|
1362
|
+
activeThinking = routed.thinking ?? sessionThinking;
|
|
1363
|
+
turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
|
|
1364
|
+
contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
1365
|
+
lastRouteDecision = { ...routed, model: activeModel, warning: `routed model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
|
|
1366
|
+
const notice = `[route] routed model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
|
|
1367
|
+
if (tui) tui.events().onNotice?.(notice);
|
|
1368
|
+
else console.log(notice);
|
|
1369
|
+
result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
|
|
1351
1370
|
}
|
|
1352
1371
|
if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
|
|
1353
1372
|
history.push({
|
package/src/tui/app.ts
CHANGED
|
@@ -1135,14 +1135,23 @@ export class LaunchTui {
|
|
|
1135
1135
|
this.draw();
|
|
1136
1136
|
});
|
|
1137
1137
|
// Watch terminal resizes: rows/cols changes invalidate the previous frame, so
|
|
1138
|
-
// force a full repaint instead of diffing against stale line positions.
|
|
1138
|
+
// force a full repaint instead of diffing against stale line positions. Tests and
|
|
1139
|
+
// embedded runners can create several co-resident TUI instances before teardown;
|
|
1140
|
+
// raise the listener ceiling just enough to represent that real fan-out instead
|
|
1141
|
+
// of letting Node/Bun turn legitimate co-residency into a MaxListeners warning.
|
|
1139
1142
|
if (this.tty) {
|
|
1143
|
+
const resizeCount = process.stdout.listenerCount("resize");
|
|
1144
|
+
const stdoutMax = process.stdout.getMaxListeners();
|
|
1145
|
+
if (stdoutMax > 0 && resizeCount + 1 >= stdoutMax) process.stdout.setMaxListeners(resizeCount + 2);
|
|
1140
1146
|
process.stdout.on("resize", this.onResize);
|
|
1141
1147
|
// Suspend (Ctrl-Z) → resume (fg): SIGWINCH is lost while the process is stopped,
|
|
1142
1148
|
// so a terminal resized mid-suspend would resume with stale geometry and a torn
|
|
1143
1149
|
// frame. SIGCONT fires on resume; force a re-measure + full repaint. POSIX only
|
|
1144
1150
|
// (Windows has no SIGCONT — registering it would throw).
|
|
1145
1151
|
if (process.platform !== "win32") {
|
|
1152
|
+
const resumeCount = process.listenerCount("SIGCONT");
|
|
1153
|
+
const processMax = process.getMaxListeners();
|
|
1154
|
+
if (processMax > 0 && resumeCount + 1 >= processMax) process.setMaxListeners(resumeCount + 2);
|
|
1146
1155
|
process.on("SIGCONT", this.onResume);
|
|
1147
1156
|
}
|
|
1148
1157
|
}
|