diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index 8db8985d7b0..13d427aab62 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -2,6 +2,7 @@ import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@open import { Effect, Schema } from "effect" import { type streamText } from "ai" import { errorMessage } from "@/util/error" +import { ProviderError } from "@/provider/error" type Result = Awaited> type AISDKEvent = Result["fullStream"] extends AsyncIterable ? T : never @@ -85,6 +86,8 @@ export function toLLMEvents( return Effect.succeed([LLMEvent.stepStart({ index: state.step })]) case "finish-step": + if (event.rawFinishReason === "network_error") + return Effect.fail(new ProviderError.ResponseStreamError("Provider finish_reason: network_error")) return Effect.sync(() => { const original = providerMetadata(event.providerMetadata) const metadata = diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3bfc722e2be..635e697517c 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) + + if (!(error instanceof ProviderError.ResponseStreamError)) throw error + 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", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 052477d0a2e..c67f82d9c71 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -651,6 +651,63 @@ it.live("session.processor effect tests retry OpenAI-compatible midstream server ), ) +it.live("session.processor effect tests retry network_error finish reasons", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + chunks: [ + { + id: "chatcmpl-network-error", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: "network_error" }], + }, + ], + }), + ) + yield* llm.text("after retry") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "retry network error") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "retry network error" }], + tools: {}, + }) + + const parts = yield* MessageV2.parts(msg.id) + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(parts.some((part) => part.type === "text" && part.text === "after retry")).toBe(true) + expect(handle.message.error).toBeUndefined() + }), + { config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests publish retry status updates", () => provideTmpdirServer( ({ dir, llm }) =>