diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index 722728c3a031..f6a436cfac53 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -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", @@ -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({ diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index c325698cd591..e003fed24728 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -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( [ diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index ea8665a49bbf..542615f68190 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -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. */ @@ -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, diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 5e25326bf7b8..0a1e16a9e983 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -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 @@ -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/") @@ -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, diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index fb1837a5956e..c7512c7dd9bb 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -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), @@ -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, @@ -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", () => {