@codilore/llm 1.15.13

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 (145) hide show
  1. package/AGENTS.md +321 -0
  2. package/README.md +131 -0
  3. package/example/call-sites.md +591 -0
  4. package/example/tutorial.ts +255 -0
  5. package/package.json +50 -0
  6. package/script/recording-cost-report.ts +250 -0
  7. package/script/setup-recording-env.ts +542 -0
  8. package/src/cache-policy.ts +111 -0
  9. package/src/index.ts +32 -0
  10. package/src/llm.ts +186 -0
  11. package/src/protocols/anthropic-messages.ts +841 -0
  12. package/src/protocols/bedrock-converse.ts +649 -0
  13. package/src/protocols/bedrock-event-stream.ts +87 -0
  14. package/src/protocols/gemini.ts +465 -0
  15. package/src/protocols/index.ts +6 -0
  16. package/src/protocols/openai-chat.ts +431 -0
  17. package/src/protocols/openai-compatible-chat.ts +24 -0
  18. package/src/protocols/openai-responses.ts +987 -0
  19. package/src/protocols/shared.ts +283 -0
  20. package/src/protocols/utils/bedrock-auth.ts +70 -0
  21. package/src/protocols/utils/bedrock-cache.ts +37 -0
  22. package/src/protocols/utils/bedrock-media.ts +80 -0
  23. package/src/protocols/utils/cache.ts +16 -0
  24. package/src/protocols/utils/gemini-tool-schema.ts +101 -0
  25. package/src/protocols/utils/lifecycle.ts +102 -0
  26. package/src/protocols/utils/openai-options.ts +84 -0
  27. package/src/protocols/utils/tool-stream.ts +218 -0
  28. package/src/provider.ts +37 -0
  29. package/src/providers/amazon-bedrock.ts +43 -0
  30. package/src/providers/anthropic.ts +35 -0
  31. package/src/providers/azure.ts +110 -0
  32. package/src/providers/cloudflare.ts +127 -0
  33. package/src/providers/github-copilot.ts +66 -0
  34. package/src/providers/google.ts +35 -0
  35. package/src/providers/index.ts +11 -0
  36. package/src/providers/openai-compatible-profile.ts +20 -0
  37. package/src/providers/openai-compatible.ts +65 -0
  38. package/src/providers/openai-options.ts +81 -0
  39. package/src/providers/openai.ts +63 -0
  40. package/src/providers/openrouter.ts +98 -0
  41. package/src/providers/xai.ts +56 -0
  42. package/src/route/auth-options.ts +57 -0
  43. package/src/route/auth.ts +156 -0
  44. package/src/route/client.ts +434 -0
  45. package/src/route/endpoint.ts +53 -0
  46. package/src/route/executor.ts +374 -0
  47. package/src/route/framing.ts +27 -0
  48. package/src/route/index.ts +25 -0
  49. package/src/route/protocol.ts +84 -0
  50. package/src/route/transport/http.ts +108 -0
  51. package/src/route/transport/index.ts +33 -0
  52. package/src/route/transport/websocket.ts +280 -0
  53. package/src/schema/errors.ts +203 -0
  54. package/src/schema/events.ts +370 -0
  55. package/src/schema/ids.ts +43 -0
  56. package/src/schema/index.ts +5 -0
  57. package/src/schema/messages.ts +404 -0
  58. package/src/schema/options.ts +221 -0
  59. package/src/tool-runtime.ts +78 -0
  60. package/src/tool.ts +241 -0
  61. package/src/utils/record.ts +3 -0
  62. package/sst-env.d.ts +10 -0
  63. package/test/adapter.test.ts +164 -0
  64. package/test/auth-options.types.ts +168 -0
  65. package/test/auth.test.ts +103 -0
  66. package/test/cache-policy.test.ts +262 -0
  67. package/test/continuation-scenarios.ts +104 -0
  68. package/test/endpoint.test.ts +58 -0
  69. package/test/executor.test.ts +418 -0
  70. package/test/exports.test.ts +62 -0
  71. package/test/fixtures/media/restroom.png +0 -0
  72. package/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json +29 -0
  73. package/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json +43 -0
  74. package/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json +56 -0
  75. package/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json +29 -0
  76. package/test/fixtures/recordings/anthropic-messages/streams-text.json +29 -0
  77. package/test/fixtures/recordings/anthropic-messages/streams-tool-call.json +29 -0
  78. package/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json +48 -0
  79. package/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json +55 -0
  80. package/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json +29 -0
  81. package/test/fixtures/recordings/bedrock-converse/streams-text.json +29 -0
  82. package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
  83. package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json +32 -0
  84. package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
  85. package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json +32 -0
  86. package/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json +32 -0
  87. package/test/fixtures/recordings/gemini/streams-text.json +28 -0
  88. package/test/fixtures/recordings/gemini/streams-tool-call.json +28 -0
  89. package/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +46 -0
  90. package/test/fixtures/recordings/openai-chat/continues-after-tool-result.json +28 -0
  91. package/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json +46 -0
  92. package/test/fixtures/recordings/openai-chat/streams-text.json +28 -0
  93. package/test/fixtures/recordings/openai-chat/streams-tool-call.json +28 -0
  94. package/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json +28 -0
  95. package/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json +53 -0
  96. package/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json +28 -0
  97. package/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json +28 -0
  98. package/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json +54 -0
  99. package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json +53 -0
  100. package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json +54 -0
  101. package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json +28 -0
  102. package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json +28 -0
  103. package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json +28 -0
  104. package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json +28 -0
  105. package/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +54 -0
  106. package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json +28 -0
  107. package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +28 -0
  108. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +42 -0
  109. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +58 -0
  110. package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json +32 -0
  111. package/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json +46 -0
  112. package/test/generate-object.test.ts +184 -0
  113. package/test/lib/effect.ts +50 -0
  114. package/test/lib/http.ts +98 -0
  115. package/test/lib/openai-chunks.ts +27 -0
  116. package/test/lib/sse.ts +17 -0
  117. package/test/lib/tool-runtime.ts +146 -0
  118. package/test/llm.test.ts +167 -0
  119. package/test/provider/anthropic-messages-cache.recorded.test.ts +54 -0
  120. package/test/provider/anthropic-messages.recorded.test.ts +46 -0
  121. package/test/provider/anthropic-messages.test.ts +829 -0
  122. package/test/provider/bedrock-converse-cache.recorded.test.ts +54 -0
  123. package/test/provider/bedrock-converse.test.ts +707 -0
  124. package/test/provider/cloudflare.test.ts +230 -0
  125. package/test/provider/gemini-cache.recorded.test.ts +48 -0
  126. package/test/provider/gemini.test.ts +476 -0
  127. package/test/provider/golden.recorded.test.ts +219 -0
  128. package/test/provider/openai-chat.test.ts +446 -0
  129. package/test/provider/openai-compatible-chat.test.ts +238 -0
  130. package/test/provider/openai-responses-cache.recorded.test.ts +46 -0
  131. package/test/provider/openai-responses.test.ts +1322 -0
  132. package/test/provider/openrouter.test.ts +56 -0
  133. package/test/provider.types.ts +41 -0
  134. package/test/recorded-golden.ts +97 -0
  135. package/test/recorded-runner.ts +100 -0
  136. package/test/recorded-scenarios.ts +531 -0
  137. package/test/recorded-test.ts +74 -0
  138. package/test/recorded-utils.ts +56 -0
  139. package/test/recorded-websocket.ts +26 -0
  140. package/test/route.test.ts +43 -0
  141. package/test/schema.test.ts +97 -0
  142. package/test/tool-runtime.test.ts +802 -0
  143. package/test/tool-stream.test.ts +99 -0
  144. package/test/tool.types.ts +40 -0
  145. package/tsconfig.json +15 -0
@@ -0,0 +1,404 @@
1
+ import { Schema } from "effect"
2
+ import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
3
+ import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
4
+ import { isRecord } from "../utils/record"
5
+
6
+ const systemPartSchema = Schema.Struct({
7
+ type: Schema.Literal("text"),
8
+ text: Schema.String,
9
+ cache: Schema.optional(CacheHint),
10
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
11
+ }).annotate({ identifier: "LLM.SystemPart" })
12
+ export type SystemPart = Schema.Schema.Type<typeof systemPartSchema>
13
+
14
+ const makeSystemPart = (text: string): SystemPart => ({ type: "text", text })
15
+
16
+ export const SystemPart = Object.assign(systemPartSchema, {
17
+ make: makeSystemPart,
18
+ content: (input?: string | SystemPart | ReadonlyArray<SystemPart>) => {
19
+ if (input === undefined) return []
20
+ return typeof input === "string" ? [makeSystemPart(input)] : Array.isArray(input) ? [...input] : [input]
21
+ },
22
+ })
23
+
24
+ export const TextPart = Schema.Struct({
25
+ type: Schema.Literal("text"),
26
+ text: Schema.String,
27
+ cache: Schema.optional(CacheHint),
28
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
29
+ providerMetadata: Schema.optional(ProviderMetadata),
30
+ }).annotate({ identifier: "LLM.Content.Text" })
31
+ export type TextPart = Schema.Schema.Type<typeof TextPart>
32
+
33
+ export const MediaPart = Schema.Struct({
34
+ type: Schema.Literal("media"),
35
+ mediaType: Schema.String,
36
+ data: Schema.Union([Schema.String, Schema.Uint8Array]),
37
+ filename: Schema.optional(Schema.String),
38
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
39
+ }).annotate({ identifier: "LLM.Content.Media" })
40
+ export type MediaPart = Schema.Schema.Type<typeof MediaPart>
41
+
42
+ export const ToolResultMediaPart = Schema.Struct({
43
+ type: Schema.Literal("media"),
44
+ mediaType: Schema.String,
45
+ data: Schema.String,
46
+ filename: Schema.optional(Schema.String),
47
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
48
+ }).annotate({ identifier: "LLM.ToolResult.Media" })
49
+ export type ToolResultMediaPart = Schema.Schema.Type<typeof ToolResultMediaPart>
50
+
51
+ export const ToolResultContentPart = Schema.Union([TextPart, ToolResultMediaPart])
52
+ export type ToolResultContentPart = Schema.Schema.Type<typeof ToolResultContentPart>
53
+
54
+ export class ToolTextContent extends Schema.Class<ToolTextContent>("Tool.TextContent")({
55
+ type: Schema.Literal("text"),
56
+ text: Schema.String,
57
+ }) {}
58
+
59
+ export const ToolFileSource = Schema.Union([
60
+ Schema.Struct({ type: Schema.Literal("data"), data: Schema.String }),
61
+ Schema.Struct({ type: Schema.Literal("url"), url: Schema.String }),
62
+ Schema.Struct({ type: Schema.Literal("file"), uri: Schema.String }),
63
+ ]).pipe(Schema.toTaggedUnion("type"))
64
+ export type ToolFileSource = Schema.Schema.Type<typeof ToolFileSource>
65
+
66
+ export class ToolFileContent extends Schema.Class<ToolFileContent>("Tool.FileContent")({
67
+ type: Schema.Literal("file"),
68
+ source: ToolFileSource,
69
+ mime: Schema.String,
70
+ name: Schema.optional(Schema.String),
71
+ }) {}
72
+
73
+ /** Ordered, provider-independent content shown to models and UIs after a tool succeeds. */
74
+ export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type"))
75
+ export type ToolContent = Schema.Schema.Type<typeof ToolContent>
76
+
77
+ export const toolText = (value: ConstructorParameters<typeof ToolTextContent>[0]) => new ToolTextContent(value)
78
+ export const toolFile = (value: ConstructorParameters<typeof ToolFileContent>[0]) => new ToolFileContent(value)
79
+
80
+ const inlineData = (uri: string) => {
81
+ if (!uri.startsWith("data:")) return undefined
82
+ const match = /^data:[^;,]+;base64,(.*)$/s.exec(uri)
83
+ if (!match) throw new Error("Tool file data URI must contain raw base64 bytes")
84
+ return match[1]!
85
+ }
86
+
87
+ const legacyInlineData = (value: string) => {
88
+ const data = inlineData(value)
89
+ if (data !== undefined) return data
90
+ if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return value
91
+ throw new Error("Legacy tool-result media must contain raw base64 bytes or a base64 data URI")
92
+ }
93
+
94
+ /** Convert a legacy attachment URI without guessing unknown string semantics. */
95
+ export const toolFileSourceFromUri = (uri: string): ToolFileSource => {
96
+ const data = inlineData(uri)
97
+ if (data !== undefined) return { type: "data", data }
98
+ const url = URL.parse(uri)
99
+ if (url?.protocol === "file:") return { type: "file", uri }
100
+ if (url?.protocol === "http:" || url?.protocol === "https:") return { type: "url", url: uri }
101
+ throw new Error(`Unsupported tool file URI: ${uri}`)
102
+ }
103
+
104
+ const isToolResultValue = (value: unknown): value is ToolResultValue =>
105
+ isRecord(value) &&
106
+ (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
107
+ "value" in value
108
+
109
+ export const ToolResultValue = Object.assign(
110
+ Schema.Union([
111
+ Schema.Struct({
112
+ type: Schema.Literal("json"),
113
+ value: Schema.Unknown,
114
+ }),
115
+ Schema.Struct({
116
+ type: Schema.Literal("text"),
117
+ value: Schema.Unknown,
118
+ }),
119
+ Schema.Struct({
120
+ type: Schema.Literal("error"),
121
+ value: Schema.Unknown,
122
+ }),
123
+ Schema.Struct({
124
+ type: Schema.Literal("content"),
125
+ value: Schema.Array(ToolResultContentPart),
126
+ }),
127
+ ]).annotate({ identifier: "LLM.ToolResult" }),
128
+ {
129
+ is: isToolResultValue,
130
+ make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
131
+ if (isToolResultValue(value)) return value
132
+ if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
133
+ return { type, value }
134
+ },
135
+ },
136
+ )
137
+ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
138
+
139
+ export interface ToolOutput {
140
+ readonly structured: unknown
141
+ readonly content: ReadonlyArray<ToolContent>
142
+ }
143
+
144
+ export const ToolOutput = Object.assign(
145
+ Schema.Struct({
146
+ structured: Schema.Unknown,
147
+ content: Schema.Array(ToolContent),
148
+ }).annotate({ identifier: "LLM.ToolOutput" }),
149
+ {
150
+ make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({
151
+ structured,
152
+ content: content.map((item) =>
153
+ item.type === "text"
154
+ ? toolText({ type: "text", text: item.text })
155
+ : toolFile({ type: "file", source: item.source, mime: item.mime, name: item.name }),
156
+ ),
157
+ }),
158
+ fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
159
+ switch (result.type) {
160
+ case "json":
161
+ return { structured: result.value, content: [] }
162
+ case "text":
163
+ return { structured: {}, content: [toolText({ type: "text", text: toolResultText(result.value) })] }
164
+ case "content":
165
+ return {
166
+ structured: {},
167
+ content: result.value.map((item) =>
168
+ item.type === "text"
169
+ ? toolText({ type: "text", text: item.text })
170
+ : toolFile({
171
+ type: "file",
172
+ source: { type: "data", data: legacyInlineData(item.data) },
173
+ mime: item.mediaType,
174
+ name: item.filename,
175
+ }),
176
+ ),
177
+ }
178
+ case "error":
179
+ return undefined
180
+ }
181
+ },
182
+ toResultValue: (output: ToolOutput): ToolResultValue => {
183
+ if (output.content.length === 0) return { type: "json", value: output.structured }
184
+ if (output.content.length === 1 && output.content[0]?.type === "text")
185
+ return { type: "text", value: output.content[0].text }
186
+ const unsupported = output.content.find((item) => item.type === "file" && item.source.type !== "data")
187
+ if (unsupported?.type === "file")
188
+ return {
189
+ type: "error",
190
+ value: `Tool file source "${unsupported.source.type}" must be materialized to inline data before provider conversion`,
191
+ }
192
+ return {
193
+ type: "content",
194
+ value: output.content.map((item) => {
195
+ if (item.type === "text") return { type: "text", text: item.text }
196
+ if (item.source.type !== "data")
197
+ throw new Error("Unmaterialized tool file source reached provider conversion")
198
+ return { type: "media", mediaType: item.mime, data: item.source.data, filename: item.name }
199
+ }),
200
+ }
201
+ },
202
+ },
203
+ )
204
+
205
+ const toolResultText = (value: unknown) => {
206
+ if (typeof value === "string") return value
207
+ try {
208
+ return JSON.stringify(value) ?? String(value)
209
+ } catch {
210
+ return String(value)
211
+ }
212
+ }
213
+
214
+ export const ToolCallPart = Object.assign(
215
+ Schema.Struct({
216
+ type: Schema.Literal("tool-call"),
217
+ id: Schema.String,
218
+ name: Schema.String,
219
+ input: Schema.Unknown,
220
+ providerExecuted: Schema.optional(Schema.Boolean),
221
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
222
+ providerMetadata: Schema.optional(ProviderMetadata),
223
+ }).annotate({ identifier: "LLM.Content.ToolCall" }),
224
+ {
225
+ make: (input: Omit<ToolCallPart, "type">): ToolCallPart => ({ type: "tool-call", ...input }),
226
+ },
227
+ )
228
+ export type ToolCallPart = Schema.Schema.Type<typeof ToolCallPart>
229
+
230
+ export const ToolResultPart = Object.assign(
231
+ Schema.Struct({
232
+ type: Schema.Literal("tool-result"),
233
+ id: Schema.String,
234
+ name: Schema.String,
235
+ result: ToolResultValue,
236
+ providerExecuted: Schema.optional(Schema.Boolean),
237
+ cache: Schema.optional(CacheHint),
238
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
239
+ providerMetadata: Schema.optional(ProviderMetadata),
240
+ }).annotate({ identifier: "LLM.Content.ToolResult" }),
241
+ {
242
+ make: (
243
+ input: Omit<ToolResultPart, "type" | "result"> & {
244
+ readonly result: unknown
245
+ readonly resultType?: ToolResultValue["type"]
246
+ },
247
+ ): ToolResultPart => ({
248
+ type: "tool-result",
249
+ id: input.id,
250
+ name: input.name,
251
+ result: ToolResultValue.make(input.result, input.resultType),
252
+ providerExecuted: input.providerExecuted,
253
+ cache: input.cache,
254
+ metadata: input.metadata,
255
+ providerMetadata: input.providerMetadata,
256
+ }),
257
+ },
258
+ )
259
+ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
260
+
261
+ export const ReasoningPart = Schema.Struct({
262
+ type: Schema.Literal("reasoning"),
263
+ text: Schema.String,
264
+ encrypted: Schema.optional(Schema.String),
265
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
266
+ providerMetadata: Schema.optional(ProviderMetadata),
267
+ }).annotate({ identifier: "LLM.Content.Reasoning" })
268
+ export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
269
+
270
+ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
271
+ Schema.toTaggedUnion("type"),
272
+ )
273
+ export type ContentPart = Schema.Schema.Type<typeof ContentPart>
274
+
275
+ export class Message extends Schema.Class<Message>("LLM.Message")({
276
+ id: Schema.optional(Schema.String),
277
+ role: MessageRole,
278
+ content: Schema.Array(ContentPart),
279
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
280
+ native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
281
+ }) {}
282
+
283
+ export namespace Message {
284
+ export type ContentInput = string | ContentPart | ReadonlyArray<ContentPart>
285
+ export type SystemContentInput = string | TextPart | ReadonlyArray<TextPart>
286
+ export type Input = Omit<ConstructorParameters<typeof Message>[0], "content"> & {
287
+ readonly content: ContentInput
288
+ }
289
+
290
+ export const text = (value: string): ContentPart => ({ type: "text", text: value })
291
+
292
+ export const content = (input: ContentInput) =>
293
+ typeof input === "string" ? [text(input)] : Array.isArray(input) ? [...input] : [input]
294
+
295
+ export const make = (input: Message | Input) => {
296
+ if (input instanceof Message) return input
297
+ return new Message({ ...input, content: content(input.content) })
298
+ }
299
+
300
+ export const user = (content: ContentInput) => make({ role: "user", content })
301
+
302
+ export const assistant = (content: ContentInput) => make({ role: "assistant", content })
303
+
304
+ /**
305
+ * Add an operator-authored instruction at this chronological point in the
306
+ * conversation. This is distinct from the initial `LLMRequest.system`
307
+ * prompt. Keep raw retrieved, tool, and web content out of privileged system
308
+ * updates; pass that untrusted content through ordinary user/tool channels.
309
+ */
310
+ export const system = (content: SystemContentInput) => make({ role: "system", content })
311
+
312
+ export const tool = (result: ToolResultPart | Parameters<typeof ToolResultPart.make>[0]) =>
313
+ make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
314
+ }
315
+
316
+ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefinition")({
317
+ name: Schema.String,
318
+ description: Schema.String,
319
+ inputSchema: JsonSchema,
320
+ outputSchema: Schema.optional(JsonSchema),
321
+ cache: Schema.optional(CacheHint),
322
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
323
+ native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
324
+ }) {}
325
+
326
+ export namespace ToolDefinition {
327
+ export type Input = ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]
328
+
329
+ /** Normalize tool definition input into the canonical `ToolDefinition` class. */
330
+ export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input))
331
+ }
332
+
333
+ export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
334
+ type: Schema.Literals(["auto", "none", "required", "tool"]),
335
+ name: Schema.optional(Schema.String),
336
+ }) {}
337
+
338
+ export namespace ToolChoice {
339
+ export type Mode = Exclude<ToolChoice["type"], "tool">
340
+ export type Input = ToolChoice | ConstructorParameters<typeof ToolChoice>[0] | ToolDefinition | string
341
+
342
+ const isMode = (value: string): value is Mode => value === "auto" || value === "none" || value === "required"
343
+
344
+ /** Select a specific named tool. */
345
+ export const named = (value: string) => new ToolChoice({ type: "tool", name: value })
346
+
347
+ /** Normalize ergonomic tool-choice inputs into the canonical `ToolChoice` class. */
348
+ export const make = (input: Input) => {
349
+ if (input instanceof ToolChoice) return input
350
+ if (input instanceof ToolDefinition) return named(input.name)
351
+ if (typeof input === "string") return isMode(input) ? new ToolChoice({ type: input }) : named(input)
352
+ return new ToolChoice(input)
353
+ }
354
+ }
355
+
356
+ export const ResponseFormat = Schema.Union([
357
+ Schema.Struct({ type: Schema.Literal("text") }),
358
+ Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
359
+ Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }),
360
+ ]).pipe(Schema.toTaggedUnion("type"))
361
+ export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
362
+
363
+ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
364
+ id: Schema.optional(Schema.String),
365
+ model: ModelSchema,
366
+ system: Schema.Array(SystemPart),
367
+ messages: Schema.Array(Message),
368
+ tools: Schema.Array(ToolDefinition),
369
+ toolChoice: Schema.optional(ToolChoice),
370
+ generation: Schema.optional(GenerationOptions),
371
+ providerOptions: Schema.optional(ProviderOptions),
372
+ http: Schema.optional(HttpOptions),
373
+ responseFormat: Schema.optional(ResponseFormat),
374
+ cache: Schema.optional(CachePolicy),
375
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
376
+ }) {}
377
+
378
+ export namespace LLMRequest {
379
+ export type Input = ConstructorParameters<typeof LLMRequest>[0]
380
+
381
+ export const input = (request: LLMRequest): Input => ({
382
+ id: request.id,
383
+ model: request.model,
384
+ system: request.system,
385
+ messages: request.messages,
386
+ tools: request.tools,
387
+ toolChoice: request.toolChoice,
388
+ generation: request.generation,
389
+ providerOptions: request.providerOptions,
390
+ http: request.http,
391
+ responseFormat: request.responseFormat,
392
+ cache: request.cache,
393
+ metadata: request.metadata,
394
+ })
395
+
396
+ export const update = (request: LLMRequest, patch: Partial<Input>) => {
397
+ if (Object.keys(patch).length === 0) return request
398
+ return new LLMRequest({
399
+ ...input(request),
400
+ ...patch,
401
+ model: patch.model ?? request.model,
402
+ })
403
+ }
404
+ }
@@ -0,0 +1,221 @@
1
+ import { Schema } from "effect"
2
+ import { JsonSchema, ModelID, ProviderID } from "./ids"
3
+ import type { AnyRoute } from "../route/client"
4
+ import { isRecord } from "../utils/record"
5
+
6
+ export const mergeJsonRecords = (
7
+ ...items: ReadonlyArray<Record<string, unknown> | undefined>
8
+ ): Record<string, unknown> | undefined => {
9
+ const defined = items.filter((item): item is Record<string, unknown> => item !== undefined)
10
+ if (defined.length === 0) return undefined
11
+ if (defined.length === 1 && Object.values(defined[0]).every((value) => value !== undefined)) return defined[0]
12
+ const result: Record<string, unknown> = {}
13
+ for (const item of defined) {
14
+ for (const [key, value] of Object.entries(item)) {
15
+ if (value === undefined) continue
16
+ result[key] = isRecord(result[key]) && isRecord(value) ? mergeJsonRecords(result[key], value) : value
17
+ }
18
+ }
19
+ return Object.keys(result).length === 0 ? undefined : result
20
+ }
21
+
22
+ const mergeStringRecords = (
23
+ ...items: ReadonlyArray<Record<string, string> | undefined>
24
+ ): Record<string, string> | undefined => {
25
+ const defined = items.filter((item): item is Record<string, string> => item !== undefined)
26
+ if (defined.length === 0) return undefined
27
+ if (defined.length === 1) return defined[0]
28
+ const result = Object.fromEntries(
29
+ defined.flatMap((item) =>
30
+ Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined),
31
+ ),
32
+ )
33
+ return Object.keys(result).length === 0 ? undefined : result
34
+ }
35
+
36
+ export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))
37
+ export type ProviderOptions = Schema.Schema.Type<typeof ProviderOptions>
38
+
39
+ export const mergeProviderOptions = (
40
+ ...items: ReadonlyArray<ProviderOptions | undefined>
41
+ ): ProviderOptions | undefined => {
42
+ const result: Record<string, Record<string, unknown>> = {}
43
+ for (const item of items) {
44
+ if (!item) continue
45
+ for (const [provider, options] of Object.entries(item)) {
46
+ const merged = mergeJsonRecords(result[provider], options)
47
+ if (merged) result[provider] = merged
48
+ }
49
+ }
50
+ return Object.keys(result).length === 0 ? undefined : result
51
+ }
52
+
53
+ export class HttpOptions extends Schema.Class<HttpOptions>("LLM.HttpOptions")({
54
+ body: Schema.optional(JsonSchema),
55
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
56
+ query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
57
+ }) {}
58
+
59
+ export namespace HttpOptions {
60
+ export type Input = HttpOptions | ConstructorParameters<typeof HttpOptions>[0]
61
+
62
+ /** Normalize HTTP option input into the canonical `HttpOptions` class. */
63
+ export const make = (input: Input) => (input instanceof HttpOptions ? input : new HttpOptions(input))
64
+ }
65
+
66
+ export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined>): HttpOptions | undefined => {
67
+ const body = mergeJsonRecords(...items.map((item) => item?.body))
68
+ const headers = mergeStringRecords(...items.map((item) => item?.headers))
69
+ const query = mergeStringRecords(...items.map((item) => item?.query))
70
+ if (!body && !headers && !query) return undefined
71
+ return new HttpOptions({ body, headers, query })
72
+ }
73
+
74
+ export class GenerationOptions extends Schema.Class<GenerationOptions>("LLM.GenerationOptions")({
75
+ maxTokens: Schema.optional(Schema.Number),
76
+ temperature: Schema.optional(Schema.Number),
77
+ topP: Schema.optional(Schema.Number),
78
+ topK: Schema.optional(Schema.Number),
79
+ frequencyPenalty: Schema.optional(Schema.Number),
80
+ presencePenalty: Schema.optional(Schema.Number),
81
+ seed: Schema.optional(Schema.Number),
82
+ stop: Schema.optional(Schema.Array(Schema.String)),
83
+ }) {}
84
+
85
+ export namespace GenerationOptions {
86
+ export type Input = GenerationOptions | ConstructorParameters<typeof GenerationOptions>[0]
87
+
88
+ /** Normalize generation option input into the canonical `GenerationOptions` class. */
89
+ export const make = (input: Input = {}) => (input instanceof GenerationOptions ? input : new GenerationOptions(input))
90
+ }
91
+
92
+ export type GenerationOptionsFields = {
93
+ readonly maxTokens?: number
94
+ readonly temperature?: number
95
+ readonly topP?: number
96
+ readonly topK?: number
97
+ readonly frequencyPenalty?: number
98
+ readonly presencePenalty?: number
99
+ readonly seed?: number
100
+ readonly stop?: ReadonlyArray<string>
101
+ }
102
+
103
+ export type GenerationOptionsInput = GenerationOptions | GenerationOptionsFields
104
+
105
+ const latestGeneration = <Key extends keyof GenerationOptionsFields>(
106
+ items: ReadonlyArray<GenerationOptionsInput | undefined>,
107
+ key: Key,
108
+ ) => items.findLast((item) => item?.[key] !== undefined)?.[key]
109
+
110
+ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptionsInput | undefined>) => {
111
+ const result = new GenerationOptions({
112
+ maxTokens: latestGeneration(items, "maxTokens"),
113
+ temperature: latestGeneration(items, "temperature"),
114
+ topP: latestGeneration(items, "topP"),
115
+ topK: latestGeneration(items, "topK"),
116
+ frequencyPenalty: latestGeneration(items, "frequencyPenalty"),
117
+ presencePenalty: latestGeneration(items, "presencePenalty"),
118
+ seed: latestGeneration(items, "seed"),
119
+ stop: latestGeneration(items, "stop"),
120
+ })
121
+ return Object.values(result).some((value) => value !== undefined) ? result : undefined
122
+ }
123
+
124
+ export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
125
+ context: Schema.optional(Schema.Number),
126
+ output: Schema.optional(Schema.Number),
127
+ }) {}
128
+
129
+ export namespace ModelLimits {
130
+ export type Input = ModelLimits | ConstructorParameters<typeof ModelLimits>[0]
131
+
132
+ /** Normalize model limit input into the canonical `ModelLimits` class. */
133
+ export const make = (input: Input | undefined) =>
134
+ input instanceof ModelLimits ? input : new ModelLimits(input ?? {})
135
+ }
136
+
137
+ export class Model {
138
+ readonly id: ModelID
139
+ readonly provider: ProviderID
140
+ readonly route: AnyRoute
141
+
142
+ constructor(input: Model.ConstructorInput) {
143
+ this.id = input.id
144
+ this.provider = input.provider
145
+ this.route = input.route
146
+ }
147
+
148
+ static make(input: Model.Input) {
149
+ return new Model({
150
+ id: ModelID.make(input.id),
151
+ provider: ProviderID.make(input.provider),
152
+ route: input.route,
153
+ })
154
+ }
155
+
156
+ static input(model: Model): Model.ConstructorInput {
157
+ return {
158
+ id: model.id,
159
+ provider: model.provider,
160
+ route: model.route,
161
+ }
162
+ }
163
+
164
+ static update(model: Model, patch: Partial<Model.Input>) {
165
+ if (Object.keys(patch).length === 0) return model
166
+ return Model.make({
167
+ ...Model.input(model),
168
+ ...patch,
169
+ })
170
+ }
171
+ }
172
+
173
+ export namespace Model {
174
+ export type ConstructorInput = {
175
+ readonly id: ModelID
176
+ readonly provider: ProviderID
177
+ readonly route: AnyRoute
178
+ }
179
+
180
+ export type Input = Omit<ConstructorInput, "id" | "provider"> & {
181
+ readonly id: string | ModelID
182
+ readonly provider: string | ProviderID
183
+ }
184
+ }
185
+
186
+ export type ModelInput = Model.Input
187
+
188
+ export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
189
+
190
+ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
191
+ type: Schema.Literals(["ephemeral", "persistent"]),
192
+ ttlSeconds: Schema.optional(Schema.Number),
193
+ }) {}
194
+
195
+ // Auto-placement policy for prompt caching. The protocol-neutral lowering step
196
+ // reads this and injects `CacheHint`s at the configured boundaries; the
197
+ // per-protocol body builders then translate those hints into wire markers as
198
+ // usual. `"auto"` is the recommended default for agent loops — it places one
199
+ // breakpoint at the last tool definition, one at the last system part, and one
200
+ // at the latest user message. The combination of provider invalidation
201
+ // hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
202
+ // lookback means three trailing breakpoints reliably cover the static prefix.
203
+ //
204
+ // Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
205
+ // object form to override individual choices.
206
+ export const CachePolicyObject = Schema.Struct({
207
+ tools: Schema.optional(Schema.Boolean),
208
+ system: Schema.optional(Schema.Boolean),
209
+ messages: Schema.optional(
210
+ Schema.Union([
211
+ Schema.Literal("latest-user-message"),
212
+ Schema.Literal("latest-assistant"),
213
+ Schema.Struct({ tail: Schema.Number }),
214
+ ]),
215
+ ),
216
+ ttlSeconds: Schema.optional(Schema.Number),
217
+ })
218
+ export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
219
+
220
+ export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
221
+ export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>