@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,98 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Cause, Effect, Queue, Ref, Stream } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { expect } from "vitest"
5
+ import { accumulatePartials, type TranscriptEvent } from "./Transcript.js"
6
+
7
+ const collecting = (stream: Stream.Stream<TranscriptEvent, never>) =>
8
+ Effect.gen(function* () {
9
+ const seen = yield* Ref.make<ReadonlyArray<TranscriptEvent>>([])
10
+ yield* Effect.forkChild(Stream.runForEach(stream, (e) => Ref.update(seen, (xs) => [...xs, e])))
11
+ return seen
12
+ })
13
+
14
+ /** Deltas arriving faster than any settle window. */
15
+ const speak = (
16
+ queue: Queue.Queue<TranscriptEvent, Cause.Done>,
17
+ tokens: ReadonlyArray<string>,
18
+ ): Effect.Effect<void> =>
19
+ Effect.forEach(
20
+ tokens,
21
+ (text) =>
22
+ Effect.andThen(Queue.offer(queue, { _tag: "partial", text }), TestClock.adjust("100 millis")),
23
+ { discard: true },
24
+ )
25
+
26
+ const opened = Effect.gen(function* () {
27
+ const queue = yield* Queue.unbounded<TranscriptEvent, Cause.Done>()
28
+ const seen = yield* collecting(
29
+ Stream.fromQueue(queue).pipe(accumulatePartials({ silence: "1 second" })),
30
+ )
31
+ return { queue, seen }
32
+ })
33
+
34
+ describe("accumulatePartials", () => {
35
+ it.effect("joins deltas into the running hypothesis, forwarding each as it arrives", () =>
36
+ Effect.gen(function* () {
37
+ const { queue, seen } = yield* opened
38
+
39
+ yield* speak(queue, [" Hi", ",", " how"])
40
+
41
+ // Three in, three out, each the whole sentence so far, none delayed.
42
+ expect(yield* Ref.get(seen)).toEqual([
43
+ { _tag: "partial", text: "Hi" },
44
+ { _tag: "partial", text: "Hi," },
45
+ { _tag: "partial", text: "Hi, how" },
46
+ ])
47
+ }),
48
+ )
49
+
50
+ it.effect("commits on silence and starts the next utterance from empty", () =>
51
+ Effect.gen(function* () {
52
+ const { queue, seen } = yield* opened
53
+
54
+ yield* speak(queue, [" Hi", " there"])
55
+ yield* TestClock.adjust("3 seconds")
56
+ yield* speak(queue, [" Bye"])
57
+ yield* TestClock.adjust("3 seconds")
58
+
59
+ expect(yield* Ref.get(seen)).toEqual([
60
+ { _tag: "partial", text: "Hi" },
61
+ { _tag: "partial", text: "Hi there" },
62
+ { _tag: "final", text: "Hi there" },
63
+ { _tag: "partial", text: "Bye" },
64
+ { _tag: "final", text: "Bye" },
65
+ ])
66
+ }),
67
+ )
68
+
69
+ it.effect("a provider's own final resets the accumulator, so silence adds no duplicate", () =>
70
+ Effect.gen(function* () {
71
+ const { queue, seen } = yield* opened
72
+
73
+ yield* speak(queue, [" Hi"])
74
+ yield* Queue.offer(queue, { _tag: "final", text: "Hi." })
75
+ yield* TestClock.adjust("3 seconds")
76
+
77
+ expect(yield* Ref.get(seen)).toEqual([
78
+ { _tag: "partial", text: "Hi" },
79
+ { _tag: "final", text: "Hi." },
80
+ ])
81
+ }),
82
+ )
83
+
84
+ it.effect("commits the pending text when the stream ends mid-utterance", () =>
85
+ Effect.gen(function* () {
86
+ const { queue, seen } = yield* opened
87
+
88
+ yield* speak(queue, [" Half a sen"])
89
+ yield* Queue.end(queue)
90
+ yield* TestClock.adjust("10 millis")
91
+
92
+ expect(yield* Ref.get(seen)).toEqual([
93
+ { _tag: "partial", text: "Half a sen" },
94
+ { _tag: "final", text: "Half a sen" },
95
+ ])
96
+ }),
97
+ )
98
+ })
@@ -1,4 +1,5 @@
1
- import type { Duration } from "effect"
1
+ import { type Duration, Effect, Match, Option, Ref, Stream } from "effect"
2
+ import * as Settle from "../streaming/Settle.js"
2
3
 
3
4
  /**
4
5
  * Per-word timing + metadata. `confidence` and `speakerId` are optional
@@ -87,3 +88,54 @@ export const isMetadata = (
87
88
  ): e is Extract<TranscriptEvent, { _tag: "metadata" }> => e._tag === "metadata"
88
89
  export const isError = (e: TranscriptEvent): e is Extract<TranscriptEvent, { _tag: "error" }> =>
89
90
  e._tag === "error"
91
+
92
+ export type AccumulatePartialsOptions = {
93
+ /** Silence after the last `partial` before the text so far is committed. */
94
+ readonly silence?: Duration.Input
95
+ }
96
+
97
+ const DEFAULT_SILENCE = "700 millis"
98
+
99
+ const finalOf = (text: string): Option.Option<TranscriptEvent> =>
100
+ text.trim().length === 0 ? Option.none() : Option.some({ _tag: "final", text: text.trim() })
101
+
102
+ /**
103
+ * Join fragment `partial`s into the running hypothesis and commit it as a
104
+ * `final` after `silence`. For providers that stream token-sized deltas
105
+ * (OpenAI Realtime) or never segment; piping one that already accumulates
106
+ * repeats its text.
107
+ *
108
+ * Partials are forwarded as they arrive; only the synthetic `final` waits.
109
+ * A real `final` resets the accumulator, and the stream end flushes it.
110
+ */
111
+ export const accumulatePartials =
112
+ (options?: AccumulatePartialsOptions) =>
113
+ <E, R>(self: Stream.Stream<TranscriptEvent, E, R>): Stream.Stream<TranscriptEvent, E, R> =>
114
+ Stream.unwrap(
115
+ Effect.gen(function* () {
116
+ const acc = yield* Ref.make("")
117
+ const flush = Effect.map(Ref.getAndSet(acc, ""), finalOf)
118
+
119
+ const rewrite = (event: TranscriptEvent): Effect.Effect<TranscriptEvent> =>
120
+ Match.value(event).pipe(
121
+ Match.when({ _tag: "partial" }, (e) =>
122
+ Effect.map(
123
+ Ref.updateAndGet(acc, (text) => text + e.text),
124
+ (text): TranscriptEvent => ({ ...e, text: text.trim() }),
125
+ ),
126
+ ),
127
+ Match.when({ _tag: "final" }, (e) => Effect.as(Ref.set(acc, ""), e as TranscriptEvent)),
128
+ Match.orElse((e) => Effect.succeed(e as TranscriptEvent)),
129
+ )
130
+
131
+ return self.pipe(
132
+ Stream.mapEffect(rewrite),
133
+ Settle.onQuiet(options?.silence ?? DEFAULT_SILENCE, flush),
134
+ Stream.concat(
135
+ Stream.unwrap(
136
+ Effect.map(flush, Option.match({ onNone: () => Stream.empty, onSome: Stream.make })),
137
+ ),
138
+ ),
139
+ )
140
+ }),
141
+ )
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export * as BrowserTool from "./browser/BrowserTool.js"
6
6
  export * as Capabilities from "./capabilities/Capabilities.js"
7
7
  export * as Chunker from "./chunker/Chunker.js"
8
8
  export * as Citation from "./domain/Citation.js"
9
+ export * as Decision from "./decision-model/Decision.js"
10
+ export * as DecisionModel from "./decision-model/DecisionModel.js"
9
11
  export * as DeepResearch from "./research/DeepResearch.js"
10
12
  export * as Image from "./domain/Image.js"
11
13
  export * as Items from "./domain/Items.js"
@@ -13,6 +15,8 @@ export * as Job from "./job/Job.js"
13
15
  export * as Media from "./domain/Media.js"
14
16
  export * as Music from "./domain/Music.js"
15
17
  export * as Research from "./domain/Research.js"
18
+ export * as Realtime from "./domain/Realtime.js"
19
+ export * as RealtimeSession from "./realtime/RealtimeSession.js"
16
20
  export * as Transcript from "./domain/Transcript.js"
17
21
  export * as Turn from "./domain/Turn.js"
18
22
  export * as Embedding from "./embedding-model/Embedding.js"
@@ -35,9 +39,11 @@ export * as WebRead from "./web-read/WebRead.js"
35
39
  export * as WebReadTool from "./web-read/WebReadTool.js"
36
40
  export * as WebSearch from "./web-search/WebSearch.js"
37
41
  export * as WebSearchTool from "./web-search/WebSearchTool.js"
42
+ export * as Probability from "./math/Probability.js"
38
43
  export * as Vector from "./math/Vector.js"
39
44
  export * as Tokenizer from "./tokenizer/Tokenizer.js"
40
- export * as Inbox from "./inbox/Inbox.js"
45
+ export * as Settle from "./streaming/Settle.js"
46
+ export * as WebSocketSession from "./streaming/WebSocketSession.js"
41
47
  export * as Loop from "./loop/Loop.js"
42
48
  export * as Retry from "./retry/Retry.js"
43
49
  export * as Tool from "./tool/Tool.js"
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { confidence, margin } from "./Probability.js"
3
+
4
+ describe("confidence", () => {
5
+ it("is 1 when all mass sits on one outcome", () => {
6
+ expect(confidence([1, 0, 0])).toBeCloseTo(1, 10)
7
+ })
8
+
9
+ it("is 0 when mass is spread evenly, at any length", () => {
10
+ expect(confidence([0.5, 0.5])).toBeCloseTo(0, 10)
11
+ expect(confidence([0.25, 0.25, 0.25, 0.25])).toBeCloseTo(0, 10)
12
+ })
13
+
14
+ it("is 1 for fewer than two outcomes, which have nothing to be uncertain between", () => {
15
+ expect(confidence([])).toBe(1)
16
+ expect(confidence([1])).toBe(1)
17
+ })
18
+
19
+ it("rises as mass concentrates", () => {
20
+ const spread = confidence([0.4, 0.35, 0.25])
21
+ const leaning = confidence([0.6, 0.25, 0.15])
22
+ const decided = confidence([0.9, 0.07, 0.03])
23
+ expect(spread).toBeLessThan(leaning)
24
+ expect(leaning).toBeLessThan(decided)
25
+ })
26
+
27
+ it("is conservative on two outcomes, so a threshold does not transfer across lengths", () => {
28
+ expect(confidence([0.94, 0.06])).toBeCloseTo(0.67, 2)
29
+ })
30
+ })
31
+
32
+ describe("margin", () => {
33
+ it("is the gap between the top two, whatever order they arrive in", () => {
34
+ expect(margin([0.6, 0.3, 0.1])).toBeCloseTo(0.3, 10)
35
+ expect(margin([0.1, 0.6, 0.3])).toBeCloseTo(0.3, 10)
36
+ })
37
+
38
+ it("is 0 on a tie and 0 when empty", () => {
39
+ expect(margin([0.5, 0.5])).toBeCloseTo(0, 10)
40
+ expect(margin([])).toBe(0)
41
+ })
42
+
43
+ it("yields the lone probability for a single outcome", () => {
44
+ expect(margin([0.7])).toBeCloseTo(0.7, 10)
45
+ })
46
+ })
47
+
48
+ describe("confidence and margin disagree, which is why both exist", () => {
49
+ // Two plausible answers versus no idea. Same winner, same top mass.
50
+ const closeSecond = [0.45, 0.42, 0.13]
51
+ const flatSpread = [0.45, 0.11, 0.11, 0.11, 0.11, 0.11]
52
+
53
+ it("confidence cannot separate them", () => {
54
+ expect(Math.abs(confidence(closeSecond) - confidence(flatSpread))).toBeLessThan(0.05)
55
+ })
56
+
57
+ it("margin separates them sharply", () => {
58
+ expect(margin(closeSecond)).toBeCloseTo(0.03, 10)
59
+ expect(margin(flatSpread)).toBeCloseTo(0.34, 10)
60
+ })
61
+ })
@@ -0,0 +1,44 @@
1
+ /** Certainty measures over a probability vector. */
2
+ import { Array as A, Number as N, Option, Order, pipe } from "effect"
3
+
4
+ const descending = Order.flip(Order.Number)
5
+
6
+ /**
7
+ * 1 minus normalized Shannon entropy: 1 when all mass sits on one outcome, 0
8
+ * when spread evenly. Fewer than two outcomes yields 1.
9
+ *
10
+ * Conservative on two outcomes, where `[0.94, 0.06]` scores ~0.67, so a
11
+ * threshold tuned on a long vector does not transfer. {@link margin} is the
12
+ * more stable gate across vectors of different length.
13
+ */
14
+ export const confidence = (probabilities: ReadonlyArray<number>): number =>
15
+ probabilities.length < 2
16
+ ? 1
17
+ : 1 -
18
+ pipe(
19
+ probabilities,
20
+ A.filter((p) => p > 0),
21
+ A.map((p) => -p * Math.log(p)),
22
+ N.sumAll,
23
+ ) /
24
+ Math.log(probabilities.length)
25
+
26
+ /**
27
+ * Top mass minus runner-up mass, which unlike {@link confidence} separates a
28
+ * close second from a flat spread. A single outcome yields its own
29
+ * probability; empty yields 0.
30
+ */
31
+ export const margin = (probabilities: ReadonlyArray<number>): number => {
32
+ const top = pipe(probabilities, A.sort(descending), A.take(2))
33
+ return pipe(
34
+ A.get(top, 0),
35
+ Option.map((first) =>
36
+ pipe(
37
+ A.get(top, 1),
38
+ Option.getOrElse(() => 0),
39
+ (second) => first - second,
40
+ ),
41
+ ),
42
+ Option.getOrElse(() => 0),
43
+ )
44
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Stream } from "effect"
3
+ import { expect, expectTypeOf } from "vitest"
4
+ import type * as AiError from "../domain/AiError.js"
5
+ import { imageBase64 } from "../domain/Image.js"
6
+ import { type CommonSessionRequest, RealtimeEvent, RealtimeInput } from "../domain/Realtime.js"
7
+ import * as MockRealtimeSession from "../testing/MockRealtimeSession.js"
8
+ import { RealtimeVideoInput, open, sendVideoFrame, toolCalls } from "./RealtimeSession.js"
9
+
10
+ const pcm = { container: "raw", encoding: "pcm_s16le", sampleRate: 24000, channels: 1 } as const
11
+
12
+ const request: CommonSessionRequest = {
13
+ model: "gpt-realtime-2.1",
14
+ inputFormat: pcm,
15
+ outputFormat: pcm,
16
+ }
17
+
18
+ const call = {
19
+ type: "function_call",
20
+ call_id: "call_1",
21
+ name: "get_time",
22
+ arguments: "{}",
23
+ } as const
24
+
25
+ describe("RealtimeSession against a scripted session", () => {
26
+ it.effect("answers each input and ends `events` when the scope closes", () =>
27
+ Effect.gen(function* () {
28
+ const mock = MockRealtimeSession.layer({
29
+ initial: [RealtimeEvent.ResponseStarted({ responseId: "r1" })],
30
+ onInput: (input) =>
31
+ RealtimeInput.$is("Audio")(input)
32
+ ? [RealtimeEvent.AudioDelta({ responseId: "r1", bytes: new Uint8Array([7]) })]
33
+ : [RealtimeEvent.ResponseDone({ responseId: "r1", reason: "interrupted" })],
34
+ })
35
+
36
+ const handle = yield* Effect.scoped(
37
+ Effect.gen(function* () {
38
+ const handle = yield* open(request)
39
+ yield* handle.send(RealtimeInput.Audio({ bytes: new Uint8Array([1, 2]) }))
40
+ yield* handle.send(RealtimeInput.Interrupt())
41
+ return handle
42
+ }),
43
+ ).pipe(Effect.provide(mock.layer))
44
+
45
+ // The scope is closed, so the stream drains what is queued and ends
46
+ // rather than hanging.
47
+ const events = yield* Stream.runCollect(handle.events)
48
+ expect(events).toEqual([
49
+ RealtimeEvent.ResponseStarted({ responseId: "r1" }),
50
+ RealtimeEvent.AudioDelta({ responseId: "r1", bytes: new Uint8Array([7]) }),
51
+ RealtimeEvent.ResponseDone({ responseId: "r1", reason: "interrupted" }),
52
+ ])
53
+
54
+ const recorder = yield* mock.recorder
55
+ expect(recorder.openCalls.map((r) => r.model)).toEqual(["gpt-realtime-2.1"])
56
+ expect(recorder.sent.map((i) => i._tag)).toEqual(["Audio", "Interrupt"])
57
+ }),
58
+ )
59
+
60
+ it.effect("narrows `toolCalls` to the calls the model made", () =>
61
+ Effect.gen(function* () {
62
+ const mock = MockRealtimeSession.layer({
63
+ initial: [
64
+ RealtimeEvent.ResponseStarted({ responseId: "r1" }),
65
+ RealtimeEvent.ToolCall({ responseId: "r1", call }),
66
+ RealtimeEvent.OutputTranscriptDelta({ responseId: "r1", text: "one moment" }),
67
+ ],
68
+ })
69
+
70
+ const handle = yield* Effect.scoped(open(request)).pipe(Effect.provide(mock.layer))
71
+ const calls = yield* Stream.runCollect(toolCalls(handle.events))
72
+
73
+ expect(calls.map((e) => e.call.call_id)).toEqual(["call_1"])
74
+ }),
75
+ )
76
+ })
77
+
78
+ describe("RealtimeVideoInput marker", () => {
79
+ const frame = imageBase64("aW1n", "image/jpeg")
80
+
81
+ it("leaves the marker unsatisfied against an audio-only Layer", () => {
82
+ const mock = MockRealtimeSession.layerAudioOnly({})
83
+ const program = Effect.scoped(
84
+ Effect.flatMap(open(request), (handle) => sendVideoFrame(handle, frame)),
85
+ ).pipe(Effect.provide(mock.layer))
86
+
87
+ // `RealtimeSession` is provided, `RealtimeVideoInput` is not, so running
88
+ // this would be a type error.
89
+ expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, RealtimeVideoInput>>()
90
+ })
91
+
92
+ it("clears R to never against a Layer that ships the marker", () => {
93
+ const mock = MockRealtimeSession.layer({})
94
+ const program = Effect.scoped(
95
+ Effect.flatMap(open(request), (handle) => sendVideoFrame(handle, frame)),
96
+ ).pipe(Effect.provide(mock.layer))
97
+
98
+ expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
99
+ })
100
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A duplex speech-to-speech session. The server drives turn-taking, so this is
3
+ * its own primitive rather than a variant of `Loop`.
4
+ *
5
+ * The session is deliberately low level: tool execution, playback, barge-in
6
+ * bookkeeping and reconnection are the caller's, built from `send` and
7
+ * `events`. There is no agent wrapper, no hidden queue and no automatic
8
+ * reconnect here.
9
+ */
10
+ import { Context, Effect, type Scope, Stream } from "effect"
11
+ import type * as AiError from "../domain/AiError.js"
12
+ import type { ImageSource } from "../domain/Image.js"
13
+ import { type CommonSessionRequest, RealtimeInput, RealtimeEvent } from "../domain/Realtime.js"
14
+
15
+ /**
16
+ * The live connection. `events` ends when the socket closes cleanly; a close
17
+ * during a response ends it with `AiError.IncompleteTurn` instead, and never
18
+ * synthesizes a `ResponseDone`. `send` after close fails `Unavailable`.
19
+ */
20
+ export type RealtimeSessionHandle = {
21
+ readonly send: (input: RealtimeInput) => Effect.Effect<void, AiError.AiError>
22
+ readonly events: Stream.Stream<RealtimeEvent, AiError.AiError>
23
+ }
24
+
25
+ export type RealtimeSessionService = {
26
+ /**
27
+ * Connect and complete the provider handshake before succeeding. Closing the
28
+ * `Scope` closes the socket.
29
+ */
30
+ readonly open: (
31
+ request: CommonSessionRequest,
32
+ ) => Effect.Effect<RealtimeSessionHandle, AiError.AiError, Scope.Scope>
33
+ }
34
+
35
+ export class RealtimeSession extends Context.Service<RealtimeSession, RealtimeSessionService>()(
36
+ "@betalyra/effect-uai/RealtimeSession",
37
+ ) {}
38
+
39
+ /**
40
+ * Capability marker for sessions that accept video frames. Phantom: providers
41
+ * register with `Layer.succeed(RealtimeVideoInput, undefined)`. Only Gemini
42
+ * ships it, so `sendVideoFrame` against an OpenAI-only Layer is a type error.
43
+ */
44
+ export class RealtimeVideoInput extends Context.Service<RealtimeVideoInput, void>()(
45
+ "@betalyra/effect-uai/capability/RealtimeVideoInput",
46
+ ) {}
47
+
48
+ /** Open a session for the lifetime of the surrounding `Scope`. */
49
+ export const open = (
50
+ request: CommonSessionRequest,
51
+ ): Effect.Effect<RealtimeSessionHandle, AiError.AiError, RealtimeSession | Scope.Scope> =>
52
+ Effect.flatMap(RealtimeSession, (s) => s.open(request))
53
+
54
+ /**
55
+ * Push one video frame. Requires `RealtimeVideoInput`, so a provider without
56
+ * video fails at `Effect.provide` rather than at runtime.
57
+ */
58
+ export const sendVideoFrame = (
59
+ handle: RealtimeSessionHandle,
60
+ frame: ImageSource,
61
+ ): Effect.Effect<void, AiError.AiError, RealtimeVideoInput> =>
62
+ Effect.flatMap(RealtimeVideoInput, () => handle.send(RealtimeInput.VideoFrame({ frame })))
63
+
64
+ const isAudioDelta = RealtimeEvent.$is("AudioDelta")
65
+ const isToolCall = RealtimeEvent.$is("ToolCall")
66
+
67
+ /** Just the audio, for wiring straight to playback. */
68
+ export const audioDeltas = <E, R>(
69
+ events: Stream.Stream<RealtimeEvent, E, R>,
70
+ ): Stream.Stream<Extract<RealtimeEvent, { readonly _tag: "AudioDelta" }>, E, R> =>
71
+ Stream.filter(events, isAudioDelta)
72
+
73
+ /** Just the tool calls. Answer each with a `ToolResult`; the adapter resumes. */
74
+ export const toolCalls = <E, R>(
75
+ events: Stream.Stream<RealtimeEvent, E, R>,
76
+ ): Stream.Stream<Extract<RealtimeEvent, { readonly _tag: "ToolCall" }>, E, R> =>
77
+ Stream.filter(events, isToolCall)