@effect-uai/core 0.5.1 → 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.
- package/README.md +1 -1
- package/dist/{Items-BH8xUkoR.d.mts → Items-pXR5BAgW.d.mts} +12 -12
- package/dist/Items-pXR5BAgW.d.mts.map +1 -0
- package/dist/SandboxError-BvKypkJm.d.mts +158 -0
- package/dist/SandboxError-BvKypkJm.d.mts.map +1 -0
- package/dist/{Tool-87ViKCCO.d.mts → Tool-DP-AwKOf.d.mts} +11 -11
- package/dist/{Tool-87ViKCCO.d.mts.map → Tool-DP-AwKOf.d.mts.map} +1 -1
- package/dist/domain/Items.d.mts +2 -2
- package/dist/domain/Items.mjs +15 -15
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Turn.d.mts +388 -2
- package/dist/domain/Turn.d.mts.map +1 -0
- package/dist/domain/Turn.mjs +13 -13
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/index.d.mts +12 -6
- package/dist/index.mjs +9 -3
- package/dist/language-model/LanguageModel.d.mts +29 -27
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +16 -25
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +10 -81
- package/dist/language-model/LanguageModel.test.mjs.map +1 -1
- package/dist/loop/Loop.d.mts +37 -90
- package/dist/loop/Loop.d.mts.map +1 -1
- package/dist/loop/Loop.mjs +39 -93
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +46 -54
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/retry/Retry.d.mts +64 -0
- package/dist/retry/Retry.d.mts.map +1 -0
- package/dist/retry/Retry.mjs +69 -0
- package/dist/retry/Retry.mjs.map +1 -0
- package/dist/retry/Retry.test.mjs +142 -0
- package/dist/retry/Retry.test.mjs.map +1 -0
- package/dist/sandbox/Memory.d.mts +53 -0
- package/dist/sandbox/Memory.d.mts.map +1 -0
- package/dist/sandbox/Memory.mjs +68 -0
- package/dist/sandbox/Memory.mjs.map +1 -0
- package/dist/sandbox/Sandbox.d.mts +717 -0
- package/dist/sandbox/Sandbox.d.mts.map +1 -0
- package/dist/sandbox/Sandbox.mjs +192 -0
- package/dist/sandbox/Sandbox.mjs.map +1 -0
- package/dist/sandbox/SandboxError.d.mts +2 -0
- package/dist/sandbox/SandboxError.mjs +89 -0
- package/dist/sandbox/SandboxError.mjs.map +1 -0
- package/dist/sandbox/SandboxImage.d.mts +28 -0
- package/dist/sandbox/SandboxImage.d.mts.map +1 -0
- package/dist/sandbox/SandboxImage.mjs +32 -0
- package/dist/sandbox/SandboxImage.mjs.map +1 -0
- package/dist/sandbox/SandboxNetwork.d.mts +34 -0
- package/dist/sandbox/SandboxNetwork.d.mts.map +1 -0
- package/dist/sandbox/SandboxNetwork.mjs +36 -0
- package/dist/sandbox/SandboxNetwork.mjs.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +98 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs +32 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +56 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +3 -3
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs +14 -8
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/dist/testing/MockSandbox.d.mts +604 -0
- package/dist/testing/MockSandbox.d.mts.map +1 -0
- package/dist/testing/MockSandbox.mjs +160 -0
- package/dist/testing/MockSandbox.mjs.map +1 -0
- package/dist/testing/MockSpeechSynthesizer.d.mts +26 -11
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.mjs +75 -51
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
- package/dist/tool/{Resolvers.d.mts → Approval.d.mts} +27 -27
- package/dist/tool/Approval.d.mts.map +1 -0
- package/dist/tool/{Resolvers.mjs → Approval.mjs} +18 -18
- package/dist/tool/Approval.mjs.map +1 -0
- package/dist/tool/Approval.test.d.mts +1 -0
- package/dist/tool/{Resolvers.test.mjs → Approval.test.mjs} +40 -40
- package/dist/tool/Approval.test.mjs.map +1 -0
- package/dist/tool/HistoryCheck.d.mts +6 -6
- package/dist/tool/HistoryCheck.d.mts.map +1 -1
- package/dist/tool/HistoryCheck.mjs +5 -5
- package/dist/tool/HistoryCheck.mjs.map +1 -1
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +4 -4
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +19 -19
- package/dist/tool/ToolEvent.d.mts.map +1 -1
- package/dist/tool/ToolEvent.mjs +8 -8
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/{Outcome.d.mts → ToolResult.d.mts} +29 -29
- package/dist/tool/ToolResult.d.mts.map +1 -0
- package/dist/tool/ToolResult.mjs +61 -0
- package/dist/tool/ToolResult.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +58 -26
- package/dist/tool/Toolkit.d.mts.map +1 -1
- package/dist/tool/Toolkit.mjs +62 -34
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +13 -13
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/package.json +35 -7
- package/src/domain/Items.ts +9 -9
- package/src/domain/Turn.ts +16 -16
- package/src/index.ts +8 -2
- package/src/language-model/LanguageModel.test.ts +21 -102
- package/src/language-model/LanguageModel.ts +36 -59
- package/src/loop/Loop.test.ts +93 -91
- package/src/loop/Loop.ts +72 -170
- package/src/retry/Retry.test.ts +163 -0
- package/src/retry/Retry.ts +84 -0
- package/src/sandbox/Memory.ts +128 -0
- package/src/sandbox/Sandbox.ts +639 -0
- package/src/sandbox/SandboxError.ts +140 -0
- package/src/sandbox/SandboxImage.ts +26 -0
- package/src/sandbox/SandboxNetwork.ts +34 -0
- package/src/speech-synthesizer/SpeechSynthesizer.test.ts +89 -0
- package/src/speech-synthesizer/SpeechSynthesizer.ts +124 -0
- package/src/testing/MockProvider.ts +31 -25
- package/src/testing/MockSandbox.ts +342 -0
- package/src/testing/MockSpeechSynthesizer.ts +137 -77
- package/src/tool/{Resolvers.test.ts → Approval.test.ts} +45 -54
- package/src/tool/{Resolvers.ts → Approval.ts} +25 -25
- package/src/tool/HistoryCheck.ts +10 -13
- package/src/tool/Tool.ts +13 -15
- package/src/tool/ToolEvent.ts +7 -7
- package/src/tool/{Outcome.ts → ToolResult.ts} +20 -20
- package/src/tool/Toolkit.test.ts +13 -13
- package/src/tool/Toolkit.ts +97 -73
- package/dist/Items-BH8xUkoR.d.mts.map +0 -1
- package/dist/Turn-0CwCAyVe.d.mts +0 -388
- package/dist/Turn-0CwCAyVe.d.mts.map +0 -1
- package/dist/tool/Outcome.d.mts.map +0 -1
- package/dist/tool/Outcome.mjs +0 -61
- package/dist/tool/Outcome.mjs.map +0 -1
- package/dist/tool/Resolvers.d.mts.map +0 -1
- package/dist/tool/Resolvers.mjs.map +0 -1
- package/dist/tool/Resolvers.test.mjs.map +0 -1
- /package/dist/{tool/Resolvers.test.d.mts → retry/Retry.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,7 +1,11 @@
|
|
|
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
|
|
4
|
-
import {
|
|
3
|
+
import { type HistoryItem, isOutputText } from "../domain/Items.js"
|
|
4
|
+
import {
|
|
5
|
+
LanguageModel,
|
|
6
|
+
type LanguageModelService,
|
|
7
|
+
turnFromStream,
|
|
8
|
+
} from "../language-model/LanguageModel.js"
|
|
5
9
|
import { type Turn, TurnEvent } from "../domain/Turn.js"
|
|
6
10
|
|
|
7
11
|
export type MockOptions = {
|
|
@@ -14,7 +18,7 @@ export type MockOptions = {
|
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export type Call = {
|
|
17
|
-
readonly history: ReadonlyArray<
|
|
21
|
+
readonly history: ReadonlyArray<HistoryItem>
|
|
18
22
|
readonly turn: Turn
|
|
19
23
|
}
|
|
20
24
|
|
|
@@ -32,24 +36,25 @@ export type MockRecorder = {
|
|
|
32
36
|
// Pure projection: Turn → ReadonlyArray<TurnEvent>
|
|
33
37
|
// ---------------------------------------------------------------------------
|
|
34
38
|
|
|
35
|
-
const itemToDeltas: (item:
|
|
36
|
-
Match.
|
|
37
|
-
|
|
38
|
-
m
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
r
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
)
|
|
53
58
|
|
|
54
59
|
const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [
|
|
55
60
|
...turn.items.flatMap(itemToDeltas),
|
|
@@ -81,8 +86,8 @@ const buildService = (
|
|
|
81
86
|
options: MockOptions | undefined,
|
|
82
87
|
cursor: Ref.Ref<number>,
|
|
83
88
|
record: (call: Call) => Effect.Effect<void>,
|
|
84
|
-
): LanguageModelService =>
|
|
85
|
-
streamTurn: (request) =>
|
|
89
|
+
): LanguageModelService => {
|
|
90
|
+
const streamTurn: LanguageModelService["streamTurn"] = (request) =>
|
|
86
91
|
Stream.unwrap(
|
|
87
92
|
Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
|
|
88
93
|
Effect.flatMap(
|
|
@@ -96,8 +101,9 @@ const buildService = (
|
|
|
96
101
|
}),
|
|
97
102
|
),
|
|
98
103
|
),
|
|
99
|
-
)
|
|
100
|
-
}
|
|
104
|
+
)
|
|
105
|
+
return { streamTurn, turn: turnFromStream(streamTurn) }
|
|
106
|
+
}
|
|
101
107
|
|
|
102
108
|
// ---------------------------------------------------------------------------
|
|
103
109
|
// Recorder handle. Unsafe Ref is local: it backs both the `record` write
|