mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e5b12f41 |
@@ -6,6 +6,7 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
SystemPart,
|
SystemPart,
|
||||||
isContextOverflowFailure,
|
isContextOverflowFailure,
|
||||||
|
type LLMErrorReason,
|
||||||
type ProviderErrorEvent,
|
type ProviderErrorEvent,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
@@ -32,7 +33,7 @@ import { SessionSchema } from "../schema"
|
|||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
import { type RunError, Service } from "./index"
|
import { type RunError, Service } from "./index"
|
||||||
import { SessionRunnerModel } from "./model"
|
import { SessionRunnerModel } from "./model"
|
||||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
import { createLLMEventPublisher, providerError } from "./publish-llm-event"
|
||||||
import { toLLMMessages } from "./to-llm-message"
|
import { toLLMMessages } from "./to-llm-message"
|
||||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||||
import { Snapshot } from "../../snapshot"
|
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.
|
* 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(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -283,7 +307,7 @@ export const layer = Layer.effect(
|
|||||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||||
if (llmFailure && !publisher.hasProviderError()) {
|
if (llmFailure && !publisher.hasProviderError()) {
|
||||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
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)
|
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||||
@@ -299,7 +323,7 @@ export const layer = Layer.effect(
|
|||||||
yield* FiberSet.clear(toolFibers)
|
yield* FiberSet.clear(toolFibers)
|
||||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||||
if (publisher.hasActiveAssistant())
|
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)) {
|
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||||
const failure = Cause.squash(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 }
|
return { structured: record(settled.structured), content: settled.content }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerMessages: Record<SessionMessage.ProviderErrorCategory, string> = {
|
||||||
|
"invalid-request": "Provider rejected the request",
|
||||||
|
"no-route": "No provider route is available",
|
||||||
|
authentication: "Provider authentication failed",
|
||||||
|
"rate-limit": "Provider rate limit exceeded",
|
||||||
|
"quota-exceeded": "Provider quota exceeded",
|
||||||
|
"content-policy": "Provider content policy rejected the request",
|
||||||
|
"provider-internal": "Provider service error",
|
||||||
|
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. */
|
/** Persist one provider turn without executing tools or starting a continuation turn. */
|
||||||
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
|
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
|
||||||
const tools = new Map<
|
const tools = new Map<
|
||||||
@@ -196,7 +223,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
yield* flushFragments()
|
yield* flushFragments()
|
||||||
})
|
})
|
||||||
|
|
||||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
const failAssistant = Effect.fnUntraced(function* (error: SessionMessage.Error) {
|
||||||
if (assistantFailed) return
|
if (assistantFailed) return
|
||||||
yield* flush()
|
yield* flush()
|
||||||
const assistantMessageID = yield* startAssistant()
|
const assistantMessageID = yield* startAssistant()
|
||||||
@@ -206,7 +233,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
timestamp: yield* timestamp,
|
timestamp: yield* timestamp,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
error: { type: "unknown", message },
|
error,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -403,7 +430,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
return
|
return
|
||||||
case "provider-error":
|
case "provider-error":
|
||||||
providerFailed = true
|
providerFailed = true
|
||||||
yield* failAssistant(event.message)
|
yield* failAssistant(
|
||||||
|
event.category === undefined
|
||||||
|
? { type: "unknown", message: event.message }
|
||||||
|
: providerError({ category: event.category, status: event.status, retryable: event.retryable }),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,16 +70,20 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||||||
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||||
const sameModel =
|
const sameModel =
|
||||||
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
||||||
|
const preserveProviderMetadata = sameModel && message.finish !== "error"
|
||||||
const content = message.content.flatMap((item): ContentPart[] => {
|
const content = message.content.flatMap((item): ContentPart[] => {
|
||||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||||
if (item.type === "reasoning")
|
if (item.type === "reasoning")
|
||||||
return sameModel
|
return preserveProviderMetadata
|
||||||
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
|
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
|
||||||
: item.text.length > 0
|
: item.text.length > 0
|
||||||
? [{ type: "text", text: item.text }]
|
? [{ type: "text", text: item.text }]
|
||||||
: []
|
: []
|
||||||
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
|
const call = toolCall(item, preserveProviderMetadata ? item.provider?.metadata : undefined)
|
||||||
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
|
const result = toolResult(
|
||||||
|
item,
|
||||||
|
preserveProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined,
|
||||||
|
)
|
||||||
return item.provider?.executed === true && result ? [call, result] : [call]
|
return item.provider?.executed === true && result ? [call, result] : [call]
|
||||||
})
|
})
|
||||||
const meaningful = content.filter((part) => {
|
const meaningful = content.filter((part) => {
|
||||||
@@ -89,7 +93,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|||||||
})
|
})
|
||||||
const results = message.content
|
const results = message.content
|
||||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
||||||
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
|
.map((item) =>
|
||||||
|
toolResult(
|
||||||
|
item,
|
||||||
|
preserveProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
.filter((message) => message !== undefined)
|
.filter((message) => message !== undefined)
|
||||||
.map(Message.tool)
|
.map(Message.tool)
|
||||||
if (meaningful.length === 0) return results
|
if (meaningful.length === 0) return results
|
||||||
|
|||||||
@@ -327,6 +327,83 @@ Recent work
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("drops same-model provider metadata from errored turns without dropping useful output", () => {
|
||||||
|
const messages = toLLMMessages(
|
||||||
|
[
|
||||||
|
SessionMessage.Assistant.make({
|
||||||
|
id: id("assistant-error"),
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
|
finish: "error",
|
||||||
|
error: { type: "unknown", message: "Interrupted" },
|
||||||
|
content: [
|
||||||
|
SessionMessage.AssistantText.make({ type: "text", id: "text-error", text: "Partial answer" }),
|
||||||
|
SessionMessage.AssistantReasoning.make({
|
||||||
|
type: "reasoning",
|
||||||
|
id: "reasoning-error",
|
||||||
|
text: "Visible thought",
|
||||||
|
providerMetadata: { openai: { reasoningEncryptedContent: "stale-secret" } },
|
||||||
|
}),
|
||||||
|
SessionMessage.AssistantTool.make({
|
||||||
|
type: "tool",
|
||||||
|
id: "tool-error",
|
||||||
|
name: "read",
|
||||||
|
provider: {
|
||||||
|
executed: false,
|
||||||
|
metadata: { openai: { itemId: "stale-call" } },
|
||||||
|
resultMetadata: { openai: { itemId: "stale-result" } },
|
||||||
|
},
|
||||||
|
state: SessionMessage.ToolStateError.make({
|
||||||
|
status: "error",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
content: [{ type: "text", text: "Permission denied" }],
|
||||||
|
structured: {},
|
||||||
|
error: { type: "unknown", message: "Permission denied" },
|
||||||
|
}),
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2)
|
||||||
|
expect(messages[0]?.content).toEqual([
|
||||||
|
{ type: "text", text: "Partial answer" },
|
||||||
|
{ type: "text", text: "Visible thought" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "tool-error",
|
||||||
|
name: "read",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
providerExecuted: false,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(messages[1]?.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "tool-error",
|
||||||
|
name: "read",
|
||||||
|
result: {
|
||||||
|
type: "error",
|
||||||
|
value: {
|
||||||
|
error: { type: "unknown", message: "Permission denied" },
|
||||||
|
content: [{ type: "text", text: "Permission denied" }],
|
||||||
|
structured: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerExecuted: false,
|
||||||
|
cache: undefined,
|
||||||
|
metadata: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("drops provider-native continuation metadata after a model switch", () => {
|
test("drops provider-native continuation metadata after a model switch", () => {
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import {
|
|||||||
LLMError,
|
LLMError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
Model,
|
Model,
|
||||||
|
HttpContext,
|
||||||
|
HttpRateLimitDetails,
|
||||||
|
HttpRequestDetails,
|
||||||
|
HttpResponseDetails,
|
||||||
|
RateLimitReason,
|
||||||
TransportReason,
|
TransportReason,
|
||||||
InvalidRequestReason,
|
InvalidRequestReason,
|
||||||
type LLMClientShape,
|
type LLMClientShape,
|
||||||
@@ -519,7 +524,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
|||||||
{
|
{
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
finish: "error",
|
finish: "error",
|
||||||
error: { type: "unknown", message: "Provider unavailable" },
|
error: { type: "provider", category: "transport", message: "Provider connection failed", retryable: false },
|
||||||
content: [fixture.expectedContent],
|
content: [fixture.expectedContent],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -2984,11 +2989,106 @@ describe("SessionRunnerLLM", () => {
|
|||||||
yield* replaySessionProjection(sessionID)
|
yield* replaySessionProjection(sessionID)
|
||||||
expect(yield* session.context(sessionID)).toMatchObject([
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
{ type: "user", text: "Fail raw stream durably" },
|
{ 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("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 in-band rate limits 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: true,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
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: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
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", () =>
|
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
@@ -3100,7 +3200,12 @@ describe("SessionRunnerLLM", () => {
|
|||||||
{
|
{
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
finish: "error",
|
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" } }],
|
content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||||||
[coreSessionInput.Admitted, SessionInput.Admitted],
|
[coreSessionInput.Admitted, SessionInput.Admitted],
|
||||||
[coreSessionMessage.ID, SessionMessage.ID],
|
[coreSessionMessage.ID, SessionMessage.ID],
|
||||||
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
|
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
|
||||||
|
[coreSessionMessage.ProviderError, SessionMessage.ProviderError],
|
||||||
|
[coreSessionMessage.Error, SessionMessage.Error],
|
||||||
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched],
|
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched],
|
||||||
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched],
|
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched],
|
||||||
[coreSessionMessage.User, SessionMessage.User],
|
[coreSessionMessage.User, SessionMessage.User],
|
||||||
@@ -178,3 +180,17 @@ test("shared record schemas construct and decode plain objects", () => {
|
|||||||
expect(Prompt.fromUserMessage({ text: "hello" })).toEqual(made)
|
expect(Prompt.fromUserMessage({ text: "hello" })).toEqual(made)
|
||||||
expect(Workspace.ID.ascending("")).toStartWith("wrk_")
|
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" })
|
||||||
|
})
|
||||||
|
|||||||
@@ -891,6 +891,8 @@ const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
|
|||||||
return LLMEvent.providerError({
|
return LLMEvent.providerError({
|
||||||
message,
|
message,
|
||||||
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
|
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
|
||||||
|
category: code === "rate_limit_exceeded" ? "rate-limit" : undefined,
|
||||||
|
retryable: code === "rate_limit_exceeded" ? true : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
|||||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
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")({
|
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 { ToolOutput, ToolResultValue } from "./messages"
|
import { ToolOutput, ToolResultValue } from "./messages"
|
||||||
import { ProviderFailureClassification } from "./errors"
|
import { ProviderFailureCategory, ProviderFailureClassification } from "./errors"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token usage reported by an LLM provider.
|
* Token usage reported by an LLM provider.
|
||||||
@@ -201,6 +201,8 @@ export const ProviderErrorEvent = Schema.Struct({
|
|||||||
type: Schema.tag("provider-error"),
|
type: Schema.tag("provider-error"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
classification: Schema.optional(ProviderFailureClassification),
|
classification: Schema.optional(ProviderFailureClassification),
|
||||||
|
category: Schema.optional(ProviderFailureCategory),
|
||||||
|
status: Schema.optional(Schema.Number),
|
||||||
retryable: Schema.optional(Schema.Boolean),
|
retryable: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
||||||
|
|||||||
@@ -1326,7 +1326,14 @@ describe("OpenAI Responses route", () => {
|
|||||||
// 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(response.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "provider-error",
|
||||||
|
message: "rate_limit_exceeded: Slow down",
|
||||||
|
category: "rate-limit",
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ const stepSettlementOptions = {
|
|||||||
|
|
||||||
export const UnknownError = SessionMessage.UnknownError
|
export const UnknownError = SessionMessage.UnknownError
|
||||||
export type 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({
|
export const AgentSwitched = Event.define({
|
||||||
type: "session.next.agent.switched",
|
type: "session.next.agent.switched",
|
||||||
@@ -188,7 +192,7 @@ export namespace Step {
|
|||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessageID.ID,
|
assistantMessageID: SessionMessageID.ID,
|
||||||
error: UnknownError,
|
error: Error,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Failed = typeof Failed.Type
|
export type Failed = typeof Failed.Type
|
||||||
|
|||||||
@@ -17,6 +17,33 @@ export const UnknownError = Schema.Struct({
|
|||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}).annotate({ identifier: "Session.Error.Unknown" })
|
}).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 = {
|
const Base = {
|
||||||
id: ID,
|
id: ID,
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||||
@@ -173,7 +200,7 @@ export const Assistant = Schema.Struct({
|
|||||||
reasoning: Schema.Finite,
|
reasoning: Schema.Finite,
|
||||||
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
|
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
|
||||||
}).pipe(Schema.optional),
|
}).pipe(Schema.optional),
|
||||||
error: UnknownError.pipe(Schema.optional),
|
error: Error.pipe(Schema.optional),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: DateTimeUtcFromMillis,
|
created: DateTimeUtcFromMillis,
|
||||||
completed: DateTimeUtcFromMillis.pipe(Schema.optional),
|
completed: DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||||
|
|||||||
@@ -960,7 +960,7 @@ export type GlobalEvent = {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
error: SessionErrorUnknown
|
error: SessionErrorUnknown | SessionErrorProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -3005,6 +3005,25 @@ export type SessionErrorUnknown = {
|
|||||||
message: string
|
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 ToolTextContent = {
|
export type ToolTextContent = {
|
||||||
type: "text"
|
type: "text"
|
||||||
text: string
|
text: string
|
||||||
@@ -3420,7 +3439,7 @@ export type SyncEventSessionNextStepFailed = {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
error: SessionErrorUnknown
|
error: SessionErrorUnknown | SessionErrorProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4076,7 +4095,7 @@ export type SessionMessageAssistant = {
|
|||||||
write: number
|
write: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
error?: SessionErrorUnknown
|
error?: SessionErrorUnknown | SessionErrorProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageCompaction = {
|
export type SessionMessageCompaction = {
|
||||||
@@ -4780,7 +4799,7 @@ export type V2EventSessionNextStepFailed = {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
error: SessionErrorUnknown
|
error: SessionErrorUnknown | SessionErrorProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6412,7 +6431,7 @@ export type EventSessionNextStepFailed = {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
error: SessionErrorUnknown
|
error: SessionErrorUnknown | SessionErrorProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user