mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd51cdba12 |
+88
-16
@@ -15,20 +15,43 @@ import type {
|
|||||||
SharedV3ProviderOptions,
|
SharedV3ProviderOptions,
|
||||||
} from "@ai-sdk/provider"
|
} from "@ai-sdk/provider"
|
||||||
import {
|
import {
|
||||||
|
APIError,
|
||||||
|
Authentication,
|
||||||
|
BadRequest,
|
||||||
|
ConnectionError,
|
||||||
FinishReason,
|
FinishReason,
|
||||||
InvalidProviderOutputReason,
|
HttpContext,
|
||||||
|
HttpRequestDetails,
|
||||||
|
HttpResponseDetails,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
LLMError,
|
MalformedResponse,
|
||||||
Model,
|
Model,
|
||||||
|
NotFound,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
ProviderMetadata,
|
ProviderMetadata,
|
||||||
ToolResultValue,
|
ToolResultValue,
|
||||||
UnknownProviderReason,
|
classifyApiFailure,
|
||||||
|
isLLMError,
|
||||||
|
type LLMError,
|
||||||
type ContentPart,
|
type ContentPart,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type UsageInput,
|
type UsageInput,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
|
import {
|
||||||
|
APICallError,
|
||||||
|
EmptyResponseBodyError,
|
||||||
|
InvalidArgumentError,
|
||||||
|
InvalidPromptError,
|
||||||
|
InvalidResponseDataError,
|
||||||
|
JSONParseError,
|
||||||
|
LoadAPIKeyError,
|
||||||
|
LoadSettingError,
|
||||||
|
NoContentGeneratedError,
|
||||||
|
NoSuchModelError,
|
||||||
|
TypeValidationError,
|
||||||
|
UnsupportedFunctionalityError,
|
||||||
|
} from "@ai-sdk/provider"
|
||||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
|
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
|
||||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
@@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
|||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: () => language.doStream(options),
|
try: () => language.doStream(options),
|
||||||
catch: (error) => llmError("doStream", error),
|
catch: (error) => llmError(error),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((result) =>
|
Effect.map((result) =>
|
||||||
Stream.fromReadableStream({
|
Stream.fromReadableStream({
|
||||||
evaluate: () => result.stream,
|
evaluate: () => result.stream,
|
||||||
onError: (error) => llmError("readStream", error),
|
onError: (error) => llmError(error),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||||
@@ -608,7 +631,7 @@ function streamPartEvents(
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
case "error":
|
case "error":
|
||||||
return Effect.fail(llmError("stream", event.error))
|
return Effect.fail(llmError(event.error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,16 +689,65 @@ function messageValue(input: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function llmError(method: string, error: unknown) {
|
const BODY_LIMIT = 16_384
|
||||||
const reason =
|
|
||||||
error instanceof LLMError
|
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
|
||||||
? new InvalidProviderOutputReason({ message: error.message })
|
if (!headers) return undefined
|
||||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
const millis = Number(headers["retry-after-ms"])
|
||||||
return new LLMError({
|
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||||
module: "AISDK",
|
const value = headers["retry-after"]
|
||||||
method,
|
if (!value) return undefined
|
||||||
reason,
|
const seconds = Number(value)
|
||||||
})
|
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
||||||
|
const date = Date.parse(value)
|
||||||
|
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
|
||||||
|
// AI SDK route reports failures identically to native protocol routes. An
|
||||||
|
// `APICallError` without a status code is the AI SDK's representation of a
|
||||||
|
// network-level failure (connect refused, reset, DNS), not an API rejection.
|
||||||
|
function llmError(error: unknown): LLMError {
|
||||||
|
if (isLLMError(error)) return error
|
||||||
|
if (APICallError.isInstance(error)) {
|
||||||
|
if (error.statusCode === undefined) {
|
||||||
|
return new ConnectionError({ message: error.message, url: error.url, cause: error })
|
||||||
|
}
|
||||||
|
return classifyApiFailure({
|
||||||
|
message: error.message,
|
||||||
|
status: error.statusCode,
|
||||||
|
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
|
||||||
|
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
|
||||||
|
http: new HttpContext({
|
||||||
|
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
|
||||||
|
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
|
||||||
|
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
|
||||||
|
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
|
||||||
|
return new Authentication({ message: error.message })
|
||||||
|
}
|
||||||
|
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
|
||||||
|
if (
|
||||||
|
InvalidPromptError.isInstance(error) ||
|
||||||
|
InvalidArgumentError.isInstance(error) ||
|
||||||
|
UnsupportedFunctionalityError.isInstance(error)
|
||||||
|
) {
|
||||||
|
return new BadRequest({ message: error.message })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
InvalidResponseDataError.isInstance(error) ||
|
||||||
|
JSONParseError.isInstance(error) ||
|
||||||
|
TypeValidationError.isInstance(error) ||
|
||||||
|
EmptyResponseBodyError.isInstance(error) ||
|
||||||
|
NoContentGeneratedError.isInstance(error)
|
||||||
|
) {
|
||||||
|
return new MalformedResponse({ message: error.message })
|
||||||
|
}
|
||||||
|
return new APIError({ message: error instanceof Error ? error.message : String(error) })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as Generate from "./generate"
|
export * as Generate from "./generate"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
|
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Catalog } from "./catalog"
|
import { Catalog } from "./catalog"
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as SessionCompaction from "./compaction"
|
export * as SessionCompaction from "./compaction"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
@@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => {
|
|||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event))
|
|
||||||
failure = {
|
|
||||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
|
||||||
message: event.message,
|
|
||||||
}
|
|
||||||
if (LLMEvent.is.textDelta(event)) {
|
if (LLMEvent.is.textDelta(event)) {
|
||||||
chunks.push(event.text)
|
chunks.push(event.text)
|
||||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||||
@@ -261,7 +256,7 @@ const make = (dependencies: Dependencies) => {
|
|||||||
}
|
}
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.catchTag("LLM.Error", (error) =>
|
Effect.catchIf(isLLMError, (error) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
failure = toSessionError(error)
|
failure = toSessionError(error)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
export * as SessionRunnerLLM from "./llm"
|
export * as SessionRunnerLLM from "./llm"
|
||||||
|
|
||||||
import {
|
import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
|
||||||
LLM,
|
|
||||||
LLMClient,
|
|
||||||
LLMError,
|
|
||||||
LLMEvent,
|
|
||||||
Message,
|
|
||||||
SystemPart,
|
|
||||||
isContextOverflowFailure,
|
|
||||||
type ProviderErrorEvent,
|
|
||||||
} from "@opencode-ai/llm"
|
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
@@ -230,17 +221,10 @@ const layer = Layer.effect(
|
|||||||
// mid-event.
|
// mid-event.
|
||||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||||
let overflowFailure: ProviderErrorEvent | undefined
|
|
||||||
const providerStream = llm.stream(request).pipe(
|
const providerStream = llm.stream(request).pipe(
|
||||||
Stream.runForEach((event) =>
|
Stream.runForEach((event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (overflowFailure || publisher.hasProviderError()) return
|
if (publisher.hasProviderError()) return
|
||||||
if (LLMEvent.is.providerError(event)) {
|
|
||||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
|
||||||
overflowFailure = event
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield* publish(event)
|
yield* publish(event)
|
||||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||||
if (!toolMaterialization) {
|
if (!toolMaterialization) {
|
||||||
@@ -320,22 +304,21 @@ const layer = Layer.effect(
|
|||||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||||
|
|
||||||
|
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
|
||||||
|
|
||||||
// A context overflow before any assistant output is recoverable: compact and
|
// A context overflow before any assistant output is recoverable: compact and
|
||||||
// restart the step instead of surfacing the provider error.
|
// restart the step instead of surfacing the provider error.
|
||||||
if (
|
if (
|
||||||
recoverOverflow &&
|
recoverOverflow &&
|
||||||
!publisher.hasRetryEvidence() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
llmFailure?._tag === "LLM.ContextOverflow" &&
|
||||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
||||||
"completed"
|
"completed"
|
||||||
)
|
)
|
||||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||||
|
|
||||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
// A thrown LLM failure records the assistant failure unless a provider failure
|
||||||
// thrown LLM failure records the assistant failure unless a provider error was
|
// was already recorded from the stream. Terminal publication waits for owned tools.
|
||||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
|
||||||
if (overflowFailure) yield* publish(overflowFailure)
|
|
||||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
|
||||||
if (llmFailure && !publisher.hasProviderError()) {
|
if (llmFailure && !publisher.hasProviderError()) {
|
||||||
const error = toSessionError(llmFailure)
|
const error = toSessionError(llmFailure)
|
||||||
if (
|
if (
|
||||||
@@ -352,7 +335,8 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
yield* serialized(publisher.failAssistant(error))
|
yield* serialized(publisher.failAssistant(error))
|
||||||
}
|
}
|
||||||
// Provider error events only arrive from the stream, so the flag is final here.
|
// The provider-failed flag is only set while consuming the stream (content-filter
|
||||||
|
// step finish), so it is final here.
|
||||||
const providerFailed = publisher.hasProviderError()
|
const providerFailed = publisher.hasProviderError()
|
||||||
|
|
||||||
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||||
|
|||||||
@@ -428,10 +428,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
return
|
return
|
||||||
case "finish":
|
case "finish":
|
||||||
return
|
return
|
||||||
case "provider-error":
|
|
||||||
providerFailed = true
|
|
||||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as SessionRunnerRetry from "./retry"
|
export * as SessionRunnerRetry from "./retry"
|
||||||
|
|
||||||
import { LLMError } from "@opencode-ai/llm"
|
import type { LLMError } from "@opencode-ai/llm"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Data, Duration, Effect, Schedule } from "effect"
|
import { Data, Duration, Effect, Schedule } from "effect"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
@@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
|
|||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export function isRetryable(error: LLMError) {
|
export function isRetryable(error: LLMError) {
|
||||||
switch (error.reason._tag) {
|
switch (error._tag) {
|
||||||
case "RateLimit":
|
case "LLM.RateLimit":
|
||||||
case "ProviderInternal":
|
case "LLM.ServerError":
|
||||||
case "Transport":
|
case "LLM.ConnectionError":
|
||||||
|
case "LLM.TimeoutError":
|
||||||
return true
|
return true
|
||||||
case "Authentication":
|
case "LLM.Authentication":
|
||||||
case "QuotaExceeded":
|
case "LLM.PermissionDenied":
|
||||||
case "ContentPolicy":
|
case "LLM.NotFound":
|
||||||
case "InvalidProviderOutput":
|
case "LLM.QuotaExceeded":
|
||||||
case "InvalidRequest":
|
case "LLM.ContentPolicy":
|
||||||
case "NoRoute":
|
case "LLM.ContextOverflow":
|
||||||
case "UnknownProvider":
|
case "LLM.MalformedResponse":
|
||||||
|
case "LLM.BadRequest":
|
||||||
|
case "LLM.NoRoute":
|
||||||
|
case "LLM.APIError":
|
||||||
return false
|
return false
|
||||||
default: {
|
default: {
|
||||||
const exhaustive: never = error.reason
|
const exhaustive: never = error
|
||||||
return exhaustive
|
return exhaustive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryAfter = (failure: RetryableFailure) => {
|
const retryAfter = (failure: RetryableFailure) => {
|
||||||
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
|
||||||
return failure.cause.reason.retryAfterMs
|
return failure.cause.retryAfterMs
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as SessionTitle from "./title"
|
export * as SessionTitle from "./title"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
|
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
|
||||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
@@ -49,7 +49,6 @@ const make = (dependencies: Dependencies) => {
|
|||||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!resolved) return
|
if (!resolved) return
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failed = false
|
|
||||||
const streamed = yield* dependencies.llm
|
const streamed = yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => {
|
|||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event)) failed = true
|
|
||||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.as(true),
|
Effect.as(true),
|
||||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
|
||||||
)
|
)
|
||||||
if (!streamed || failed) return
|
if (!streamed) return
|
||||||
const title = chunks
|
const title = chunks
|
||||||
.join("")
|
.join("")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
@@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
|
|||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
|
|
||||||
export function toSessionError(cause: unknown): SessionError.Error {
|
export function toSessionError(cause: unknown): SessionError.Error {
|
||||||
if (cause instanceof LLMError) {
|
if (isLLMError(cause)) {
|
||||||
switch (cause.reason._tag) {
|
switch (cause._tag) {
|
||||||
case "RateLimit":
|
case "LLM.RateLimit":
|
||||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
return { type: "provider.rate-limit", message: cause.message }
|
||||||
case "Authentication":
|
case "LLM.Authentication":
|
||||||
return { type: "provider.auth", message: cause.reason.message }
|
return { type: "provider.auth", message: cause.message }
|
||||||
case "QuotaExceeded":
|
case "LLM.PermissionDenied":
|
||||||
return { type: "provider.quota", message: cause.reason.message }
|
return { type: "provider.auth", message: cause.message }
|
||||||
case "ContentPolicy":
|
case "LLM.NotFound":
|
||||||
return { type: "provider.content-filter", message: cause.reason.message }
|
return { type: "provider.not-found", message: cause.message }
|
||||||
case "Transport":
|
case "LLM.QuotaExceeded":
|
||||||
return { type: "provider.transport", message: cause.reason.message }
|
return { type: "provider.quota", message: cause.message }
|
||||||
case "ProviderInternal":
|
case "LLM.ContentPolicy":
|
||||||
return { type: "provider.internal", message: cause.reason.message }
|
return { type: "provider.content-filter", message: cause.message }
|
||||||
case "InvalidProviderOutput":
|
case "LLM.ContextOverflow":
|
||||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
return { type: "provider.context-overflow", message: cause.message }
|
||||||
case "InvalidRequest":
|
case "LLM.ConnectionError":
|
||||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
return { type: "provider.transport", message: cause.message }
|
||||||
case "NoRoute":
|
case "LLM.TimeoutError":
|
||||||
return { type: "provider.no-route", message: cause.reason.message }
|
return { type: "provider.timeout", message: cause.message }
|
||||||
case "UnknownProvider":
|
case "LLM.ServerError":
|
||||||
return { type: "provider.unknown", message: cause.reason.message }
|
return { type: "provider.internal", message: cause.message }
|
||||||
|
case "LLM.MalformedResponse":
|
||||||
|
return { type: "provider.invalid-output", message: cause.message }
|
||||||
|
case "LLM.BadRequest":
|
||||||
|
return { type: "provider.invalid-request", message: cause.message }
|
||||||
|
case "LLM.NoRoute":
|
||||||
|
return { type: "provider.no-route", message: cause.message }
|
||||||
|
case "LLM.APIError":
|
||||||
|
return { type: "provider.unknown", message: cause.message }
|
||||||
default: {
|
default: {
|
||||||
const exhaustive: never = cause.reason
|
const exhaustive: never = cause
|
||||||
return exhaustive
|
return exhaustive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
AuthenticationReason,
|
APIError,
|
||||||
ContentPolicyReason,
|
Authentication,
|
||||||
InvalidProviderOutputReason,
|
BadRequest,
|
||||||
InvalidRequestReason,
|
ConnectionError,
|
||||||
LLMError,
|
ContentPolicy,
|
||||||
NoRouteReason,
|
ContextOverflow,
|
||||||
|
MalformedResponse,
|
||||||
ModelID,
|
ModelID,
|
||||||
|
NoRoute,
|
||||||
|
NotFound,
|
||||||
|
PermissionDenied,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
ProviderInternalReason,
|
QuotaExceeded,
|
||||||
QuotaExceededReason,
|
RateLimit,
|
||||||
RateLimitReason,
|
RouteID,
|
||||||
TransportReason,
|
ServerError,
|
||||||
UnknownProviderReason,
|
TimeoutError,
|
||||||
ToolFailure,
|
ToolFailure,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
@@ -20,39 +24,33 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
|||||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||||
|
|
||||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
|
||||||
|
|
||||||
describe("toSessionError", () => {
|
describe("toSessionError", () => {
|
||||||
test("maps every LLM reason to the open wire type", () => {
|
test("maps every LLM error tag to the open wire type", () => {
|
||||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
|
||||||
type: "provider.rate-limit",
|
type: "provider.rate-limit",
|
||||||
message: "rate",
|
message: "rate",
|
||||||
})
|
})
|
||||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
|
||||||
"provider.auth",
|
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
|
||||||
)
|
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
|
||||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
|
||||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
|
||||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
|
||||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
|
||||||
"provider.internal",
|
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
|
||||||
)
|
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
|
||||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
|
||||||
"provider.invalid-output",
|
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
|
||||||
)
|
|
||||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
|
||||||
expect(
|
expect(
|
||||||
toSessionError(
|
toSessionError(
|
||||||
llm(
|
new NoRoute({
|
||||||
new NoRouteReason({
|
route: RouteID.make("route"),
|
||||||
route: "route",
|
provider: ProviderID.make("provider"),
|
||||||
provider: ProviderID.make("provider"),
|
model: ModelID.make("model"),
|
||||||
model: ModelID.make("model"),
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
).type,
|
).type,
|
||||||
).toBe("provider.no-route")
|
).toBe("provider.no-route")
|
||||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||||
@@ -71,23 +69,31 @@ describe("toSessionError", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
|
||||||
const eligible = [
|
const eligible = [
|
||||||
llm(new RateLimitReason({ message: "rate" })),
|
new RateLimit({ message: "rate" }),
|
||||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
new ServerError({ message: "internal", status: 500 }),
|
||||||
llm(new TransportReason({ message: "transport" })),
|
new ConnectionError({ message: "reset" }),
|
||||||
|
new TimeoutError({ message: "timed out" }),
|
||||||
]
|
]
|
||||||
const ineligible = [
|
const ineligible = [
|
||||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
new Authentication({ message: "auth" }),
|
||||||
llm(new QuotaExceededReason({ message: "quota" })),
|
new PermissionDenied({ message: "forbidden" }),
|
||||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
new NotFound({ message: "missing" }),
|
||||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
new QuotaExceeded({ message: "quota" }),
|
||||||
llm(new InvalidRequestReason({ message: "request" })),
|
new ContentPolicy({ message: "blocked" }),
|
||||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
new ContextOverflow({ message: "too long" }),
|
||||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
new MalformedResponse({ message: "output" }),
|
||||||
|
new BadRequest({ message: "request" }),
|
||||||
|
new NoRoute({
|
||||||
|
route: RouteID.make("route"),
|
||||||
|
provider: ProviderID.make("provider"),
|
||||||
|
model: ModelID.make("model"),
|
||||||
|
}),
|
||||||
|
new APIError({ message: "unknown" }),
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
|
||||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
import { ConnectionError } from "@opencode-ai/llm"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
@@ -25,17 +25,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
|
|||||||
describe("SessionExecution lifecycle", () => {
|
describe("SessionExecution lifecycle", () => {
|
||||||
test("classifies success and typed failure terminals", () => {
|
test("classifies success and typed failure terminals", () => {
|
||||||
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||||
expect(
|
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
|
||||||
SessionExecution.terminal(
|
type: "failed",
|
||||||
Exit.fail(
|
error: { type: "provider.transport", message: "Disconnected" },
|
||||||
new LLMError({
|
})
|
||||||
module: "test",
|
|
||||||
method: "stream",
|
|
||||||
reason: new TransportReason({ message: "Disconnected" }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
|
||||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||||
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
|
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
|
||||||
type: "failed",
|
type: "failed",
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
|
APIError,
|
||||||
|
BadRequest,
|
||||||
|
ConnectionError,
|
||||||
|
ContextOverflow,
|
||||||
LLMClient,
|
LLMClient,
|
||||||
LLMError,
|
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
Model,
|
Model,
|
||||||
|
RateLimit,
|
||||||
ToolFailure,
|
ToolFailure,
|
||||||
TransportReason,
|
|
||||||
InvalidRequestReason,
|
|
||||||
RateLimitReason,
|
|
||||||
type LLMClientShape,
|
type LLMClientShape,
|
||||||
|
type LLMError,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||||
@@ -68,8 +70,9 @@ import { asc, eq } from "drizzle-orm"
|
|||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const requests: LLMRequest[] = []
|
const requests: LLMRequest[] = []
|
||||||
|
type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
|
||||||
let response: LLMEvent[] = []
|
let response: LLMEvent[] = []
|
||||||
let responses: LLMEvent[][] | undefined
|
let responses: ScriptedResponse[] | undefined
|
||||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||||
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
||||||
let streamGate: Deferred.Deferred<void> | undefined
|
let streamGate: Deferred.Deferred<void> | undefined
|
||||||
@@ -92,9 +95,12 @@ const client = Layer.succeed(
|
|||||||
responseStream = undefined
|
responseStream = undefined
|
||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
|
const scripted = responses === undefined ? response : (responses.shift() ?? [])
|
||||||
const events = streamFailure
|
const events = streamFailure
|
||||||
? Stream.fail(streamFailure)
|
? Stream.fail(streamFailure)
|
||||||
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
|
: Array.isArray(scripted)
|
||||||
|
? Stream.fromIterable(scripted)
|
||||||
|
: scripted
|
||||||
if (!streamGate) return events
|
if (!streamGate) return events
|
||||||
return Stream.unwrap(
|
return Stream.unwrap(
|
||||||
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
||||||
@@ -480,26 +486,16 @@ const setup = Effect.gen(function* () {
|
|||||||
return yield* SessionV2.Service
|
return yield* SessionV2.Service
|
||||||
})
|
})
|
||||||
|
|
||||||
const providerUnavailable = () =>
|
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
|
||||||
new LLMError({
|
|
||||||
module: "test",
|
|
||||||
method: "stream",
|
|
||||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const invalidRequest = () =>
|
const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
|
||||||
new LLMError({
|
|
||||||
module: "test",
|
|
||||||
method: "stream",
|
|
||||||
reason: new InvalidRequestReason({ message: "Invalid request" }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const rateLimited = (retryAfterMs?: number) =>
|
const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
|
||||||
new LLMError({
|
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
|
||||||
module: "test",
|
|
||||||
method: "stream",
|
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
|
||||||
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
|
|
||||||
})
|
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
|
||||||
|
|
||||||
const setupOverflowRecovery = Effect.gen(function* () {
|
const setupOverflowRecovery = Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
@@ -1646,14 +1642,14 @@ describe("SessionRunnerLLM", () => {
|
|||||||
yield* admit(session, "Earlier question")
|
yield* admit(session, "Earlier question")
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
|
||||||
const compaction = yield* session.compact({ sessionID })
|
const compaction = yield* session.compact({ sessionID })
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: { type: "provider.error", message: "summary unavailable" },
|
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1763,7 +1759,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
currentModel = compactModel
|
currentModel = compactModel
|
||||||
requests.length = 0
|
requests.length = 0
|
||||||
responses = [
|
responses = [
|
||||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
|
||||||
reply.text("Must not run", "text-after-failed-compaction"),
|
reply.text("Must not run", "text-after-failed-compaction"),
|
||||||
]
|
]
|
||||||
yield* admit(session, "Recent exact request ".repeat(180))
|
yield* admit(session, "Recent exact request ".repeat(180))
|
||||||
@@ -1786,10 +1782,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
responses = [
|
responses = [
|
||||||
[
|
failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
|
||||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
||||||
],
|
|
||||||
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
||||||
reply.text("Recovered", "text-final"),
|
reply.text("Recovered", "text-final"),
|
||||||
]
|
]
|
||||||
@@ -1816,7 +1809,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
currentModel = model
|
currentModel = model
|
||||||
responses = [
|
responses = [
|
||||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
Stream.fail(contextOverflow()),
|
||||||
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||||
reply.text("Recovered", "text-final-unknown-limit"),
|
reply.text("Recovered", "text-final-unknown-limit"),
|
||||||
]
|
]
|
||||||
@@ -1836,7 +1829,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
currentModel = undersizedContextModel
|
currentModel = undersizedContextModel
|
||||||
responses = [
|
responses = [
|
||||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
Stream.fail(contextOverflow()),
|
||||||
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||||
reply.text("Recovered", "text-final-undersized-limit"),
|
reply.text("Recovered", "text-final-undersized-limit"),
|
||||||
]
|
]
|
||||||
@@ -1854,10 +1847,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
it.effect("persists a second context overflow after one recovery", () =>
|
it.effect("persists a second context overflow after one recovery", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
const overflow = () => [
|
const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
|
||||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
|
||||||
]
|
|
||||||
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
||||||
yield* admit(session, "Continue")
|
yield* admit(session, "Continue")
|
||||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||||
@@ -1873,16 +1863,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
it.effect("recovers once from a raw context overflow failure", () =>
|
it.effect("recovers once from a raw context overflow failure", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
responseStream = Stream.fail(
|
responseStream = Stream.fail(contextOverflow())
|
||||||
new LLMError({
|
|
||||||
module: "test",
|
|
||||||
method: "stream",
|
|
||||||
reason: new InvalidRequestReason({
|
|
||||||
message: "prompt too long",
|
|
||||||
classification: "context-overflow",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
responses = [
|
responses = [
|
||||||
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
||||||
reply.text("Recovered", "text-final"),
|
reply.text("Recovered", "text-final"),
|
||||||
@@ -1901,10 +1882,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
responses = [
|
responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
|
||||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
|
||||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
|
||||||
]
|
|
||||||
yield* admit(session, "Continue")
|
yield* admit(session, "Continue")
|
||||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||||
|
|
||||||
@@ -1915,7 +1893,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
error: { type: "provider.error", message: "summary unavailable" },
|
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(context.slice(-3)).toMatchObject([
|
expect(context.slice(-3)).toMatchObject([
|
||||||
@@ -1929,10 +1907,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
responses = [
|
responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
|
||||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
|
||||||
reply.text("## Objective\n- Interrupted", "text-summary"),
|
|
||||||
]
|
|
||||||
const firstGate = yield* Deferred.make<void>()
|
const firstGate = yield* Deferred.make<void>()
|
||||||
const summaryGate = yield* Deferred.make<void>()
|
const summaryGate = yield* Deferred.make<void>()
|
||||||
streamGate = firstGate
|
streamGate = firstGate
|
||||||
@@ -3515,7 +3490,10 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Fail durably")
|
yield* admit(session, "Fail durably")
|
||||||
|
|
||||||
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
responseStream = failingResponse(
|
||||||
|
[LLMEvent.stepStart({ index: 0 })],
|
||||||
|
new APIError({ message: "Provider unavailable" }),
|
||||||
|
)
|
||||||
|
|
||||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||||
|
|
||||||
@@ -3532,7 +3510,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Fail before step")
|
yield* admit(session, "Fail before step")
|
||||||
|
|
||||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
|
||||||
|
|
||||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||||
|
|
||||||
@@ -3620,13 +3598,15 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Fail after output")
|
yield* admit(session, "Fail after output")
|
||||||
|
|
||||||
response = [
|
responseStream = failingResponse(
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
[
|
||||||
LLMEvent.textStart({ id: "text-partial" }),
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
LLMEvent.textStart({ id: "text-partial" }),
|
||||||
LLMEvent.textEnd({ id: "text-partial" }),
|
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
LLMEvent.textEnd({ id: "text-partial" }),
|
||||||
]
|
],
|
||||||
|
contextOverflow(),
|
||||||
|
)
|
||||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||||
|
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
@@ -3784,11 +3764,13 @@ describe("SessionRunnerLLM", () => {
|
|||||||
toolExecutionGate = yield* Deferred.make<void>()
|
toolExecutionGate = yield* Deferred.make<void>()
|
||||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||||
toolExecutionsReady = 1
|
toolExecutionsReady = 1
|
||||||
response = [
|
responseStream = failingResponse(
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
[
|
||||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||||
]
|
],
|
||||||
|
new APIError({ message: "Provider unavailable" }),
|
||||||
|
)
|
||||||
|
|
||||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||||
yield* Deferred.await(toolExecutionsStarted)
|
yield* Deferred.await(toolExecutionsStarted)
|
||||||
@@ -3815,11 +3797,10 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Fail hosted tool durably")
|
yield* admit(session, "Fail hosted tool durably")
|
||||||
|
|
||||||
response = [
|
responseStream = failingResponse(
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
[LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
|
||||||
hostedCall("call-hosted-provider-error", "effect"),
|
new APIError({ message: "Provider unavailable" }),
|
||||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
)
|
||||||
]
|
|
||||||
|
|
||||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||||
|
|
||||||
@@ -3846,11 +3827,13 @@ describe("SessionRunnerLLM", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Defect while provider fails")
|
yield* admit(session, "Defect while provider fails")
|
||||||
response = [
|
responseStream = failingResponse(
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
[
|
||||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||||
]
|
],
|
||||||
|
new APIError({ message: "Provider unavailable" }),
|
||||||
|
)
|
||||||
|
|
||||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||||
|
|
||||||
|
|||||||
@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
|
|||||||
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
|
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
|
||||||
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
|
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
|
||||||
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
|
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
|
||||||
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
|
- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
|
||||||
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||||
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
|
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
|
||||||
|
|
||||||
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
|
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
|
||||||
@@ -291,7 +291,7 @@ Use this order for every protocol module:
|
|||||||
|
|
||||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||||
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
|
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
|
||||||
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
|
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
|
|||||||
export { Auth } from "./route/auth"
|
export { Auth } from "./route/auth"
|
||||||
export { Provider } from "./provider"
|
export { Provider } from "./provider"
|
||||||
export { ProviderPackage } from "./provider-package"
|
export { ProviderPackage } from "./provider-package"
|
||||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
|
||||||
export type {
|
export type {
|
||||||
RouteModelInput,
|
RouteModelInput,
|
||||||
RouteRoutedModelInput,
|
RouteRoutedModelInput,
|
||||||
|
|||||||
+6
-14
@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
|
|||||||
import {
|
import {
|
||||||
GenerationOptions,
|
GenerationOptions,
|
||||||
HttpOptions,
|
HttpOptions,
|
||||||
InvalidProviderOutputReason,
|
MalformedResponse,
|
||||||
LLMError,
|
type LLMError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
@@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
|||||||
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
||||||
)
|
)
|
||||||
if (!call || !LLMEvent.is.toolCall(call))
|
if (!call || !LLMEvent.is.toolCall(call))
|
||||||
return yield* new LLMError({
|
return yield* new MalformedResponse({
|
||||||
module: "LLM",
|
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||||
method: "generateObject",
|
|
||||||
reason: new InvalidProviderOutputReason({
|
|
||||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const object = yield* tool._decode(call.input).pipe(
|
const object = yield* tool._decode(call.input).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new LLMError({
|
new MalformedResponse({
|
||||||
module: "LLM",
|
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||||
method: "generateObject",
|
|
||||||
reason: new InvalidProviderOutputReason({
|
|
||||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
import { isContextOverflow } from "../provider-error"
|
import { classifyApiFailure } from "../provider-error"
|
||||||
import * as Cache from "./utils/cache"
|
import * as Cache from "./utils/cache"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||||
@@ -832,15 +832,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
|||||||
return message || type || "Anthropic Messages stream error"
|
return message || type || "Anthropic Messages stream error"
|
||||||
}
|
}
|
||||||
|
|
||||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
const onError = (event: AnthropicEvent) =>
|
||||||
state,
|
classifyApiFailure({
|
||||||
[
|
message: providerErrorMessage(event),
|
||||||
LLMEvent.providerError({
|
code: event.error?.type,
|
||||||
message: providerErrorMessage(event),
|
})
|
||||||
classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||||
@@ -848,7 +844,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
|||||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
if (event.type === "error") return Effect.fail(onError(event))
|
||||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { BedrockEventStream } from "./bedrock-event-stream"
|
import { BedrockEventStream } from "./bedrock-event-stream"
|
||||||
import { isContextOverflow } from "../provider-error"
|
import { classifyApiFailure } from "../provider-error"
|
||||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||||
import { BedrockAuth } from "./utils/bedrock-auth"
|
import { BedrockAuth } from "./utils/bedrock-auth"
|
||||||
import { BedrockCache } from "./utils/bedrock-cache"
|
import { BedrockCache } from "./utils/bedrock-cache"
|
||||||
@@ -586,27 +586,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
|
const exception = (
|
||||||
const message =
|
[
|
||||||
event.internalServerException?.message ??
|
["internalServerException", event.internalServerException],
|
||||||
event.modelStreamErrorException?.message ??
|
["modelStreamErrorException", event.modelStreamErrorException],
|
||||||
event.serviceUnavailableException?.message ??
|
["serviceUnavailableException", event.serviceUnavailableException],
|
||||||
"Bedrock Converse stream error"
|
["throttlingException", event.throttlingException],
|
||||||
return [state, [LLMEvent.providerError({ message })]] as const
|
["validationException", event.validationException],
|
||||||
}
|
|
||||||
|
|
||||||
if (event.validationException || event.throttlingException) {
|
|
||||||
const message =
|
|
||||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
|
||||||
return [
|
|
||||||
state,
|
|
||||||
[
|
|
||||||
LLMEvent.providerError({
|
|
||||||
message,
|
|
||||||
classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
] as const
|
] as const
|
||||||
|
).find((entry) => entry[1] !== undefined)
|
||||||
|
if (exception) {
|
||||||
|
return yield* classifyApiFailure({
|
||||||
|
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
||||||
|
code: exception[0],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return [state, []] as const
|
return [state, []] as const
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
import { isContextOverflow } from "../provider-error"
|
import { classifyApiFailure } from "../provider-error"
|
||||||
import { OpenAIOptions } from "./utils/openai-options"
|
import { OpenAIOptions } from "./utils/openai-options"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||||
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
|||||||
const NO_EVENTS: StepResult["1"] = []
|
const NO_EVENTS: StepResult["1"] = []
|
||||||
|
|
||||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||||
// `finish` event; `response.failed` is a hard failure that emits a
|
// `finish` event; `response.failed` is a hard failure that fails the stream
|
||||||
// `provider-error`. All three end the stream — kept in one set so `step` and
|
// with a classified `LLMError`. All three end the stream — kept in one set so
|
||||||
// the protocol's `terminal` predicate stay in sync.
|
// `step` and the protocol's `terminal` predicate stay in sync.
|
||||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
@@ -907,24 +907,11 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
|
|||||||
return message || code || fallback
|
return message || code || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
|
const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
|
||||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
classifyApiFailure({
|
||||||
const message = providerErrorMessage(event, fallback)
|
message: providerErrorMessage(event, fallback),
|
||||||
return LLMEvent.providerError({
|
code: event.code || event.error?.code || event.response?.error?.code || undefined,
|
||||||
message,
|
|
||||||
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
|
||||||
state,
|
|
||||||
[providerError(event, "OpenAI Responses response failed")],
|
|
||||||
]
|
|
||||||
|
|
||||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
|
||||||
state,
|
|
||||||
[providerError(event, "OpenAI Responses stream error")],
|
|
||||||
]
|
|
||||||
|
|
||||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||||
@@ -950,8 +937,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
|||||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||||
return Effect.succeed(onResponseFinish(state, event))
|
return Effect.succeed(onResponseFinish(state, event))
|
||||||
if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
|
if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
|
||||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
|
||||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
|
|||||||
import * as Sse from "effect/unstable/encoding/Sse"
|
import * as Sse from "effect/unstable/encoding/Sse"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import {
|
import {
|
||||||
InvalidProviderOutputReason,
|
BadRequest,
|
||||||
InvalidRequestReason,
|
MalformedResponse,
|
||||||
LLMError,
|
type LLMError,
|
||||||
type ContentPart,
|
type ContentPart,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
@@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const eventError = (route: string, message: string, raw?: string) =>
|
export const eventError = (route: string, message: string, raw?: string) =>
|
||||||
new LLMError({
|
new MalformedResponse({ route, message, raw })
|
||||||
module: "ProviderShared",
|
|
||||||
method: "stream",
|
|
||||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const parseJson = (route: string, input: string, message: string) =>
|
export const parseJson = (route: string, input: string, message: string) =>
|
||||||
Effect.try({
|
Effect.try({
|
||||||
@@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
|
|||||||
* Canonical invalid-request constructor. Lift one-line `const invalid =
|
* Canonical invalid-request constructor. Lift one-line `const invalid =
|
||||||
* (message) => invalidRequest(message)` aliases out of every
|
* (message) => invalidRequest(message)` aliases out of every
|
||||||
* route so the error constructor lives in one place. If we ever extend
|
* route so the error constructor lives in one place. If we ever extend
|
||||||
* `InvalidRequestReason` with route context or trace metadata, the change
|
* `BadRequest` with route context or trace metadata, the change lands here.
|
||||||
* lands here.
|
|
||||||
*/
|
*/
|
||||||
export const invalidRequest = (message: string) =>
|
export const invalidRequest = (message: string) => new BadRequest({ message })
|
||||||
new LLMError({
|
|
||||||
module: "ProviderShared",
|
|
||||||
method: "request",
|
|
||||||
reason: new InvalidRequestReason({ message }),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||||
route: string,
|
route: string,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||||
|
|
||||||
type StreamKey = string | number
|
type StreamKey = string | number
|
||||||
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
||||||
result instanceof LLMError
|
isLLMError(result)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a tool call whose start event arrived before any argument deltas.
|
* Register a tool call whose start event arrived before any argument deltas.
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { Schema } from "effect"
|
import {
|
||||||
import { LLMError, ProviderErrorEvent } from "./schema"
|
APIError,
|
||||||
|
Authentication,
|
||||||
|
BadRequest,
|
||||||
|
ContentPolicy,
|
||||||
|
ContextOverflow,
|
||||||
|
HttpContext,
|
||||||
|
HttpRateLimitDetails,
|
||||||
|
NotFound,
|
||||||
|
PermissionDenied,
|
||||||
|
ProviderMetadata,
|
||||||
|
QuotaExceeded,
|
||||||
|
RateLimit,
|
||||||
|
ServerError,
|
||||||
|
type LLMError,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/prompt is too long/i,
|
/prompt is too long/i,
|
||||||
@@ -27,7 +41,102 @@ const patterns = [
|
|||||||
export const isContextOverflow = (message: string) =>
|
export const isContextOverflow = (message: string) =>
|
||||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||||
|
|
||||||
export const isContextOverflowFailure = (failure: unknown) =>
|
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
|
||||||
failure instanceof LLMError
|
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||||
|
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
|
||||||
|
|
||||||
|
const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => LLMError> = {
|
||||||
|
overloaded_error: serverError,
|
||||||
|
api_error: serverError,
|
||||||
|
server_error: serverError,
|
||||||
|
internal_error: serverError,
|
||||||
|
server_is_overloaded: serverError,
|
||||||
|
internalServerException: serverError,
|
||||||
|
serviceUnavailableException: serverError,
|
||||||
|
modelStreamErrorException: serverError,
|
||||||
|
rate_limit_error: rateLimit,
|
||||||
|
rate_limit_exceeded: rateLimit,
|
||||||
|
too_many_requests: rateLimit,
|
||||||
|
throttlingException: rateLimit,
|
||||||
|
authentication_error: (_input, common) => new Authentication(common),
|
||||||
|
permission_error: (_input, common) => new PermissionDenied(common),
|
||||||
|
not_found_error: (_input, common) => new NotFound(common),
|
||||||
|
invalid_request_error: (_input, common) => new BadRequest(common),
|
||||||
|
invalid_prompt: (_input, common) => new BadRequest(common),
|
||||||
|
validationException: (_input, common) => new BadRequest(common),
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiFailure {
|
||||||
|
readonly message: string
|
||||||
|
readonly status?: number | undefined
|
||||||
|
/** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */
|
||||||
|
readonly code?: string | undefined
|
||||||
|
readonly retryAfterMs?: number | undefined
|
||||||
|
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||||
|
readonly requestID?: string | undefined
|
||||||
|
readonly http?: HttpContext | undefined
|
||||||
|
readonly providerMetadata?: ProviderMetadata | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommonFields = {
|
||||||
|
readonly message: string
|
||||||
|
readonly status: number | undefined
|
||||||
|
readonly code: string | undefined
|
||||||
|
readonly requestID: string | undefined
|
||||||
|
readonly http: HttpContext | undefined
|
||||||
|
readonly providerMetadata: ProviderMetadata | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverError(input: ApiFailure, common: CommonFields) {
|
||||||
|
return new ServerError({ ...common, retryAfterMs: input.retryAfterMs })
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimit(input: ApiFailure, common: CommonFields) {
|
||||||
|
return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One classifier for every failure a remote API deliberately reports.
|
||||||
|
* Protocols call it with in-stream error payloads, the request executor with
|
||||||
|
* non-2xx responses, and the AI SDK adapter with `APICallError`s, so all
|
||||||
|
* three surfaces produce identical `LLMError` tags.
|
||||||
|
*
|
||||||
|
* Precedence: context overflow (most specific, 4xx-scoped), content policy,
|
||||||
|
* HTTP status, provider code, then the generic `APIError` fallback.
|
||||||
|
*/
|
||||||
|
export const classifyApiFailure = (input: ApiFailure): LLMError => {
|
||||||
|
const common: CommonFields = {
|
||||||
|
message: input.message,
|
||||||
|
status: input.status,
|
||||||
|
code: input.code,
|
||||||
|
requestID: input.requestID,
|
||||||
|
http: input.http,
|
||||||
|
providerMetadata: input.providerMetadata,
|
||||||
|
}
|
||||||
|
const body = input.http?.body ?? ""
|
||||||
|
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||||
|
if (
|
||||||
|
clientScoped &&
|
||||||
|
((input.code !== undefined && OVERFLOW_CODES.has(input.code)) ||
|
||||||
|
isContextOverflow(input.message) ||
|
||||||
|
(body.length > 0 && isContextOverflow(body)))
|
||||||
|
)
|
||||||
|
return new ContextOverflow(common)
|
||||||
|
if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common)
|
||||||
|
if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common)
|
||||||
|
if (input.status === 401) return new Authentication(common)
|
||||||
|
if (input.status === 403) return new PermissionDenied(common)
|
||||||
|
if (input.status === 404) return new NotFound(common)
|
||||||
|
if (input.status === 429) {
|
||||||
|
if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common)
|
||||||
|
return rateLimit(input, common)
|
||||||
|
}
|
||||||
|
if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common)
|
||||||
|
if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422)
|
||||||
|
return new BadRequest(common)
|
||||||
|
const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code]
|
||||||
|
if (byCode) return byCode(input, common)
|
||||||
|
return new APIError(common)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Config, Effect, Redacted } from "effect"
|
import { Config, Effect, Redacted } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
|
import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema"
|
||||||
|
|
||||||
export class MissingCredentialError extends Error {
|
export class MissingCredentialError extends Error {
|
||||||
readonly _tag = "MissingCredentialError"
|
readonly _tag = "MissingCredentialError"
|
||||||
@@ -135,16 +135,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toLLMError = (error: AuthError): LLMError => {
|
const toLLMError = (error: AuthError): LLMError => {
|
||||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
|
||||||
return new LLMError({
|
if (error instanceof Config.ConfigError)
|
||||||
module: "Auth",
|
return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
|
||||||
method: "apply",
|
|
||||||
reason:
|
|
||||||
error instanceof MissingCredentialError
|
|
||||||
? new AuthenticationReason({ message: error.message, kind: "missing" })
|
|
||||||
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
|
|||||||
import {
|
import {
|
||||||
GenerationOptions,
|
GenerationOptions,
|
||||||
HttpOptions,
|
HttpOptions,
|
||||||
|
isLLMError,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
Model,
|
Model,
|
||||||
ModelLimits,
|
ModelLimits,
|
||||||
LLMError as LLMErrorClass,
|
|
||||||
PreparedRequest,
|
PreparedRequest,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
mergeGenerationOptions,
|
mergeGenerationOptions,
|
||||||
@@ -225,10 +225,39 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
|||||||
|
|
||||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||||
if (failed instanceof LLMErrorClass) return failed
|
if (failed !== undefined && isLLMError(failed)) return failed
|
||||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal contract for every route, native or synthetic: a successful
|
||||||
|
* stream emits exactly one `finish`, and nothing after it. EOF before
|
||||||
|
* `finish` means the provider stream was truncated (proxy cut, silent
|
||||||
|
* drop) and must fail rather than let a partial response settle as
|
||||||
|
* complete. Applied after protocol parsing so `stream.onHalt` flushes are
|
||||||
|
* still subject to it.
|
||||||
|
*/
|
||||||
|
const enforceTerminal = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) => {
|
||||||
|
let finished = false
|
||||||
|
return events.pipe(
|
||||||
|
Stream.mapEffect((event) => {
|
||||||
|
if (finished)
|
||||||
|
return Effect.fail(
|
||||||
|
ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal finish event`),
|
||||||
|
)
|
||||||
|
if (event.type === "finish") finished = true
|
||||||
|
return Effect.succeed(event)
|
||||||
|
}),
|
||||||
|
Stream.concat(
|
||||||
|
Stream.suspend(() =>
|
||||||
|
finished
|
||||||
|
? Stream.empty
|
||||||
|
: Stream.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||||
): Route<Body, Prepared> {
|
): Route<Body, Prepared> {
|
||||||
@@ -383,7 +412,10 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
|
|||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const compiled = yield* compile(request)
|
const compiled = yield* compile(request)
|
||||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
const route = `${compiled.request.model.provider}/${compiled.route.id}`
|
||||||
|
return compiled.route
|
||||||
|
.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||||
|
.pipe(enforceTerminal(route))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
@@ -8,21 +8,15 @@ import {
|
|||||||
HttpClientResponse,
|
HttpClientResponse,
|
||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
import {
|
import {
|
||||||
AuthenticationReason,
|
ConnectionError,
|
||||||
ContentPolicyReason,
|
|
||||||
HttpContext,
|
HttpContext,
|
||||||
HttpRateLimitDetails,
|
HttpRateLimitDetails,
|
||||||
HttpRequestDetails,
|
HttpRequestDetails,
|
||||||
HttpResponseDetails,
|
HttpResponseDetails,
|
||||||
InvalidRequestReason,
|
TimeoutError,
|
||||||
LLMError,
|
type LLMError,
|
||||||
ProviderInternalReason,
|
|
||||||
QuotaExceededReason,
|
|
||||||
RateLimitReason,
|
|
||||||
TransportReason,
|
|
||||||
UnknownProviderReason,
|
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { isContextOverflow } from "../provider-error"
|
import { classifyApiFailure } from "../provider-error"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
@@ -85,8 +79,6 @@ const requestId = (headers: Record<string, string>) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
|
|
||||||
|
|
||||||
const retryAfterMs = (headers: Record<string, string>) => {
|
const retryAfterMs = (headers: Record<string, string>) => {
|
||||||
const millis = Number(headers["retry-after-ms"])
|
const millis = Number(headers["retry-after-ms"])
|
||||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||||
@@ -219,56 +211,21 @@ const responseHttp = (input: {
|
|||||||
rateLimit: input.rateLimit,
|
rateLimit: input.rateLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusReason = (input: {
|
const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||||
readonly status: number
|
|
||||||
readonly message: string
|
// Provider machine code from a JSON error body (`error.code` / `error.type`),
|
||||||
readonly retryAfterMs?: number | undefined
|
// fed to the shared classifier so code-based rules (overflow, quota) work on
|
||||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
// HTTP rejections too. Truncated or non-JSON bodies yield undefined.
|
||||||
readonly http: HttpContext
|
const providerCode = (body: string | undefined) => {
|
||||||
}) => {
|
if (!body) return undefined
|
||||||
const body = input.http.body ?? ""
|
const decoded = Option.getOrUndefined(decodeBodyJson(body))
|
||||||
if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
|
if (typeof decoded !== "object" || decoded === null) return undefined
|
||||||
return new ContentPolicyReason({ message: input.message, http: input.http })
|
const error = (decoded as Record<string, unknown>).error
|
||||||
}
|
if (typeof error !== "object" || error === null) return undefined
|
||||||
if (input.status === 401) {
|
const fields = error as Record<string, unknown>
|
||||||
return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
|
if (typeof fields.code === "string") return fields.code
|
||||||
}
|
if (typeof fields.type === "string") return fields.type
|
||||||
if (input.status === 403) {
|
return undefined
|
||||||
return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
|
|
||||||
}
|
|
||||||
if (input.status === 429) {
|
|
||||||
if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
|
|
||||||
return new QuotaExceededReason({ message: input.message, http: input.http })
|
|
||||||
}
|
|
||||||
return new RateLimitReason({
|
|
||||||
message: input.message,
|
|
||||||
retryAfterMs: input.retryAfterMs,
|
|
||||||
rateLimit: input.rateLimit,
|
|
||||||
http: input.http,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
input.status === 400 ||
|
|
||||||
input.status === 404 ||
|
|
||||||
input.status === 409 ||
|
|
||||||
input.status === 413 ||
|
|
||||||
input.status === 422
|
|
||||||
) {
|
|
||||||
return new InvalidRequestReason({
|
|
||||||
message: input.message,
|
|
||||||
classification: isContextOverflow(body) ? "context-overflow" : undefined,
|
|
||||||
http: input.http,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (input.status >= 500 || providerInternalStatus(input.status)) {
|
|
||||||
return new ProviderInternalReason({
|
|
||||||
message: input.message,
|
|
||||||
status: input.status,
|
|
||||||
retryAfterMs: input.retryAfterMs,
|
|
||||||
http: input.http,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusError =
|
const statusError =
|
||||||
@@ -281,58 +238,55 @@ const statusError =
|
|||||||
const retryAfter = retryAfterMs(headers)
|
const retryAfter = retryAfterMs(headers)
|
||||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||||
const details = responseBody(body, request)
|
const details = responseBody(body, request)
|
||||||
return yield* new LLMError({
|
return yield* classifyApiFailure({
|
||||||
module: "RequestExecutor",
|
status: response.status,
|
||||||
method: "execute",
|
message: providerMessage(response.status, details),
|
||||||
reason: statusReason({
|
code: providerCode(details.body),
|
||||||
status: response.status,
|
retryAfterMs: retryAfter,
|
||||||
message: providerMessage(response.status, details),
|
rateLimit,
|
||||||
retryAfterMs: retryAfter,
|
requestID: requestId(headers),
|
||||||
|
http: responseHttp({
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
redactedNames,
|
||||||
|
body: details,
|
||||||
|
requestId: requestId(headers),
|
||||||
rateLimit,
|
rateLimit,
|
||||||
http: responseHttp({
|
|
||||||
request,
|
|
||||||
response,
|
|
||||||
redactedNames,
|
|
||||||
body: details,
|
|
||||||
requestId: requestId(headers),
|
|
||||||
rateLimit,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||||
const transportError = (input: {
|
const httpContext = (request: HttpClientRequest.HttpClientRequest | undefined) =>
|
||||||
|
request ? new HttpContext({ request: requestDetails(request, redactedNames) }) : undefined
|
||||||
|
const connectionError = (input: {
|
||||||
readonly message: string
|
readonly message: string
|
||||||
readonly kind?: string | undefined
|
readonly kind?: string | undefined
|
||||||
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||||
}) =>
|
}) =>
|
||||||
new LLMError({
|
new ConnectionError({
|
||||||
module: "RequestExecutor",
|
message: input.message,
|
||||||
method: "execute",
|
kind: input.kind,
|
||||||
reason: new TransportReason({
|
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||||
message: input.message,
|
http: httpContext(input.request),
|
||||||
kind: input.kind,
|
cause: error,
|
||||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
|
||||||
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (Cause.isTimeoutError(error)) {
|
if (Cause.isTimeoutError(error)) {
|
||||||
return transportError({ message: error.message, kind: "Timeout" })
|
return new TimeoutError({ message: error.message })
|
||||||
}
|
}
|
||||||
if (!HttpClientError.isHttpClientError(error)) {
|
if (!HttpClientError.isHttpClientError(error)) {
|
||||||
return transportError({ message: "HTTP transport failed" })
|
return connectionError({ message: "HTTP transport failed" })
|
||||||
}
|
}
|
||||||
const request = "request" in error ? error.request : undefined
|
const request = "request" in error ? error.request : undefined
|
||||||
if (error.reason._tag === "TransportError") {
|
if (error.reason._tag === "TransportError") {
|
||||||
return transportError({
|
return connectionError({
|
||||||
message: error.reason.description ?? "HTTP transport failed",
|
message: error.reason.description ?? "HTTP transport failed",
|
||||||
kind: error.reason._tag,
|
kind: error.reason._tag,
|
||||||
request,
|
request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return transportError({
|
return connectionError({
|
||||||
message: `HTTP transport failed: ${error.reason._tag}`,
|
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||||
kind: error.reason._tag,
|
kind: error.reason._tag,
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { LLMError, TransportReason } from "../../schema"
|
import { ConnectionError, type LLMError } from "../../schema"
|
||||||
import * as HttpTransport from "./http"
|
import * as HttpTransport from "./http"
|
||||||
import type { Transport } from "./index"
|
import type { Transport } from "./index"
|
||||||
|
|
||||||
@@ -27,15 +27,10 @@ type WebSocketConstructorWithHeaders = new (
|
|||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
|
||||||
|
|
||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
_method: string,
|
||||||
message: string,
|
message: string,
|
||||||
input: { readonly url?: string; readonly kind?: string } = {},
|
input: { readonly url?: string; readonly kind?: string } = {},
|
||||||
) =>
|
) => new ConnectionError({ message, url: input.url, kind: input.kind })
|
||||||
new LLMError({
|
|
||||||
module: "WebSocketExecutor",
|
|
||||||
method,
|
|
||||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const eventMessage = (event: Event) => {
|
const eventMessage = (event: Event) => {
|
||||||
if ("message" in event && typeof event.message === "string") return event.message
|
if ("message" in event && typeof event.message === "string") return event.message
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||||
|
|
||||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
|
||||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
|
||||||
|
|
||||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
@@ -31,118 +28,150 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
|
|||||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
|
/**
|
||||||
_tag: Schema.tag("InvalidRequest"),
|
* Fields shared by every failure the remote API deliberately reported —
|
||||||
|
* whether as a non-2xx response, an SSE error event, a WebSocket error
|
||||||
|
* message, or a binary exception frame. `status` is absent when the error
|
||||||
|
* arrived mid-stream without an HTTP status; `code` carries the provider's
|
||||||
|
* machine-readable error code (e.g. `context_length_exceeded`) when one
|
||||||
|
* exists.
|
||||||
|
*/
|
||||||
|
const apiFailureFields = {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
parameter: Schema.optional(Schema.String),
|
status: Schema.optional(Schema.Number),
|
||||||
classification: Schema.optional(ProviderFailureClassification),
|
code: Schema.optional(Schema.String),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
requestID: Schema.optional(Schema.String),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
|
||||||
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
|
|
||||||
_tag: Schema.tag("NoRoute"),
|
|
||||||
route: RouteID,
|
|
||||||
provider: ProviderID,
|
|
||||||
model: ModelID,
|
|
||||||
}) {
|
|
||||||
get message() {
|
|
||||||
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
|
/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
|
||||||
_tag: Schema.tag("Authentication"),
|
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
|
||||||
message: Schema.String,
|
...apiFailureFields,
|
||||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
parameter: Schema.optional(Schema.String),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
http: Schema.optional(HttpContext),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
|
/** Credentials are missing, invalid, or expired (401). */
|
||||||
_tag: Schema.tag("RateLimit"),
|
export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
|
||||||
message: Schema.String,
|
...apiFailureFields,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** Authenticated but not allowed (403). */
|
||||||
|
export class PermissionDenied extends Schema.TaggedErrorClass<PermissionDenied>()("LLM.PermissionDenied", {
|
||||||
|
...apiFailureFields,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** Model or endpoint does not exist (404). */
|
||||||
|
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("LLM.NotFound", {
|
||||||
|
...apiFailureFields,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */
|
||||||
|
export class RateLimit extends Schema.TaggedErrorClass<RateLimit>()("LLM.RateLimit", {
|
||||||
|
...apiFailureFields,
|
||||||
retryAfterMs: Schema.optional(Schema.Number),
|
retryAfterMs: Schema.optional(Schema.Number),
|
||||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
http: Schema.optional(HttpContext),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
|
/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
|
||||||
_tag: Schema.tag("QuotaExceeded"),
|
export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
|
||||||
message: Schema.String,
|
...apiFailureFields,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
http: Schema.optional(HttpContext),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
|
/** Provider refused the content for policy/safety reasons. */
|
||||||
_tag: Schema.tag("ContentPolicy"),
|
export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
|
||||||
message: Schema.String,
|
...apiFailureFields,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
http: Schema.optional(HttpContext),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
|
/**
|
||||||
_tag: Schema.tag("ProviderInternal"),
|
* The request exceeds the model's context window. Designated tag because
|
||||||
message: Schema.String,
|
* Core recovers from it structurally (compaction) rather than surfacing it.
|
||||||
status: Schema.Number,
|
* Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
|
||||||
|
*/
|
||||||
|
export class ContextOverflow extends Schema.TaggedErrorClass<ContextOverflow>()("LLM.ContextOverflow", {
|
||||||
|
...apiFailureFields,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */
|
||||||
|
export class ServerError extends Schema.TaggedErrorClass<ServerError>()("LLM.ServerError", {
|
||||||
|
...apiFailureFields,
|
||||||
retryAfterMs: Schema.optional(Schema.Number),
|
retryAfterMs: Schema.optional(Schema.Number),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
http: Schema.optional(HttpContext),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
|
/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
|
||||||
_tag: Schema.tag("Transport"),
|
export class APIError extends Schema.TaggedErrorClass<APIError>()("LLM.APIError", {
|
||||||
|
...apiFailureFields,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */
|
||||||
|
export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("LLM.ConnectionError", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
kind: Schema.optional(Schema.String),
|
kind: Schema.optional(Schema.String),
|
||||||
url: Schema.optional(Schema.String),
|
url: Schema.optional(Schema.String),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
|
cause: Schema.optional(Schema.Defect()),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
/** The request or stream read timed out before the provider answered. */
|
||||||
"LLM.Error.InvalidProviderOutput",
|
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
|
||||||
)({
|
message: Schema.String,
|
||||||
_tag: Schema.tag("InvalidProviderOutput"),
|
url: Schema.optional(Schema.String),
|
||||||
|
http: Schema.optional(HttpContext),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport succeeded but the content broke the protocol contract:
|
||||||
|
* undecodable frames, premature EOF without a terminal `finish`, duplicate
|
||||||
|
* terminals, or output after a terminal event.
|
||||||
|
*/
|
||||||
|
export class MalformedResponse extends Schema.TaggedErrorClass<MalformedResponse>()("LLM.MalformedResponse", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
route: Schema.optional(Schema.String),
|
route: Schema.optional(Schema.String),
|
||||||
raw: Schema.optional(Schema.String),
|
raw: Schema.optional(Schema.String),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
|
/** Request construction failed locally: the selected model resolves to no executable route. */
|
||||||
_tag: Schema.tag("UnknownProvider"),
|
export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
|
||||||
message: Schema.String,
|
route: RouteID,
|
||||||
status: Schema.optional(Schema.Number),
|
provider: ProviderID,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
model: ModelID,
|
||||||
http: Schema.optional(HttpContext),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const LLMErrorReason = Schema.Union([
|
|
||||||
InvalidRequestReason,
|
|
||||||
NoRouteReason,
|
|
||||||
AuthenticationReason,
|
|
||||||
RateLimitReason,
|
|
||||||
QuotaExceededReason,
|
|
||||||
ContentPolicyReason,
|
|
||||||
ProviderInternalReason,
|
|
||||||
TransportReason,
|
|
||||||
InvalidProviderOutputReason,
|
|
||||||
UnknownProviderReason,
|
|
||||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
|
||||||
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
|
|
||||||
|
|
||||||
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
|
||||||
module: Schema.String,
|
|
||||||
method: Schema.String,
|
|
||||||
reason: LLMErrorReason,
|
|
||||||
}) {
|
}) {
|
||||||
override readonly cause = this.reason
|
|
||||||
|
|
||||||
override get message() {
|
override get message() {
|
||||||
return `${this.module}.${this.method}: ${this.reason.message}`
|
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const members = [
|
||||||
|
BadRequest,
|
||||||
|
Authentication,
|
||||||
|
PermissionDenied,
|
||||||
|
NotFound,
|
||||||
|
RateLimit,
|
||||||
|
QuotaExceeded,
|
||||||
|
ContentPolicy,
|
||||||
|
ContextOverflow,
|
||||||
|
ServerError,
|
||||||
|
APIError,
|
||||||
|
ConnectionError,
|
||||||
|
TimeoutError,
|
||||||
|
MalformedResponse,
|
||||||
|
NoRoute,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const LLMErrorSchema = Schema.Union(members)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every failure of one LLM request. `LLMEvent` streams carry output only;
|
||||||
|
* all failures — HTTP rejections, in-stream provider error events, transport
|
||||||
|
* failures, and protocol-contract violations — exit through this union on
|
||||||
|
* the stream's error channel.
|
||||||
|
*/
|
||||||
|
export type LLMError = typeof LLMErrorSchema.Type
|
||||||
|
|
||||||
|
export const isLLMError = (value: unknown): value is LLMError =>
|
||||||
|
members.some((member) => value instanceof member)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Failure type for tool execute handlers. Handlers must map their internal
|
* Failure type for tool execute handlers. Handlers must map their internal
|
||||||
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them
|
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Schema } from "effect"
|
|||||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
||||||
import { ModelSchema } from "./options"
|
import { ModelSchema } from "./options"
|
||||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||||
import { ProviderFailureClassification } from "./errors"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token usage reported by an LLM provider.
|
* Token usage reported by an LLM provider.
|
||||||
@@ -197,14 +196,6 @@ export const Finish = Schema.Struct({
|
|||||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||||
export type Finish = Schema.Schema.Type<typeof Finish>
|
export type Finish = Schema.Schema.Type<typeof Finish>
|
||||||
|
|
||||||
export const ProviderErrorEvent = Schema.Struct({
|
|
||||||
type: Schema.tag("provider-error"),
|
|
||||||
message: Schema.String,
|
|
||||||
classification: Schema.optional(ProviderFailureClassification),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
|
||||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
|
||||||
|
|
||||||
const llmEventTagged = Schema.Union([
|
const llmEventTagged = Schema.Union([
|
||||||
StepStart,
|
StepStart,
|
||||||
TextStart,
|
TextStart,
|
||||||
@@ -221,7 +212,6 @@ const llmEventTagged = Schema.Union([
|
|||||||
ToolError,
|
ToolError,
|
||||||
StepFinish,
|
StepFinish,
|
||||||
Finish,
|
Finish,
|
||||||
ProviderErrorEvent,
|
|
||||||
]).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 }
|
||||||
@@ -271,7 +261,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
...input,
|
...input,
|
||||||
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
||||||
}),
|
}),
|
||||||
providerError: ProviderErrorEvent.make,
|
|
||||||
is: {
|
is: {
|
||||||
stepStart: llmEventTagged.guards["step-start"],
|
stepStart: llmEventTagged.guards["step-start"],
|
||||||
textStart: llmEventTagged.guards["text-start"],
|
textStart: llmEventTagged.guards["text-start"],
|
||||||
@@ -288,7 +277,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
toolError: llmEventTagged.guards["tool-error"],
|
toolError: llmEventTagged.guards["tool-error"],
|
||||||
stepFinish: llmEventTagged.guards["step-finish"],
|
stepFinish: llmEventTagged.guards["step-finish"],
|
||||||
finish: llmEventTagged.guards.finish,
|
finish: llmEventTagged.guards.finish,
|
||||||
providerError: llmEventTagged.guards["provider-error"],
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||||
@@ -374,13 +362,6 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
|||||||
finishReason: event.reason,
|
finishReason: event.reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (LLMEvent.is.providerError(event)) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
events,
|
|
||||||
finishReason: state.finishReason ?? "error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
events,
|
events,
|
||||||
@@ -589,7 +570,7 @@ export namespace LLMResponse {
|
|||||||
/** Purely fold one provider-neutral event into the attempt assembly state. */
|
/** Purely fold one provider-neutral event into the attempt assembly state. */
|
||||||
export const reduce = reduceResponseState
|
export const reduce = reduceResponseState
|
||||||
|
|
||||||
/** Return a completed response only after a terminal finish or provider error. */
|
/** Return a completed response only after a terminal finish event. */
|
||||||
export const complete = (state: State): LLMResponse | undefined =>
|
export const complete = (state: State): LLMResponse | undefined =>
|
||||||
state.finishReason === undefined
|
state.finishReason === undefined
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Ref } from "effect"
|
import { Effect, Layer, Ref } from "effect"
|
||||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLM, LLMError } from "../src"
|
import { LLM, isLLMError, type LLMError } 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 } from "./lib/http"
|
||||||
@@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
|
|||||||
)
|
)
|
||||||
|
|
||||||
const expectLLMError = (error: unknown) => {
|
const expectLLMError = (error: unknown) => {
|
||||||
expect(error).toBeInstanceOf(LLMError)
|
expect(isLLMError(error)).toBe(true)
|
||||||
if (!(error instanceof LLMError)) throw new Error("expected LLMError")
|
if (!isLLMError(error)) throw new Error("expected LLMError")
|
||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
|
const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
|
||||||
|
|
||||||
describe("RequestExecutor", () => {
|
describe("RequestExecutor", () => {
|
||||||
it.effect("classifies context overflow responses", () =>
|
it.effect("classifies context overflow responses", () =>
|
||||||
@@ -73,7 +73,7 @@ describe("RequestExecutor", () => {
|
|||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
responsesLayer([
|
||||||
@@ -91,8 +91,7 @@ describe("RequestExecutor", () => {
|
|||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
|
||||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,8 +101,7 @@ describe("RequestExecutor", () => {
|
|||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
|
||||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,24 +112,22 @@ describe("RequestExecutor", () => {
|
|||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error).toMatchObject({
|
expect(error).toMatchObject({
|
||||||
reason: {
|
_tag: "LLM.RateLimit",
|
||||||
_tag: "RateLimit",
|
retryAfterMs: 0,
|
||||||
retryAfterMs: 0,
|
rateLimit: { retryAfterMs: 0 },
|
||||||
rateLimit: { retryAfterMs: 0 },
|
http: {
|
||||||
http: {
|
requestId: "req_123",
|
||||||
requestId: "req_123",
|
request: {
|
||||||
request: {
|
method: "POST",
|
||||||
method: "POST",
|
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
||||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
},
|
||||||
},
|
response: {
|
||||||
response: {
|
status: 429,
|
||||||
status: 429,
|
headers: {
|
||||||
headers: {
|
"retry-after-ms": "0",
|
||||||
"retry-after-ms": "0",
|
"x-request-id": "req_123",
|
||||||
"x-request-id": "req_123",
|
"x-api-key": "<redacted>",
|
||||||
"x-api-key": "<redacted>",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -169,8 +165,8 @@ describe("RequestExecutor", () => {
|
|||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
|
||||||
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
|
expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
|
||||||
retryAfterMs: 0,
|
retryAfterMs: 0,
|
||||||
limit: { requests: "500", tokens: "30000" },
|
limit: { requests: "500", tokens: "30000" },
|
||||||
remaining: { requests: "499", tokens: "29900" },
|
remaining: { requests: "499", tokens: "29900" },
|
||||||
@@ -202,7 +198,7 @@ describe("RequestExecutor", () => {
|
|||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
expect(error).toMatchObject({ _tag: "LLM.ServerError" })
|
||||||
expect(errorHttp(error)?.rateLimit).toEqual({
|
expect(errorHttp(error)?.rateLimit).toEqual({
|
||||||
retryAfterMs: 0,
|
retryAfterMs: 0,
|
||||||
limit: { requests: "100", "input-tokens": "10000" },
|
limit: { requests: "100", "input-tokens": "10000" },
|
||||||
@@ -245,12 +241,12 @@ describe("RequestExecutor", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
|
||||||
expect(yield* Ref.get(attempts)).toBe(1)
|
expect(yield* Ref.get(attempts)).toBe(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("marks 504 and 529 status responses as provider-internal", () =>
|
it.effect("marks 504 and 529 status responses as server errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const failWith = (status: number) =>
|
const failWith = (status: number) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -258,7 +254,7 @@ describe("RequestExecutor", () => {
|
|||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
responsesLayer([
|
||||||
@@ -281,7 +277,7 @@ describe("RequestExecutor", () => {
|
|||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
expect(error).toMatchObject({ _tag: "LLM.Authentication" })
|
||||||
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
||||||
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -360,7 +356,7 @@ describe("RequestExecutor", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
|
||||||
expect(yield* Ref.get(attempts)).toBe(1)
|
expect(yield* Ref.get(attempts)).toBe(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ describe("request option precedence", () => {
|
|||||||
}),
|
}),
|
||||||
).pipe(Effect.flip)
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({
|
expect(error).toMatchObject({
|
||||||
_tag: "InvalidRequest",
|
_tag: "LLM.BadRequest",
|
||||||
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
|
import { isLLMError, LLM, Message, ToolCallPart } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import * as Anthropic from "../../src/providers/anthropic"
|
import * as Anthropic from "../../src/providers/anthropic"
|
||||||
import { weatherToolName } from "../recorded-scenarios"
|
import { weatherToolName } from "../recorded-scenarios"
|
||||||
@@ -22,6 +22,9 @@ const malformedToolOrderRequest = LLM.request({
|
|||||||
Message.user("Use that result to answer briefly."),
|
Message.user("Use that result to answer briefly."),
|
||||||
],
|
],
|
||||||
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
|
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
|
||||||
|
// The cassette predates the `cache: "auto"` default; pin the policy off so
|
||||||
|
// the replayed request matches the recorded wire shape.
|
||||||
|
cache: "none",
|
||||||
})
|
})
|
||||||
|
|
||||||
const recorded = recordedTests({
|
const recorded = recordedTests({
|
||||||
@@ -33,13 +36,17 @@ const recorded = recordedTests({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("Anthropic Messages sad-path recorded", () => {
|
describe("Anthropic Messages sad-path recorded", () => {
|
||||||
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
|
recorded.effect.with(
|
||||||
Effect.gen(function* () {
|
"rejects malformed assistant tool order",
|
||||||
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
|
// The cassette predates a test rename; keep replaying the existing recording.
|
||||||
|
{ id: "rejects-malformed-assistant-tool-order-without-patch", tags: ["tool", "sad-path"] },
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(LLMError)
|
expect(isLLMError(error)).toBe(true)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||||
expect(error.message).toContain("HTTP 400")
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
|
||||||
import { Auth, LLMClient } from "../../src/route"
|
import { Auth, LLMClient } from "../../src/route"
|
||||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
|
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
|
||||||
@@ -484,23 +484,25 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("emits provider-error events for mid-stream provider errors", () =>
|
it.effect("fails the stream for mid-stream provider errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
|
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prefix the error type so consumers can distinguish overloads, rate
|
// Prefix the error type so consumers can distinguish overloads, rate
|
||||||
// limits, and quota errors without parsing the message string.
|
// limits, and quota errors without parsing the message string.
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
|
expect(isLLMError(error)).toBe(true)
|
||||||
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("classifies prompt-too-long provider errors", () =>
|
it.effect("classifies prompt-too-long provider errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(
|
fixedResponse(
|
||||||
sseEvents({
|
sseEvents({
|
||||||
@@ -509,35 +511,35 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([
|
expect(error).toMatchObject({
|
||||||
{
|
_tag: "LLM.ContextOverflow",
|
||||||
type: "provider-error",
|
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
||||||
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
})
|
||||||
classification: "context-overflow",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to error type when no message is present", () =>
|
it.effect("falls back to error type when no message is present", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to a stable default when error payload is absent", () =>
|
it.effect("falls back to a stable default when error payload is absent", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
|
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -553,8 +555,8 @@ describe("Anthropic Messages route", () => {
|
|||||||
Effect.flip,
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(LLMError)
|
expect(isLLMError(error)).toBe(true)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||||
expect(error.message).toContain("HTTP 400")
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
|||||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import { AmazonBedrock } from "../../src/providers"
|
import { AmazonBedrock } from "../../src/providers"
|
||||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||||
@@ -355,33 +355,31 @@ describe("Bedrock Converse route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("emits provider-error for throttlingException", () =>
|
it.effect("fails the stream for throttlingException", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
const body = eventStreamBody(
|
||||||
["messageStart", { role: "assistant" }],
|
["messageStart", { role: "assistant" }],
|
||||||
["throttlingException", { message: "Slow down" }],
|
["throttlingException", { message: "Slow down" }],
|
||||||
)
|
)
|
||||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||||
|
|
||||||
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
|
expect(isLLMError(error)).toBe(true)
|
||||||
type: "provider-error",
|
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "Slow down" })
|
||||||
message: "Slow down",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("classifies input-too-long validation exceptions", () =>
|
it.effect("classifies input-too-long validation exceptions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
|
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
|
expect(error).toMatchObject({
|
||||||
type: "provider-error",
|
_tag: "LLM.ContextOverflow",
|
||||||
message: "Input is too long for requested model",
|
message: "Input is too long for requested model",
|
||||||
classification: "context-overflow",
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
import { isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
|
||||||
import { Auth, LLMClient } from "../../src/route"
|
import { Auth, LLMClient } from "../../src/route"
|
||||||
import * as Gemini from "../../src/protocols/gemini"
|
import * as Gemini from "../../src/protocols/gemini"
|
||||||
import { ProviderShared } from "../../src/protocols/shared"
|
import { ProviderShared } from "../../src/protocols/shared"
|
||||||
@@ -560,8 +560,8 @@ describe("Gemini route", () => {
|
|||||||
Effect.flip,
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(LLMError)
|
expect(isLLMError(error)).toBe(true)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
|
||||||
expect(error.message).toContain("Invalid google/gemini stream event")
|
expect(error.message).toContain("Invalid google/gemini stream event")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
import { isLLMError, LLM, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||||
@@ -614,8 +614,11 @@ describe("OpenAI Chat route", () => {
|
|||||||
const input = LLM.updateRequest(request, {
|
const input = LLM.updateRequest(request, {
|
||||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
})
|
})
|
||||||
const events = Array.from(
|
const events: LLMEvent[] = []
|
||||||
yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))),
|
const streamError = yield* LLMClient.stream(input).pipe(
|
||||||
|
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
|
||||||
|
Effect.flip,
|
||||||
|
Effect.provide(fixedResponse(body)),
|
||||||
)
|
)
|
||||||
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||||
|
|
||||||
@@ -626,6 +629,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||||
])
|
])
|
||||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||||
|
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
|
||||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -662,8 +666,8 @@ describe("OpenAI Chat route", () => {
|
|||||||
Effect.flip,
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(LLMError)
|
expect(isLLMError(error)).toBe(true)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||||
expect(error.message).toContain("HTTP 400")
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
import { isLLMError, LLM, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
@@ -1368,37 +1368,41 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("emits provider-error events for mid-stream provider errors", () =>
|
it.effect("fails the stream for mid-stream provider errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prefix the code so consumers see the failure mode, not just the
|
// Prefix the code so consumers see the failure mode, not just the
|
||||||
// sometimes-generic provider message. The bare message alone meant
|
// sometimes-generic provider message. The bare message alone meant
|
||||||
// production errors like rate limits were indistinguishable from
|
// production errors like rate limits were indistinguishable from
|
||||||
// unrelated stream failures.
|
// unrelated stream failures.
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
|
expect(isLLMError(error)).toBe(true)
|
||||||
|
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to error code when no message is present", () =>
|
it.effect("falls back to error code when no message is present", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to error code when message is empty", () =>
|
it.effect("falls back to error code when message is empty", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1408,7 +1412,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
// "OpenAI Responses response failed" string, hiding the real cause.
|
// "OpenAI Responses response failed" string, hiding the real cause.
|
||||||
it.effect("surfaces response.failed details from response.error", () =>
|
it.effect("surfaces response.failed details from response.error", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(
|
fixedResponse(
|
||||||
sseEvents({
|
sseEvents({
|
||||||
@@ -1420,15 +1424,16 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("surfaces response.failed code when no nested message is present", () =>
|
it.effect("surfaces response.failed code when no nested message is present", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(
|
fixedResponse(
|
||||||
sseEvents({
|
sseEvents({
|
||||||
@@ -1437,9 +1442,10 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1450,7 +1456,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
// when they bubble up an HTTP error as an SSE `error` event. Honour
|
// when they bubble up an HTTP error as an SSE `error` event. Honour
|
||||||
// both shapes so the user still sees the underlying cause instead
|
// both shapes so the user still sees the underlying cause instead
|
||||||
// of the catch-all string.
|
// of the catch-all string.
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(
|
fixedResponse(
|
||||||
sseEvents({
|
sseEvents({
|
||||||
@@ -1459,21 +1465,19 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([
|
expect(error).toMatchObject({
|
||||||
{
|
_tag: "LLM.ContextOverflow",
|
||||||
type: "provider-error",
|
message: "context_length_exceeded: prompt too long",
|
||||||
message: "context_length_exceeded: prompt too long",
|
})
|
||||||
classification: "context-overflow",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("surfaces error event details nested under error", () =>
|
it.effect("surfaces error event details nested under error", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(
|
fixedResponse(
|
||||||
sseEvents({
|
sseEvents({
|
||||||
@@ -1488,21 +1492,19 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([
|
expect(error).toMatchObject({
|
||||||
{
|
_tag: "LLM.ContextOverflow",
|
||||||
type: "provider-error",
|
message: "context_length_exceeded: prompt too long",
|
||||||
message: "context_length_exceeded: prompt too long",
|
})
|
||||||
classification: "context-overflow",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("accepts nullable fields in spec-compliant error events", () =>
|
it.effect("accepts nullable fields in spec-compliant error events", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
fixedResponse(
|
fixedResponse(
|
||||||
sseEvents({
|
sseEvents({
|
||||||
@@ -1514,39 +1516,43 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
|
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to a stable default when error is null", () =>
|
it.effect("falls back to a stable default when error is null", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
|
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
|
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||||
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
|
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1562,8 +1568,8 @@ describe("OpenAI Responses route", () => {
|
|||||||
Effect.flip,
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(LLMError)
|
expect(isLLMError(error)).toBe(true)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||||
expect(error.message).toContain("HTTP 400")
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLMError } from "../src/schema"
|
import { isLLMError } from "../src/schema"
|
||||||
import { ToolStream } from "../src/protocols/utils/tool-stream"
|
import { ToolStream } from "../src/protocols/utils/tool-stream"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
|
|
||||||
@@ -40,8 +40,9 @@ describe("ToolStream", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(LLMError)
|
expect(isLLMError(error)).toBe(true)
|
||||||
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
|
if (ToolStream.isError(error))
|
||||||
|
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -418,9 +418,6 @@ const layer = Layer.effect(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "provider-error":
|
|
||||||
throw new Error(value.message)
|
|
||||||
|
|
||||||
case "step-start":
|
case "step-start":
|
||||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||||
yield* session.updatePart({
|
yield* session.updatePart({
|
||||||
|
|||||||
@@ -219,8 +219,7 @@ const fragmentFailureLLM = Layer.succeed(
|
|||||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
|
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
|
||||||
LLMEvent.textStart({ id: "text-1" }),
|
LLMEvent.textStart({ id: "text-1" }),
|
||||||
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
|
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
|
||||||
LLMEvent.providerError({ message: "provider boom" }),
|
).pipe(Stream.concat(Stream.fail(new Error("provider boom")))),
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
|
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
|
||||||
|
|||||||
Reference in New Issue
Block a user