@effect-uai/core 0.5.2 → 0.6.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 (123) hide show
  1. package/README.md +1 -1
  2. package/dist/{Items-BH8xUkoR.d.mts → Items-pXR5BAgW.d.mts} +12 -12
  3. package/dist/Items-pXR5BAgW.d.mts.map +1 -0
  4. package/dist/SandboxError-BvKypkJm.d.mts +158 -0
  5. package/dist/SandboxError-BvKypkJm.d.mts.map +1 -0
  6. package/dist/{Tool-87ViKCCO.d.mts → Tool-DP-AwKOf.d.mts} +11 -11
  7. package/dist/{Tool-87ViKCCO.d.mts.map → Tool-DP-AwKOf.d.mts.map} +1 -1
  8. package/dist/domain/Items.d.mts +2 -2
  9. package/dist/domain/Items.mjs +15 -15
  10. package/dist/domain/Items.mjs.map +1 -1
  11. package/dist/domain/Turn.d.mts +388 -2
  12. package/dist/domain/Turn.d.mts.map +1 -0
  13. package/dist/domain/Turn.mjs +13 -13
  14. package/dist/domain/Turn.mjs.map +1 -1
  15. package/dist/index.d.mts +11 -6
  16. package/dist/index.mjs +8 -3
  17. package/dist/language-model/LanguageModel.d.mts +5 -5
  18. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  19. package/dist/language-model/LanguageModel.mjs.map +1 -1
  20. package/dist/loop/Loop.d.mts +37 -90
  21. package/dist/loop/Loop.d.mts.map +1 -1
  22. package/dist/loop/Loop.mjs +39 -93
  23. package/dist/loop/Loop.mjs.map +1 -1
  24. package/dist/loop/Loop.test.mjs +46 -54
  25. package/dist/loop/Loop.test.mjs.map +1 -1
  26. package/dist/sandbox/Memory.d.mts +53 -0
  27. package/dist/sandbox/Memory.d.mts.map +1 -0
  28. package/dist/sandbox/Memory.mjs +68 -0
  29. package/dist/sandbox/Memory.mjs.map +1 -0
  30. package/dist/sandbox/Sandbox.d.mts +717 -0
  31. package/dist/sandbox/Sandbox.d.mts.map +1 -0
  32. package/dist/sandbox/Sandbox.mjs +192 -0
  33. package/dist/sandbox/Sandbox.mjs.map +1 -0
  34. package/dist/sandbox/SandboxError.d.mts +2 -0
  35. package/dist/sandbox/SandboxError.mjs +89 -0
  36. package/dist/sandbox/SandboxError.mjs.map +1 -0
  37. package/dist/sandbox/SandboxImage.d.mts +28 -0
  38. package/dist/sandbox/SandboxImage.d.mts.map +1 -0
  39. package/dist/sandbox/SandboxImage.mjs +32 -0
  40. package/dist/sandbox/SandboxImage.mjs.map +1 -0
  41. package/dist/sandbox/SandboxNetwork.d.mts +34 -0
  42. package/dist/sandbox/SandboxNetwork.d.mts.map +1 -0
  43. package/dist/sandbox/SandboxNetwork.mjs +36 -0
  44. package/dist/sandbox/SandboxNetwork.mjs.map +1 -0
  45. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +98 -2
  46. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -1
  47. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +32 -2
  48. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
  49. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +56 -2
  50. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -1
  51. package/dist/testing/MockProvider.d.mts +3 -3
  52. package/dist/testing/MockProvider.d.mts.map +1 -1
  53. package/dist/testing/MockProvider.mjs.map +1 -1
  54. package/dist/testing/MockSandbox.d.mts +604 -0
  55. package/dist/testing/MockSandbox.d.mts.map +1 -0
  56. package/dist/testing/MockSandbox.mjs +160 -0
  57. package/dist/testing/MockSandbox.mjs.map +1 -0
  58. package/dist/testing/MockSpeechSynthesizer.d.mts +26 -11
  59. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
  60. package/dist/testing/MockSpeechSynthesizer.mjs +75 -51
  61. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
  62. package/dist/tool/{Resolvers.d.mts → Approval.d.mts} +27 -27
  63. package/dist/tool/Approval.d.mts.map +1 -0
  64. package/dist/tool/{Resolvers.mjs → Approval.mjs} +18 -18
  65. package/dist/tool/Approval.mjs.map +1 -0
  66. package/dist/tool/{Resolvers.test.mjs → Approval.test.mjs} +40 -40
  67. package/dist/tool/Approval.test.mjs.map +1 -0
  68. package/dist/tool/HistoryCheck.d.mts +6 -6
  69. package/dist/tool/HistoryCheck.d.mts.map +1 -1
  70. package/dist/tool/HistoryCheck.mjs +5 -5
  71. package/dist/tool/HistoryCheck.mjs.map +1 -1
  72. package/dist/tool/Tool.d.mts +2 -2
  73. package/dist/tool/Tool.mjs +4 -4
  74. package/dist/tool/Tool.mjs.map +1 -1
  75. package/dist/tool/ToolEvent.d.mts +19 -19
  76. package/dist/tool/ToolEvent.d.mts.map +1 -1
  77. package/dist/tool/ToolEvent.mjs +8 -8
  78. package/dist/tool/ToolEvent.mjs.map +1 -1
  79. package/dist/tool/{Outcome.d.mts → ToolResult.d.mts} +29 -29
  80. package/dist/tool/ToolResult.d.mts.map +1 -0
  81. package/dist/tool/ToolResult.mjs +61 -0
  82. package/dist/tool/ToolResult.mjs.map +1 -0
  83. package/dist/tool/Toolkit.d.mts +58 -26
  84. package/dist/tool/Toolkit.d.mts.map +1 -1
  85. package/dist/tool/Toolkit.mjs +62 -34
  86. package/dist/tool/Toolkit.mjs.map +1 -1
  87. package/dist/tool/Toolkit.test.mjs +13 -13
  88. package/dist/tool/Toolkit.test.mjs.map +1 -1
  89. package/package.json +31 -7
  90. package/src/domain/Items.ts +9 -9
  91. package/src/domain/Turn.ts +16 -16
  92. package/src/index.ts +7 -2
  93. package/src/language-model/LanguageModel.ts +3 -3
  94. package/src/loop/Loop.test.ts +93 -91
  95. package/src/loop/Loop.ts +72 -170
  96. package/src/sandbox/Memory.ts +128 -0
  97. package/src/sandbox/Sandbox.ts +639 -0
  98. package/src/sandbox/SandboxError.ts +140 -0
  99. package/src/sandbox/SandboxImage.ts +26 -0
  100. package/src/sandbox/SandboxNetwork.ts +34 -0
  101. package/src/speech-synthesizer/SpeechSynthesizer.test.ts +89 -0
  102. package/src/speech-synthesizer/SpeechSynthesizer.ts +124 -0
  103. package/src/testing/MockProvider.ts +21 -20
  104. package/src/testing/MockSandbox.ts +342 -0
  105. package/src/testing/MockSpeechSynthesizer.ts +137 -77
  106. package/src/tool/{Resolvers.test.ts → Approval.test.ts} +45 -54
  107. package/src/tool/{Resolvers.ts → Approval.ts} +25 -25
  108. package/src/tool/HistoryCheck.ts +10 -13
  109. package/src/tool/Tool.ts +13 -15
  110. package/src/tool/ToolEvent.ts +7 -7
  111. package/src/tool/{Outcome.ts → ToolResult.ts} +20 -20
  112. package/src/tool/Toolkit.test.ts +13 -13
  113. package/src/tool/Toolkit.ts +97 -73
  114. package/dist/Items-BH8xUkoR.d.mts.map +0 -1
  115. package/dist/Turn-0CwCAyVe.d.mts +0 -388
  116. package/dist/Turn-0CwCAyVe.d.mts.map +0 -1
  117. package/dist/tool/Outcome.d.mts.map +0 -1
  118. package/dist/tool/Outcome.mjs +0 -61
  119. package/dist/tool/Outcome.mjs.map +0 -1
  120. package/dist/tool/Resolvers.d.mts.map +0 -1
  121. package/dist/tool/Resolvers.mjs.map +0 -1
  122. package/dist/tool/Resolvers.test.mjs.map +0 -1
  123. /package/dist/tool/{Resolvers.test.d.mts → Approval.test.d.mts} +0 -0
@@ -0,0 +1,140 @@
1
+ import { Data, Duration } from "effect"
2
+
3
+ /**
4
+ * Failed to provision a sandbox. Covers image pull failures, region
5
+ * unavailability, the provider being unreachable, etc. The provider's
6
+ * structured reason (if any) is on `reason`; the raw error from the
7
+ * underlying SDK is on `raw`.
8
+ */
9
+ export class SandboxCreateFailed extends Data.TaggedError("SandboxCreateFailed")<{
10
+ provider: string
11
+ reason?: string
12
+ raw: unknown
13
+ }> {}
14
+
15
+ /**
16
+ * The requested sandbox id does not exist (or was already destroyed).
17
+ * Distinct from `SandboxCreateFailed` — this is for `attach` / `destroy` /
18
+ * `get`-style lookups against a stale id.
19
+ */
20
+ export class SandboxNotFound extends Data.TaggedError("SandboxNotFound")<{
21
+ provider: string
22
+ id: string
23
+ }> {}
24
+
25
+ /**
26
+ * Caller tried to create a sandbox whose id is already taken. Providers
27
+ * that keyed by name (Microsandbox, Modal) surface this when a previous
28
+ * sandbox with the same name is still alive. Distinct from
29
+ * `SandboxCreateFailed` so callers can retry with `replace` semantics.
30
+ */
31
+ export class SandboxAlreadyExists extends Data.TaggedError("SandboxAlreadyExists")<{
32
+ provider: string
33
+ id: string
34
+ }> {}
35
+
36
+ /**
37
+ * A shell exec inside the sandbox failed at the transport / SDK layer —
38
+ * i.e. the SDK couldn't deliver the command to the guest, or the guest
39
+ * agent itself errored. A non-zero exit code from the guest's command
40
+ * is NOT an error; it's an `ExecResult` with `exitCode !== 0`.
41
+ */
42
+ export class SandboxExecFailed extends Data.TaggedError("SandboxExecFailed")<{
43
+ provider: string
44
+ reason?: string
45
+ raw: unknown
46
+ }> {}
47
+
48
+ /**
49
+ * A sandbox operation (create, exec, file op) exceeded its time budget.
50
+ * Carries the budget that was breached for observability.
51
+ */
52
+ export class SandboxTimeout extends Data.TaggedError("SandboxTimeout")<{
53
+ provider: string
54
+ operation: "create" | "exec" | "filesystem" | "attach"
55
+ budget?: Duration.Duration
56
+ raw?: unknown
57
+ }> {}
58
+
59
+ /**
60
+ * Outbound network request from inside the sandbox was blocked by the
61
+ * configured egress policy (host not on allowlist, CIDR mismatch, etc.).
62
+ * Surfaced when the provider's SDK reports the denial back to the host
63
+ * — many providers simply let the request fail inside the guest and
64
+ * never report this to the host, in which case this error is never
65
+ * raised by the adapter.
66
+ */
67
+ export class SandboxNetworkPolicyDenied extends Data.TaggedError("SandboxNetworkPolicyDenied")<{
68
+ provider: string
69
+ host?: string
70
+ reason?: string
71
+ raw: unknown
72
+ }> {}
73
+
74
+ /**
75
+ * Provider rejected the create / exec because account / org / project
76
+ * quota or concurrency limit was exceeded. Surfaced separately from
77
+ * `SandboxCreateFailed` so callers can back off and retry intelligently.
78
+ */
79
+ export class SandboxQuotaExceeded extends Data.TaggedError("SandboxQuotaExceeded")<{
80
+ provider: string
81
+ reason?: string
82
+ raw: unknown
83
+ }> {}
84
+
85
+ /**
86
+ * Caller's request shape is malformed for the wired provider — e.g. a
87
+ * Dockerfile passed to a provider that only accepts registry refs, a
88
+ * hostname allowlist passed to a CIDR-only provider, etc. Adapters
89
+ * should also reject these at the request-schema layer where possible;
90
+ * this is the runtime fallback.
91
+ */
92
+ export class SandboxInvalidRequest extends Data.TaggedError("SandboxInvalidRequest")<{
93
+ provider: string
94
+ param?: string
95
+ reason?: string
96
+ raw?: unknown
97
+ }> {}
98
+
99
+ /**
100
+ * Sandbox SDK could not authenticate against the provider. Subtype
101
+ * mirrors the model-side `AiError.AuthFailed` shape for consistency.
102
+ */
103
+ export type SandboxAuthSubtype = "auth" | "permission" | "billing" | "quota"
104
+
105
+ export class SandboxAuthFailed extends Data.TaggedError("SandboxAuthFailed")<{
106
+ provider: string
107
+ subtype: SandboxAuthSubtype
108
+ raw: unknown
109
+ }> {}
110
+
111
+ /**
112
+ * The wired provider does not implement the requested capability for
113
+ * this specific request. Distinct from `SandboxInvalidRequest` (the
114
+ * request shape is malformed) and from compile-time capability
115
+ * markers (`SandboxSnapshots`, `SandboxSecretInjection`, …) which gate
116
+ * blanket provider-level gaps.
117
+ *
118
+ * Reserved for request-data-dependent gaps where the provider supports
119
+ * the method in general but not for these inputs — e.g. passing
120
+ * `hosts: [...]` to a CIDR-only allowlist provider, or passing
121
+ * `dockerfile: ...` to a provider whose `SandboxCustomImage` marker
122
+ * was provided but which only accepts a flat Dockerfile string.
123
+ */
124
+ export class SandboxUnsupported extends Data.TaggedError("SandboxUnsupported")<{
125
+ provider: string
126
+ capability: string
127
+ reason?: string
128
+ }> {}
129
+
130
+ export type SandboxError =
131
+ | SandboxCreateFailed
132
+ | SandboxNotFound
133
+ | SandboxAlreadyExists
134
+ | SandboxExecFailed
135
+ | SandboxTimeout
136
+ | SandboxNetworkPolicyDenied
137
+ | SandboxQuotaExceeded
138
+ | SandboxInvalidRequest
139
+ | SandboxAuthFailed
140
+ | SandboxUnsupported
@@ -0,0 +1,26 @@
1
+ import { ImageRef, SnapshotId } from "./Sandbox.js"
2
+
3
+ /**
4
+ * Ergonomic constructors for {@link ImageRef}. `ImageRef.*` tagged
5
+ * constructors still exist (and are what pattern-matching code uses);
6
+ * these helpers are what end users reach for at the call site.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import * as Image from "@effect-uai/core/SandboxImage"
11
+ *
12
+ * Image.auto // provider's house image
13
+ * Image.registry("python:3.12") // OCI registry ref
14
+ * Image.snapshot("ml-warm-1") // restore captured state (gated)
15
+ * Image.dockerfile("FROM ubuntu...") // build custom (gated)
16
+ * ```
17
+ */
18
+
19
+ export const auto: ImageRef = ImageRef.Default()
20
+
21
+ export const registry = (ref: string): ImageRef => ImageRef.Registry({ ref })
22
+
23
+ export const snapshot = (id: SnapshotId | string): ImageRef =>
24
+ ImageRef.Snapshot({ id: SnapshotId(id) })
25
+
26
+ export const dockerfile = (contents: string): ImageRef => ImageRef.Dockerfile({ contents })
@@ -0,0 +1,34 @@
1
+ import { NetworkPolicy } from "./Sandbox.js"
2
+
3
+ /**
4
+ * Ergonomic constructors for {@link NetworkPolicy}. The tagged
5
+ * constructors under `Sandbox.NetworkPolicy.*` still exist (and are
6
+ * what pattern-matching code uses); these helpers are what end users
7
+ * reach for at the call site.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import * as Network from "@effect-uai/core/SandboxNetwork"
12
+ *
13
+ * Network.open // provider defaults apply
14
+ * Network.blocked // airgapped — no egress
15
+ * Network.allowHosts("api.openai.com", "github.com")
16
+ * Network.allowCidrs("10.0.0.0/8")
17
+ * Network.allow({ hosts: [...], cidrs: [...] }) // combined allowlist
18
+ * ```
19
+ */
20
+
21
+ export const open: NetworkPolicy = NetworkPolicy.Open()
22
+
23
+ export const blocked: NetworkPolicy = NetworkPolicy.Blocked()
24
+
25
+ export const allowHosts = (...hosts: ReadonlyArray<string>): NetworkPolicy =>
26
+ NetworkPolicy.Allowlist({ hosts })
27
+
28
+ export const allowCidrs = (...cidrs: ReadonlyArray<string>): NetworkPolicy =>
29
+ NetworkPolicy.Allowlist({ cidrs })
30
+
31
+ export const allow = (opts: {
32
+ readonly hosts?: ReadonlyArray<string>
33
+ readonly cidrs?: ReadonlyArray<string>
34
+ }): NetworkPolicy => NetworkPolicy.Allowlist(opts)
@@ -106,6 +106,95 @@ describe("SpeechSynthesizer capability marker (compile-time)", () => {
106
106
  })
107
107
  })
108
108
 
109
+ describe("SpeechSynthesizer.synthesizeDialogue", () => {
110
+ const dialogueReq: SpeechSynthesizer.CommonSynthesizeDialogueRequest = {
111
+ model: "mock-tts",
112
+ turns: [
113
+ { voiceId: "voice-a", text: "Hi" },
114
+ { voiceId: "voice-b", text: "Hello" },
115
+ ],
116
+ }
117
+
118
+ it("returns the scripted AudioBlob", async () => {
119
+ const mock = MockSpeechSynthesizer.layer({ dialogueBlobs: [blob] })
120
+ const result = await Effect.runPromise(
121
+ SpeechSynthesizer.synthesizeDialogue(dialogueReq).pipe(Effect.provide(mock.layer)),
122
+ )
123
+ expect(result.bytes).toEqual(blob.bytes)
124
+ })
125
+
126
+ it("records the request on the synthesizeDialogue call channel", async () => {
127
+ const mock = MockSpeechSynthesizer.layer({ dialogueBlobs: [blob] })
128
+ const program = Effect.gen(function* () {
129
+ yield* SpeechSynthesizer.synthesizeDialogue(dialogueReq)
130
+ return yield* mock.recorder
131
+ })
132
+ const rec = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
133
+ expect(rec.synthesizeDialogueCalls.length).toBe(1)
134
+ expect(rec.synthesizeDialogueCalls[0]!.turns.length).toBe(2)
135
+ expect(rec.synthesizeCalls.length).toBe(0)
136
+ })
137
+
138
+ it("emits scripted chunks for streamSynthesizeDialogue", async () => {
139
+ const mock = MockSpeechSynthesizer.layer({
140
+ streamSynthesizeDialogueChunks: [[chunk(7), chunk(8)]],
141
+ })
142
+ const out = await Effect.runPromise(
143
+ Stream.runCollect(SpeechSynthesizer.streamSynthesizeDialogue(dialogueReq)).pipe(
144
+ Effect.provide(mock.layer),
145
+ ),
146
+ )
147
+ expect(out.map((c) => Array.from(c.bytes))).toEqual([[7], [8]])
148
+ })
149
+ })
150
+
151
+ describe("MultiSpeakerTts capability marker (compile-time)", () => {
152
+ const dialogueReq: SpeechSynthesizer.CommonSynthesizeDialogueRequest = {
153
+ model: "mock-tts",
154
+ turns: [{ voiceId: "a", text: "Hi" }],
155
+ }
156
+
157
+ it("requires `MultiSpeakerTts` on the R channel of synthesizeDialogue", () => {
158
+ const eff = SpeechSynthesizer.synthesizeDialogue(dialogueReq)
159
+ expectTypeOf(eff).toEqualTypeOf<
160
+ Effect.Effect<
161
+ AudioBlob,
162
+ AiError.AiError,
163
+ SpeechSynthesizer.SpeechSynthesizer | SpeechSynthesizer.MultiSpeakerTts
164
+ >
165
+ >()
166
+ })
167
+
168
+ it("requires `MultiSpeakerTts` on the R channel of streamSynthesizeDialogue", () => {
169
+ const s = SpeechSynthesizer.streamSynthesizeDialogue(dialogueReq)
170
+ expectTypeOf(s).toEqualTypeOf<
171
+ Stream.Stream<
172
+ AudioChunk,
173
+ AiError.AiError,
174
+ SpeechSynthesizer.SpeechSynthesizer | SpeechSynthesizer.MultiSpeakerTts
175
+ >
176
+ >()
177
+ })
178
+
179
+ it("a layer without the marker leaves `MultiSpeakerTts` unsatisfied in R", () => {
180
+ const noMarker = MockSpeechSynthesizer.layerWithoutMultiSpeaker({})
181
+ const program = SpeechSynthesizer.synthesizeDialogue(dialogueReq).pipe(
182
+ Effect.provide(noMarker.layer),
183
+ )
184
+ expectTypeOf(program).toEqualTypeOf<
185
+ Effect.Effect<AudioBlob, AiError.AiError, SpeechSynthesizer.MultiSpeakerTts>
186
+ >()
187
+ })
188
+
189
+ it("a full layer (with marker) clears R to never", () => {
190
+ const fullMock = MockSpeechSynthesizer.layer({ dialogueBlobs: [blob] })
191
+ const program = SpeechSynthesizer.synthesizeDialogue(dialogueReq).pipe(
192
+ Effect.provide(fullMock.layer),
193
+ )
194
+ expectTypeOf(program).toEqualTypeOf<Effect.Effect<AudioBlob, AiError.AiError, never>>()
195
+ })
196
+ })
197
+
109
198
  describe("SpeechSynthesizer.streamSynthesisFrom", () => {
110
199
  const ssfReq: SpeechSynthesizer.CommonStreamSynthesizeRequest = {
111
200
  model: "mock-tts",
@@ -2,6 +2,34 @@ import { Context, Effect, Function, Stream } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
3
  import type { AudioBlob, AudioChunk, AudioFormat } from "../domain/Audio.js"
4
4
 
5
+ /**
6
+ * Phonetic alphabets accepted by `CustomPronunciation`. Not every
7
+ * provider supports every alphabet — adapters translate or warn+drop
8
+ * unsupported entries.
9
+ *
10
+ * - `ipa` — universal lowest common denominator (Google, ElevenLabs,
11
+ * Inworld, Deepgram, Cartesia, MiniMax).
12
+ * - `x-sampa` — Google Cloud TTS.
13
+ * - `cmu-arpabet` — ElevenLabs `eleven_flash_v2` / `eleven_english_v1`.
14
+ */
15
+ export type PhoneticEncoding = "ipa" | "x-sampa" | "cmu-arpabet"
16
+
17
+ /**
18
+ * Per-phrase pronunciation override. The same shape unifies the
19
+ * structured-field providers (Google) and the inline-markup providers
20
+ * (ElevenLabs SSML, Inworld `/ipa/`, Deepgram, Cartesia, MiniMax) — the
21
+ * adapter does the wire translation.
22
+ *
23
+ * Unsupported alphabets are dropped at the adapter with a structured
24
+ * `logWarning` (one per call). The audio still renders, just without
25
+ * the override.
26
+ */
27
+ export type CustomPronunciation = {
28
+ readonly phrase: string
29
+ readonly pronunciation: string
30
+ readonly encoding: PhoneticEncoding
31
+ }
32
+
5
33
  /**
6
34
  * Cross-provider synthesis request. Provider-specific extensions
7
35
  * (ElevenLabs `stability` / `similarity_boost`, Cartesia `emotion`,
@@ -23,6 +51,14 @@ export type CommonSynthesizeRequest = {
23
51
  readonly outputFormat?: AudioFormat
24
52
  readonly speed?: number
25
53
  readonly languageCode?: string
54
+ /**
55
+ * Phoneme overrides for specific phrases. 7 of 9 TTS providers
56
+ * support some form of this; adapters either translate to a
57
+ * structured field, rewrite `text` inline, or `warn+drop` per-entry
58
+ * for alphabets they don't accept. OpenAI rejects non-empty arrays
59
+ * with `Unsupported`.
60
+ */
61
+ readonly pronunciations?: ReadonlyArray<CustomPronunciation>
26
62
  }
27
63
 
28
64
  /**
@@ -36,6 +72,34 @@ export type CommonSynthesizeRequest = {
36
72
  */
37
73
  export type CommonStreamSynthesizeRequest = Omit<CommonSynthesizeRequest, "text">
38
74
 
75
+ /**
76
+ * One turn in a multi-speaker dialogue.
77
+ *
78
+ * `styleDescription` and `speed` are honored by providers that expose
79
+ * per-turn knobs (Hume Octave-2) and silently ignored by others
80
+ * (ElevenLabs `/v1/text-to-dialogue` accepts `{voice_id, text}` only;
81
+ * Google Gemini TTS multi-speaker has no per-turn styling).
82
+ */
83
+ export type DialogueTurn = {
84
+ readonly voiceId: string
85
+ readonly text: string
86
+ readonly styleDescription?: string
87
+ readonly speed?: number
88
+ }
89
+
90
+ /**
91
+ * Cross-provider dialogue request. Same return type as `synthesize` —
92
+ * one continuous `AudioBlob`. Per-turn timing metadata is not exposed
93
+ * in the common shape (only Hume returns it natively).
94
+ */
95
+ export type CommonSynthesizeDialogueRequest = {
96
+ readonly model: string
97
+ readonly turns: ReadonlyArray<DialogueTurn>
98
+ readonly outputFormat?: AudioFormat
99
+ readonly languageCode?: string
100
+ readonly pronunciations?: ReadonlyArray<CustomPronunciation>
101
+ }
102
+
39
103
  export type SpeechSynthesizerService = {
40
104
  /** One-shot. Full text in, full audio bytes out. Universally supported. */
41
105
  readonly synthesize: (
@@ -62,6 +126,26 @@ export type SpeechSynthesizerService = {
62
126
  textIn: Stream.Stream<string, E, R>,
63
127
  request: CommonStreamSynthesizeRequest,
64
128
  ) => Stream.Stream<AudioChunk, AiError.AiError | E, R>
129
+ /**
130
+ * One-shot multi-speaker dialogue. Turn array in, single `AudioBlob`
131
+ * out. Providers without native dialogue support return
132
+ * `AiError.Unsupported` and do NOT ship the `MultiSpeakerTts` marker
133
+ * — calls via the top-level helper become a compile-time error.
134
+ */
135
+ readonly synthesizeDialogue: (
136
+ request: CommonSynthesizeDialogueRequest,
137
+ ) => Effect.Effect<AudioBlob, AiError.AiError>
138
+ /**
139
+ * Chunked-streaming variant of `synthesizeDialogue`. Same input,
140
+ * audio chunks emitted as the wire delivers them. Providers that
141
+ * synthesize the whole dialogue server-side may fall back to a
142
+ * single-chunk wrap of the sync result.
143
+ *
144
+ * Gated by `MultiSpeakerTts` at the top-level helper.
145
+ */
146
+ readonly streamSynthesizeDialogue: (
147
+ request: CommonSynthesizeDialogueRequest,
148
+ ) => Stream.Stream<AudioChunk, AiError.AiError>
65
149
  }
66
150
 
67
151
  export class SpeechSynthesizer extends Context.Service<
@@ -83,6 +167,19 @@ export class TtsIncrementalText extends Context.Service<TtsIncrementalText, void
83
167
  "@betalyra/effect-uai/capability/TtsIncrementalText",
84
168
  ) {}
85
169
 
170
+ /**
171
+ * Capability marker for multi-speaker dialogue. Shipped by provider
172
+ * Layers whose `synthesizeDialogue` is wired to a native multi-speaker
173
+ * endpoint (ElevenLabs `/v1/text-to-dialogue`, Hume `/v0/tts` with
174
+ * `utterances[]`, Google Cloud TTS Gemini TTS `multiSpeakerMarkup`).
175
+ * Other providers leave the marker unregistered so the top-level
176
+ * `synthesizeDialogue` / `streamSynthesizeDialogue` helpers fail to
177
+ * satisfy R against those Layers — compile-time error.
178
+ */
179
+ export class MultiSpeakerTts extends Context.Service<MultiSpeakerTts, void>()(
180
+ "@betalyra/effect-uai/capability/MultiSpeakerTts",
181
+ ) {}
182
+
86
183
  /** One-shot synthesis. */
87
184
  export const synthesize = (
88
185
  request: CommonSynthesizeRequest,
@@ -129,3 +226,30 @@ export const streamSynthesisFrom: {
129
226
  }),
130
227
  ),
131
228
  )
229
+
230
+ /**
231
+ * One-shot multi-speaker dialogue. Requires `MultiSpeakerTts` in R —
232
+ * providers without dialogue support are a compile-time error.
233
+ */
234
+ export const synthesizeDialogue = (
235
+ request: CommonSynthesizeDialogueRequest,
236
+ ): Effect.Effect<AudioBlob, AiError.AiError, SpeechSynthesizer | MultiSpeakerTts> =>
237
+ Effect.gen(function* () {
238
+ const s = yield* SpeechSynthesizer.asEffect()
239
+ yield* MultiSpeakerTts.asEffect()
240
+ return yield* s.synthesizeDialogue(request)
241
+ })
242
+
243
+ /**
244
+ * Chunked-streaming variant. Same R requirement as `synthesizeDialogue`.
245
+ */
246
+ export const streamSynthesizeDialogue = (
247
+ request: CommonSynthesizeDialogueRequest,
248
+ ): Stream.Stream<AudioChunk, AiError.AiError, SpeechSynthesizer | MultiSpeakerTts> =>
249
+ Stream.unwrap(
250
+ Effect.gen(function* () {
251
+ const s = yield* SpeechSynthesizer.asEffect()
252
+ yield* MultiSpeakerTts.asEffect()
253
+ return s.streamSynthesizeDialogue(request)
254
+ }),
255
+ )
@@ -1,6 +1,6 @@
1
1
  import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
- import { type Item, isOutputText } from "../domain/Items.js"
3
+ import { type HistoryItem, isOutputText } from "../domain/Items.js"
4
4
  import {
5
5
  LanguageModel,
6
6
  type LanguageModelService,
@@ -18,7 +18,7 @@ export type MockOptions = {
18
18
  }
19
19
 
20
20
  export type Call = {
21
- readonly history: ReadonlyArray<Item>
21
+ readonly history: ReadonlyArray<HistoryItem>
22
22
  readonly turn: Turn
23
23
  }
24
24
 
@@ -36,24 +36,25 @@ export type MockRecorder = {
36
36
  // Pure projection: Turn → ReadonlyArray<TurnEvent>
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
- const itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(
40
- Match.discriminators("type")({
41
- message: (m): ReadonlyArray<TurnEvent> =>
42
- m.role === "assistant"
43
- ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))
44
- : [],
45
- function_call: (fc) => [
46
- TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),
47
- TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),
48
- ],
49
- function_call_output: () => [],
50
- reasoning: (r) =>
51
- r.summary !== undefined
52
- ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: "summary" as const })]
53
- : [],
54
- }),
55
- Match.exhaustive,
56
- )
39
+ const itemToDeltas: (item: HistoryItem) => ReadonlyArray<TurnEvent> =
40
+ Match.type<HistoryItem>().pipe(
41
+ Match.discriminators("type")({
42
+ message: (m): ReadonlyArray<TurnEvent> =>
43
+ m.role === "assistant"
44
+ ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))
45
+ : [],
46
+ function_call: (fc) => [
47
+ TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),
48
+ TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),
49
+ ],
50
+ function_call_output: () => [],
51
+ reasoning: (r) =>
52
+ r.summary !== undefined
53
+ ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: "summary" as const })]
54
+ : [],
55
+ }),
56
+ Match.exhaustive,
57
+ )
57
58
 
58
59
  const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [
59
60
  ...turn.items.flatMap(itemToDeltas),