@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,312 @@
1
+ import { Schema } from "effect"
2
+ import { ToolContent, ToolFileContent, ToolTextContent } from "@neurocode-ai/schema/llm"
3
+ import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
4
+ import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
5
+ import { isRecord } from "../utils/record"
6
+
7
+ const systemPartSchema = Schema.Struct({
8
+ type: Schema.Literal("text"),
9
+ text: Schema.String,
10
+ cache: Schema.optional(CacheHint),
11
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
12
+ }).annotate({ identifier: "LLM.SystemPart" })
13
+ export type SystemPart = Schema.Schema.Type<typeof systemPartSchema>
14
+
15
+ const makeSystemPart = (text: string): SystemPart => ({ type: "text", text })
16
+
17
+ export const SystemPart = Object.assign(systemPartSchema, {
18
+ make: makeSystemPart,
19
+ content: (input?: string | SystemPart | ReadonlyArray<SystemPart>) => {
20
+ if (input === undefined) return []
21
+ return typeof input === "string" ? [makeSystemPart(input)] : Array.isArray(input) ? [...input] : [input]
22
+ },
23
+ })
24
+
25
+ export const TextPart = Schema.Struct({
26
+ type: Schema.Literal("text"),
27
+ text: Schema.String,
28
+ cache: Schema.optional(CacheHint),
29
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
30
+ providerMetadata: Schema.optional(ProviderMetadata),
31
+ }).annotate({ identifier: "LLM.Content.Text" })
32
+ export type TextPart = Schema.Schema.Type<typeof TextPart>
33
+
34
+ export const MediaPart = Schema.Struct({
35
+ type: Schema.Literal("media"),
36
+ mediaType: Schema.String,
37
+ data: Schema.Union([Schema.String, Schema.Uint8Array]),
38
+ filename: Schema.optional(Schema.String),
39
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
40
+ }).annotate({ identifier: "LLM.Content.Media" })
41
+ export type MediaPart = Schema.Schema.Type<typeof MediaPart>
42
+
43
+ export { ToolContent, ToolFileContent, ToolTextContent }
44
+
45
+ const isToolResultValue = (value: unknown): value is ToolResultValue =>
46
+ isRecord(value) &&
47
+ (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
48
+ "value" in value
49
+
50
+ export const ToolResultValue = Object.assign(
51
+ Schema.Union([
52
+ Schema.Struct({
53
+ type: Schema.Literal("json"),
54
+ value: Schema.Unknown,
55
+ }),
56
+ Schema.Struct({
57
+ type: Schema.Literal("text"),
58
+ value: Schema.Unknown,
59
+ }),
60
+ Schema.Struct({
61
+ type: Schema.Literal("error"),
62
+ value: Schema.Unknown,
63
+ }),
64
+ Schema.Struct({
65
+ type: Schema.Literal("content"),
66
+ value: Schema.Array(ToolContent),
67
+ }),
68
+ ]).annotate({ identifier: "LLM.ToolResult" }),
69
+ {
70
+ is: isToolResultValue,
71
+ make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
72
+ if (isToolResultValue(value)) return value
73
+ if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
74
+ return { type, value }
75
+ },
76
+ },
77
+ )
78
+ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
79
+
80
+ export interface ToolOutput {
81
+ readonly structured: unknown
82
+ readonly content: ReadonlyArray<ToolContent>
83
+ }
84
+
85
+ export const ToolOutput = Object.assign(
86
+ Schema.Struct({
87
+ structured: Schema.Unknown,
88
+ content: Schema.Array(ToolContent),
89
+ }).annotate({ identifier: "LLM.ToolOutput" }),
90
+ {
91
+ make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
92
+ fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
93
+ switch (result.type) {
94
+ case "json":
95
+ return { structured: result.value, content: [] }
96
+ case "text":
97
+ return { structured: {}, content: [{ type: "text", text: toolResultText(result.value) }] }
98
+ case "content":
99
+ return { structured: {}, content: result.value }
100
+ case "error":
101
+ return undefined
102
+ }
103
+ },
104
+ toResultValue: (output: ToolOutput): ToolResultValue => {
105
+ if (output.content.length === 0) return { type: "json", value: output.structured }
106
+ if (output.content.length === 1 && output.content[0]?.type === "text")
107
+ return { type: "text", value: output.content[0].text }
108
+ return { type: "content", value: output.content }
109
+ },
110
+ },
111
+ )
112
+
113
+ const toolResultText = (value: unknown) => {
114
+ if (typeof value === "string") return value
115
+ try {
116
+ return JSON.stringify(value) ?? String(value)
117
+ } catch {
118
+ return String(value)
119
+ }
120
+ }
121
+
122
+ export const ToolCallPart = Object.assign(
123
+ Schema.Struct({
124
+ type: Schema.Literal("tool-call"),
125
+ id: Schema.String,
126
+ name: Schema.String,
127
+ input: Schema.Unknown,
128
+ providerExecuted: Schema.optional(Schema.Boolean),
129
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
130
+ providerMetadata: Schema.optional(ProviderMetadata),
131
+ }).annotate({ identifier: "LLM.Content.ToolCall" }),
132
+ {
133
+ make: (input: Omit<ToolCallPart, "type">): ToolCallPart => ({ type: "tool-call", ...input }),
134
+ },
135
+ )
136
+ export type ToolCallPart = Schema.Schema.Type<typeof ToolCallPart>
137
+
138
+ export const ToolResultPart = Object.assign(
139
+ Schema.Struct({
140
+ type: Schema.Literal("tool-result"),
141
+ id: Schema.String,
142
+ name: Schema.String,
143
+ result: ToolResultValue,
144
+ providerExecuted: Schema.optional(Schema.Boolean),
145
+ cache: Schema.optional(CacheHint),
146
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
147
+ providerMetadata: Schema.optional(ProviderMetadata),
148
+ }).annotate({ identifier: "LLM.Content.ToolResult" }),
149
+ {
150
+ make: (
151
+ input: Omit<ToolResultPart, "type" | "result"> & {
152
+ readonly result: unknown
153
+ readonly resultType?: ToolResultValue["type"]
154
+ },
155
+ ): ToolResultPart => ({
156
+ type: "tool-result",
157
+ id: input.id,
158
+ name: input.name,
159
+ result: ToolResultValue.make(input.result, input.resultType),
160
+ providerExecuted: input.providerExecuted,
161
+ cache: input.cache,
162
+ metadata: input.metadata,
163
+ providerMetadata: input.providerMetadata,
164
+ }),
165
+ },
166
+ )
167
+ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
168
+
169
+ export const ReasoningPart = Schema.Struct({
170
+ type: Schema.Literal("reasoning"),
171
+ text: Schema.String,
172
+ encrypted: Schema.optional(Schema.String),
173
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
174
+ providerMetadata: Schema.optional(ProviderMetadata),
175
+ }).annotate({ identifier: "LLM.Content.Reasoning" })
176
+ export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
177
+
178
+ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
179
+ Schema.toTaggedUnion("type"),
180
+ )
181
+ export type ContentPart = Schema.Schema.Type<typeof ContentPart>
182
+
183
+ export class Message extends Schema.Class<Message>("LLM.Message")({
184
+ id: Schema.optional(Schema.String),
185
+ role: MessageRole,
186
+ content: Schema.Array(ContentPart),
187
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
188
+ native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
189
+ }) {}
190
+
191
+ export namespace Message {
192
+ export type ContentInput = string | ContentPart | ReadonlyArray<ContentPart>
193
+ export type SystemContentInput = string | TextPart | ReadonlyArray<TextPart>
194
+ export type Input = Omit<ConstructorParameters<typeof Message>[0], "content"> & {
195
+ readonly content: ContentInput
196
+ }
197
+
198
+ export const text = (value: string): ContentPart => ({ type: "text", text: value })
199
+
200
+ export const content = (input: ContentInput) =>
201
+ typeof input === "string" ? [text(input)] : Array.isArray(input) ? [...input] : [input]
202
+
203
+ export const make = (input: Message | Input) => {
204
+ if (input instanceof Message) return input
205
+ return new Message({ ...input, content: content(input.content) })
206
+ }
207
+
208
+ export const user = (content: ContentInput) => make({ role: "user", content })
209
+
210
+ export const assistant = (content: ContentInput) => make({ role: "assistant", content })
211
+
212
+ /**
213
+ * Add an operator-authored instruction at this chronological point in the
214
+ * conversation. This is distinct from the initial `LLMRequest.system`
215
+ * prompt. Keep raw retrieved, tool, and web content out of privileged system
216
+ * updates; pass that untrusted content through ordinary user/tool channels.
217
+ */
218
+ export const system = (content: SystemContentInput) => make({ role: "system", content })
219
+
220
+ export const tool = (result: ToolResultPart | Parameters<typeof ToolResultPart.make>[0]) =>
221
+ make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
222
+ }
223
+
224
+ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefinition")({
225
+ name: Schema.String,
226
+ description: Schema.String,
227
+ inputSchema: JsonSchema,
228
+ outputSchema: Schema.optional(JsonSchema),
229
+ cache: Schema.optional(CacheHint),
230
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
231
+ native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
232
+ }) {}
233
+
234
+ export namespace ToolDefinition {
235
+ export type Input = ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]
236
+
237
+ /** Normalize tool definition input into the canonical `ToolDefinition` class. */
238
+ export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input))
239
+ }
240
+
241
+ export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
242
+ type: Schema.Literals(["auto", "none", "required", "tool"]),
243
+ name: Schema.optional(Schema.String),
244
+ }) {}
245
+
246
+ export namespace ToolChoice {
247
+ export type Mode = Exclude<ToolChoice["type"], "tool">
248
+ export type Input = ToolChoice | ConstructorParameters<typeof ToolChoice>[0] | ToolDefinition | string
249
+
250
+ const isMode = (value: string): value is Mode => value === "auto" || value === "none" || value === "required"
251
+
252
+ /** Select a specific named tool. */
253
+ export const named = (value: string) => new ToolChoice({ type: "tool", name: value })
254
+
255
+ /** Normalize ergonomic tool-choice inputs into the canonical `ToolChoice` class. */
256
+ export const make = (input: Input) => {
257
+ if (input instanceof ToolChoice) return input
258
+ if (input instanceof ToolDefinition) return named(input.name)
259
+ if (typeof input === "string") return isMode(input) ? new ToolChoice({ type: input }) : named(input)
260
+ return new ToolChoice(input)
261
+ }
262
+ }
263
+
264
+ export const ResponseFormat = Schema.Union([
265
+ Schema.Struct({ type: Schema.Literal("text") }),
266
+ Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
267
+ Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }),
268
+ ]).pipe(Schema.toTaggedUnion("type"))
269
+ export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
270
+
271
+ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
272
+ id: Schema.optional(Schema.String),
273
+ model: ModelSchema,
274
+ system: Schema.Array(SystemPart),
275
+ messages: Schema.Array(Message),
276
+ tools: Schema.Array(ToolDefinition),
277
+ toolChoice: Schema.optional(ToolChoice),
278
+ generation: Schema.optional(GenerationOptions),
279
+ providerOptions: Schema.optional(ProviderOptions),
280
+ http: Schema.optional(HttpOptions),
281
+ responseFormat: Schema.optional(ResponseFormat),
282
+ cache: Schema.optional(CachePolicy),
283
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
284
+ }) {}
285
+
286
+ export namespace LLMRequest {
287
+ export type Input = ConstructorParameters<typeof LLMRequest>[0]
288
+
289
+ export const input = (request: LLMRequest): Input => ({
290
+ id: request.id,
291
+ model: request.model,
292
+ system: request.system,
293
+ messages: request.messages,
294
+ tools: request.tools,
295
+ toolChoice: request.toolChoice,
296
+ generation: request.generation,
297
+ providerOptions: request.providerOptions,
298
+ http: request.http,
299
+ responseFormat: request.responseFormat,
300
+ cache: request.cache,
301
+ metadata: request.metadata,
302
+ })
303
+
304
+ export const update = (request: LLMRequest, patch: Partial<Input>) => {
305
+ if (Object.keys(patch).length === 0) return request
306
+ return new LLMRequest({
307
+ ...input(request),
308
+ ...patch,
309
+ model: patch.model ?? request.model,
310
+ })
311
+ }
312
+ }
@@ -0,0 +1,276 @@
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 ModelDefaults extends Schema.Class<ModelDefaults>("LLM.ModelDefaults")({
138
+ limits: Schema.optional(ModelLimits),
139
+ generation: Schema.optional(GenerationOptions),
140
+ providerOptions: Schema.optional(ProviderOptions),
141
+ http: Schema.optional(HttpOptions),
142
+ }) {}
143
+
144
+ export namespace ModelDefaults {
145
+ export type Input =
146
+ | ModelDefaults
147
+ | {
148
+ readonly limits?: ModelLimits.Input
149
+ readonly generation?: GenerationOptions.Input
150
+ readonly providerOptions?: ProviderOptions
151
+ readonly http?: HttpOptions.Input
152
+ }
153
+
154
+ /** Normalize selected-model request defaults without applying precedence. */
155
+ export const make = (input: Input) => {
156
+ if (input instanceof ModelDefaults) return input
157
+ return new ModelDefaults({
158
+ limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits),
159
+ generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
160
+ providerOptions: input.providerOptions,
161
+ http: input.http === undefined ? undefined : HttpOptions.make(input.http),
162
+ })
163
+ }
164
+ }
165
+
166
+ export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
167
+ export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
168
+
169
+ export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
170
+ toolSchema: Schema.optional(ModelToolSchemaCompatibility),
171
+ }) {}
172
+
173
+ export namespace ModelCompatibility {
174
+ export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0]
175
+
176
+ /** Normalize model/upstream compatibility metadata without projecting requests. */
177
+ export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
178
+ }
179
+
180
+ export class Model {
181
+ readonly id: ModelID
182
+ readonly provider: ProviderID
183
+ readonly route: AnyRoute
184
+ readonly defaults?: ModelDefaults
185
+ readonly compatibility?: ModelCompatibility
186
+
187
+ constructor(input: Model.ConstructorInput) {
188
+ this.id = input.id
189
+ this.provider = input.provider
190
+ this.route = input.route
191
+ this.defaults = input.defaults
192
+ this.compatibility = input.compatibility
193
+ }
194
+
195
+ static make(input: Model.Input) {
196
+ return new Model({
197
+ id: ModelID.make(input.id),
198
+ provider: ProviderID.make(input.provider),
199
+ route: input.route,
200
+ defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults),
201
+ compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility),
202
+ })
203
+ }
204
+
205
+ static input(model: Model): Model.ConstructorInput {
206
+ return {
207
+ id: model.id,
208
+ provider: model.provider,
209
+ route: model.route,
210
+ defaults: model.defaults,
211
+ compatibility: model.compatibility,
212
+ }
213
+ }
214
+
215
+ static update(model: Model, patch: Partial<Model.Input>) {
216
+ if (Object.keys(patch).length === 0) return model
217
+ return Model.make({
218
+ ...Model.input(model),
219
+ ...patch,
220
+ })
221
+ }
222
+ }
223
+
224
+ export namespace Model {
225
+ export type ConstructorInput = {
226
+ readonly id: ModelID
227
+ readonly provider: ProviderID
228
+ readonly route: AnyRoute
229
+ readonly defaults?: ModelDefaults
230
+ readonly compatibility?: ModelCompatibility
231
+ }
232
+
233
+ export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
234
+ readonly id: string | ModelID
235
+ readonly provider: string | ProviderID
236
+ readonly defaults?: ModelDefaults.Input
237
+ readonly compatibility?: ModelCompatibility.Input
238
+ }
239
+ }
240
+
241
+ export type ModelInput = Model.Input
242
+
243
+ export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
244
+
245
+ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
246
+ type: Schema.Literals(["ephemeral", "persistent"]),
247
+ ttlSeconds: Schema.optional(Schema.Number),
248
+ }) {}
249
+
250
+ // Auto-placement policy for prompt caching. The protocol-neutral lowering step
251
+ // reads this and injects `CacheHint`s at the configured boundaries; the
252
+ // per-protocol body builders then translate those hints into wire markers as
253
+ // usual. `"auto"` is the recommended default for agent loops — it places one
254
+ // breakpoint at the last tool definition, one at the last system part, and one
255
+ // at the latest user message. The combination of provider invalidation
256
+ // hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
257
+ // lookback means three trailing breakpoints reliably cover the static prefix.
258
+ //
259
+ // Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
260
+ // object form to override individual choices.
261
+ export const CachePolicyObject = Schema.Struct({
262
+ tools: Schema.optional(Schema.Boolean),
263
+ system: Schema.optional(Schema.Boolean),
264
+ messages: Schema.optional(
265
+ Schema.Union([
266
+ Schema.Literal("latest-user-message"),
267
+ Schema.Literal("latest-assistant"),
268
+ Schema.Struct({ tail: Schema.Number }),
269
+ ]),
270
+ ),
271
+ ttlSeconds: Schema.optional(Schema.Number),
272
+ })
273
+ export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
274
+
275
+ export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
276
+ export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>
@@ -0,0 +1,78 @@
1
+ import { Effect } from "effect"
2
+ import {
3
+ LLMEvent,
4
+ type ToolCallPart,
5
+ ToolFailure,
6
+ ToolOutput,
7
+ ToolResultValue,
8
+ type ToolOutput as ToolOutputType,
9
+ type ToolResultValue as ToolResultValueType,
10
+ } from "./schema"
11
+ import { type AnyTool, type Tools } from "./tool"
12
+
13
+ export interface ToolSettlement {
14
+ readonly result: ToolResultValueType
15
+ readonly output?: ToolOutputType
16
+ }
17
+
18
+ export interface DispatchResult extends ToolSettlement {
19
+ readonly events: ReadonlyArray<LLMEvent>
20
+ }
21
+
22
+ /** Execute one canonical tool call without owning provider IO or continuation. */
23
+ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
24
+ const tool = tools[call.name]
25
+ if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` }))
26
+ if (!tool.execute)
27
+ return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${call.name}` }))
28
+
29
+ return decodeAndExecute(tool, call).pipe(
30
+ Effect.map((value) => result(call, value)),
31
+ Effect.catchTag("LLM.ToolFailure", (failure) =>
32
+ Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
33
+ ),
34
+ )
35
+ }
36
+
37
+ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolSettlement, ToolFailure> =>
38
+ tool._decode(call.input).pipe(
39
+ Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
40
+ Effect.flatMap((decoded) =>
41
+ tool.execute!(decoded, { id: call.id, name: call.name }).pipe(
42
+ Effect.flatMap((value) =>
43
+ tool._encode(value).pipe(
44
+ Effect.mapError(
45
+ (error) =>
46
+ new ToolFailure({
47
+ message: `Tool returned an invalid value for its success schema: ${error.message}`,
48
+ }),
49
+ ),
50
+ ),
51
+ ),
52
+ Effect.map((encoded) => {
53
+ if (tool._legacyResult && ToolResultValue.is(encoded))
54
+ return { result: encoded, output: ToolOutput.fromResultValue(encoded) }
55
+ const output = tool._project(decoded, call.id, encoded)
56
+ const result = ToolOutput.toResultValue(output)
57
+ return result.type === "error" ? { result } : { result, output }
58
+ }),
59
+ ),
60
+ ),
61
+ )
62
+
63
+ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, error?: unknown): DispatchResult => {
64
+ const settlement = ToolResultValue.is(value) ? { result: value } : value
65
+ return {
66
+ result: settlement.result,
67
+ output: settlement.output,
68
+ events:
69
+ settlement.result.type === "error"
70
+ ? [
71
+ LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }),
72
+ LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }),
73
+ ]
74
+ : [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })],
75
+ }
76
+ }
77
+
78
+ export const ToolRuntime = { dispatch } as const