@effect-uai/core 0.1.0

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 (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/AiError-CqmYjXyx.d.mts +110 -0
  4. package/dist/AiError-CqmYjXyx.d.mts.map +1 -0
  5. package/dist/Items-D1C2686t.d.mts +372 -0
  6. package/dist/Items-D1C2686t.d.mts.map +1 -0
  7. package/dist/Loop-CzSJo1h8.d.mts +87 -0
  8. package/dist/Loop-CzSJo1h8.d.mts.map +1 -0
  9. package/dist/Outcome-C2JYknCu.d.mts +40 -0
  10. package/dist/Outcome-C2JYknCu.d.mts.map +1 -0
  11. package/dist/StructuredFormat-B5ueioNr.d.mts +88 -0
  12. package/dist/StructuredFormat-B5ueioNr.d.mts.map +1 -0
  13. package/dist/Tool-5wxOCuOh.d.mts +86 -0
  14. package/dist/Tool-5wxOCuOh.d.mts.map +1 -0
  15. package/dist/ToolEvent-B2N10hr3.d.mts +29 -0
  16. package/dist/ToolEvent-B2N10hr3.d.mts.map +1 -0
  17. package/dist/Turn-rlTfuHaQ.d.mts +211 -0
  18. package/dist/Turn-rlTfuHaQ.d.mts.map +1 -0
  19. package/dist/chunk-CfYAbeIz.mjs +13 -0
  20. package/dist/domain/AiError.d.mts +2 -0
  21. package/dist/domain/AiError.mjs +40 -0
  22. package/dist/domain/AiError.mjs.map +1 -0
  23. package/dist/domain/Items.d.mts +2 -0
  24. package/dist/domain/Items.mjs +238 -0
  25. package/dist/domain/Items.mjs.map +1 -0
  26. package/dist/domain/Turn.d.mts +2 -0
  27. package/dist/domain/Turn.mjs +82 -0
  28. package/dist/domain/Turn.mjs.map +1 -0
  29. package/dist/index.d.mts +14 -0
  30. package/dist/index.mjs +14 -0
  31. package/dist/language-model/LanguageModel.d.mts +60 -0
  32. package/dist/language-model/LanguageModel.d.mts.map +1 -0
  33. package/dist/language-model/LanguageModel.mjs +33 -0
  34. package/dist/language-model/LanguageModel.mjs.map +1 -0
  35. package/dist/loop/Loop.d.mts +2 -0
  36. package/dist/loop/Loop.mjs +172 -0
  37. package/dist/loop/Loop.mjs.map +1 -0
  38. package/dist/match/Match.d.mts +16 -0
  39. package/dist/match/Match.d.mts.map +1 -0
  40. package/dist/match/Match.mjs +15 -0
  41. package/dist/match/Match.mjs.map +1 -0
  42. package/dist/observability/Metrics.d.mts +45 -0
  43. package/dist/observability/Metrics.d.mts.map +1 -0
  44. package/dist/observability/Metrics.mjs +52 -0
  45. package/dist/observability/Metrics.mjs.map +1 -0
  46. package/dist/streaming/JSONL.d.mts +34 -0
  47. package/dist/streaming/JSONL.d.mts.map +1 -0
  48. package/dist/streaming/JSONL.mjs +51 -0
  49. package/dist/streaming/JSONL.mjs.map +1 -0
  50. package/dist/streaming/Lines.d.mts +27 -0
  51. package/dist/streaming/Lines.d.mts.map +1 -0
  52. package/dist/streaming/Lines.mjs +32 -0
  53. package/dist/streaming/Lines.mjs.map +1 -0
  54. package/dist/streaming/SSE.d.mts +31 -0
  55. package/dist/streaming/SSE.d.mts.map +1 -0
  56. package/dist/streaming/SSE.mjs +58 -0
  57. package/dist/streaming/SSE.mjs.map +1 -0
  58. package/dist/structured-format/StructuredFormat.d.mts +2 -0
  59. package/dist/structured-format/StructuredFormat.mjs +68 -0
  60. package/dist/structured-format/StructuredFormat.mjs.map +1 -0
  61. package/dist/testing/MockProvider.d.mts +48 -0
  62. package/dist/testing/MockProvider.d.mts.map +1 -0
  63. package/dist/testing/MockProvider.mjs +95 -0
  64. package/dist/testing/MockProvider.mjs.map +1 -0
  65. package/dist/tool/HistoryCheck.d.mts +24 -0
  66. package/dist/tool/HistoryCheck.d.mts.map +1 -0
  67. package/dist/tool/HistoryCheck.mjs +39 -0
  68. package/dist/tool/HistoryCheck.mjs.map +1 -0
  69. package/dist/tool/Outcome.d.mts +2 -0
  70. package/dist/tool/Outcome.mjs +45 -0
  71. package/dist/tool/Outcome.mjs.map +1 -0
  72. package/dist/tool/Resolvers.d.mts +44 -0
  73. package/dist/tool/Resolvers.d.mts.map +1 -0
  74. package/dist/tool/Resolvers.mjs +67 -0
  75. package/dist/tool/Resolvers.mjs.map +1 -0
  76. package/dist/tool/Tool.d.mts +2 -0
  77. package/dist/tool/Tool.mjs +79 -0
  78. package/dist/tool/Tool.mjs.map +1 -0
  79. package/dist/tool/ToolEvent.d.mts +2 -0
  80. package/dist/tool/ToolEvent.mjs +8 -0
  81. package/dist/tool/ToolEvent.mjs.map +1 -0
  82. package/dist/tool/Toolkit.d.mts +34 -0
  83. package/dist/tool/Toolkit.d.mts.map +1 -0
  84. package/dist/tool/Toolkit.mjs +105 -0
  85. package/dist/tool/Toolkit.mjs.map +1 -0
  86. package/package.json +127 -0
  87. package/src/domain/AiError.ts +93 -0
  88. package/src/domain/Items.ts +260 -0
  89. package/src/domain/Turn.ts +174 -0
  90. package/src/index.ts +13 -0
  91. package/src/language-model/LanguageModel.ts +73 -0
  92. package/src/loop/Loop.test.ts +412 -0
  93. package/src/loop/Loop.ts +295 -0
  94. package/src/match/Match.ts +9 -0
  95. package/src/observability/Metrics.ts +87 -0
  96. package/src/streaming/JSONL.test.ts +85 -0
  97. package/src/streaming/JSONL.ts +96 -0
  98. package/src/streaming/Lines.ts +34 -0
  99. package/src/streaming/SSE.test.ts +72 -0
  100. package/src/streaming/SSE.ts +114 -0
  101. package/src/structured-format/StructuredFormat.ts +160 -0
  102. package/src/testing/MockProvider.ts +161 -0
  103. package/src/tool/HistoryCheck.ts +49 -0
  104. package/src/tool/Outcome.ts +101 -0
  105. package/src/tool/Resolvers.test.ts +426 -0
  106. package/src/tool/Resolvers.ts +166 -0
  107. package/src/tool/Tool.ts +150 -0
  108. package/src/tool/ToolEvent.ts +37 -0
  109. package/src/tool/Toolkit.test.ts +45 -0
  110. package/src/tool/Toolkit.ts +228 -0
@@ -0,0 +1,260 @@
1
+ import { Schema } from "effect"
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Content blocks (inside Message.content)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export const InputText = Schema.Struct({
8
+ type: Schema.Literal("input_text"),
9
+ text: Schema.String,
10
+ })
11
+ export type InputText = typeof InputText.Type
12
+
13
+ /**
14
+ * Where an image lives. `url` covers HTTP(S) URLs (the model fetches
15
+ * them); `base64` covers inline bytes embedded in the request. Provider
16
+ * encoders dispatch on `_tag`. File-id / uploaded-asset references are
17
+ * provider-specific and stay out of this union for now.
18
+ */
19
+ export const ImageUrlSource = Schema.Struct({
20
+ _tag: Schema.Literal("url"),
21
+ url: Schema.String,
22
+ })
23
+ export type ImageUrlSource = typeof ImageUrlSource.Type
24
+
25
+ /**
26
+ * Inline image bytes. `data` is **already base64-encoded** (matches what
27
+ * the wire formats expect; no double-encoding needed downstream).
28
+ * `media_type` is the MIME type, e.g. `"image/png"`.
29
+ */
30
+ export const ImageBase64Source = Schema.Struct({
31
+ _tag: Schema.Literal("base64"),
32
+ media_type: Schema.String,
33
+ data: Schema.String,
34
+ })
35
+ export type ImageBase64Source = typeof ImageBase64Source.Type
36
+
37
+ export const ImageSource = Schema.Union([ImageUrlSource, ImageBase64Source])
38
+ export type ImageSource = typeof ImageSource.Type
39
+
40
+ export const isImageUrlSource = (s: ImageSource): s is ImageUrlSource => s._tag === "url"
41
+ export const isImageBase64Source = (s: ImageSource): s is ImageBase64Source => s._tag === "base64"
42
+
43
+ /**
44
+ * User-provided image content block. Pair with `InputText` inside a
45
+ * `Message.content` array to ask "what's in this image?" style questions.
46
+ */
47
+ export const InputImage = Schema.Struct({
48
+ type: Schema.Literal("input_image"),
49
+ source: ImageSource,
50
+ })
51
+ export type InputImage = typeof InputImage.Type
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Annotations - source / citation pointers attached to `output_text` blocks.
55
+ // Mirrors OpenAI Responses API; other providers can omit or map onto these
56
+ // shapes.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export const UrlCitation = Schema.Struct({
60
+ type: Schema.Literal("url_citation"),
61
+ url: Schema.String,
62
+ start_index: Schema.Number,
63
+ end_index: Schema.Number,
64
+ title: Schema.String,
65
+ })
66
+ export type UrlCitation = typeof UrlCitation.Type
67
+
68
+ export const FileCitation = Schema.Struct({
69
+ type: Schema.Literal("file_citation"),
70
+ file_id: Schema.String,
71
+ index: Schema.Number,
72
+ })
73
+ export type FileCitation = typeof FileCitation.Type
74
+
75
+ export const ContainerFileCitation = Schema.Struct({
76
+ type: Schema.Literal("container_file_citation"),
77
+ container_id: Schema.String,
78
+ file_id: Schema.String,
79
+ start_index: Schema.Number,
80
+ end_index: Schema.Number,
81
+ })
82
+ export type ContainerFileCitation = typeof ContainerFileCitation.Type
83
+
84
+ export const FilePath = Schema.Struct({
85
+ type: Schema.Literal("file_path"),
86
+ file_id: Schema.String,
87
+ index: Schema.Number,
88
+ })
89
+ export type FilePath = typeof FilePath.Type
90
+
91
+ export const Annotation = Schema.Union([UrlCitation, FileCitation, ContainerFileCitation, FilePath])
92
+ export type Annotation = typeof Annotation.Type
93
+
94
+ export const isUrlCitation = (a: Annotation): a is UrlCitation => a.type === "url_citation"
95
+ export const isFileCitation = (a: Annotation): a is FileCitation => a.type === "file_citation"
96
+ export const isContainerFileCitation = (a: Annotation): a is ContainerFileCitation =>
97
+ a.type === "container_file_citation"
98
+ export const isFilePath = (a: Annotation): a is FilePath => a.type === "file_path"
99
+
100
+ export const OutputText = Schema.Struct({
101
+ type: Schema.Literal("output_text"),
102
+ text: Schema.String,
103
+ annotations: Schema.optional(Schema.Array(Annotation)),
104
+ })
105
+ export type OutputText = typeof OutputText.Type
106
+
107
+ /**
108
+ * Model-emitted refusal. Distinct from `output_text`: the model declined
109
+ * to answer rather than producing normal output. Pair with
110
+ * `stop_reason: "refusal"` on the surrounding `Turn`. Streamed via the
111
+ * `refusal_delta` `TurnEvent`.
112
+ */
113
+ export const Refusal = Schema.Struct({
114
+ type: Schema.Literal("refusal"),
115
+ text: Schema.String,
116
+ })
117
+ export type Refusal = typeof Refusal.Type
118
+
119
+ export const ContentBlock = Schema.Union([InputText, InputImage, OutputText, Refusal])
120
+ export type ContentBlock = typeof ContentBlock.Type
121
+
122
+ export const Role = Schema.Literals(["user", "assistant", "system"])
123
+ export type Role = typeof Role.Type
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Provider passthrough - every Item type carries this opaque slot.
127
+ // The framework never reads or interprets it; provider modules decode
128
+ // their own data via their own typed readers (see e.g.
129
+ // the `@effect-uai/responses` package).
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const ProviderData = Schema.optional(Schema.Unknown)
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Items
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export const Message = Schema.Struct({
139
+ type: Schema.Literal("message"),
140
+ role: Role,
141
+ content: Schema.Array(ContentBlock),
142
+ providerData: ProviderData,
143
+ })
144
+ export type Message = typeof Message.Type
145
+
146
+ export const FunctionCall = Schema.Struct({
147
+ type: Schema.Literal("function_call"),
148
+ call_id: Schema.String,
149
+ name: Schema.String,
150
+ // JSON-encoded arguments string, mirroring OpenAI Responses API
151
+ arguments: Schema.String,
152
+ providerData: ProviderData,
153
+ })
154
+ export type FunctionCall = typeof FunctionCall.Type
155
+
156
+ export const FunctionCallOutput = Schema.Struct({
157
+ type: Schema.Literal("function_call_output"),
158
+ call_id: Schema.String,
159
+ output: Schema.String,
160
+ providerData: ProviderData,
161
+ })
162
+ export type FunctionCallOutput = typeof FunctionCallOutput.Type
163
+
164
+ /**
165
+ * Reasoning item - top-level, mirrors OpenAI Responses API. Common shape
166
+ * across providers covers `summary` (human-readable text) and `signature`
167
+ * (opaque round-trip blob - Anthropic's signed thinking, OpenAI's
168
+ * encrypted_content, etc.). Provider-specific fields go in `providerData`.
169
+ */
170
+ export const Reasoning = Schema.Struct({
171
+ type: Schema.Literal("reasoning"),
172
+ id: Schema.optional(Schema.String),
173
+ summary: Schema.optional(Schema.String),
174
+ signature: Schema.optional(Schema.String),
175
+ providerData: ProviderData,
176
+ })
177
+ export type Reasoning = typeof Reasoning.Type
178
+
179
+ export const Item = Schema.Union([Message, FunctionCall, FunctionCallOutput, Reasoning])
180
+ export type Item = typeof Item.Type
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Type guards
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export const isInputText = (block: ContentBlock): block is InputText => block.type === "input_text"
187
+ export const isInputImage = (block: ContentBlock): block is InputImage =>
188
+ block.type === "input_image"
189
+ export const isOutputText = (block: ContentBlock): block is OutputText =>
190
+ block.type === "output_text"
191
+ export const isRefusal = (block: ContentBlock): block is Refusal => block.type === "refusal"
192
+
193
+ export const isMessage = (item: Item): item is Message => item.type === "message"
194
+ export const isFunctionCall = (item: Item): item is FunctionCall => item.type === "function_call"
195
+ export const isFunctionCallOutput = (item: Item): item is FunctionCallOutput =>
196
+ item.type === "function_call_output"
197
+ export const isReasoning = (item: Item): item is Reasoning => item.type === "reasoning"
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Usage and stop reason
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export const InputTokensDetails = Schema.Struct({
204
+ cached_tokens: Schema.optional(Schema.Number),
205
+ })
206
+ export type InputTokensDetails = typeof InputTokensDetails.Type
207
+
208
+ export const OutputTokensDetails = Schema.Struct({
209
+ reasoning_tokens: Schema.optional(Schema.Number),
210
+ })
211
+ export type OutputTokensDetails = typeof OutputTokensDetails.Type
212
+
213
+ export const Usage = Schema.Struct({
214
+ input_tokens: Schema.optional(Schema.Number),
215
+ output_tokens: Schema.optional(Schema.Number),
216
+ total_tokens: Schema.optional(Schema.Number),
217
+ input_tokens_details: Schema.optional(InputTokensDetails),
218
+ output_tokens_details: Schema.optional(OutputTokensDetails),
219
+ })
220
+ export type Usage = typeof Usage.Type
221
+
222
+ export const StopReason = Schema.Literals([
223
+ "stop",
224
+ "tool_calls",
225
+ "max_tokens",
226
+ "refusal",
227
+ /** Provider-side safety classifier flagged the output. */
228
+ "content_filter",
229
+ /** Server-enforced cap on tool calls per turn was hit. */
230
+ "max_tool_calls",
231
+ ])
232
+ export type StopReason = typeof StopReason.Type
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Helper constructors
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export const userText = (text: string): Message => ({
239
+ type: "message",
240
+ role: "user",
241
+ content: [{ type: "input_text", text }],
242
+ })
243
+
244
+ export const systemText = (text: string): Message => ({
245
+ type: "message",
246
+ role: "system",
247
+ content: [{ type: "input_text", text }],
248
+ })
249
+
250
+ export const assistantText = (text: string): Message => ({
251
+ type: "message",
252
+ role: "assistant",
253
+ content: [{ type: "output_text", text }],
254
+ })
255
+
256
+ export const functionCallOutput = (call_id: string, output: string): FunctionCallOutput => ({
257
+ type: "function_call_output",
258
+ call_id,
259
+ output,
260
+ })
@@ -0,0 +1,174 @@
1
+ import { Data, Effect, Result, Schema, Stream, pipe } from "effect"
2
+ import * as StructuredFormat from "../structured-format/StructuredFormat.js"
3
+ import {
4
+ FunctionCall,
5
+ FunctionCallOutput,
6
+ Item,
7
+ isOutputText,
8
+ isRefusal,
9
+ Message,
10
+ Reasoning,
11
+ StopReason,
12
+ Usage,
13
+ } from "./Items.js"
14
+
15
+ /**
16
+ * The result of a single LLM generation. A turn produces zero or more items
17
+ * (typically one assistant message and zero or more function_call items)
18
+ * and reports usage + a stop reason.
19
+ */
20
+ export const Turn = Schema.Struct({
21
+ items: Schema.Array(Item),
22
+ usage: Usage,
23
+ stop_reason: StopReason,
24
+ })
25
+ export type Turn = typeof Turn.Type
26
+
27
+ /**
28
+ * Canonical events emitted while a single turn is being generated. Most
29
+ * variants are streaming deltas (text, reasoning, tool-call args); the
30
+ * terminal `turn_complete` carries the assembled `Turn`. Lifecycle members
31
+ * aren't deltas, hence the union name.
32
+ */
33
+ export type TurnEvent =
34
+ | { readonly type: "text_delta"; readonly text: string }
35
+ | {
36
+ readonly type: "reasoning_delta"
37
+ readonly text: string
38
+ /**
39
+ * `trace` is the model's raw chain-of-thought; `summary` is a
40
+ * model-written summary intended for display. OpenAI Responses emits
41
+ * both as separate wire events; Anthropic and Gemini only emit
42
+ * `trace`. Consumers who just want any reasoning text match once;
43
+ * those who want only summaries filter `kind === "summary"`.
44
+ */
45
+ readonly kind: "trace" | "summary"
46
+ }
47
+ /**
48
+ * The model declined to answer. `text` is the (streamed) explanation.
49
+ * Distinct from the failure channel: a refusal is normal model output and
50
+ * the stream still completes with `turn_complete`. OpenAI Responses emits
51
+ * this; Anthropic surfaces refusals via `stop_reason`, and Gemini collapses
52
+ * them into `finishReason: SAFETY` - both go without a `refusal_delta`.
53
+ */
54
+ | { readonly type: "refusal_delta"; readonly text: string }
55
+ | { readonly type: "tool_call_start"; readonly call_id: string; readonly name: string }
56
+ | { readonly type: "tool_call_args_delta"; readonly call_id: string; readonly delta: string }
57
+ /**
58
+ * Mid-stream cumulative usage. Carries the full `Usage` (including cache
59
+ * token fields when the provider surfaces them) so consumers can drive
60
+ * live budget / cost tracking without waiting for `turn_complete`.
61
+ * Anthropic emits this on `message_start` and `message_delta`; other
62
+ * providers may not emit any `usage_update` and only deliver usage via
63
+ * `turn_complete.turn.usage`.
64
+ */
65
+ | { readonly type: "usage_update"; readonly usage: Usage }
66
+ | { readonly type: "turn_complete"; readonly turn: Turn }
67
+
68
+ /**
69
+ * What flows out of an agent loop body to its consumer per turn: every
70
+ * `TurnEvent` the provider emits (including the terminal `turn_complete`
71
+ * carrying the assembled `Turn`), plus the output of any tool the loop ran.
72
+ * Both variants carry a `type` discriminator.
73
+ */
74
+ export type InteractionEvent = TurnEvent | FunctionCallOutput
75
+
76
+ export const isTurnComplete = (d: TurnEvent): d is Extract<TurnEvent, { type: "turn_complete" }> =>
77
+ d.type === "turn_complete"
78
+
79
+ export const functionCalls = (turn: Turn): ReadonlyArray<FunctionCall> =>
80
+ turn.items.filter((i): i is FunctionCall => i.type === "function_call")
81
+
82
+ export const reasonings = (turn: Turn): ReadonlyArray<Reasoning> =>
83
+ turn.items.filter((i): i is Reasoning => i.type === "reasoning")
84
+
85
+ export const assistantMessages = (turn: Turn): ReadonlyArray<Message> =>
86
+ turn.items.filter((i): i is Message => i.type === "message" && i.role === "assistant")
87
+
88
+ /**
89
+ * State stamped with the just-completed `Turn`. Recipes use this as the
90
+ * intermediate value between "turn lands" and "compute next state": extend
91
+ * `state.history` with the turn's items, and keep the assembled turn
92
+ * around for stop-reason / usage / function-call inspection.
93
+ *
94
+ * Generic over the recipe's state shape - any record carrying a
95
+ * `history: ReadonlyArray<Item>` field works.
96
+ */
97
+ export type Cursor<S> = S & { readonly turn: Turn }
98
+
99
+ /**
100
+ * Build a `Cursor<S>` from a state record and the just-completed turn.
101
+ * Extends `state.history` with `turn.items` and stamps the turn.
102
+ */
103
+ export const cursor = <S extends { readonly history: ReadonlyArray<Item> }>(
104
+ state: S,
105
+ turn: Turn,
106
+ ): Cursor<S> => ({
107
+ ...state,
108
+ history: [...state.history, ...turn.items],
109
+ turn,
110
+ })
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Stream operators
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Project a `TurnEvent` stream onto its `text_delta` payloads. Other
118
+ * variants are dropped. Composes with `Lines.lines` +
119
+ * `decodeJsonLines` for prompted-JSONL streaming.
120
+ */
121
+ export const textDeltas = <E, R>(
122
+ self: Stream.Stream<TurnEvent, E, R>,
123
+ ): Stream.Stream<string, E, R> =>
124
+ self.pipe(
125
+ Stream.filterMap((ev) =>
126
+ ev.type === "text_delta" ? Result.succeed(ev.text) : Result.failVoid,
127
+ ),
128
+ )
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Structured-output integration
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * The assistant message on the just-completed turn was a refusal block,
136
+ * not an `output_text` payload. Returned by `toStructured` to short-circuit
137
+ * decoding before `JSON.parse` / schema validation runs.
138
+ */
139
+ export class RefusalRejected extends Data.TaggedError("RefusalRejected")<{
140
+ readonly turn: Turn
141
+ }> {}
142
+
143
+ const lastAssistantContent = (turn: Turn): { readonly text: string; readonly refused: boolean } => {
144
+ const assistants = assistantMessages(turn)
145
+ const last = assistants[assistants.length - 1]
146
+ if (last === undefined) return { text: "", refused: false }
147
+ if (last.content.some(isRefusal)) return { text: "", refused: true }
148
+ const text = last.content
149
+ .filter(isOutputText)
150
+ .map((b) => b.text)
151
+ .join("")
152
+ return { text, refused: false }
153
+ }
154
+
155
+ /**
156
+ * Validate a completed `Turn` against a `StructuredFormat`. Concatenates
157
+ * `output_text` blocks on the last assistant message, then runs
158
+ * `JSON.parse` + the format's schema validation.
159
+ *
160
+ * Three failure modes:
161
+ * - `RefusalRejected` — the assistant emitted a refusal block.
162
+ * - `JsonParseError` — the assembled text wasn't valid JSON.
163
+ * - `StructuredDecodeError` — the JSON didn't match the schema.
164
+ */
165
+ export const toStructured = <A>(
166
+ turn: Turn,
167
+ format: StructuredFormat.StructuredFormat<A>,
168
+ ): Effect.Effect<
169
+ A,
170
+ RefusalRejected | StructuredFormat.JsonParseError | StructuredFormat.StructuredDecodeError
171
+ > =>
172
+ pipe(lastAssistantContent(turn), ({ text, refused }) =>
173
+ refused ? Effect.fail(new RefusalRejected({ turn })) : StructuredFormat.parseJson(format)(text),
174
+ )
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * as AiError from "./domain/AiError.js"
2
+ export * as Items from "./domain/Items.js"
3
+ export * as Turn from "./domain/Turn.js"
4
+ export * as LanguageModel from "./language-model/LanguageModel.js"
5
+ export * as Loop from "./loop/Loop.js"
6
+ export * as Match from "./match/Match.js"
7
+ export * as Tool from "./tool/Tool.js"
8
+ export * as Toolkit from "./tool/Toolkit.js"
9
+ export * as JSONL from "./streaming/JSONL.js"
10
+ export * as Lines from "./streaming/Lines.js"
11
+ export * as SSE from "./streaming/SSE.js"
12
+ export * as StructuredFormat from "./structured-format/StructuredFormat.js"
13
+ export * as Metrics from "./observability/Metrics.js"
@@ -0,0 +1,73 @@
1
+ import { Context, Effect, Stream } from "effect"
2
+ import * as AiError from "../domain/AiError.js"
3
+ import type { Item } from "../domain/Items.js"
4
+ import type * as StructuredFormat from "../structured-format/StructuredFormat.js"
5
+ import type { ToolDescriptor } from "../tool/Tool.js"
6
+ import { isTurnComplete, type Turn, type TurnEvent } from "../domain/Turn.js"
7
+
8
+ /**
9
+ * Cross-provider request shape. Every call carries its own `history` and
10
+ * `model` - models are not bound at layer construction. Anything specific
11
+ * to a single provider (reasoning effort, prompt caching, store flags,
12
+ * ...) lives in that provider's own request interface, which extends this.
13
+ */
14
+ export interface CommonRequest {
15
+ readonly history: ReadonlyArray<Item>
16
+ /**
17
+ * Model identifier. Each provider narrows this to its typed literal union,
18
+ * so code that yields a typed provider tag gets autocompletion.
19
+ */
20
+ readonly model: string
21
+ readonly tools?: ReadonlyArray<ToolDescriptor>
22
+ readonly toolChoice?:
23
+ | "auto"
24
+ | "required"
25
+ | "none"
26
+ | { readonly type: "function"; readonly name: string }
27
+ readonly temperature?: number
28
+ readonly topP?: number
29
+ readonly maxOutputTokens?: number
30
+ /**
31
+ * Schema-bound JSON output. The provider constrains the wire to match the
32
+ * schema; pair with `Turn.toStructured` for runtime validation. Supported
33
+ * across all current providers (OpenAI Responses json_schema, Anthropic
34
+ * `output_config`, Gemini `responseJsonSchema`).
35
+ */
36
+ readonly structured?: StructuredFormat.StructuredFormat<unknown>
37
+ }
38
+
39
+ export interface LanguageModelService {
40
+ readonly streamTurn: (request: CommonRequest) => Stream.Stream<TurnEvent, AiError.AiError>
41
+ }
42
+
43
+ export class LanguageModel extends Context.Service<LanguageModel, LanguageModelService>()(
44
+ "@betalyra/effect-uai/LanguageModel",
45
+ ) {}
46
+
47
+ /**
48
+ * Stream the deltas of a single turn.
49
+ */
50
+ export const streamTurn = (
51
+ request: CommonRequest,
52
+ ): Stream.Stream<TurnEvent, AiError.AiError, LanguageModel> =>
53
+ Stream.unwrap(Effect.map(LanguageModel.asEffect(), (m) => m.streamTurn(request)))
54
+
55
+ /**
56
+ * Run a single turn to completion and return the assembled `Turn`.
57
+ *
58
+ * Implementation: drain the delta stream and pluck the terminal
59
+ * `turn_complete` event. The provider is contractually required to emit
60
+ * exactly one such event as the last delta.
61
+ */
62
+ export const turn = (request: CommonRequest): Effect.Effect<Turn, AiError.AiError, LanguageModel> =>
63
+ Effect.flatMap(Stream.runCollect(streamTurn(request)), (deltas) => {
64
+ const last = deltas[deltas.length - 1]
65
+ return last !== undefined && isTurnComplete(last)
66
+ ? Effect.succeed(last.turn)
67
+ : Effect.fail(
68
+ new AiError.Unavailable({
69
+ provider: "unknown",
70
+ raw: "Provider stream ended without a turn_complete event",
71
+ }),
72
+ )
73
+ })