diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 9ec502de65cf..41b1d5c1f558 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -6,11 +6,13 @@ import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/ import { InvalidProviderOutputError, InvalidRequestError, + UnsupportedOperationError, AIError, HttpContext, type ContentPart, type LLMRequest, type MediaPart, + type ProviderID, type TextPart, type ToolResultPart, } from "../schema/index.js" @@ -254,6 +256,29 @@ export const invalidRequest = (message: string, cause?: unknown) => reason: new InvalidRequestError({ message, cause }), }) +/** + * Canonical constructor for operations the selected route does not implement. + * Prefer this over `invalidRequest` when the failure is a missing route + * capability rather than a malformed caller input, so consumers can branch on + * `reason._tag` plus `reason.operation` instead of matching message text. + */ +export const unsupportedOperation = (input: { + readonly operation: string + readonly message: string + readonly provider?: ProviderID + readonly route?: string + readonly cause?: unknown +}) => + new AIError({ + reason: new UnsupportedOperationError({ + operation: input.operation, + message: input.message, + provider: input.provider, + route: input.route, + cause: input.cause, + }), + }) + export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* ( route: string, name: string, diff --git a/packages/ai/src/protocols/xai-responses.ts b/packages/ai/src/protocols/xai-responses.ts index acd55f347dc3..00a059e10198 100644 --- a/packages/ai/src/protocols/xai-responses.ts +++ b/packages/ai/src/protocols/xai-responses.ts @@ -46,9 +46,12 @@ const adapter = { const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody)) const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) { if (request.providerOptions?.contextManagement !== undefined) - return yield* ProviderShared.invalidRequest( - "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported", - ) + return yield* ProviderShared.unsupportedOperation({ + operation: "in-band-compaction", + provider: request.model.provider, + route: request.model.route.id, + message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported", + }) return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter)) }) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 8ad47c491f2b..c8665f6722a8 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -585,9 +585,12 @@ export const layer: Layer.Layer = Layer Effect.suspend(() => { const operation = request.model.route.compact if (!operation) - return ProviderShared.invalidRequest( - `${request.model.provider}/${request.model.route.id} does not support explicit compaction`, - ) + return ProviderShared.unsupportedOperation({ + operation: "compact", + provider: request.model.provider, + route: request.model.route.id, + message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`, + }) return operation(prepareRequest(request), executor, options) }), }) diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index 79a8f7886910..9566cb331018 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -35,6 +35,21 @@ export class InvalidRequestError extends Schema.TaggedError }, ) {} +/** + * A caller-requested operation the selected route does not implement, such as + * explicit compaction on a route without a compact endpoint. Detected locally + * before any network I/O, so unlike transport or provider-output failures it + * never carries HTTP context from a provider round-trip. + */ +export class UnsupportedOperationError extends Schema.TaggedError( + "AI.Error.UnsupportedOperation", +)("UnsupportedOperation", { + ...ReasonFields, + operation: Schema.String, + provider: Schema.optional(ProviderID), + route: Schema.optional(RouteID), +}) {} + export class NoRouteError extends Schema.TaggedError("AI.Error.NoRoute")("NoRoute", { ...ReasonFields, route: RouteID, @@ -107,6 +122,7 @@ export class UnknownProviderError extends Schema.TaggedError { const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture") @@ -75,3 +77,25 @@ test("tagged content and event guards accept both checkpoint representations", ( expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message) } }) + +testEffect(fixedResponse("")).effect( + "explicit compaction on a route without a compact endpoint fails with UnsupportedOperation", + () => + Effect.gen(function* () { + const request = LLM.request({ + model: Anthropic.configure({ apiKey: "test" }).model("fixture"), + prompt: "hello", + }) + expect(LLMClient.canCompact(request)).toBe(false) + const error = yield* LLMClient.compact( + request as unknown as Parameters[0], + ).pipe(Effect.flip) + expect(error.reason._tag).toBe("UnsupportedOperation") + expect(error.message).toContain("does not support explicit compaction") + if (error.reason._tag === "UnsupportedOperation") { + expect(error.reason.operation).toBe("compact") + expect(error.reason.provider).toBe("anthropic") + expect(error.reason.route).toBe("anthropic-messages") + } + }), +) diff --git a/packages/ai/test/provider/explicit-compaction.test.ts b/packages/ai/test/provider/explicit-compaction.test.ts index a9d07f06331a..070f55bd6766 100644 --- a/packages/ai/test/provider/explicit-compaction.test.ts +++ b/packages/ai/test/provider/explicit-compaction.test.ts @@ -130,16 +130,22 @@ for (const model of [ }), ], }) - for (const candidate of [ - LLMRequest.update(request, { - tools: [ - { name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }, - ], - }), - LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }), - ]) { + for (const [candidate, tag] of [ + [ + LLMRequest.update(request, { + tools: [ + { name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }, + ], + }), + "InvalidRequest", + ], + [ + LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }), + model.provider === "xai" ? "UnsupportedOperation" : "InvalidRequest", + ], + ] as const) { const error = yield* LLMClient.generate(candidate).pipe(Effect.flip) - expect(error.reason._tag).toBe("InvalidRequest") + expect(error.reason._tag).toBe(tag) const response = yield* LLMClient.compact(candidate) expect(response.replacement[0]?.content[0]?.type).toBe("compaction") } @@ -361,8 +367,9 @@ testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic comp { providerOptions: { contextManagement: [{ type: "compaction" }] } }, ) const error = yield* LLMClient.generate(request).pipe(Effect.flip) - expect(error.reason._tag).toBe("InvalidRequest") + expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("LLMClient.compact") + if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("in-band-compaction") }), ) @@ -428,7 +435,9 @@ for (const model of [ Effect.gen(function* () { // @ts-expect-error Untyped callers must still receive the runtime capability error. const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip) - expect(error.reason._tag).toBe("InvalidRequest") + expect(error.reason._tag).toBe("UnsupportedOperation") + expect(error.message).toContain("does not support explicit compaction") + if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("compact") }), ) } diff --git a/packages/ai/test/schema.test.ts b/packages/ai/test/schema.test.ts index 28325eda5fbe..10a45d012372 100644 --- a/packages/ai/test/schema.test.ts +++ b/packages/ai/test/schema.test.ts @@ -24,6 +24,7 @@ import { ToolResultValue, TransportError, UnknownProviderError, + UnsupportedOperationError, Usage, } from "../src/schema/index.js" import { ProviderShared } from "../src/protocols/shared.js" @@ -276,6 +277,12 @@ test("AI errors serialize diagnostics only on their typed reason", () => { test("AI error reasons are tagged Errors with required messages", () => { const reasons = [ new InvalidRequestError({ message: "Invalid request" }), + new UnsupportedOperationError({ + message: "Unsupported operation", + operation: "compact", + provider: model.provider, + route: "fake-route", + }), new NoRouteError({ message: "No route", route: RouteID.make("missing"), @@ -293,6 +300,7 @@ test("AI error reasons are tagged Errors with required messages", () => { ] expect(reasons.map((reason) => reason._tag)).toEqual([ "InvalidRequest", + "UnsupportedOperation", "NoRoute", "Authentication", "RateLimit", diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 215d6dcdea8d..f1e0c5c3cdc1 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -522,7 +522,11 @@ function userPart(part: ContentPart): UserContent { function assistantPart(part: ContentPart): AssistantContent { switch (part.type) { case "compaction": - throw ProviderShared.invalidRequest("AI SDK routes cannot replay native provider compaction state") + throw ProviderShared.unsupportedOperation({ + operation: "compaction-replay", + provider: part.provider, + message: "AI SDK routes cannot replay native provider compaction state", + }) case "text": return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }] case "media": diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index 88002291936e..b67cb23de0cb 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -47,6 +47,7 @@ export function isRetryable(error: AIError) { case "QuotaExceeded": case "ContentPolicy": case "InvalidRequest": + case "UnsupportedOperation": case "NoRoute": return false default: { diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index d731a959c9a2..90a1f4ed9004 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -25,6 +25,8 @@ export function toSessionError(cause: unknown): SessionError.Error { return providerError("provider.invalid-output", cause.reason) case "InvalidRequest": return providerError("provider.invalid-request", cause.reason) + case "UnsupportedOperation": + return providerError("provider.unsupported-operation", cause.reason) case "NoRoute": return providerError("provider.no-route", cause.reason) case "UnknownProvider": diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index c59e4e7e8a64..9d890a8c719d 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -90,8 +90,9 @@ it.effect("rejects native provider compaction rather than silently dropping repl ], }), ).pipe(Effect.flip) - expect(error.reason._tag).toBe("InvalidRequest") + expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("cannot replay") + if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("compaction-replay") }), ) diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 4836feb2d2a9..8febfa53f2eb 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -4,6 +4,7 @@ import { ContentPolicyError, InvalidProviderOutputError, InvalidRequestError, + UnsupportedOperationError, AIError, NoRouteError, ModelID, @@ -43,6 +44,18 @@ describe("toSessionError", () => { "provider.invalid-output", ) expect(toSessionError(llm(new InvalidRequestError({ message: "request" }))).type).toBe("provider.invalid-request") + expect( + toSessionError( + llm( + new UnsupportedOperationError({ + message: "no compact endpoint", + operation: "compact", + provider: ProviderID.make("provider"), + route: "route", + }), + ), + ), + ).toEqual({ type: "provider.unsupported-operation", message: "no compact endpoint" }) expect( toSessionError( llm( @@ -140,6 +153,7 @@ describe("toSessionError", () => { llm(new ContentPolicyError({ message: "blocked" })), llm(new InvalidProviderOutputError({ message: "output" })), llm(new InvalidRequestError({ message: "request" })), + llm(new UnsupportedOperationError({ message: "unsupported", operation: "compact" })), llm( new NoRouteError({ message: "failed", @@ -151,7 +165,7 @@ describe("toSessionError", () => { ] expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true]) - expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false]) + expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false]) }) test("retries transport failures only when delivery is absent or not sent", () => {