diff --git a/packages/llm/src/schema.ts b/packages/llm/src/schema.ts index 71149ab0f7..b789fbae58 100644 --- a/packages/llm/src/schema.ts +++ b/packages/llm/src/schema.ts @@ -169,10 +169,19 @@ export const ReasoningPart = TypeStruct("reasoning", "LLM.Content.Reasoning", { }) export type ReasoningPart = Schema.Schema.Type -export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe( +const contentPartTagged = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe( Schema.toTaggedUnion("type"), ) -export type ContentPart = Schema.Schema.Type +export const ContentPart = Object.assign(contentPartTagged, { + is: { + text: contentPartTagged.guards.text, + media: contentPartTagged.guards.media, + toolCall: contentPartTagged.guards["tool-call"], + toolResult: contentPartTagged.guards["tool-result"], + reasoning: contentPartTagged.guards.reasoning, + }, +}) +export type ContentPart = Schema.Schema.Type export class Message extends Schema.Class("LLM.Message")({ id: Schema.optional(Schema.String), @@ -214,12 +223,19 @@ export class CacheIntent extends Schema.Class("LLM.CacheIntent")({ key: Schema.optional(Schema.String), }) {} -export const ResponseFormat = Schema.Union([ +const responseFormatTagged = Schema.Union([ TypeStruct("text", "LLM.ResponseFormat.Text", {}), TypeStruct("json", "LLM.ResponseFormat.Json", { schema: JsonSchema }), TypeStruct("tool", "LLM.ResponseFormat.Tool", { tool: ToolDefinition }), ]).pipe(Schema.toTaggedUnion("type")) -export type ResponseFormat = Schema.Schema.Type +export const ResponseFormat = Object.assign(responseFormatTagged, { + is: { + text: responseFormatTagged.guards.text, + json: responseFormatTagged.guards.json, + tool: responseFormatTagged.guards.tool, + }, +}) +export type ResponseFormat = Schema.Schema.Type export class LLMRequest extends Schema.Class("LLM.Request")({ id: Schema.optional(Schema.String), @@ -457,9 +473,27 @@ export class ToolFailure extends Schema.TaggedErrorClass()("LLM.Too metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} -export type LLMError = - | InvalidRequestError - | NoAdapterError - | ProviderChunkError - | ProviderRequestError - | TransportError +const llmErrorTagged = Schema.Union([ + InvalidRequestError, + NoAdapterError, + ProviderChunkError, + ProviderRequestError, + TransportError, +]).pipe(Schema.toTaggedUnion("_tag")) + +/** + * Tagged-union helpers for every error that can escape the LLM client runtime. + * Individual classes still support `Effect.catchTag("LLM.ProviderChunkError", ...)`; + * this union adds `LLMError.is.*`, `LLMError.guards`, `LLMError.isAnyOf`, and + * `LLMError.match` for plain values, arrays, and UI/rendering code. + */ +export const LLMError = Object.assign(llmErrorTagged, { + is: { + invalidRequest: llmErrorTagged.guards["LLM.InvalidRequestError"], + noAdapter: llmErrorTagged.guards["LLM.NoAdapterError"], + providerChunk: llmErrorTagged.guards["LLM.ProviderChunkError"], + providerRequest: llmErrorTagged.guards["LLM.ProviderRequestError"], + transport: llmErrorTagged.guards["LLM.TransportError"], + }, +}) +export type LLMError = Schema.Schema.Type diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index d80acfa5e9..4398142075 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,6 +1,18 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { ContentPart, LLMEvent, LLMRequest, ModelCapabilities, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema" +import { + ContentPart, + InvalidRequestError, + LLMError, + LLMEvent, + LLMRequest, + ModelCapabilities, + ModelID, + ModelLimits, + ModelRef, + ProviderID, + ResponseFormat, +} from "../src/schema" const capabilities = new ModelCapabilities({ input: { text: true, image: false, audio: false, video: false, pdf: false }, @@ -55,4 +67,25 @@ describe("llm schema", () => { expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true) expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) }) + + test("tagged unions expose consistent camel-case is helpers", () => { + expect(ContentPart.is.toolCall({ type: "tool-call", id: "call_1", name: "lookup", input: {} })).toBe(true) + expect(ContentPart.is.toolResult({ type: "tool-call", id: "call_1", name: "lookup", input: {} })).toBe(false) + expect(ResponseFormat.is.json({ type: "json", schema: { type: "object" } })).toBe(true) + expect(LLMEvent.is.providerError({ type: "provider-error", message: "Nope" })).toBe(true) + }) + + test("LLMError exposes tagged error guards and matching", () => { + const error = new InvalidRequestError({ message: "Bad request" }) + + expect(LLMError.is.invalidRequest(error)).toBe(true) + expect(LLMError.guards["LLM.InvalidRequestError"](error)).toBe(true) + expect(LLMError.match(error, { + "LLM.InvalidRequestError": (value) => value.message, + "LLM.NoAdapterError": (value) => value.protocol, + "LLM.ProviderChunkError": (value) => value.adapter, + "LLM.ProviderRequestError": (value) => String(value.status), + "LLM.TransportError": (value) => value.reason ?? value.message, + })).toBe("Bad request") + }) })