fix(ai): expose raw HTTP responses to hooks
This commit is contained in:
parent
4dc5ba66d8
commit
1eec3e640a
6 changed files with 145 additions and 73 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer, Stream } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
|
|
@ -20,9 +20,13 @@ import { classifyProviderFailure } from "../provider-error"
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
middleware?: HttpMiddleware,
|
||||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||||
|
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
||||||
|
|
||||||
const BODY_LIMIT = 16_384
|
const BODY_LIMIT = 16_384
|
||||||
|
|
@ -282,12 +286,41 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
|
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||||
return yield* http
|
if (!middleware)
|
||||||
.execute(request)
|
return yield* http
|
||||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
.execute(request)
|
||||||
|
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||||
|
|
||||||
|
let sent = request
|
||||||
|
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||||
|
const response = yield* HttpClientRequest.toWeb(request).pipe(
|
||||||
|
Effect.flatMap((web) =>
|
||||||
|
middleware(web, (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
sent = HttpClientRequest.fromWeb(input)
|
||||||
|
if (input.body)
|
||||||
|
sent = HttpClientRequest.bodyUint8Array(
|
||||||
|
sent,
|
||||||
|
new Uint8Array(yield* Effect.promise(() => input.arrayBuffer())),
|
||||||
|
input.headers.get("content-type") ?? undefined,
|
||||||
|
)
|
||||||
|
const response = yield* http
|
||||||
|
.execute(sent)
|
||||||
|
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||||
|
const body = yield* Stream.toReadableStreamEffect(response.stream)
|
||||||
|
const web = new Response(body, { status: response.status, headers: response.headers })
|
||||||
|
origins.set(web, sent)
|
||||||
|
return web
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.mapError(toHttpError(redactedNames)),
|
||||||
|
)
|
||||||
|
const origin = origins.get(response) ?? sent
|
||||||
|
return yield* statusError(origin, redactedNames)(HttpClientResponse.fromWeb(origin, response))
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
execute: executeOnce,
|
execute: executeOnce,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { render as renderEndpoint } from "../endpoint"
|
import { render as renderEndpoint } from "../endpoint"
|
||||||
import { Framing } from "../framing"
|
import { Framing } from "../framing"
|
||||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||||
import * as ProviderShared from "../../protocols/shared"
|
import * as ProviderShared from "../../protocols/shared"
|
||||||
import { LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
|
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||||
|
|
||||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||||
|
|
||||||
|
|
@ -18,7 +18,6 @@ export interface JsonRequestParts<Body = unknown> {
|
||||||
|
|
||||||
export interface HttpPrepared<Frame> {
|
export interface HttpPrepared<Frame> {
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request: HttpClientRequest.HttpClientRequest
|
||||||
readonly web: Request
|
|
||||||
readonly framing: Framing.Definition<Frame>
|
readonly framing: Framing.Definition<Frame>
|
||||||
readonly middleware?: HttpMiddleware
|
readonly middleware?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
@ -83,55 +82,14 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
web: new Request(parts.url, { method: "POST", headers: parts.headers, body: parts.bodyText }),
|
|
||||||
framing: input.framing,
|
framing: input.framing,
|
||||||
middleware: prepareInput.middleware,
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
runtime.http
|
||||||
const request = prepared.web
|
.execute(prepared.request, prepared.middleware)
|
||||||
const execute = (input: Request) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: () => input.text(),
|
|
||||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
||||||
}).pipe(
|
|
||||||
Effect.flatMap((body) =>
|
|
||||||
runtime.http.execute(
|
|
||||||
ProviderShared.jsonPost({
|
|
||||||
url: input.url,
|
|
||||||
body,
|
|
||||||
headers: Headers.fromInput(input.headers),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.flatMap((response) =>
|
|
||||||
Stream.toReadableStreamEffect(response.stream).pipe(
|
|
||||||
Effect.map(
|
|
||||||
(body) =>
|
|
||||||
new Response(body, {
|
|
||||||
status: response.status,
|
|
||||||
headers: response.headers,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return yield* prepared.middleware ? prepared.middleware(request, execute) : execute(request)
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
error instanceof LLMError
|
|
||||||
? error
|
|
||||||
: ProviderShared.eventError(
|
|
||||||
`${request.model.provider}/${request.model.route.id}`,
|
|
||||||
`Failed to execute ${request.model.provider}/${request.model.route.id} request`,
|
|
||||||
ProviderShared.errorText(error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.map((response) => HttpClientResponse.fromWeb(prepared.request, response)),
|
|
||||||
)
|
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.map((response) =>
|
Effect.map((response) =>
|
||||||
prepared.framing.frame(
|
prepared.framing.frame(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Effect, Stream } from "effect"
|
import type { Effect, Stream } from "effect"
|
||||||
import { Endpoint } from "../endpoint"
|
import { Endpoint } from "../endpoint"
|
||||||
import { Auth } from "../auth"
|
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 { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||||
import type { LLMError, LLMRequest } from "../../schema"
|
import type { LLMError, LLMRequest } from "../../schema"
|
||||||
|
|
||||||
|
|
@ -10,9 +10,6 @@ export interface TransportRuntime {
|
||||||
readonly webSocket?: WebSocketExecutorInterface
|
readonly webSocket?: WebSocketExecutorInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
|
||||||
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
|
|
||||||
|
|
||||||
export interface Transport<Body, Prepared, Frame> {
|
export interface Transport<Body, Prepared, Frame> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
||||||
|
|
@ -34,4 +31,5 @@ export interface TransportPrepareInput<Body> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as HttpTransport from "./http"
|
export * as HttpTransport from "./http"
|
||||||
|
export type { HttpHandler, HttpMiddleware } from "../executor"
|
||||||
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Ref, Schema } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, mergeProviderOptions } from "../src"
|
import { LLM, mergeProviderOptions } from "../src"
|
||||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||||
|
|
@ -151,9 +151,10 @@ describe("request option precedence", () => {
|
||||||
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
|
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
|
||||||
const headers = new Headers(request.headers)
|
const headers = new Headers(request.headers)
|
||||||
headers.set("x-plugin", "transformed")
|
headers.set("x-plugin", "transformed")
|
||||||
|
headers.set("content-type", "application/custom+json")
|
||||||
return yield* handler(
|
return yield* handler(
|
||||||
new Request("https://proxy.test/v1/chat/completions", {
|
new Request("https://proxy.test/v1/chat/completions", {
|
||||||
method: request.method,
|
method: "PUT",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ transformed: true }),
|
body: JSON.stringify({ transformed: true }),
|
||||||
}),
|
}),
|
||||||
|
|
@ -166,7 +167,9 @@ describe("request option precedence", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
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("x-plugin")).toBe("transformed")
|
||||||
|
expect(web.headers.get("content-type")).toBe("application/custom+json")
|
||||||
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
||||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
|
@ -213,6 +216,47 @@ describe("request option precedence", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 retry = request.clone()
|
||||||
|
const response = yield* handler(request)
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
const headers = new Headers(retry.headers)
|
||||||
|
headers.set("authorization", "Bearer refreshed")
|
||||||
|
return yield* handler(new Request(retry, { headers }))
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
).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", () =>
|
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||||
LLMClient.generate(
|
LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|
|
||||||
|
|
@ -277,14 +277,15 @@ export function fromPromise(plugin: Plugin) {
|
||||||
const request = event.request
|
const request = event.request
|
||||||
const output = {
|
const output = {
|
||||||
...event,
|
...event,
|
||||||
request: (input: Request) => Effect.runPromiseWith(context)(request(input)),
|
request: (input: Request) =>
|
||||||
|
Effect.runPromiseWith(context)(request(input), { signal: input.signal }),
|
||||||
}
|
}
|
||||||
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
|
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
|
||||||
Effect.tap(() =>
|
Effect.tap(() =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
event.request = (input) =>
|
event.request = (input) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: () => output.request(input),
|
try: (signal) => output.request(new Request(input, { signal })),
|
||||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
|
|
@ -148,7 +148,9 @@ describe("fromPromise", () => {
|
||||||
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
|
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
|
||||||
description: "Reviews code",
|
description: "Reviews code",
|
||||||
})
|
})
|
||||||
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
|
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
|
||||||
|
"Agent not found: missing",
|
||||||
|
)
|
||||||
const models = (await ctx.catalog.model.list()).data
|
const models = (await ctx.catalog.model.list()).data
|
||||||
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
|
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
|
||||||
modelID: "gpt-5",
|
modelID: "gpt-5",
|
||||||
|
|
@ -254,6 +256,44 @@ describe("fromPromise", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
yield* PluginPromise.fromPromise(
|
||||||
|
define({
|
||||||
|
id: "promise-session-http-interrupt",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.session.hook("http", (event) => {
|
||||||
|
const request = event.request
|
||||||
|
event.request = (input) => request(input)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).effect(host)
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const interrupted = yield* Deferred.make<void>()
|
||||||
|
const event: SessionHooks["http"] = {
|
||||||
|
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
request: () =>
|
||||||
|
Deferred.succeed(started, undefined).pipe(
|
||||||
|
Effect.andThen(Effect.never),
|
||||||
|
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* hooks.trigger("session", "http", event)
|
||||||
|
const fiber = yield* event.request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("disposes a hook registration on request", () =>
|
it.effect("disposes a hook registration on request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
|
|
@ -348,19 +388,17 @@ describe("fromPromise", () => {
|
||||||
id: "promise-tool",
|
id: "promise-tool",
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
await ctx.tool.transform((tools) => {
|
await ctx.tool.transform((tools) => {
|
||||||
tools.add(
|
tools.add({
|
||||||
{
|
name: "hello",
|
||||||
name: "hello",
|
options: { codemode: false },
|
||||||
options: { codemode: false },
|
description: "Hello",
|
||||||
description: "Hello",
|
input: Schema.Struct({ name: Schema.String }),
|
||||||
input: Schema.Struct({ name: Schema.String }),
|
output: Schema.String,
|
||||||
output: Schema.String,
|
execute: async ({ name }, context) => {
|
||||||
execute: async ({ name }, context) => {
|
await context.progress({ phase: "greeting" })
|
||||||
await context.progress({ phase: "greeting" })
|
return { output: `Hello, ${name}!` }
|
||||||
return { output: `Hello, ${name}!` }
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue