Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton bba1c5bbf4 chore(client): generate provider error types 2026-06-26 00:21:46 -04:00
Kit Langton 007cdd6d70 fix(core): preserve provider session failures 2026-06-25 23:58:00 -04:00
10 changed files with 424 additions and 32 deletions
+60 -3
View File
@@ -581,7 +581,26 @@ export type SessionsContextOutput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: "unknown"; readonly message: string }
readonly error?:
| { readonly type: "unknown"; readonly message: string }
| {
readonly type: "provider"
readonly category:
| "invalid-request"
| "no-route"
| "authentication"
| "rate-limit"
| "quota-exceeded"
| "content-policy"
| "provider-internal"
| "transport"
| "invalid-provider-output"
| "unknown"
readonly message: string
readonly status?: number | null
readonly retryable: boolean
readonly retryAfterMs?: number | null
}
}
| {
readonly type: "compaction"
@@ -794,7 +813,26 @@ export type SessionsEventsOutput =
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly error: { readonly type: "unknown"; readonly message: string }
readonly error:
| { readonly type: "unknown"; readonly message: string }
| {
readonly type: "provider"
readonly category:
| "invalid-request"
| "no-route"
| "authentication"
| "rate-limit"
| "quota-exceeded"
| "content-policy"
| "provider-internal"
| "transport"
| "invalid-provider-output"
| "unknown"
readonly message: string
readonly status?: number | undefined
readonly retryable: boolean
readonly retryAfterMs?: number | undefined
}
}
}
| {
@@ -1198,7 +1236,26 @@ export type SessionsMessageOutput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: "unknown"; readonly message: string }
readonly error?:
| { readonly type: "unknown"; readonly message: string }
| {
readonly type: "provider"
readonly category:
| "invalid-request"
| "no-route"
| "authentication"
| "rate-limit"
| "quota-exceeded"
| "content-policy"
| "provider-internal"
| "transport"
| "invalid-provider-output"
| "unknown"
readonly message: string
readonly status?: number | null
readonly retryable: boolean
readonly retryAfterMs?: number | null
}
}
| {
readonly type: "compaction"
+27 -3
View File
@@ -6,6 +6,7 @@ import {
Message,
SystemPart,
isContextOverflowFailure,
type LLMErrorReason,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@@ -32,7 +33,7 @@ import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { createLLMEventPublisher, providerError } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import { Snapshot } from "../../snapshot"
@@ -87,6 +88,29 @@ import { Snapshot } from "../../snapshot"
* explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
*/
const providerCategories = {
InvalidRequest: "invalid-request",
NoRoute: "no-route",
Authentication: "authentication",
RateLimit: "rate-limit",
QuotaExceeded: "quota-exceeded",
ContentPolicy: "content-policy",
ProviderInternal: "provider-internal",
Transport: "transport",
InvalidProviderOutput: "invalid-provider-output",
UnknownProvider: "unknown",
} as const satisfies Record<LLMErrorReason["_tag"], Parameters<typeof providerError>[0]["category"]>
const toSessionProviderError = (reason: LLMErrorReason) =>
providerError({
category: providerCategories[reason._tag],
status:
("http" in reason ? reason.http?.response?.status : undefined) ??
("status" in reason ? reason.status : undefined),
retryable: reason.retryable,
retryAfterMs: "retryAfterMs" in reason ? reason.retryAfterMs : undefined,
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -283,7 +307,7 @@ export const layer = Layer.effect(
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
yield* withPublication(publisher.failAssistant(toSessionProviderError(llmFailure.reason)))
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -299,7 +323,7 @@ export const layer = Layer.effect(
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (publisher.hasActiveAssistant())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
yield* withPublication(publisher.failAssistant({ type: "unknown", message: "Provider turn interrupted" }))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
@@ -50,6 +50,33 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue):
return { structured: record(settled.structured), content: settled.content }
}
const providerMessages: Record<SessionMessage.ProviderErrorCategory, string> = {
"invalid-request": "Provider rejected the request",
"no-route": "Provider unavailable: no provider route",
authentication: "Provider authentication failed",
"rate-limit": "Provider rate limit exceeded",
"quota-exceeded": "Provider quota exceeded",
"content-policy": "Provider rejected the invalid request due to content policy",
"provider-internal": "Provider service unavailable",
transport: "Provider connection failed",
"invalid-provider-output": "Provider returned an invalid response",
unknown: "Provider request failed",
}
export const providerError = (input: {
readonly category: SessionMessage.ProviderErrorCategory
readonly status?: number
readonly retryable?: boolean
readonly retryAfterMs?: number
}): SessionMessage.ProviderError => ({
type: "provider",
category: input.category,
message: providerMessages[input.category],
status: input.status,
retryable: input.retryable ?? (input.category === "rate-limit" || input.category === "provider-internal"),
retryAfterMs: input.retryAfterMs,
})
/** Persist one provider turn without executing tools or starting a continuation turn. */
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map<
@@ -67,7 +94,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
let providerFailed = false
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
@@ -196,17 +222,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments()
})
const failAssistant = Effect.fnUntraced(function* (message: string) {
if (assistantFailed) return
const failAssistant = Effect.fnUntraced(function* (error: SessionMessage.Error) {
if (providerFailed) return
providerFailed = true
yield* flush()
const assistantMessageID = yield* startAssistant()
assistantActive = false
assistantFailed = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
error: { type: "unknown", message },
error,
})
})
@@ -402,8 +428,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "finish":
return
case "provider-error":
providerFailed = true
yield* failAssistant(event.message)
yield* failAssistant(
providerError({
category: event.category ?? (event.classification === "context-overflow" ? "invalid-request" : "unknown"),
status: event.status,
retryable: event.retryable,
}),
)
return
}
})
+208 -10
View File
@@ -4,6 +4,11 @@ import {
LLMError,
LLMEvent,
Model,
HttpContext,
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
RateLimitReason,
TransportReason,
InvalidRequestReason,
type LLMClientShape,
@@ -515,13 +520,21 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
const expectedContent =
kind === "tool input"
? {
type: "tool",
id: fragmentID(kind, "partial"),
state: { status: "error", error: { message: "Tool execution interrupted" } },
}
: fixture.expectedContent
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: prompt },
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider unavailable" },
content: [fixture.expectedContent],
error: { type: "provider", category: "transport", message: "Provider connection failed", retryable: false },
content: [expectedContent],
},
])
})
@@ -1196,7 +1209,16 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction" },
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
{
type: "assistant",
finish: "error",
error: {
type: "provider",
category: "invalid-request",
message: "Provider rejected the request",
retryable: false,
},
},
])
}),
)
@@ -1244,7 +1266,16 @@ describe("SessionRunnerLLM", () => {
expect(context.some((message) => message.type === "compaction")).toBe(false)
expect(context.slice(-2)).toMatchObject([
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
{
type: "assistant",
finish: "error",
error: {
type: "provider",
category: "invalid-request",
message: "Provider rejected the request",
retryable: false,
},
},
])
}),
)
@@ -2920,7 +2951,16 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail durably" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
{
type: "assistant",
finish: "error",
error: {
type: "provider",
category: "unknown",
message: "Provider request failed",
retryable: false,
},
},
])
}),
)
@@ -2939,7 +2979,16 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail before step" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
{
type: "assistant",
finish: "error",
error: {
type: "provider",
category: "unknown",
message: "Provider request failed",
retryable: false,
},
},
])
}),
)
@@ -2966,7 +3015,12 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { message: "prompt too long" },
error: {
type: "provider",
category: "invalid-request",
message: "Provider rejected the request",
retryable: false,
},
content: [{ type: "text", text: "Partial" }],
},
])
@@ -2985,11 +3039,138 @@ describe("SessionRunnerLLM", () => {
yield* replaySessionProjection(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail raw stream durably" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
{
type: "assistant",
finish: "error",
error: { type: "provider", category: "transport", message: "Provider connection failed", retryable: false },
},
])
}),
)
it.effect("does not publish step ended when the provider throws after step finish", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const db = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after finish" }), resume: false })
const failure = providerUnavailable()
responseStream = Stream.concat(
Stream.fromIterable([LLMEvent.stepFinish({ index: 0, reason: "stop" })]),
Stream.fail(failure),
)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
const types = yield* db.db.select({ type: EventTable.type }).from(EventTable).all().pipe(Effect.orDie)
expect(types).toContainEqual({
type: EventV2.versionedType(SessionEvent.Step.Failed.type, 2),
})
expect(types).not.toContainEqual({
type: EventV2.versionedType(SessionEvent.Step.Ended.type, 2),
})
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail after finish" },
{
type: "assistant",
finish: "error",
error: { type: "provider", category: "transport", message: "Provider connection failed" },
},
])
}),
)
it.effect("preserves sanitized HTTP rate-limit details in the durable event and projection", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const db = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail with rate limit" }), resume: false })
const failure = new LLMError({
module: "RequestExecutor",
method: "execute",
reason: new RateLimitReason({
message: "secret provider response message",
retryAfterMs: 12_000,
rateLimit: new HttpRateLimitDetails({ retryAfterMs: 12_000, limit: { requests: "secret-limit" } }),
http: new HttpContext({
request: new HttpRequestDetails({
method: "POST",
url: "https://secret.example/v1/responses?api_key=credential",
headers: { authorization: "Bearer credential" },
}),
response: new HttpResponseDetails({ status: 429, headers: { "x-secret": "secret-header" } }),
body: '{"secret":"provider body"}',
requestId: "secret-request-id",
}),
}),
})
responseStream = Stream.fail(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
const event = yield* db.db
.select({ data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.type, EventV2.versionedType(SessionEvent.Step.Failed.type, 2)))
.get()
.pipe(Effect.orDie)
const expected = {
type: "provider",
category: "rate-limit",
message: "Provider rate limit exceeded",
status: 429,
retryable: true,
retryAfterMs: 12_000,
}
expect(event?.data.error).toEqual(expected)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail with rate limit" },
{ type: "assistant", finish: "error", error: expected },
])
expect(JSON.stringify(event)).not.toMatch(
/secret provider|secret\.example|credential|secret-header|provider body|secret-request-id|secret-limit/,
)
expect(JSON.stringify(yield* session.context(sessionID))).not.toMatch(
/secret provider|secret\.example|credential|secret-header|provider body|secret-request-id|secret-limit/,
)
}),
)
it.effect("projects categorized in-band failures without fabricating an HTTP status", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail in band" }), resume: false })
response = [
LLMEvent.providerError({
message: "rate_limit_exceeded: secret provider message",
category: "rate-limit",
retryable: false,
}),
]
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail in band" },
{
type: "assistant",
finish: "error",
error: {
type: "provider",
category: "rate-limit",
message: "Provider rate limit exceeded",
retryable: false,
},
},
])
const assistant = (yield* session.context(sessionID))[1]
expect(assistant?.type === "assistant" ? assistant.error : undefined).not.toHaveProperty("status")
expect(JSON.stringify(assistant)).not.toContain("secret provider message")
}),
)
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
Effect.gen(function* () {
yield* setup
@@ -3005,13 +3186,25 @@ describe("SessionRunnerLLM", () => {
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
LLMEvent.providerError({ message: "Provider unavailable" }),
LLMEvent.providerError({ message: "secret provider failure" }),
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(executions.slice(executionCount)).toEqual(["settled"])
const assistant = (yield* session.context(sessionID))[1]
expect(assistant).toMatchObject({
type: "assistant",
finish: "error",
error: {
type: "provider",
category: "unknown",
message: "Provider request failed",
retryable: false,
},
})
expect(JSON.stringify(assistant)).not.toContain("secret provider failure")
}),
)
@@ -3101,7 +3294,12 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider unavailable" },
error: {
type: "provider",
category: "transport",
message: "Provider connection failed",
retryable: false,
},
content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }],
},
])
+16
View File
@@ -144,6 +144,8 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
[coreSessionMessage.ProviderError, SessionMessage.ProviderError],
[coreSessionMessage.Error, SessionMessage.Error],
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched],
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched],
[coreSessionMessage.User, SessionMessage.User],
@@ -204,3 +206,17 @@ test("shared record schemas construct and decode plain objects", () => {
expect(Prompt.fromUserMessage({ text: "hello" })).toEqual(made)
expect(Workspace.ID.ascending("")).toStartWith("wrk_")
})
test("assistant errors retain legacy unknown decode compatibility", () => {
const assistant = Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: "msg_legacy_error",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
error: { type: "unknown", message: "Legacy failure" },
time: { created: 0 },
})
expect(assistant.error).toEqual({ type: "unknown", message: "Legacy failure" })
})
+14
View File
@@ -4,6 +4,20 @@ import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export const ProviderFailureCategory = Schema.Literals([
"invalid-request",
"no-route",
"authentication",
"rate-limit",
"quota-exceeded",
"content-policy",
"provider-internal",
"transport",
"invalid-provider-output",
"unknown",
])
export type ProviderFailureCategory = typeof ProviderFailureCategory.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
+3 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { ToolOutput, ToolResultValue } from "./messages"
import { ProviderFailureClassification } from "./errors"
import { ProviderFailureCategory, ProviderFailureClassification } from "./errors"
/**
* Token usage reported by an LLM provider.
@@ -201,6 +201,8 @@ export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
message: Schema.String,
classification: Schema.optional(ProviderFailureClassification),
category: Schema.optional(ProviderFailureCategory),
status: Schema.optional(Schema.Number),
retryable: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ProviderError" })
+5 -1
View File
@@ -50,6 +50,10 @@ const stepSettlementOptions = {
export const UnknownError = SessionMessage.UnknownError
export type UnknownError = SessionMessage.UnknownError
export const ProviderError = SessionMessage.ProviderError
export type ProviderError = SessionMessage.ProviderError
export const Error = SessionMessage.Error
export type Error = SessionMessage.Error
export const AgentSwitched = Event.define({
type: "session.next.agent.switched",
@@ -188,7 +192,7 @@ export namespace Step {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
error: UnknownError,
error: Error,
},
})
export type Failed = typeof Failed.Type
+28 -1
View File
@@ -21,6 +21,33 @@ export const UnknownError = Schema.Struct({
message: Schema.String,
}).annotate({ identifier: "Session.Error.Unknown" })
export const ProviderErrorCategory = Schema.Literals([
"invalid-request",
"no-route",
"authentication",
"rate-limit",
"quota-exceeded",
"content-policy",
"provider-internal",
"transport",
"invalid-provider-output",
"unknown",
])
export type ProviderErrorCategory = typeof ProviderErrorCategory.Type
export interface ProviderError extends Schema.Schema.Type<typeof ProviderError> {}
export const ProviderError = Schema.Struct({
type: Schema.Literal("provider"),
category: ProviderErrorCategory,
message: Schema.String,
status: Schema.Finite.pipe(Schema.optional),
retryable: Schema.Boolean,
retryAfterMs: Schema.Finite.pipe(Schema.optional),
}).annotate({ identifier: "Session.Error.Provider" })
export const Error = Schema.Union([UnknownError, ProviderError]).pipe(Schema.toTaggedUnion("type"))
export type Error = UnknownError | ProviderError
const Base = {
id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
@@ -177,7 +204,7 @@ export const Assistant = Schema.Struct({
reasoning: Schema.Finite,
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
}).pipe(optional),
error: UnknownError.pipe(optional),
error: Error.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
+25 -6
View File
@@ -952,7 +952,7 @@ export type GlobalEvent = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}
| {
@@ -2955,6 +2955,25 @@ export type SessionErrorUnknown = {
message: string
}
export type SessionErrorProvider = {
type: "provider"
category:
| "invalid-request"
| "no-route"
| "authentication"
| "rate-limit"
| "quota-exceeded"
| "content-policy"
| "provider-internal"
| "transport"
| "invalid-provider-output"
| "unknown"
message: string
status?: number
retryable: boolean
retryAfterMs?: number
}
export type LlmProviderMetadata = {
[key: string]: {
[key: string]: unknown
@@ -3399,7 +3418,7 @@ export type SyncEventSessionNextStepFailed = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}
}
@@ -4009,7 +4028,7 @@ export type SessionMessageAssistant = {
write: number
}
}
error?: SessionErrorUnknown
error?: SessionErrorUnknown | SessionErrorProvider
}
export type SessionMessageCompaction = {
@@ -4289,7 +4308,7 @@ export type SessionNextStepFailed = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}
@@ -5344,7 +5363,7 @@ export type V2EventSessionNextStepFailed = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}
@@ -6931,7 +6950,7 @@ export type EventSessionNextStepFailed = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}