refactor(llm): tighten helper and diagnostic surfaces

This commit is contained in:
Kit Langton
2026-05-06 13:28:49 -04:00
parent 9fc1d154c4
commit c99e278e28
30 changed files with 729 additions and 400 deletions
+20 -22
View File
@@ -104,24 +104,26 @@ const tools = {
const streamWithTools = Effect.gen(function* () {
const runtime = yield* ToolRuntime.Service
return yield* runtime.run({
request: LLM.request({
model,
prompt: "Use get_weather for San Francisco, then answer in one sentence.",
generation: { maxTokens: 80, temperature: 0 },
}),
tools,
maxSteps: 3,
}).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "tool-call") console.log("tool call", event.name, event.input)
if (event.type === "tool-result") console.log("tool result", event.name, event.result)
if (event.type === "text-delta") process.stdout.write(event.text)
return yield* runtime
.run({
request: LLM.request({
model,
prompt: "Use get_weather for San Francisco, then answer in one sentence.",
generation: { maxTokens: 80, temperature: 0 },
}),
),
Stream.runDrain,
)
tools,
maxSteps: 3,
})
.pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "tool-call") console.log("tool call", event.name, event.input)
if (event.type === "tool-result") console.log("tool result", event.name, event.result)
if (event.type === "text-delta") process.stdout.write(event.text)
}),
),
Stream.runDrain,
)
})
// -----------------------------------------------------------------------------
@@ -207,11 +209,7 @@ const program = Effect.gen(function* () {
yield* streamWithTools
}).pipe(
Effect.provide(
Layer.mergeAll(
requestExecutorLayer,
llmClientLayer,
ToolRuntime.layer.pipe(Layer.provide(llmClientLayer)),
),
Layer.mergeAll(requestExecutorLayer, llmClientLayer, ToolRuntime.layer.pipe(Layer.provide(llmClientLayer))),
),
)
+2 -15
View File
@@ -11,8 +11,8 @@ import type { LLMError, LLMRequest } from "../schema"
* Most adapters use the default `Auth.bearer`, which reads
* `request.model.apiKey` and sets `Authorization: Bearer ...`. Providers
* that use a different header pick `Auth.apiKeyHeader(name)` (e.g.
* Anthropic's `x-api-key`, Gemini's `x-goog-api-key`) or a provider-aware
* helper such as `Auth.openAI` for Azure OpenAI's static `api-key` header.
* Anthropic's `x-api-key`, Gemini's `x-goog-api-key`, Azure OpenAI's
* `api-key`).
*
* Adapters that need per-request signing (AWS SigV4, future Vertex IAM,
* future Azure AAD) implement `Auth` as a function that hashes the body,
@@ -56,19 +56,6 @@ const fromApiKey =
*/
export const bearer: Auth = fromApiKey((key) => ({ authorization: `Bearer ${key}` }))
/**
* OpenAI-compatible auth with Azure OpenAI's static API-key exception. Azure
* Entra/OAuth callers can still pre-set `authorization` and omit `apiKey`.
*/
export const openAI: Auth = ({ request, headers }) => {
const key = request.model.apiKey
if (!key) return Effect.succeed(headers)
if (request.model.provider === "azure") {
return Effect.succeed(Headers.set(Headers.remove(headers, "authorization"), "api-key", key))
}
return Effect.succeed(Headers.set(headers, "authorization", `Bearer ${key}`))
}
/**
* Set a custom header to `request.model.apiKey`. No-op when `model.apiKey`
* is unset. Used by Anthropic (`x-api-key`) and Gemini (`x-goog-api-key`).
+18 -2
View File
@@ -342,7 +342,7 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
}
})
const prepare = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
const compiled = yield* compile(request)
return new PreparedRequest({
@@ -378,11 +378,24 @@ const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(
)
})
export const prepare = <Payload = unknown>(request: LLMRequest) =>
prepareWith(request) as Effect.Effect<PreparedRequestOf<Payload>, LLMError>
export const stream = (request: LLMRequest) =>
Stream.unwrap(Effect.gen(function* () {
return (yield* Service).stream(request)
}))
export const generate = (request: LLMRequest) =>
Effect.gen(function* () {
return yield* (yield* Service).generate(request)
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const stream = streamWith(yield* RequestExecutor.Service)
return Service.of({ prepare: prepare as Interface["prepare"], stream, generate: generateWith(stream) })
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}),
)
@@ -391,4 +404,7 @@ export const Adapter = { make, model } as const
export const LLMClient = {
Service,
layer,
prepare,
stream,
generate,
} as const
+128 -14
View File
@@ -1,12 +1,19 @@
import { Cause, Context, Effect, Layer } from "effect"
import {
FetchHttpClient,
Headers,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { ProviderRequestError, TransportError, type LLMError } from "../schema"
import {
HttpRequestDetails,
HttpResponseDetails,
ProviderRequestError,
TransportError,
type LLMError,
} from "../schema"
export interface Interface {
readonly execute: (
@@ -16,41 +23,148 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
const statusError = (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
if (response.status < 400) return response
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(undefined)))
return yield* new ProviderRequestError({
status: response.status,
message: `Provider request failed with HTTP ${response.status}`,
body,
})
const BODY_LIMIT = 16_384
const MAX_RETRIES = 2
const MAX_DELAY_MS = 10_000
const REDACTED = "<redacted>"
const sensitiveName = (name: string) =>
/authorization|api[-_]?key|token|secret|credential|signature|x-amz-signature/i.test(name)
const redactHeaders = (headers: Headers.Headers) =>
Object.fromEntries(
Object.entries(headers).map(([name, value]) => [
name,
sensitiveName(name) ? REDACTED : value,
]),
)
const redactUrl = (value: string) => {
if (!URL.canParse(value)) return REDACTED
const url = new URL(value)
url.searchParams.forEach((_, key) => {
if (sensitiveName(key)) url.searchParams.set(key, REDACTED)
})
return url.toString()
}
const normalizedHeaders = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
const requestId = (headers: Record<string, string>) => {
return headers["x-request-id"] ??
headers["request-id"] ??
headers["x-amzn-requestid"] ??
headers["x-amz-request-id"] ??
headers["x-goog-request-id"] ??
headers["cf-ray"]
}
const retryableStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
const retryAfterMs = (headers: Record<string, string>) => {
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
const value = headers["retry-after"]
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const date = Date.parse(value)
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
return undefined
}
const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
new HttpRequestDetails({
method: request.method,
url: redactUrl(request.url),
headers: redactHeaders(request.headers),
})
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
new HttpResponseDetails({
status: response.status,
headers: redactHeaders(response.headers),
})
const responseBody = (body: string | void) => {
if (body === undefined) return {}
if (body.length <= BODY_LIMIT) return { body }
return { body: body.slice(0, BODY_LIMIT), bodyTruncated: true }
}
const statusError = (request: HttpClientRequest.HttpClientRequest) =>
(response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
if (response.status < 400) return response
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
const headers = normalizedHeaders(response.headers)
const retryable = retryableStatus(response.status)
return yield* new ProviderRequestError({
status: response.status,
message: `Provider request failed with HTTP ${response.status}`,
...responseBody(body),
retryable,
retryAfterMs: retryAfterMs(headers),
requestId: requestId(headers),
request: requestDetails(request),
response: responseDetails(response),
})
})
const toHttpError = (error: unknown) => {
if (Cause.isTimeoutError(error)) return new TransportError({ message: error.message, reason: "Timeout" })
if (!HttpClientError.isHttpClientError(error)) return new TransportError({ message: "HTTP transport failed" })
const url = "request" in error ? error.request.url : undefined
if (Cause.isTimeoutError(error)) {
return new TransportError({ message: error.message, reason: "Timeout", retryable: false })
}
if (!HttpClientError.isHttpClientError(error)) {
return new TransportError({ message: "HTTP transport failed", retryable: false })
}
const request = "request" in error ? error.request : undefined
const url = request ? redactUrl(request.url) : undefined
if (error.reason._tag === "TransportError") {
return new TransportError({
message: error.reason.description ?? "HTTP transport failed",
reason: error.reason._tag,
url,
retryable: false,
request: request ? requestDetails(request) : undefined,
})
}
return new TransportError({
message: `HTTP transport failed: ${error.reason._tag}`,
reason: error.reason._tag,
url,
retryable: false,
request: request ? requestDetails(request) : undefined,
})
}
const retryDelay = (error: ProviderRequestError) => Math.min(error.retryAfterMs ?? 500, MAX_DELAY_MS)
const retryStatusFailures = <A, R>(
effect: Effect.Effect<A, LLMError, R>,
retries = MAX_RETRIES,
): Effect.Effect<A, LLMError, R> =>
Effect.catchTag(
effect,
"LLM.ProviderRequestError",
(error): Effect.Effect<A, LLMError, R> => {
if (!error.retryable || retries <= 0) return Effect.fail(error)
return Effect.sleep(retryDelay(error)).pipe(Effect.flatMap(() => retryStatusFailures(effect, retries - 1)))
},
)
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
http.execute(request).pipe(Effect.mapError(toHttpError), Effect.flatMap(statusError(request)))
return Service.of({
execute: (request) => http.execute(request).pipe(Effect.mapError(toHttpError), Effect.flatMap(statusError)),
execute: (request) => retryStatusFailures(executeOnce(request)),
})
}),
)
+11 -55
View File
@@ -8,9 +8,7 @@ import {
import {
GenerationOptions,
HttpOptions,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
ToolChoice,
ToolDefinition,
@@ -26,11 +24,12 @@ export type ModelInput = ModelRefInput
export type MessageInput = Message.Input
export type ToolChoiceInput = ToolChoice | ConstructorParameters<typeof ToolChoice>[0] | ToolDefinition | string
export type ToolChoiceMode = Exclude<ToolChoice["type"], "tool">
export type ToolChoiceInput = ToolChoice.Input
export type ToolChoiceMode = ToolChoice.Mode
export type ToolResultInput = Parameters<typeof ToolResultPart.make>[0]
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput = Omit<
ConstructorParameters<typeof LLMRequest>[0],
"system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
@@ -38,11 +37,11 @@ export type RequestInput = Omit<
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | MessageInput>
readonly tools?: ReadonlyArray<ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoiceInput
readonly generation?: GenerationOptions | ConstructorParameters<typeof GenerationOptions>[0]
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
readonly http?: HttpOptions | ConstructorParameters<typeof HttpOptions>[0]
readonly http?: HttpOptions.Input
}
export const capabilities = modelCapabilities
@@ -66,10 +65,7 @@ export const assistant = Message.assistant
export const model = modelRef
export const toolDefinition = (input: ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]) => {
if (input instanceof ToolDefinition) return input
return new ToolDefinition(input)
}
export const toolDefinition = ToolDefinition.make
export const toolCall = ToolCallPart.make
@@ -77,28 +73,11 @@ export const toolResult = ToolResultPart.make
export const toolMessage = Message.tool
export const toolChoiceName = (name: string) => new ToolChoice({ type: "tool", name })
export const toolChoiceName = ToolChoice.named
const isToolChoiceMode = (value: string): value is ToolChoiceMode =>
value === "auto" || value === "none" || value === "required"
export const toolChoice = ToolChoice.make
export const toolChoice = (input: ToolChoiceInput) => {
if (input instanceof ToolChoice) return input
if (input instanceof ToolDefinition) return new ToolChoice({ type: "tool", name: input.name })
if (typeof input === "string")
return isToolChoiceMode(input) ? new ToolChoice({ type: input }) : toolChoiceName(input)
return new ToolChoice(input)
}
export const generation = (input: GenerationOptions | ConstructorParameters<typeof GenerationOptions>[0] = {}) => {
if (input instanceof GenerationOptions) return input
return new GenerationOptions(input)
}
const http = (input: HttpOptions | ConstructorParameters<typeof HttpOptions>[0] | undefined) => {
if (input === undefined || input instanceof HttpOptions) return input
return new HttpOptions(input)
}
export const generation = GenerationOptions.make
export const requestInput = (input: LLMRequest): RequestInput => ({
...LLMRequest.input(input),
@@ -124,32 +103,9 @@ export const request = (input: RequestInput) => {
toolChoice: requestToolChoice ? toolChoice(requestToolChoice) : undefined,
generation: requestGeneration === undefined ? undefined : generation(requestGeneration),
providerOptions: requestProviderOptions,
http: http(requestHttp),
http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp),
})
}
export const updateRequest = (input: LLMRequest, patch: Partial<RequestInput>) =>
request({ ...requestInput(input), ...patch })
export const outputText = (response: LLMResponse | { readonly events: ReadonlyArray<LLMEvent> }) =>
response.events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
export const outputUsage = (response: LLMResponse | { readonly events: ReadonlyArray<LLMEvent> }) => {
if (response instanceof LLMResponse) return response.usage
return response.events.reduce<LLMResponse["usage"]>(
(usage, event) => ("usage" in event && event.usage !== undefined ? event.usage : usage),
undefined,
)
}
export const outputToolCalls = (response: LLMResponse | { readonly events: ReadonlyArray<LLMEvent> }) =>
response.events.filter(LLMEvent.is.toolCall)
export const outputReasoning = (response: LLMResponse | { readonly events: ReadonlyArray<LLMEvent> }) =>
response.events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
+33 -12
View File
@@ -1,7 +1,7 @@
import { Array as Arr, Effect, Schema } from "effect"
import { Adapter, type AdapterModelInput } from "../adapter/client"
import { Auth } from "../adapter/auth"
import { Endpoint } from "../adapter/endpoint"
import type { Auth } from "../adapter/auth"
import { Endpoint, type Endpoint as EndpointConfig } from "../adapter/endpoint"
import { Framing } from "../adapter/framing"
import { capabilities } from "../llm"
import { Protocol } from "../adapter/protocol"
@@ -19,6 +19,8 @@ import { OpenAIOptions } from "./utils/openai-options"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-chat"
const DEFAULT_BASE_URL = "https://api.openai.com/v1"
const PATH = "/chat/completions"
// =============================================================================
// Public Model Input
@@ -373,16 +375,35 @@ export const protocol = Protocol.define({
onHalt: finishEvents,
})
export const adapter = Adapter.make({
id: ADAPTER,
protocol,
// The adapter supplies deployment concerns around the protocol: URL, auth,
// and response framing. Other providers can reuse `protocol` with different
// endpoint/auth choices instead of cloning this whole file.
endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/chat/completions" }),
auth: Auth.openAI,
framing: Framing.sse,
})
export const endpoint = (input: {
readonly defaultBaseURL?: string | false
readonly required?: string
} = {}) =>
Endpoint.baseURL<OpenAIChatPayload>({
default: input.defaultBaseURL === false ? undefined : input.defaultBaseURL ?? DEFAULT_BASE_URL,
path: PATH,
required: input.required,
})
export const makeAdapter = (input: {
readonly id?: string
readonly auth?: Auth
readonly endpoint?: EndpointConfig<OpenAIChatPayload>
readonly defaultBaseURL?: string | false
readonly endpointRequired?: string
} = {}) =>
Adapter.make({
id: input.id ?? ADAPTER,
protocol,
// The adapter supplies deployment concerns around the protocol: URL, auth,
// and response framing. Other providers can reuse `protocol` with different
// endpoint/auth choices instead of cloning this whole file.
endpoint: input.endpoint ?? endpoint({ defaultBaseURL: input.defaultBaseURL, required: input.endpointRequired }),
auth: input.auth,
framing: Framing.sse,
})
export const adapter = makeAdapter()
// =============================================================================
// Model Helper
+30 -9
View File
@@ -1,7 +1,7 @@
import { Effect, Schema } from "effect"
import { Adapter, type AdapterModelInput } from "../adapter/client"
import { Auth } from "../adapter/auth"
import { Endpoint } from "../adapter/endpoint"
import type { Auth } from "../adapter/auth"
import { Endpoint, type Endpoint as EndpointConfig } from "../adapter/endpoint"
import { Framing } from "../adapter/framing"
import { capabilities } from "../llm"
import { Protocol } from "../adapter/protocol"
@@ -19,6 +19,8 @@ import { OpenAIOptions } from "./utils/openai-options"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-responses"
const DEFAULT_BASE_URL = "https://api.openai.com/v1"
const PATH = "/responses"
// =============================================================================
// Public Model Input
@@ -401,13 +403,32 @@ export const protocol = Protocol.define({
process: processChunk,
})
export const adapter = Adapter.make({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/responses" }),
auth: Auth.openAI,
framing: Framing.sse,
})
export const endpoint = (input: {
readonly defaultBaseURL?: string | false
readonly required?: string
} = {}) =>
Endpoint.baseURL<OpenAIResponsesPayload>({
default: input.defaultBaseURL === false ? undefined : input.defaultBaseURL ?? DEFAULT_BASE_URL,
path: PATH,
required: input.required,
})
export const makeAdapter = (input: {
readonly id?: string
readonly auth?: Auth
readonly endpoint?: EndpointConfig<OpenAIResponsesPayload>
readonly defaultBaseURL?: string | false
readonly endpointRequired?: string
} = {}) =>
Adapter.make({
id: input.id ?? ADAPTER,
protocol,
endpoint: input.endpoint ?? endpoint({ defaultBaseURL: input.defaultBaseURL, required: input.endpointRequired }),
auth: input.auth,
framing: Framing.sse,
})
export const adapter = makeAdapter()
// =============================================================================
// Model Helper
+29 -5
View File
@@ -1,3 +1,6 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../adapter/auth"
import type { Auth as AuthFn } from "../adapter/auth"
import { Adapter } from "../adapter/client"
import type { ModelInput } from "../llm"
import { ProviderID } from "../schema"
@@ -6,6 +9,9 @@ import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure")
const MISSING_BASE_URL = "Azure OpenAI requires resourceName or baseURL"
const apiKeyAuth = Auth.apiKeyHeader("api-key")
const auth: AuthFn = (input) => apiKeyAuth({ ...input, headers: Headers.remove(input.headers, "authorization") })
export type ModelOptions = Omit<ModelInput, "id" | "provider" | "protocol"> & {
readonly resourceName?: string
@@ -21,7 +27,21 @@ const resourceBaseURL = (resourceName: string | undefined) => {
return `https://${resource}.openai.azure.com/openai/v1`
}
export const adapters = [OpenAIResponses.adapter, OpenAIChat.adapter]
const responsesAdapter = OpenAIResponses.makeAdapter({
id: "azure-openai-responses",
auth,
defaultBaseURL: false,
endpointRequired: MISSING_BASE_URL,
})
const chatAdapter = OpenAIChat.makeAdapter({
id: "azure-openai-chat",
auth,
defaultBaseURL: false,
endpointRequired: MISSING_BASE_URL,
})
export const adapters = [responsesAdapter, chatAdapter]
const mapInput = (input: AzureModelInput) => {
const { apiVersion, resourceName, useCompletionUrls, ...rest } = input
@@ -35,10 +55,14 @@ const mapInput = (input: AzureModelInput) => {
}
}
const chatModel = Adapter.model<AzureModelInput>(OpenAIChat.adapter, { provider: id }, { mapInput })
const responsesModel = Adapter.model<AzureModelInput>(OpenAIResponses.adapter, { provider: id }, { mapInput })
const chatModel = Adapter.model<AzureModelInput>(chatAdapter, { provider: id }, { mapInput })
const responsesModel = Adapter.model<AzureModelInput>(responsesAdapter, { provider: id }, { mapInput })
export const responses = (modelID: string, options: ModelOptions = {}) => responsesModel({ ...options, id: modelID })
export const chat = (modelID: string, options: ModelOptions = {}) => chatModel({ ...options, id: modelID })
export const model = (modelID: string, options: ModelOptions = {}) => {
const create = options.useCompletionUrls === true ? chatModel : responsesModel
return create({ ...options, id: modelID })
if (options.useCompletionUrls === true) return chat(modelID, options)
return responses(modelID, options)
}
+98 -8
View File
@@ -83,6 +83,13 @@ export class HttpOptions extends Schema.Class<HttpOptions>("LLM.HttpOptions")({
query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export namespace HttpOptions {
export type Input = HttpOptions | ConstructorParameters<typeof HttpOptions>[0]
/** Normalize HTTP option input into the canonical `HttpOptions` class. */
export const make = (input: Input) => input instanceof HttpOptions ? input : new HttpOptions(input)
}
export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined>): HttpOptions | undefined => {
const body = mergeJsonRecords(...items.map((item) => item?.body))
const headers = mergeStringRecords(...items.map((item) => item?.headers))
@@ -102,6 +109,13 @@ export class GenerationOptions extends Schema.Class<GenerationOptions>("LLM.Gene
stop: Schema.optional(Schema.Array(Schema.String)),
}) {}
export namespace GenerationOptions {
export type Input = GenerationOptions | ConstructorParameters<typeof GenerationOptions>[0]
/** Normalize generation option input into the canonical `GenerationOptions` class. */
export const make = (input: Input = {}) => input instanceof GenerationOptions ? input : new GenerationOptions(input)
}
export type GenerationOptionsFields = {
readonly maxTokens?: number
readonly temperature?: number
@@ -363,11 +377,37 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export namespace ToolDefinition {
export type Input = ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]
/** Normalize tool definition input into the canonical `ToolDefinition` class. */
export const make = (input: Input) => input instanceof ToolDefinition ? input : new ToolDefinition(input)
}
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
type: Schema.Literals(["auto", "none", "required", "tool"]),
name: Schema.optional(Schema.String),
}) {}
export namespace ToolChoice {
export type Mode = Exclude<ToolChoice["type"], "tool">
export type Input = ToolChoice | ConstructorParameters<typeof ToolChoice>[0] | ToolDefinition | string
const isMode = (value: string): value is Mode =>
value === "auto" || value === "none" || value === "required"
/** Select a specific named tool. */
export const named = (value: string) => new ToolChoice({ type: "tool", name: value })
/** Normalize ergonomic tool-choice inputs into the canonical `ToolChoice` class. */
export const make = (input: Input) => {
if (input instanceof ToolChoice) return input
if (input instanceof ToolDefinition) return named(input.name)
if (typeof input === "string") return isMode(input) ? new ToolChoice({ type: input }) : named(input)
return new ToolChoice(input)
}
}
export const ResponseFormat = Schema.Union([
Schema.Struct({ type: Schema.Literal("text") }),
Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
@@ -583,29 +623,60 @@ export type PreparedRequestOf<Payload> = Omit<PreparedRequest, "payload"> & {
readonly payload: Payload
}
const responseText = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
events.reduce<Usage | undefined>(
(usage, event) => ("usage" in event && event.usage !== undefined ? event.usage : usage),
undefined,
)
export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
events: Schema.Array(LLMEvent),
usage: Schema.optional(Usage),
}) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */
get text() {
return this.events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
return responseText(this.events)
}
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
get reasoning() {
return this.events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
return responseReasoning(this.events)
}
/** Completed tool calls emitted by the provider. */
get toolCalls() {
return this.events.filter(LLMEvent.is.toolCall)
}
}
export namespace LLMResponse {
export type Output = LLMResponse | { readonly events: ReadonlyArray<LLMEvent>; readonly usage?: Usage }
/** Concatenate assistant text from a response or collected event list. */
export const text = (response: Output) => responseText(response.events)
/** Return response usage, falling back to the latest usage-bearing event. */
export const usage = (response: Output) => response.usage ?? responseUsage(response.events)
/** Return completed tool calls from a response or collected event list. */
export const toolCalls = (response: Output) => response.events.filter(LLMEvent.is.toolCall)
/** Concatenate reasoning text from a response or collected event list. */
export const reasoning = (response: Output) => responseReasoning(response.events)
}
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()("LLM.InvalidRequestError", {
message: Schema.String,
}) {}
@@ -627,10 +698,27 @@ export class ProviderChunkError extends Schema.TaggedErrorClass<ProviderChunkErr
raw: Schema.optional(Schema.String),
}) {}
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("LLM.HttpResponseDetails")({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class ProviderRequestError extends Schema.TaggedErrorClass<ProviderRequestError>()("LLM.ProviderRequestError", {
status: Schema.Number,
message: Schema.String,
body: Schema.optional(Schema.String),
bodyTruncated: Schema.optional(Schema.Boolean),
retryable: Schema.Boolean,
retryAfterMs: Schema.optional(Schema.Number),
requestId: Schema.optional(Schema.String),
request: Schema.optional(HttpRequestDetails),
response: Schema.optional(HttpResponseDetails),
}) {}
export class TransportError extends Schema.TaggedErrorClass<TransportError>()("LLM.TransportError", {
@@ -641,6 +729,8 @@ export class TransportError extends Schema.TaggedErrorClass<TransportError>()("L
reason: Schema.optional(Schema.String),
// Optional URL of the failing request when the transport layer surfaces it.
url: Schema.optional(Schema.String),
retryable: Schema.Boolean,
request: Schema.optional(HttpRequestDetails),
}) {}
/**
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ProviderRequestError } from "../src"
import { RequestExecutor } from "../src/adapter"
import { it } from "./lib/effect"
const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&debug=1").pipe(
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer secret", "x-safe": "visible" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
describe("RequestExecutor", () => {
it.effect("returns redacted diagnostics for retryable rate limits", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error).toBeInstanceOf(ProviderRequestError)
if (!(error instanceof ProviderRequestError)) throw new Error("expected ProviderRequestError")
expect(error).toMatchObject({
status: 429,
retryable: true,
retryAfterMs: 0,
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
},
},
})
expect(error.body).toBe("rate limited")
}).pipe(
Effect.provide(
responsesLayer([
...Array.from({ length: 3 }, () => new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
})),
]),
),
),
)
it.effect("retries retryable status responses before returning the stream", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const response = yield* executor.execute(request)
expect(response.status).toBe(200)
expect(yield* response.text).toBe("ok")
}).pipe(
Effect.provide(
responsesLayer([
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
),
),
)
it.effect("does not retry non-retryable status responses and truncates large bodies", () => {
return Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error).toBeInstanceOf(ProviderRequestError)
if (!(error instanceof ProviderRequestError)) throw new Error("expected ProviderRequestError")
expect(error.retryable).toBe(false)
expect(error.bodyTruncated).toBe(true)
expect(error.body).toHaveLength(16_384)
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
)
})
})
-18
View File
@@ -1,18 +0,0 @@
import { Effect, Layer, Stream } from "effect"
import { LLMClient, RequestExecutor } from "../../src/adapter"
import type { LLMRequest } from "../../src/schema"
export const prepare = <Payload = unknown>(request: LLMRequest) =>
Effect.gen(function* () {
return yield* (yield* LLMClient.Service).prepare<Payload>(request)
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))))
export const generate = (request: LLMRequest) =>
Effect.gen(function* () {
return yield* (yield* LLMClient.Service).generate(request)
})
export const stream = (request: LLMRequest) =>
Stream.unwrap(Effect.gen(function* () {
return (yield* LLMClient.Service).stream(request)
}))
+5 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { LLM } from "../src"
import { LLM, LLMResponse } from "../src"
import { LLMRequest, Message, ModelRef, ToolChoice, ToolDefinition } from "../src/schema"
describe("llm constructors", () => {
@@ -119,7 +119,9 @@ describe("llm constructors", () => {
])
})
test("extracts output text from responses", () => {
expect(LLM.outputText({ events: [{ type: "text-delta", text: "hi" }, { type: "request-finish", reason: "stop" }] })).toBe("hi")
test("extracts output text from response events", () => {
expect(LLMResponse.text({
events: [{ type: "text-delta", text: "hi" }, { type: "request-finish", reason: "stop" }],
})).toBe("hi")
})
})
@@ -5,7 +5,6 @@ import { LLMClient } from "../../src/adapter"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { eventSummary, expectWeatherToolLoop, runWeatherToolLoop, textRequest, weatherToolLoopRequest, weatherToolName, weatherToolRequest } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
import * as TestLLMClient from "../lib/llm-client"
const model = AnthropicMessages.model({
id: "claude-haiku-4-5-20251001",
@@ -34,7 +33,7 @@ const recorded = recordedTests({
})
const generate = (request: LLMRequest) =>
Effect.gen(function* () {
return yield* TestLLMClient.generate(request)
return yield* LLMClient.generate(request)
})
const malformedToolOrderRequest = LLM.request({
@@ -4,7 +4,6 @@ import { CacheHint, LLM, ProviderRequestError } from "../../src"
import { LLMClient } from "../../src/adapter"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { it } from "../lib/effect"
import * as TestLLMClient from "../lib/llm-client"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@@ -25,7 +24,7 @@ const request = LLM.request({
describe("Anthropic Messages adapter", () => {
it.effect("prepares Anthropic Messages target", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.payload).toEqual({
model: "claude-sonnet-4-5",
@@ -40,7 +39,7 @@ describe("Anthropic Messages adapter", () => {
it.effect("prepares tool call and tool result messages", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result",
model,
@@ -79,12 +78,12 @@ describe("Anthropic Messages adapter", () => {
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
)
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(body)))
expect(LLM.outputText(response)).toBe("Hello!")
expect(LLM.outputReasoning(response)).toBe("thinking")
expect(LLM.outputUsage(response)).toMatchObject({
expect(response.text).toBe("Hello!")
expect(response.reasoning).toBe("thinking")
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
cacheReadInputTokens: 1,
@@ -104,14 +103,14 @@ describe("Anthropic Messages adapter", () => {
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
)
const response = yield* TestLLMClient.generate(
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
)
.pipe(Effect.provide(fixedResponse(body)))
expect(LLM.outputToolCalls(response)).toEqual([{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }])
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }])
expect(response.events).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
@@ -127,7 +126,7 @@ describe("Anthropic Messages adapter", () => {
it.effect("emits provider-error events for mid-stream provider errors", () =>
Effect.gen(function* () {
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(
Effect.provide(
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
@@ -140,7 +139,7 @@ describe("Anthropic Messages adapter", () => {
it.effect("fails HTTP provider errors before stream parsing", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.generate(request)
const error = yield* LLMClient.generate(request)
.pipe(
Effect.provide(
fixedResponse('{"type":"error","error":{"type":"invalid_request_error","message":"Bad request"}}', {
@@ -179,7 +178,7 @@ describe("Anthropic Messages adapter", () => {
{ type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
)
const response = yield* TestLLMClient.generate(
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
}),
@@ -202,7 +201,7 @@ describe("Anthropic Messages adapter", () => {
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
providerExecuted: true,
})
expect(LLM.outputText(response)).toBe("Found it.")
expect(response.text).toBe("Found it.")
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
}),
)
@@ -226,7 +225,7 @@ describe("Anthropic Messages adapter", () => {
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
)
const response = yield* TestLLMClient.generate(
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
}),
@@ -246,7 +245,7 @@ describe("Anthropic Messages adapter", () => {
it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_round_trip",
model,
@@ -297,7 +296,7 @@ describe("Anthropic Messages adapter", () => {
it.effect("rejects round-trip for unknown server tool names", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_unknown_server_tool",
model,
@@ -322,7 +321,7 @@ describe("Anthropic Messages adapter", () => {
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_media",
model,
@@ -6,7 +6,6 @@ import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/adapter"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { it } from "../lib/effect"
import * as TestLLMClient from "../lib/llm-client"
import { fixedResponse } from "../lib/http"
import { eventSummary, expectWeatherToolLoop, runWeatherToolLoop, weatherTool, weatherToolLoopRequest, weatherToolName } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
@@ -63,7 +62,7 @@ const baseRequest = LLM.request({
describe("Bedrock Converse adapter", () => {
it.effect("prepares Converse target with system, inference config, and messages", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(baseRequest)
const prepared = yield* LLMClient.prepare(baseRequest)
expect(prepared.payload).toEqual({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -76,7 +75,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(baseRequest, {
tools: [
{
@@ -110,7 +109,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_history",
model,
@@ -156,17 +155,17 @@ describe("Bedrock Converse adapter", () => {
["messageStop", { stopReason: "end_turn" }],
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
)
const response = yield* TestLLMClient.generate(baseRequest)
const response = yield* LLMClient.generate(baseRequest)
.pipe(Effect.provide(fixedBytes(body)))
expect(LLM.outputText(response)).toBe("Hello!")
expect(response.text).toBe("Hello!")
const finishes = response.events.filter((event) => event.type === "request-finish")
// Bedrock splits the finish across `messageStop` (carries reason) and
// `metadata` (carries usage). We consolidate them into a single
// terminal `request-finish` event with both.
expect(finishes).toHaveLength(1)
expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" })
expect(LLM.outputUsage(response)).toMatchObject({
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
totalTokens: 7,
@@ -190,14 +189,14 @@ describe("Bedrock Converse adapter", () => {
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "tool_use" }],
)
const response = yield* TestLLMClient.generate(
const response = yield* LLMClient.generate(
LLM.updateRequest(baseRequest, {
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
}),
)
.pipe(Effect.provide(fixedBytes(body)))
expect(LLM.outputToolCalls(response)).toEqual([
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
])
const events = response.events.filter((event) => event.type === "tool-input-delta")
@@ -220,10 +219,10 @@ describe("Bedrock Converse adapter", () => {
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* TestLLMClient.generate(baseRequest)
const response = yield* LLMClient.generate(baseRequest)
.pipe(Effect.provide(fixedBytes(body)))
expect(LLM.outputReasoning(response)).toBe("Let me think.")
expect(response.reasoning).toBe("Let me think.")
}),
)
@@ -233,7 +232,7 @@ describe("Bedrock Converse adapter", () => {
["messageStart", { role: "assistant" }],
["throttlingException", { message: "Slow down" }],
)
const response = yield* TestLLMClient.generate(baseRequest)
const response = yield* LLMClient.generate(baseRequest)
.pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
@@ -250,7 +249,7 @@ describe("Bedrock Converse adapter", () => {
id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
baseURL: "https://bedrock-runtime.test",
})
const error = yield* TestLLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel }))
const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel }))
.pipe(Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))), Effect.flip)
expect(error.message).toContain("Bedrock Converse requires either model.apiKey")
@@ -268,7 +267,7 @@ describe("Bedrock Converse adapter", () => {
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
})
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(baseRequest, { model: signed }),
)
@@ -285,7 +284,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_cache",
model,
@@ -317,7 +316,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("does not emit cachePoint when no cache hint is set", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(baseRequest)
const prepared = yield* LLMClient.prepare(baseRequest)
expect(prepared.payload).toMatchObject({
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
@@ -327,7 +326,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("lowers image media into Bedrock image blocks", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_image",
model,
@@ -363,7 +362,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("base64-encodes Uint8Array image bytes", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_image_bytes",
model,
@@ -389,7 +388,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_doc",
model,
@@ -420,7 +419,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_bad_image",
model,
@@ -435,7 +434,7 @@ describe("Bedrock Converse adapter", () => {
it.effect("rejects unsupported document media types", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_bad_doc",
model,
@@ -1,11 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, type LLMRequest } from "../../src"
import { LLM } from "../../src"
import { LLMClient } from "../../src/adapter"
import * as Gemini from "../../src/protocols/gemini"
import { eventSummary, textRequest, weatherToolName, weatherToolRequest } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
import * as TestLLMClient from "../lib/llm-client"
const model = Gemini.model({
id: "gemini-2.5-flash",
@@ -21,15 +20,10 @@ const recorded = recordedTests({
protocol: "gemini",
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
})
const generate = (request: LLMRequest) =>
Effect.gen(function* () {
return yield* TestLLMClient.generate(request)
})
describe("Gemini recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* generate(request)
const response = yield* LLMClient.generate(request)
expect(eventSummary(response.events)).toEqual([
{ type: "text", value: expect.stringMatching(/^Hello!?$/) },
@@ -40,7 +34,7 @@ describe("Gemini recorded", () => {
recorded.effect.with("streams tool call", { tags: ["tool"] }, () =>
Effect.gen(function* () {
const response = yield* generate(toolRequest)
const response = yield* LLMClient.generate(toolRequest)
expect(eventSummary(response.events)).toEqual([
{ type: "tool-call", name: weatherToolName, input: { city: "Paris" } },
+136 -115
View File
@@ -4,7 +4,6 @@ import { LLM, ProviderChunkError } from "../../src"
import { LLMClient } from "../../src/adapter"
import * as Gemini from "../../src/protocols/gemini"
import { it } from "../lib/effect"
import * as TestLLMClient from "../lib/llm-client"
import { fixedResponse } from "../lib/http"
import { sseEvents, sseRaw } from "../lib/sse"
@@ -25,7 +24,7 @@ const request = LLM.request({
describe("Gemini adapter", () => {
it.effect("prepares Gemini target", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.payload).toEqual({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
@@ -37,15 +36,17 @@ describe("Gemini adapter", () => {
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result",
model,
tools: [{
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
}],
tools: [
{
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
},
],
toolChoice: { type: "tool", name: "lookup" },
messages: [
LLM.user([
@@ -62,10 +63,7 @@ describe("Gemini adapter", () => {
contents: [
{
role: "user",
parts: [
{ text: "What is in this image?" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
],
parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
},
{
role: "model",
@@ -73,16 +71,22 @@ describe("Gemini adapter", () => {
},
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } }],
parts: [
{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
],
},
],
tools: [
{
functionDeclarations: [
{
name: "lookup",
description: "Lookup data",
parameters: { type: "object", properties: { query: { type: "string" } } },
},
],
},
],
tools: [{
functionDeclarations: [{
name: "lookup",
description: "Lookup data",
parameters: { type: "object", properties: { query: { type: "string" } } },
}],
}],
toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } },
})
}),
@@ -90,7 +94,7 @@ describe("Gemini adapter", () => {
it.effect("omits tools when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_no_tools",
model,
@@ -108,41 +112,47 @@ describe("Gemini adapter", () => {
it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_schema_patch",
model,
prompt: "Use the tool.",
tools: [{
name: "lookup",
description: "Lookup data",
inputSchema: {
type: "object",
required: ["status", "missing"],
properties: {
status: { type: "integer", enum: [1, 2] },
tags: { type: "array" },
name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
tools: [
{
name: "lookup",
description: "Lookup data",
inputSchema: {
type: "object",
required: ["status", "missing"],
properties: {
status: { type: "integer", enum: [1, 2] },
tags: { type: "array" },
name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
},
},
},
}],
],
}),
)
expect(prepared.payload).toMatchObject({
tools: [{
functionDeclarations: [{
parameters: {
type: "object",
required: ["status"],
properties: {
status: { type: "string", enum: ["1", "2"] },
tags: { type: "array", items: { type: "string" } },
name: { type: "string" },
tools: [
{
functionDeclarations: [
{
parameters: {
type: "object",
required: ["status"],
properties: {
status: { type: "string", enum: ["1", "2"] },
tags: { type: "array", items: { type: "string" } },
name: { type: "string" },
},
},
},
},
}],
}],
],
},
],
})
}),
)
@@ -151,20 +161,26 @@ describe("Gemini adapter", () => {
Effect.gen(function* () {
const body = sseEvents(
{
candidates: [{
content: { role: "model", parts: [{ text: "thinking", thought: true }] },
}],
candidates: [
{
content: { role: "model", parts: [{ text: "thinking", thought: true }] },
},
],
},
{
candidates: [{
content: { role: "model", parts: [{ text: "Hello" }] },
}],
candidates: [
{
content: { role: "model", parts: [{ text: "Hello" }] },
},
],
},
{
candidates: [{
content: { role: "model", parts: [{ text: "!" }] },
finishReason: "STOP",
}],
candidates: [
{
content: { role: "model", parts: [{ text: "!" }] },
finishReason: "STOP",
},
],
},
{
usageMetadata: {
@@ -176,12 +192,11 @@ describe("Gemini adapter", () => {
},
},
)
const response = yield* TestLLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(body)))
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(LLM.outputText(response)).toBe("Hello!")
expect(LLM.outputReasoning(response)).toBe("thinking")
expect(LLM.outputUsage(response)).toMatchObject({
expect(response.text).toBe("Hello!")
expect(response.reasoning).toBe("thinking")
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
reasoningTokens: 1,
@@ -216,32 +231,38 @@ describe("Gemini adapter", () => {
it.effect("emits streamed tool calls and maps finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(
{
candidates: [{
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
finishReason: "STOP",
}],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
},
)
const response = yield* TestLLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
)
.pipe(Effect.provide(fixedResponse(body)))
},
],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
})
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(LLM.outputToolCalls(response)).toEqual([{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }])
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
])
expect(response.events).toEqual([
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
{
type: "request-finish",
reason: "tool-calls",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { promptTokenCount: 5, candidatesTokenCount: 1 } },
usage: {
inputTokens: 5,
outputTokens: 1,
totalTokens: 6,
native: { promptTokenCount: 5, candidatesTokenCount: 1 },
},
},
])
}),
@@ -249,9 +270,9 @@ describe("Gemini adapter", () => {
it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents(
{
candidates: [{
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
@@ -260,17 +281,16 @@ describe("Gemini adapter", () => {
],
},
finishReason: "STOP",
}],
},
)
const response = yield* TestLLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
)
.pipe(Effect.provide(fixedResponse(body)))
},
],
})
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(LLM.outputToolCalls(response)).toEqual([
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
@@ -280,18 +300,18 @@ describe("Gemini adapter", () => {
it.effect("maps length and content-filter finish reasons", () =>
Effect.gen(function* () {
const length = yield* TestLLMClient.generate(request)
.pipe(
Effect.provide(
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] })),
const length = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }),
),
)
const filtered = yield* TestLLMClient.generate(request)
.pipe(
Effect.provide(
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })),
),
)
),
)
const filtered = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })),
),
)
expect(length.events).toEqual([{ type: "request-finish", reason: "length" }])
expect(filtered.events).toEqual([{ type: "request-finish", reason: "content-filter" }])
@@ -300,8 +320,9 @@ describe("Gemini adapter", () => {
it.effect("leaves total usage undefined when component counts are missing", () =>
Effect.gen(function* () {
const response = yield* TestLLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))))
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))),
)
expect(response.usage).toMatchObject({ reasoningTokens: 1 })
expect(response.usage?.totalTokens).toBeUndefined()
@@ -310,11 +331,10 @@ describe("Gemini adapter", () => {
it.effect("fails invalid stream chunks", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.generate(request)
.pipe(
Effect.provide(fixedResponse(sseRaw("data: {not json}"))),
Effect.flip,
)
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseRaw("data: {not json}"))),
Effect.flip,
)
expect(error).toBeInstanceOf(ProviderChunkError)
expect(error.message).toContain("Invalid google/gemini stream chunk")
@@ -323,16 +343,17 @@ describe("Gemini adapter", () => {
it.effect("rejects unsupported assistant media content", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
LLM.request({
id: "req_media",
model,
messages: [LLM.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
}),
)
.pipe(Effect.flip)
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_media",
model,
messages: [LLM.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("Gemini assistant messages only support text, reasoning, and tool-call content for now")
expect(error.message).toContain(
"Gemini assistant messages only support text, reasoning, and tool-call content for now",
)
}),
)
})
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM } from "../../src"
import { LLM, LLMResponse } from "../../src"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import { ToolRuntime } from "../../src/tool-runtime"
import { eventSummary, weatherRuntimeTool } from "../recorded-scenarios"
@@ -39,7 +39,7 @@ describe("OpenAI Chat tool-loop recorded", () => {
yield* TestToolRuntime.runTools({ request, tools: { get_weather: weatherRuntimeTool } }).pipe(Stream.runCollect),
)
expect(LLM.outputText({ events })).toContain("Paris")
expect(LLMResponse.text({ events })).toContain("Paris")
expect(eventSummary(events)).toEqual([
{ type: "tool-call", name: "get_weather", input: { city: "Paris" } },
{
@@ -5,7 +5,6 @@ import { LLMClient } from "../../src/adapter"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import { eventSummary, textRequest, weatherToolName, weatherToolRequest } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
import * as TestLLMClient from "../lib/llm-client"
const model = OpenAIChat.model({
id: "gpt-4o-mini",
@@ -39,7 +38,7 @@ const recorded = recordedTests({
})
const generate = (request: LLMRequest) =>
Effect.gen(function* () {
return yield* TestLLMClient.generate(request)
return yield* LLMClient.generate(request)
})
describe("OpenAI Chat recorded", () => {
+19 -20
View File
@@ -5,8 +5,8 @@ import { LLM, ProviderRequestError } from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import { LLMClient } from "../../src/adapter"
import { it } from "../lib/effect"
import * as TestLLMClient from "../lib/llm-client"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
import { deltaChunk, usageChunk } from "../lib/openai-chunks"
import { sseEvents } from "../lib/sse"
@@ -35,7 +35,7 @@ describe("OpenAI Chat adapter", () => {
// Pass the OpenAIChat payload type so `prepared.payload` is statically
// typed to the adapter's native shape — the assertions below read field
// names without `unknown` casts.
const prepared = yield* TestLLMClient.prepare<OpenAIChat.OpenAIChatPayload>(request)
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatPayload>(request)
const _typed: { readonly model: string; readonly stream: true } = prepared.payload
expect(prepared.payload).toEqual({
@@ -54,7 +54,7 @@ describe("OpenAI Chat adapter", () => {
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare<OpenAIChat.OpenAIChatPayload>(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatPayload>(
LLM.request({
model: OpenAI.chat("gpt-4o-mini", { baseURL: "https://api.openai.test/v1/" }),
prompt: "think",
@@ -68,7 +68,7 @@ describe("OpenAI Chat adapter", () => {
)
it.effect("adds native query params to the Chat Completions URL", () =>
TestLLMClient.generate(LLM.updateRequest(request, { model: OpenAIChat.model({ ...model, queryParams: { "api-version": "v1" } }) }))
LLMClient.generate(LLM.updateRequest(request, { model: OpenAIChat.model({ ...model, queryParams: { "api-version": "v1" } }) }))
.pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -85,10 +85,9 @@ describe("OpenAI Chat adapter", () => {
)
it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
TestLLMClient.generate(
LLMClient.generate(
LLM.updateRequest(request, {
model: Azure.model("gpt-4o-mini", {
useCompletionUrls: true,
model: Azure.chat("gpt-4o-mini", {
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
@@ -112,7 +111,7 @@ describe("OpenAI Chat adapter", () => {
)
it.effect("applies serializable HTTP overlays after payload lowering", () =>
TestLLMClient.generate(
LLMClient.generate(
LLM.updateRequest(request, {
model: OpenAIChat.model({ ...model, apiKey: "fresh-key", headers: { authorization: "Bearer stale" } }),
http: {
@@ -146,7 +145,7 @@ describe("OpenAI Chat adapter", () => {
it.effect("prepares assistant tool-call and tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result",
model,
@@ -183,7 +182,7 @@ describe("OpenAI Chat adapter", () => {
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_media",
model,
@@ -198,7 +197,7 @@ describe("OpenAI Chat adapter", () => {
it.effect("rejects unsupported assistant reasoning content", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_reasoning",
model,
@@ -225,10 +224,10 @@ describe("OpenAI Chat adapter", () => {
completion_tokens_details: { reasoning_tokens: 0 },
}),
)
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(body)))
expect(LLM.outputText(response)).toBe("Hello!")
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "text-delta", text: "Hello" },
{ type: "text-delta", text: "!" },
@@ -264,7 +263,7 @@ describe("OpenAI Chat adapter", () => {
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
deltaChunk({}, "tool_calls"),
)
const response = yield* TestLLMClient.generate(
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
@@ -289,7 +288,7 @@ describe("OpenAI Chat adapter", () => {
}),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
)
const response = yield* TestLLMClient.generate(
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
@@ -300,14 +299,14 @@ describe("OpenAI Chat adapter", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(LLM.outputToolCalls(response)).toEqual([])
expect(response.toolCalls).toEqual([])
}),
)
it.effect("fails on malformed stream chunks", () =>
Effect.gen(function* () {
const body = sseEvents(deltaChunk({ content: 123 }))
const error = yield* TestLLMClient.generate(request)
const error = yield* LLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error.message).toContain("Invalid openai/openai-chat stream chunk")
@@ -319,7 +318,7 @@ describe("OpenAI Chat adapter", () => {
const layer = truncatedStream([
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
])
const error = yield* TestLLMClient.generate(request)
const error = yield* LLMClient.generate(request)
.pipe(Effect.provide(layer), Effect.flip)
expect(error.message).toContain("Failed to read openai/openai-chat stream")
@@ -328,7 +327,7 @@ describe("OpenAI Chat adapter", () => {
it.effect("fails HTTP provider errors before stream parsing", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.generate(request)
const error = yield* LLMClient.generate(request)
.pipe(
Effect.provide(
fixedResponse('{"error":{"message":"Bad request","type":"invalid_request_error"}}', {
@@ -357,7 +356,7 @@ describe("OpenAI Chat adapter", () => {
)
const events = Array.from(
yield* TestLLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))),
yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))),
)
expect(events.map((event) => event.type)).toEqual(["text-delta"])
}),
@@ -7,7 +7,6 @@ import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-cha
import * as OpenRouter from "../../src/providers/openrouter"
import { expectFinish, expectWeatherToolCall, expectWeatherToolLoop, runWeatherToolLoop, textRequest, weatherToolLoopRequest, weatherToolRequest } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
import * as TestLLMClient from "../lib/llm-client"
const deepseekModel = OpenAICompatible.deepseek.model("deepseek-chat", {
apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture",
@@ -58,7 +57,7 @@ const xaiToolRequest = weatherToolRequest({ id: "recorded_xai_tool_call", model:
const recorded = recordedTests({ prefix: "openai-compatible-chat", protocol: "openai-compatible-chat" })
const generate = (request: LLMRequest) =>
Effect.gen(function* () {
return yield* TestLLMClient.generate(request)
return yield* LLMClient.generate(request)
})
const openrouterToolLoops = [
@@ -87,7 +86,7 @@ describe("OpenAI-compatible Chat recorded", () => {
Effect.gen(function* () {
const response = yield* generate(deepseekRequest)
expect(LLM.outputText(response)).toMatch(/^Hello!?$/)
expect(response.text).toMatch(/^Hello!?$/)
expectFinish(response.events, "stop")
}),
)
@@ -96,7 +95,7 @@ describe("OpenAI-compatible Chat recorded", () => {
Effect.gen(function* () {
const response = yield* generate(togetherRequest)
expect(LLM.outputText(response)).toMatch(/^Hello!?$/)
expect(response.text).toMatch(/^Hello!?$/)
expectFinish(response.events, "stop")
}),
)
@@ -115,7 +114,7 @@ describe("OpenAI-compatible Chat recorded", () => {
Effect.gen(function* () {
const response = yield* generate(groqRequest)
expect(LLM.outputText(response)).toMatch(/^Hello!?$/)
expect(response.text).toMatch(/^Hello!?$/)
expectFinish(response.events, "stop")
}),
)
@@ -144,7 +143,7 @@ describe("OpenAI-compatible Chat recorded", () => {
Effect.gen(function* () {
const response = yield* generate(openrouterRequest)
expect(LLM.outputText(response)).toMatch(/^Hello!?$/)
expect(response.text).toMatch(/^Hello!?$/)
expectFinish(response.events, "stop")
}),
)
@@ -175,7 +174,7 @@ describe("OpenAI-compatible Chat recorded", () => {
Effect.gen(function* () {
const response = yield* generate(xaiRequest)
expect(LLM.outputText(response)).toMatch(/^Hello!?$/)
expect(response.text).toMatch(/^Hello!?$/)
expectFinish(response.events, "stop")
}),
)
@@ -6,7 +6,6 @@ import { LLMClient } from "../../src/adapter"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect"
import * as TestLLMClient from "../lib/llm-client"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@@ -53,7 +52,7 @@ const providerFamilies = [
describe("OpenAI-compatible Chat adapter", () => {
it.effect("prepares generic Chat target", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
toolChoice: { type: "required" },
@@ -126,7 +125,7 @@ describe("OpenAI-compatible Chat adapter", () => {
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.payload).toEqual({
model: "deepseek-chat",
@@ -144,7 +143,7 @@ describe("OpenAI-compatible Chat adapter", () => {
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_parity",
model,
@@ -194,7 +193,7 @@ describe("OpenAI-compatible Chat adapter", () => {
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -224,8 +223,8 @@ describe("OpenAI-compatible Chat adapter", () => {
),
)
expect(LLM.outputText(response)).toBe("Hello!")
expect(LLM.outputUsage(response)).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
expect(response.text).toBe("Hello!")
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
}),
)
@@ -5,7 +5,6 @@ import { LLMClient } from "../../src/adapter"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import { expectFinish, expectWeatherToolCall, expectWeatherToolLoop, runWeatherToolLoop, weatherTool, weatherToolLoopRequest, weatherToolName } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
import * as TestLLMClient from "../lib/llm-client"
const model = OpenAIResponses.model({
id: "gpt-5.5",
@@ -44,7 +43,7 @@ const recorded = recordedTests({
})
const generate = (request: LLMRequest) =>
Effect.gen(function* () {
return yield* TestLLMClient.generate(request)
return yield* LLMClient.generate(request)
})
describe("OpenAI Responses recorded", () => {
@@ -52,7 +51,7 @@ describe("OpenAI Responses recorded", () => {
Effect.gen(function* () {
const response = yield* generate(textRequest)
expect(LLM.outputText(response)).toMatch(/^Hello!?$/)
expect(response.text).toMatch(/^Hello!?$/)
expect(response.usage?.totalTokens).toBeGreaterThan(0)
expectFinish(response.events, "stop")
}),
@@ -7,7 +7,6 @@ import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import { it } from "../lib/effect"
import * as TestLLMClient from "../lib/llm-client"
import { dynamicResponse, fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@@ -28,7 +27,7 @@ const request = LLM.request({
describe("OpenAI Responses adapter", () => {
it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.payload).toEqual({
model: "gpt-4.1-mini",
@@ -45,7 +44,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("adds native query params to the Responses URL", () =>
Effect.gen(function* () {
yield* TestLLMClient.generate(LLM.updateRequest(request, { model: OpenAIResponses.model({ ...model, queryParams: { "api-version": "v1" } }) }))
yield* LLMClient.generate(LLM.updateRequest(request, { model: OpenAIResponses.model({ ...model, queryParams: { "api-version": "v1" } }) }))
.pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -64,9 +63,9 @@ describe("OpenAI Responses adapter", () => {
it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
Effect.gen(function* () {
yield* TestLLMClient.generate(
yield* LLMClient.generate(
LLM.updateRequest(request, {
model: Azure.model("gpt-4.1-mini", {
model: Azure.responses("gpt-4.1-mini", {
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
@@ -92,7 +91,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("prepares function call and function output input items", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result",
model,
@@ -118,7 +117,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("maps OpenAI provider options to Responses options", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare<OpenAIResponses.OpenAIResponsesPayload>(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesPayload>(
LLM.request({
model: OpenAI.model("gpt-5.2", { baseURL: "https://api.openai.test/v1/" }),
prompt: "think",
@@ -143,7 +142,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("request OpenAI provider options override model defaults", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare<OpenAIResponses.OpenAIResponsesPayload>(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesPayload>(
LLM.request({
model: OpenAI.model("gpt-4.1-mini", {
baseURL: "https://api.openai.test/v1/",
@@ -176,10 +175,10 @@ describe("OpenAI Responses adapter", () => {
},
},
)
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(body)))
expect(LLM.outputText(response)).toBe("Hello!")
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
@@ -226,7 +225,7 @@ describe("OpenAI Responses adapter", () => {
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* TestLLMClient.generate(
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
@@ -259,7 +258,7 @@ describe("OpenAI Responses adapter", () => {
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(body)))
const callsAndResults = response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")
@@ -296,7 +295,7 @@ describe("OpenAI Responses adapter", () => {
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(body)))
const toolCall = response.events.find((event) => event.type === "tool-call")
@@ -320,7 +319,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.prepare(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_media",
model,
@@ -335,7 +334,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("emits provider-error events for mid-stream provider errors", () =>
Effect.gen(function* () {
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(
Effect.provide(
fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" })),
@@ -348,7 +347,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("falls back to error code when no message is present", () =>
Effect.gen(function* () {
const response = yield* TestLLMClient.generate(request)
const response = yield* LLMClient.generate(request)
.pipe(Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))))
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
@@ -357,7 +356,7 @@ describe("OpenAI Responses adapter", () => {
it.effect("fails HTTP provider errors before stream parsing", () =>
Effect.gen(function* () {
const error = yield* TestLLMClient.generate(request)
const error = yield* LLMClient.generate(request)
.pipe(
Effect.provide(
fixedResponse('{"error":{"type":"invalid_request_error","message":"Bad request"}}', {
@@ -4,7 +4,6 @@ import { LLM } from "../../src"
import { LLMClient } from "../../src/adapter"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
import * as TestLLMClient from "../lib/llm-client"
describe("OpenRouter", () => {
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
@@ -19,7 +18,7 @@ describe("OpenRouter", () => {
apiKey: "test-key",
})
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({ model, prompt: "Say hello." }),
)
@@ -34,7 +33,7 @@ describe("OpenRouter", () => {
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* TestLLMClient.prepare(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: OpenRouter.model("anthropic/claude-3.7-sonnet:thinking", {
providerOptions: {
+3 -3
View File
@@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMEvent, type LLMRequest, type LLMResponse, type ModelRef } from "../src"
import { LLM, LLMEvent, LLMResponse, type LLMRequest, type ModelRef } from "../src"
import { tool } from "../src/tool"
import { ToolRuntime } from "../src/tool-runtime"
@@ -90,7 +90,7 @@ export const expectFinish = (
) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason })
export const expectWeatherToolCall = (response: LLMResponse) =>
expect(LLM.outputToolCalls(response)).toMatchObject([
expect(response.toolCalls).toMatchObject([
{ type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } },
])
@@ -112,7 +112,7 @@ export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
result: { type: "json", value: { temperature: 22, condition: "sunny" } },
})
const output = LLM.outputText({ events })
const output = LLMResponse.text({ events })
expect(output).toContain("Paris")
expect(output.trim().length).toBeGreaterThan(0)
}
+5 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMEvent, LLMRequest } from "../src"
import { LLM, LLMEvent, LLMRequest, LLMResponse } from "../src"
import { LLMClient } from "../src/adapter"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as OpenAIChat from "../src/protocols/openai-chat"
@@ -49,7 +49,7 @@ describe("ToolRuntime", () => {
),
)
expect(LLM.outputText({ events })).toBe("Done.")
expect(LLMResponse.text({ events })).toBe("Done.")
}),
)
@@ -123,7 +123,7 @@ describe("ToolRuntime", () => {
result: { type: "json", value: { temperature: 22, condition: "sunny" } },
})
expect(events.at(-1)?.type).toBe("request-finish")
expect(LLM.outputText({ events })).toBe("It's sunny in Paris.")
expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
}),
)
@@ -205,7 +205,7 @@ describe("ToolRuntime", () => {
)
expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
expect(LLM.outputText({ events })).toBe("Done.")
expect(LLMResponse.text({ events })).toBe("Done.")
}),
)
@@ -300,7 +300,7 @@ describe("ToolRuntime", () => {
providerExecuted: true,
},
])
expect(LLM.outputText({ events })).toBe("Done.")
expect(LLMResponse.text({ events })).toBe("Done.")
}),
)
+5 -4
View File
@@ -162,13 +162,14 @@ const PROVIDERS: Record<string, ProviderModel> = {
AmazonBedrock.model(String(input.model.api.id), sharedOptions(input, options, { protocol: "bedrock-converse" })),
"@ai-sdk/anthropic": (input, options) =>
Anthropic.model(String(input.model.api.id), sharedOptions(input, options, { protocol: "anthropic-messages" })),
"@ai-sdk/azure": (input, options) =>
Azure.model(String(input.model.api.id), {
"@ai-sdk/azure": (input, options) => {
const create = options.useCompletionUrls === true ? Azure.chat : Azure.responses
return create(String(input.model.api.id), {
...sharedOptions(input, options, { protocol: azureProtocol(options), providerOptions: openAIOptions(options) }),
resourceName: stringOption(options, "resourceName"),
apiVersion: stringOption(options, "apiVersion"),
useCompletionUrls: options.useCompletionUrls === true,
}),
})
},
"@ai-sdk/baseten": openAICompatibleModel,
"@ai-sdk/cerebras": openAICompatibleModel,
"@ai-sdk/deepinfra": openAICompatibleModel,
@@ -131,6 +131,7 @@ describe("ProviderLLMBridge", () => {
expect(ref).toMatchObject({
provider: "azure",
adapter: "azure-openai-responses",
protocol: "openai-responses",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
apiKey: "azure-key",
@@ -146,6 +147,7 @@ describe("ProviderLLMBridge", () => {
expect(ref).toMatchObject({
provider: "azure",
adapter: "azure-openai-chat",
protocol: "openai-chat",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
queryParams: { "api-version": "v1" },
@@ -799,6 +799,7 @@ describe("LLMNative.request", () => {
expect(request.model).toMatchObject({
id: "gpt-5-deployment",
provider: "azure",
adapter: "azure-openai-responses",
protocol: "openai-responses",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
apiKey: "azure-key",
@@ -823,6 +824,7 @@ describe("LLMNative.request", () => {
expect(request.model).toMatchObject({
id: "gpt-4-1-deployment",
provider: "azure",
adapter: "azure-openai-chat",
protocol: "openai-chat",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
apiKey: "azure-key",