jeo-code 0.8.9 → 0.8.11
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 +25 -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/engine.ts +18 -10
- package/src/ai/model-discovery.ts +21 -0
- package/src/ai/model-manager.ts +34 -15
- package/src/ai/providers/errors.ts +27 -0
- package/src/ai/providers/openai-responses.ts +11 -6
- package/src/commands/launch.ts +27 -1
- package/src/util/provider-error.ts +14 -1
- package/src/util/retry.ts +76 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ 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.11] - 2026-07-08
|
|
10
|
+
_"프롬프트 라우팅 동작이 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._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Local-provider reachability veto gate** (`src/commands/launch.ts`, `src/ai/model-discovery.ts`) — new `isLocalProviderReachable()` performs a short-timeout (1500ms) live probe against ollama/lmstudio before routing commits to a pinned local model. An unreachable server now vetoes the route (falls back to the session/default model, same `warnOnce` pattern as the existing credential/servability veto branches) instead of routing into a turn that would fail mid-call. Cloud providers never reach this branch — their existing credential/servability checks are sufficient.
|
|
14
|
+
- **Provider + base URL context on connection failures** (`src/util/retry.ts`, `src/ai/model-manager.ts`) — new `ConnectionContextError` wraps a raw `isConnectionError` failure (Bun `ConnectionRefused` / Node `ECONNREFUSED`/`ENOTFOUND`/`EHOSTUNREACH`/`EAI_AGAIN`) with the resolved provider and effective base URL, threaded through `resolveCall`'s `Resolved` type and applied at both `call()` and `stream()` in `createModelManager()` — covers the residual case where routing was NOT engaged (e.g. an explicit `/model ollama/...` pin) and a connection failure still needs to reach the user with useful context.
|
|
15
|
+
- **Actionable connection-error messages** (`src/util/provider-error.ts`) — `friendlyProviderError` now recognizes `ConnectionContextError` (names the provider + base URL; local providers get "start the server" guidance, others get "check the base URL / network" guidance) and bare `isConnectionError` failures as a defensive fallback, replacing the raw Bun message with `Could not connect to <provider> at <url>. …` in every case.
|
|
16
|
+
- **Connection failures are now retryable** (`src/util/retry.ts`) — `defaultRetryable` treats `isConnectionError` as retryable (a local server mid-restart, or a transient DNS blip, can clear within the backoff budget); a persistently-down provider still exhausts the budget and surfaces the new actionable message rather than the raw one.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Reachability veto gate tests** (`test/launch-prompt-routing.test.ts`) — 5 tests covering the veto firing on an unreachable ollama pin, no false positive when reachable, `/route why` naming the fallback, `warnOnce` notice suppression across turns, and confirming cloud-provider routes never trigger the live probe.
|
|
20
|
+
- **`isLocalProviderReachable` unit tests** (`test/model-discovery.test.ts`) — reachable/unreachable/non-ok-status coverage.
|
|
21
|
+
- **`ConnectionContextError`/`withConnectionContext` unit tests** (`test/retry.test.ts`) — wrapping, pass-through of non-connection errors, and `defaultRetryable` classification.
|
|
22
|
+
- **`friendlyProviderError` connection-message tests** (`test/provider-error-taxonomy.test.ts`) — local vs. non-local guidance, and the bare-error fallback.
|
|
23
|
+
- **`model-manager` integration tests** (`test/model-provider-mapping.test.ts`) — `call()` and `stream()` both re-throw a raw connection failure as `ConnectionContextError` naming the correct provider/base URL.
|
|
24
|
+
|
|
25
|
+
## [0.8.10] - 2026-07-08
|
|
26
|
+
_"지피티 모델 연결이 안되는데 원인파악해서 개선해" — 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)._
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **In-band SSE error now retries instead of hard-failing** (`src/ai/providers/errors.ts`, `src/ai/providers/openai-responses.ts`, `src/util/retry.ts`, `src/agent/engine.ts`) — new `ProviderStreamError` class carries a synthetic `.status` (429 for `rate_limit_exceeded`, 500 otherwise) so existing retry classifiers treat it like the equivalent HTTP failure; `codexResponsesCall`/`codexResponsesStream` now throw it instead of a bare `Error`; the engine's mid-stream transient-network retry ladder (bounded, capped exponential backoff) now also catches it, sharing ONE retry budget with the existing socket-drop recovery path rather than two independent budgets.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- **In-band SSE error retry regression tests** (`test/codex-responses.test.ts`, `test/transient-network-recovery.test.ts`) — cover error-code capture on both SSE error shapes, `ProviderStreamError` retry classification, engine-level recovery from a mid-stream fault, and the shared retry-budget invariant across BOTH failure classes crossing the total cap (not just staying under a per-class cap).
|
|
33
|
+
|
|
9
34
|
## [0.8.9] - 2026-07-08
|
|
10
35
|
_"프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — 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._
|
|
11
36
|
|
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.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.
|
|
266
|
+
- **[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).
|
|
265
267
|
- **[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.
|
|
266
268
|
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
267
269
|
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
268
|
-
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
269
|
-
- **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
|
|
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.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.
|
|
267
|
+
- **[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).
|
|
266
268
|
- **[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.
|
|
267
269
|
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
268
270
|
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
269
|
-
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
270
|
-
- **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
|
|
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.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).
|
|
268
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.
|
|
269
271
|
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
270
272
|
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
271
|
-
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
272
|
-
- **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
|
|
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.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.
|
|
266
|
+
- **[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).
|
|
265
267
|
- **[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.
|
|
266
268
|
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
267
269
|
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
268
|
-
- **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
|
|
269
|
-
- **[0.8.5]** (2026-07-08) — "딥리서치 다음과같이 페이블5 모델이 응답하게 되는경우를 피하기 위해" — a claude-fable-5 turn hit `Error: Refusal (reasoning_extraction): This request was blocked as it seems to violate Anthropic's Terms of Service.` jeo already had a full reactive recovery ladder (context reset → thinking-artifact strip → guidance strip → fail-fast on a category-shaped refusal), but every rung still resends to the SAME model — a deterministic classifier trip re-refuses identically no matter how much context gets stripped. Deep-researched Anthropic's current docs and found the actual fix: a documented, dedicated **server-side fallback** beta released alongside Fable 5 that retries a safety-classifier decline against a different model inside the SAME request, before the caller ever sees an error.
|
|
270
270
|
|
|
271
271
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
272
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { createLspTool } from "./lsp-tool";
|
|
|
22
22
|
import { createLspRenameTool } from "./lsp-rename-tool";
|
|
23
23
|
import { createDebugTool } from "./debug-tool";
|
|
24
24
|
import { createBrowserTool } from "./browser-tool";
|
|
25
|
-
import { isRateLimitError, isTransientStreamDropError, waitAbortable } from "../util/retry";
|
|
25
|
+
import { isRateLimitError, isTransientStreamDropError, isProviderStreamError, waitAbortable } from "../util/retry";
|
|
26
26
|
import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
|
|
27
27
|
import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
28
28
|
import { truncateToolOutput, formatToolResultBody } from "./tool-output";
|
|
@@ -818,14 +818,22 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
818
818
|
await waitAbortable(delayMs, opts.signal);
|
|
819
819
|
continue; // loop top surfaces "Cancelled." when the wait was aborted
|
|
820
820
|
}
|
|
821
|
-
// Mid-stream transient
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
|
|
821
|
+
// Mid-stream transient fault (see `transientNetworkRetries` above): bounded resend
|
|
822
|
+
// with capped exponential backoff, covering TWO distinct failure classes that both
|
|
823
|
+
// land here unretried:
|
|
824
|
+
// - `isTransientStreamDropError` — a live TCP/TLS socket dying mid-stream.
|
|
825
|
+
// - `isProviderStreamError` — an in-band SSE error EVENT on an otherwise-200
|
|
826
|
+
// connection (OpenAI Responses `response.failed`/`error`, e.g. `server_error`).
|
|
827
|
+
// Deliberately narrower than a general `defaultRetryable` check — a rate-limit/5xx/
|
|
828
|
+
// timeout error already ran the FULL model-manager `withRetry` budget (visible as
|
|
829
|
+
// its own "auto-retry #N" notices) before reaching here, so re-retrying it in this
|
|
830
|
+
// ladder too would silently double that budget. Both classes above are structurally
|
|
831
|
+
// exempt from that budget: `retryableStream` (model-manager.ts) only auto-retries
|
|
832
|
+
// losing the FIRST chunk — once any chunk has streamed it deliberately stops (a full
|
|
833
|
+
// re-call would replay already-emitted content) — so a fault arriving AFTER the
|
|
834
|
+
// first chunk never got a model-manager retry at all and needs this engine-level
|
|
835
|
+
// recovery instead.
|
|
836
|
+
if (isTransientStreamDropError(err) || isProviderStreamError(err)) {
|
|
829
837
|
transientNetworkRetries++;
|
|
830
838
|
if (transientNetworkRetries <= GUARD_LIMITS.MAX_TRANSIENT_NETWORK_RETRIES) {
|
|
831
839
|
const baseRaw = Number(jeoEnv("TRANSIENT_NETWORK_BACKOFF_BASE_MS") ?? NaN);
|
|
@@ -833,7 +841,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
833
841
|
const delayMs = Math.min(baseMs * 2 ** (transientNetworkRetries - 1), GUARD_LIMITS.TRANSIENT_NETWORK_BACKOFF_MAX_MS);
|
|
834
842
|
const escHint = ev.onModelStream ? " (Esc to cancel)" : "";
|
|
835
843
|
ev.onNotice?.(
|
|
836
|
-
`
|
|
844
|
+
`mid-response provider fault (${friendlyProviderError(err)}) — auto-retry #${transientNetworkRetries} in ${Math.max(1, Math.round(delayMs / 1000))}s${escHint}`,
|
|
837
845
|
);
|
|
838
846
|
await waitAbortable(delayMs, opts.signal);
|
|
839
847
|
continue; // loop top surfaces "Cancelled." when the wait was aborted
|
|
@@ -412,6 +412,27 @@ export async function listProviderModels(
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
/** Live reachability probe for a KEYLESS local provider (ollama/lmstudio) — the
|
|
416
|
+
* narrow gap `describeProvider` leaves open: it reports these as `ready: true`
|
|
417
|
+
* unconditionally (keyless means "no credential needed", not "server is up"), so
|
|
418
|
+
* a routing decision that lands on a downed local server previously sailed past
|
|
419
|
+
* every readiness check and only failed at call time with a raw, unhelpful
|
|
420
|
+
* connection error (see `friendlyProviderError`'s `ConnectionContextError`
|
|
421
|
+
* branch for the same failure surfaced OUTSIDE this proactive check, e.g. an
|
|
422
|
+
* explicit `/model ollama/...` pin). Reuses `listProviderModels`'s tested
|
|
423
|
+
* fetch/timeout/error handling — never throws. `timeoutMs` defaults short
|
|
424
|
+
* (1500ms, well under `listProviderModels`'s 5000ms default): a live loopback
|
|
425
|
+
* service answers in single-digit ms, so this stays cheap on the turn hot path
|
|
426
|
+
* and still fails fast on a genuinely unreachable host. */
|
|
427
|
+
export async function isLocalProviderReachable(
|
|
428
|
+
provider: "ollama" | "lmstudio",
|
|
429
|
+
baseUrl: string | undefined,
|
|
430
|
+
opts: Pick<DiscoveryOptions, "timeoutMs" | "fetchImpl"> = {},
|
|
431
|
+
): Promise<boolean> {
|
|
432
|
+
const result = await listProviderModels(provider, { baseUrl, timeoutMs: opts.timeoutMs ?? 1500, fetchImpl: opts.fetchImpl, limit: 1 });
|
|
433
|
+
return result.ok;
|
|
434
|
+
}
|
|
435
|
+
|
|
415
436
|
/**
|
|
416
437
|
* Discover live models across providers. By default only queries providers that
|
|
417
438
|
* are logged in / reachable (skips `none` cloud providers); ollama is always
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat
|
|
|
9
9
|
import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel } from "./model-catalog";
|
|
10
10
|
import { xaiCredential } from "./providers/xai";
|
|
11
11
|
import { OPENAI_COMPAT_NAMES, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
|
|
12
|
-
import { withRetry, defaultRetryable, type RetryOptions } from "../util/retry";
|
|
12
|
+
import { withRetry, defaultRetryable, withConnectionContext, isConnectionError, ConnectionContextError, type RetryOptions } from "../util/retry";
|
|
13
13
|
import { jeoEnv } from "../util/env";
|
|
14
14
|
import type { Config } from "../agent/state";
|
|
15
15
|
|
|
@@ -380,6 +380,12 @@ interface Resolved {
|
|
|
380
380
|
callOptions: CallOptions;
|
|
381
381
|
credential: Credential;
|
|
382
382
|
retry: RetryOptions;
|
|
383
|
+
/** The resolved provider + effective base URL, threaded through so a raw
|
|
384
|
+
* pre-response connection failure (see `isConnectionError`) can be enriched
|
|
385
|
+
* with "which provider/URL was unreachable" before it reaches the user —
|
|
386
|
+
* the bare fetch error carries neither. */
|
|
387
|
+
provider: ProviderName;
|
|
388
|
+
baseUrl: string | undefined;
|
|
383
389
|
}
|
|
384
390
|
|
|
385
391
|
/**
|
|
@@ -452,13 +458,13 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
|
|
|
452
458
|
const retry: RetryOptions = { ...resolveRetryOptions(config.retry, kind), ...(options.onRetry ? { onRetry: options.onRetry } : {}), ...(callOptions.signal ? { signal: callOptions.signal } : {}) };
|
|
453
459
|
|
|
454
460
|
if (provider === "ollama" || provider === "lmstudio") {
|
|
455
|
-
return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry };
|
|
461
|
+
return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry, provider, baseUrl };
|
|
456
462
|
}
|
|
457
463
|
|
|
458
464
|
if (provider === "xai") {
|
|
459
465
|
const key = config.providers?.xai;
|
|
460
466
|
if (!key) throw new Error("No credential for provider 'xai'. Set XAI_API_KEY (or providers.xai in config).");
|
|
461
|
-
return { adapter, callOptions, credential: xaiCredential(key), retry };
|
|
467
|
+
return { adapter, callOptions, credential: xaiCredential(key), retry, provider, baseUrl };
|
|
462
468
|
}
|
|
463
469
|
|
|
464
470
|
if (provider === "antigravity") {
|
|
@@ -472,7 +478,7 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
|
|
|
472
478
|
if (credential.kind !== "oauth") {
|
|
473
479
|
throw new Error("Antigravity models use Google OAuth. Run 'jeo auth login antigravity' (recommended) or 'jeo auth login gemini', then retry — the Google Cloud projectId is discovered automatically.");
|
|
474
480
|
}
|
|
475
|
-
return { adapter, callOptions, credential, retry };
|
|
481
|
+
return { adapter, callOptions, credential, retry, provider, baseUrl };
|
|
476
482
|
}
|
|
477
483
|
|
|
478
484
|
const credentialProvider = provider as AuthProvider;
|
|
@@ -494,7 +500,7 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
|
|
|
494
500
|
`No credential for provider '${provider}'. Run 'jeo setup', 'jeo auth login', or set ${provider.toUpperCase()}_API_KEY / ${provider.toUpperCase()}_OAUTH_TOKEN.`
|
|
495
501
|
);
|
|
496
502
|
}
|
|
497
|
-
return { adapter, callOptions, credential: credentialForCall(provider, effective, config, baseUrl), retry };
|
|
503
|
+
return { adapter, callOptions, credential: credentialForCall(provider, effective, config, baseUrl), retry, provider, baseUrl };
|
|
498
504
|
}
|
|
499
505
|
|
|
500
506
|
/** Hard wall-clock cap for a single NON-streaming provider request (service-readiness: a
|
|
@@ -694,11 +700,15 @@ export function createModelManager(): ModelManager {
|
|
|
694
700
|
return {
|
|
695
701
|
resolveProvider,
|
|
696
702
|
async call(messages, options = {}) {
|
|
697
|
-
const { adapter, callOptions, credential, retry } = await resolveCall(options);
|
|
698
|
-
return
|
|
703
|
+
const { adapter, callOptions, credential, retry, provider, baseUrl } = await resolveCall(options);
|
|
704
|
+
return withConnectionContext(
|
|
705
|
+
() => withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry),
|
|
706
|
+
provider,
|
|
707
|
+
baseUrl,
|
|
708
|
+
);
|
|
699
709
|
},
|
|
700
710
|
async *stream(messages, options = {}) {
|
|
701
|
-
const { adapter, callOptions, credential, retry } = await resolveCall(options, "stream");
|
|
711
|
+
const { adapter, callOptions, credential, retry, provider, baseUrl } = await resolveCall(options, "stream");
|
|
702
712
|
if (adapter.stream) {
|
|
703
713
|
const streamFn = adapter.stream.bind(adapter);
|
|
704
714
|
// Per-attempt abort controller fired by the idle timeout — so a stalled stream
|
|
@@ -733,15 +743,24 @@ export function createModelManager(): ModelManager {
|
|
|
733
743
|
return streamFn(messages, { ...liveOptions, signal }, credential)[Symbol.asyncIterator]();
|
|
734
744
|
};
|
|
735
745
|
const maxMs = streamMaxMs();
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
746
|
+
try {
|
|
747
|
+
yield* retryableStream(makeIter, retry, {
|
|
748
|
+
idleMs: streamIdleMs(),
|
|
749
|
+
...(maxMs !== undefined ? { deadlineAt: Date.now() + maxMs } : {}),
|
|
750
|
+
lastActivityAt: () => lastActivityAt,
|
|
751
|
+
onIdle: () => attempt?.abort(),
|
|
752
|
+
});
|
|
753
|
+
} catch (err) {
|
|
754
|
+
if (isConnectionError(err)) throw new ConnectionContextError(provider, baseUrl, err);
|
|
755
|
+
throw err;
|
|
756
|
+
}
|
|
742
757
|
} else {
|
|
743
758
|
// Fallback: providers without streaming yield the full response as one chunk.
|
|
744
|
-
yield await
|
|
759
|
+
yield await withConnectionContext(
|
|
760
|
+
() => withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry),
|
|
761
|
+
provider,
|
|
762
|
+
baseUrl,
|
|
763
|
+
);
|
|
745
764
|
}
|
|
746
765
|
},
|
|
747
766
|
};
|
|
@@ -21,6 +21,33 @@ export class ProviderHttpError extends Error {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* In-band stream failure: the HTTP response itself was 200 (a live SSE
|
|
26
|
+
* connection), but the provider emitted a terminal error EVENT instead of
|
|
27
|
+
* completing normally (OpenAI Responses `response.failed`/`error`: `{"error":
|
|
28
|
+
* {"code":"server_error","message":"…"}}`). No `Response` object survives to
|
|
29
|
+
* build a `ProviderHttpError` from, so this carries a SYNTHETIC `.status` —
|
|
30
|
+
* 429 for a rate-limit code, 500 otherwise — purely so the existing
|
|
31
|
+
* status-based retry checks (`defaultRetryable`, `isRateLimitError`) treat it
|
|
32
|
+
* exactly like the equivalent HTTP failure instead of falling through as an
|
|
33
|
+
* unclassified bare Error (OpenAI's own guidance for `server_error`/
|
|
34
|
+
* `rate_limit_exceeded` is "retry with exponential backoff" — every other
|
|
35
|
+
* code is unenumerated/rare enough that defaulting to retryable is safer than
|
|
36
|
+
* hard-failing a whole turn on a transient backend hiccup).
|
|
37
|
+
*/
|
|
38
|
+
export class ProviderStreamError extends Error {
|
|
39
|
+
readonly status: number;
|
|
40
|
+
readonly provider: string;
|
|
41
|
+
readonly code?: string;
|
|
42
|
+
constructor(provider: string, message: string, code?: string) {
|
|
43
|
+
super(`${provider} stream failed${code ? ` (${code})` : ""}: ${message}`);
|
|
44
|
+
this.name = "ProviderStreamError";
|
|
45
|
+
this.status = code === "rate_limit_exceeded" ? 429 : 500;
|
|
46
|
+
this.provider = provider;
|
|
47
|
+
this.code = code;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
24
51
|
/**
|
|
25
52
|
* Parse a `Retry-After` header into ms. Supports the delta-seconds form
|
|
26
53
|
* (`"5"`) and the HTTP-date form (`"Wed, 21 Oct 2025 07:28:00 GMT"`).
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { Credential } from "../../auth";
|
|
14
14
|
import type { CallOptions, Message } from "../types";
|
|
15
15
|
import { readSse } from "../sse";
|
|
16
|
-
import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
|
|
16
|
+
import { providerHttpError, fetchWithArtifactFailSafe, ProviderStreamError } from "./errors";
|
|
17
17
|
import { serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
18
18
|
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
19
19
|
import os from "node:os";
|
|
@@ -181,6 +181,10 @@ export interface ResponsesEvent {
|
|
|
181
181
|
reasoningDelta?: string;
|
|
182
182
|
usage?: { inputTokens?: number; outputTokens?: number };
|
|
183
183
|
error?: string;
|
|
184
|
+
/** OpenAI's structured error `code` (e.g. `server_error`, `rate_limit_exceeded`) —
|
|
185
|
+
* carried separately from the human-readable message so callers can classify
|
|
186
|
+
* retryability without parsing prose (round: in-band SSE error retry fix). */
|
|
187
|
+
errorCode?: string;
|
|
184
188
|
/** `response.incomplete` cause (e.g. max_output_tokens) — surfaced when the
|
|
185
189
|
* whole response produced no text (round-5 #1). */
|
|
186
190
|
incompleteReason?: string;
|
|
@@ -201,10 +205,10 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
|
|
|
201
205
|
output_index?: number;
|
|
202
206
|
response?: {
|
|
203
207
|
usage?: { input_tokens?: number; output_tokens?: number };
|
|
204
|
-
error?: { message?: string };
|
|
208
|
+
error?: { message?: string; code?: string };
|
|
205
209
|
incomplete_details?: { reason?: string };
|
|
206
210
|
};
|
|
207
|
-
error?: { message?: string };
|
|
211
|
+
error?: { message?: string; code?: string };
|
|
208
212
|
};
|
|
209
213
|
try {
|
|
210
214
|
o = JSON.parse(data);
|
|
@@ -237,7 +241,8 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
|
|
|
237
241
|
};
|
|
238
242
|
}
|
|
239
243
|
if (o.type === "response.failed" || o.type === "error") {
|
|
240
|
-
|
|
244
|
+
const e = o.response?.error ?? o.error;
|
|
245
|
+
return { error: e?.message ?? "Codex response failed", errorCode: e?.code };
|
|
241
246
|
}
|
|
242
247
|
return {};
|
|
243
248
|
}
|
|
@@ -294,7 +299,7 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
|
|
|
294
299
|
accumulateResponsesToolCall(toolAcc, ev);
|
|
295
300
|
if (ev.usage) options.onUsage?.(ev.usage);
|
|
296
301
|
if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
|
|
297
|
-
if (ev.error) throw new
|
|
302
|
+
if (ev.error) throw new ProviderStreamError("OpenAI Codex", ev.error, ev.errorCode);
|
|
298
303
|
}
|
|
299
304
|
// Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
|
|
300
305
|
const envelope = serializeAccumulatedToolCalls(toolAcc);
|
|
@@ -326,7 +331,7 @@ export async function* codexResponsesStream(
|
|
|
326
331
|
accumulateResponsesToolCall(toolAcc, ev);
|
|
327
332
|
if (ev.usage) options.onUsage?.(ev.usage);
|
|
328
333
|
if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
|
|
329
|
-
if (ev.error) throw new
|
|
334
|
+
if (ev.error) throw new ProviderStreamError("OpenAI Codex", ev.error, ev.errorCode);
|
|
330
335
|
}
|
|
331
336
|
// Native tool calls have no output_text deltas — yield the re-serialized envelope once.
|
|
332
337
|
const envelope = serializeAccumulatedToolCalls(toolAcc);
|
package/src/commands/launch.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { callLlm, type Message } from "../agent/loop";
|
|
|
45
45
|
import { friendlyProviderError } from "../util/provider-error";
|
|
46
46
|
import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
|
|
47
47
|
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
48
|
-
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig } from "../ai";
|
|
48
|
+
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
49
49
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
50
50
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
51
51
|
import { routePrompt, deriveCacheSessionKey, warnOnce, type RouteDecision } from "../agent/prompt-router";
|
|
@@ -926,6 +926,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
926
926
|
`[route] routed to '${routed.model}' but the stored ${routedProvider} OAuth login cannot serve that model — falling back to the default model this turn. Set ${routedProvider.toUpperCase()}_API_KEY or reconfigure routing.tiers/roles to a model your login serves.`,
|
|
927
927
|
);
|
|
928
928
|
routed = null;
|
|
929
|
+
} else if (routedProvider === "ollama" || routedProvider === "lmstudio") {
|
|
930
|
+
// Live reachability gate for LOCAL providers only: `describeProvider` reports
|
|
931
|
+
// ollama/lmstudio as `ready: true` unconditionally (keyless just means "no
|
|
932
|
+
// credential needed", not "the server is actually up"), so a routing.tiers/
|
|
933
|
+
// roles pin to a downed local server previously sailed past every readiness
|
|
934
|
+
// check above and only failed mid-turn with a raw, provider-less connection
|
|
935
|
+
// error ("Unable to connect. Is the computer able to access the url?" —
|
|
936
|
+
// Bun's fetch/undici error for a refused connection AND an unresolvable
|
|
937
|
+
// host alike). A short-timeout live probe here keeps routing's fail-open
|
|
938
|
+
// contract intact for the one credential class it couldn't previously see
|
|
939
|
+
// through. Cloud providers never reach this branch — their credential/
|
|
940
|
+
// servability checks above are sufficient and adding a live probe there
|
|
941
|
+
// would just add latency for a class of failure `withRetry` already
|
|
942
|
+
// recovers from at call time.
|
|
943
|
+
const localBaseUrl = routedProvider === "ollama"
|
|
944
|
+
? (turnConfig.ollamaBaseUrl ?? "http://localhost:11434")
|
|
945
|
+
: (turnConfig.lmstudioBaseUrl ?? "http://localhost:1234/v1");
|
|
946
|
+
const reachable = await isLocalProviderReachable(routedProvider, localBaseUrl);
|
|
947
|
+
if (!reachable) {
|
|
948
|
+
routeVetoNote = `routed model '${routed.model}' (${routedProvider}) is unreachable at ${localBaseUrl} this turn — fell back to '${sessionModel || defaultModel}'`;
|
|
949
|
+
routeCredentialNotice = warnOnce(
|
|
950
|
+
`prompt-router:provider-unreachable:${routedProvider}`,
|
|
951
|
+
`[route] routed to '${routed.model}' (${routedProvider}) but ${localBaseUrl} is unreachable — falling back to the default model this turn. Start the ${routedProvider} server, or reconfigure routing.tiers/roles for a provider that is running.`,
|
|
952
|
+
);
|
|
953
|
+
routed = null;
|
|
954
|
+
}
|
|
929
955
|
}
|
|
930
956
|
}
|
|
931
957
|
lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* (carrying `.status` and a body), and the agent loop surfaces failures both as a
|
|
5
5
|
* thrown error and as a `doneReason`, so this lives in a shared util used by both.
|
|
6
6
|
*/
|
|
7
|
-
import { isUsageLimitError, isRefusalError } from "./retry";
|
|
7
|
+
import { isUsageLimitError, isRefusalError, isConnectionError, ConnectionContextError } from "./retry";
|
|
8
|
+
import { companyLabel } from "../ai/model-catalog";
|
|
8
9
|
// Re-export: engine.ts and callers import the refusal predicate from here; the
|
|
9
10
|
// definition lives in retry.ts so defaultRetryable can fail fast without a cycle.
|
|
10
11
|
export { isRefusalError } from "./retry";
|
|
@@ -32,6 +33,18 @@ function formatDuration(ms: number): string {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export function friendlyProviderError(err: unknown): string {
|
|
36
|
+
if (err instanceof ConnectionContextError) {
|
|
37
|
+
const provider = companyLabel(err.provider);
|
|
38
|
+
const target = err.baseUrl ? ` at ${err.baseUrl}` : "";
|
|
39
|
+
const isLocal = err.provider === "ollama" || err.provider === "lmstudio";
|
|
40
|
+
const hint = isLocal
|
|
41
|
+
? ` Start the ${provider} server (or check it's still running), verify the base URL in 'jeo setup' / config.json ('${err.provider}BaseUrl'), or switch model with /model.`
|
|
42
|
+
: ` Check the configured base URL, your network connection, or switch model with /model.`;
|
|
43
|
+
return `Could not connect to ${provider}${target}.${hint}`;
|
|
44
|
+
}
|
|
45
|
+
if (isConnectionError(err)) {
|
|
46
|
+
return `Could not connect to the provider. Check the configured base URL, your network connection, or switch model with /model.`;
|
|
47
|
+
}
|
|
35
48
|
const msg = (err as Error)?.message ?? String(err);
|
|
36
49
|
const status = (err as { status?: number })?.status;
|
|
37
50
|
const provider = /antigravity/i.test(msg)
|
package/src/util/retry.ts
CHANGED
|
@@ -56,6 +56,14 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
56
56
|
if (isRefusalError(err)) {
|
|
57
57
|
return false;
|
|
58
58
|
}
|
|
59
|
+
// A pre-response connection failure (refused / unresolved host) — retryable: a
|
|
60
|
+
// local provider that is mid-restart, or a transient DNS blip, can clear within
|
|
61
|
+
// the backoff budget. A provider that is simply never running exhausts the
|
|
62
|
+
// budget and surfaces friendlyProviderError's actionable message instead of a
|
|
63
|
+
// bare "Unable to connect" (see isConnectionError's docs for the full rationale).
|
|
64
|
+
if (isConnectionError(err)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
59
67
|
|
|
60
68
|
let message = "";
|
|
61
69
|
if (err instanceof Error) {
|
|
@@ -312,3 +320,71 @@ export function isRefusalError(err: unknown): boolean {
|
|
|
312
320
|
export function isTransientStreamDropError(err: unknown): boolean {
|
|
313
321
|
return /socket connection was closed|socket hang up|other side closed|und_err_socket/i.test(errorMessageOf(err));
|
|
314
322
|
}
|
|
323
|
+
|
|
324
|
+
/** True for a pre-response TCP-level connection failure — the request never reached
|
|
325
|
+
* a server at all (refused, DNS unresolved, host unreachable), as opposed to a
|
|
326
|
+
* server responding with an error status or a mid-stream drop (see
|
|
327
|
+
* `isTransientStreamDropError` for the latter). Bun's fetch/undici throws a bare
|
|
328
|
+
* `Error("Unable to connect. Is the computer able to access the url?")` with
|
|
329
|
+
* `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable
|
|
330
|
+
* host — no HTTP status is ever attached, so `defaultRetryable`'s status-based
|
|
331
|
+
* checks never catch it. Node's equivalent carries a `.code` of ECONNREFUSED /
|
|
332
|
+
* ENOTFOUND / EHOSTUNREACH / EAI_AGAIN. Most commonly hit when a local provider
|
|
333
|
+
* (ollama/lmstudio) isn't running, or a custom base URL is misconfigured — see
|
|
334
|
+
* `friendlyProviderError`, which turns this into an actionable message naming the
|
|
335
|
+
* provider and base URL (attached by model-manager's `resolveCall` callers). */
|
|
336
|
+
export function isConnectionError(err: unknown): boolean {
|
|
337
|
+
let code: string | undefined;
|
|
338
|
+
if (err && typeof err === "object" && "code" in err) code = String(err.code);
|
|
339
|
+
if (code && ["ConnectionRefused", "ECONNREFUSED", "ENOTFOUND", "EHOSTUNREACH", "EAI_AGAIN"].includes(code)) return true;
|
|
340
|
+
return /unable to connect\. is the computer able to access the url\?/i.test(errorMessageOf(err));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A raw `isConnectionError` failure re-thrown with the provider/URL context the bare
|
|
344
|
+
* fetch error never carries (see `isConnectionError`'s docs). `.cause` preserves the
|
|
345
|
+
* original error so `isConnectionError`/`defaultRetryable` still recognize it (both
|
|
346
|
+
* inspect `.code` and the message text, which this class forwards). */
|
|
347
|
+
export class ConnectionContextError extends Error {
|
|
348
|
+
readonly code: string | undefined;
|
|
349
|
+
constructor(readonly provider: string, readonly baseUrl: string | undefined, cause: unknown) {
|
|
350
|
+
const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : undefined;
|
|
351
|
+
super(errorMessageOf(cause), { cause });
|
|
352
|
+
this.name = "ConnectionContextError";
|
|
353
|
+
this.code = causeCode;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Run `fn`; a raw `isConnectionError` failure is re-thrown as a `ConnectionContextError`
|
|
358
|
+
* naming `provider`/`baseUrl` so `friendlyProviderError` can turn Bun's context-free
|
|
359
|
+
* "Unable to connect. Is the computer able to access the url?" into an actionable
|
|
360
|
+
* message. Every other error (including a non-connection failure) passes through
|
|
361
|
+
* unchanged. */
|
|
362
|
+
export async function withConnectionContext<T>(fn: () => Promise<T>, provider: string, baseUrl: string | undefined): Promise<T> {
|
|
363
|
+
try {
|
|
364
|
+
return await fn();
|
|
365
|
+
} catch (err) {
|
|
366
|
+
if (isConnectionError(err)) throw new ConnectionContextError(provider, baseUrl, err);
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** True for an in-band provider stream failure: the HTTP response itself was 200 (a
|
|
372
|
+
* live SSE connection), but the provider emitted a terminal error EVENT instead of
|
|
373
|
+
* completing (OpenAI Responses `response.failed`/`error`:
|
|
374
|
+
* `{"error":{"code":"server_error",...}}`) — see `ProviderStreamError` in
|
|
375
|
+
* `ai/providers/errors.ts`. Narrowed via `instanceof Error` + `.name` (not an import
|
|
376
|
+
* of the concrete class) to keep this generic util free of a provider-layer
|
|
377
|
+
* dependency, mirroring the existing `.name === "TimeoutError"` check in
|
|
378
|
+
* `provider-error.ts`. Every instance is treated as retryable here: OpenAI's own
|
|
379
|
+
* guidance for the two documented codes (`server_error`, `rate_limit_exceeded`) is
|
|
380
|
+
* "retry with exponential backoff", and any other/unenumerated code is rare enough
|
|
381
|
+
* that defaulting to retryable beats hard-failing a whole turn on one backend
|
|
382
|
+
* hiccup. Note `ProviderStreamError` ALSO carries a numeric `.status` (429/500), so
|
|
383
|
+
* `defaultRetryable`'s existing status check already retries it in the
|
|
384
|
+
* pre-first-chunk/non-streaming path; this predicate exists for the SEPARATE
|
|
385
|
+
* engine-level mid-stream ladder (see `transientNetworkRetries` in engine.ts),
|
|
386
|
+
* which — like `isTransientStreamDropError` — deliberately does not call
|
|
387
|
+
* `defaultRetryable` there to avoid double-spending that budget. */
|
|
388
|
+
export function isProviderStreamError(err: unknown): boolean {
|
|
389
|
+
return err instanceof Error && err.name === "ProviderStreamError";
|
|
390
|
+
}
|