mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5aeea550c | |||
| 930b1dde3c |
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
|||||||
import { RequestExecutor } from "./executor"
|
import { RequestExecutor } from "./executor"
|
||||||
import { Framing } from "./framing"
|
import { Framing } from "./framing"
|
||||||
import { HttpTransport } from "./transport"
|
import { HttpTransport } from "./transport"
|
||||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
import type { HttpMiddleware, HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||||
import { WebSocketExecutor } from "./transport"
|
import { WebSocketExecutor } from "./transport"
|
||||||
import type { Protocol } from "./protocol"
|
import type { Protocol } from "./protocol"
|
||||||
import { applyCachePolicy } from "../cache-policy"
|
import { applyCachePolicy } from "../cache-policy"
|
||||||
@@ -156,6 +156,7 @@ export interface Interface {
|
|||||||
|
|
||||||
export interface StreamOptions {
|
export interface StreamOptions {
|
||||||
readonly transform?: HttpRequestTransform
|
readonly transform?: HttpRequestTransform
|
||||||
|
readonly http?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamMethod {
|
export interface StreamMethod {
|
||||||
@@ -308,6 +309,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
encodeBody,
|
encodeBody,
|
||||||
headers: routeInput.headers,
|
headers: routeInput.headers,
|
||||||
transform: options?.transform,
|
transform: options?.transform,
|
||||||
|
middleware: options?.http,
|
||||||
}),
|
}),
|
||||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||||
const route = `${request.model.provider}/${request.model.route.id}`
|
const route = `${request.model.provider}/${request.model.route.id}`
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ 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, AIError>
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HttpHandler = (
|
||||||
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||||
|
export type HttpMiddleware = (
|
||||||
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
handler: HttpHandler,
|
||||||
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||||
|
|
||||||
const BODY_LIMIT = 16_384
|
const BODY_LIMIT = 16_384
|
||||||
@@ -282,12 +291,20 @@ 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)))
|
||||||
|
|
||||||
|
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({
|
return Service.of({
|
||||||
execute: executeOnce,
|
execute: executeOnce,
|
||||||
|
|||||||
@@ -23,4 +23,11 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
|||||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||||
export type { Definition as FramingDef } from "./framing"
|
export type { Definition as FramingDef } from "./framing"
|
||||||
export type { Protocol as ProtocolDef } from "./protocol"
|
export type { Protocol as ProtocolDef } from "./protocol"
|
||||||
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
export type {
|
||||||
|
HttpHandler,
|
||||||
|
HttpMiddleware,
|
||||||
|
HttpRequest,
|
||||||
|
HttpRequestTransform,
|
||||||
|
Transport as TransportDef,
|
||||||
|
TransportRuntime,
|
||||||
|
} from "./transport"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { Transport, TransportPrepareInput } from "./index"
|
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||||
import * as ProviderShared from "../../protocols/shared"
|
import * as ProviderShared from "../../protocols/shared"
|
||||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
|
|||||||
export interface HttpPrepared<Frame> {
|
export interface HttpPrepared<Frame> {
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request: HttpClientRequest.HttpClientRequest
|
||||||
readonly framing: Framing.Definition<Frame>
|
readonly framing: Framing.Definition<Frame>
|
||||||
|
readonly middleware?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
@@ -74,21 +75,23 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
prepare: (prepareInput) =>
|
prepare: (prepareInput) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parts = yield* jsonRequestParts({ ...prepareInput })
|
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||||
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
const transformed = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
||||||
yield* (prepareInput.transform?.(request) ?? Effect.void)
|
yield* prepareInput.transform?.(transformed) ?? Effect.void
|
||||||
|
const request = ProviderShared.jsonPost({
|
||||||
|
url: transformed.url,
|
||||||
|
body: transformed.body ?? "",
|
||||||
|
headers: Headers.fromInput(transformed.headers),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
request: ProviderShared.jsonPost({
|
request,
|
||||||
url: request.url,
|
|
||||||
body: request.body ?? "",
|
|
||||||
headers: Headers.fromInput(request.headers),
|
|
||||||
}),
|
|
||||||
framing: input.framing,
|
framing: input.framing,
|
||||||
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
runtime.http
|
runtime.http
|
||||||
.execute(prepared.request)
|
.execute(prepared.request, prepared.middleware)
|
||||||
.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 { AIError, LLMRequest } from "../../schema"
|
import type { AIError, LLMRequest } from "../../schema"
|
||||||
|
|
||||||
@@ -33,7 +33,9 @@ export interface TransportPrepareInput<Body> {
|
|||||||
readonly encodeBody: (body: Body) => string
|
readonly encodeBody: (body: Body) => string
|
||||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||||
readonly transform?: HttpRequestTransform
|
readonly transform?: HttpRequestTransform
|
||||||
|
readonly middleware?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +1,6 @@
|
|||||||
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, HttpClientResponse } 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"
|
||||||
import { Auth, LLMClient } from "../src/route"
|
import { Auth, LLMClient } from "../src/route"
|
||||||
@@ -146,12 +146,16 @@ describe("request option precedence", () => {
|
|||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
transform: (request) =>
|
http: (request, handler) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
expect(request.headers.authorization).toBe("Bearer fresh-key")
|
return yield* handler(
|
||||||
request.url = "https://proxy.test/v1/chat/completions"
|
request.pipe(
|
||||||
request.headers["x-plugin"] = "transformed"
|
HttpClientRequest.setUrl("https://proxy.test/v1/chat/completions"),
|
||||||
request.body = JSON.stringify({ transformed: true })
|
HttpClientRequest.setMethod("PUT"),
|
||||||
|
HttpClientRequest.setHeader("x-plugin", "transformed"),
|
||||||
|
HttpClientRequest.bodyText(JSON.stringify({ transformed: true }), "application/custom+json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -160,7 +164,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" },
|
||||||
@@ -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", () =>
|
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||||
LLMClient.generate(
|
LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|||||||
Reference in New Issue
Block a user