@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,385 @@
1
+ import { Cause, Context, Effect, Layer, Random } from "effect"
2
+ import {
3
+ FetchHttpClient,
4
+ Headers,
5
+ HttpClient,
6
+ HttpClientError,
7
+ HttpClientRequest,
8
+ HttpClientResponse,
9
+ } from "effect/unstable/http"
10
+ import {
11
+ AuthenticationReason,
12
+ ContentPolicyReason,
13
+ HttpContext,
14
+ HttpRateLimitDetails,
15
+ HttpRequestDetails,
16
+ HttpResponseDetails,
17
+ InvalidRequestReason,
18
+ LLMError,
19
+ ProviderInternalReason,
20
+ QuotaExceededReason,
21
+ RateLimitReason,
22
+ TransportReason,
23
+ UnknownProviderReason,
24
+ } from "../schema"
25
+ import { isContextOverflow } from "../provider-error"
26
+
27
+ export interface Interface {
28
+ readonly execute: (
29
+ request: HttpClientRequest.HttpClientRequest,
30
+ ) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
31
+ }
32
+
33
+ export class Service extends Context.Service<Service, Interface>()("@neurocode/LLM/RequestExecutor") {}
34
+
35
+ const BODY_LIMIT = 16_384
36
+ const MAX_RETRIES = 2
37
+ const BASE_DELAY_MS = 500
38
+ const MAX_DELAY_MS = 10_000
39
+ const REDACTED = "<redacted>"
40
+
41
+ // One source of truth for what counts as a sensitive name across headers,
42
+ // URL query keys, and field names embedded inside request/response bodies.
43
+ //
44
+ // `SENSITIVE_NAME` is used as both a substring matcher (for free-form header
45
+ // names like `Authorization` / `X-API-Key`) and as the body-field alternation
46
+ // list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…`
47
+ // that are too generic to redact substring-style without false positives.
48
+ const SENSITIVE_NAME_SOURCE =
49
+ "authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
50
+ const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
51
+ const SHORT_QUERY_NAME = /^(key|sig)$/i
52
+ const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
53
+ const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
54
+ const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")
55
+
56
+ const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
57
+
58
+ const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
59
+
60
+ const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
61
+ Object.fromEntries(
62
+ Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
63
+ name,
64
+ String(value),
65
+ ]),
66
+ )
67
+
68
+ const redactUrl = (value: string) => {
69
+ if (!URL.canParse(value)) return REDACTED
70
+ const url = new URL(value)
71
+ url.searchParams.forEach((_, key) => {
72
+ if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED)
73
+ })
74
+ return url.toString()
75
+ }
76
+
77
+ const normalizedHeaders = (headers: Headers.Headers) =>
78
+ Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
79
+
80
+ const requestId = (headers: Record<string, string>) => {
81
+ return (
82
+ headers["x-request-id"] ??
83
+ headers["request-id"] ??
84
+ headers["x-amzn-requestid"] ??
85
+ headers["x-amz-request-id"] ??
86
+ headers["x-goog-request-id"] ??
87
+ headers["cf-ray"]
88
+ )
89
+ }
90
+
91
+ const retryableStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
92
+
93
+ const retryAfterMs = (headers: Record<string, string>) => {
94
+ const millis = Number(headers["retry-after-ms"])
95
+ if (Number.isFinite(millis)) return Math.max(0, millis)
96
+
97
+ const value = headers["retry-after"]
98
+ if (!value) return undefined
99
+
100
+ const seconds = Number(value)
101
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
102
+
103
+ const date = Date.parse(value)
104
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
105
+ return undefined
106
+ }
107
+
108
+ const addRateLimitValue = (target: Record<string, string>, key: string, value: string) => {
109
+ if (key.length > 0) target[key] = value
110
+ }
111
+
112
+ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number | undefined) => {
113
+ const limit: Record<string, string> = {}
114
+ const remaining: Record<string, string> = {}
115
+ const reset: Record<string, string> = {}
116
+
117
+ Object.entries(headers).forEach(([name, value]) => {
118
+ const openaiLimit = /^x-ratelimit-limit-(.+)$/.exec(name)?.[1]
119
+ if (openaiLimit) return addRateLimitValue(limit, openaiLimit, value)
120
+
121
+ const openaiRemaining = /^x-ratelimit-remaining-(.+)$/.exec(name)?.[1]
122
+ if (openaiRemaining) return addRateLimitValue(remaining, openaiRemaining, value)
123
+
124
+ const openaiReset = /^x-ratelimit-reset-(.+)$/.exec(name)?.[1]
125
+ if (openaiReset) return addRateLimitValue(reset, openaiReset, value)
126
+
127
+ const anthropic = /^anthropic-ratelimit-(.+)-(limit|remaining|reset)$/.exec(name)
128
+ if (!anthropic) return
129
+ if (anthropic[2] === "limit") return addRateLimitValue(limit, anthropic[1], value)
130
+ if (anthropic[2] === "remaining") return addRateLimitValue(remaining, anthropic[1], value)
131
+ return addRateLimitValue(reset, anthropic[1], value)
132
+ })
133
+
134
+ if (
135
+ retryAfter === undefined &&
136
+ Object.keys(limit).length === 0 &&
137
+ Object.keys(remaining).length === 0 &&
138
+ Object.keys(reset).length === 0
139
+ )
140
+ return undefined
141
+
142
+ return new HttpRateLimitDetails({
143
+ retryAfterMs: retryAfter,
144
+ limit: Object.keys(limit).length === 0 ? undefined : limit,
145
+ remaining: Object.keys(remaining).length === 0 ? undefined : remaining,
146
+ reset: Object.keys(reset).length === 0 ? undefined : reset,
147
+ })
148
+ }
149
+
150
+ const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
151
+ new HttpRequestDetails({
152
+ method: request.method,
153
+ url: redactUrl(request.url),
154
+ headers: redactHeaders(request.headers, redactedNames),
155
+ })
156
+
157
+ const responseDetails = (
158
+ response: HttpClientResponse.HttpClientResponse,
159
+ redactedNames: ReadonlyArray<string | RegExp>,
160
+ ) =>
161
+ new HttpResponseDetails({
162
+ status: response.status,
163
+ headers: redactHeaders(response.headers, redactedNames),
164
+ })
165
+
166
+ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
167
+ const values = new Set<string>()
168
+ const add = (value: string) => {
169
+ if (value.length < 4) return
170
+ values.add(value)
171
+ values.add(encodeURIComponent(value))
172
+ }
173
+
174
+ Object.entries(request.headers).forEach(([name, value]) => {
175
+ if (!isSensitiveHeaderName(name)) return
176
+ add(value)
177
+ const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1]
178
+ if (bearer) add(bearer)
179
+ })
180
+
181
+ if (!URL.canParse(request.url)) return values
182
+ new URL(request.url).searchParams.forEach((value, key) => {
183
+ if (isSensitiveQueryName(key)) add(value)
184
+ })
185
+ return values
186
+ }
187
+
188
+ // Two passes: structural (redact `"name": "value"` and `name=value` patterns
189
+ // for any field name that looks sensitive) plus literal (replace any actual
190
+ // secret values we sent in the request, in case the response echoes one back).
191
+ const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
192
+ Array.from(secretValues(request)).reduce(
193
+ (text, secret) => text.split(secret).join(REDACTED),
194
+ body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
195
+ )
196
+
197
+ const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
198
+ if (body === undefined) return {}
199
+ const redacted = redactBody(body, request)
200
+ if (redacted.length <= BODY_LIMIT) return { body: redacted }
201
+ return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
202
+ }
203
+
204
+ const providerMessage = (status: number, body: { readonly body?: string }) => {
205
+ if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
206
+ return `Provider request failed with HTTP ${status}`
207
+ }
208
+
209
+ const responseHttp = (input: {
210
+ readonly request: HttpClientRequest.HttpClientRequest
211
+ readonly response: HttpClientResponse.HttpClientResponse
212
+ readonly redactedNames: ReadonlyArray<string | RegExp>
213
+ readonly body: ReturnType<typeof responseBody>
214
+ readonly requestId?: string | undefined
215
+ readonly rateLimit?: HttpRateLimitDetails | undefined
216
+ }) =>
217
+ new HttpContext({
218
+ request: requestDetails(input.request, input.redactedNames),
219
+ response: responseDetails(input.response, input.redactedNames),
220
+ ...input.body,
221
+ requestId: input.requestId,
222
+ rateLimit: input.rateLimit,
223
+ })
224
+
225
+ const statusReason = (input: {
226
+ readonly status: number
227
+ readonly message: string
228
+ readonly retryAfterMs?: number | undefined
229
+ readonly rateLimit?: HttpRateLimitDetails | undefined
230
+ readonly http: HttpContext
231
+ }) => {
232
+ const body = input.http.body ?? ""
233
+ if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
234
+ return new ContentPolicyReason({ message: input.message, http: input.http })
235
+ }
236
+ if (input.status === 401) {
237
+ return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
238
+ }
239
+ if (input.status === 403) {
240
+ return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
241
+ }
242
+ if (input.status === 429) {
243
+ if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
244
+ return new QuotaExceededReason({ message: input.message, http: input.http })
245
+ }
246
+ return new RateLimitReason({
247
+ message: input.message,
248
+ retryAfterMs: input.retryAfterMs,
249
+ rateLimit: input.rateLimit,
250
+ http: input.http,
251
+ })
252
+ }
253
+ if (
254
+ input.status === 400 ||
255
+ input.status === 404 ||
256
+ input.status === 409 ||
257
+ input.status === 413 ||
258
+ input.status === 422
259
+ ) {
260
+ return new InvalidRequestReason({
261
+ message: input.message,
262
+ classification: isContextOverflow(body) ? "context-overflow" : undefined,
263
+ http: input.http,
264
+ })
265
+ }
266
+ if (input.status >= 500 || retryableStatus(input.status)) {
267
+ return new ProviderInternalReason({
268
+ message: input.message,
269
+ status: input.status,
270
+ retryAfterMs: input.retryAfterMs,
271
+ http: input.http,
272
+ })
273
+ }
274
+ return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
275
+ }
276
+
277
+ const statusError =
278
+ (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
279
+ (response: HttpClientResponse.HttpClientResponse) =>
280
+ Effect.gen(function* () {
281
+ if (response.status < 400) return response
282
+ const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
283
+ const headers = normalizedHeaders(response.headers)
284
+ const retryAfter = retryAfterMs(headers)
285
+ const rateLimit = rateLimitDetails(headers, retryAfter)
286
+ const details = responseBody(body, request)
287
+ return yield* new LLMError({
288
+ module: "RequestExecutor",
289
+ method: "execute",
290
+ reason: statusReason({
291
+ status: response.status,
292
+ message: providerMessage(response.status, details),
293
+ retryAfterMs: retryAfter,
294
+ rateLimit,
295
+ http: responseHttp({
296
+ request,
297
+ response,
298
+ redactedNames,
299
+ body: details,
300
+ requestId: requestId(headers),
301
+ rateLimit,
302
+ }),
303
+ }),
304
+ })
305
+ })
306
+
307
+ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
308
+ const transportError = (input: {
309
+ readonly message: string
310
+ readonly kind?: string | undefined
311
+ readonly request?: HttpClientRequest.HttpClientRequest | undefined
312
+ }) =>
313
+ new LLMError({
314
+ module: "RequestExecutor",
315
+ method: "execute",
316
+ reason: new TransportReason({
317
+ message: input.message,
318
+ kind: input.kind,
319
+ url: input.request ? redactUrl(input.request.url) : undefined,
320
+ http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
321
+ }),
322
+ })
323
+
324
+ if (Cause.isTimeoutError(error)) {
325
+ return transportError({ message: error.message, kind: "Timeout" })
326
+ }
327
+ if (!HttpClientError.isHttpClientError(error)) {
328
+ return transportError({ message: "HTTP transport failed" })
329
+ }
330
+ const request = "request" in error ? error.request : undefined
331
+ if (error.reason._tag === "TransportError") {
332
+ return transportError({
333
+ message: error.reason.description ?? "HTTP transport failed",
334
+ kind: error.reason._tag,
335
+ request,
336
+ })
337
+ }
338
+ return transportError({
339
+ message: `HTTP transport failed: ${error.reason._tag}`,
340
+ kind: error.reason._tag,
341
+ request,
342
+ })
343
+ }
344
+
345
+ const retryDelay = (error: LLMError, attempt: number) => {
346
+ if (error.retryAfterMs !== undefined) return Effect.succeed(Math.min(error.retryAfterMs, MAX_DELAY_MS))
347
+ return Random.nextBetween(
348
+ Math.min(BASE_DELAY_MS * 2 ** attempt * 0.8, MAX_DELAY_MS),
349
+ Math.min(BASE_DELAY_MS * 2 ** attempt * 1.2, MAX_DELAY_MS),
350
+ ).pipe(Effect.map((delay) => Math.round(delay)))
351
+ }
352
+
353
+ const retryStatusFailures = <A, R>(
354
+ effect: Effect.Effect<A, LLMError, R>,
355
+ retries = MAX_RETRIES,
356
+ attempt = 0,
357
+ ): Effect.Effect<A, LLMError, R> =>
358
+ Effect.catchTag(effect, "LLM.Error", (error): Effect.Effect<A, LLMError, R> => {
359
+ if (!error.retryable || retries <= 0) return Effect.fail(error)
360
+ return retryDelay(error, attempt).pipe(
361
+ Effect.flatMap((delay) => Effect.sleep(delay)),
362
+ Effect.flatMap(() => retryStatusFailures(effect, retries - 1, attempt + 1)),
363
+ )
364
+ })
365
+
366
+ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
367
+ Service,
368
+ Effect.gen(function* () {
369
+ const http = yield* HttpClient.HttpClient
370
+ const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
371
+ Effect.gen(function* () {
372
+ const redactedNames = yield* Headers.CurrentRedactedNames
373
+ return yield* http
374
+ .execute(request)
375
+ .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
376
+ })
377
+ return Service.of({
378
+ execute: (request) => retryStatusFailures(executeOnce(request)),
379
+ })
380
+ }),
381
+ )
382
+
383
+ export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
384
+
385
+ export * as RequestExecutor from "./executor"
@@ -0,0 +1,27 @@
1
+ import type { Stream } from "effect"
2
+ import * as ProviderShared from "../protocols/shared"
3
+ import type { LLMError } from "../schema"
4
+
5
+ /**
6
+ * Decode a streaming HTTP response body into provider-protocol frames.
7
+ *
8
+ * `Framing` is the byte-stream-shaped seam between transport and protocol:
9
+ *
10
+ * - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder,
11
+ * drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:`
12
+ * payload of one event.
13
+ * - AWS event stream — length-prefixed binary frames with CRC checksums.
14
+ * Each emitted frame is one parsed binary event record.
15
+ *
16
+ * The frame type is opaque to this layer; the protocol's `decode` step turns
17
+ * a frame into a typed chunk.
18
+ */
19
+ export interface Framing<Frame> {
20
+ readonly id: string
21
+ readonly frame: (bytes: Stream.Stream<Uint8Array, LLMError>) => Stream.Stream<Frame, LLMError>
22
+ }
23
+
24
+ /** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
25
+ export const sse: Framing<string> = { id: "sse", frame: ProviderShared.sseFraming }
26
+
27
+ export * as Framing from "./framing"
@@ -0,0 +1,25 @@
1
+ export { Route, LLMClient } from "./client"
2
+ export type {
3
+ Route as RouteShape,
4
+ RouteModelInput,
5
+ RouteRoutedModelInput,
6
+ RouteDefaults,
7
+ RouteDefaultsInput,
8
+ AnyRoute,
9
+ Interface as LLMClientShape,
10
+ Service as LLMClientService,
11
+ } from "./client"
12
+ export * from "./executor"
13
+ export { Auth } from "./auth"
14
+ export { AuthOptions } from "./auth-options"
15
+ export { Endpoint } from "./endpoint"
16
+ export { Framing } from "./framing"
17
+ export { Protocol } from "./protocol"
18
+ export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport"
19
+ export * as Transport from "./transport"
20
+ export type { Auth as AuthShape, AuthInput, Credential, CredentialError } from "./auth"
21
+ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options"
22
+ export type { Endpoint as EndpointFn, EndpointInput } from "./endpoint"
23
+ export type { Framing as FramingDef } from "./framing"
24
+ export type { Protocol as ProtocolDef } from "./protocol"
25
+ export type { Transport as TransportDef, TransportRuntime } from "./transport"
@@ -0,0 +1,84 @@
1
+ import { Schema, type Effect } from "effect"
2
+ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
3
+
4
+ /**
5
+ * The semantic API contract of one model server family.
6
+ *
7
+ * A `Protocol` owns the parts of a route that are intrinsic to "what does
8
+ * this API look like": how a common `LLMRequest` becomes a provider-native
9
+ * body, what schema that body must satisfy before it is JSON-encoded, and
10
+ * how the streaming response decodes back into common `LLMEvent`s.
11
+ *
12
+ * Examples:
13
+ *
14
+ * - `OpenAIChat.protocol` — chat completions style
15
+ * - `OpenAIResponses.protocol` — responses API
16
+ * - `AnthropicMessages.protocol` — messages API with content blocks
17
+ * - `Gemini.protocol` — generateContent
18
+ * - `BedrockConverse.protocol` — Converse with binary event-stream framing
19
+ *
20
+ * A `Protocol` is **not** a deployment. It does not know which URL, which
21
+ * headers, or which auth scheme to use. Those are deployment concerns owned
22
+ * by `Route.make(...)` along with the chosen `Endpoint`, `Auth`,
23
+ * and `Framing`. This separation is what lets DeepSeek, TogetherAI, Cerebras,
24
+ * etc. all reuse `OpenAIChat.protocol` without forking 300 lines per provider.
25
+ *
26
+ * The four type parameters reflect the pipeline:
27
+ *
28
+ * - `Body` — provider-native request body candidate. `Route.make(...)`
29
+ * validates and JSON-encodes it with `body.schema`.
30
+ * - `Frame` — one unit of the framed response stream. SSE: a JSON data
31
+ * string. AWS event stream: a parsed binary frame.
32
+ * - `Event` — schema-decoded provider event produced from one frame.
33
+ * - `State` — accumulator threaded through `stream.step` to translate event
34
+ * sequences into `LLMEvent` sequences.
35
+ */
36
+ export interface Protocol<Body, Frame, Event, State> {
37
+ /** Stable id for the wire protocol implementation. */
38
+ readonly id: ProtocolID
39
+ /** Request side: schema for the provider-native body and how to build it. */
40
+ readonly body: ProtocolBody<Body>
41
+ /** Response side: streaming state machine. */
42
+ readonly stream: ProtocolStream<Frame, Event, State>
43
+ }
44
+
45
+ export interface ProtocolBody<Body> {
46
+ /** Schema for the validated provider-native body sent as the JSON request. */
47
+ readonly schema: Schema.Codec<Body, unknown>
48
+ /** Build the provider-native body from a common `LLMRequest`. */
49
+ readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
50
+ }
51
+
52
+ export interface ProtocolStream<Frame, Event, State> {
53
+ /** Schema for one decoded streaming event, decoded from a transport frame. */
54
+ readonly event: Schema.Codec<Event, Frame>
55
+ /** Initial parser state. Called once per response with the resolved request. */
56
+ readonly initial: (request: LLMRequest) => State
57
+ /** Translate one event into emitted `LLMEvent`s plus the next state. */
58
+ readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
59
+ /** Optional request-completion signal for transports that do not end naturally. */
60
+ readonly terminal?: (event: Event) => boolean
61
+ /** Optional flush emitted when the framed stream ends. */
62
+ readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
63
+ }
64
+
65
+ /**
66
+ * Construct a `Protocol` from its body and stream pieces:
67
+ *
68
+ * - `body.schema` infers the provider-native request body shape.
69
+ * - `body.from` ties the common `LLMRequest` to the provider body.
70
+ * - `stream.event` infers the decoded streaming event and the wire frame.
71
+ * - `stream.initial`, `stream.step`, and `stream.onHalt` infer the parser state.
72
+ *
73
+ * Provider implementations should usually call `Protocol.make({ ... })`
74
+ * without explicit type arguments; the schemas and parser functions are the
75
+ * source of truth. The constructor remains as the public seam for future
76
+ * cross-cutting concerns such as tracing or instrumentation.
77
+ */
78
+ export const make = <Body, Frame, Event, State>(
79
+ input: Protocol<Body, Frame, Event, State>,
80
+ ): Protocol<Body, Frame, Event, State> => input
81
+
82
+ export const jsonEvent = <const S extends Schema.Top>(schema: S) => Schema.fromJsonString(schema)
83
+
84
+ export * as Protocol from "./protocol"
@@ -0,0 +1,155 @@
1
+ import { Effect, Stream } from "effect"
2
+ import { Headers, HttpClientRequest } from "effect/unstable/http"
3
+ import { Auth } from "../auth"
4
+ import { render as renderEndpoint } from "../endpoint"
5
+ import { Framing, type Framing as FramingDef } from "../framing"
6
+ import type { Transport, TransportPrepareInput } from "./index"
7
+ import * as ProviderShared from "../../protocols/shared"
8
+ import { mergeJsonRecords, type LLMRequest } from "../../schema"
9
+
10
+ export type JsonRequestInput<Body> = TransportPrepareInput<Body>
11
+
12
+ export interface JsonRequestParts<Body = unknown> {
13
+ readonly url: string
14
+ readonly jsonBody: Body | Record<string, unknown>
15
+ readonly bodyText: string
16
+ readonly headers: Headers.Headers
17
+ }
18
+
19
+ export interface HttpPrepared<Frame> {
20
+ readonly request: HttpClientRequest.HttpClientRequest
21
+ readonly framing: FramingDef<Frame>
22
+ }
23
+
24
+ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
25
+ if (!query) return url
26
+ const next = new URL(url)
27
+ Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
28
+ return next.toString()
29
+ }
30
+
31
+ const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
32
+ "content",
33
+ "contents",
34
+ "frequencyPenalty",
35
+ "frequency_penalty",
36
+ "generationConfig",
37
+ "inferenceConfig",
38
+ "input",
39
+ "maxTokens",
40
+ "max_tokens",
41
+ "messages",
42
+ "model",
43
+ "presencePenalty",
44
+ "presence_penalty",
45
+ "responseFormat",
46
+ "response_format",
47
+ "seed",
48
+ "stop",
49
+ "stopSequences",
50
+ "stop_sequences",
51
+ "stream",
52
+ "streamOptions",
53
+ "stream_options",
54
+ "system",
55
+ "systemInstruction",
56
+ "system_instruction",
57
+ "temperature",
58
+ "thinking",
59
+ "toolChoice",
60
+ "toolConfig",
61
+ "tool_choice",
62
+ "tool_config",
63
+ "tools",
64
+ "topK",
65
+ "topP",
66
+ "top_k",
67
+ "top_p",
68
+ ])
69
+
70
+ const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
71
+ Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
72
+
73
+ const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
74
+ Effect.gen(function* () {
75
+ if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
76
+ const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
77
+ if (forbiddenKeys.length > 0)
78
+ return yield* ProviderShared.invalidRequest(
79
+ `http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
80
+ )
81
+ if (ProviderShared.isRecord(body)) {
82
+ const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
83
+ return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
84
+ }
85
+ return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies")
86
+ })
87
+
88
+ export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
89
+ Effect.gen(function* () {
90
+ const url = applyQuery(
91
+ renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
92
+ input.request.http?.query,
93
+ )
94
+ const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
95
+ const headers = yield* Auth.toEffect(input.auth)({
96
+ request: input.request,
97
+ method: "POST",
98
+ url,
99
+ body: body.bodyText,
100
+ headers: Headers.fromInput({
101
+ ...input.headers?.({ request: input.request }),
102
+ ...input.request.http?.headers,
103
+ }),
104
+ })
105
+ return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers }
106
+ })
107
+
108
+ export interface HttpJsonInput<_Body, Frame> {
109
+ readonly framing: FramingDef<Frame>
110
+ }
111
+
112
+ export type HttpJsonPatch<Body, Frame> = Partial<HttpJsonInput<Body, Frame>>
113
+
114
+ export interface HttpJsonTransport<Body, Frame> extends Transport<Body, HttpPrepared<Frame>, Frame> {
115
+ readonly with: (patch: HttpJsonPatch<Body, Frame>) => HttpJsonTransport<Body, Frame>
116
+ }
117
+
118
+ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJsonTransport<Body, Frame> => ({
119
+ id: "http-json",
120
+ with: (patch) => httpJson({ ...input, ...patch }),
121
+ prepare: (prepareInput) =>
122
+ jsonRequestParts({
123
+ ...prepareInput,
124
+ }).pipe(
125
+ Effect.map((parts) => ({
126
+ request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
127
+ framing: input.framing,
128
+ })),
129
+ ),
130
+ frames: (prepared, request, runtime) =>
131
+ Stream.unwrap(
132
+ runtime.http
133
+ .execute(prepared.request)
134
+ .pipe(
135
+ Effect.map((response) =>
136
+ prepared.framing.frame(
137
+ response.stream.pipe(
138
+ Stream.mapError((error) =>
139
+ ProviderShared.eventError(
140
+ `${request.model.provider}/${request.model.route.id}`,
141
+ `Failed to read ${request.model.provider}/${request.model.route.id} stream`,
142
+ ProviderShared.errorText(error),
143
+ ),
144
+ ),
145
+ ),
146
+ ),
147
+ ),
148
+ ),
149
+ ),
150
+ })
151
+
152
+ export const sseJson = {
153
+ id: "http-json/sse",
154
+ with: <Body>() => httpJson<Body, string>({ framing: Framing.sse }),
155
+ } as const