diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20..180f810468c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -29,6 +29,8 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm/ai-sdk" import { LLMNativeRuntime } from "./llm/native-runtime" import { LLMRequestPrep } from "./llm/request" +import { ProviderError } from "@/provider/error" +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -338,6 +340,26 @@ const live: Layer.Layer< } return args.params }, + async wrapStream({ doStream }) { + const result = await doStream() + return { + ...result, + stream: result.stream.pipeThrough( + new TransformStream({ + transform(part, controller) { + if (part.type === "finish" && part.finishReason.raw === "network_error") { + controller.enqueue({ + type: "error", + error: new ProviderError.ResponseStreamError("Provider finish_reason: network_error"), + }) + return + } + controller.enqueue(part) + }, + }), + ), + } + }, }, ], }), diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3bfc722e2be..8dc4bdb7916 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -27,6 +27,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { ProviderError } from "@/provider/error" type ConfigModel = NonNullable[string]["models"]>[string] @@ -832,6 +833,70 @@ describe("session.llm.stream", () => { }, ) + it.instance( + "surfaces network_error finish reasons as retryable stream failures", + () => + Effect.gen(function* () { + const fixture = loadFixture(vivgridFixture.providerID, vivgridFixture.modelID) + const request = waitRequest( + "/chat/completions", + createEventResponse( + [ + { + id: "chatcmpl-network-error", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: "network_error" }], + }, + ], + true, + ), + ) + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make(vivgridFixture.providerID), + ModelV2.ID.make(fixture.model.id), + ) + const sessionID = SessionID.make("session-test-network-error") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_user-network-error"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make(vivgridFixture.providerID), modelID: resolved.id }, + } satisfies SessionV1.User + + const error = yield* drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }).pipe(Effect.flip) + yield* Effect.promise(() => request) + + expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) + expect(error.message).toBe("Provider finish_reason: network_error") + }), + { + config: () => ({ + enabled_providers: [vivgridFixture.providerID], + provider: { + [vivgridFixture.providerID]: { + options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` }, + }, + }, + }), + }, + ) + const cerebrasFixture = { providerID: "cerebras", modelID: "gpt-oss-120b" } it.instance( "replays Cerebras assistant reasoning using the provider-supported field",