@effect-uai/core 0.15.0 → 0.17.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 (114) hide show
  1. package/README.md +30 -16
  2. package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
  3. package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
  4. package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
  5. package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
  6. package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
  7. package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
  8. package/dist/chunker/Chunker.d.mts +1 -1
  9. package/dist/decision-model/Decision.d.mts +155 -0
  10. package/dist/decision-model/Decision.d.mts.map +1 -0
  11. package/dist/decision-model/Decision.mjs +118 -0
  12. package/dist/decision-model/Decision.mjs.map +1 -0
  13. package/dist/decision-model/Decision.test.mjs +164 -0
  14. package/dist/decision-model/Decision.test.mjs.map +1 -0
  15. package/dist/decision-model/DecisionModel.d.mts +45 -0
  16. package/dist/decision-model/DecisionModel.d.mts.map +1 -0
  17. package/dist/decision-model/DecisionModel.mjs +25 -0
  18. package/dist/decision-model/DecisionModel.mjs.map +1 -0
  19. package/dist/domain/AiError.d.mts +2 -2
  20. package/dist/domain/AiError.mjs +9 -2
  21. package/dist/domain/AiError.mjs.map +1 -1
  22. package/dist/domain/Realtime.d.mts +616 -0
  23. package/dist/domain/Realtime.d.mts.map +1 -0
  24. package/dist/domain/Realtime.mjs +21 -0
  25. package/dist/domain/Realtime.mjs.map +1 -0
  26. package/dist/domain/Transcript.d.mts +17 -3
  27. package/dist/domain/Transcript.d.mts.map +1 -1
  28. package/dist/domain/Transcript.mjs +30 -1
  29. package/dist/domain/Transcript.mjs.map +1 -1
  30. package/dist/domain/Transcript.test.d.mts +1 -0
  31. package/dist/domain/Transcript.test.mjs +110 -0
  32. package/dist/domain/Transcript.test.mjs.map +1 -0
  33. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  34. package/dist/image-generator/ImageGenerator.d.mts +1 -1
  35. package/dist/index.d.mts +11 -5
  36. package/dist/index.mjs +9 -3
  37. package/dist/job/Job.d.mts +1 -1
  38. package/dist/language-model/LanguageModel.d.mts +2 -2
  39. package/dist/loop/Loop.d.mts +1 -1
  40. package/dist/math/Probability.d.mts +21 -0
  41. package/dist/math/Probability.d.mts.map +1 -0
  42. package/dist/math/Probability.mjs +31 -0
  43. package/dist/math/Probability.mjs.map +1 -0
  44. package/dist/math/Probability.test.d.mts +1 -0
  45. package/dist/math/Probability.test.mjs +95 -0
  46. package/dist/math/Probability.test.mjs.map +1 -0
  47. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  48. package/dist/realtime/RealtimeSession.d.mts +50 -0
  49. package/dist/realtime/RealtimeSession.d.mts.map +1 -0
  50. package/dist/realtime/RealtimeSession.mjs +45 -0
  51. package/dist/realtime/RealtimeSession.mjs.map +1 -0
  52. package/dist/realtime/RealtimeSession.test.d.mts +1 -0
  53. package/dist/realtime/RealtimeSession.test.mjs +95 -0
  54. package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
  55. package/dist/reranker/Reranker.d.mts +1 -1
  56. package/dist/research/DeepResearch.d.mts +1 -1
  57. package/dist/retry/Retry.d.mts +1 -1
  58. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  59. package/dist/streaming/Settle.d.mts +45 -0
  60. package/dist/streaming/Settle.d.mts.map +1 -0
  61. package/dist/streaming/Settle.mjs +95 -0
  62. package/dist/streaming/Settle.mjs.map +1 -0
  63. package/dist/streaming/Settle.test.d.mts +1 -0
  64. package/dist/streaming/Settle.test.mjs +252 -0
  65. package/dist/streaming/Settle.test.mjs.map +1 -0
  66. package/dist/streaming/WebSocketSession.d.mts +36 -0
  67. package/dist/streaming/WebSocketSession.d.mts.map +1 -0
  68. package/dist/streaming/WebSocketSession.mjs +89 -0
  69. package/dist/streaming/WebSocketSession.mjs.map +1 -0
  70. package/dist/streaming/WebSocketSession.test.d.mts +1 -0
  71. package/dist/streaming/WebSocketSession.test.mjs +51 -0
  72. package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
  73. package/dist/testing/FakeWebSocket.d.mts +25 -0
  74. package/dist/testing/FakeWebSocket.d.mts.map +1 -0
  75. package/dist/testing/FakeWebSocket.mjs +79 -0
  76. package/dist/testing/FakeWebSocket.mjs.map +1 -0
  77. package/dist/testing/MockRealtimeSession.d.mts +30 -0
  78. package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
  79. package/dist/testing/MockRealtimeSession.mjs +52 -0
  80. package/dist/testing/MockRealtimeSession.mjs.map +1 -0
  81. package/dist/tool/Toolkit.d.mts +1 -1
  82. package/dist/transcriber/Transcriber.d.mts +1 -1
  83. package/dist/web-read/WebRead.d.mts +1 -1
  84. package/dist/web-read/WebReadTool.d.mts +1 -1
  85. package/dist/web-search/WebSearch.d.mts +1 -1
  86. package/dist/web-search/WebSearchTool.d.mts +1 -1
  87. package/package.json +36 -4
  88. package/src/decision-model/Decision.test.ts +146 -0
  89. package/src/decision-model/Decision.ts +273 -0
  90. package/src/decision-model/DecisionModel.ts +53 -0
  91. package/src/domain/AiError.ts +11 -0
  92. package/src/domain/Realtime.ts +101 -0
  93. package/src/domain/Transcript.test.ts +98 -0
  94. package/src/domain/Transcript.ts +53 -1
  95. package/src/index.ts +7 -1
  96. package/src/math/Probability.test.ts +61 -0
  97. package/src/math/Probability.ts +44 -0
  98. package/src/realtime/RealtimeSession.test.ts +100 -0
  99. package/src/realtime/RealtimeSession.ts +77 -0
  100. package/src/streaming/Settle.test.ts +344 -0
  101. package/src/streaming/Settle.ts +196 -0
  102. package/src/streaming/WebSocketSession.test.ts +52 -0
  103. package/src/streaming/WebSocketSession.ts +132 -0
  104. package/src/testing/FakeWebSocket.ts +120 -0
  105. package/src/testing/MockRealtimeSession.ts +92 -0
  106. package/dist/inbox/Inbox.d.mts +0 -16
  107. package/dist/inbox/Inbox.d.mts.map +0 -1
  108. package/dist/inbox/Inbox.mjs +0 -28
  109. package/dist/inbox/Inbox.mjs.map +0 -1
  110. package/dist/inbox/Inbox.test.mjs +0 -42
  111. package/dist/inbox/Inbox.test.mjs.map +0 -1
  112. package/src/inbox/Inbox.test.ts +0 -52
  113. package/src/inbox/Inbox.ts +0 -33
  114. /package/dist/{inbox/Inbox.test.d.mts → decision-model/Decision.test.d.mts} +0 -0
@@ -0,0 +1,146 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Layer, Option, Ref, Schema } from "effect"
3
+ import { expect, expectTypeOf } from "vitest"
4
+ import * as AiError from "../domain/AiError.js"
5
+ import * as Decision from "./Decision.js"
6
+ import { decide, DecisionModel, type DecisionModelService } from "./DecisionModel.js"
7
+
8
+ const triage = Decision.make({
9
+ inputSchema: Schema.Struct({ message: Schema.String }),
10
+ decisions: {
11
+ department: Decision.classify({
12
+ instructions: "Which team should handle this?",
13
+ criteria: { billing: "Payments, invoicing", technical: "Bugs, outages" },
14
+ }),
15
+ severity: Decision.rate({
16
+ instructions: "How severe is it?",
17
+ criteria: ["Cosmetic", "Degraded", "Blocking"],
18
+ }),
19
+ urgent: Decision.probability({ instructions: "Does this convey urgency?" }),
20
+ },
21
+ })
22
+
23
+ const fixture = {
24
+ department: { _tag: "Classify", labels: { billing: 0.62, technical: 0.38 } },
25
+ severity: { _tag: "Rate", levels: [0, 0.7, 0.3], legend: ["Cosmetic", "Degraded", "Blocking"] },
26
+ urgent: { _tag: "Probability", probability: 0.94 },
27
+ } as const
28
+
29
+ /**
30
+ * A fixed fixture cannot prove it satisfies the caller's `Answers<D>`. This is
31
+ * the boundary a provider casts at too, once it has decoded the wire.
32
+ */
33
+ const fixed = <D extends Decision.Decisions>(
34
+ answers: Readonly<Record<string, Decision.Answer>>,
35
+ ): Decision.Answers<D> => answers as Decision.Answers<D>
36
+
37
+ type Call = { readonly input: unknown; readonly model: string }
38
+
39
+ /** A `DecisionModel` layer that records what reached it. */
40
+ const recording = Effect.gen(function* () {
41
+ const calls = yield* Ref.make<ReadonlyArray<Call>>([])
42
+ const service: DecisionModelService = {
43
+ decide: (_definition, request) =>
44
+ Effect.as(
45
+ Ref.update(calls, (seen) => [...seen, { input: request.input, model: request.model }]),
46
+ { answers: fixed(fixture), usage: {} },
47
+ ),
48
+ }
49
+ return { calls, layer: Layer.succeed(DecisionModel, service) }
50
+ })
51
+
52
+ describe("the answer map is derived from the definition", () => {
53
+ it.effect("keeps each decision's label keys and rejects labels it never had", () =>
54
+ Effect.gen(function* () {
55
+ const { calls, layer } = yield* recording
56
+ const result = yield* decide(triage, {
57
+ model: "jev-latest",
58
+ input: { message: "hi" },
59
+ }).pipe(Effect.provide(layer))
60
+
61
+ expectTypeOf(result.answers.department.labels).toEqualTypeOf<{
62
+ readonly billing: number
63
+ readonly technical: number
64
+ }>()
65
+ // @ts-expect-error shipping is not a label of this decision
66
+ result.answers.department.labels.shipping
67
+ // @ts-expect-error a Probability answer has no labels
68
+ result.answers.urgent.labels
69
+
70
+ // `decide` forwards both arguments rather than dropping either.
71
+ expect(yield* Ref.get(calls)).toEqual([{ input: { message: "hi" }, model: "jev-latest" }])
72
+ }),
73
+ )
74
+
75
+ it("keeps the rubric as a tuple, so levels are positional rather than parsed", () => {
76
+ type Severity = (typeof triage)["decisions"]["severity"]
77
+ expectTypeOf<Decision.AnswerFor<Severity>["levels"]>().toEqualTypeOf<
78
+ readonly [number, number, number]
79
+ >()
80
+ expectTypeOf<Decision.AnswerFor<Severity>["legend"]>().toEqualTypeOf<
81
+ readonly ["Cosmetic", "Degraded", "Blocking"]
82
+ >()
83
+ })
84
+ })
85
+
86
+ describe("reading an answer", () => {
87
+ it("derives a Probability answer's distribution as [p, 1 - p]", () => {
88
+ const [yes, no] = Decision.probabilitiesOf(fixture.urgent)
89
+ expect(yes).toBeCloseTo(0.94, 10)
90
+ expect(no).toBeCloseTo(0.06, 10)
91
+ })
92
+
93
+ it("ranks labels best first and names the winner", () => {
94
+ expect(Decision.ranked(fixture.department)).toEqual([
95
+ { label: "billing", probability: 0.62 },
96
+ { label: "technical", probability: 0.38 },
97
+ ])
98
+ expect(Decision.winner(fixture.department)).toStrictEqual(Option.some("billing"))
99
+ })
100
+
101
+ it("has no winner for an empty label set", () => {
102
+ expect(Decision.winner({ _tag: "Classify", labels: {} })).toStrictEqual(Option.none())
103
+ })
104
+
105
+ it("reads a rubric by index and by expected value", () => {
106
+ expect(Decision.topLevel(fixture.severity)).toBe(1)
107
+ expect(Decision.expectedLevel(fixture.severity)).toBeCloseTo(1.3, 10)
108
+ expect(fixture.severity.legend[Decision.topLevel(fixture.severity)]).toBe("Degraded")
109
+ })
110
+
111
+ it("resolves a level tie to the lowest index", () => {
112
+ expect(Decision.topLevel({ _tag: "Rate", levels: [0.5, 0.5], legend: ["a", "b"] })).toBe(0)
113
+ })
114
+
115
+ it("computes certainty for every kind, including Probability", () => {
116
+ expect(Decision.confidence(fixture.urgent)).toBeCloseTo(0.67, 2)
117
+ expect(Decision.margin(fixture.urgent)).toBeCloseTo(0.88, 10)
118
+ expect(Decision.margin(fixture.department)).toBeCloseTo(0.24, 10)
119
+ })
120
+ })
121
+
122
+ describe("assertKinds", () => {
123
+ const supported = ["Classify", "Probability"] as const
124
+
125
+ it.effect("passes when every kind is supported", () => {
126
+ const { department, urgent } = triage.decisions
127
+ return Decision.assertKinds({ department, urgent }, supported, "nli")
128
+ })
129
+
130
+ it.effect("fails Unsupported and names every offender at once", () =>
131
+ Effect.gen(function* () {
132
+ const decisions = {
133
+ ...triage.decisions,
134
+ impact: Decision.rate({ instructions: "How bad?", criteria: ["low", "high"] }),
135
+ }
136
+ const error = yield* Effect.flip(Decision.assertKinds(decisions, supported, "nli"))
137
+
138
+ expect(error).toBeInstanceOf(AiError.Unsupported)
139
+ if (error._tag !== "Unsupported") return
140
+
141
+ expect(error.reason).toContain('"severity" is a Rate')
142
+ expect(error.reason).toContain('"impact" is a Rate')
143
+ expect(error.reason).not.toContain("department")
144
+ }),
145
+ )
146
+ })
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Typed questions about one input, answered with probabilities.
3
+ *
4
+ * A {@link Definition} pairs the shape being read with the questions asked of
5
+ * it. Build one at module scope and apply it per call with
6
+ * `DecisionModel.decide`.
7
+ *
8
+ * @experimental The model class this abstracts is new and the shape may change.
9
+ */
10
+ import {
11
+ Array as A,
12
+ Effect,
13
+ Match,
14
+ Number as N,
15
+ Option,
16
+ Order,
17
+ Record,
18
+ type Schema,
19
+ pipe,
20
+ } from "effect"
21
+ import * as AiError from "../domain/AiError.js"
22
+ import * as Probability from "../math/Probability.js"
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Criteria
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Labels mapped to what each one means. */
29
+ export type Labels = Readonly<Record<string, string>>
30
+
31
+ /** An ordered rubric, lowest level first. */
32
+ export type Levels = readonly [string, string, ...ReadonlyArray<string>]
33
+
34
+ /** What a true and a false answer mean, when the boundary is subtle. */
35
+ export type Boundary = { readonly true?: string; readonly false?: string }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Decisions
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export type ClassifyDecision<L extends Labels = Labels> = {
42
+ readonly _tag: "Classify"
43
+ readonly instructions: string
44
+ readonly criteria: L
45
+ }
46
+
47
+ export type RateDecision<V extends Levels = Levels> = {
48
+ readonly _tag: "Rate"
49
+ readonly instructions: string
50
+ readonly criteria: V
51
+ }
52
+
53
+ export type ProbabilityDecision = {
54
+ readonly _tag: "Probability"
55
+ readonly instructions: string
56
+ readonly criteria?: Boundary
57
+ }
58
+
59
+ export type Decision = ClassifyDecision | RateDecision | ProbabilityDecision
60
+
61
+ /** Pick one label. Use {@link rate} when the options form a spectrum. */
62
+ export const classify = <const L extends Labels>(decision: {
63
+ readonly instructions: string
64
+ readonly criteria: L
65
+ }): ClassifyDecision<L> => ({ _tag: "Classify", ...decision })
66
+
67
+ /** Place the input on an ordered rubric. */
68
+ export const rate = <const V extends Levels>(decision: {
69
+ readonly instructions: string
70
+ readonly criteria: V
71
+ }): RateDecision<V> => ({ _tag: "Rate", ...decision })
72
+
73
+ /**
74
+ * How likely a statement is to hold. Named for its answer rather than for an
75
+ * act, unlike its siblings, so nobody reads the result as a boolean.
76
+ */
77
+ export const probability = (decision: {
78
+ readonly instructions: string
79
+ readonly criteria?: Boundary
80
+ }): ProbabilityDecision => ({ _tag: "Probability", ...decision })
81
+
82
+ /** Decisions keyed by the name their answer comes back under. */
83
+ export type Decisions = { readonly [name: string]: Decision }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Definition
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * A reusable decision set bound to the shape it reads.
91
+ *
92
+ * `inputSchema` encodes the input on the way out, so a schema that omits a
93
+ * field keeps it from the model. That projection is the supported way to hold
94
+ * irrelevant or sensitive context out of a request.
95
+ */
96
+ export type Definition<A_, D extends Decisions = Decisions> = {
97
+ readonly inputSchema: Schema.Codec<A_, unknown, any, never>
98
+ readonly decisions: D
99
+ }
100
+
101
+ /**
102
+ * Build a decision set. Declare it once at module scope: the questions and
103
+ * their rubrics are the reviewed artifact, the input changes per call.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * const triage = Decision.make({
108
+ * inputSchema: Schema.Struct({ message: Schema.String }),
109
+ * decisions: {
110
+ * department: Decision.classify({
111
+ * instructions: "Which team should handle this?",
112
+ * criteria: { billing: "Payments, invoicing", technical: "Bugs, outages" },
113
+ * }),
114
+ * },
115
+ * })
116
+ * ```
117
+ */
118
+ export const make = <
119
+ S extends Schema.Codec<any, any, any, never>,
120
+ const D extends Decisions,
121
+ >(definition: {
122
+ readonly inputSchema: S
123
+ readonly decisions: D
124
+ }): Definition<S["Type"], D> => definition
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Answers
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Score contract for {@link ClassifyAnswer.labels} and
132
+ * {@link RateAnswer.levels}: probabilities sum to 1 within floating-point
133
+ * error and are comparable within one answer. Whether they are *calibrated*,
134
+ * so a stated 0.8 means right about 80% of the time, is a property of the
135
+ * Layer and is documented per provider. No implementor promises calibration
136
+ * across requests, across decision kinds, or between a statement and its
137
+ * negation: `P(claim) != 1 - P(not claim)`. A threshold fitted against one
138
+ * model does not transfer to another.
139
+ */
140
+ export type ClassifyAnswer<L extends Labels = Labels> = {
141
+ readonly _tag: "Classify"
142
+ readonly labels: Readonly<Record<keyof L & string, number>>
143
+ }
144
+
145
+ export type RateAnswer<V extends Levels = Levels> = {
146
+ readonly _tag: "Rate"
147
+ /** Index-aligned with {@link RateAnswer.legend}. */
148
+ readonly levels: { readonly [K in keyof V]: number }
149
+ /** The rubric, echoed so an index reads back as prose. */
150
+ readonly legend: Readonly<V>
151
+ }
152
+
153
+ export type ProbabilityAnswer = {
154
+ readonly _tag: "Probability"
155
+ readonly probability: number
156
+ }
157
+
158
+ export type Answer = ClassifyAnswer | RateAnswer | ProbabilityAnswer
159
+
160
+ /** The answer a decision determines, keeping its labels or rubric. */
161
+ export type AnswerFor<D> = D extends {
162
+ readonly _tag: "Classify"
163
+ readonly criteria: infer L extends Labels
164
+ }
165
+ ? ClassifyAnswer<L>
166
+ : D extends { readonly _tag: "Rate"; readonly criteria: infer V extends Levels }
167
+ ? RateAnswer<V>
168
+ : ProbabilityAnswer
169
+
170
+ /** Answers keyed by their decision's name. */
171
+ export type Answers<D extends Decisions> = { readonly [K in keyof D]: AnswerFor<D[K]> }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Guards
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Fail `AiError.Unsupported` naming every decision whose kind this provider
179
+ * cannot express. Bucket 1: omitting an answer would leave a hole where
180
+ * {@link Answers} promises a key.
181
+ *
182
+ * Reports all offenders at once, since the definition is known up front.
183
+ */
184
+ export const assertKinds = (
185
+ decisions: Decisions,
186
+ supported: ReadonlyArray<Decision["_tag"]>,
187
+ provider: string,
188
+ ): Effect.Effect<void, AiError.AiError> => {
189
+ const offenders = pipe(
190
+ Record.toEntries(decisions),
191
+ A.filter(([, decision]) => !supported.includes(decision._tag)),
192
+ A.map(([name, decision]) => `"${name}" is a ${decision._tag}`),
193
+ )
194
+ return offenders.length === 0
195
+ ? Effect.void
196
+ : Effect.fail(
197
+ new AiError.Unsupported({
198
+ provider,
199
+ capability: "decision",
200
+ reason: `${provider} answers ${supported.join(" / ")} only; ${offenders.join(", ")}.`,
201
+ }),
202
+ )
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Reading an answer
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /** A `Probability` answer's implied distribution is `[p, 1 - p]`. */
210
+ export const probabilitiesOf: (answer: Answer) => ReadonlyArray<number> = Match.type<Answer>().pipe(
211
+ Match.discriminatorsExhaustive("_tag")({
212
+ Classify: (answer) => Record.values(answer.labels),
213
+ Rate: (answer) => answer.levels,
214
+ Probability: (answer) => [answer.probability, 1 - answer.probability],
215
+ }),
216
+ )
217
+
218
+ /** See {@link Probability.confidence}. Defined for every kind. */
219
+ export const confidence = (answer: Answer): number =>
220
+ Probability.confidence(probabilitiesOf(answer))
221
+
222
+ /** See {@link Probability.margin}. Defined for every kind. */
223
+ export const margin = (answer: Answer): number => Probability.margin(probabilitiesOf(answer))
224
+
225
+ const byProbability = Order.mapInput(
226
+ Order.flip(Order.Number),
227
+ (outcome: { readonly probability: number }) => outcome.probability,
228
+ )
229
+
230
+ /**
231
+ * Labels best first. Ties keep first-seen order. Also the bridge to rank
232
+ * fusion: map to `label` and pass alongside another retriever's ordering.
233
+ */
234
+ export const ranked = <L extends Labels>(
235
+ answer: ClassifyAnswer<L>,
236
+ ): ReadonlyArray<{ readonly label: keyof L & string; readonly probability: number }> =>
237
+ pipe(
238
+ Record.toEntries(answer.labels),
239
+ A.map(([label, probability]) => ({ label, probability })),
240
+ A.sort(byProbability),
241
+ )
242
+
243
+ /** Highest-probability label. `None` only when the label set is empty. */
244
+ export const winner = <L extends Labels>(
245
+ answer: ClassifyAnswer<L>,
246
+ ): Option.Option<keyof L & string> =>
247
+ pipe(
248
+ ranked(answer),
249
+ A.head,
250
+ Option.map((outcome) => outcome.label),
251
+ )
252
+
253
+ /** Index of the highest-probability level. Ties resolve to the lowest. */
254
+ export const topLevel = (answer: RateAnswer): number =>
255
+ pipe(
256
+ answer.levels as ReadonlyArray<number>,
257
+ A.reduce({ index: 0, probability: -Infinity }, (best, probability, index) =>
258
+ probability > best.probability ? { index, probability } : best,
259
+ ),
260
+ (best) => best.index,
261
+ )
262
+
263
+ /**
264
+ * Probability-weighted mean of the level indices, so it can land between
265
+ * them. Weakly calibrated in practice: a summary, not a magnitude to
266
+ * interpolate. Use {@link topLevel} with `legend` to name a level instead.
267
+ */
268
+ export const expectedLevel = (answer: RateAnswer): number =>
269
+ pipe(
270
+ answer.levels as ReadonlyArray<number>,
271
+ A.map((probability, index) => index * probability),
272
+ N.sumAll,
273
+ )
@@ -0,0 +1,53 @@
1
+ import { Context, Effect } from "effect"
2
+ import type * as AiError from "../domain/AiError.js"
3
+ import type * as Decision from "./Decision.js"
4
+
5
+ /** Optional throughout: not every backing reports token counts. */
6
+ export type DecideUsage = {
7
+ readonly inputTokens?: number
8
+ readonly outputTokens?: number
9
+ }
10
+
11
+ export type DecideResponse<D extends Decision.Decisions = Decision.Decisions> = {
12
+ readonly answers: Decision.Answers<D>
13
+ readonly usage: DecideUsage
14
+ }
15
+
16
+ /**
17
+ * Cross-provider decide request. The input is billed once for the whole
18
+ * definition, so batching decisions is cheaper than repeating the input.
19
+ */
20
+ export type CommonDecideRequest<A> = {
21
+ readonly input: A
22
+ /** Each provider narrows this to its typed literal union. */
23
+ readonly model: string
24
+ }
25
+
26
+ export type DecisionModelService = {
27
+ readonly decide: <A, D extends Decision.Decisions>(
28
+ definition: Decision.Definition<A, D>,
29
+ request: CommonDecideRequest<A>,
30
+ ) => Effect.Effect<DecideResponse<D>, AiError.AiError>
31
+ }
32
+
33
+ /**
34
+ * Answering typed questions about an input with probabilities instead of
35
+ * generating text. The model reports beliefs; the decision stays in the
36
+ * caller's code.
37
+ *
38
+ * Implementor contract: answer every decision or fail. Omitting one leaves a
39
+ * hole where {@link DecideResponse} promises a key, so a kind the backing
40
+ * cannot express is rejected up front with `Decision.assertKinds`.
41
+ *
42
+ * @experimental The model class this abstracts is new and the shape may change.
43
+ */
44
+ export class DecisionModel extends Context.Service<DecisionModel, DecisionModelService>()(
45
+ "@betalyra/effect-uai/DecisionModel",
46
+ ) {}
47
+
48
+ /** Answer a definition's decisions about one input in one call. */
49
+ export const decide = <A, D extends Decision.Decisions>(
50
+ definition: Decision.Definition<A, D>,
51
+ request: CommonDecideRequest<A>,
52
+ ): Effect.Effect<DecideResponse<D>, AiError.AiError, DecisionModel> =>
53
+ Effect.flatMap(DecisionModel, (m) => m.decide(definition, request))
@@ -100,6 +100,15 @@ export class Unsupported extends Data.TaggedError("Unsupported")<{
100
100
  reason?: string
101
101
  }> {}
102
102
 
103
+ /**
104
+ * A long-lived session hit the provider's lifetime cap. Distinct from
105
+ * `Unavailable`: reconnecting works, retrying the same session does not.
106
+ */
107
+ export class SessionExpired extends Data.TaggedError("SessionExpired")<{
108
+ provider: string
109
+ raw?: unknown
110
+ }> {}
111
+
103
112
  export type AiError =
104
113
  | RateLimited
105
114
  | Unavailable
@@ -112,6 +121,7 @@ export type AiError =
112
121
  | IncompleteTurn
113
122
  | GenerationFailed
114
123
  | Unsupported
124
+ | SessionExpired
115
125
 
116
126
  const withReason = (base: string, reason: string | undefined): string =>
117
127
  reason === undefined ? base : `${base}: ${reason}`
@@ -136,5 +146,6 @@ export const describe: (e: AiError) => string = Match.type<AiError>().pipe(
136
146
  IncompleteTurn: () => "the provider stream ended without completing",
137
147
  GenerationFailed: (e) => e.message ?? `${e.provider} failed mid-generation`,
138
148
  Unsupported: (e) => withReason(`${e.provider} does not support ${e.capability}`, e.reason),
149
+ SessionExpired: (e) => `the ${e.provider} session reached its lifetime limit`,
139
150
  }),
140
151
  )
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Types for a duplex speech-to-speech session: one long-lived connection the
3
+ * server drives, rather than a caller-driven turn.
4
+ *
5
+ * Only what both OpenAI Realtime and Gemini Live do natively lives here. VAD
6
+ * knobs, truncation, resumption, thinking, grounding and noise reduction are
7
+ * provider-typed on each adapter's request.
8
+ */
9
+ import { Data, type Duration } from "effect"
10
+ import type { AudioFormat } from "./Audio.js"
11
+ import type { ImageSource } from "./Image.js"
12
+ import type { HistoryItem, ToolCall, ToolCallOutput, Usage } from "./Items.js"
13
+ import type { ToolDescriptor } from "../tool/Tool.js"
14
+
15
+ /**
16
+ * What the caller pushes into a live session.
17
+ *
18
+ * - `Audio`: encoded per `request.inputFormat`. The adapter neither paces nor
19
+ * buffers; send at real time.
20
+ * - `Text`: role `system` maps to a system item where the provider has one and
21
+ * fails `Unsupported` where it does not.
22
+ * - `Interrupt`: cancel the response in flight.
23
+ * - `VideoFrame`: gated by the `RealtimeVideoInput` marker.
24
+ * - `ActivityStart` / `ActivityEnd`: manual turn detection only; `ActivityEnd`
25
+ * also asks for a response. Dropped with a warning on a `"server"` session.
26
+ * - `PlaybackPosition`: how much of a response the user actually heard, so the
27
+ * adapter can trim the rest from context. A no-op where the wire has no
28
+ * truncate.
29
+ */
30
+ export type RealtimeInput = Data.TaggedEnum<{
31
+ Audio: { readonly bytes: Uint8Array }
32
+ Text: { readonly text: string; readonly role?: "user" | "system" }
33
+ ToolResult: { readonly output: ToolCallOutput }
34
+ Interrupt: {}
35
+ VideoFrame: { readonly frame: ImageSource }
36
+ ActivityStart: {}
37
+ ActivityEnd: {}
38
+ PlaybackPosition: { readonly responseId: string; readonly playedMs: number }
39
+ }>
40
+
41
+ export const RealtimeInput = Data.taggedEnum<RealtimeInput>()
42
+
43
+ /**
44
+ * What the session emits. Every `AudioDelta` and `OutputTranscriptDelta` is
45
+ * bracketed by `ResponseStarted` and `ResponseDone` carrying the same
46
+ * `responseId`.
47
+ *
48
+ * - `InputTranscript`: transcription of the user's own speech.
49
+ * - `SpeechStarted` / `SpeechStopped`: sparse. OpenAI emits them, Gemini
50
+ * does not.
51
+ * - `Interrupted`: stop and flush playback now. Always precedes the
52
+ * `ResponseDone { reason: "interrupted" }` for that response.
53
+ * - `ToolCallCancelled`: calls of an interrupted response that will never be
54
+ * answered.
55
+ * - `ResumptionHandle`: Gemini only; pass it back as `request.resume`.
56
+ * - `SessionEnding`: the connection is about to close on the server's terms.
57
+ * - `Error`: non-fatal. A fatal error fails the stream instead.
58
+ */
59
+ export type RealtimeEvent = Data.TaggedEnum<{
60
+ ResponseStarted: { readonly responseId: string }
61
+ AudioDelta: { readonly responseId: string; readonly bytes: Uint8Array }
62
+ OutputTranscriptDelta: { readonly responseId: string; readonly text: string }
63
+ InputTranscript: { readonly text: string; readonly final: boolean }
64
+ SpeechStarted: {}
65
+ SpeechStopped: {}
66
+ ToolCall: { readonly responseId: string; readonly call: ToolCall }
67
+ ToolCallCancelled: { readonly callIds: ReadonlyArray<string> }
68
+ Interrupted: { readonly responseId: string }
69
+ ResponseDone: {
70
+ readonly responseId: string
71
+ readonly reason: "complete" | "interrupted" | "cancelled" | "error"
72
+ readonly usage?: Usage
73
+ }
74
+ ResumptionHandle: { readonly handle: string }
75
+ SessionEnding: { readonly timeLeft?: Duration.Duration }
76
+ Error: { readonly code?: string; readonly message: string }
77
+ }>
78
+
79
+ export const RealtimeEvent = Data.taggedEnum<RealtimeEvent>()
80
+
81
+ /**
82
+ * Cross-provider session setup. Each adapter extends this, narrowing `model`
83
+ * and adding its own knobs.
84
+ */
85
+ export type CommonSessionRequest = {
86
+ readonly model: string
87
+ readonly instructions?: string
88
+ readonly voiceId?: string
89
+ /** `Toolkit.descriptors(toolkit)`. Immutable for the connection on Gemini. */
90
+ readonly tools?: ReadonlyArray<ToolDescriptor>
91
+ readonly inputFormat: AudioFormat
92
+ readonly outputFormat: AudioFormat
93
+ /** Who decides a turn ended. Defaults to `"server"`. */
94
+ readonly turnDetection?: "server" | "manual"
95
+ /** Transcribe the user's own audio. Defaults to `true`. */
96
+ readonly transcribeInput?: boolean
97
+ /** Text-only conversation seed. */
98
+ readonly history?: ReadonlyArray<HistoryItem>
99
+ /** A `ResumptionHandle` from a previous session. `Unsupported` off Gemini. */
100
+ readonly resume?: string
101
+ }