diff --git a/packages/llm/src/provider/anthropic-messages.ts b/packages/llm/src/provider/anthropic-messages.ts index ea117ff706..70a4019efb 100644 --- a/packages/llm/src/provider/anthropic-messages.ts +++ b/packages/llm/src/provider/anthropic-messages.ts @@ -1,9 +1,8 @@ import { Effect, Schema, Stream } from "effect" -import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import type { HttpClientResponse } from "effect/unstable/http" import { Adapter } from "../adapter" import { capabilities, model as llmModel, type ModelInput } from "../llm" import { - InvalidRequestError, Usage, type CacheHint, type FinishReason, @@ -205,7 +204,7 @@ const decodeChunk = (data: string) => const encodeTarget = Schema.encodeSync(AnthropicTargetJson) const decodeTarget = Schema.decodeUnknownEffect(AnthropicMessagesDraft.pipe(Schema.decodeTo(AnthropicMessagesTarget))) -const invalid = (message: string) => new InvalidRequestError({ message }) +const invalid = ProviderShared.invalidRequest const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.anthropic.com/v1").replace(/\/+$/, "") @@ -348,14 +347,11 @@ const prepare = Effect.fn("AnthropicMessages.prepare")(function* (request: LLMRe const toHttp = (target: AnthropicMessagesTarget, request: LLMRequest) => Effect.succeed( - HttpClientRequest.post(`${baseUrl(request)}/messages`).pipe( - HttpClientRequest.setHeaders({ - "anthropic-version": "2023-06-01", - ...request.model.headers, - "content-type": "application/json", - }), - HttpClientRequest.bodyText(encodeTarget(target), "application/json"), - ), + ProviderShared.jsonPost({ + url: `${baseUrl(request)}/messages`, + body: encodeTarget(target), + headers: { "anthropic-version": "2023-06-01", ...request.model.headers }, + }), ) const mapFinishReason = (reason: string | null | undefined): FinishReason => { @@ -529,7 +525,7 @@ export const adapter = Adapter.define target, prepare, - validate: (draft) => decodeTarget(draft).pipe(Effect.mapError((error) => invalid(error.message))), + validate: ProviderShared.validateWith(decodeTarget), toHttp: (target, context) => toHttp(target, context.request), parse: events, }) diff --git a/packages/llm/src/provider/bedrock-converse.ts b/packages/llm/src/provider/bedrock-converse.ts index d4391e404f..280a21860b 100644 --- a/packages/llm/src/provider/bedrock-converse.ts +++ b/packages/llm/src/provider/bedrock-converse.ts @@ -2,11 +2,10 @@ import { EventStreamCodec } from "@smithy/eventstream-codec" import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { AwsV4Signer } from "aws4fetch" import { Effect, Option, Schema, Stream } from "effect" -import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClientResponse } from "effect/unstable/http" import { Adapter } from "../adapter" import { capabilities, model as llmModel, type ModelInput } from "../llm" import { - InvalidRequestError, Usage, type FinishReason, type LLMEvent, @@ -225,7 +224,7 @@ const decodeChunk = (data: unknown) => const encodeTarget = Schema.encodeSync(Schema.fromJsonString(BedrockConverseTarget)) const decodeTarget = Schema.decodeUnknownEffect(BedrockConverseDraft.pipe(Schema.decodeTo(BedrockConverseTarget))) -const invalid = (message: string) => new InvalidRequestError({ message }) +const invalid = ProviderShared.invalidRequest const region = (request: LLMRequest) => { const fromNative = request.model.native?.aws_region @@ -401,9 +400,7 @@ const signRequest = (input: { return Object.fromEntries(signed.headers.entries()) }, catch: (error) => - new InvalidRequestError({ - message: `Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, - }), + invalid(`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`), }) const toHttp = Effect.fn("BedrockConverse.toHttp")(function* (target: BedrockConverseTarget, request: LLMRequest) { @@ -415,10 +412,7 @@ const toHttp = Effect.fn("BedrockConverse.toHttp")(function* (target: BedrockCon } if (isBearerAuth(request.model.headers)) { - return HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeaders(baseHeaders), - HttpClientRequest.bodyText(body, "application/json"), - ) + return ProviderShared.jsonPost({ url, body, headers: request.model.headers }) } const credentials = credentialsFromInput(request) @@ -427,11 +421,10 @@ const toHttp = Effect.fn("BedrockConverse.toHttp")(function* (target: BedrockCon "Bedrock Converse requires either a Bearer API key in headers or AWS credentials in model.native.aws_credentials", ) } + // SigV4 signs the request including content-type; keep `baseHeaders` so the + // signed payload matches what `jsonPost` ultimately sends. const signed = yield* signRequest({ url, body, headers: baseHeaders, credentials }) - return HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeaders({ ...baseHeaders, ...signed }), - HttpClientRequest.bodyText(body, "application/json"), - ) + return ProviderShared.jsonPost({ url, body, headers: { ...baseHeaders, ...signed } }) }) const mapFinishReason = (reason: string): FinishReason => { @@ -666,7 +659,7 @@ export const adapter = Adapter.define target, prepare, - validate: (draft) => decodeTarget(draft).pipe(Effect.mapError((error) => invalid(error.message))), + validate: ProviderShared.validateWith(decodeTarget), toHttp: (target, context) => toHttp(target, context.request), parse: parseStream, }) diff --git a/packages/llm/src/provider/gemini.ts b/packages/llm/src/provider/gemini.ts index 642ea81417..4994fcb477 100644 --- a/packages/llm/src/provider/gemini.ts +++ b/packages/llm/src/provider/gemini.ts @@ -1,10 +1,9 @@ import { Buffer } from "node:buffer" import { Effect, Schema, Stream } from "effect" -import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import type { HttpClientResponse } from "effect/unstable/http" import { Adapter } from "../adapter" import { capabilities, model as llmModel, type ModelInput } from "../llm" import { - InvalidRequestError, Usage, type FinishReason, type LLMEvent, @@ -151,7 +150,7 @@ const decodeChunk = (data: string) => const encodeTarget = Schema.encodeSync(GeminiTargetJson) const decodeTarget = Schema.decodeUnknownEffect(GeminiDraft.pipe(Schema.decodeTo(GeminiTarget))) -const invalid = (message: string) => new InvalidRequestError({ message }) +const invalid = ProviderShared.invalidRequest const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "") @@ -315,13 +314,11 @@ const prepare = Effect.fn("Gemini.prepare")(function* (request: LLMRequest) { const toHttp = (target: GeminiTarget, request: LLMRequest) => Effect.succeed( - HttpClientRequest.post(`${baseUrl(request)}/models/${request.model.id}:streamGenerateContent?alt=sse`).pipe( - HttpClientRequest.setHeaders({ - ...request.model.headers, - "content-type": "application/json", - }), - HttpClientRequest.bodyText(encodeTarget(target), "application/json"), - ), + ProviderShared.jsonPost({ + url: `${baseUrl(request)}/models/${request.model.id}:streamGenerateContent?alt=sse`, + body: encodeTarget(target), + headers: request.model.headers, + }), ) const mapUsage = (usage: GeminiUsage | undefined) => { @@ -412,7 +409,7 @@ export const adapter = Adapter.define({ protocol: "gemini", redact: (target) => target, prepare, - validate: (draft) => decodeTarget(draft).pipe(Effect.mapError((error) => invalid(error.message))), + validate: ProviderShared.validateWith(decodeTarget), toHttp: (target, context) => toHttp(target, context.request), parse: events, }) diff --git a/packages/llm/src/provider/openai-chat.ts b/packages/llm/src/provider/openai-chat.ts index 779fa3f28e..30db07d03d 100644 --- a/packages/llm/src/provider/openai-chat.ts +++ b/packages/llm/src/provider/openai-chat.ts @@ -1,9 +1,8 @@ import { Effect, Schema, Stream } from "effect" -import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import type { HttpClientResponse } from "effect/unstable/http" import { Adapter } from "../adapter" import { capabilities, model as llmModel, type ModelInput } from "../llm" import { - InvalidRequestError, Usage, type FinishReason, type ContentPart, @@ -164,7 +163,7 @@ interface ParserState { const decodeTarget = Schema.decodeUnknownEffect(OpenAIChatDraft.pipe(Schema.decodeTo(OpenAIChatTarget))) -const invalid = (message: string) => new InvalidRequestError({ message }) +const invalid = ProviderShared.invalidRequest const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "") @@ -263,13 +262,11 @@ const prepare = Effect.fn("OpenAIChat.prepare")(function* (request: LLMRequest) const toHttp = (target: OpenAIChatTarget, request: LLMRequest) => Effect.succeed( - HttpClientRequest.post(`${baseUrl(request)}/chat/completions`).pipe( - HttpClientRequest.setHeaders({ - ...request.model.headers, - "content-type": "application/json", - }), - HttpClientRequest.bodyText(encodeTarget(target), "application/json"), - ), + ProviderShared.jsonPost({ + url: `${baseUrl(request)}/chat/completions`, + body: encodeTarget(target), + headers: request.model.headers, + }), ) const mapFinishReason = (reason: string | null | undefined): FinishReason => { @@ -371,7 +368,7 @@ export const adapter = Adapter.define({ protocol: "openai-chat", redact: (target) => target, prepare, - validate: (draft) => decodeTarget(draft).pipe(Effect.mapError((error) => invalid(error.message))), + validate: ProviderShared.validateWith(decodeTarget), toHttp: (target, context) => toHttp(target, context.request), parse: events, }) diff --git a/packages/llm/src/provider/openai-compatible-chat.ts b/packages/llm/src/provider/openai-compatible-chat.ts index 268f31d1e8..0b1836393a 100644 --- a/packages/llm/src/provider/openai-compatible-chat.ts +++ b/packages/llm/src/provider/openai-compatible-chat.ts @@ -1,8 +1,7 @@ import { Effect, Stream } from "effect" -import { HttpClientRequest } from "effect/unstable/http" import { Adapter } from "../adapter" import { capabilities, model as llmModel, type ModelInput } from "../llm" -import { InvalidRequestError, ProviderChunkError, type LLMError, type LLMRequest } from "../schema" +import { ProviderChunkError, type LLMError, type LLMRequest } from "../schema" import { OpenAIChat, type OpenAIChatTarget } from "./openai-chat" import { families, type ProviderFamily } from "./openai-compatible-family" import { ProviderShared } from "./shared" @@ -20,7 +19,7 @@ export type ProviderFamilyModelInput = Omit new InvalidRequestError({ message }) +const invalid = ProviderShared.invalidRequest const isStringRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string") @@ -42,14 +41,11 @@ const toHttp = (target: OpenAIChatTarget, request: LLMRequest) => Effect.gen(function* () { const url = completionUrl(request) if (!url) return yield* invalid("OpenAI-compatible Chat requires a baseURL") - - return HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeaders({ - ...request.model.headers, - "content-type": "application/json", - }), - HttpClientRequest.bodyText(ProviderShared.encodeJson(target), "application/json"), - ) + return ProviderShared.jsonPost({ + url, + body: ProviderShared.encodeJson(target), + headers: request.model.headers, + }) }) const mapParseError = (error: LLMError) => { diff --git a/packages/llm/src/provider/openai-responses.ts b/packages/llm/src/provider/openai-responses.ts index 5e38e7cf3e..1ee02c703e 100644 --- a/packages/llm/src/provider/openai-responses.ts +++ b/packages/llm/src/provider/openai-responses.ts @@ -1,9 +1,8 @@ import { Effect, Schema, Stream } from "effect" -import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import type { HttpClientResponse } from "effect/unstable/http" import { Adapter } from "../adapter" import { capabilities, model as llmModel, type ModelInput } from "../llm" import { - InvalidRequestError, Usage, type FinishReason, type LLMEvent, @@ -149,7 +148,7 @@ interface ParserState { readonly tools: Record } -const invalid = (message: string) => new InvalidRequestError({ message }) +const invalid = ProviderShared.invalidRequest const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "") @@ -239,13 +238,11 @@ const prepare = Effect.fn("OpenAIResponses.prepare")(function* (request: LLMRequ const toHttp = (target: OpenAIResponsesTarget, request: LLMRequest) => Effect.succeed( - HttpClientRequest.post(`${baseUrl(request)}/responses`).pipe( - HttpClientRequest.setHeaders({ - ...request.model.headers, - "content-type": "application/json", - }), - HttpClientRequest.bodyText(encodeTarget(target), "application/json"), - ), + ProviderShared.jsonPost({ + url: `${baseUrl(request)}/responses`, + body: encodeTarget(target), + headers: request.model.headers, + }), ) const mapUsage = (usage: OpenAIResponsesUsage | undefined) => { @@ -396,7 +393,7 @@ export const adapter = Adapter.define target, prepare, - validate: (draft) => decodeTarget(draft).pipe(Effect.mapError((error) => invalid(error.message))), + validate: ProviderShared.validateWith(decodeTarget), toHttp: (target, context) => toHttp(target, context.request), parse: events, }) diff --git a/packages/llm/src/provider/shared.ts b/packages/llm/src/provider/shared.ts index f9e347d04e..ca4996ede6 100644 --- a/packages/llm/src/provider/shared.ts +++ b/packages/llm/src/provider/shared.ts @@ -1,7 +1,7 @@ import { Cause, Effect, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" -import type { HttpClientResponse } from "effect/unstable/http" -import { ProviderChunkError } from "../schema" +import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import { InvalidRequestError, ProviderChunkError } from "../schema" export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) @@ -114,4 +114,41 @@ export const sse = (input: { readonly onHalt?: (state: State) => ReadonlyArray }): Stream.Stream => framed({ ...input, framing: sseFraming }) +/** + * Canonical `InvalidRequestError` constructor. Lift one-line `const invalid = + * (message) => new InvalidRequestError({ message })` aliases out of every + * adapter so the error constructor lives in one place. If we ever extend + * `InvalidRequestError` with adapter context or trace metadata, the change + * lands here. + */ +export const invalidRequest = (message: string) => new InvalidRequestError({ message }) + +/** + * Build a `validate` step from a Schema decoder. Replaces the per-adapter + * lambda body `(draft) => decode(draft).pipe(Effect.mapError((e) => + * invalid(e.message)))`. Any decode error is translated into + * `InvalidRequestError` carrying the original parse-error message. + */ +export const validateWith = + (decode: (input: I) => Effect.Effect) => + (draft: I) => + decode(draft).pipe(Effect.mapError((error) => invalidRequest(error.message))) + +/** + * Build an HTTP POST with a JSON body. Sets `content-type: application/json` + * automatically (callers can't override it — every adapter today places it + * last so caller headers win on everything else) and merges caller-supplied + * headers. The body is passed pre-encoded so adapters can choose between + * `Schema.encodeSync(target)` and `ProviderShared.encodeJson(target)`. + */ +export const jsonPost = (input: { + readonly url: string + readonly body: string + readonly headers?: Record +}) => + HttpClientRequest.post(input.url).pipe( + HttpClientRequest.setHeaders({ ...input.headers, "content-type": "application/json" }), + HttpClientRequest.bodyText(input.body, "application/json"), + ) + export * as ProviderShared from "./shared"