@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,326 @@
1
+ import { Buffer } from "node:buffer"
2
+ import { Effect, Schema, Stream } from "effect"
3
+ import * as Sse from "effect/unstable/encoding/Sse"
4
+ import { Headers, HttpClientRequest } from "effect/unstable/http"
5
+ import {
6
+ InvalidProviderOutputReason,
7
+ InvalidRequestReason,
8
+ LLMError,
9
+ type ContentPart,
10
+ type LLMRequest,
11
+ type MediaPart,
12
+ type ToolFileContent,
13
+ type TextPart,
14
+ type ToolResultPart,
15
+ } from "../schema"
16
+ import { isRecord } from "../utils/record"
17
+ export { isRecord }
18
+
19
+ export const Json = Schema.fromJsonString(Schema.Unknown)
20
+ export const decodeJson = Schema.decodeUnknownSync(Json)
21
+ export const encodeJson = Schema.encodeSync(Json)
22
+ const isJson = Schema.is(Schema.Json)
23
+ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
24
+ export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
25
+ export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
26
+
27
+ /**
28
+ * Streaming tool-call accumulator. Adapters that build a tool call across
29
+ * multiple `tool-input-delta` chunks store the partial JSON input string here
30
+ * and finalize it with `parseToolInput` once the call completes.
31
+ */
32
+ export interface ToolAccumulator {
33
+ readonly id: string
34
+ readonly name: string
35
+ readonly input: string
36
+ }
37
+
38
+ /**
39
+ * `Usage.totalTokens` policy shared by every route. Honors a provider-
40
+ * supplied total; otherwise falls back to `inputTokens + outputTokens` only
41
+ * when at least one is defined. Returns `undefined` when neither input nor
42
+ * output is known so routes don't publish a misleading `0`.
43
+ *
44
+ * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
45
+ * are the non-cached input and visible output only. The provider-supplied
46
+ * `total` is the source of truth when present; the computed fallback
47
+ * under-counts cache and reasoning by design and exists mainly so
48
+ * Anthropic-style providers (which don't surface a total) still get a
49
+ * sensible aggregate on the input + output axes.
50
+ */
51
+ export const totalTokens = (
52
+ inputTokens: number | undefined,
53
+ outputTokens: number | undefined,
54
+ total: number | undefined,
55
+ ) => {
56
+ if (total !== undefined) return total
57
+ if (inputTokens === undefined && outputTokens === undefined) return undefined
58
+ return (inputTokens ?? 0) + (outputTokens ?? 0)
59
+ }
60
+
61
+ /**
62
+ * Subtract `subtrahend` from `total`, clamping to zero if the provider
63
+ * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`).
64
+ * Used by protocol mappers when deriving a non-overlapping breakdown field
65
+ * from a provider's inclusive total — `nonCachedInputTokens` from
66
+ * `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`.
67
+ *
68
+ * If `total` is `undefined`, returns `undefined` (we don't fabricate
69
+ * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
70
+ * provider-native breakdown stays available on `Usage.native` for debugging.
71
+ */
72
+ export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
73
+ if (total === undefined) return undefined
74
+ if (subtrahend === undefined) return total
75
+ return Math.max(0, total - subtrahend)
76
+ }
77
+
78
+ /**
79
+ * Sum a list of optional token counts, returning `undefined` only when
80
+ * every value is `undefined` (so we don't fabricate a `0`). Used by
81
+ * protocol mappers to derive the inclusive `inputTokens` total from a
82
+ * provider that natively reports a non-overlapping breakdown
83
+ * (e.g. Anthropic, whose `input_tokens` is already non-cached only).
84
+ */
85
+ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
86
+ if (values.every((value) => value === undefined)) return undefined
87
+ return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
88
+ }
89
+
90
+ export const eventError = (route: string, message: string, raw?: string) =>
91
+ new LLMError({
92
+ module: "ProviderShared",
93
+ method: "stream",
94
+ reason: new InvalidProviderOutputReason({ route, message, raw }),
95
+ })
96
+
97
+ export const parseJson = (route: string, input: string, message: string) =>
98
+ Effect.try({
99
+ try: () => decodeJson(input),
100
+ catch: () => eventError(route, message, input),
101
+ })
102
+
103
+ /**
104
+ * Join the `text` field of a list of parts with newlines. Used by routes
105
+ * that flatten system / message content arrays into a single provider string
106
+ * (OpenAI Chat `system` content, OpenAI Responses `system` content, Gemini
107
+ * `systemInstruction.parts[].text`).
108
+ */
109
+ export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n")
110
+
111
+ const escapeSystemUpdateText = (text: string) =>
112
+ text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
113
+
114
+ /**
115
+ * Stable fallback representation for chronological `Message.system(...)`
116
+ * updates on routes that do not support that privileged role natively. The
117
+ * wrapper remains visibly lower-authority user text, preserves the original
118
+ * temporal position, and XML-escapes content so it cannot close the wrapper.
119
+ */
120
+ export const wrapSystemUpdate = (parts: ReadonlyArray<{ readonly text: string }>) =>
121
+ `<system-update>\n${escapeSystemUpdateText(joinText(parts))}\n</system-update>`
122
+
123
+ /**
124
+ * Chronological system updates deliberately accept text only. Do not insert
125
+ * raw retrieved, tool, or web content into privileged updates: keep untrusted
126
+ * data in ordinary user/tool messages instead.
127
+ */
128
+ export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(function* (
129
+ route: string,
130
+ message: LLMRequest["messages"][number],
131
+ ) {
132
+ const content: TextPart[] = []
133
+ for (const part of message.content) {
134
+ if (!supportsContent(part, ["text"])) return yield* unsupportedContent(route, "system", ["text"])
135
+ content.push(part)
136
+ }
137
+ return content
138
+ })
139
+
140
+ /** Lower an unsupported privileged update into visible, in-order user text. */
141
+ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate")(function* (
142
+ route: string,
143
+ message: LLMRequest["messages"][number],
144
+ ) {
145
+ const content = yield* systemUpdateText(route, message)
146
+ return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache }
147
+ })
148
+
149
+ /**
150
+ * Parse the streamed JSON input of a tool call. Treats an empty string as
151
+ * `"{}"` — providers occasionally finish a tool call without ever emitting
152
+ * input deltas (e.g. zero-arg tools). The error message is uniform across
153
+ * routes: `Invalid JSON input for <route> tool call <name>`.
154
+ */
155
+ export const parseToolInput = (route: string, name: string, raw: string) =>
156
+ parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`)
157
+
158
+ export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
159
+ export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
160
+ export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
161
+ export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
162
+ export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
163
+ export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
164
+
165
+ const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
166
+
167
+ export interface ValidatedMedia {
168
+ readonly mime: string
169
+ readonly base64: string
170
+ readonly dataUrl: string
171
+ readonly bytes: Uint8Array
172
+ }
173
+
174
+ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function* (
175
+ route: string,
176
+ part: MediaPart,
177
+ supportedMimes: ReadonlySet<string>,
178
+ ) {
179
+ const mime = part.mediaType.toLowerCase()
180
+ if (!supportedMimes.has(mime)) return yield* invalidRequest(`${route} does not support media type ${part.mediaType}`)
181
+
182
+ let base64: string
183
+ if (typeof part.data !== "string") {
184
+ if (part.data.byteLength > MAX_MEDIA_DECODED_BYTES)
185
+ return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
186
+ base64 = Buffer.from(part.data).toString("base64")
187
+ } else if (part.data.startsWith("data:")) {
188
+ const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/s.exec(part.data)
189
+ if (!match) return yield* invalidRequest(`${route} media data URL must contain valid base64`)
190
+ if (match[1]!.toLowerCase() !== mime)
191
+ return yield* invalidRequest(`${route} media type ${part.mediaType} does not match data URL type ${match[1]}`)
192
+ base64 = match[2]!
193
+ } else {
194
+ base64 = part.data
195
+ }
196
+
197
+ if (Buffer.byteLength(base64, "utf8") > MAX_MEDIA_ENCODED_BYTES)
198
+ return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_ENCODED_BYTES} byte encoded limit`)
199
+ if (!base64 || base64.length % 4 !== 0 || !base64Pattern.test(base64))
200
+ return yield* invalidRequest(`${route} media must contain valid base64`)
201
+ const bytes = Buffer.from(base64, "base64")
202
+ if (bytes.byteLength > MAX_MEDIA_DECODED_BYTES)
203
+ return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
204
+ if (bytes.toString("base64") !== base64) return yield* invalidRequest(`${route} media must contain canonical base64`)
205
+ return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
206
+ })
207
+
208
+ export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
209
+ validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
210
+
211
+ export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
212
+
213
+ export const toolResultText = (part: ToolResultPart) => {
214
+ if (part.result.type === "text") return String(part.result.value)
215
+ if (part.result.type === "error") {
216
+ const value = part.result.value
217
+ const prototype =
218
+ typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value)
219
+ const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null
220
+ return structured && isJson(value) ? encodeJson(value) : String(value)
221
+ }
222
+ return encodeJson(part.result.value)
223
+ }
224
+
225
+ export const errorText = (error: unknown) => {
226
+ if (error instanceof Error) return error.message
227
+ if (typeof error === "string") return error
228
+ if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return String(error)
229
+ if (error === null) return "null"
230
+ if (error === undefined) return "undefined"
231
+ return "Unknown stream error"
232
+ }
233
+
234
+ /**
235
+ * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
236
+ * decoder, and drops empty / `[DONE]` keep-alive events so the downstream
237
+ * `decodeChunk` sees one JSON string per element. The SSE channel emits a
238
+ * `Retry` control event on its error channel; we drop it here (we don't
239
+ * implement client-driven retries) so the public error channel stays
240
+ * `LLMError`.
241
+ */
242
+ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> =>
243
+ bytes.pipe(
244
+ Stream.decodeText(),
245
+ Stream.pipeThroughChannel(Sse.decode()),
246
+ Stream.catchTag("Retry", () => Stream.empty),
247
+ Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
248
+ Stream.map((event) => event.data),
249
+ )
250
+
251
+ /**
252
+ * Canonical invalid-request constructor. Lift one-line `const invalid =
253
+ * (message) => invalidRequest(message)` aliases out of every
254
+ * route so the error constructor lives in one place. If we ever extend
255
+ * `InvalidRequestReason` with route context or trace metadata, the change
256
+ * lands here.
257
+ */
258
+ export const invalidRequest = (message: string) =>
259
+ new LLMError({
260
+ module: "ProviderShared",
261
+ method: "request",
262
+ reason: new InvalidRequestReason({ message }),
263
+ })
264
+
265
+ export const matchToolChoice = <Auto, None, Required, Tool>(
266
+ route: string,
267
+ toolChoice: NonNullable<LLMRequest["toolChoice"]>,
268
+ cases: {
269
+ readonly auto: () => Auto
270
+ readonly none: () => None
271
+ readonly required: () => Required
272
+ readonly tool: (name: string) => Tool
273
+ },
274
+ ) =>
275
+ Effect.gen(function* () {
276
+ if (toolChoice.type === "auto") return cases.auto()
277
+ if (toolChoice.type === "none") return cases.none()
278
+ if (toolChoice.type === "required") return cases.required()
279
+ if (!toolChoice.name) return yield* invalidRequest(`${route} tool choice requires a tool name`)
280
+ return cases.tool(toolChoice.name)
281
+ })
282
+
283
+ type ContentType = ContentPart["type"]
284
+
285
+ const formatContentTypes = (types: ReadonlyArray<ContentType>) => {
286
+ if (types.length <= 1) return types[0] ?? ""
287
+ if (types.length === 2) return `${types[0]} and ${types[1]}`
288
+ return `${types.slice(0, -1).join(", ")}, and ${types.at(-1)}`
289
+ }
290
+
291
+ export const supportsContent = <const Type extends ContentType>(
292
+ part: ContentPart,
293
+ types: ReadonlyArray<Type>,
294
+ ): part is Extract<ContentPart, { readonly type: Type }> => (types as ReadonlyArray<ContentType>).includes(part.type)
295
+
296
+ export const unsupportedContent = (
297
+ route: string,
298
+ role: LLMRequest["messages"][number]["role"],
299
+ types: ReadonlyArray<ContentType>,
300
+ ) => invalidRequest(`${route} ${role} messages only support ${formatContentTypes(types)} content for now`)
301
+
302
+ /**
303
+ * Build a `validate` step from a Schema decoder. Replaces the per-route
304
+ * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
305
+ * invalid(e.message)))`. Any decode error is translated into
306
+ * `LLMError` carrying the original parse-error message.
307
+ */
308
+ export const validateWith =
309
+ <A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
310
+ (payload: I) =>
311
+ decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message)))
312
+
313
+ /**
314
+ * Build an HTTP POST with a JSON body. Sets `content-type: application/json`
315
+ * automatically after caller-supplied headers so routes cannot accidentally
316
+ * send JSON with a stale content type. The body is passed pre-encoded so
317
+ * routes can choose between
318
+ * `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`.
319
+ */
320
+ export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) =>
321
+ HttpClientRequest.post(input.url).pipe(
322
+ HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")),
323
+ HttpClientRequest.bodyText(input.body, "application/json"),
324
+ )
325
+
326
+ export * as ProviderShared from "./shared"
@@ -0,0 +1,70 @@
1
+ import { AwsV4Signer } from "aws4fetch"
2
+ import { Effect } from "effect"
3
+ import { Headers } from "effect/unstable/http"
4
+ import { Auth, type AuthInput } from "../../route/auth"
5
+ import { ProviderShared } from "../shared"
6
+
7
+ /**
8
+ * AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
9
+ * which provider facades configure as route auth instead of SigV4. STS-vended
10
+ * credentials should be refreshed by the consumer (rebuild the model) before
11
+ * they expire; the route does not refresh.
12
+ */
13
+ export interface Credentials {
14
+ readonly region: string
15
+ readonly accessKeyId: string
16
+ readonly secretAccessKey: string
17
+ readonly sessionToken?: string
18
+ }
19
+
20
+ const signRequest = (input: {
21
+ readonly url: string
22
+ readonly body: string
23
+ readonly headers: Headers.Headers
24
+ readonly credentials: Credentials
25
+ }) =>
26
+ Effect.tryPromise({
27
+ try: async () => {
28
+ const signed = await new AwsV4Signer({
29
+ url: input.url,
30
+ method: "POST",
31
+ headers: Object.entries(input.headers),
32
+ body: input.body,
33
+ region: input.credentials.region,
34
+ accessKeyId: input.credentials.accessKeyId,
35
+ secretAccessKey: input.credentials.secretAccessKey,
36
+ sessionToken: input.credentials.sessionToken,
37
+ service: "bedrock",
38
+ }).sign()
39
+ return Object.fromEntries(signed.headers.entries())
40
+ },
41
+ catch: (error) =>
42
+ ProviderShared.invalidRequest(
43
+ `Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
44
+ ),
45
+ })
46
+
47
+ /** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
48
+ export const sigV4 = (credentials: Credentials | undefined) =>
49
+ Auth.custom((input: AuthInput) => {
50
+ return Effect.gen(function* () {
51
+ if (!credentials) {
52
+ return yield* ProviderShared.invalidRequest(
53
+ "Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
54
+ )
55
+ }
56
+ const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
57
+ const signed = yield* signRequest({
58
+ url: input.url,
59
+ body: input.body,
60
+ headers: headersForSigning,
61
+ credentials,
62
+ })
63
+ return Headers.setAll(headersForSigning, signed)
64
+ })
65
+ })
66
+
67
+ /** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
68
+ export const auth = sigV4(undefined)
69
+
70
+ export * as BedrockAuth from "./bedrock-auth"
@@ -0,0 +1,37 @@
1
+ import { Schema } from "effect"
2
+ import type { CacheHint } from "../../schema"
3
+ import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache"
4
+
5
+ // Bedrock cache markers are positional: emit a `cachePoint` block immediately
6
+ // after the content the caller wants treated as a cacheable prefix. Bedrock
7
+ // accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic.
8
+ export const CachePointBlock = Schema.Struct({
9
+ cachePoint: Schema.Struct({
10
+ type: Schema.tag("default"),
11
+ ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
12
+ }),
13
+ })
14
+ export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
15
+
16
+ // Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages
17
+ // API. Callers pass a shared counter through every `block()` call site so the
18
+ // budget is respected across `system`, `messages`, and `tools`.
19
+ export const BEDROCK_BREAKPOINT_CAP = 4
20
+
21
+ export type { Breakpoints } from "./cache"
22
+ export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
23
+
24
+ const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
25
+ const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
26
+
27
+ export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
28
+ if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
29
+ if (breakpoints.remaining <= 0) {
30
+ breakpoints.dropped += 1
31
+ return undefined
32
+ }
33
+ breakpoints.remaining -= 1
34
+ return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
35
+ }
36
+
37
+ export * as BedrockCache from "./bedrock-cache"
@@ -0,0 +1,90 @@
1
+ import { Effect, Schema } from "effect"
2
+ import type { MediaPart } from "../../schema"
3
+ import { ProviderShared } from "../shared"
4
+
5
+ // Bedrock Converse accepts image `format` as the file extension and
6
+ // `source.bytes` as base64 in the JSON wire format.
7
+ export const ImageFormat = Schema.Literals(["png", "jpeg", "gif", "webp"])
8
+ export type ImageFormat = Schema.Schema.Type<typeof ImageFormat>
9
+
10
+ export const ImageBlock = Schema.Struct({
11
+ image: Schema.Struct({
12
+ format: ImageFormat,
13
+ source: Schema.Struct({ bytes: Schema.String }),
14
+ }),
15
+ })
16
+ export type ImageBlock = Schema.Schema.Type<typeof ImageBlock>
17
+
18
+ // Bedrock document blocks require a user-facing name so the model can refer to
19
+ // the uploaded document.
20
+ export const DocumentFormat = Schema.Literals(["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"])
21
+ export type DocumentFormat = Schema.Schema.Type<typeof DocumentFormat>
22
+
23
+ export const DocumentBlock = Schema.Struct({
24
+ document: Schema.Struct({
25
+ format: DocumentFormat,
26
+ name: Schema.String,
27
+ source: Schema.Struct({ bytes: Schema.String }),
28
+ }),
29
+ })
30
+ export type DocumentBlock = Schema.Schema.Type<typeof DocumentBlock>
31
+
32
+ const IMAGE_FORMATS = {
33
+ "image/png": "png",
34
+ "image/jpeg": "jpeg",
35
+ "image/jpg": "jpeg",
36
+ "image/gif": "gif",
37
+ "image/webp": "webp",
38
+ } as const satisfies Record<string, ImageFormat>
39
+
40
+ const DOCUMENT_FORMATS = {
41
+ "application/pdf": "pdf",
42
+ "text/csv": "csv",
43
+ "application/msword": "doc",
44
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
45
+ "application/vnd.ms-excel": "xls",
46
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
47
+ "text/html": "html",
48
+ "text/plain": "txt",
49
+ "text/markdown": "md",
50
+ } as const satisfies Record<string, DocumentFormat>
51
+
52
+ const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
53
+ document: {
54
+ format,
55
+ name: part.filename ?? `document.${format}`,
56
+ source: { bytes },
57
+ },
58
+ })
59
+
60
+ // Route by MIME. Known image/document formats lower into a typed block; anything
61
+ // else fails with a clear error instead of silently degrading to a malformed
62
+ // document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
63
+ // get an image-specific error so the caller knows it's a format-support issue,
64
+ // not a kind-detection issue.
65
+ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) {
66
+ const mime = part.mediaType.toLowerCase()
67
+ const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
68
+ if (imageFormat) {
69
+ const media = yield* ProviderShared.validateMedia(
70
+ "Bedrock Converse",
71
+ part,
72
+ new Set<string>(Object.keys(IMAGE_FORMATS)),
73
+ )
74
+ return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
75
+ }
76
+ if (mime.startsWith("image/"))
77
+ return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
78
+ const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
79
+ if (documentFormat) {
80
+ const media = yield* ProviderShared.validateMedia(
81
+ "Bedrock Converse",
82
+ part,
83
+ new Set<string>(Object.keys(DOCUMENT_FORMATS)),
84
+ )
85
+ return documentBlock(part, documentFormat, media.base64)
86
+ }
87
+ return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
88
+ })
89
+
90
+ export * as BedrockMedia from "./bedrock-media"
@@ -0,0 +1,16 @@
1
+ // Shared helpers for provider cache-marker lowering. Anthropic and Bedrock
2
+ // both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h`
3
+ // TTL buckets, so the counter and TTL mapping live here.
4
+
5
+ export interface Breakpoints {
6
+ remaining: number
7
+ dropped: number
8
+ }
9
+
10
+ export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 })
11
+
12
+ // Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the
13
+ // provider default 5m). Anthropic & Bedrock both treat anything shorter than
14
+ // an hour as 5m.
15
+ export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
16
+ ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
@@ -0,0 +1,99 @@
1
+ import { isRecord } from "../../utils/record"
2
+
3
+ // Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a
4
+ // handful of common JSON Schema shapes. Keep this projection isolated so the
5
+ // Gemini protocol file still reads like the other protocol modules.
6
+ const SCHEMA_INTENT_KEYS = [
7
+ "type",
8
+ "properties",
9
+ "items",
10
+ "prefixItems",
11
+ "enum",
12
+ "const",
13
+ "$ref",
14
+ "additionalProperties",
15
+ "patternProperties",
16
+ "required",
17
+ "not",
18
+ "if",
19
+ "then",
20
+ "else",
21
+ ]
22
+
23
+ const hasCombiner = (schema: unknown) =>
24
+ isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf))
25
+
26
+ const hasSchemaIntent = (schema: unknown) =>
27
+ isRecord(schema) && (hasCombiner(schema) || SCHEMA_INTENT_KEYS.some((key) => key in schema))
28
+
29
+ const sanitizeNode = (schema: unknown): unknown => {
30
+ if (!isRecord(schema)) return Array.isArray(schema) ? schema.map(sanitizeNode) : schema
31
+
32
+ const result: Record<string, unknown> = Object.fromEntries(
33
+ Object.entries(schema).map(([key, value]) => [
34
+ key,
35
+ key === "enum" && Array.isArray(value) ? value.map(String) : sanitizeNode(value),
36
+ ]),
37
+ )
38
+
39
+ if (Array.isArray(result.enum) && (result.type === "integer" || result.type === "number")) result.type = "string"
40
+
41
+ const properties = result.properties
42
+ if (result.type === "object" && isRecord(properties) && Array.isArray(result.required)) {
43
+ result.required = result.required.filter((field) => typeof field === "string" && field in properties)
44
+ }
45
+
46
+ if (result.type === "array" && !hasCombiner(result)) {
47
+ result.items = result.items ?? {}
48
+ if (isRecord(result.items) && !hasSchemaIntent(result.items)) result.items = { ...result.items, type: "string" }
49
+ }
50
+
51
+ if (typeof result.type === "string" && result.type !== "object" && !hasCombiner(result)) {
52
+ delete result.properties
53
+ delete result.required
54
+ }
55
+
56
+ return result
57
+ }
58
+
59
+ const emptyObjectSchema = (schema: Record<string, unknown>) =>
60
+ schema.type === "object" &&
61
+ (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
62
+ !schema.additionalProperties
63
+
64
+ const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
65
+ if (!isRecord(schema)) return undefined
66
+ if (emptyObjectSchema(schema)) return undefined
67
+ return Object.fromEntries(
68
+ [
69
+ ["description", schema.description],
70
+ ["required", schema.required],
71
+ ["format", schema.format],
72
+ ["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
73
+ ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
74
+ ["enum", schema.const !== undefined ? [schema.const] : schema.enum],
75
+ [
76
+ "properties",
77
+ isRecord(schema.properties)
78
+ ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
79
+ : undefined,
80
+ ],
81
+ [
82
+ "items",
83
+ Array.isArray(schema.items)
84
+ ? schema.items.map(projectNode)
85
+ : schema.items === undefined
86
+ ? undefined
87
+ : projectNode(schema.items),
88
+ ],
89
+ ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
90
+ ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
91
+ ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
92
+ ["minLength", schema.minLength],
93
+ ].filter((entry) => entry[1] !== undefined),
94
+ )
95
+ }
96
+
97
+ export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
98
+
99
+ export * as GeminiToolSchema from "./gemini-tool-schema"