@effect-uai/google 0.3.0 → 0.5.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 (62) hide show
  1. package/dist/Gemini.d.mts +6 -5
  2. package/dist/Gemini.d.mts.map +1 -1
  3. package/dist/Gemini.mjs +42 -22
  4. package/dist/Gemini.mjs.map +1 -1
  5. package/dist/GeminiEmbedding.d.mts.map +1 -1
  6. package/dist/GeminiEmbedding.mjs +2 -2
  7. package/dist/GeminiEmbedding.mjs.map +1 -1
  8. package/dist/GeminiSynthesizer.d.mts +38 -0
  9. package/dist/GeminiSynthesizer.d.mts.map +1 -0
  10. package/dist/GeminiSynthesizer.mjs +95 -0
  11. package/dist/GeminiSynthesizer.mjs.map +1 -0
  12. package/dist/GeminiSynthesizer.test.d.mts +1 -0
  13. package/dist/GeminiSynthesizer.test.mjs +84 -0
  14. package/dist/GeminiSynthesizer.test.mjs.map +1 -0
  15. package/dist/GeminiTranscriber.d.mts +42 -0
  16. package/dist/GeminiTranscriber.d.mts.map +1 -0
  17. package/dist/GeminiTranscriber.mjs +80 -0
  18. package/dist/GeminiTranscriber.mjs.map +1 -0
  19. package/dist/GeminiTranscriber.test.d.mts +1 -0
  20. package/dist/GeminiTranscriber.test.mjs +125 -0
  21. package/dist/GeminiTranscriber.test.mjs.map +1 -0
  22. package/dist/LyriaGenerator.d.mts +57 -0
  23. package/dist/LyriaGenerator.d.mts.map +1 -0
  24. package/dist/LyriaGenerator.mjs +202 -0
  25. package/dist/LyriaGenerator.mjs.map +1 -0
  26. package/dist/LyriaGenerator.test.d.mts +1 -0
  27. package/dist/LyriaGenerator.test.mjs +138 -0
  28. package/dist/LyriaGenerator.test.mjs.map +1 -0
  29. package/dist/{chunk-CfYAbeIz.mjs → chunk-uyGKjUfl.mjs} +2 -1
  30. package/dist/codec.d.mts +51 -6
  31. package/dist/codec.d.mts.map +1 -1
  32. package/dist/codec.mjs +151 -27
  33. package/dist/codec.mjs.map +1 -1
  34. package/dist/dist-DMsg8YLb.mjs +13782 -0
  35. package/dist/dist-DMsg8YLb.mjs.map +1 -0
  36. package/dist/geminiSpeechCodec.d.mts +37 -0
  37. package/dist/geminiSpeechCodec.d.mts.map +1 -0
  38. package/dist/geminiSpeechCodec.mjs +110 -0
  39. package/dist/geminiSpeechCodec.mjs.map +1 -0
  40. package/dist/geminiSpeechCodec.test.d.mts +1 -0
  41. package/dist/geminiSpeechCodec.test.mjs +78 -0
  42. package/dist/geminiSpeechCodec.test.mjs.map +1 -0
  43. package/dist/index.d.mts +5 -2
  44. package/dist/index.mjs +4 -1
  45. package/dist/magic-string.es-CaWgF-3p.mjs +1013 -0
  46. package/dist/magic-string.es-CaWgF-3p.mjs.map +1 -0
  47. package/dist/models.d.mts +35 -1
  48. package/dist/models.d.mts.map +1 -1
  49. package/package.json +14 -2
  50. package/src/Gemini.ts +53 -18
  51. package/src/GeminiEmbedding.ts +23 -5
  52. package/src/GeminiSynthesizer.test.ts +93 -0
  53. package/src/GeminiSynthesizer.ts +214 -0
  54. package/src/GeminiTranscriber.test.ts +136 -0
  55. package/src/GeminiTranscriber.ts +204 -0
  56. package/src/LyriaGenerator.test.ts +162 -0
  57. package/src/LyriaGenerator.ts +390 -0
  58. package/src/codec.ts +312 -36
  59. package/src/geminiSpeechCodec.test.ts +56 -0
  60. package/src/geminiSpeechCodec.ts +137 -0
  61. package/src/index.ts +3 -0
  62. package/src/models.ts +84 -0
@@ -0,0 +1,214 @@
1
+ import { Context, Effect, Layer, Match, Redacted, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import type { AudioBlob, AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
5
+ import {
6
+ type CommonStreamSynthesizeRequest,
7
+ type CommonSynthesizeRequest,
8
+ SpeechSynthesizer,
9
+ type SpeechSynthesizerService,
10
+ } from "@effect-uai/core/SpeechSynthesizer"
11
+ import {
12
+ decodeBase64Audio,
13
+ httpStatusError,
14
+ transportFailure,
15
+ wrapPcmAsWav,
16
+ } from "./geminiSpeechCodec.js"
17
+ import type { GeminiTtsModel, GeminiVoiceName } from "./models.js"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Public types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type GeminiSynthesizeRequest = Omit<CommonSynthesizeRequest, "model" | "voiceId"> & {
24
+ readonly model: GeminiTtsModel
25
+ readonly voiceId: GeminiVoiceName
26
+ }
27
+
28
+ export type GeminiSynthesizerService = {
29
+ readonly synthesize: (r: GeminiSynthesizeRequest) => Effect.Effect<AudioBlob, AiError.AiError>
30
+ readonly streamSynthesis: (
31
+ r: GeminiSynthesizeRequest,
32
+ ) => Stream.Stream<AudioChunk, AiError.AiError>
33
+ readonly streamSynthesisFrom: SpeechSynthesizerService["streamSynthesisFrom"]
34
+ }
35
+
36
+ export class GeminiSynthesizer extends Context.Service<
37
+ GeminiSynthesizer,
38
+ GeminiSynthesizerService
39
+ >()("@betalyra/effect-uai/providers/google/GeminiSynthesizer") {}
40
+
41
+ export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Codec — Gemini TTS always emits 24 kHz mono signed-16 PCM. Caller picks
45
+ // raw bytes or WAV-wrapped.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const PCM_RATE = 24000
49
+ const PCM_CHANNELS = 1
50
+
51
+ export const realizeOutput = Match.type<AudioFormat["container"]>().pipe(
52
+ Match.when("raw", () =>
53
+ Effect.succeed([
54
+ { container: "raw", encoding: "pcm_s16le", sampleRate: PCM_RATE, channels: PCM_CHANNELS },
55
+ (b: Uint8Array) => b,
56
+ ] as const),
57
+ ),
58
+ Match.when("wav", () =>
59
+ Effect.succeed([
60
+ { container: "wav", encoding: "pcm_s16le", sampleRate: PCM_RATE, channels: PCM_CHANNELS },
61
+ (b: Uint8Array) => wrapPcmAsWav(b, PCM_RATE, PCM_CHANNELS),
62
+ ] as const),
63
+ ),
64
+ Match.whenOr("mp3", "opus", "aac", "flac", "ogg", "webm", (c) =>
65
+ Effect.fail(
66
+ new AiError.Unsupported({
67
+ provider: "gemini",
68
+ capability: "outputFormat",
69
+ reason: `Gemini TTS emits raw 16-bit PCM at 24 kHz. Request "raw" or "wav"; ${c} is unavailable.`,
70
+ }),
71
+ ),
72
+ ),
73
+ Match.exhaustive,
74
+ )
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Wire
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const Wire = Schema.Struct({
81
+ candidates: Schema.optional(
82
+ Schema.Array(
83
+ Schema.Struct({
84
+ content: Schema.optional(
85
+ Schema.Struct({
86
+ parts: Schema.optional(
87
+ Schema.Array(
88
+ Schema.Struct({
89
+ inlineData: Schema.optional(
90
+ Schema.Struct({
91
+ mimeType: Schema.optional(Schema.String),
92
+ data: Schema.String,
93
+ }),
94
+ ),
95
+ }),
96
+ ),
97
+ ),
98
+ }),
99
+ ),
100
+ }),
101
+ ),
102
+ ),
103
+ })
104
+ const decodeWire = Schema.decodeUnknownEffect(Wire)
105
+
106
+ const findInline = (wire: typeof Wire.Type) =>
107
+ (wire.candidates ?? [])
108
+ .flatMap((c) => c.content?.parts ?? [])
109
+ .find((p) => p.inlineData !== undefined)?.inlineData
110
+
111
+ const ttsBody = (r: GeminiSynthesizeRequest) => ({
112
+ contents: [{ parts: [{ text: r.text }] }],
113
+ generationConfig: {
114
+ responseModalities: ["AUDIO"],
115
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: r.voiceId } } },
116
+ },
117
+ })
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Service implementation
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const baseUrl = (cfg: Config) => cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
124
+
125
+ const synthesizeImpl = (cfg: Config) => (request: GeminiSynthesizeRequest) =>
126
+ Effect.gen(function* () {
127
+ const client = yield* HttpClient.HttpClient
128
+ const [format, wrap] = yield* realizeOutput(request.outputFormat?.container ?? "raw")
129
+ const httpRequest = HttpClientRequest.post(
130
+ `${baseUrl(cfg)}/models/${request.model}:generateContent`,
131
+ ).pipe(
132
+ HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
133
+ HttpClientRequest.bodyJsonUnsafe(ttsBody(request)),
134
+ )
135
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
136
+ if (response.status >= 400) {
137
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
138
+ return yield* httpStatusError(response.status, text)
139
+ }
140
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
141
+ const inline = yield* decodeWire(json).pipe(
142
+ Effect.map(findInline),
143
+ Effect.mapError((cause) => new AiError.GenerationFailed({ provider: "gemini", raw: cause })),
144
+ Effect.flatMap((found) =>
145
+ found === undefined
146
+ ? Effect.fail(
147
+ new AiError.GenerationFailed({
148
+ provider: "gemini",
149
+ raw: {
150
+ message: "Gemini TTS response had no inlineData audio part.",
151
+ hint: "Likely a prompt-safety rejection or the model didn't honour responseModalities.",
152
+ response: json,
153
+ },
154
+ }),
155
+ )
156
+ : Effect.succeed(found),
157
+ ),
158
+ )
159
+ const pcm = yield* decodeBase64Audio(inline.data)
160
+ return { format, bytes: wrap(pcm) } satisfies AudioBlob
161
+ })
162
+
163
+ const unsupportedStreamFrom: SpeechSynthesizerService["streamSynthesisFrom"] = () =>
164
+ Stream.fail(
165
+ new AiError.Unsupported({
166
+ provider: "gemini",
167
+ capability: "streamSynthesisFrom",
168
+ reason:
169
+ "Gemini TTS via generateContent is sync-only. Use Cloud Text-to-Speech (Chirp 3 HD) for incremental text-in.",
170
+ }),
171
+ )
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Constructors
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export const make = (cfg: Config) =>
178
+ Effect.map(
179
+ HttpClient.HttpClient.asEffect(),
180
+ (client): GeminiSynthesizerService => ({
181
+ synthesize: (r) =>
182
+ synthesizeImpl(cfg)(r).pipe(Effect.provideService(HttpClient.HttpClient, client)),
183
+ streamSynthesis: (r) =>
184
+ Stream.fromEffect(synthesizeImpl(cfg)(r)).pipe(
185
+ Stream.map((b): AudioChunk => ({ bytes: b.bytes })),
186
+ Stream.provideService(HttpClient.HttpClient, client),
187
+ ),
188
+ streamSynthesisFrom: unsupportedStreamFrom,
189
+ }),
190
+ )
191
+
192
+ /**
193
+ * Layer registers both `GeminiSynthesizer` and the generic
194
+ * `SpeechSynthesizer`. Does NOT register `TtsIncrementalText` — Gemini
195
+ * TTS is sync-only at the wire level, so `streamSynthesisFrom` against
196
+ * this Layer alone is a compile-time error.
197
+ */
198
+ export const layer = (cfg: Config) =>
199
+ Layer.merge(
200
+ Layer.effect(GeminiSynthesizer, make(cfg)),
201
+ Layer.effect(
202
+ SpeechSynthesizer,
203
+ Effect.map(
204
+ make(cfg),
205
+ (s): SpeechSynthesizerService => ({
206
+ synthesize: (req: CommonSynthesizeRequest) =>
207
+ s.synthesize(req as GeminiSynthesizeRequest),
208
+ streamSynthesis: (req: CommonSynthesizeRequest) =>
209
+ s.streamSynthesis(req as GeminiSynthesizeRequest),
210
+ streamSynthesisFrom: s.streamSynthesisFrom,
211
+ }),
212
+ ),
213
+ ),
214
+ )
@@ -0,0 +1,136 @@
1
+ import { Effect, Layer, Redacted, Stream } from "effect"
2
+ import { FetchHttpClient } from "effect/unstable/http"
3
+ import { describe, expect, expectTypeOf, it } from "vitest"
4
+ import type * as AiError from "@effect-uai/core/AiError"
5
+ import type { AudioSource } from "@effect-uai/core/Audio"
6
+ import type { TranscriptResult } from "@effect-uai/core/Transcript"
7
+ import * as Transcriber from "@effect-uai/core/Transcriber"
8
+ import * as GeminiTranscriber from "./GeminiTranscriber.js"
9
+
10
+ const cfg: GeminiTranscriber.Config = { apiKey: Redacted.make("test-key") }
11
+ const live = Layer.provide(GeminiTranscriber.layer(cfg), FetchHttpClient.layer)
12
+
13
+ const dummyAudio: AudioSource = {
14
+ _tag: "bytes",
15
+ bytes: new Uint8Array([0, 1, 2, 3]),
16
+ mimeType: "audio/wav",
17
+ }
18
+
19
+ describe("GeminiTranscriber.buildPrompt", () => {
20
+ it("uses the language-agnostic prompt when no language is set", () => {
21
+ expect(
22
+ GeminiTranscriber.buildPrompt({ audio: dummyAudio, model: "gemini-2.5-flash" }),
23
+ ).toContain("Transcribe the audio verbatim. Return only the transcript text")
24
+ })
25
+
26
+ it("includes the language when set", () => {
27
+ expect(
28
+ GeminiTranscriber.buildPrompt({
29
+ audio: dummyAudio,
30
+ model: "gemini-2.5-flash",
31
+ language: "pt-BR",
32
+ }),
33
+ ).toContain("in pt-BR")
34
+ })
35
+
36
+ it("appends a free-text prompt as context", () => {
37
+ const out = GeminiTranscriber.buildPrompt({
38
+ audio: dummyAudio,
39
+ model: "gemini-2.5-flash",
40
+ prompt: "Engineering meeting; expect technical jargon.",
41
+ })
42
+ expect(out).toContain("Context: Engineering meeting")
43
+ })
44
+
45
+ it("appends a terms list as biasing vocabulary", () => {
46
+ const out = GeminiTranscriber.buildPrompt({
47
+ audio: dummyAudio,
48
+ model: "gemini-2.5-flash",
49
+ prompt: { terms: ["Effect", "Lyria", "MusicGenerator"] },
50
+ })
51
+ expect(out).toContain("Effect, Lyria, MusicGenerator")
52
+ })
53
+
54
+ it("omits the biasing block when the terms list is empty", () => {
55
+ const out = GeminiTranscriber.buildPrompt({
56
+ audio: dummyAudio,
57
+ model: "gemini-2.5-flash",
58
+ prompt: { terms: [] },
59
+ })
60
+ expect(out).not.toContain("terms / names")
61
+ })
62
+ })
63
+
64
+ describe("GeminiTranscriber capability guards (runtime)", () => {
65
+ it("fails Unsupported for wordTimestamps", async () => {
66
+ const program = GeminiTranscriber.GeminiTranscriber.use((s) =>
67
+ s.transcribe({
68
+ audio: dummyAudio,
69
+ model: "gemini-2.5-flash",
70
+ wordTimestamps: true,
71
+ }),
72
+ )
73
+ const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
74
+ expect(exit._tag).toBe("Failure")
75
+ if (exit._tag === "Failure") {
76
+ expect(JSON.stringify(exit.cause)).toContain("Unsupported")
77
+ expect(JSON.stringify(exit.cause)).toContain("wordTimestamps")
78
+ }
79
+ })
80
+
81
+ it("fails Unsupported for diarization", async () => {
82
+ const program = GeminiTranscriber.GeminiTranscriber.use((s) =>
83
+ s.transcribe({
84
+ audio: dummyAudio,
85
+ model: "gemini-2.5-flash",
86
+ diarization: true,
87
+ }),
88
+ )
89
+ const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
90
+ expect(exit._tag).toBe("Failure")
91
+ if (exit._tag === "Failure") {
92
+ expect(JSON.stringify(exit.cause)).toContain("Unsupported")
93
+ expect(JSON.stringify(exit.cause)).toContain("diarization")
94
+ }
95
+ })
96
+
97
+ it("streamTranscriptionFrom returns an Unsupported stream", async () => {
98
+ const program = GeminiTranscriber.GeminiTranscriber.use((s) =>
99
+ Stream.runDrain(
100
+ s.streamTranscriptionFrom(Stream.fromIterable([new Uint8Array([0])]), {
101
+ model: "gemini-2.5-flash",
102
+ inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
103
+ }),
104
+ ),
105
+ )
106
+ const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
107
+ expect(exit._tag).toBe("Failure")
108
+ if (exit._tag === "Failure") {
109
+ expect(JSON.stringify(exit.cause)).toContain("Unsupported")
110
+ expect(JSON.stringify(exit.cause)).toContain("streamTranscriptionFrom")
111
+ }
112
+ })
113
+ })
114
+
115
+ describe("GeminiTranscriber Layer (compile-time)", () => {
116
+ it("leaves `SttStreaming` unsatisfied when using `streamTranscriptionFrom` against this Layer", () => {
117
+ const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
118
+ const events = audio.pipe(
119
+ Transcriber.streamTranscriptionFrom({
120
+ model: "gemini-2.5-flash",
121
+ inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
122
+ }),
123
+ )
124
+ const provided = Stream.runDrain(events).pipe(Effect.provide(live))
125
+ expectTypeOf(provided).toEqualTypeOf<
126
+ Effect.Effect<void, AiError.AiError, Transcriber.SttStreaming>
127
+ >()
128
+ })
129
+
130
+ it("sync `transcribe` requires no marker", () => {
131
+ const t = Transcriber.transcribe({ audio: dummyAudio, model: "gemini-2.5-flash" }).pipe(
132
+ Effect.provide(live),
133
+ )
134
+ expectTypeOf(t).toEqualTypeOf<Effect.Effect<TranscriptResult, AiError.AiError, never>>()
135
+ })
136
+ })
@@ -0,0 +1,204 @@
1
+ import { Context, Effect, Layer, Match, Redacted, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import type { TranscriptResult } from "@effect-uai/core/Transcript"
5
+ import {
6
+ type CommonTranscribeRequest,
7
+ Transcriber,
8
+ type TranscriberService,
9
+ } from "@effect-uai/core/Transcriber"
10
+ import { audioSourceToInlineData, httpStatusError, transportFailure } from "./geminiSpeechCodec.js"
11
+ import type { GeminiSttModel } from "./models.js"
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Public types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Gemini transcription rides on `:generateContent` — the model receives
19
+ * audio plus a textual "transcribe verbatim" prompt and returns plain
20
+ * text. There is no native structured timestamp / diarization channel,
21
+ * so `wordTimestamps` / `diarization` short-circuit with `Unsupported`.
22
+ */
23
+ export type GeminiTranscribeRequest = Omit<CommonTranscribeRequest, "model"> & {
24
+ readonly model: GeminiSttModel
25
+ }
26
+
27
+ export type GeminiTranscriberService = {
28
+ readonly transcribe: (
29
+ r: GeminiTranscribeRequest,
30
+ ) => Effect.Effect<TranscriptResult, AiError.AiError>
31
+ readonly streamTranscriptionFrom: TranscriberService["streamTranscriptionFrom"]
32
+ }
33
+
34
+ export class GeminiTranscriber extends Context.Service<
35
+ GeminiTranscriber,
36
+ GeminiTranscriberService
37
+ >()("@betalyra/effect-uai/providers/google/GeminiTranscriber") {}
38
+
39
+ export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Prompt builder + capability guards
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const unsupported = (capability: string, reason: string) =>
46
+ new AiError.Unsupported({ provider: "gemini", capability, reason })
47
+
48
+ const ensureSupported = Match.type<GeminiTranscribeRequest>().pipe(
49
+ Match.when({ wordTimestamps: true }, () =>
50
+ Effect.fail(
51
+ unsupported(
52
+ "wordTimestamps",
53
+ "Gemini's prompt-driven transcription returns plain text — no per-word timing. Use Cloud Speech-to-Text (Chirp 2) for word timestamps.",
54
+ ),
55
+ ),
56
+ ),
57
+ Match.when({ diarization: true }, () =>
58
+ Effect.fail(
59
+ unsupported(
60
+ "diarization",
61
+ "Gemini's prompt-driven transcription does not surface speaker IDs. Use Cloud Speech-to-Text (Chirp 2) for diarization.",
62
+ ),
63
+ ),
64
+ ),
65
+ Match.orElse(() => Effect.void),
66
+ )
67
+
68
+ const biasingHint = (r: GeminiTranscribeRequest) => {
69
+ if (r.prompt === undefined) return ""
70
+ if (typeof r.prompt === "string") return `\nContext: ${r.prompt}`
71
+ return r.prompt.terms.length === 0
72
+ ? ""
73
+ : `\nThe following terms / names appear in the audio: ${r.prompt.terms.join(", ")}.`
74
+ }
75
+
76
+ export const buildPrompt = (r: GeminiTranscribeRequest) =>
77
+ (r.language !== undefined
78
+ ? `Transcribe the audio verbatim in ${r.language}. Return only the transcript text, no preamble.`
79
+ : "Transcribe the audio verbatim. Return only the transcript text, no preamble.") +
80
+ biasingHint(r)
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Wire
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const Wire = Schema.Struct({
87
+ candidates: Schema.optional(
88
+ Schema.Array(
89
+ Schema.Struct({
90
+ content: Schema.optional(
91
+ Schema.Struct({
92
+ parts: Schema.optional(
93
+ Schema.Array(Schema.Struct({ text: Schema.optional(Schema.String) })),
94
+ ),
95
+ }),
96
+ ),
97
+ }),
98
+ ),
99
+ ),
100
+ })
101
+ const decodeWire = Schema.decodeUnknownEffect(Wire)
102
+
103
+ const collectText = (wire: typeof Wire.Type) =>
104
+ (wire.candidates ?? [])
105
+ .flatMap((c) => c.content?.parts ?? [])
106
+ .flatMap((p) => (p.text !== undefined ? [p.text] : []))
107
+ .join("\n")
108
+ .trim()
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Service implementation
112
+ // ---------------------------------------------------------------------------
113
+
114
+ const baseUrl = (cfg: Config) => cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
115
+
116
+ const transcribeImpl = (cfg: Config) => (request: GeminiTranscribeRequest) =>
117
+ Effect.gen(function* () {
118
+ yield* ensureSupported(request)
119
+ const client = yield* HttpClient.HttpClient
120
+ const inline = yield* audioSourceToInlineData(request.audio)
121
+ const httpRequest = HttpClientRequest.post(
122
+ `${baseUrl(cfg)}/models/${request.model}:generateContent`,
123
+ ).pipe(
124
+ HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
125
+ HttpClientRequest.bodyJsonUnsafe({
126
+ contents: [{ parts: [{ text: buildPrompt(request) }, { inlineData: inline }] }],
127
+ }),
128
+ )
129
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
130
+ if (response.status >= 400) {
131
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
132
+ return yield* httpStatusError(response.status, text)
133
+ }
134
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
135
+ const text = yield* decodeWire(json).pipe(
136
+ Effect.map(collectText),
137
+ Effect.mapError((cause) => new AiError.GenerationFailed({ provider: "gemini", raw: cause })),
138
+ Effect.flatMap((t) =>
139
+ t.length === 0
140
+ ? Effect.fail(
141
+ new AiError.GenerationFailed({
142
+ provider: "gemini",
143
+ raw: {
144
+ message: "Gemini transcription response had no text part.",
145
+ hint: "Likely a prompt-safety rejection or the model returned only metadata.",
146
+ response: json,
147
+ },
148
+ }),
149
+ )
150
+ : Effect.succeed(t),
151
+ ),
152
+ )
153
+ return {
154
+ text,
155
+ ...(request.language !== undefined && { languageCode: request.language }),
156
+ raw: json,
157
+ } satisfies TranscriptResult
158
+ })
159
+
160
+ const unsupportedStreamFrom: TranscriberService["streamTranscriptionFrom"] = () =>
161
+ Stream.fail(
162
+ new AiError.Unsupported({
163
+ provider: "gemini",
164
+ capability: "streamTranscriptionFrom",
165
+ reason:
166
+ "Gemini transcription via generateContent is sync-only. Use Cloud Speech-to-Text (StreamingRecognize) for live transcription.",
167
+ }),
168
+ )
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Constructors
172
+ // ---------------------------------------------------------------------------
173
+
174
+ export const make = (cfg: Config) =>
175
+ Effect.map(
176
+ HttpClient.HttpClient.asEffect(),
177
+ (client): GeminiTranscriberService => ({
178
+ transcribe: (r) =>
179
+ transcribeImpl(cfg)(r).pipe(Effect.provideService(HttpClient.HttpClient, client)),
180
+ streamTranscriptionFrom: unsupportedStreamFrom,
181
+ }),
182
+ )
183
+
184
+ /**
185
+ * Layer registers both `GeminiTranscriber` and the generic
186
+ * `Transcriber`. Does NOT register `SttStreaming` — Gemini's transcription
187
+ * is sync-only, so `streamTranscriptionFrom` against this Layer alone
188
+ * is a compile-time error.
189
+ */
190
+ export const layer = (cfg: Config) =>
191
+ Layer.merge(
192
+ Layer.effect(GeminiTranscriber, make(cfg)),
193
+ Layer.effect(
194
+ Transcriber,
195
+ Effect.map(
196
+ make(cfg),
197
+ (s): TranscriberService => ({
198
+ transcribe: (req: CommonTranscribeRequest) =>
199
+ s.transcribe(req as GeminiTranscribeRequest),
200
+ streamTranscriptionFrom: s.streamTranscriptionFrom,
201
+ }),
202
+ ),
203
+ ),
204
+ )