@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,618 @@
1
+ import { Schema } from "effect"
2
+ import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
3
+ import { ModelSchema } from "./options"
4
+ import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
5
+ import { ProviderFailureClassification } from "./errors"
6
+
7
+ /**
8
+ * Token usage reported by an LLM provider.
9
+ *
10
+ * **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a
11
+ * reader from any of those ecosystems sees the number they expect):
12
+ *
13
+ * - `inputTokens` — total prompt tokens, *including* cached reads/writes.
14
+ * - `outputTokens` — total output tokens, *including* reasoning.
15
+ * - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`.
16
+ *
17
+ * **Non-overlapping breakdown** (every field is independently meaningful;
18
+ * consumers never have to subtract):
19
+ *
20
+ * - `nonCachedInputTokens` — the "fresh" portion of the prompt.
21
+ * - `cacheReadInputTokens` — input tokens served from cache.
22
+ * - `cacheWriteInputTokens` — input tokens written to cache.
23
+ * - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning.
24
+ *
25
+ * **Invariant**: `nonCachedInputTokens + cacheReadInputTokens +
26
+ * cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`.
27
+ * Each protocol mapper computes whichever side it doesn't get natively,
28
+ * with `Math.max(0, …)` clamping for defense against provider bugs. Because
29
+ * every breakdown field is stored independently, downstream consumers can
30
+ * read whatever they need (cost-by-category, context-pressure, AI-SDK-style
31
+ * inclusive total) without ever subtracting — eliminating the underflow
32
+ * class of bug where a clamped difference would silently store the wrong
33
+ * value.
34
+ *
35
+ * **Semantics by provider**:
36
+ *
37
+ * - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
38
+ * `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
39
+ * derive the breakdown.
40
+ * - Anthropic: provider reports the breakdown natively (`input_tokens` is
41
+ * non-cached only); mapper sums to derive the inclusive `inputTokens`.
42
+ * Anthropic does *not* break extended-thinking out of `output_tokens`, so
43
+ * `reasoningTokens` is `undefined` and `outputTokens` carries the
44
+ * combined total — a documented limitation of the Anthropic API.
45
+ *
46
+ * `providerMetadata` always carries the provider's raw usage payload —
47
+ * keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
48
+ * — for fields we don't normalize and for billing-level audit trails.
49
+ * Matches the same escape-hatch field on `LLMEvent`.
50
+ */
51
+ export class Usage extends Schema.Class<Usage>("LLM.Usage")({
52
+ inputTokens: Schema.optional(Schema.Number),
53
+ outputTokens: Schema.optional(Schema.Number),
54
+ nonCachedInputTokens: Schema.optional(Schema.Number),
55
+ cacheReadInputTokens: Schema.optional(Schema.Number),
56
+ cacheWriteInputTokens: Schema.optional(Schema.Number),
57
+ reasoningTokens: Schema.optional(Schema.Number),
58
+ totalTokens: Schema.optional(Schema.Number),
59
+ providerMetadata: Schema.optional(ProviderMetadata),
60
+ }) {
61
+ /**
62
+ * Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
63
+ * to zero. The one place subtraction happens in this contract; the clamp
64
+ * means a provider reporting `reasoningTokens > outputTokens` produces a
65
+ * harmless zero rather than a negative that crashes downstream schemas.
66
+ */
67
+ get visibleOutputTokens() {
68
+ return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0))
69
+ }
70
+
71
+ static from(input: UsageInput) {
72
+ return input instanceof Usage ? input : new Usage(input)
73
+ }
74
+ }
75
+
76
+ export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
77
+
78
+ export const StepStart = Schema.Struct({
79
+ type: Schema.tag("step-start"),
80
+ index: Schema.Number,
81
+ }).annotate({ identifier: "LLM.Event.StepStart" })
82
+ export type StepStart = Schema.Schema.Type<typeof StepStart>
83
+
84
+ export const TextStart = Schema.Struct({
85
+ type: Schema.tag("text-start"),
86
+ id: ContentBlockID,
87
+ providerMetadata: Schema.optional(ProviderMetadata),
88
+ }).annotate({ identifier: "LLM.Event.TextStart" })
89
+ export type TextStart = Schema.Schema.Type<typeof TextStart>
90
+
91
+ export const TextDelta = Schema.Struct({
92
+ type: Schema.tag("text-delta"),
93
+ id: ContentBlockID,
94
+ text: Schema.String,
95
+ providerMetadata: Schema.optional(ProviderMetadata),
96
+ }).annotate({ identifier: "LLM.Event.TextDelta" })
97
+ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
98
+
99
+ export const TextEnd = Schema.Struct({
100
+ type: Schema.tag("text-end"),
101
+ id: ContentBlockID,
102
+ providerMetadata: Schema.optional(ProviderMetadata),
103
+ }).annotate({ identifier: "LLM.Event.TextEnd" })
104
+ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
105
+
106
+ export const ReasoningStart = Schema.Struct({
107
+ type: Schema.tag("reasoning-start"),
108
+ id: ContentBlockID,
109
+ providerMetadata: Schema.optional(ProviderMetadata),
110
+ }).annotate({ identifier: "LLM.Event.ReasoningStart" })
111
+ export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
112
+
113
+ export const ReasoningDelta = Schema.Struct({
114
+ type: Schema.tag("reasoning-delta"),
115
+ id: ContentBlockID,
116
+ text: Schema.String,
117
+ providerMetadata: Schema.optional(ProviderMetadata),
118
+ }).annotate({ identifier: "LLM.Event.ReasoningDelta" })
119
+ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
120
+
121
+ export const ReasoningEnd = Schema.Struct({
122
+ type: Schema.tag("reasoning-end"),
123
+ id: ContentBlockID,
124
+ providerMetadata: Schema.optional(ProviderMetadata),
125
+ }).annotate({ identifier: "LLM.Event.ReasoningEnd" })
126
+ export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
127
+
128
+ export const ToolInputStart = Schema.Struct({
129
+ type: Schema.tag("tool-input-start"),
130
+ id: ToolCallID,
131
+ name: Schema.String,
132
+ providerMetadata: Schema.optional(ProviderMetadata),
133
+ }).annotate({ identifier: "LLM.Event.ToolInputStart" })
134
+ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
135
+
136
+ export const ToolInputDelta = Schema.Struct({
137
+ type: Schema.tag("tool-input-delta"),
138
+ id: ToolCallID,
139
+ name: Schema.String,
140
+ text: Schema.String,
141
+ }).annotate({ identifier: "LLM.Event.ToolInputDelta" })
142
+ export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
143
+
144
+ export const ToolInputEnd = Schema.Struct({
145
+ type: Schema.tag("tool-input-end"),
146
+ id: ToolCallID,
147
+ name: Schema.String,
148
+ providerMetadata: Schema.optional(ProviderMetadata),
149
+ }).annotate({ identifier: "LLM.Event.ToolInputEnd" })
150
+ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
151
+
152
+ export const ToolCall = Schema.Struct({
153
+ type: Schema.tag("tool-call"),
154
+ id: ToolCallID,
155
+ name: Schema.String,
156
+ input: Schema.Unknown,
157
+ providerExecuted: Schema.optional(Schema.Boolean),
158
+ providerMetadata: Schema.optional(ProviderMetadata),
159
+ }).annotate({ identifier: "LLM.Event.ToolCall" })
160
+ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
161
+
162
+ export const ToolResult = Schema.Struct({
163
+ type: Schema.tag("tool-result"),
164
+ id: ToolCallID,
165
+ name: Schema.String,
166
+ result: ToolResultValue,
167
+ output: Schema.optional(ToolOutput),
168
+ providerExecuted: Schema.optional(Schema.Boolean),
169
+ providerMetadata: Schema.optional(ProviderMetadata),
170
+ }).annotate({ identifier: "LLM.Event.ToolResult" })
171
+ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
172
+
173
+ export const ToolError = Schema.Struct({
174
+ type: Schema.tag("tool-error"),
175
+ id: ToolCallID,
176
+ name: Schema.String,
177
+ message: Schema.String,
178
+ error: Schema.optional(Schema.Defect()),
179
+ providerMetadata: Schema.optional(ProviderMetadata),
180
+ }).annotate({ identifier: "LLM.Event.ToolError" })
181
+ export type ToolError = Schema.Schema.Type<typeof ToolError>
182
+
183
+ export const StepFinish = Schema.Struct({
184
+ type: Schema.tag("step-finish"),
185
+ index: Schema.Number,
186
+ reason: FinishReason,
187
+ usage: Schema.optional(Usage),
188
+ providerMetadata: Schema.optional(ProviderMetadata),
189
+ }).annotate({ identifier: "LLM.Event.StepFinish" })
190
+ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
191
+
192
+ export const Finish = Schema.Struct({
193
+ type: Schema.tag("finish"),
194
+ reason: FinishReason,
195
+ usage: Schema.optional(Usage),
196
+ providerMetadata: Schema.optional(ProviderMetadata),
197
+ }).annotate({ identifier: "LLM.Event.Finish" })
198
+ export type Finish = Schema.Schema.Type<typeof Finish>
199
+
200
+ export const ProviderErrorEvent = Schema.Struct({
201
+ type: Schema.tag("provider-error"),
202
+ message: Schema.String,
203
+ classification: Schema.optional(ProviderFailureClassification),
204
+ retryable: Schema.optional(Schema.Boolean),
205
+ providerMetadata: Schema.optional(ProviderMetadata),
206
+ }).annotate({ identifier: "LLM.Event.ProviderError" })
207
+ export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
208
+
209
+ const llmEventTagged = Schema.Union([
210
+ StepStart,
211
+ TextStart,
212
+ TextDelta,
213
+ TextEnd,
214
+ ReasoningStart,
215
+ ReasoningDelta,
216
+ ReasoningEnd,
217
+ ToolInputStart,
218
+ ToolInputDelta,
219
+ ToolInputEnd,
220
+ ToolCall,
221
+ ToolResult,
222
+ ToolError,
223
+ StepFinish,
224
+ Finish,
225
+ ProviderErrorEvent,
226
+ ]).pipe(Schema.toTaggedUnion("type"))
227
+
228
+ type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
229
+ type WithUsage<Event extends { readonly usage?: Usage }> = Omit<Event, "type" | "usage"> & {
230
+ readonly usage?: UsageInput
231
+ }
232
+
233
+ const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
234
+ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
235
+
236
+ /**
237
+ * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
238
+ * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
239
+ * `events.filter(LLMEvent.guards["tool-call"])`.
240
+ */
241
+ export const LLMEvent = Object.assign(llmEventTagged, {
242
+ stepStart: StepStart.make,
243
+ textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
244
+ textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
245
+ textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
246
+ reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
247
+ ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
248
+ reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
249
+ ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
250
+ reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
251
+ ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
252
+ toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
253
+ ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
254
+ toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
255
+ ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
256
+ toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
257
+ toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
258
+ toolResult: (input: WithID<ToolResult, ToolCallID>) =>
259
+ ToolResult.make({
260
+ ...input,
261
+ id: toolCallID(input.id),
262
+ output: input.output === undefined ? undefined : ToolOutput.make(input.output.structured, input.output.content),
263
+ }),
264
+ toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
265
+ stepFinish: (input: WithUsage<StepFinish>) =>
266
+ StepFinish.make({
267
+ ...input,
268
+ usage: input.usage === undefined ? undefined : Usage.from(input.usage),
269
+ }),
270
+ finish: (input: WithUsage<Finish>) =>
271
+ Finish.make({
272
+ ...input,
273
+ usage: input.usage === undefined ? undefined : Usage.from(input.usage),
274
+ }),
275
+ providerError: ProviderErrorEvent.make,
276
+ is: {
277
+ stepStart: llmEventTagged.guards["step-start"],
278
+ textStart: llmEventTagged.guards["text-start"],
279
+ textDelta: llmEventTagged.guards["text-delta"],
280
+ textEnd: llmEventTagged.guards["text-end"],
281
+ reasoningStart: llmEventTagged.guards["reasoning-start"],
282
+ reasoningDelta: llmEventTagged.guards["reasoning-delta"],
283
+ reasoningEnd: llmEventTagged.guards["reasoning-end"],
284
+ toolInputStart: llmEventTagged.guards["tool-input-start"],
285
+ toolInputDelta: llmEventTagged.guards["tool-input-delta"],
286
+ toolInputEnd: llmEventTagged.guards["tool-input-end"],
287
+ toolCall: llmEventTagged.guards["tool-call"],
288
+ toolResult: llmEventTagged.guards["tool-result"],
289
+ toolError: llmEventTagged.guards["tool-error"],
290
+ stepFinish: llmEventTagged.guards["step-finish"],
291
+ finish: llmEventTagged.guards.finish,
292
+ providerError: llmEventTagged.guards["provider-error"],
293
+ },
294
+ })
295
+ export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
296
+
297
+ export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.PreparedRequest")({
298
+ id: Schema.String,
299
+ route: RouteID,
300
+ protocol: ProtocolID,
301
+ model: ModelSchema,
302
+ body: Schema.Unknown,
303
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
304
+ }) {}
305
+
306
+ /**
307
+ * A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic
308
+ * on `LLMClient.prepare<Body>(...)` when the caller knows which route their
309
+ * request will resolve to and wants its native shape statically exposed
310
+ * (debug UIs, request previews, plan rendering).
311
+ *
312
+ * The runtime body is identical — the route still emits `body: unknown` — so
313
+ * this is a type-level assertion the caller makes about what they expect to
314
+ * find. The prepare runtime does not validate the assertion.
315
+ */
316
+ export type PreparedRequestOf<Body> = Omit<PreparedRequest, "body"> & {
317
+ readonly body: Body
318
+ }
319
+
320
+ const responseText = (events: ReadonlyArray<LLMEvent>) =>
321
+ events
322
+ .filter(LLMEvent.is.textDelta)
323
+ .map((event) => event.text)
324
+ .join("")
325
+
326
+ const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
327
+ events
328
+ .filter(LLMEvent.is.reasoningDelta)
329
+ .map((event) => event.text)
330
+ .join("")
331
+
332
+ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
333
+ events.reduce<Usage | undefined>(
334
+ (usage, event) => ("usage" in event && event.usage !== undefined ? event.usage : usage),
335
+ undefined,
336
+ )
337
+
338
+ interface ContentAssembly {
339
+ readonly contentIndex: number
340
+ readonly text: string
341
+ readonly providerMetadata?: ProviderMetadata
342
+ }
343
+
344
+ interface ToolInputAssembly {
345
+ readonly name: string
346
+ readonly text: string
347
+ readonly providerMetadata?: ProviderMetadata
348
+ }
349
+
350
+ interface ResponseState {
351
+ readonly events: ReadonlyArray<LLMEvent>
352
+ readonly message: Message
353
+ readonly usage?: Usage
354
+ readonly finishReason?: FinishReason
355
+ readonly textParts: Readonly<Record<string, ContentAssembly>>
356
+ readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
357
+ readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
358
+ }
359
+
360
+ const emptyResponseState = (): ResponseState => ({
361
+ events: [],
362
+ message: Message.assistant([]),
363
+ textParts: {},
364
+ reasoningParts: {},
365
+ toolInputs: {},
366
+ })
367
+
368
+ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
369
+ const events = [...state.events, event]
370
+ if (LLMEvent.is.finish(event)) {
371
+ return {
372
+ ...state,
373
+ events,
374
+ usage: event.usage ?? state.usage,
375
+ finishReason: event.reason,
376
+ }
377
+ }
378
+ if (LLMEvent.is.providerError(event)) {
379
+ return {
380
+ ...state,
381
+ events,
382
+ finishReason: state.finishReason ?? "error",
383
+ }
384
+ }
385
+ return {
386
+ ...state,
387
+ events,
388
+ usage: "usage" in event && event.usage !== undefined ? event.usage : state.usage,
389
+ }
390
+ }
391
+
392
+ const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
393
+ providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
394
+
395
+ const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
396
+ providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
397
+
398
+ const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
399
+ ...state,
400
+ message: Message.assistant(content),
401
+ })
402
+
403
+ const appendContent = (state: ResponseState, part: ContentPart) => contentWith(state, [...state.message.content, part])
404
+
405
+ const replaceContent = (state: ResponseState, index: number, part: ContentPart) =>
406
+ contentWith(
407
+ state,
408
+ state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
409
+ )
410
+
411
+ const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
412
+ if (state.textParts[id]) return state
413
+ return {
414
+ ...appendContent(state, textContent("", providerMetadata)),
415
+ textParts: {
416
+ ...state.textParts,
417
+ [id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
418
+ },
419
+ }
420
+ }
421
+
422
+ const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
423
+ const started = ensureText(state, event.id, event.providerMetadata)
424
+ const current = started.textParts[event.id]
425
+ if (!current) return started
426
+ const text = current.text + event.text
427
+ const providerMetadata = event.providerMetadata ?? current.providerMetadata
428
+ return {
429
+ ...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
430
+ textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
431
+ }
432
+ }
433
+
434
+ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
435
+ const current = state.textParts[event.id]
436
+ if (!current) return state
437
+ const providerMetadata = event.providerMetadata ?? current.providerMetadata
438
+ return {
439
+ ...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
440
+ textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
441
+ }
442
+ }
443
+
444
+ const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
445
+ if (state.reasoningParts[id]) return state
446
+ return {
447
+ ...appendContent(state, reasoningContent("", providerMetadata)),
448
+ reasoningParts: {
449
+ ...state.reasoningParts,
450
+ [id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
451
+ },
452
+ }
453
+ }
454
+
455
+ const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
456
+ const started = ensureReasoning(state, event.id, event.providerMetadata)
457
+ const current = started.reasoningParts[event.id]
458
+ if (!current) return started
459
+ const text = current.text + event.text
460
+ const providerMetadata = event.providerMetadata ?? current.providerMetadata
461
+ return {
462
+ ...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
463
+ reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
464
+ }
465
+ }
466
+
467
+ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => {
468
+ const current = state.reasoningParts[event.id]
469
+ if (!current) return state
470
+ const providerMetadata = event.providerMetadata ?? current.providerMetadata
471
+ return {
472
+ ...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
473
+ reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
474
+ }
475
+ }
476
+
477
+ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): ResponseState => ({
478
+ ...state,
479
+ toolInputs: {
480
+ ...state.toolInputs,
481
+ [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
482
+ },
483
+ })
484
+
485
+ const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => {
486
+ const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
487
+ return {
488
+ ...state,
489
+ toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } },
490
+ }
491
+ }
492
+
493
+ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => {
494
+ const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
495
+ return {
496
+ ...state,
497
+ toolInputs: {
498
+ ...state.toolInputs,
499
+ [event.id]: {
500
+ ...current,
501
+ name: event.name,
502
+ providerMetadata: event.providerMetadata ?? current.providerMetadata,
503
+ },
504
+ },
505
+ }
506
+ }
507
+
508
+ const toolCallContent = (event: ToolCall): ContentPart =>
509
+ ToolCallPart.make({
510
+ id: event.id,
511
+ name: event.name,
512
+ input: event.input,
513
+ ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
514
+ ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
515
+ })
516
+
517
+ const toolResultContent = (event: ToolResult): ContentPart =>
518
+ ToolResultPart.make({
519
+ id: event.id,
520
+ name: event.name,
521
+ result: event.result,
522
+ ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
523
+ ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
524
+ })
525
+
526
+ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState => {
527
+ const { [event.id]: _finished, ...toolInputs } = state.toolInputs
528
+ return { ...appendContent(state, toolCallContent(event)), toolInputs }
529
+ }
530
+
531
+ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
532
+ const next = appendEvent(state, event)
533
+ switch (event.type) {
534
+ case "text-start":
535
+ return ensureText(next, event.id, event.providerMetadata)
536
+ case "text-delta":
537
+ return reduceTextDelta(next, event)
538
+ case "text-end":
539
+ return reduceTextEnd(next, event)
540
+ case "reasoning-start":
541
+ return ensureReasoning(next, event.id, event.providerMetadata)
542
+ case "reasoning-delta":
543
+ return reduceReasoningDelta(next, event)
544
+ case "reasoning-end":
545
+ return reduceReasoningEnd(next, event)
546
+ case "tool-input-start":
547
+ return reduceToolInputStart(next, event)
548
+ case "tool-input-delta":
549
+ return reduceToolInputDelta(next, event)
550
+ case "tool-input-end":
551
+ return reduceToolInputEnd(next, event)
552
+ case "tool-call":
553
+ return reduceToolCall(next, event)
554
+ case "tool-result":
555
+ return appendContent(next, toolResultContent(event))
556
+ default:
557
+ return next
558
+ }
559
+ }
560
+
561
+ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
562
+ message: Message,
563
+ events: Schema.Array(LLMEvent),
564
+ usage: Schema.optional(Usage),
565
+ finishReason: FinishReason,
566
+ }) {
567
+ /** Concatenated assistant text assembled from streamed `text-delta` events. */
568
+ get text() {
569
+ return responseText(this.events)
570
+ }
571
+
572
+ /** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
573
+ get reasoning() {
574
+ return responseReasoning(this.events)
575
+ }
576
+
577
+ /** Completed tool calls emitted by the provider. */
578
+ get toolCalls() {
579
+ return this.events.filter(LLMEvent.is.toolCall)
580
+ }
581
+ }
582
+
583
+ export namespace LLMResponse {
584
+ export type State = ResponseState
585
+ export type Output = LLMResponse | { readonly events: ReadonlyArray<LLMEvent>; readonly usage?: Usage }
586
+
587
+ /** Initial reducer state for assembling one provider attempt. */
588
+ export const empty = emptyResponseState
589
+
590
+ /** Purely fold one provider-neutral event into the attempt assembly state. */
591
+ export const reduce = reduceResponseState
592
+
593
+ /** Return a completed response only after a terminal finish or provider error. */
594
+ export const complete = (state: State): LLMResponse | undefined =>
595
+ state.finishReason === undefined
596
+ ? undefined
597
+ : new LLMResponse({
598
+ message: state.message,
599
+ events: [...state.events],
600
+ usage: state.usage,
601
+ finishReason: state.finishReason,
602
+ })
603
+
604
+ /** Convenience reducer for callers that already have a collected event list. */
605
+ export const fromEvents = (events: ReadonlyArray<LLMEvent>) => complete(events.reduce(reduce, empty()))
606
+
607
+ /** Concatenate assistant text from a response or collected event list. */
608
+ export const text = (response: Output) => responseText(response.events)
609
+
610
+ /** Return response usage, falling back to the latest usage-bearing event. */
611
+ export const usage = (response: Output) => response.usage ?? responseUsage(response.events)
612
+
613
+ /** Return completed tool calls from a response or collected event list. */
614
+ export const toolCalls = (response: Output) => response.events.filter(LLMEvent.is.toolCall)
615
+
616
+ /** Concatenate reasoning text from a response or collected event list. */
617
+ export const reasoning = (response: Output) => responseReasoning(response.events)
618
+ }
@@ -0,0 +1,43 @@
1
+ import { Schema } from "effect"
2
+ import { ProviderMetadata } from "@neurocode-ai/schema/llm"
3
+
4
+ export { ProviderMetadata }
5
+
6
+ /** Stable string identifier for a protocol implementation. */
7
+ export const ProtocolID = Schema.String
8
+ export type ProtocolID = Schema.Schema.Type<typeof ProtocolID>
9
+
10
+ /** Stable string identifier for the runnable route. */
11
+ export const RouteID = Schema.String
12
+ export type RouteID = Schema.Schema.Type<typeof RouteID>
13
+
14
+ export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID"))
15
+ export type ModelID = typeof ModelID.Type
16
+
17
+ export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
18
+ export type ProviderID = typeof ProviderID.Type
19
+
20
+ export const ResponseID = Schema.String
21
+ export type ResponseID = Schema.Schema.Type<typeof ResponseID>
22
+
23
+ export const ContentBlockID = Schema.String
24
+ export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
25
+
26
+ export const ToolCallID = Schema.String
27
+ export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
28
+
29
+ export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
30
+ export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
31
+ export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
32
+
33
+ export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
34
+ export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
35
+
36
+ export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
37
+ export type MessageRole = Schema.Schema.Type<typeof MessageRole>
38
+
39
+ export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
40
+ export type FinishReason = Schema.Schema.Type<typeof FinishReason>
41
+
42
+ export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
43
+ export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
@@ -0,0 +1,5 @@
1
+ export * from "./ids"
2
+ export * from "./options"
3
+ export * from "./messages"
4
+ export * from "./events"
5
+ export * from "./errors"