mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 07:53:33 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8044016ec6 | |||
| 117c0bf086 |
@@ -16,7 +16,6 @@ import {
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/request_too_large/i,
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
@@ -33,7 +32,6 @@ const patterns = [
|
||||
/context window exceeds limit/i,
|
||||
/exceeded model token limit/i,
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/request entity too large/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
@@ -44,11 +42,15 @@ const patterns = [
|
||||
/token limit exceeded/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
|
||||
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
||||
it.effect("classifies generic HTTP 413 payload errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "payload-too-large",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
classifyProviderFailure({
|
||||
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
status: 400,
|
||||
}),
|
||||
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||
]
|
||||
|
||||
expect(failures).toEqual(
|
||||
failures.map((failure) =>
|
||||
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||
),
|
||||
)
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
const messages = [
|
||||
"Throttling error: Too many tokens, please wait before trying again.",
|
||||
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect(
|
||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
"ProviderInternal",
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string }
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
|
||||
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
return providerError("provider.rate-limit", cause.reason)
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
return providerError("provider.auth", cause.reason)
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
return providerError("provider.quota", cause.reason)
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
return providerError("provider.content-filter", cause.reason)
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
return providerError("provider.transport", cause.reason)
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
return providerError("provider.internal", cause.reason)
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
return providerError("provider.invalid-output", cause.reason)
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
return providerError("provider.invalid-request", cause.reason)
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
return providerError("provider.no-route", cause.reason)
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
return providerError("provider.unknown", cause.reason)
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
return exhaustive
|
||||
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
|
||||
const status =
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
@@ -71,6 +74,23 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider HTTP status", () => {
|
||||
const http = new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
|
||||
response: new HttpResponseDetails({ status: 413, headers: {} }),
|
||||
})
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
|
||||
type: "provider.invalid-request",
|
||||
message: "too large",
|
||||
status: 413,
|
||||
})
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
|
||||
type: "provider.internal",
|
||||
message: "bad gateway",
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * as SessionError from "./session-error.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
||||
export const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||
}).annotate({ identifier: "Session.StructuredError" })
|
||||
|
||||
@@ -98,6 +98,8 @@ export const Definitions = {
|
||||
session_fork: keybind("none", "Fork session from message"),
|
||||
session_rename: keybind("ctrl+r", "Rename session"),
|
||||
session_delete: keybind("ctrl+d", "Delete session"),
|
||||
session_share: keybind("none", "Share current session"),
|
||||
session_unshare: keybind("none", "Unshare current session"),
|
||||
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
@@ -299,6 +301,8 @@ export const CommandMap = {
|
||||
session_fork: "session.fork",
|
||||
session_rename: "session.rename",
|
||||
session_delete: "session.delete",
|
||||
session_share: "session.share",
|
||||
session_unshare: "session.unshare",
|
||||
session_interrupt: "session.interrupt",
|
||||
session_background: "session.background",
|
||||
session_compact: "session.compact",
|
||||
|
||||
@@ -509,6 +509,14 @@ export function Session() {
|
||||
]
|
||||
|
||||
const baseCommands = createMemo(() => [
|
||||
{
|
||||
title: "Share session",
|
||||
id: "session.share",
|
||||
suggested: route.type === "session",
|
||||
group: "Session",
|
||||
slash: { name: "share" },
|
||||
run: () => unavailable("Sharing"),
|
||||
},
|
||||
{
|
||||
title: "Rename session",
|
||||
id: "session.rename",
|
||||
@@ -561,6 +569,14 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Unshare session",
|
||||
id: "session.unshare",
|
||||
group: "Session",
|
||||
enabled: false,
|
||||
slash: { name: "unshare" },
|
||||
run: () => unavailable("Unsharing"),
|
||||
},
|
||||
{
|
||||
title: "Undo previous message",
|
||||
id: "session.undo",
|
||||
|
||||
Reference in New Issue
Block a user