@neurocode-ai/llm 1.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/AGENTS.md +321 -0
  2. package/DESIGN.md +1114 -0
  3. package/README.md +131 -0
  4. package/example/call-sites.md +591 -0
  5. package/example/tutorial.ts +255 -0
  6. package/package.json +51 -0
  7. package/script/recording-cost-report.ts +250 -0
  8. package/script/setup-recording-env.ts +542 -0
  9. package/src/cache-policy.ts +111 -0
  10. package/src/index.ts +33 -0
  11. package/src/llm.ts +186 -0
  12. package/src/protocols/anthropic-messages.ts +855 -0
  13. package/src/protocols/bedrock-converse.ts +674 -0
  14. package/src/protocols/bedrock-event-stream.ts +87 -0
  15. package/src/protocols/gemini.ts +512 -0
  16. package/src/protocols/index.ts +6 -0
  17. package/src/protocols/openai-chat.ts +506 -0
  18. package/src/protocols/openai-compatible-chat.ts +24 -0
  19. package/src/protocols/openai-responses.ts +1022 -0
  20. package/src/protocols/shared.ts +326 -0
  21. package/src/protocols/utils/bedrock-auth.ts +70 -0
  22. package/src/protocols/utils/bedrock-cache.ts +37 -0
  23. package/src/protocols/utils/bedrock-media.ts +90 -0
  24. package/src/protocols/utils/cache.ts +16 -0
  25. package/src/protocols/utils/gemini-tool-schema.ts +99 -0
  26. package/src/protocols/utils/lifecycle.ts +102 -0
  27. package/src/protocols/utils/openai-options.ts +93 -0
  28. package/src/protocols/utils/tool-schema.ts +86 -0
  29. package/src/protocols/utils/tool-stream.ts +218 -0
  30. package/src/provider-error.ts +43 -0
  31. package/src/provider.ts +36 -0
  32. package/src/providers/amazon-bedrock.ts +43 -0
  33. package/src/providers/anthropic.ts +35 -0
  34. package/src/providers/azure.ts +110 -0
  35. package/src/providers/cloudflare.ts +127 -0
  36. package/src/providers/github-copilot.ts +69 -0
  37. package/src/providers/google.ts +35 -0
  38. package/src/providers/index.ts +11 -0
  39. package/src/providers/openai-compatible-profile.ts +20 -0
  40. package/src/providers/openai-compatible.ts +65 -0
  41. package/src/providers/openai-options.ts +83 -0
  42. package/src/providers/openai.ts +63 -0
  43. package/src/providers/openrouter.ts +98 -0
  44. package/src/providers/xai.ts +56 -0
  45. package/src/route/auth-options.ts +57 -0
  46. package/src/route/auth.ts +156 -0
  47. package/src/route/client.ts +436 -0
  48. package/src/route/endpoint.ts +53 -0
  49. package/src/route/executor.ts +385 -0
  50. package/src/route/framing.ts +27 -0
  51. package/src/route/index.ts +25 -0
  52. package/src/route/protocol.ts +84 -0
  53. package/src/route/transport/http.ts +155 -0
  54. package/src/route/transport/index.ts +33 -0
  55. package/src/route/transport/websocket.ts +280 -0
  56. package/src/schema/errors.ts +207 -0
  57. package/src/schema/events.ts +618 -0
  58. package/src/schema/ids.ts +43 -0
  59. package/src/schema/index.ts +5 -0
  60. package/src/schema/messages.ts +312 -0
  61. package/src/schema/options.ts +276 -0
  62. package/src/tool-runtime.ts +78 -0
  63. package/src/tool.ts +253 -0
  64. package/src/utils/record.ts +3 -0
  65. package/sst-env.d.ts +10 -0
  66. package/test/adapter.test.ts +171 -0
  67. package/test/auth-options.types.ts +168 -0
  68. package/test/auth.test.ts +103 -0
  69. package/test/cache-policy.test.ts +262 -0
  70. package/test/continuation-scenarios.ts +104 -0
  71. package/test/endpoint.test.ts +58 -0
  72. package/test/executor.test.ts +458 -0
  73. package/test/exports.test.ts +76 -0
  74. package/test/fixtures/media/restroom.png +0 -0
  75. package/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json +29 -0
  76. package/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json +43 -0
  77. package/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json +56 -0
  78. package/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json +29 -0
  79. package/test/fixtures/recordings/anthropic-messages/streams-text.json +29 -0
  80. package/test/fixtures/recordings/anthropic-messages/streams-tool-call.json +29 -0
  81. package/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json +48 -0
  82. package/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json +55 -0
  83. package/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json +29 -0
  84. package/test/fixtures/recordings/bedrock-converse/streams-text.json +29 -0
  85. package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
  86. package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json +32 -0
  87. package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
  88. package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json +32 -0
  89. package/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json +32 -0
  90. package/test/fixtures/recordings/gemini/streams-text.json +28 -0
  91. package/test/fixtures/recordings/gemini/streams-tool-call.json +28 -0
  92. package/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +46 -0
  93. package/test/fixtures/recordings/openai-chat/continues-after-tool-result.json +28 -0
  94. package/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json +46 -0
  95. package/test/fixtures/recordings/openai-chat/streams-text.json +28 -0
  96. package/test/fixtures/recordings/openai-chat/streams-tool-call.json +28 -0
  97. package/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json +28 -0
  98. package/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json +53 -0
  99. package/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json +28 -0
  100. package/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json +28 -0
  101. package/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json +54 -0
  102. package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json +53 -0
  103. package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json +54 -0
  104. package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json +28 -0
  105. package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json +28 -0
  106. package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json +28 -0
  107. package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json +28 -0
  108. package/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +54 -0
  109. package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json +28 -0
  110. package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +28 -0
  111. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +42 -0
  112. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +58 -0
  113. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json +32 -0
  114. package/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json +46 -0
  115. package/test/generate-object.test.ts +184 -0
  116. package/test/lib/effect.ts +50 -0
  117. package/test/lib/http.ts +98 -0
  118. package/test/lib/openai-chunks.ts +27 -0
  119. package/test/lib/sse.ts +17 -0
  120. package/test/lib/tool-runtime.ts +146 -0
  121. package/test/llm.test.ts +199 -0
  122. package/test/prepare.test.ts +178 -0
  123. package/test/provider/anthropic-messages-cache.recorded.test.ts +53 -0
  124. package/test/provider/anthropic-messages.recorded.test.ts +45 -0
  125. package/test/provider/anthropic-messages.test.ts +895 -0
  126. package/test/provider/bedrock-converse-cache.recorded.test.ts +54 -0
  127. package/test/provider/bedrock-converse.test.ts +744 -0
  128. package/test/provider/cloudflare.test.ts +230 -0
  129. package/test/provider/gemini-cache.recorded.test.ts +48 -0
  130. package/test/provider/gemini.test.ts +584 -0
  131. package/test/provider/golden.recorded.test.ts +223 -0
  132. package/test/provider/openai-chat.test.ts +674 -0
  133. package/test/provider/openai-compatible-chat.test.ts +238 -0
  134. package/test/provider/openai-responses-cache.recorded.test.ts +46 -0
  135. package/test/provider/openai-responses.test.ts +1472 -0
  136. package/test/provider/openrouter.test.ts +56 -0
  137. package/test/provider-error.test.ts +30 -0
  138. package/test/provider.types.ts +41 -0
  139. package/test/recorded-golden.ts +97 -0
  140. package/test/recorded-runner.ts +100 -0
  141. package/test/recorded-scenarios.ts +531 -0
  142. package/test/recorded-test.ts +94 -0
  143. package/test/recorded-utils.ts +56 -0
  144. package/test/recorded-websocket.ts +26 -0
  145. package/test/response.test.ts +98 -0
  146. package/test/route.test.ts +43 -0
  147. package/test/schema.test.ts +86 -0
  148. package/test/tool-runtime.test.ts +818 -0
  149. package/test/tool-schema-projection.test.ts +117 -0
  150. package/test/tool-stream.test.ts +99 -0
  151. package/test/tool.types.ts +40 -0
  152. package/tsconfig.json +15 -0
@@ -0,0 +1,436 @@
1
+ import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
2
+ import * as Option from "effect/Option"
3
+ import { Auth, type Auth as AuthDef } from "./auth"
4
+ import { Endpoint, type EndpointPatch } from "./endpoint"
5
+ import { RequestExecutor } from "./executor"
6
+ import type { Framing } from "./framing"
7
+ import { HttpTransport } from "./transport"
8
+ import type { Transport, TransportRuntime } from "./transport"
9
+ import { WebSocketExecutor } from "./transport"
10
+ import type { Protocol } from "./protocol"
11
+ import { applyCachePolicy } from "../cache-policy"
12
+ import * as ProviderShared from "../protocols/shared"
13
+ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
14
+ import {
15
+ GenerationOptions,
16
+ HttpOptions,
17
+ LLMRequest,
18
+ LLMResponse,
19
+ Model,
20
+ ModelLimits,
21
+ LLMError as LLMErrorClass,
22
+ PreparedRequest,
23
+ ProviderID,
24
+ mergeGenerationOptions,
25
+ mergeHttpOptions,
26
+ mergeProviderOptions,
27
+ } from "../schema"
28
+
29
+ export interface RouteBody<Body> {
30
+ /** Schema for the validated provider-native body sent as the JSON request. */
31
+ readonly schema: Schema.Codec<Body, unknown>
32
+ /** Build the provider-native body from a common `LLMRequest`. */
33
+ readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
34
+ }
35
+
36
+ export interface Route<Body, Prepared = unknown> {
37
+ readonly id: string
38
+ readonly provider?: ProviderID
39
+ readonly protocol: ProtocolID
40
+ readonly endpoint: Endpoint<Body>
41
+ readonly auth: AuthDef
42
+ readonly transport: Transport<Body, Prepared, unknown>
43
+ readonly defaults: RouteDefaults
44
+ readonly body: RouteBody<Body>
45
+ readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
46
+ readonly model: (input: RouteMappedModelInput) => Model
47
+ readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
48
+ readonly streamPrepared: (
49
+ prepared: Prepared,
50
+ request: LLMRequest,
51
+ runtime: TransportRuntime,
52
+ ) => Stream.Stream<LLMEvent, LLMError>
53
+ }
54
+
55
+ // Route registries intentionally erase body generics after construction.
56
+ // Normal call sites use `OpenAIChat.route`; callers only need body types
57
+ // when preparing a request with a protocol-specific type assertion.
58
+ // oxlint-disable-next-line typescript-eslint/no-explicit-any
59
+ export type AnyRoute = Route<any, any>
60
+
61
+ export type HttpOptionsInput = HttpOptions.Input
62
+
63
+ export type RouteModelInput = Omit<Model.Input, "provider" | "route">
64
+
65
+ export type RouteRoutedModelInput = Omit<Model.Input, "route">
66
+
67
+ export interface RouteDefaults {
68
+ readonly headers?: Record<string, string>
69
+ readonly limits?: ModelLimits
70
+ readonly generation?: GenerationOptions
71
+ readonly providerOptions?: ProviderOptions
72
+ readonly http?: HttpOptions
73
+ }
74
+
75
+ export interface RouteDefaultsInput {
76
+ readonly headers?: Record<string, string>
77
+ readonly limits?: ModelLimits.Input
78
+ readonly generation?: GenerationOptions.Input
79
+ readonly providerOptions?: ProviderOptions
80
+ readonly http?: HttpOptions.Input
81
+ }
82
+
83
+ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
84
+ readonly id?: string
85
+ readonly provider?: string | ProviderID
86
+ readonly auth?: AuthDef
87
+ readonly transport?: Transport<Body, Prepared, unknown>
88
+ readonly endpoint?: EndpointPatch<Body>
89
+ }
90
+
91
+ type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
92
+
93
+ const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
94
+ const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
95
+ if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
96
+ if (!endpointBaseURL(route.endpoint))
97
+ throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
98
+ return Model.make({
99
+ ...mapped,
100
+ provider,
101
+ route,
102
+ })
103
+ }
104
+
105
+ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaultsInput): RouteDefaults => {
106
+ const headers = mergeHeaders(base?.headers, patch.headers)
107
+ return {
108
+ ...base,
109
+ ...patch,
110
+ headers,
111
+ limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits),
112
+ generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
113
+ providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
114
+ http: mergeHttpOptions(
115
+ base?.http,
116
+ httpOptions(patch.http),
117
+ headers === undefined ? undefined : new HttpOptions({ headers }),
118
+ ),
119
+ }
120
+ }
121
+
122
+ const endpointBaseURL = <Body>(endpoint: Endpoint<Body>) =>
123
+ typeof endpoint.baseURL === "string" ? endpoint.baseURL : undefined
124
+
125
+ const mergeHeaders = (...items: ReadonlyArray<Record<string, string> | undefined>) => {
126
+ const entries = items.flatMap((item) =>
127
+ item === undefined ? [] : Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined),
128
+ )
129
+ if (entries.length === 0) return undefined
130
+ return Object.fromEntries(entries)
131
+ }
132
+
133
+ export const generationOptions = (input: GenerationOptions.Input | undefined) =>
134
+ input === undefined ? undefined : GenerationOptions.make(input)
135
+
136
+ export const httpOptions = (input: HttpOptionsInput | undefined) => {
137
+ if (input === undefined) return input
138
+ return HttpOptions.make(input)
139
+ }
140
+
141
+ export interface Interface {
142
+ /**
143
+ * Compile a request through protocol body construction, validation, and HTTP
144
+ * preparation without sending it. Returns the prepared request including the
145
+ * provider-native body.
146
+ *
147
+ * Pass a `Body` type argument to statically expose the route's body
148
+ * shape (e.g. `prepare<OpenAIChatBody>(...)`) — the runtime body is
149
+ * identical, so this is a type-level assertion the caller makes about which
150
+ * route the request will resolve to.
151
+ */
152
+ readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
153
+ readonly stream: StreamMethod
154
+ readonly generate: GenerateMethod
155
+ }
156
+
157
+ export interface StreamMethod {
158
+ (request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
159
+ }
160
+
161
+ export interface GenerateMethod {
162
+ (request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
163
+ }
164
+
165
+ export class Service extends Context.Service<Service, Interface>()("@neurocode/LLMClient") {}
166
+
167
+ const resolveRequestOptions = (request: LLMRequest) => {
168
+ const routeDefaults = request.model.route.defaults
169
+ const modelDefaults = request.model.defaults
170
+ const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
171
+ return LLMRequest.update(request, {
172
+ generation: generation ?? new GenerationOptions({}),
173
+ providerOptions: mergeProviderOptions(
174
+ routeDefaults.providerOptions,
175
+ modelDefaults?.providerOptions,
176
+ request.providerOptions,
177
+ ),
178
+ http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
179
+ })
180
+ }
181
+
182
+ export interface MakeInput<Body, Frame, Event, State> {
183
+ /** Route id used in diagnostics and prepared request metadata. */
184
+ readonly id: string
185
+ /** Provider identity for route-owned model construction. */
186
+ readonly provider?: string | ProviderID
187
+ /** Semantic API contract — owns body construction, body schema, and parsing. */
188
+ readonly protocol: Protocol<Body, Frame, Event, State>
189
+ /** Where the request is sent. */
190
+ readonly endpoint: Endpoint<Body>
191
+ /** Per-request transport auth. Provider facades override this via `route.with(...)`. */
192
+ readonly auth?: AuthDef
193
+ /** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */
194
+ readonly framing: Framing<Frame>
195
+ /** Static / per-request headers added before `auth` runs. */
196
+ readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
197
+ /** Route/request defaults used when compiling requests for this route. */
198
+ readonly defaults?: RouteDefaultsInput
199
+ }
200
+
201
+ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
202
+ /** Route id used in diagnostics and prepared request metadata. */
203
+ readonly id: string
204
+ /** Provider identity for route-owned model construction. */
205
+ readonly provider?: string | ProviderID
206
+ /** Semantic API contract — owns body construction, body schema, and parsing. */
207
+ readonly protocol: Protocol<Body, Frame, Event, State>
208
+ /** Where the request is sent. */
209
+ readonly endpoint: Endpoint<Body>
210
+ /** Per-request transport auth. Provider facades override this via `route.with(...)`. */
211
+ readonly auth?: AuthDef
212
+ /** Static / per-request headers added before `auth` runs. */
213
+ readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
214
+ /** Runnable transport route. */
215
+ readonly transport: Transport<Body, Prepared, Frame>
216
+ /** Route/request defaults used when compiling requests for this route. */
217
+ readonly defaults?: RouteDefaultsInput
218
+ }
219
+
220
+ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
221
+ const failed = cause.reasons.find(Cause.isFailReason)?.error
222
+ if (failed instanceof LLMErrorClass) return failed
223
+ return ProviderShared.eventError(route, message, Cause.pretty(cause))
224
+ }
225
+
226
+ function makeFromTransport<Body, Prepared, Frame, Event, State>(
227
+ input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
228
+ ): Route<Body, Prepared> {
229
+ const protocol = input.protocol
230
+ const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema))
231
+ const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
232
+ const decodeEvent = (route: string) => (frame: Frame) =>
233
+ decodeEventEffect(frame).pipe(
234
+ Effect.mapError(() =>
235
+ ProviderShared.eventError(
236
+ input.id,
237
+ `Invalid ${route} stream event`,
238
+ typeof frame === "string" ? frame : ProviderShared.encodeJson(frame),
239
+ ),
240
+ ),
241
+ )
242
+
243
+ type BuiltRouteInput = Omit<MakeTransportInput<Body, Prepared, Frame, Event, State>, "defaults"> & {
244
+ readonly defaults?: RouteDefaults
245
+ }
246
+
247
+ const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
248
+ const route: Route<Body, Prepared> = {
249
+ id: routeInput.id,
250
+ provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
251
+ protocol: protocol.id,
252
+ endpoint: routeInput.endpoint,
253
+ auth: routeInput.auth ?? Auth.none,
254
+ transport: routeInput.transport,
255
+ defaults: routeInput.defaults ?? {},
256
+ body: protocol.body,
257
+ with: (patch: RoutePatch<Body, Prepared>) => {
258
+ const { id, provider, auth, transport, endpoint, ...defaults } = patch
259
+ return build({
260
+ ...routeInput,
261
+ id: id ?? routeInput.id,
262
+ provider: provider ?? routeInput.provider,
263
+ auth: auth ?? routeInput.auth,
264
+ endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
265
+ transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
266
+ defaults: mergeRouteDefaults(route.defaults, defaults),
267
+ })
268
+ },
269
+ model: (input) => makeRouteModel(route, input),
270
+ prepareTransport: (body, request) =>
271
+ routeInput.transport.prepare({
272
+ body,
273
+ request,
274
+ endpoint: routeInput.endpoint,
275
+ auth: routeInput.auth ?? Auth.none,
276
+ encodeBody,
277
+ headers: routeInput.headers,
278
+ }),
279
+ streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
280
+ const route = `${request.model.provider}/${request.model.route.id}`
281
+ const events = routeInput.transport
282
+ .frames(prepared, request, runtime)
283
+ .pipe(
284
+ Stream.mapEffect(decodeEvent(route)),
285
+ protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
286
+ )
287
+ return events.pipe(
288
+ Stream.mapAccumEffect(
289
+ () => protocol.stream.initial(request),
290
+ protocol.stream.step,
291
+ protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
292
+ ),
293
+ Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
294
+ )
295
+ },
296
+ } satisfies Route<Body, Prepared>
297
+ return route
298
+ }
299
+
300
+ return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
301
+ }
302
+
303
+ export function make<Body, Prepared, Frame, Event, State>(
304
+ input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
305
+ ): Route<Body, Prepared>
306
+ /**
307
+ * Build a `Route` by composing the four orthogonal pieces of a deployment:
308
+ *
309
+ * - `Protocol` — what is the API I'm speaking?
310
+ * - `Endpoint` — where do I send the request?
311
+ * - `Auth` — how do I authenticate it?
312
+ * - `Framing` — how do I cut the response stream into protocol frames?
313
+ *
314
+ * Plus optional `headers` for cross-cutting deployment concerns (provider
315
+ * version pins, per-deployment quirks).
316
+ *
317
+ * This is the canonical route constructor. If a new route does not fit
318
+ * this four-axis model, add a purpose-built constructor rather than widening
319
+ * the public surface preemptively.
320
+ */
321
+ export function make<Body, Frame, Event, State>(
322
+ input: MakeInput<Body, Frame, Event, State>,
323
+ ): Route<Body, HttpTransport.HttpPrepared<Frame>>
324
+ export function make<Body, Prepared, Frame, Event, State>(
325
+ input: MakeInput<Body, Frame, Event, State> | MakeTransportInput<Body, Prepared, Frame, Event, State>,
326
+ ): Route<Body, Prepared> | Route<Body, HttpTransport.HttpPrepared<Frame>> {
327
+ if ("transport" in input) return makeFromTransport(input)
328
+ const protocol = input.protocol
329
+ return makeFromTransport({
330
+ id: input.id,
331
+ provider: input.provider,
332
+ protocol,
333
+ endpoint: input.endpoint,
334
+ auth: input.auth,
335
+ headers: input.headers,
336
+ transport: HttpTransport.httpJson({ framing: input.framing }),
337
+ defaults: input.defaults,
338
+ })
339
+ }
340
+
341
+ // `compile` is the important boundary: it turns a common `LLMRequest` into a
342
+ // validated provider body plus transport-private prepared data, but does not
343
+ // execute transport.
344
+ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
345
+ const resolved = applyCachePolicy(resolveRequestOptions(request))
346
+ const route = resolved.model.route
347
+
348
+ const body = yield* route.body
349
+ .from(resolved)
350
+ .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
351
+ const prepared = yield* route.prepareTransport(body, resolved)
352
+
353
+ return {
354
+ request: resolved,
355
+ route,
356
+ body,
357
+ prepared,
358
+ }
359
+ })
360
+
361
+ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
362
+ const compiled = yield* compile(request)
363
+
364
+ return new PreparedRequest({
365
+ id: compiled.request.id ?? "request",
366
+ route: compiled.route.id,
367
+ protocol: compiled.route.protocol,
368
+ model: compiled.request.model,
369
+ body: compiled.body,
370
+ metadata: { transport: compiled.route.transport.id },
371
+ })
372
+ })
373
+
374
+ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
375
+ Stream.unwrap(
376
+ Effect.gen(function* () {
377
+ const compiled = yield* compile(request)
378
+ return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
379
+ }),
380
+ )
381
+
382
+ const generateWith = (stream: Interface["stream"]) =>
383
+ Effect.fn("LLM.generate")(function* (request: LLMRequest) {
384
+ const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
385
+ const response = LLMResponse.complete(state)
386
+ if (response) return response
387
+ return yield* ProviderShared.eventError(
388
+ `${request.model.provider}/${request.model.route.id}`,
389
+ "Provider stream ended without a terminal finish event",
390
+ )
391
+ })
392
+
393
+ export const prepare = <Body = unknown>(request: LLMRequest) =>
394
+ prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
395
+
396
+ export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
397
+ return Stream.unwrap(
398
+ Effect.gen(function* () {
399
+ return (yield* Service).stream(request)
400
+ }),
401
+ ) as Stream.Stream<LLMEvent, LLMError>
402
+ }
403
+
404
+ export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
405
+ return Effect.gen(function* () {
406
+ return yield* (yield* Service).generate(request)
407
+ }) as Effect.Effect<LLMResponse, LLMError>
408
+ }
409
+
410
+ export const streamRequest = (request: LLMRequest) =>
411
+ Stream.unwrap(
412
+ Effect.gen(function* () {
413
+ return (yield* Service).stream(request)
414
+ }),
415
+ )
416
+
417
+ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
418
+ Service,
419
+ Effect.gen(function* () {
420
+ const stream = streamRequestWith({
421
+ http: yield* RequestExecutor.Service,
422
+ webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
423
+ })
424
+ return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
425
+ }),
426
+ )
427
+
428
+ export const Route = { make } as const
429
+
430
+ export const LLMClient = {
431
+ Service,
432
+ layer,
433
+ prepare,
434
+ stream,
435
+ generate,
436
+ } as const
@@ -0,0 +1,53 @@
1
+ import type { LLMRequest } from "../schema"
2
+ import * as ProviderShared from "../protocols/shared"
3
+
4
+ export interface EndpointInput<Body> {
5
+ readonly request: LLMRequest
6
+ readonly body: Body
7
+ }
8
+
9
+ export type EndpointPart<Body> = string | ((input: EndpointInput<Body>) => string)
10
+
11
+ /**
12
+ * Declarative URL construction for one route.
13
+ *
14
+ * `Endpoint` carries URL construction for one route. Routes with a canonical
15
+ * host put `baseURL` here; provider helpers can override it by configuring the
16
+ * route before selecting a model.
17
+ *
18
+ * `path` may be a string or a function of `EndpointInput`, for routes whose
19
+ * URL embeds the model id, region, or another body field (e.g. Bedrock,
20
+ * Gemini).
21
+ */
22
+ export interface Endpoint<Body> {
23
+ readonly baseURL?: string
24
+ readonly path: EndpointPart<Body>
25
+ readonly query?: Record<string, string>
26
+ }
27
+
28
+ export type EndpointPatch<Body> = Partial<Endpoint<Body>>
29
+
30
+ /** Construct an `Endpoint` from a path string or path function. */
31
+ export const path = <Body>(value: EndpointPart<Body>, options: Omit<Endpoint<Body>, "path"> = {}): Endpoint<Body> => ({
32
+ ...options,
33
+ path: value,
34
+ })
35
+
36
+ export const merge = <Body>(base: Endpoint<Body>, patch: EndpointPatch<Body>): Endpoint<Body> => ({
37
+ ...base,
38
+ ...patch,
39
+ baseURL: patch.baseURL ?? base.baseURL,
40
+ path: patch.path ?? base.path,
41
+ query: patch.query === undefined ? base.query : { ...base.query, ...patch.query },
42
+ })
43
+
44
+ const renderPart = <Body>(part: EndpointPart<Body>, input: EndpointInput<Body>) =>
45
+ typeof part === "function" ? part(input) : part
46
+
47
+ export const render = <Body>(endpoint: Endpoint<Body>, input: EndpointInput<Body>) => {
48
+ const url = new URL(`${ProviderShared.trimBaseUrl(endpoint.baseURL ?? "")}${renderPart(endpoint.path, input)}`)
49
+ for (const [key, value] of Object.entries(endpoint.query ?? {})) url.searchParams.set(key, value)
50
+ return url
51
+ }
52
+
53
+ export * as Endpoint from "./endpoint"