import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" import type { Auth as AuthDef } from "./auth" import type { Endpoint } from "./endpoint" import { RequestExecutor } from "./executor" import type { Framing } from "./framing" import { HttpTransport } from "./transport" import type { Transport, TransportRuntime } from "./transport" import { WebSocketExecutor } from "./transport" import type { Service as WebSocketExecutorService } from "./transport/websocket" import type { Protocol } from "./protocol" import * as ProviderShared from "../protocols/shared" import * as ToolRuntime from "../tool-runtime" import type { Tools } from "../tool" import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID } from "../schema" import { GenerationOptions, HttpOptions, LLMRequest, LLMResponse, ModelID, ModelLimits, ModelRef, LLMError as LLMErrorClass, NoRouteReason, PreparedRequest, ProviderID, RouteID, mergeGenerationOptions, mergeHttpOptions, mergeProviderOptions, } from "../schema" export interface RouteBody { /** Schema for the validated provider-native body sent as the JSON request. */ readonly schema: Schema.Codec /** Build the provider-native body from a common `LLMRequest`. */ readonly from: (request: LLMRequest) => Effect.Effect } export interface Route { readonly id: string readonly provider?: ProviderID readonly protocol: ProtocolID readonly transport: Transport readonly defaults: RouteDefaults readonly body: RouteBody readonly with: (patch: RoutePatch) => Route readonly model: (input: Input) => ModelRef readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect readonly streamPrepared: ( prepared: Prepared, request: LLMRequest, runtime: TransportRuntime, ) => Stream.Stream } // Route registries intentionally erase body generics after construction. // Normal call sites use `OpenAIChat.route`; callers only need body types // when preparing a request with a protocol-specific type assertion. // oxlint-disable-next-line typescript-eslint/no-explicit-any export type AnyRoute = Route const routeRegistry = new Map() // Route lookup is intentionally global: model refs name a route id, and // importing the provider/protocol/custom-route module registers the runnable // implementation. Duplicate ids are bugs because model refs cannot disambiguate // them. const register = (route: R): R => { const existing = routeRegistry.get(route.id) if (existing && existing !== route) throw new Error(`Duplicate LLM route id "${route.id}"`) routeRegistry.set(route.id, route) return route } const registeredRoute = (id: string) => routeRegistry.get(id) export type HttpOptionsInput = HttpOptions.Input export type ModelRefInput = Omit< ConstructorParameters[0], "id" | "provider" | "route" | "limits" | "generation" | "http" | "auth" > & { readonly id: string | ModelID readonly provider: string | ProviderID readonly route: string | RouteID readonly auth?: AuthDef readonly limits?: ModelLimits.Input readonly generation?: GenerationOptions.Input readonly http?: HttpOptionsInput } // `baseURL` is required on `ModelRefInput` (every materialized `ModelRef` has // a host) but optional at the route-input layers below. The route's `defaults` // can supply a canonical URL (e.g. OpenAI/Anthropic) so the user's input may // omit it. Routes without a canonical URL (OpenAI-compatible, GitHub Copilot) // re-tighten this in their own input type. export type RouteModelInput = Omit & { readonly baseURL?: string } export type RouteModelDefaults = Omit & { readonly baseURL?: string } export type RouteRoutedModelInput = Omit & { readonly baseURL?: string } export type RouteRoutedModelDefaults = Partial> export type RouteDefaults = Partial> export interface RoutePatch extends RouteDefaults { readonly id: string readonly provider?: string | ProviderID readonly transport?: Transport } type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput export interface RouteModelOptions< Input extends RouteMappedModelInput, Output extends RouteMappedModelInput = RouteMappedModelInput, > { readonly mapInput?: (input: Input) => Output } export interface RouteMappedModelOptions { readonly mapInput: (input: Input) => Output } const modelWithDefaults = ( route: AnyRoute, defaults: Partial>, options: { readonly mapInput?: (input: Input) => RouteMappedModelInput }, ) => (input: Input) => { const mapped = options.mapInput === undefined ? (input as RouteMappedModelInput) : options.mapInput(input) const provider = defaults.provider ?? route.provider ?? ("provider" in mapped ? mapped.provider : undefined) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) const baseURL = mapped.baseURL ?? defaults.baseURL ?? route.defaults.baseURL if (!baseURL) throw new Error(`Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`) const generation = mergeGenerationOptions(route.defaults.generation, defaults.generation) const providerOptions = mergeProviderOptions(route.defaults.providerOptions, defaults.providerOptions) const http = mergeHttpOptions(httpOptions(route.defaults.http), httpOptions(defaults.http)) return modelRef({ ...route.defaults, ...defaults, ...mapped, baseURL, provider, route: route.id, limits: mapped.limits ?? defaults.limits ?? route.defaults.limits, generation: mergeGenerationOptions(generation, mapped.generation), providerOptions: mergeProviderOptions(providerOptions, mapped.providerOptions), http: mergeHttpOptions(http, httpOptions(mapped.http)), }) } const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaults): RouteDefaults => ({ ...base, ...patch, limits: patch.limits ?? base?.limits, generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), http: mergeHttpOptions(httpOptions(base?.http), httpOptions(patch.http)), }) export const modelLimits = ModelLimits.make export const generationOptions = (input: GenerationOptions.Input | undefined) => input === undefined ? undefined : GenerationOptions.make(input) export const httpOptions = (input: HttpOptionsInput | undefined) => { if (input === undefined) return input return HttpOptions.make(input) } export const modelRef = (input: ModelRefInput) => new ModelRef({ ...input, id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: RouteID.make(input.route), limits: modelLimits(input.limits), generation: generationOptions(input.generation), http: httpOptions(input.http), }) function model( route: AnyRoute, defaults: RouteModelDefaults, options?: RouteModelOptions, ): (input: Input) => ModelRef function model( route: AnyRoute, defaults?: RouteRoutedModelDefaults, options?: RouteModelOptions, ): (input: Input) => ModelRef function model( route: AnyRoute, defaults: Partial>, options: RouteMappedModelOptions, ): (input: Input) => ModelRef function model( route: AnyRoute, defaults: Partial> = {}, options: { readonly mapInput?: (input: Input) => RouteMappedModelInput } = {}, ) { return modelWithDefaults(route, defaults, options) } export interface Interface { /** * Compile a request through protocol body construction, validation, and HTTP * preparation without sending it. Returns the prepared request including the * provider-native body. * * Pass a `Body` type argument to statically expose the route's body * shape (e.g. `prepare(...)`) — the runtime body is * identical, so this is a type-level assertion the caller makes about which * route the request will resolve to. */ readonly prepare: (request: LLMRequest) => Effect.Effect, LLMError> readonly stream: StreamMethod readonly generate: GenerateMethod } export interface StreamMethod { (request: LLMRequest): Stream.Stream (options: ToolRuntime.RunOptions): Stream.Stream } export interface GenerateMethod { (request: LLMRequest): Effect.Effect (options: ToolRuntime.RunOptions): Effect.Effect } export class Service extends Context.Service()("@opencode/LLMClient") {} const noRoute = (model: ModelRef) => new LLMErrorClass({ module: "LLMClient", method: "resolveRoute", reason: new NoRouteReason({ route: model.route, provider: model.provider, model: model.id }), }) const resolveRequestOptions = (request: LLMRequest) => LLMRequest.update(request, { generation: mergeGenerationOptions(request.model.generation, request.generation) ?? new GenerationOptions({}), providerOptions: mergeProviderOptions(request.model.providerOptions, request.providerOptions), http: mergeHttpOptions(request.model.http, request.http), }) export interface MakeInput { /** Route id used in registry lookup and error messages. */ readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ readonly endpoint: Endpoint /** Per-request transport auth. Model-level `Auth` overrides this. */ readonly auth?: AuthDef /** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */ readonly framing: Framing /** Static / per-request headers added before `auth` runs. */ readonly headers?: (input: { readonly request: LLMRequest }) => Record /** Model defaults used by the route's `.model(...)` helper. */ readonly defaults?: RouteDefaults } export interface MakeTransportInput { /** Route id used in registry lookup and error messages. */ readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Runnable transport route. */ readonly transport: Transport /** Provider/model defaults used by the route's `.model(...)` helper. */ readonly defaults?: RouteDefaults } const streamError = (route: string, message: string, cause: Cause.Cause) => { const failed = cause.reasons.find(Cause.isFailReason)?.error if (failed instanceof LLMErrorClass) return failed return ProviderShared.eventError(route, message, Cause.pretty(cause)) } function makeFromTransport( input: MakeTransportInput, ): Route { const protocol = input.protocol const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event) const decodeEvent = (route: string) => (frame: Frame) => decodeEventEffect(frame).pipe( Effect.mapError(() => ProviderShared.eventError( input.id, `Invalid ${route} stream event`, typeof frame === "string" ? frame : ProviderShared.encodeJson(frame), ), ), ) const build = (routeInput: MakeTransportInput): Route => { const route: Route = { id: routeInput.id, provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), protocol: protocol.id, transport: routeInput.transport, defaults: routeInput.defaults ?? {}, body: protocol.body, with: (patch: RoutePatch) => { const { id, provider, transport, ...defaults } = patch if (!id || id === routeInput.id) throw new Error(`Route.with(${routeInput.id}) requires a new route id`) return build({ ...routeInput, id, provider: provider ?? routeInput.provider, transport: (transport as Transport | undefined) ?? routeInput.transport, defaults: mergeRouteDefaults(routeInput.defaults, defaults), }) }, model: (input: RouteModelInput): ModelRef => modelWithDefaults(route, {}, {})(input), prepareTransport: routeInput.transport.prepare, streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { const route = `${request.model.provider}/${request.model.route}` const events = routeInput.transport .frames(prepared, request, runtime) .pipe( Stream.mapEffect(decodeEvent(route)), protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream, ) return events.pipe( Stream.mapAccumEffect( protocol.stream.initial, protocol.stream.step, protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined, ), Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))), ) }, } satisfies Route return register(route) } return build(input) } export function make( input: MakeTransportInput, ): Route /** * Build a `Route` by composing the four orthogonal pieces of a deployment: * * - `Protocol` — what is the API I'm speaking? * - `Endpoint` — where do I send the request? * - `Auth` — how do I authenticate it? * - `Framing` — how do I cut the response stream into protocol frames? * * Plus optional `headers` for cross-cutting deployment concerns (provider * version pins, per-deployment quirks). * * This is the canonical route constructor. If a new route does not fit * this four-axis model, add a purpose-built constructor rather than widening * the public surface preemptively. */ export function make( input: MakeInput, ): Route> export function make( input: MakeInput | MakeTransportInput, ): Route | Route> { if ("transport" in input) return makeFromTransport(input) const protocol = input.protocol const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema)) return makeFromTransport({ id: input.id, provider: input.provider, protocol, transport: HttpTransport.httpJson({ endpoint: input.endpoint, auth: input.auth, framing: input.framing, encodeBody, headers: input.headers, }), defaults: input.defaults, }) } // `compile` is the important boundary: it turns a common `LLMRequest` into a // validated provider body plus transport-private prepared data, but does not // execute transport. const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { const resolved = resolveRequestOptions(request) const route = registeredRoute(resolved.model.route) if (!route) return yield* noRoute(resolved.model) const body = yield* route.body .from(resolved) .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) const prepared = yield* route.prepareTransport(body, resolved) return { request: resolved, route, body, prepared, } }) const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { const compiled = yield* compile(request) return new PreparedRequest({ id: compiled.request.id ?? "request", route: compiled.route.id, protocol: compiled.route.protocol, model: compiled.request.model, body: compiled.body, metadata: { transport: compiled.route.transport.id }, }) }) const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => Stream.unwrap( Effect.gen(function* () { const compiled = yield* compile(request) return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) }), ) const isToolRunOptions = (input: LLMRequest | ToolRuntime.RunOptions): input is ToolRuntime.RunOptions => "request" in input && "tools" in input const streamWith = (streamRequest: (request: LLMRequest) => Stream.Stream): StreamMethod => ((input: LLMRequest | ToolRuntime.RunOptions) => { if (isToolRunOptions(input)) return ToolRuntime.stream({ ...input, stream: streamRequest }) return streamRequest(input) }) as StreamMethod const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(function* (input: LLMRequest | ToolRuntime.RunOptions) { return new LLMResponse( yield* stream(input as never).pipe( Stream.runFold( () => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }), (acc, event) => { acc.events.push(event) if ("usage" in event && event.usage !== undefined) acc.usage = event.usage return acc }, ), ), ) }) export const prepare = (request: LLMRequest) => prepareWith(request) as Effect.Effect, LLMError> export function stream(request: LLMRequest): Stream.Stream export function stream(options: ToolRuntime.RunOptions): Stream.Stream export function stream(input: LLMRequest | ToolRuntime.RunOptions) { return Stream.unwrap( Effect.gen(function* () { return (yield* Service).stream(input as never) }), ) } export function generate(request: LLMRequest): Effect.Effect export function generate(options: ToolRuntime.RunOptions): Effect.Effect export function generate(input: LLMRequest | ToolRuntime.RunOptions) { return Effect.gen(function* () { return yield* (yield* Service).generate(input as never) }) } export const streamRequest = (request: LLMRequest) => Stream.unwrap( Effect.gen(function* () { return (yield* Service).stream(request) }), ) export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const stream = streamWith(streamRequestWith({ http: yield* RequestExecutor.Service })) return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) }), ) export const layerWithWebSocket: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const stream = streamWith( streamRequestWith({ http: yield* RequestExecutor.Service, webSocket: yield* WebSocketExecutor.Service, }), ) return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) }), ) export const Route = { make, model } as const export const LLMClient = { Service, layer, layerWithWebSocket, prepare, stream, generate, stepCountIs: ToolRuntime.stepCountIs, } as const