refactor(llm): add route derivation
This commit is contained in:
@@ -253,29 +253,22 @@ Route.make({
|
||||
})
|
||||
```
|
||||
|
||||
Raw routes should stay reusable: they are protocol + transport mechanics. Provider identity, capabilities, limits, and generation defaults are model-factory defaults layered onto a route.
|
||||
Routes carry provider identity directly, plus capabilities, limits, and generation defaults. Reuse happens by deriving a new route with `.with(...)`, not by layering "configuration" onto a separate raw route.
|
||||
|
||||
The ideal authoring shape is a configured route value:
|
||||
The authoring shape is a single route value:
|
||||
|
||||
```ts
|
||||
const responsesHttp = responsesHttpRoute.with({
|
||||
provider: "openai",
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
})
|
||||
|
||||
const model = responsesHttp.model("gpt-4.1-mini", { apiKey })
|
||||
const model = openAIResponses.model("gpt-4.1-mini", { apiKey })
|
||||
```
|
||||
|
||||
This is better than `Provider.model(...)`: a provider is the catalog namespace, while route configuration means "from this runnable route, make a model-ref constructor with these provider/model defaults."
|
||||
`route.model(...)` is better than `Provider.model(...)`: a provider is the catalog namespace, while a provider-bound route owns route-backed model-ref construction. Capabilities live as route defaults and on the final `ModelRef`, and remain overridable because capabilities and limits can vary by concrete model id.
|
||||
|
||||
Capabilities belong in this configured-route/default layer and on the final `ModelRef`, not on the raw route. The defaults are close to route selection because they are provider API defaults, but they must remain overridable because capabilities and limits can vary by concrete model id.
|
||||
|
||||
Provider helpers should then map user options to concrete route-backed model factories:
|
||||
Provider helpers map user options to concrete provider-bound routes:
|
||||
|
||||
```ts
|
||||
const responsesRoutes = {
|
||||
http: responsesHttpRoute.with(openaiResponsesDefaults),
|
||||
websocket: responsesWebSocketRoute.with(openaiResponsesDefaults),
|
||||
http: openAIResponses,
|
||||
websocket: openAIResponsesWebSocket,
|
||||
} as const
|
||||
```
|
||||
|
||||
@@ -328,13 +321,13 @@ The current code still has several related smells:
|
||||
- Protocol files expose hand-written `makeRoute(...)` factories.
|
||||
- Provider files derive variants by passing knobs like `defaultBaseURL: false` and `endpointRequired` into those factories.
|
||||
- Provider identity and capabilities are added later through `Route.model(route, defaults)` rather than being visibly attached to a provider-bound route.
|
||||
- The same reusable route shape sometimes acts like a template and sometimes acts like a user-facing provider route.
|
||||
- The same reusable route shape sometimes acts like a base and sometimes acts like a user-facing provider route.
|
||||
|
||||
These are all symptoms of the same missing concept: route derivation.
|
||||
|
||||
### Endpoint Policy Smell
|
||||
|
||||
`defaultBaseURL: false` means "do not use the route template's default URL; require the model/provider options to supply one."
|
||||
`defaultBaseURL: false` means "do not use the route's default URL; require the model/provider options to supply one."
|
||||
|
||||
`endpointRequired` is the custom error message used when no base URL is available.
|
||||
|
||||
@@ -395,42 +388,40 @@ export const makeRoute = (input = {}) =>
|
||||
|
||||
It exists only because route values are not yet easy to copy and modify.
|
||||
|
||||
The target is immutable derivation:
|
||||
The target is immutable derivation on a single `Route` value:
|
||||
|
||||
```ts
|
||||
export const responsesTemplate = Route.template({
|
||||
export const openAIResponses = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
transport: Transport.httpJson({
|
||||
endpoint: Endpoint.baseURL({ path: "/responses", base: { type: "default", url: DEFAULT_BASE_URL } }),
|
||||
auth: Auth.bearer(),
|
||||
framing: Framing.sse,
|
||||
}),
|
||||
})
|
||||
|
||||
export const openAIResponses = responsesTemplate.route({
|
||||
id: "openai-responses",
|
||||
provider: "openai",
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
defaults: {
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
},
|
||||
})
|
||||
|
||||
export const azureResponses = openAIResponses.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: "azure",
|
||||
transport: Transport.httpJson({
|
||||
transport: openAIResponses.transport.with({
|
||||
endpoint: Endpoint.requiredBaseURL({ path: "/responses", message: "Azure OpenAI requires resourceName or baseURL" }),
|
||||
auth: azureAuth,
|
||||
framing: Framing.sse,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
This preserves reuse without hiding variant behavior behind protocol-specific factory parameters.
|
||||
This preserves reuse without hiding variant behavior behind protocol-specific factory parameters, and without a second route concept.
|
||||
|
||||
### One Route Concept
|
||||
|
||||
Prefer one `Route` concept, not `RouteTemplate` plus `Route`.
|
||||
There is one `Route` concept. No `RouteTemplate`, no separate base/derived split.
|
||||
|
||||
Every route used by a provider helper should have a provider. Reuse can still happen by immutably deriving one provider route from another:
|
||||
Every route used by a provider helper should have a provider. Reuse happens by immutably deriving one provider route from another:
|
||||
|
||||
```ts
|
||||
export const responses = Route.make({
|
||||
@@ -442,7 +433,9 @@ export const responses = Route.make({
|
||||
auth: Auth.bearer(),
|
||||
framing: Framing.sse,
|
||||
}),
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
defaults: {
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
},
|
||||
})
|
||||
|
||||
export const azureResponses = responses.with({
|
||||
@@ -460,7 +453,7 @@ The risk is inherited provider/default leakage. Mitigate that with API shape:
|
||||
- `.with(...)` is immutable and returns a new route.
|
||||
- deriving a provider route should require `id` and `provider` when either changes.
|
||||
- duplicate route ids should fail or be explicit.
|
||||
- provider/capabilities/limits/generation are route defaults and remain overridable by model options.
|
||||
- provider is route identity; capabilities/limits/generation are route defaults and remain overridable by model options.
|
||||
- `.model(...)` uses the route defaults and returns a concrete `ModelRef` with `route` set.
|
||||
|
||||
### Typed Transport Derivation
|
||||
@@ -532,7 +525,7 @@ The smallest coherent target that addresses all these smells:
|
||||
- Treat provider/capabilities/limits/generation as route defaults that can be overridden by model options.
|
||||
- Keep one `Route` concept; reuse happens through immutable `.with(...)` derivation.
|
||||
- Make transports immutable/copyable so provider variants can override endpoint/auth without restating framing or unrelated transport internals.
|
||||
- Let provider modules export provider-bound routes and model helpers, not protocol-template internals as the primary API.
|
||||
- Let provider modules export provider-bound routes and model helpers as the primary API.
|
||||
|
||||
## Registry Semantics
|
||||
|
||||
@@ -637,16 +630,15 @@ Derive protocol from route metadata after route resolution. If missing-route err
|
||||
|
||||
Temporary compatibility aliases are acceptable only if they are clearly deprecated and not used in new code/docs.
|
||||
|
||||
### Step 2: Move Toward Configured Routes
|
||||
### Step 2: Move `.model(...)` Onto The Route
|
||||
|
||||
Current implementation can keep `Route.model(route, defaults)` while the rename lands. The cleaner target is:
|
||||
Current implementation can keep `Route.model(route, defaults)` while the rename lands. The cleaner target is `route.model(id, options)` directly on the provider-bound route — provider, capabilities, limits, and generation already live on the route, and `.with(...)` covers any per-derivation overrides.
|
||||
|
||||
```ts
|
||||
const configured = route.with(defaults)
|
||||
const model = configured.model(id, options)
|
||||
const model = openAIResponses.model("gpt-4.1-mini", { apiKey })
|
||||
```
|
||||
|
||||
Do not move this to `Provider.model(...)`. A provider is the catalog namespace; configured routes own route-backed model-ref construction.
|
||||
Do not move this to `Provider.model(...)`. A provider is the catalog namespace; routes own route-backed model-ref construction.
|
||||
|
||||
### Step 3: Keep Runtime Behavior Stable
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Array as Arr, Effect, Schema } from "effect"
|
||||
import { Route } from "../route/client"
|
||||
import type { Auth } from "../route/auth"
|
||||
import { Endpoint, type Endpoint as EndpointConfig } from "../route/endpoint"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { capabilities } from "../llm"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
@@ -381,35 +382,28 @@ export const endpoint = (input: {
|
||||
required: input.required,
|
||||
})
|
||||
|
||||
export const makeRoute = (input: {
|
||||
readonly id?: string
|
||||
readonly auth?: Auth
|
||||
readonly endpoint?: EndpointConfig<OpenAIChatPayload>
|
||||
readonly defaultBaseURL?: string | false
|
||||
readonly endpointRequired?: string
|
||||
} = {}) =>
|
||||
Route.make({
|
||||
id: input.id ?? ADAPTER,
|
||||
protocol,
|
||||
// The route supplies deployment concerns around the protocol: URL, auth,
|
||||
// and response framing. Other providers can reuse `protocol` with different
|
||||
// endpoint/auth choices instead of cloning this whole file.
|
||||
endpoint: input.endpoint ?? endpoint({ defaultBaseURL: input.defaultBaseURL, required: input.endpointRequired }),
|
||||
auth: input.auth,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
const encodePayload = Schema.encodeSync(Schema.fromJsonString(OpenAIChatPayload))
|
||||
|
||||
export const route = makeRoute()
|
||||
export const httpTransport = HttpTransport.httpJson({
|
||||
endpoint: endpoint(),
|
||||
auth: Auth.bearer(),
|
||||
framing: Framing.sse,
|
||||
encodePayload,
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
protocol,
|
||||
transport: httpTransport,
|
||||
defaults: {
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
},
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Model Helper
|
||||
// =============================================================================
|
||||
export const model = Route.model(route, {
|
||||
// `Route.model` creates a user-facing model factory bound to this route.
|
||||
// The model route is derived from the route, so
|
||||
// provider authors only specify provider identity and defaults here.
|
||||
provider: "openai",
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
})
|
||||
export const model = route.model
|
||||
|
||||
export * as OpenAIChat from "./openai-chat"
|
||||
|
||||
@@ -482,24 +482,24 @@ export const endpoint = (
|
||||
required: input.required,
|
||||
})
|
||||
|
||||
export const makeRoute = (
|
||||
input: {
|
||||
readonly id?: string
|
||||
readonly auth?: AuthDef
|
||||
readonly endpoint?: EndpointConfig<OpenAIResponsesPayload>
|
||||
readonly defaultBaseURL?: string | false
|
||||
readonly endpointRequired?: string
|
||||
} = {},
|
||||
) =>
|
||||
Route.make({
|
||||
id: input.id ?? ADAPTER,
|
||||
protocol,
|
||||
endpoint: input.endpoint ?? endpoint({ defaultBaseURL: input.defaultBaseURL, required: input.endpointRequired }),
|
||||
auth: input.auth,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
const encodePayload = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesPayload))
|
||||
|
||||
export const route = makeRoute()
|
||||
export const httpTransport = HttpTransport.httpJson({
|
||||
endpoint: endpoint(),
|
||||
auth: Auth.bearer(),
|
||||
framing: Framing.sse,
|
||||
encodePayload,
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
protocol,
|
||||
transport: httpTransport,
|
||||
defaults: {
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
},
|
||||
})
|
||||
|
||||
type WebSocketPrepared = {
|
||||
readonly url: string
|
||||
@@ -541,24 +541,26 @@ const webSocketPayload = (body: string) =>
|
||||
),
|
||||
)
|
||||
|
||||
const webSocketTransport = (
|
||||
input: {
|
||||
readonly auth?: AuthDef
|
||||
readonly endpoint?: EndpointConfig<OpenAIResponsesPayload>
|
||||
readonly defaultBaseURL?: string | false
|
||||
readonly endpointRequired?: string
|
||||
} = {},
|
||||
): Transport<OpenAIResponsesPayload, WebSocketPrepared, string> => ({
|
||||
interface WebSocketTransportInput {
|
||||
readonly auth?: AuthDef
|
||||
readonly endpoint?: EndpointConfig<OpenAIResponsesPayload>
|
||||
}
|
||||
|
||||
interface WebSocketTransport extends Transport<OpenAIResponsesPayload, WebSocketPrepared, string> {
|
||||
readonly with: (patch: WebSocketTransportInput) => WebSocketTransport
|
||||
}
|
||||
|
||||
const makeWebSocketTransport = (input: WebSocketTransportInput = {}): WebSocketTransport => ({
|
||||
id: "websocket-json",
|
||||
with: (patch) => makeWebSocketTransport({ ...input, ...patch }),
|
||||
prepare: (payload, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* HttpTransport.jsonRequestParts({
|
||||
payload,
|
||||
context,
|
||||
endpoint:
|
||||
input.endpoint ?? endpoint({ defaultBaseURL: input.defaultBaseURL, required: input.endpointRequired }),
|
||||
endpoint: input.endpoint ?? endpoint(),
|
||||
auth: input.auth ?? Auth.bearer(),
|
||||
encodePayload: Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesPayload)),
|
||||
encodePayload,
|
||||
})
|
||||
const message = yield* webSocketPayload(parts.body)
|
||||
return {
|
||||
@@ -588,34 +590,23 @@ const webSocketTransport = (
|
||||
),
|
||||
})
|
||||
|
||||
export const makeWebSocketRoute = (
|
||||
input: {
|
||||
readonly id?: string
|
||||
readonly auth?: AuthDef
|
||||
readonly endpoint?: EndpointConfig<OpenAIResponsesPayload>
|
||||
readonly defaultBaseURL?: string | false
|
||||
readonly endpointRequired?: string
|
||||
} = {},
|
||||
) =>
|
||||
Route.make({
|
||||
id: input.id ?? `${ADAPTER}-websocket`,
|
||||
protocol,
|
||||
transport: webSocketTransport(input),
|
||||
})
|
||||
export const webSocketTransport = makeWebSocketTransport()
|
||||
|
||||
export const webSocketRoute = makeWebSocketRoute()
|
||||
export const webSocketRoute = Route.make({
|
||||
id: `${ADAPTER}-websocket`,
|
||||
provider: "openai",
|
||||
protocol,
|
||||
transport: webSocketTransport,
|
||||
defaults: {
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
},
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Model Helper
|
||||
// =============================================================================
|
||||
export const model = Route.model(route, {
|
||||
provider: "openai",
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
})
|
||||
export const model = route.model
|
||||
|
||||
export const webSocketModel = Route.model(webSocketRoute, {
|
||||
provider: "openai",
|
||||
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
|
||||
})
|
||||
export const webSocketModel = webSocketRoute.model
|
||||
|
||||
export * as OpenAIResponses from "./openai-responses"
|
||||
|
||||
@@ -26,21 +26,25 @@ const resourceBaseURL = (resourceName: string | undefined) => {
|
||||
return `https://${resource}.openai.azure.com/openai/v1`
|
||||
}
|
||||
|
||||
const responsesAdapter = OpenAIResponses.makeRoute({
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
transport: OpenAIResponses.httpTransport.with({
|
||||
auth: routeAuth,
|
||||
defaultBaseURL: false,
|
||||
endpointRequired: MISSING_BASE_URL,
|
||||
endpoint: OpenAIResponses.endpoint({ defaultBaseURL: false, required: MISSING_BASE_URL }),
|
||||
}),
|
||||
})
|
||||
|
||||
const chatAdapter = OpenAIChat.makeRoute({
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
id: "azure-openai-chat",
|
||||
provider: id,
|
||||
transport: OpenAIChat.httpTransport.with({
|
||||
auth: routeAuth,
|
||||
defaultBaseURL: false,
|
||||
endpointRequired: MISSING_BASE_URL,
|
||||
endpoint: OpenAIChat.endpoint({ defaultBaseURL: false, required: MISSING_BASE_URL }),
|
||||
}),
|
||||
})
|
||||
|
||||
export const routes = [responsesAdapter, chatAdapter]
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const mapInput = (input: AzureModelInput) => {
|
||||
const { apiKey: _, apiVersion, resourceName, useCompletionUrls, ...rest } = input
|
||||
@@ -61,8 +65,8 @@ const mapInput = (input: AzureModelInput) => {
|
||||
}
|
||||
}
|
||||
|
||||
const chatModel = Route.model<AzureModelInput>(chatAdapter, { provider: id }, { mapInput })
|
||||
const responsesModel = Route.model<AzureModelInput>(responsesAdapter, { provider: id }, { mapInput })
|
||||
const chatModel = Route.model<AzureModelInput>(chatRoute, {}, { mapInput })
|
||||
const responsesModel = Route.model<AzureModelInput>(responsesRoute, {}, { mapInput })
|
||||
|
||||
export const responses = (modelID: string | ModelID, options: ModelOptions = {}) => responsesModel({ ...options, id: modelID })
|
||||
|
||||
|
||||
@@ -41,10 +41,14 @@ export interface RouteContext {
|
||||
|
||||
export interface Route<Payload, Prepared = unknown> {
|
||||
readonly id: string
|
||||
readonly provider?: ProviderID
|
||||
readonly protocol: ProtocolID
|
||||
readonly transport: string
|
||||
readonly transport: Transport<Payload, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly payloadSchema: Schema.Codec<Payload, unknown>
|
||||
readonly toPayload: (request: LLMRequest) => Effect.Effect<Payload, LLMError>
|
||||
readonly with: (patch: RoutePatch<Payload, Prepared>) => Route<Payload, Prepared>
|
||||
readonly model: <Input extends RouteModelInput = RouteModelInput>(input: Input) => ModelRef
|
||||
readonly prepareTransport: (
|
||||
payload: Payload,
|
||||
context: RouteContext,
|
||||
@@ -100,6 +104,14 @@ export type RouteRoutedModelInput = Omit<ModelRefInput, "route">
|
||||
|
||||
export type RouteRoutedModelDefaults = Partial<Omit<ModelRefInput, "id" | "provider" | "route">>
|
||||
|
||||
export type RouteDefaults = Partial<Omit<ModelRefInput, "id" | "provider" | "route">>
|
||||
|
||||
export interface RoutePatch<Payload, Prepared> extends RouteDefaults {
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly transport?: Transport<Payload, Prepared, unknown>
|
||||
}
|
||||
|
||||
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
||||
|
||||
export interface RouteModelOptions<Input extends RouteMappedModelInput, Output extends RouteMappedModelInput = RouteMappedModelInput> {
|
||||
@@ -110,6 +122,32 @@ export interface RouteMappedModelOptions<Input, Output extends RouteMappedModelI
|
||||
readonly mapInput: (input: Input) => Output
|
||||
}
|
||||
|
||||
const modelWithDefaults = <Input>(
|
||||
route: AnyRoute,
|
||||
defaults: Partial<Omit<ModelRefInput, "id" | "route">>,
|
||||
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 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,
|
||||
provider,
|
||||
route: route.id,
|
||||
capabilities: mapped.capabilities ?? defaults.capabilities ?? route.defaults.capabilities,
|
||||
limits: mapped.limits ?? defaults.limits ?? route.defaults.limits,
|
||||
generation: mergeGenerationOptions(generation, mapped.generation),
|
||||
providerOptions: mergeProviderOptions(providerOptions, mapped.providerOptions),
|
||||
http: mergeHttpOptions(http, httpOptions(mapped.http)),
|
||||
})
|
||||
}
|
||||
|
||||
export const modelCapabilities = ModelCapabilities.make
|
||||
|
||||
export const modelLimits = ModelLimits.make
|
||||
@@ -154,23 +192,7 @@ function model<Input>(
|
||||
defaults: Partial<Omit<ModelRefInput, "id" | "route">> = {},
|
||||
options: { readonly mapInput?: (input: Input) => RouteMappedModelInput } = {},
|
||||
) {
|
||||
return (input: Input) => {
|
||||
const mapped = options.mapInput === undefined ? input as RouteMappedModelInput : options.mapInput(input)
|
||||
const provider = defaults.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
register(route)
|
||||
return modelRef({
|
||||
...defaults,
|
||||
...mapped,
|
||||
provider,
|
||||
route: route.id,
|
||||
capabilities: mapped.capabilities ?? defaults.capabilities,
|
||||
limits: mapped.limits ?? defaults.limits,
|
||||
generation: mergeGenerationOptions(defaults.generation, mapped.generation),
|
||||
providerOptions: mergeProviderOptions(defaults.providerOptions, mapped.providerOptions),
|
||||
http: mergeHttpOptions(httpOptions(defaults.http), httpOptions(mapped.http)),
|
||||
})
|
||||
}
|
||||
return modelWithDefaults(route, defaults, options)
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -218,6 +240,8 @@ const resolveRequestOptions = (request: LLMRequest) =>
|
||||
export interface MakeInput<Payload, Frame, Chunk, State> {
|
||||
/** 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 lowering, payload schema, and parsing. */
|
||||
readonly protocol: Protocol<Payload, Frame, Chunk, State>
|
||||
/** Where the request is sent. */
|
||||
@@ -228,15 +252,21 @@ export interface MakeInput<Payload, Frame, Chunk, State> {
|
||||
readonly framing: Framing<Frame>
|
||||
/** Static / per-request headers added before `auth` runs. */
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
/** Model defaults used by the route's `.model(...)` helper. */
|
||||
readonly defaults?: RouteDefaults
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Payload, Prepared, Frame, Chunk, State> {
|
||||
/** 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 lowering, payload schema, and parsing. */
|
||||
readonly protocol: Protocol<Payload, Frame, Chunk, State>
|
||||
/** Runnable transport route. */
|
||||
readonly transport: Transport<Payload, Prepared, Frame>
|
||||
/** Provider/model defaults used by the route's `.model(...)` helper. */
|
||||
readonly defaults?: RouteDefaults
|
||||
}
|
||||
|
||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
@@ -261,25 +291,46 @@ function makeFromTransport<Payload, Prepared, Frame, Chunk, State>(
|
||||
),
|
||||
)
|
||||
|
||||
return register({
|
||||
id: input.id,
|
||||
protocol: protocol.id,
|
||||
transport: input.transport.id,
|
||||
payloadSchema: protocol.payload,
|
||||
toPayload: protocol.toPayload,
|
||||
prepareTransport: input.transport.prepare,
|
||||
streamPrepared: (prepared, ctx, runtime) => {
|
||||
const route = `${ctx.request.model.provider}/${ctx.request.model.route}`
|
||||
const chunks = input.transport.frames(prepared, ctx, runtime).pipe(
|
||||
Stream.mapEffect(decodeChunk(route)),
|
||||
protocol.terminal ? Stream.takeUntil(protocol.terminal) : (stream) => stream,
|
||||
)
|
||||
return chunks.pipe(
|
||||
Stream.mapAccumEffect(protocol.initial, protocol.process, protocol.onHalt ? { onHalt: protocol.onHalt } : undefined),
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
)
|
||||
},
|
||||
})
|
||||
const build = (routeInput: MakeTransportInput<Payload, Prepared, Frame, Chunk, State>): Route<Payload, Prepared> => {
|
||||
const route: Route<Payload, Prepared> = {
|
||||
id: routeInput.id,
|
||||
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
|
||||
protocol: protocol.id,
|
||||
transport: routeInput.transport,
|
||||
defaults: routeInput.defaults ?? {},
|
||||
payloadSchema: protocol.payload,
|
||||
toPayload: protocol.toPayload,
|
||||
with: (patch: RoutePatch<Payload, Prepared>) => {
|
||||
const { id, provider, transport, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
transport: (transport as Transport<Payload, Prepared, Frame> | undefined) ?? routeInput.transport,
|
||||
defaults: {
|
||||
...routeInput.defaults,
|
||||
...defaults,
|
||||
},
|
||||
})
|
||||
},
|
||||
model: (input: RouteModelInput): ModelRef => modelWithDefaults<RouteModelInput>(route, {}, {})(input),
|
||||
prepareTransport: routeInput.transport.prepare,
|
||||
streamPrepared: (prepared: Prepared, ctx: RouteContext, runtime: TransportRuntime) => {
|
||||
const route = `${ctx.request.model.provider}/${ctx.request.model.route}`
|
||||
const chunks = routeInput.transport.frames(prepared, ctx, runtime).pipe(
|
||||
Stream.mapEffect(decodeChunk(route)),
|
||||
protocol.terminal ? Stream.takeUntil(protocol.terminal) : (stream) => stream,
|
||||
)
|
||||
return chunks.pipe(
|
||||
Stream.mapAccumEffect(protocol.initial, protocol.process, protocol.onHalt ? { onHalt: protocol.onHalt } : undefined),
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
)
|
||||
},
|
||||
} satisfies Route<Payload, Prepared>
|
||||
return register(route)
|
||||
}
|
||||
|
||||
return build(input)
|
||||
}
|
||||
|
||||
export function make<Payload, Prepared, Frame, Chunk, State>(
|
||||
@@ -311,6 +362,7 @@ export function make<Payload, Prepared, Frame, Chunk, State>(
|
||||
const encodePayload = Schema.encodeSync(Schema.fromJsonString(protocol.payload))
|
||||
return makeFromTransport({
|
||||
id: input.id,
|
||||
provider: input.provider,
|
||||
protocol,
|
||||
transport: HttpTransport.httpJson({
|
||||
endpoint: input.endpoint,
|
||||
@@ -319,6 +371,7 @@ export function make<Payload, Prepared, Frame, Chunk, State>(
|
||||
encodePayload,
|
||||
headers: input.headers,
|
||||
}),
|
||||
defaults: input.defaults,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,7 +407,7 @@ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMReques
|
||||
protocol: compiled.route.protocol,
|
||||
model: compiled.request.model,
|
||||
payload: compiled.payload,
|
||||
metadata: { transport: compiled.route.transport },
|
||||
metadata: { transport: compiled.route.transport.id },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -61,14 +61,23 @@ export const jsonRequestParts = <Payload>(input: JsonRequestInput<Payload>) =>
|
||||
return { url, body, headers }
|
||||
})
|
||||
|
||||
export const httpJson = <Payload, Frame>(input: {
|
||||
export interface HttpJsonInput<Payload, Frame> {
|
||||
readonly endpoint: Endpoint<Payload>
|
||||
readonly auth?: AuthDef
|
||||
readonly framing: Framing<Frame>
|
||||
readonly encodePayload: (payload: Payload) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
}): Transport<Payload, HttpPrepared<Frame>, Frame> => ({
|
||||
}
|
||||
|
||||
export type HttpJsonPatch<Payload, Frame> = Partial<HttpJsonInput<Payload, Frame>>
|
||||
|
||||
export interface HttpJsonTransport<Payload, Frame> extends Transport<Payload, HttpPrepared<Frame>, Frame> {
|
||||
readonly with: (patch: HttpJsonPatch<Payload, Frame>) => HttpJsonTransport<Payload, Frame>
|
||||
}
|
||||
|
||||
export const httpJson = <Payload, Frame>(input: HttpJsonInput<Payload, Frame>): HttpJsonTransport<Payload, Frame> => ({
|
||||
id: "http-json",
|
||||
with: (patch) => httpJson({ ...input, ...patch }),
|
||||
prepare: (payload, context) =>
|
||||
jsonRequestParts({
|
||||
payload,
|
||||
|
||||
Reference in New Issue
Block a user