Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/ai/src/provider-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ const SERVER_CODES = new Set([
"serviceunavailableexception",
])
// `invalid_request` is the Vercel AI Gateway's code for an upstream request rejection.
// xAI reports an undecodable image over WebSocket with no status as `invalid_image` under type `api_error`.
const INVALID_REQUEST_CODES = new Set([
"invalid_image",
"model_not_found",
"invalid_prompt",
"invalid_request",
Expand Down Expand Up @@ -290,8 +292,10 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
// A specific invalid-request code outranks a generic server code or phrase in the same body.
((input.status === undefined || input.status < 400) &&
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
(SERVER_ERROR_TEXT.test(text) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
)
return new ProviderInternalError({
Expand Down
15 changes: 15 additions & 0 deletions packages/ai/test/provider-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,21 @@ describe("provider error classification", () => {
).toBe("ProviderInternal")
})

test("does not retry an xAI invalid image reported as api_error without a status", () => {
// Recorded from the xAI Responses WebSocket after sending a GIF.
const body = {
error: {
message:
"gRPC error: code: 'Client specified an invalid argument', message: \"Downloaded response does not contain a valid JPG, PNG, WebP, or ICO image.",
type: "api_error",
code: "invalid_image",
},
}
expect(classifyProviderFailure({ message: body.error.message, rawBody: JSON.stringify(body) })._tag).toBe(
"InvalidRequest",
)
})

test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export interface Resolved {
readonly ref: Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: Capabilities
/** Catalog model family; family-wide input limits apply however the model is served. */
readonly family?: Info["family"]
/** Catalog pricing in dollars per million tokens. */
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
Expand Down Expand Up @@ -388,6 +390,7 @@ export const layer = Layer.effect(
...(variant === undefined ? {} : { variant }),
}),
capabilities: selected.capabilities,
family: selected.family,
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
Expand Down
29 changes: 19 additions & 10 deletions packages/core/src/session/model-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,27 @@ const mimeToModality = (mime: string) => {
if (mime === "application/pdf") return "pdf"
}

// xAI rejects any other image type (e.g. GIF) with invalid_image, and the stored image would fail every later turn.
const XAI_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp"])
// Grok rejects any other image type (e.g. GIF) with invalid_image, through xAI and through gateways such as
// OpenCode Zen alike, and the stored image would fail every later turn.
const GROK_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp"])

interface Served {
readonly provider?: string
readonly family?: string
}

const unsupportedMedia = (
mime: string,
name: string | undefined,
capabilities: Model.Capabilities,
provider: string | undefined,
model: Served | undefined,
) => {
const modality = mimeToModality(mime)
if (!modality) return
const grok = model?.provider === "xai" || model?.family?.startsWith("grok") === true
const unsupported = !capabilities.input.some((item) => item.startsWith(modality))
? modality
: provider === "xai" && modality === "image" && !XAI_IMAGE_MIMES.has(mime.toLowerCase())
: grok && modality === "image" && !GROK_IMAGE_MIMES.has(mime.toLowerCase())
? mime
: undefined
if (!unsupported) return
Expand Down Expand Up @@ -180,11 +187,8 @@ const replaceMedia = (
: new Message({ ...message, content })
})

export const unsupportedParts = (
messages: LLMRequest["messages"],
capabilities: Model.Capabilities,
provider?: string,
) => replaceMedia(messages, (media) => unsupportedMedia(media.mime, media.name, capabilities, provider))
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: Model.Capabilities, model?: Served) =>
replaceMedia(messages, (media) => unsupportedMedia(media.mime, media.name, capabilities, model))

export const boundImages = (messages: LLMRequest["messages"]) => {
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
Expand Down Expand Up @@ -294,7 +298,12 @@ export const layer = Layer.effect(
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: /^ses_[0-9a-f]{64}$/.test(affinity) ? affinity.slice(4) : affinity,
system: shaped.system,
messages: boundImages(unsupportedParts(shaped.messages, model.capabilities, model.model.provider)),
messages: boundImages(
unsupportedParts(shaped.messages, model.capabilities, {
provider: model.model.provider,
family: model.family,
}),
),
tools: Array.from(hooked, ([name, t]) => ({ ...t, name })),
toolChoice: input.toolChoice,
generation: Object.keys(generation).length === 0 ? undefined : generation,
Expand Down
14 changes: 11 additions & 3 deletions packages/core/test/session-model-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("SessionModelRequest.unsupportedParts", () => {
})
})

test("replaces images xAI cannot decode and keeps png, jpeg and webp", () => {
test("replaces images Grok cannot decode and keeps png, jpeg and webp", () => {
const image = (mime: string, name: string) => ({
type: "media" as const,
media: Media.base64("aGVsbG8=", mime),
Expand All @@ -82,7 +82,7 @@ describe("SessionModelRequest.unsupportedParts", () => {
}),
),
]
const result = unsupportedParts(messages, capabilities(["text", "image"]), "xai")
const result = unsupportedParts(messages, capabilities(["text", "image"]), { provider: "xai" })

expect(result[0]?.content).toEqual([
...user,
Expand All @@ -101,7 +101,15 @@ describe("SessionModelRequest.unsupportedParts", () => {
],
},
})
expect(unsupportedParts(messages, capabilities(["text", "image"]), "openai")).toEqual(messages)
expect(unsupportedParts(messages, capabilities(["text", "image"]), { provider: "openai" })).toEqual(messages)
// Gateways such as OpenCode Zen serve Grok under their own provider ID, and the upstream check still applies.
for (const family of ["grok", "grok-build"])
expect(unsupportedParts(messages, capabilities(["text", "image"]), { provider: "opencode", family })).toEqual(
result,
)
expect(
unsupportedParts(messages, capabilities(["text", "image"]), { provider: "opencode", family: "gpt" }),
).toEqual(messages)
})

test("preserves supported media", () => {
Expand Down
Loading