@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.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/AiError-CqmYjXyx.d.mts +110 -0
- package/dist/AiError-CqmYjXyx.d.mts.map +1 -0
- package/dist/Items-D1C2686t.d.mts +372 -0
- package/dist/Items-D1C2686t.d.mts.map +1 -0
- package/dist/Loop-CzSJo1h8.d.mts +87 -0
- package/dist/Loop-CzSJo1h8.d.mts.map +1 -0
- package/dist/Outcome-C2JYknCu.d.mts +40 -0
- package/dist/Outcome-C2JYknCu.d.mts.map +1 -0
- package/dist/StructuredFormat-B5ueioNr.d.mts +88 -0
- package/dist/StructuredFormat-B5ueioNr.d.mts.map +1 -0
- package/dist/Tool-5wxOCuOh.d.mts +86 -0
- package/dist/Tool-5wxOCuOh.d.mts.map +1 -0
- package/dist/ToolEvent-B2N10hr3.d.mts +29 -0
- package/dist/ToolEvent-B2N10hr3.d.mts.map +1 -0
- package/dist/Turn-rlTfuHaQ.d.mts +211 -0
- package/dist/Turn-rlTfuHaQ.d.mts.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/domain/AiError.d.mts +2 -0
- package/dist/domain/AiError.mjs +40 -0
- package/dist/domain/AiError.mjs.map +1 -0
- package/dist/domain/Items.d.mts +2 -0
- package/dist/domain/Items.mjs +238 -0
- package/dist/domain/Items.mjs.map +1 -0
- package/dist/domain/Turn.d.mts +2 -0
- package/dist/domain/Turn.mjs +82 -0
- package/dist/domain/Turn.mjs.map +1 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +14 -0
- package/dist/language-model/LanguageModel.d.mts +60 -0
- package/dist/language-model/LanguageModel.d.mts.map +1 -0
- package/dist/language-model/LanguageModel.mjs +33 -0
- package/dist/language-model/LanguageModel.mjs.map +1 -0
- package/dist/loop/Loop.d.mts +2 -0
- package/dist/loop/Loop.mjs +172 -0
- package/dist/loop/Loop.mjs.map +1 -0
- package/dist/match/Match.d.mts +16 -0
- package/dist/match/Match.d.mts.map +1 -0
- package/dist/match/Match.mjs +15 -0
- package/dist/match/Match.mjs.map +1 -0
- package/dist/observability/Metrics.d.mts +45 -0
- package/dist/observability/Metrics.d.mts.map +1 -0
- package/dist/observability/Metrics.mjs +52 -0
- package/dist/observability/Metrics.mjs.map +1 -0
- package/dist/streaming/JSONL.d.mts +34 -0
- package/dist/streaming/JSONL.d.mts.map +1 -0
- package/dist/streaming/JSONL.mjs +51 -0
- package/dist/streaming/JSONL.mjs.map +1 -0
- package/dist/streaming/Lines.d.mts +27 -0
- package/dist/streaming/Lines.d.mts.map +1 -0
- package/dist/streaming/Lines.mjs +32 -0
- package/dist/streaming/Lines.mjs.map +1 -0
- package/dist/streaming/SSE.d.mts +31 -0
- package/dist/streaming/SSE.d.mts.map +1 -0
- package/dist/streaming/SSE.mjs +58 -0
- package/dist/streaming/SSE.mjs.map +1 -0
- package/dist/structured-format/StructuredFormat.d.mts +2 -0
- package/dist/structured-format/StructuredFormat.mjs +68 -0
- package/dist/structured-format/StructuredFormat.mjs.map +1 -0
- package/dist/testing/MockProvider.d.mts +48 -0
- package/dist/testing/MockProvider.d.mts.map +1 -0
- package/dist/testing/MockProvider.mjs +95 -0
- package/dist/testing/MockProvider.mjs.map +1 -0
- package/dist/tool/HistoryCheck.d.mts +24 -0
- package/dist/tool/HistoryCheck.d.mts.map +1 -0
- package/dist/tool/HistoryCheck.mjs +39 -0
- package/dist/tool/HistoryCheck.mjs.map +1 -0
- package/dist/tool/Outcome.d.mts +2 -0
- package/dist/tool/Outcome.mjs +45 -0
- package/dist/tool/Outcome.mjs.map +1 -0
- package/dist/tool/Resolvers.d.mts +44 -0
- package/dist/tool/Resolvers.d.mts.map +1 -0
- package/dist/tool/Resolvers.mjs +67 -0
- package/dist/tool/Resolvers.mjs.map +1 -0
- package/dist/tool/Tool.d.mts +2 -0
- package/dist/tool/Tool.mjs +79 -0
- package/dist/tool/Tool.mjs.map +1 -0
- package/dist/tool/ToolEvent.d.mts +2 -0
- package/dist/tool/ToolEvent.mjs +8 -0
- package/dist/tool/ToolEvent.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +34 -0
- package/dist/tool/Toolkit.d.mts.map +1 -0
- package/dist/tool/Toolkit.mjs +105 -0
- package/dist/tool/Toolkit.mjs.map +1 -0
- package/package.json +127 -0
- package/src/domain/AiError.ts +93 -0
- package/src/domain/Items.ts +260 -0
- package/src/domain/Turn.ts +174 -0
- package/src/index.ts +13 -0
- package/src/language-model/LanguageModel.ts +73 -0
- package/src/loop/Loop.test.ts +412 -0
- package/src/loop/Loop.ts +295 -0
- package/src/match/Match.ts +9 -0
- package/src/observability/Metrics.ts +87 -0
- package/src/streaming/JSONL.test.ts +85 -0
- package/src/streaming/JSONL.ts +96 -0
- package/src/streaming/Lines.ts +34 -0
- package/src/streaming/SSE.test.ts +72 -0
- package/src/streaming/SSE.ts +114 -0
- package/src/structured-format/StructuredFormat.ts +160 -0
- package/src/testing/MockProvider.ts +161 -0
- package/src/tool/HistoryCheck.ts +49 -0
- package/src/tool/Outcome.ts +101 -0
- package/src/tool/Resolvers.test.ts +426 -0
- package/src/tool/Resolvers.ts +166 -0
- package/src/tool/Tool.ts +150 -0
- package/src/tool/ToolEvent.ts +37 -0
- package/src/tool/Toolkit.test.ts +45 -0
- 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
|
+
})
|