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
25 changes: 25 additions & 0 deletions packages/ai/src/protocols/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions packages/ai/src/protocols/xai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})

Expand Down
9 changes: 6 additions & 3 deletions packages/ai/src/route/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,9 +585,12 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = 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)
}),
})
Expand Down
16 changes: 16 additions & 0 deletions packages/ai/src/schema/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
},
) {}

/**
* 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<UnsupportedOperationError>(
"AI.Error.UnsupportedOperation",
)("UnsupportedOperation", {
...ReasonFields,
operation: Schema.String,
provider: Schema.optional(ProviderID),
route: Schema.optional(RouteID),
}) {}

export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
Expand Down Expand Up @@ -107,6 +122,7 @@ export class UnknownProviderError extends Schema.TaggedError<UnknownProviderErro

export const AIErrorReason = Schema.Union([
InvalidRequestError,
UnsupportedOperationError,
NoRouteError,
AuthenticationError,
RateLimitError,
Expand Down
26 changes: 25 additions & 1 deletion packages/ai/test/compaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { fixedResponse } from "./lib/http.js"

test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
Expand Down Expand Up @@ -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<typeof LLMClient.compact>[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")
}
}),
)
31 changes: 20 additions & 11 deletions packages/ai/test/provider/explicit-compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}),
)

Expand Down Expand Up @@ -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")
}),
)
}
Expand Down
8 changes: 8 additions & 0 deletions packages/ai/test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ToolResultValue,
TransportError,
UnknownProviderError,
UnsupportedOperationError,
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
Expand Down Expand Up @@ -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"),
Expand All @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/runner/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function isRetryable(error: AIError) {
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidRequest":
case "UnsupportedOperation":
case "NoRoute":
return false
default: {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/session/to-session-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
3 changes: 2 additions & 1 deletion packages/core/test/aisdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}),
)

Expand Down
16 changes: 15 additions & 1 deletion packages/core/test/session-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ContentPolicyError,
InvalidProviderOutputError,
InvalidRequestError,
UnsupportedOperationError,
AIError,
NoRouteError,
ModelID,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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", () => {
Expand Down
Loading