mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75140f588a |
@@ -1,7 +1,8 @@
|
|||||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Queue, Schema, Stream } from "effect"
|
||||||
import type { Auth as AuthDef } from "./auth"
|
import type { Auth as AuthDef } from "./auth"
|
||||||
import type { Endpoint } from "./endpoint"
|
import type { Endpoint } from "./endpoint"
|
||||||
import { RequestExecutor } from "./executor"
|
import { RequestExecutor } from "./executor"
|
||||||
|
import type { RetryInfo } from "./executor"
|
||||||
import type { Framing } from "./framing"
|
import type { Framing } from "./framing"
|
||||||
import { HttpTransport } from "./transport"
|
import { HttpTransport } from "./transport"
|
||||||
import type { Transport, TransportRuntime } from "./transport"
|
import type { Transport, TransportRuntime } from "./transport"
|
||||||
@@ -12,10 +13,11 @@ import { applyCachePolicy } from "../cache-policy"
|
|||||||
import * as ProviderShared from "../protocols/shared"
|
import * as ProviderShared from "../protocols/shared"
|
||||||
import * as ToolRuntime from "../tool-runtime"
|
import * as ToolRuntime from "../tool-runtime"
|
||||||
import type { Tools } from "../tool"
|
import type { Tools } from "../tool"
|
||||||
import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID } from "../schema"
|
import type { LLMError, PreparedRequestOf, ProtocolID } from "../schema"
|
||||||
import {
|
import {
|
||||||
GenerationOptions,
|
GenerationOptions,
|
||||||
HttpOptions,
|
HttpOptions,
|
||||||
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
ModelID,
|
ModelID,
|
||||||
@@ -439,6 +441,50 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const retryEvent = (info: RetryInfo) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const http = "http" in info.error.reason ? info.error.reason.http : undefined
|
||||||
|
return LLMEvent.retry({
|
||||||
|
attempt: info.attempt,
|
||||||
|
delayMs: info.delayMs,
|
||||||
|
message: info.error.reason.message,
|
||||||
|
providerMetadata: {
|
||||||
|
opencode: {
|
||||||
|
module: info.error.module,
|
||||||
|
method: info.error.method,
|
||||||
|
reason: info.error.reason._tag,
|
||||||
|
status: ("status" in info.error.reason ? info.error.reason.status : undefined) ?? http?.response?.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const streamRequestObservableWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||||
|
Stream.callback<LLMEvent, LLMError>((queue) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pull = yield* Stream.toPull(
|
||||||
|
streamRequestWith({
|
||||||
|
...runtime,
|
||||||
|
httpOptions: {
|
||||||
|
onRetry: (info) => retryEvent(info).pipe(Effect.map((event) => Queue.offerUnsafe(queue, event))),
|
||||||
|
},
|
||||||
|
})(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
const drain: Effect.Effect<void, never> = Effect.gen(function* () {
|
||||||
|
const chunk = yield* pull
|
||||||
|
for (const event of chunk) Queue.offerUnsafe(queue, event)
|
||||||
|
return yield* drain
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchIf(Cause.isDone, () => Effect.sync(() => Queue.endUnsafe(queue))),
|
||||||
|
Effect.catchTag("LLM.Error", (error) => Queue.fail(queue, error)),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* drain
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
const isToolRunOptions = (input: LLMRequest | ToolRuntime.RunOptions<Tools>): input is ToolRuntime.RunOptions<Tools> =>
|
const isToolRunOptions = (input: LLMRequest | ToolRuntime.RunOptions<Tools>): input is ToolRuntime.RunOptions<Tools> =>
|
||||||
"request" in input && "tools" in input
|
"request" in input && "tools" in input
|
||||||
|
|
||||||
@@ -495,7 +541,7 @@ export const streamRequest = (request: LLMRequest) =>
|
|||||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const stream = streamWith(streamRequestWith({ http: yield* RequestExecutor.Service }))
|
const stream = streamWith(streamRequestObservableWith({ http: yield* RequestExecutor.Service }))
|
||||||
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
|
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -505,7 +551,7 @@ export const layerWithWebSocket: Layer.Layer<Service, never, RequestExecutor.Ser
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const stream = streamWith(
|
const stream = streamWith(
|
||||||
streamRequestWith({
|
streamRequestObservableWith({
|
||||||
http: yield* RequestExecutor.Service,
|
http: yield* RequestExecutor.Service,
|
||||||
webSocket: yield* WebSocketExecutor.Service,
|
webSocket: yield* WebSocketExecutor.Service,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -26,9 +26,20 @@ import {
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
options?: ExecuteOptions,
|
||||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RetryInfo = {
|
||||||
|
readonly attempt: number
|
||||||
|
readonly delayMs: number
|
||||||
|
readonly error: LLMError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecuteOptions = {
|
||||||
|
readonly onRetry?: (info: RetryInfo) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
||||||
|
|
||||||
const BODY_LIMIT = 16_384
|
const BODY_LIMIT = 16_384
|
||||||
@@ -341,14 +352,16 @@ const retryDelay = (error: LLMError, attempt: number) => {
|
|||||||
|
|
||||||
const retryStatusFailures = <A, R>(
|
const retryStatusFailures = <A, R>(
|
||||||
effect: Effect.Effect<A, LLMError, R>,
|
effect: Effect.Effect<A, LLMError, R>,
|
||||||
|
options: ExecuteOptions | undefined,
|
||||||
retries = MAX_RETRIES,
|
retries = MAX_RETRIES,
|
||||||
attempt = 0,
|
attempt = 0,
|
||||||
): Effect.Effect<A, LLMError, R> =>
|
): Effect.Effect<A, LLMError, R> =>
|
||||||
Effect.catchTag(effect, "LLM.Error", (error): Effect.Effect<A, LLMError, R> => {
|
Effect.catchTag(effect, "LLM.Error", (error): Effect.Effect<A, LLMError, R> => {
|
||||||
if (!error.retryable || retries <= 0) return Effect.fail(error)
|
if (!error.retryable || retries <= 0) return Effect.fail(error)
|
||||||
return retryDelay(error, attempt).pipe(
|
return retryDelay(error, attempt).pipe(
|
||||||
|
Effect.tap((delay) => options?.onRetry?.({ attempt: attempt + 1, delayMs: delay, error }) ?? Effect.void),
|
||||||
Effect.flatMap((delay) => Effect.sleep(delay)),
|
Effect.flatMap((delay) => Effect.sleep(delay)),
|
||||||
Effect.flatMap(() => retryStatusFailures(effect, retries - 1, attempt + 1)),
|
Effect.flatMap(() => retryStatusFailures(effect, options, retries - 1, attempt + 1)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -364,7 +377,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
|||||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
execute: (request) => retryStatusFailures(executeOnce(request)),
|
execute: (request, options) => retryStatusFailures(executeOnce(request), options),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
frames: (prepared, request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
runtime.http
|
runtime.http
|
||||||
.execute(prepared.request)
|
.execute(prepared.request, runtime.httpOptions)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.map((response) =>
|
Effect.map((response) =>
|
||||||
prepared.framing.frame(
|
prepared.framing.frame(
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Effect, Stream } from "effect"
|
import type { Effect, Stream } from "effect"
|
||||||
import type { Interface as RequestExecutorInterface } from "../executor"
|
import type { ExecuteOptions, Interface as RequestExecutorInterface } from "../executor"
|
||||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||||
import type { LLMError, LLMRequest } from "../../schema"
|
import type { LLMError, LLMRequest } from "../../schema"
|
||||||
|
|
||||||
export interface TransportRuntime {
|
export interface TransportRuntime {
|
||||||
readonly http: RequestExecutorInterface
|
readonly http: RequestExecutorInterface
|
||||||
readonly webSocket?: WebSocketExecutorInterface
|
readonly webSocket?: WebSocketExecutorInterface
|
||||||
|
readonly httpOptions?: ExecuteOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transport<Body, Prepared, Frame> {
|
export interface Transport<Body, Prepared, Frame> {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Tr
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {
|
}) {
|
||||||
get retryable() {
|
get retryable() {
|
||||||
return false
|
return this.kind === "TransportError" || this.kind === "Timeout"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ export const ProviderErrorEvent = Schema.Struct({
|
|||||||
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
||||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||||
|
|
||||||
|
export const RetryEvent = Schema.Struct({
|
||||||
|
type: Schema.tag("retry"),
|
||||||
|
attempt: Schema.Number,
|
||||||
|
delayMs: Schema.Number,
|
||||||
|
message: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}).annotate({ identifier: "LLM.Event.Retry" })
|
||||||
|
export type RetryEvent = Schema.Schema.Type<typeof RetryEvent>
|
||||||
|
|
||||||
const llmEventTagged = Schema.Union([
|
const llmEventTagged = Schema.Union([
|
||||||
StepStart,
|
StepStart,
|
||||||
TextStart,
|
TextStart,
|
||||||
@@ -220,6 +229,7 @@ const llmEventTagged = Schema.Union([
|
|||||||
StepFinish,
|
StepFinish,
|
||||||
Finish,
|
Finish,
|
||||||
ProviderErrorEvent,
|
ProviderErrorEvent,
|
||||||
|
RetryEvent,
|
||||||
]).pipe(Schema.toTaggedUnion("type"))
|
]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
|
||||||
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||||
@@ -265,6 +275,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
||||||
}),
|
}),
|
||||||
providerError: ProviderErrorEvent.make,
|
providerError: ProviderErrorEvent.make,
|
||||||
|
retry: RetryEvent.make,
|
||||||
is: {
|
is: {
|
||||||
stepStart: llmEventTagged.guards["step-start"],
|
stepStart: llmEventTagged.guards["step-start"],
|
||||||
textStart: llmEventTagged.guards["text-start"],
|
textStart: llmEventTagged.guards["text-start"],
|
||||||
@@ -282,6 +293,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
stepFinish: llmEventTagged.guards["step-finish"],
|
stepFinish: llmEventTagged.guards["step-finish"],
|
||||||
finish: llmEventTagged.guards.finish,
|
finish: llmEventTagged.guards.finish,
|
||||||
providerError: llmEventTagged.guards["provider-error"],
|
providerError: llmEventTagged.guards["provider-error"],
|
||||||
|
retry: llmEventTagged.guards.retry,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Fiber, Layer, Random, Ref } from "effect"
|
import { Effect, Fiber, Layer, Random, Ref, Stream } from "effect"
|
||||||
import * as TestClock from "effect/testing/TestClock"
|
import * as TestClock from "effect/testing/TestClock"
|
||||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLM, LLMError } from "../src"
|
import { LLM, LLMError, TransportReason } from "../src"
|
||||||
import { LLMClient, RequestExecutor } from "../src/route"
|
import { LLMClient, RequestExecutor } from "../src/route"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
import { dynamicResponse } from "./lib/http"
|
import { dynamicResponse, runtimeLayer } from "./lib/http"
|
||||||
import { deltaChunk } from "./lib/openai-chunks"
|
import { deltaChunk, finishChunk } from "./lib/openai-chunks"
|
||||||
import { sseRaw } from "./lib/sse"
|
import { sseRaw } from "./lib/sse"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
|
|
||||||
@@ -18,23 +18,26 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
|
|||||||
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
|
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const httpResponsesLayer = (responses: ReadonlyArray<Response>) =>
|
||||||
|
Layer.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cursor = yield* Ref.make(0)
|
||||||
|
return Layer.succeed(
|
||||||
|
HttpClient.HttpClient,
|
||||||
|
HttpClient.make((request) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||||
|
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const responsesLayer = (responses: ReadonlyArray<Response>) =>
|
const responsesLayer = (responses: ReadonlyArray<Response>) =>
|
||||||
RequestExecutor.layer.pipe(
|
RequestExecutor.layer.pipe(
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.unwrap(
|
httpResponsesLayer(responses),
|
||||||
Effect.gen(function* () {
|
|
||||||
const cursor = yield* Ref.make(0)
|
|
||||||
return Layer.succeed(
|
|
||||||
HttpClient.HttpClient,
|
|
||||||
HttpClient.make((request) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
|
||||||
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +62,30 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const transportThenResponseLayer = (attempts: Ref.Ref<number>, response: Response) =>
|
||||||
|
RequestExecutor.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
HttpClient.HttpClient,
|
||||||
|
HttpClient.make((request) =>
|
||||||
|
Ref.getAndUpdate(attempts, (value) => value + 1).pipe(
|
||||||
|
Effect.flatMap((index) =>
|
||||||
|
index === 0
|
||||||
|
? Effect.fail(
|
||||||
|
new HttpClientError.HttpClientError({
|
||||||
|
reason: new HttpClientError.TransportError({
|
||||||
|
request,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.succeed(HttpClientResponse.fromWeb(request, response)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const randomMidpoint = {
|
const randomMidpoint = {
|
||||||
nextDoubleUnsafe: () => 0.5,
|
nextDoubleUnsafe: () => 0.5,
|
||||||
nextIntUnsafe: () => 0,
|
nextIntUnsafe: () => 0,
|
||||||
@@ -73,6 +100,23 @@ const expectLLMError = (error: unknown) => {
|
|||||||
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
|
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||||
|
|
||||||
describe("RequestExecutor", () => {
|
describe("RequestExecutor", () => {
|
||||||
|
it.effect("only marks transient transport reasons retryable", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const retryable = (kind: string | undefined) =>
|
||||||
|
new LLMError({
|
||||||
|
module: "RequestExecutor",
|
||||||
|
method: "execute",
|
||||||
|
reason: new TransportReason({ message: "HTTP transport failed", kind }),
|
||||||
|
}).retryable
|
||||||
|
|
||||||
|
expect(retryable("TransportError")).toBe(true)
|
||||||
|
expect(retryable("Timeout")).toBe(true)
|
||||||
|
expect(retryable("EncodeError")).toBe(false)
|
||||||
|
expect(retryable("InvalidUrlError")).toBe(false)
|
||||||
|
expect(retryable(undefined)).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("returns redacted diagnostics for retryable rate limits", () =>
|
it.effect("returns redacted diagnostics for retryable rate limits", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
@@ -225,6 +269,57 @@ describe("RequestExecutor", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reports retry attempts before retrying status responses", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const executor = yield* RequestExecutor.Service
|
||||||
|
const retries: RequestExecutor.RetryInfo[] = []
|
||||||
|
const response = yield* executor.execute(request, {
|
||||||
|
onRetry: (info) => Effect.sync(() => retries.push(info)),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(retries).toHaveLength(1)
|
||||||
|
expect(retries[0].attempt).toBe(1)
|
||||||
|
expect(retries[0].delayMs).toBe(0)
|
||||||
|
expect(retries[0].error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
responsesLayer([
|
||||||
|
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
|
||||||
|
new Response("ok", { status: 200 }),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("emits retry events before silently retried stream requests", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = OpenAIChat.model({ id: "gpt-4o-mini", baseURL: "https://api.openai.test/v1" })
|
||||||
|
const events = yield* LLMClient.stream(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.provide(
|
||||||
|
runtimeLayer(
|
||||||
|
httpResponsesLayer([
|
||||||
|
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
|
||||||
|
new Response(
|
||||||
|
sseRaw(
|
||||||
|
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`,
|
||||||
|
`data: ${JSON.stringify(finishChunk("stop"))}`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events[0]).toMatchObject({ type: "retry", attempt: 1, delayMs: 0 })
|
||||||
|
expect(events.some((event) => event.type === "text-delta")).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("marks 504 and 529 status responses retryable", () =>
|
it.effect("marks 504 and 529 status responses retryable", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const failWith = (status: number) =>
|
const failWith = (status: number) =>
|
||||||
@@ -385,6 +480,30 @@ describe("RequestExecutor", () => {
|
|||||||
}).pipe(Effect.provideService(Random.Random, randomMidpoint)),
|
}).pipe(Effect.provideService(Random.Random, randomMidpoint)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("retries retryable transport failures before returning the stream", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const attempts = yield* Ref.make(0)
|
||||||
|
return yield* Effect.gen(function* () {
|
||||||
|
const executor = yield* RequestExecutor.Service
|
||||||
|
const fiber = yield* executor.execute(request).pipe(Effect.forkChild)
|
||||||
|
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
expect(yield* Ref.get(attempts)).toBe(1)
|
||||||
|
|
||||||
|
yield* TestClock.adjust(499)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
expect(yield* Ref.get(attempts)).toBe(1)
|
||||||
|
|
||||||
|
yield* TestClock.adjust(1)
|
||||||
|
const response = yield* Fiber.join(fiber)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(yield* response.text).toBe("ok")
|
||||||
|
expect(yield* Ref.get(attempts)).toBe(2)
|
||||||
|
}).pipe(Effect.provide(transportThenResponseLayer(attempts, new Response("ok", { status: 200 }))))
|
||||||
|
}).pipe(Effect.provideService(Random.Random, randomMidpoint)),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("does not retry after a successful response reaches stream parsing", () =>
|
it.effect("does not retry after a successful response reaches stream parsing", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const attempts = yield* Ref.make(0)
|
const attempts = yield* Ref.make(0)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
|
|||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { SessionID, MessageID, PartID } from "./schema"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
||||||
|
import { LLMError } from "@opencode-ai/llm"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { SyncEvent } from "../sync"
|
import { SyncEvent } from "../sync"
|
||||||
@@ -1168,6 +1169,19 @@ export function fromError(
|
|||||||
},
|
},
|
||||||
{ cause: e },
|
{ cause: e },
|
||||||
).toObject()
|
).toObject()
|
||||||
|
case e instanceof LLMError:
|
||||||
|
const http = "http" in e.reason ? e.reason.http : undefined
|
||||||
|
return new APIError(
|
||||||
|
{
|
||||||
|
message: e.reason.message,
|
||||||
|
statusCode: ("status" in e.reason ? e.reason.status : undefined) ?? http?.response?.status,
|
||||||
|
isRetryable: e.retryable,
|
||||||
|
responseHeaders: http?.response?.headers,
|
||||||
|
responseBody: http?.body,
|
||||||
|
metadata: http?.request.url ? { url: http.request.url } : undefined,
|
||||||
|
},
|
||||||
|
{ cause: e },
|
||||||
|
).toObject()
|
||||||
case e instanceof Error:
|
case e instanceof Error:
|
||||||
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
|
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -303,6 +303,15 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
|
case "retry":
|
||||||
|
yield* status.set(ctx.sessionID, {
|
||||||
|
type: "retry",
|
||||||
|
attempt: value.attempt,
|
||||||
|
message: value.message,
|
||||||
|
next: Date.now() + value.delayMs,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
if (value.id in ctx.reasoningMap) return
|
if (value.id in ctx.reasoningMap) return
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { APICallError } from "ai"
|
import { APICallError } from "ai"
|
||||||
|
import { HttpContext, HttpRequestDetails, HttpResponseDetails, LLMError, TransportReason } from "@opencode-ai/llm"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
@@ -1546,6 +1547,69 @@ describe("session.message-v2.fromError", () => {
|
|||||||
|
|
||||||
expect(result.name).toBe("MessageAbortedError")
|
expect(result.name).toBe("MessageAbortedError")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("classifies native LLM transport errors as retryable APIError", () => {
|
||||||
|
const result = MessageV2.fromError(
|
||||||
|
new LLMError({
|
||||||
|
module: "RequestExecutor",
|
||||||
|
method: "execute",
|
||||||
|
reason: new TransportReason({
|
||||||
|
message: "HTTP transport failed",
|
||||||
|
kind: "TransportError",
|
||||||
|
url: "https://provider.test/v1/chat",
|
||||||
|
http: new HttpContext({
|
||||||
|
request: new HttpRequestDetails({
|
||||||
|
method: "POST",
|
||||||
|
url: "https://provider.test/v1/chat",
|
||||||
|
headers: { "x-safe": "visible" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ providerID },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||||
|
expect((result as MessageV2.APIError).data).toMatchObject({
|
||||||
|
message: "HTTP transport failed",
|
||||||
|
isRetryable: true,
|
||||||
|
metadata: { url: "https://provider.test/v1/chat" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves native LLM HTTP response metadata for retry scheduling", () => {
|
||||||
|
const result = MessageV2.fromError(
|
||||||
|
new LLMError({
|
||||||
|
module: "RequestExecutor",
|
||||||
|
method: "execute",
|
||||||
|
reason: new TransportReason({
|
||||||
|
message: "HTTP transport failed",
|
||||||
|
kind: "TransportError",
|
||||||
|
http: new HttpContext({
|
||||||
|
request: new HttpRequestDetails({
|
||||||
|
method: "POST",
|
||||||
|
url: "https://provider.test/v1/chat",
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
response: new HttpResponseDetails({
|
||||||
|
status: 503,
|
||||||
|
headers: { "retry-after-ms": "1000" },
|
||||||
|
}),
|
||||||
|
body: "busy",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ providerID },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||||
|
expect((result as MessageV2.APIError).data).toMatchObject({
|
||||||
|
statusCode: 503,
|
||||||
|
isRetryable: true,
|
||||||
|
responseHeaders: { "retry-after-ms": "1000" },
|
||||||
|
responseBody: "busy",
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("session.message-v2.latest", () => {
|
describe("session.message-v2.latest", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user