From 930b1dde3c7de09a75555930301eb49066e28acb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 3 Aug 2026 16:27:29 -0500 Subject: [PATCH] feat(ai): add native HTTP middleware --- packages/ai/src/route/client.ts | 6 +- packages/ai/src/route/executor.ts | 25 +++++- packages/ai/src/route/index.ts | 2 +- packages/ai/src/route/transport/http.ts | 19 ++--- packages/ai/src/route/transport/index.ts | 14 +--- packages/ai/test/compile.test.ts | 98 ++++++++++++++++++++++-- 6 files changed, 128 insertions(+), 36 deletions(-) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 03f43d17a19..f4873f347ef 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint" import { RequestExecutor } from "./executor" import { Framing } from "./framing" import { HttpTransport } from "./transport" -import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport" +import type { HttpMiddleware, Transport, TransportRuntime } from "./transport" import { WebSocketExecutor } from "./transport" import type { Protocol } from "./protocol" import { applyCachePolicy } from "../cache-policy" @@ -155,7 +155,7 @@ export interface Interface { } export interface StreamOptions { - readonly transform?: HttpRequestTransform + readonly http?: HttpMiddleware } export interface StreamMethod { @@ -307,7 +307,7 @@ function makeFromTransport( auth: routeInput.auth ?? Auth.none, encodeBody, headers: routeInput.headers, - transform: options?.transform, + middleware: options?.http, }), streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { const route = `${request.model.provider}/${request.model.route.id}` diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index f7a0fb465b3..73433d05ed4 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -20,9 +20,18 @@ import { classifyProviderFailure } from "../provider-error" export interface Interface { readonly execute: ( request: HttpClientRequest.HttpClientRequest, + middleware?: HttpMiddleware, ) => Effect.Effect } +export type HttpHandler = ( + request: HttpClientRequest.HttpClientRequest, +) => Effect.Effect +export type HttpMiddleware = ( + request: HttpClientRequest.HttpClientRequest, + handler: HttpHandler, +) => Effect.Effect + export class Service extends Context.Service()("@opencode/AI/RequestExecutor") {} const BODY_LIMIT = 16_384 @@ -282,12 +291,20 @@ export const layer: Layer.Layer = Layer.e Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient - const executeOnce = (request: HttpClientRequest.HttpClientRequest) => + const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) => Effect.gen(function* () { const redactedNames = yield* Headers.CurrentRedactedNames - return yield* http - .execute(request) - .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) + if (!middleware) + return yield* http + .execute(request) + .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) + + const response = yield* middleware(request, (input) => + http + .execute(input) + .pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), + ).pipe(Effect.mapError(toHttpError(redactedNames))) + return yield* statusError(response.request, redactedNames)(response) }) return Service.of({ execute: executeOnce, diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts index 8b85cfc3f76..eb9f1759c55 100644 --- a/packages/ai/src/route/index.ts +++ b/packages/ai/src/route/index.ts @@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option export type { Definition as EndpointFn, EndpointInput } from "./endpoint" export type { Definition as FramingDef } from "./framing" export type { Protocol as ProtocolDef } from "./protocol" -export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport" +export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport" diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index a1de9ec5735..223afb353c1 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http" import { Auth } from "../auth" import { render as renderEndpoint } from "../endpoint" import { Framing } from "../framing" -import type { Transport, TransportPrepareInput } from "./index" +import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index" import * as ProviderShared from "../../protocols/shared" import { mergeJsonRecords, type LLMRequest } from "../../schema" @@ -19,6 +19,7 @@ export interface JsonRequestParts { export interface HttpPrepared { readonly request: HttpClientRequest.HttpClientRequest readonly framing: Framing.Definition + readonly middleware?: HttpMiddleware } const applyQuery = (url: string, query: Record | undefined) => { @@ -74,21 +75,21 @@ export const httpJson = (input: HttpJsonInput): HttpJs prepare: (prepareInput) => Effect.gen(function* () { const parts = yield* jsonRequestParts({ ...prepareInput }) - const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText } - yield* (prepareInput.transform?.(request) ?? Effect.void) + const request = ProviderShared.jsonPost({ + url: parts.url, + body: parts.bodyText, + headers: parts.headers, + }) return { - request: ProviderShared.jsonPost({ - url: request.url, - body: request.body ?? "", - headers: Headers.fromInput(request.headers), - }), + request, framing: input.framing, + middleware: prepareInput.middleware, } }), frames: (prepared, request, runtime) => Stream.unwrap( runtime.http - .execute(prepared.request) + .execute(prepared.request, prepared.middleware) .pipe( Effect.map((response) => prepared.framing.frame( diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts index 16624cce563..c74e578e8bb 100644 --- a/packages/ai/src/route/transport/index.ts +++ b/packages/ai/src/route/transport/index.ts @@ -1,7 +1,7 @@ import type { Effect, Stream } from "effect" import { Endpoint } from "../endpoint" import { Auth } from "../auth" -import type { Interface as RequestExecutorInterface } from "../executor" +import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor" import type { Interface as WebSocketExecutorInterface } from "./websocket" import type { AIError, LLMRequest } from "../../schema" @@ -10,15 +10,6 @@ export interface TransportRuntime { readonly webSocket?: WebSocketExecutorInterface } -export interface HttpRequest { - url: string - readonly method: string - headers: Record - body: string | undefined -} - -export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect - export interface Transport { readonly id: string readonly prepare: (input: TransportPrepareInput) => Effect.Effect @@ -32,8 +23,9 @@ export interface TransportPrepareInput { readonly auth: Auth.Definition readonly encodeBody: (body: Body) => string readonly headers?: (input: { readonly request: LLMRequest }) => Record - readonly transform?: HttpRequestTransform + readonly middleware?: HttpMiddleware } export * as HttpTransport from "./http" +export type { HttpHandler, HttpMiddleware } from "../executor" export { WebSocketExecutor, WebSocketTransport } from "./websocket" diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index 27971bf5c8e..0f1260873e0 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Effect, Schema } from "effect" -import { HttpClientRequest } from "effect/unstable/http" +import { Effect, Ref, Schema } from "effect" +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { LLM, mergeProviderOptions } from "../src" import { AnthropicMessages, OpenAIChat } from "../src/protocols" import { Auth, LLMClient } from "../src/route" @@ -146,12 +146,16 @@ describe("request option precedence", () => { prompt: "Say hello.", }), { - transform: (request) => - Effect.sync(() => { - expect(request.headers.authorization).toBe("Bearer fresh-key") - request.url = "https://proxy.test/v1/chat/completions" - request.headers["x-plugin"] = "transformed" - request.body = JSON.stringify({ transformed: true }) + http: (request, handler) => + Effect.gen(function* () { + return yield* handler( + request.pipe( + HttpClientRequest.setUrl("https://proxy.test/v1/chat/completions"), + HttpClientRequest.setMethod("PUT"), + HttpClientRequest.setHeader("x-plugin", "transformed"), + HttpClientRequest.bodyText(JSON.stringify({ transformed: true }), "application/custom+json"), + ), + ) }), }, ).pipe( @@ -160,7 +164,9 @@ describe("request option precedence", () => { Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) expect(web.url).toBe("https://proxy.test/v1/chat/completions") + expect(web.method).toBe("PUT") expect(web.headers.get("x-plugin")).toBe("transformed") + expect(web.headers.get("content-type")).toBe("application/custom+json") expect(decodeJson(input.text)).toEqual({ transformed: true }) return input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" }, @@ -171,6 +177,82 @@ describe("request option precedence", () => { ), ) + it.effect("transforms the HTTP response before protocol decoding", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }), + prompt: "Say hello.", + }), + { + http: (request, handler) => + Effect.gen(function* () { + const response = yield* handler(request) + return HttpClientResponse.fromWeb( + response.request, + new Response((yield* response.text).replace("network", "hooked"), { + status: response.status, + headers: response.headers, + }), + ) + }), + }, + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.succeed( + input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ), + ) + + expect(response.text).toBe("hooked") + }), + ) + + it.effect("can inspect an error response and retry the native request", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + const response = yield* LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") }) + .model({ id: "gpt-4o-mini" }), + prompt: "Say hello.", + }), + { + http: (request, handler) => + Effect.gen(function* () { + const response = yield* handler(request) + expect(response.status).toBe(401) + return yield* handler(HttpClientRequest.setHeader(request, "authorization", "Bearer refreshed")) + }), + }, + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + yield* Ref.update(attempts, (value) => value + 1) + if (input.request.headers.authorization !== "Bearer refreshed") + return input.respond("unauthorized", { status: 401 }) + return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + + expect(response.text).toBe("retried") + expect(yield* Ref.get(attempts)).toBe(2) + }), + ) + it.effect("applies raw body overlays after protocol lowering", () => LLMClient.generate( LLM.request({