Skip to content

Commit c859b57

Browse files
anandgupta42claude
andauthored
release: v0.7.1 — provider error handling pass (#794)
* release: v0.7.1 — provider error handling pass A focused pre-release review (5-persona + 2-persona re-review) widened v0.7.1 from a single OpenAI-message-extraction fix into a tight provider-error pass. All changes are surgical, marker-disciplined, and pinned by 40 adversarial tests in `release-v0.7.1-adversarial.test.ts`. User-facing fixes: - Bedrock / AWS Lambda `errorMessage` shape now extracted by both `parseAPICallError` and `parseStreamError`. - `parseStreamError` no longer falls through to `JSON.stringify(e)` for non-OpenAI codes — uses the same string-typeof chain as the API path. - `model_not_found` short-circuits OpenAI 404 retry-storm; user sees the actionable error on attempt 1 instead of after 5 silent retries. - `altimate models` discoverability hint appended on `model_not_found`. Privacy / defense-in-depth: - `Telemetry.maskString` redacts email addresses and internal hostnames (`*.local` / `*.internal` / RFC1918 / IMDS) so the unwrapped provider text from #789 doesn't leak more PII than the prior JSON-quote shred. - `MessageV2.APIError.metadata.url` masks internal hosts (incl. IPv6 loopback / ULA / link-local, AWS IMDS) and strips basic-auth userinfo before the URL flows into local storage / share / telemetry. - `responseBody` capped at 4KB at the `parseAPICallError` boundary. Docs: - New "Provider API Errors" section in `docs/docs/reference/troubleshooting.md`. CI gates passed locally: - typecheck (`tsgo --noEmit`): clean - marker guard (`analyze.ts --markers --base main --strict`): clean - pre-release (`bun run pre-release`): all 4 checks pass; binary starts - targeted tests: 102/102 (provider/error + adversarial + branding + retry) - full opencode suite: 8011 pass / 503 skip / 0 fail Closes #788 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR #794 review feedback (CodeRabbit + cubic) CodeRabbit and cubic flagged three classes of issue on the v0.7.1 PR. All three are addressed here; CHANGELOG, troubleshooting doc, and code are now consistent. 1. Hint spelling consistency (CR Minor / cubic P3) - Three different forms shipped in the original PR: - error.ts:290 -> "Run `altimate models` to see available models." - troubleshooting.md:47 -> "altimate-code models <provider>" - CHANGELOG.md:21 -> "Run `altimate-code models` to see available models." - Aligned to the form actually emitted by the code: `altimate models` (no dash, no provider arg). troubleshooting.md auth-login example also aligned to `altimate auth login` to match error.ts:99. 2. maskString missing IMDS + IPv6 (CR Major / cubic P1) - The regex covered only RFC1918 + *.local / *.internal, while the paired maskInternalHost in error.ts already covered AWS IMDS (169.254.169.254), IPv6 loopback ([::1]), ULA (fc00::/7), and link-local (fe80::/10). - Extended the regex so an error string containing the same URL is redacted with the same coverage as the metadata.url path. - Added query-string/fragment chars (`+`, `#`, `,`, `;`) to the trailing char class so secrets after `?` don't survive past the `<internal-host>` marker. - 5 new adversarial tests for IMDS, IPv6 loopback/ULA/link-local, and the query/fragment leak guard. 3. maskInternalHost only stripped userinfo for internal hosts (cubic P1) - Previously, `https://user:pass@10.0.0.5/x` redacted host AND userinfo, but `https://user:pass@api.openai.com/x` preserved `user:pass@`. A credential in a public-host URL is at least as sensitive as one in an internal-host URL — strip userinfo on EVERY URL, regardless of internal/public classification. - 1 new adversarial test for public-host userinfo strip. Also expanded the upstream_fix marker comment in parseAPICallError to mention all four extractor shapes (OpenAI nested, Anthropic-style top- level, Bedrock errorMessage, legacy string error) so readers don't have to reverse-engineer the chain. Tests: 46 release-v0.7.1 adversarial cases (40 -> 46), 238/238 across provider+adversarial+upgrade+retry+telemetry suites. Typecheck clean. Marker guard --strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: wrap capResponseBody call sites in altimate_change markers The capResponseBody helper was wrapped in markers but the two call sites in parseAPICallError (context_overflow and api_error returns) were unmarked. Marker analyzer --strict caught this. Same fix as the "hint comment expanded" pass — every modified line in an upstream- shared file needs to live inside a marker block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: wrap finalMessage usage in altimate_change markers * fix: address PR #794 round-2 review feedback (cubic P1 + CR nits) cubic flagged a real P1 on the previous round's commit and CodeRabbit posted a few low-value nits worth picking up while we're here. P1 (cubic) — `Telemetry.maskString` URL regex missed URLs carrying basic-auth userinfo before the host. `https://admin:hunter2@10.0.0.5/x` fell through unredacted because the regex started with the host alternation. Added an optional `(?:[^\/\s@]+@)?` userinfo prefix so the whole URL — credentials + host — is captured into `<internal-host>`. CR quick wins: - Hoisted the `Telemetry` import to the top of the adversarial test file (mid-file imports work in Bun but tooling that scans headers miss them). - Tightened the `/models` hint test to assert the full canonical string ``Run `altimate models` to see available models.`` so docs / changelog / code can't quietly diverge again. Defense-in-depth from CR nits: - Added `0.0.0.0` (any-interface bind) to maskInternalHost — shows up in misconfigured proxy URLs and is functionally internal. - Added a one-line comment to the silent `catch {}` in isOpenAiErrorRetryable so a future refactor doesn't "fix" it into something that throws on malformed JSON. Also added 2 new adversarial tests pinning the userinfo+host case and the `0.0.0.0` case (240/240 across the 5 affected files, up from 238). Deferred to v0.7.2: extracting `makeAPICallError` to a shared fixture (refactor, scope creep), reusing `codeFromBody` in `isOpenAiErrorRetryable` (signature change), IPv4-mapped IPv6 (`::ffff:10.0.0.1`) coverage, byte-vs-char `RESPONSE_BODY_CAP` rename. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: pin prototype-pollution test to a real malicious payload CodeRabbit caught that the __proto__ regression guard was hollow: JSON.stringify({__proto__: ...}) produces `{}` — `__proto__` in object-literal syntax is the prototype setter, not an enumerable own property, so it never makes it into the JSON. The test was serializing an empty payload and asserting prototype wasn't polluted (trivially true). Replace the JSON.stringify call with a hand-written JSON literal containing an explicit "__proto__" key. parseAPICallError's JSON.parse now actually receives the malicious key, exercising the regression guard against a future refactor that switches to Object.assign / spread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR #794 round-4 nits + bump release date CodeRabbit Quick Wins: 1. Wrap prototype-pollution tests in try/finally. If a regression ever breaks the guards, Object.prototype stays polluted for the rest of the suite — cascading noise instead of a localized failure. The finally block restores (or deletes) the original key value. 2. Pin the 4KB truncation boundary exactly. The test was asserting `length < 5000`, which would still pass if a future refactor moved the cap from 4096 → 4500 → 4900 — none of those are the documented guarantee. Now asserts the exact 4096-char prefix and the full truncation suffix `…[truncated 95904 chars]`. Cap is now the load-bearing assertion, not the upper bound. Also bumped CHANGELOG date to today (2026-05-06) since the tag pushes today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: deflake tracing-display-crash flushSync test CI run 25448250105 hit: (fail) flushSync — crash recovery > flushSync preserves all accumulated data [126.00ms] The test used two `await new Promise((r) => setTimeout(r, 50))` waits for async snapshot operations to settle. The neighboring test on the same describe block already documents this exact failure mode and uses `await tracer.flush()` as the deterministic antidote (see comment at line 215: "deterministic vs sleep(50) which flakes on slow CI runners"). Replace both setTimeout waits with `tracer.flush()`. Locally now ~700ms (was ~1.2s with the sleeps) and 5/5 passes in a row. The test exercises the same crash-recovery invariants with no timing dependency. Pre-existing flake unrelated to v0.7.1 scope but blocking the release PR's CI — fixing in scope per the project's "no flaky tests" rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 95b7b90 commit c859b57

7 files changed

Lines changed: 1035 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.1] - 2026-05-06
9+
10+
A focused pass on provider error handling, surfaced by a 5-persona pre-release review.
11+
12+
### Fixed
13+
14+
- **Provider 4xx errors now show the inner error message instead of a raw JSON dump.** When any provider returned the standard `{error: {message, type, code}}` shape (OpenAI, Azure OpenAI, OpenRouter, etc.), `parseAPICallError`'s extraction chain short-circuited on the truthy parent `error` object, the `typeof errMsg === "string"` guard rejected it, and the parser fell through to dumping the raw response body — which appeared as `APIError: Bad Request: {?:?}` after telemetry redaction collapsed string values to `?`. Telemetry caught users retrying broken model selections 3+ times in the same session because the surfaced error gave no clue about the cause. Users now see actionable text such as `APIError: Bad Request: The model 'gpt-5-codex' does not exist or you do not have access to it.` The OR-chain is replaced with explicit-typeof ternaries that mirror `parseStreamError`'s pattern, so a truthy non-string at any tier cannot block a valid string further down the chain. (#789, closes #788)
15+
- **Bedrock / AWS Lambda `errorMessage` shape is now extracted.** AWS APIs that return `{errorMessage: "...", errorType: "..."}` (Lambda style) previously fell through the OpenAI/Anthropic-shaped chain to a raw-body dump. Added `body.errorMessage` to the extraction ladder in both `parseAPICallError` and `parseStreamError`.
16+
- **Streaming error path no longer dumps `Unknown: {"type":"error",...}` for non-OpenAI codes.** `parseStreamError` previously handled only 4 OpenAI error codes (`context_length_exceeded`, `insufficient_quota`, `usage_not_included`, `invalid_prompt`); everything else fell through to `JSON.stringify(e)`. Added a default fallback that runs the same string-typeof chain as `parseAPICallError`, so any extractable provider message becomes a clean api_error.
17+
- **`model_not_found` no longer triggers a silent retry storm.** OpenAI 404s are forced retryable in general (some legitimate models 404 transiently), but `error.code === "model_not_found"` now short-circuits to `isRetryable: false` — the user sees the actionable error on attempt 1 instead of after 5 silent retries.
18+
19+
### Added
20+
21+
- **`altimate models` discoverability hint on model-not-found errors.** When `error.code === "model_not_found"`, the surfaced message now ends with `Run \`altimate models\` to see available models.` so the next step is one command away.
22+
- **Provider-API-Errors troubleshooting reference** at `docs/docs/reference/troubleshooting.md` covering model-not-found, unauthorized, rate-limited, context-overflow, and HTML-page error classes.
23+
24+
### Privacy
25+
26+
- **`Telemetry.maskString` now redacts email addresses and internal hostnames.** Pre-fix, the JSON-quote masking rule incidentally collapsed everything inside provider error JSON to `?`. The provider-error fix unwraps that JSON, which means provider-side identifiers (caller emails, internal `*.local` / `*.internal` / RFC1918 / IPv6 loopback / ULA / link-local / AWS IMDS endpoints) now flow as plain English. Added explicit redaction patterns so they're masked before reaching telemetry, the share backend, or local session storage. The masker is kept in sync with `parseAPICallError`'s `maskInternalHost` (same internal-endpoint coverage); query-string and fragment characters (`+`, `#`, `,`, `;`) are inside the trailing char class so secrets past the `<internal-host>` marker don't survive. `sk-…` and `Bearer …` token redaction is unchanged.
27+
- **`metadata.url` on `MessageV2.APIError` masks internal hosts and strips basic-auth userinfo.** When `error.url` points at `localhost`, `*.local`, `*.internal`, an RFC1918 IPv4, IPv6 loopback / ULA / link-local, or the AWS IMDS address (`169.254.169.254`), the host is rewritten to `internal-host.redacted` before the URL lands on the parsed error. Basic-auth userinfo (`user:pass@…`) is stripped on **every** URL — internal or public — since a credential in a public-host URL is at least as risky as one in an internal proxy. Public-host URLs are otherwise preserved verbatim for debugging.
28+
- **`responseBody` is capped at 4KB** at the `parseAPICallError` boundary. Without this, a hostile or verbose gateway could persist a 100KB+ body into local storage and (for shared sessions) the share backend.
29+
30+
### Testing
31+
32+
- 46 adversarial tests covering JSON-scalar bodies, prototype-pollution attempts, 100KB error messages, malformed JSON, every-tier null/numeric extraction, Bedrock `errorMessage` precedence, the `parseStreamError` fallback for unknown codes, the `model_not_found` retry-storm carve-out, the `altimate models` hint, the responseBody cap, the metadata.url internal-host masking (incl. IPv6 loopback/ULA/link-local, AWS IMDS, public-host basic-auth userinfo strip, RFC1918 boundary checks, lookalike-hostname guards), and the new email / internal-host `maskString` patterns (incl. IMDS, IPv6, and query-fragment leak guards).
33+
834
## [0.7.0] - 2026-05-03
935

1036
### Changed

docs/docs/reference/troubleshooting.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,30 @@ altimate --print-logs --log-level DEBUG
3030
3. If behind a proxy, set `HTTPS_PROXY` (see [Network](network.md))
3131
4. Try a different provider to isolate the issue
3232

33+
### Provider API Errors
34+
35+
**Symptoms:** `APIError: <status>: <message>` shown in chat output. Common forms:
36+
37+
- `APIError: Bad Request: The model 'foo' does not exist or you do not have access to it.`
38+
- `APIError: Unauthorized: Invalid API key`
39+
- `APIError: Rate limit exceeded`
40+
41+
As of v0.7.1, altimate-code surfaces the **inner provider message** instead of dumping the raw JSON body. The status prefix (`Bad Request:`, `Unauthorized:`, etc.) comes from the provider's HTTP status code; everything after the colon is the provider's text verbatim.
42+
43+
**Solutions by error class:**
44+
45+
1. **Model not found** (`APIError: Bad Request: The model '<name>' does not exist...`) — list the models your provider currently exposes and re-run with one of them:
46+
```bash
47+
altimate models <provider>
48+
```
49+
`model_not_found` errors no longer auto-retry; the message you see is the first attempt, not the fifth.
50+
2. **Unauthorized / 401** — re-run `altimate auth login <provider>` and re-issue the request.
51+
3. **Rate limited / 429** — altimate-code automatically retries on rate-limit responses (including plain-text 429s from Alibaba/DashScope). If you keep hitting rate limits, lower `parallel_tool_calls` or switch to a less-saturated model.
52+
4. **Context overflow** — switch to a larger-context model or trim earlier turns with `/compact`. Detection covers Anthropic, Bedrock, OpenAI, Gemini, xAI, Groq, OpenRouter, DeepSeek, Copilot, llama.cpp, LM Studio, MiniMax, Kimi, Moonshot, Azure OpenAI, and HTTP 413.
53+
5. **HTML page returned** — usually a gateway/proxy error. The CLI returns a friendly hint pointing at `altimate auth login` rather than dumping the raw HTML.
54+
55+
**Privacy note:** error messages flow through the same redaction layer as everything else (`sk-…`, `Bearer …`, email addresses, and `*.local` / `*.internal` / RFC1918 / IPv6 loopback / ULA / link-local / AWS IMDS hostnames are masked before reaching telemetry). Internal-host URLs in `metadata.url` are also redacted before they reach local storage or shared sessions, and basic-auth userinfo (`user:pass@…`) is stripped from every URL regardless of whether the host is internal.
56+
3357
### Tool Execution Errors
3458

3559
**Symptoms:** "No native handler" or tool execution failures for data engineering tools.

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,26 @@ export namespace Telemetry {
10601060
return s
10611061
.replace(/sk-(?:ant-)?[A-Za-z0-9_-]{20,}/g, "sk-***")
10621062
.replace(/Bearer\s+[A-Za-z0-9._-]{20,}/gi, "Bearer ***")
1063+
// Email addresses — providers occasionally echo caller identity in error text.
1064+
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
1065+
// Internal hostnames in URLs — keeps parity with `parseAPICallError`'s
1066+
// `maskInternalHost` so an error message containing the same URL doesn't
1067+
// leak through telemetry while metadata.url is masked. Covers:
1068+
// *.local / *.internal / *.localhost
1069+
// RFC1918 IPv4: 10/8, 172.16/12, 192.168/16, plus 127/8 loopback
1070+
// AWS IMDS / link-local IPv4: 169.254/16
1071+
// IPv6 in brackets: [::1] loopback, [fc??::/[fd??:: ULA, [fe80:: link-local
1072+
// Char class includes `+`, `#`, `,`, `;` so secrets in query/fragment
1073+
// don't survive past the redaction marker. Over-masking is the correct
1074+
// failure mode here.
1075+
.replace(
1076+
// `(?:[^\/\s@]+@)?` allows optional basic-auth userinfo
1077+
// (`user:pass@`) before the host so URLs like
1078+
// `https://admin:hunter2@10.0.0.5/x` are still recognized as internal
1079+
// and redacted whole. The credential goes with the host into <internal-host>.
1080+
/\bhttps?:\/\/(?:[^\/\s@]+@)?(?:localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|0\.0\.0\.0|\[(?:::1|fc[0-9a-f]{2}:[^\]]*|fd[0-9a-f]{2}:[^\]]*|fe80:[^\]]*)\]|[A-Za-z0-9.-]+\.(?:local|internal|localhost))(?::\d+)?[\w/.?=&%+#,;~!*'()@:-]*/gi,
1081+
"<internal-host>",
1082+
)
10631083
.replace(/'(?:[^'\\]|\\.)*'/g, "?")
10641084
.replace(/"(?:[^"\\]|\\.)*"/g, "?")
10651085
.replace(/\s+/g, " ")

packages/opencode/src/provider/error.ts

Lines changed: 140 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ export namespace ProviderError {
2828
function isOpenAiErrorRetryable(e: APICallError) {
2929
const status = e.statusCode
3030
if (!status) return e.isRetryable
31+
// altimate_change start — upstream_fix: don't retry-storm on model_not_found.
32+
// OpenAI 404s are forced retryable below because some legitimate models 404
33+
// transiently, but `model_not_found` will never recover; retrying 5x just
34+
// delays the user seeing the (now-readable) error message.
35+
if (status === 404) {
36+
try {
37+
const body = e.responseBody ? JSON.parse(e.responseBody) : null
38+
if (body?.error?.code === "model_not_found") return false
39+
} catch {
40+
// Malformed JSON on a 404 falls through to "force retryable" below —
41+
// intentional; some providers emit non-JSON 404 bodies for transient
42+
// model availability blips and those should still retry.
43+
}
44+
}
45+
// altimate_change end
3146
// openai sometimes returns 404 for models that are actually available
3247
return status === 404 || e.isRetryable
3348
}
@@ -61,19 +76,27 @@ export namespace ProviderError {
6176

6277
try {
6378
const body = JSON.parse(e.responseBody)
64-
// altimate_change start — upstream_fix: OpenAI errors use {error: {message}} shape;
65-
// the original `body.message || body.error || body.error?.message` short-circuits on
66-
// the parent object, fails the typeof string guard, and dumps the raw body. Use an
67-
// explicit-typeof ternary so a truthy non-string at any level can't block a valid
68-
// string further down the chain (matches parseStreamError's pattern below).
79+
// altimate_change start — upstream_fix: extract provider error messages
80+
// across the four shapes in the wild:
81+
// 1. {error: {message: "..."}} — OpenAI / Azure OpenAI / OpenRouter
82+
// 2. {message: "..."} — Anthropic-style top-level
83+
// 3. {errorMessage: "..."} — Bedrock / AWS Lambda
84+
// 4. {error: "..."} — legacy plain-string shape
85+
// The original `body.message || body.error || body.error?.message` short-
86+
// circuited on a truthy parent object, failed the `typeof === "string"`
87+
// guard, and dumped the raw body. Use an explicit-typeof ternary so a
88+
// truthy non-string at any tier can't block a valid string further down
89+
// the chain (matches parseStreamError's pattern below).
6990
const errMsg =
7091
typeof body.error?.message === "string"
7192
? body.error.message
7293
: typeof body.message === "string"
7394
? body.message
74-
: typeof body.error === "string"
75-
? body.error
76-
: undefined
95+
: typeof body.errorMessage === "string"
96+
? body.errorMessage
97+
: typeof body.error === "string"
98+
? body.error
99+
: undefined
77100
if (errMsg) return `${msg}: ${errMsg}`
78101
// altimate_change end
79102
} catch {}
@@ -161,6 +184,32 @@ export namespace ProviderError {
161184
responseBody,
162185
}
163186
}
187+
188+
// altimate_change start — upstream_fix: extend extraction to non-OpenAI error
189+
// codes. The switch above only handles 4 OpenAI shapes; everything else fell
190+
// through to `JSON.stringify(e)` in the caller (session/message-v2.ts), which
191+
// showed users `Unknown: {"type":"error",...}`. Apply the same string-typeof
192+
// chain we use in parseAPICallError so any extractable provider message lands
193+
// as a clean api_error.
194+
const fallbackMsg =
195+
typeof body?.error?.message === "string"
196+
? body.error.message
197+
: typeof body?.message === "string"
198+
? body.message
199+
: typeof body?.errorMessage === "string"
200+
? body.errorMessage
201+
: typeof body?.error === "string"
202+
? body.error
203+
: undefined
204+
if (fallbackMsg) {
205+
return {
206+
type: "api_error",
207+
message: fallbackMsg,
208+
isRetryable: false,
209+
responseBody,
210+
}
211+
}
212+
// altimate_change end
164213
}
165214

166215
export type ParsedAPICallError =
@@ -179,6 +228,67 @@ export namespace ProviderError {
179228
metadata?: Record<string, string>
180229
}
181230

231+
// altimate_change start — cap responseBody at 4KB before it lands on a
232+
// MessageV2.APIError. Without this cap, a hostile gateway returning a 100KB
233+
// body (or just verbose providers like LiteLLM) would inflate local storage,
234+
// share-backend uploads, and diagnostic dumps.
235+
const RESPONSE_BODY_CAP = 4096
236+
function capResponseBody(body: string | undefined): string | undefined {
237+
if (!body) return body
238+
if (body.length <= RESPONSE_BODY_CAP) return body
239+
return body.slice(0, RESPONSE_BODY_CAP) + `…[truncated ${body.length - RESPONSE_BODY_CAP} chars]`
240+
}
241+
// altimate_change end
242+
243+
// altimate_change start — sanitize metadata.url before it lands on the
244+
// parsed error. Two transforms are applied:
245+
// (1) basic-auth userinfo (`user:pass@…`) is stripped on every URL,
246+
// internal or public — a credential in a misconfigured proxy URL
247+
// must not flow into telemetry / local storage / share regardless
248+
// of where the URL points.
249+
// (2) the hostname is rewritten to `internal-host.redacted` if it
250+
// matches an internal endpoint (RFC1918, *.local, *.internal,
251+
// localhost, *.localhost, IPv6 loopback / ULA / link-local, or
252+
// the AWS IMDS address 169.254.169.254). Public provider URLs
253+
// are otherwise preserved for debugging.
254+
function maskInternalHost(url: string): string {
255+
try {
256+
const u = new URL(url)
257+
// u.hostname keeps IPv6 brackets (e.g. "[::1]"); strip for regex match.
258+
const host = u.hostname.replace(/^\[|\]$/g, "")
259+
const hadCredentials = u.username !== "" || u.password !== ""
260+
// Always clear userinfo — the credential is the riskier part of the URL.
261+
u.username = ""
262+
u.password = ""
263+
const isInternal =
264+
host === "localhost" ||
265+
host === "0.0.0.0" || // any-interface bind, often misconfigured proxy
266+
host.endsWith(".local") ||
267+
host.endsWith(".internal") ||
268+
host.endsWith(".localhost") ||
269+
/^127\./.test(host) ||
270+
/^10\./.test(host) ||
271+
/^192\.168\./.test(host) ||
272+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
273+
/^169\.254\./.test(host) || // AWS IMDS / link-local IPv4
274+
host === "::1" || // IPv6 loopback
275+
/^fc[0-9a-f]{2}:/i.test(host) || // IPv6 ULA (RFC4193 fc00::/8)
276+
/^fd[0-9a-f]{2}:/i.test(host) || // IPv6 ULA (RFC4193 fd00::/8)
277+
/^fe80:/i.test(host) // IPv6 link-local
278+
if (isInternal) {
279+
u.hostname = "internal-host.redacted"
280+
return u.toString()
281+
}
282+
// No host change but we may have removed credentials — re-serialize
283+
// only if userinfo was present, otherwise return the original string
284+
// so URLs round-trip untouched (preserves trailing slashes, casing).
285+
return hadCredentials ? u.toString() : url
286+
} catch {
287+
return url
288+
}
289+
}
290+
// altimate_change end
291+
182292
export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError {
183293
const m = message(input.providerID, input.error)
184294
// Check responseBody for context_length_exceeded code (e.g., OpenAI-style errors)
@@ -188,20 +298,38 @@ export namespace ProviderError {
188298
return {
189299
type: "context_overflow",
190300
message: m,
191-
responseBody: input.error.responseBody,
301+
// altimate_change start — cap responseBody on context_overflow path
302+
responseBody: capResponseBody(input.error.responseBody),
303+
// altimate_change end
192304
}
193305
}
194306

195-
const metadata = input.error.url ? { url: input.error.url } : undefined
307+
// altimate_change start — append a `models` discoverability hint when the
308+
// error code is model_not_found. Pairs with the retry-storm carve-out in
309+
// isOpenAiErrorRetryable so the user sees the hint on the first attempt
310+
// instead of after 5 silent retries.
311+
let finalMessage = m
312+
if (codeFromBody === "model_not_found") {
313+
finalMessage = `${m} Run \`altimate models\` to see available models.`
314+
}
315+
// altimate_change end
316+
317+
// altimate_change start — mask internal hostnames in metadata.url
318+
const metadata = input.error.url ? { url: maskInternalHost(input.error.url) } : undefined
319+
// altimate_change end
196320
return {
197321
type: "api_error",
198-
message: m,
322+
// altimate_change start — finalMessage carries the optional /models hint
323+
message: finalMessage,
324+
// altimate_change end
199325
statusCode: input.error.statusCode,
200326
isRetryable: input.providerID.startsWith("openai")
201327
? isOpenAiErrorRetryable(input.error)
202328
: input.error.isRetryable,
203329
responseHeaders: input.error.responseHeaders,
204-
responseBody: input.error.responseBody,
330+
// altimate_change start — cap responseBody on api_error path
331+
responseBody: capResponseBody(input.error.responseBody),
332+
// altimate_change end
205333
metadata,
206334
}
207335
}

packages/opencode/test/altimate/tracing-display-crash.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ describe("flushSync — crash recovery", () => {
169169
model: "anthropic/claude-sonnet-4-20250514",
170170
agent: "builder",
171171
})
172-
await new Promise((r) => setTimeout(r, 50))
172+
// Deterministic wait for the startTrace snapshot — `await sleep(50)`
173+
// races on slow CI runners (this test failed on CI run 25448250105).
174+
await tracer.flush()
173175

174176
tracer.logStepStart({ id: "1" })
175177
tracer.logToolCall({
@@ -181,8 +183,8 @@ describe("flushSync — crash recovery", () => {
181183
id: "1", reason: "tool_calls", cost: 0.005,
182184
tokens: { input: 1000, output: 200, reasoning: 50, cache: { read: 100, write: 25 } },
183185
})
184-
// Wait for logStepFinish snapshot
185-
await new Promise((r) => setTimeout(r, 50))
186+
// Deterministic wait for the logStepFinish snapshot.
187+
await tracer.flush()
186188

187189
tracer.logStepStart({ id: "2" })
188190
// Crash mid-generation

0 commit comments

Comments
 (0)