From 0d06ff8d189898deeafd44fcbc82b93e5fdb852d Mon Sep 17 00:00:00 2001 From: Ricardo Romero Date: Sun, 25 Oct 2026 01:30:13 -0300 Subject: [PATCH 1/6] fix(opencode): show a readable message when a provider error is OpenCode stores "" when a model endpoint returns an HTML error page. Use the page title and HTTP status instead. --- .../provider-opencode/src/server/adapter.ts | 15 +++- .../src/server/providerErrorMessage.test.ts | 49 ++++++++++ .../src/server/providerErrorMessage.ts | 90 +++++++++++++++++++ .../src/server/v2/adapter.ts | 15 +++- 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 packages/provider-opencode/src/server/providerErrorMessage.test.ts create mode 100644 packages/provider-opencode/src/server/providerErrorMessage.ts diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index d8f763f44035e..84e240aa6d76b 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -66,6 +66,7 @@ import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/ import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { readableProviderErrorMessage } from "./providerErrorMessage.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; @@ -859,7 +860,19 @@ function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { function openCodeErrorMessage(event: Extract): string { const error = event.properties.error; if (error === undefined) return "OpenCode session failed without an error payload."; - return recordString(error.data, "message") ?? error.name; + const message = recordString(error.data, "message"); + const statusCode = recordValue(error.data, "statusCode"); + const responseBody = recordString(error.data, "responseBody"); + const readable = readableProviderErrorMessage({ + message, + statusCode: typeof statusCode === "number" ? statusCode : undefined, + responseBody, + }); + if (message !== undefined && readable === message) return message; + if (message === undefined && responseBody === undefined && typeof statusCode !== "number") { + return error.name; + } + return readable; } function terminalStatusForError( diff --git a/packages/provider-opencode/src/server/providerErrorMessage.test.ts b/packages/provider-opencode/src/server/providerErrorMessage.test.ts new file mode 100644 index 0000000000000..47cfa9223591d --- /dev/null +++ b/packages/provider-opencode/src/server/providerErrorMessage.test.ts @@ -0,0 +1,49 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { readableProviderErrorMessage } from "./providerErrorMessage.ts"; + +const CLOUDFLARE_TUNNEL_PAGE = `Cloudflare Tunnel error | llm.thirdamendment.ai | Cloudflareerror code: 1033`; + +describe("readableProviderErrorMessage", () => { + // regression: OpenCode stores AI_APICallError as the message "" when the + // provider returns an HTML error page, and that placeholder was the thread failure. + it.effect("uses the page title and HTTP status instead of ", () => + Effect.sync(() => { + assert.equal( + readableProviderErrorMessage({ + message: "", + statusCode: 530, + responseBody: CLOUDFLARE_TUNNEL_PAGE, + }), + "Cloudflare Tunnel error | llm.thirdamendment.ai | Cloudflare (HTTP 530).", + ); + }), + ); + + it.effect("keeps a message that already explains the failure", () => + Effect.sync(() => { + assert.equal( + readableProviderErrorMessage({ + message: "Rate limit exceeded: free-models-per-min.", + statusCode: 429, + responseBody: CLOUDFLARE_TUNNEL_PAGE, + }), + "Rate limit exceeded: free-models-per-min.", + ); + }), + ); + + it.effect("replaces a bare with an explicit sentence", () => + Effect.sync(() => { + assert.equal( + readableProviderErrorMessage({ message: " " }), + "The model provider failed the request and did not include an error message.", + ); + assert.equal( + readableProviderErrorMessage({ message: "", statusCode: 530 }), + "The model provider failed the request (HTTP 530).", + ); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/providerErrorMessage.ts b/packages/provider-opencode/src/server/providerErrorMessage.ts new file mode 100644 index 0000000000000..9d79b1b8e559d --- /dev/null +++ b/packages/provider-opencode/src/server/providerErrorMessage.ts @@ -0,0 +1,90 @@ +const OPAQUE_MESSAGES = new Set(["", "none", "ai_apicallerror: "]); +const MAX_DETAIL_LENGTH = 240; + +/** + * OpenCode's AI SDK stores `` when a provider answers with a body it + * cannot read, such as a Cloudflare HTML error page. The status and that + * page's title are the message the user can act on. + */ +export function readableProviderErrorMessage(input: { + readonly message?: string | null | undefined; + readonly statusCode?: number | null | undefined; + readonly responseBody?: string | null | undefined; +}): string { + const message = input.message?.trim() ?? ""; + if (message.length > 0 && !isOpaqueProviderMessage(message)) return message; + + const detail = + input.responseBody === undefined || input.responseBody === null + ? undefined + : detailFromBody(input.responseBody); + const statusCode = + typeof input.statusCode === "number" && Number.isInteger(input.statusCode) + ? input.statusCode + : undefined; + + if (detail !== undefined && statusCode !== undefined) return `${detail} (HTTP ${statusCode}).`; + if (detail !== undefined) return /[.!?]$/u.test(detail) ? detail : `${detail}.`; + if (statusCode !== undefined) + return `The model provider failed the request (HTTP ${statusCode}).`; + return "The model provider failed the request and did not include an error message."; +} + +function isOpaqueProviderMessage(message: string): boolean { + return OPAQUE_MESSAGES.has(message.trim().toLowerCase()); +} + +function detailFromBody(body: string): string | undefined { + const trimmed = body.trim(); + if (trimmed.length === 0) return undefined; + const title = htmlTitle(trimmed); + if (title !== undefined) return title; + if (trimmed.startsWith("<")) return undefined; + const jsonMessage = jsonErrorMessage(trimmed); + if (jsonMessage !== undefined) return boundDetail(jsonMessage); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return undefined; + return boundDetail(trimmed); +} + +function htmlTitle(body: string): string | undefined { + const title = /]*>([^<]{1,300})<\/title>/iu.exec(body)?.[1]; + if (title === undefined) return undefined; + return boundDetail(decodeBasicEntities(title)); +} + +function jsonErrorMessage(body: string): string | undefined { + if (!body.startsWith("{") && !body.startsWith("[")) return undefined; + try { + return stringField(JSON.parse(body), "message"); + } catch { + return undefined; + } +} + +function stringField(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + const record = value as Record; + const direct = record[key]; + if (typeof direct === "string" && !isOpaqueProviderMessage(direct)) return direct; + const nested = record.error; + if (nested !== undefined && nested !== value) return stringField(nested, key); + return undefined; +} + +function boundDetail(value: string): string | undefined { + const collapsed = value.replace(/\s+/gu, " ").trim(); + if (collapsed.length === 0 || isOpaqueProviderMessage(collapsed)) return undefined; + return collapsed.length <= MAX_DETAIL_LENGTH + ? collapsed + : `${collapsed.slice(0, MAX_DETAIL_LENGTH - 1)}…`; +} + +function decodeBasicEntities(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("'", "'"); +} diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index f837e24e2793e..030228da90ce5 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -57,6 +57,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as OpenCode2Client from "./OpenCode2Client.ts"; import * as OpenCode2Server from "./OpenCode2Server.ts"; import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; +import { readableProviderErrorMessage } from "../providerErrorMessage.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; @@ -1108,7 +1109,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr retryDelayMs: Math.max(0, Math.trunc(delayMs)), }, failure: makeProviderFailure({ - message: error.message, + message: readableProviderErrorMessage({ + message: error.message, + statusCode: error.status, + responseBody: error.response?.body, + }), code: error.type, class: "provider_error", retryable: true, @@ -2183,7 +2188,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr case "session.execution.interrupted": return yield* endExecution(state, turn, { status: "interrupted" }); case "session.execution.failed": { - const { type, message, status } = event.data.error; + const { type, message, status, response } = event.data.error; // OpenCode types an empty balance as provider.quota too, but it answers // that with HTTP 402 and it never resets. Usage windows and budgets are 429. const usageLimit = @@ -2191,7 +2196,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* endExecution(state, turn, { status: "failed", failure: makeProviderFailure({ - message, + message: readableProviderErrorMessage({ + message, + statusCode: status, + responseBody: response?.body, + }), code: type, class: usageLimit ? "usage_limit" : "provider_error", }), From a59cd7a1ab09f719ca365e2f43990aeeb21d0a1e Mon Sep 17 00:00:00 2001 From: Ricardo Romero Date: Sun, 25 Oct 2026 01:46:15 -0300 Subject: [PATCH 2/6] test(opencode): use a generic host in the opaque error fixture --- .../provider-opencode/src/server/providerErrorMessage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/providerErrorMessage.test.ts b/packages/provider-opencode/src/server/providerErrorMessage.test.ts index 47cfa9223591d..66da8fcbe61b4 100644 --- a/packages/provider-opencode/src/server/providerErrorMessage.test.ts +++ b/packages/provider-opencode/src/server/providerErrorMessage.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import { readableProviderErrorMessage } from "./providerErrorMessage.ts"; -const CLOUDFLARE_TUNNEL_PAGE = `Cloudflare Tunnel error | llm.thirdamendment.ai | Cloudflareerror code: 1033`; +const CLOUDFLARE_TUNNEL_PAGE = `Cloudflare Tunnel error | llm.example.com | Cloudflareerror code: 1033`; describe("readableProviderErrorMessage", () => { // regression: OpenCode stores AI_APICallError as the message "" when the @@ -16,7 +16,7 @@ describe("readableProviderErrorMessage", () => { statusCode: 530, responseBody: CLOUDFLARE_TUNNEL_PAGE, }), - "Cloudflare Tunnel error | llm.thirdamendment.ai | Cloudflare (HTTP 530).", + "Cloudflare Tunnel error | llm.example.com | Cloudflare (HTTP 530).", ); }), ); From cd9e22701a381b5ed7145cbbd12f5b4c2f78ec26 Mon Sep 17 00:00:00 2001 From: Ricardo Romero Date: Sun, 25 Oct 2026 01:51:56 -0300 Subject: [PATCH 3/6] fix(opencode): only read the page title when the error is --- .../provider-opencode/src/server/adapter.ts | 19 ++-- .../src/server/providerErrorMessage.test.ts | 35 ++------ .../src/server/providerErrorMessage.ts | 90 +++---------------- 3 files changed, 24 insertions(+), 120 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 84e240aa6d76b..0fdc6ab1adfe9 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -860,19 +860,14 @@ function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") { function openCodeErrorMessage(event: Extract): string { const error = event.properties.error; if (error === undefined) return "OpenCode session failed without an error payload."; - const message = recordString(error.data, "message"); const statusCode = recordValue(error.data, "statusCode"); - const responseBody = recordString(error.data, "responseBody"); - const readable = readableProviderErrorMessage({ - message, - statusCode: typeof statusCode === "number" ? statusCode : undefined, - responseBody, - }); - if (message !== undefined && readable === message) return message; - if (message === undefined && responseBody === undefined && typeof statusCode !== "number") { - return error.name; - } - return readable; + return ( + readableProviderErrorMessage({ + message: recordString(error.data, "message"), + statusCode: typeof statusCode === "number" ? statusCode : undefined, + responseBody: recordString(error.data, "responseBody"), + }) ?? error.name + ); } function terminalStatusForError( diff --git a/packages/provider-opencode/src/server/providerErrorMessage.test.ts b/packages/provider-opencode/src/server/providerErrorMessage.test.ts index 66da8fcbe61b4..db1836a34be65 100644 --- a/packages/provider-opencode/src/server/providerErrorMessage.test.ts +++ b/packages/provider-opencode/src/server/providerErrorMessage.test.ts @@ -3,46 +3,21 @@ import * as Effect from "effect/Effect"; import { readableProviderErrorMessage } from "./providerErrorMessage.ts"; -const CLOUDFLARE_TUNNEL_PAGE = `Cloudflare Tunnel error | llm.example.com | Cloudflareerror code: 1033`; - describe("readableProviderErrorMessage", () => { - // regression: OpenCode stores AI_APICallError as the message "" when the - // provider returns an HTML error page, and that placeholder was the thread failure. - it.effect("uses the page title and HTTP status instead of ", () => + // regression: OpenCode stores "" when the provider returns an HTML error page. + it.effect("replaces with the page title and HTTP status", () => Effect.sync(() => { assert.equal( readableProviderErrorMessage({ message: "", statusCode: 530, - responseBody: CLOUDFLARE_TUNNEL_PAGE, + responseBody: "Cloudflare Tunnel error | llm.example.com | Cloudflare", }), "Cloudflare Tunnel error | llm.example.com | Cloudflare (HTTP 530).", ); - }), - ); - - it.effect("keeps a message that already explains the failure", () => - Effect.sync(() => { - assert.equal( - readableProviderErrorMessage({ - message: "Rate limit exceeded: free-models-per-min.", - statusCode: 429, - responseBody: CLOUDFLARE_TUNNEL_PAGE, - }), - "Rate limit exceeded: free-models-per-min.", - ); - }), - ); - - it.effect("replaces a bare with an explicit sentence", () => - Effect.sync(() => { - assert.equal( - readableProviderErrorMessage({ message: " " }), - "The model provider failed the request and did not include an error message.", - ); assert.equal( - readableProviderErrorMessage({ message: "", statusCode: 530 }), - "The model provider failed the request (HTTP 530).", + readableProviderErrorMessage({ message: "Rate limit exceeded." }), + "Rate limit exceeded.", ); }), ); diff --git a/packages/provider-opencode/src/server/providerErrorMessage.ts b/packages/provider-opencode/src/server/providerErrorMessage.ts index 9d79b1b8e559d..ba939dc27fc46 100644 --- a/packages/provider-opencode/src/server/providerErrorMessage.ts +++ b/packages/provider-opencode/src/server/providerErrorMessage.ts @@ -1,90 +1,24 @@ -const OPAQUE_MESSAGES = new Set(["", "none", "ai_apicallerror: "]); -const MAX_DETAIL_LENGTH = 240; - -/** - * OpenCode's AI SDK stores `` when a provider answers with a body it - * cannot read, such as a Cloudflare HTML error page. The status and that - * page's title are the message the user can act on. - */ +/** OpenCode stores `` when it cannot read the provider body. */ export function readableProviderErrorMessage(input: { readonly message?: string | null | undefined; readonly statusCode?: number | null | undefined; readonly responseBody?: string | null | undefined; -}): string { +}): string | undefined { const message = input.message?.trim() ?? ""; - if (message.length > 0 && !isOpaqueProviderMessage(message)) return message; + if (message.length > 0 && message.toLowerCase() !== "") return message; - const detail = - input.responseBody === undefined || input.responseBody === null - ? undefined - : detailFromBody(input.responseBody); - const statusCode = + const title = input.responseBody + ?.match(/]*>([^<]*)<\/title>/i)?.[1] + ?.replace(/\s+/g, " ") + .trim(); + const status = typeof input.statusCode === "number" && Number.isInteger(input.statusCode) ? input.statusCode : undefined; - - if (detail !== undefined && statusCode !== undefined) return `${detail} (HTTP ${statusCode}).`; - if (detail !== undefined) return /[.!?]$/u.test(detail) ? detail : `${detail}.`; - if (statusCode !== undefined) - return `The model provider failed the request (HTTP ${statusCode}).`; - return "The model provider failed the request and did not include an error message."; -} - -function isOpaqueProviderMessage(message: string): boolean { - return OPAQUE_MESSAGES.has(message.trim().toLowerCase()); -} - -function detailFromBody(body: string): string | undefined { - const trimmed = body.trim(); - if (trimmed.length === 0) return undefined; - const title = htmlTitle(trimmed); - if (title !== undefined) return title; - if (trimmed.startsWith("<")) return undefined; - const jsonMessage = jsonErrorMessage(trimmed); - if (jsonMessage !== undefined) return boundDetail(jsonMessage); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) return undefined; - return boundDetail(trimmed); -} - -function htmlTitle(body: string): string | undefined { - const title = /]*>([^<]{1,300})<\/title>/iu.exec(body)?.[1]; - if (title === undefined) return undefined; - return boundDetail(decodeBasicEntities(title)); -} - -function jsonErrorMessage(body: string): string | undefined { - if (!body.startsWith("{") && !body.startsWith("[")) return undefined; - try { - return stringField(JSON.parse(body), "message"); - } catch { - return undefined; + if (title) return status === undefined ? title : `${title} (HTTP ${status}).`; + if (status !== undefined) return `The model provider failed the request (HTTP ${status}).`; + if (message.length > 0) { + return "The model provider failed the request and did not include an error message."; } -} - -function stringField(value: unknown, key: string): string | undefined { - if (typeof value !== "object" || value === null) return undefined; - const record = value as Record; - const direct = record[key]; - if (typeof direct === "string" && !isOpaqueProviderMessage(direct)) return direct; - const nested = record.error; - if (nested !== undefined && nested !== value) return stringField(nested, key); return undefined; } - -function boundDetail(value: string): string | undefined { - const collapsed = value.replace(/\s+/gu, " ").trim(); - if (collapsed.length === 0 || isOpaqueProviderMessage(collapsed)) return undefined; - return collapsed.length <= MAX_DETAIL_LENGTH - ? collapsed - : `${collapsed.slice(0, MAX_DETAIL_LENGTH - 1)}…`; -} - -function decodeBasicEntities(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll(""", '"') - .replaceAll("'", "'") - .replaceAll("'", "'"); -} From 7de6d57b3e33108566af67366090ca3c6ce267c8 Mon Sep 17 00:00:00 2001 From: Ricardo Romero Date: Sun, 25 Oct 2026 01:54:44 -0300 Subject: [PATCH 4/6] test(opencode): drop the opaque error fixture --- .../src/server/providerErrorMessage.test.ts | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 packages/provider-opencode/src/server/providerErrorMessage.test.ts diff --git a/packages/provider-opencode/src/server/providerErrorMessage.test.ts b/packages/provider-opencode/src/server/providerErrorMessage.test.ts deleted file mode 100644 index db1836a34be65..0000000000000 --- a/packages/provider-opencode/src/server/providerErrorMessage.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; - -import { readableProviderErrorMessage } from "./providerErrorMessage.ts"; - -describe("readableProviderErrorMessage", () => { - // regression: OpenCode stores "" when the provider returns an HTML error page. - it.effect("replaces with the page title and HTTP status", () => - Effect.sync(() => { - assert.equal( - readableProviderErrorMessage({ - message: "", - statusCode: 530, - responseBody: "Cloudflare Tunnel error | llm.example.com | Cloudflare", - }), - "Cloudflare Tunnel error | llm.example.com | Cloudflare (HTTP 530).", - ); - assert.equal( - readableProviderErrorMessage({ message: "Rate limit exceeded." }), - "Rate limit exceeded.", - ); - }), - ); -}); From bfcd2ebdd82b3e54d63093a90a9a91c9c030a76e Mon Sep 17 00:00:00 2001 From: Ricardo Romero Date: Sun, 25 Oct 2026 01:59:23 -0300 Subject: [PATCH 5/6] fix(opencode): shorten the error helper --- .../provider-opencode/src/server/adapter.ts | 10 +++--- .../src/server/providerErrorMessage.ts | 32 ++++++++----------- .../src/server/v2/adapter.ts | 12 ++----- 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 0fdc6ab1adfe9..2553ceb9be7c1 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -862,11 +862,11 @@ function openCodeErrorMessage(event: Extract` when it cannot read the provider body. */ -export function readableProviderErrorMessage(input: { - readonly message?: string | null | undefined; - readonly statusCode?: number | null | undefined; - readonly responseBody?: string | null | undefined; -}): string | undefined { - const message = input.message?.trim() ?? ""; - if (message.length > 0 && message.toLowerCase() !== "") return message; - - const title = input.responseBody +export function readableProviderErrorMessage( + message?: string, + status?: number, + body?: string, +): string | undefined { + const text = message?.trim() ?? ""; + if (text && text.toLowerCase() !== "") return text; + const title = body ?.match(/]*>([^<]*)<\/title>/i)?.[1] ?.replace(/\s+/g, " ") .trim(); - const status = - typeof input.statusCode === "number" && Number.isInteger(input.statusCode) - ? input.statusCode - : undefined; - if (title) return status === undefined ? title : `${title} (HTTP ${status}).`; - if (status !== undefined) return `The model provider failed the request (HTTP ${status}).`; - if (message.length > 0) { - return "The model provider failed the request and did not include an error message."; - } - return undefined; + if (title) return typeof status === "number" ? `${title} (HTTP ${status}).` : title; + if (typeof status === "number") return `The model provider failed the request (HTTP ${status}).`; + return text + ? "The model provider failed the request and did not include an error message." + : undefined; } diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 030228da90ce5..bff75798e9626 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -1109,11 +1109,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr retryDelayMs: Math.max(0, Math.trunc(delayMs)), }, failure: makeProviderFailure({ - message: readableProviderErrorMessage({ - message: error.message, - statusCode: error.status, - responseBody: error.response?.body, - }), + message: readableProviderErrorMessage(error.message, error.status, error.response?.body), code: error.type, class: "provider_error", retryable: true, @@ -2196,11 +2192,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* endExecution(state, turn, { status: "failed", failure: makeProviderFailure({ - message: readableProviderErrorMessage({ - message, - statusCode: status, - responseBody: response?.body, - }), + message: readableProviderErrorMessage(message, status, response?.body), code: type, class: usageLimit ? "usage_limit" : "provider_error", }), From 58215a0c414bbf9a5e342c470e65e5cf1e63c3d2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 04:11:35 +0000 Subject: [PATCH 6/6] fix(opencode): sanitize fallback failures and cover provider details --- .../src/server/adapter.test.ts | 64 +++++++++++++++++++ .../provider-opencode/src/server/adapter.ts | 14 ++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index cec5c7847c885..ed29e578256ba 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -239,6 +239,70 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect.each([ + { + message: "", + statusCode: 503, + responseBody: " Service\n unavailable ", + expected: "Service unavailable (HTTP 503).", + }, + { + message: " ", + statusCode: 502, + responseBody: "bad gateway", + expected: "The model provider failed the request (HTTP 502).", + }, + { + message: "", + expected: "The model provider failed the request and did not include an error message.", + }, + { + message: "provider says no", + statusCode: 503, + responseBody: "Unavailable", + expected: "provider says no", + }, + { + message: "", + statusCode: 503, + responseBody: "https://user:secret@example.com/error?token=secret", + expected: "https://example.com/error (HTTP 503).", + }, + ])("reports readable and sanitized session and turn failures: $expected", (sample) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("provider-error", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { sessionID: "root", error: { name: "APIError", data: sample } }, + }), + ); + const events = yield* Fiber.join(received); + const session = events.findLast((event) => event.type === "provider_session.updated"); + const terminal = events.findLast((event) => event.type === "turn.terminal"); + assert.strictEqual(session?.providerSession.lastError, sample.expected); + assert.strictEqual(terminal?.failure?.message, sample.expected); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect.each(["completed", "failed", "unavailable"] as const)( "normalizes OpenCode step usage for %s turns", (ending) => diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 4b3d26fe4e3dd..08aa2340bb093 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2634,13 +2634,17 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function : [threads.get(event.properties.sessionID)].filter( (state): state is OpenCodeThreadState => state !== undefined, ); - const message = openCodeErrorMessage(event); + const failure = makeProviderFailure({ + message: openCodeErrorMessage(event), + code: event.properties.error?.name ?? null, + class: "provider_error", + }); if ( !isMessageAbortedError(event) && (event.properties.sessionID === undefined || states.some((state) => state.parentSubagent === null)) ) { - yield* updateProviderSession("error", message); + yield* updateProviderSession("error", failure.message); } for (const state of states) { if (state.activeTurn !== null) { @@ -2649,11 +2653,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function state.activeTurn, terminalStatusForError(event, state.activeTurn), { - failure: makeProviderFailure({ - message, - code: event.properties.error?.name ?? null, - class: "provider_error", - }), + failure, threadDisposition: event.properties.sessionID === undefined ? "broken" : "reusable", },