@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
package/src/codec.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Array as Arr, Encoding, Match, Option, Result, Schema, pipe } from "effect"
2
- import type { ContentBlock, InputImage, Item, Message } from "@effect-uai/core/Items"
2
+ import type { ContentBlock, FunctionCall, InputImage, Item, Message } from "@effect-uai/core/Items"
3
+ import type { ToolDescriptor } from "@effect-uai/core/Tool"
3
4
  import type { Turn } from "@effect-uai/core/Turn"
4
5
 
5
6
  // ---------------------------------------------------------------------------
@@ -17,7 +18,22 @@ const TextPart = Schema.Struct({
17
18
  thought: Schema.optional(Schema.Boolean),
18
19
  })
19
20
 
20
- const Part = Schema.Union([TextPart])
21
+ /**
22
+ * Gemini function-call part. Args are delivered as a whole JSON *object*
23
+ * in a single chunk (Gemini does not stream tool-call args). On Gemini 3
24
+ * the response also carries an `id` per call, which we echo back on the
25
+ * corresponding `functionResponse`.
26
+ */
27
+ const FunctionCallPart = Schema.Struct({
28
+ functionCall: Schema.Struct({
29
+ id: Schema.optional(Schema.String),
30
+ name: Schema.String,
31
+ args: Schema.optional(Schema.Unknown),
32
+ }),
33
+ })
34
+
35
+ const Part = Schema.Union([TextPart, FunctionCallPart])
36
+ type WireFunctionCallPart = typeof FunctionCallPart.Type
21
37
 
22
38
  const Content = Schema.Struct({
23
39
  role: Schema.optional(Schema.String),
@@ -51,6 +67,13 @@ export type WireChunk = typeof WireChunk.Type
51
67
  type RequestPart =
52
68
  | { readonly text: string }
53
69
  | { readonly inlineData: { readonly mimeType: string; readonly data: string } }
70
+ | { readonly functionCall: { readonly name: string; readonly args: unknown } }
71
+ | {
72
+ readonly functionResponse: {
73
+ readonly name: string
74
+ readonly response: Record<string, unknown>
75
+ }
76
+ }
54
77
 
55
78
  type RequestContent = {
56
79
  readonly role: "user" | "model"
@@ -61,6 +84,22 @@ type RequestSystemInstruction = {
61
84
  readonly parts: ReadonlyArray<{ readonly text: string }>
62
85
  }
63
86
 
87
+ /** Gemini's tool declaration. We translate one `ToolDescriptor` per entry. */
88
+ type RequestTool = {
89
+ readonly functionDeclarations: ReadonlyArray<{
90
+ readonly name: string
91
+ readonly description: string
92
+ readonly parameters: Record<string, unknown>
93
+ }>
94
+ }
95
+
96
+ type RequestToolConfig = {
97
+ readonly functionCallingConfig: {
98
+ readonly mode: "AUTO" | "ANY" | "NONE"
99
+ readonly allowedFunctionNames?: ReadonlyArray<string>
100
+ }
101
+ }
102
+
64
103
  export type ThinkingConfig = {
65
104
  readonly thinkingBudget: number
66
105
  }
@@ -80,6 +119,8 @@ export type RequestBody = {
80
119
  readonly contents: ReadonlyArray<RequestContent>
81
120
  readonly systemInstruction?: RequestSystemInstruction
82
121
  readonly generationConfig?: GenerationConfig
122
+ readonly tools?: ReadonlyArray<RequestTool>
123
+ readonly toolConfig?: RequestToolConfig
83
124
  }
84
125
 
85
126
  const blockText = Match.type<ContentBlock>().pipe(
@@ -142,19 +183,165 @@ const systemMessageText = (message: Message): Result.Result<string, void> => {
142
183
  return text.length === 0 ? Result.failVoid : Result.succeed(text)
143
184
  }
144
185
 
145
- const messages = (history: ReadonlyArray<Item>): ReadonlyArray<Message> =>
186
+ const allMessages = (history: ReadonlyArray<Item>): ReadonlyArray<Message> =>
146
187
  pipe(
147
188
  history,
148
189
  Arr.filterMap((item) => (item.type === "message" ? Result.succeed(item) : Result.failVoid)),
149
190
  )
150
191
 
192
+ // ---------------------------------------------------------------------------
193
+ // Function-call round-trip
194
+ //
195
+ // `FunctionCall.arguments` and `FunctionCallOutput.output` are JSON-encoded
196
+ // strings; Gemini's `functionCall.args` and `functionResponse.response`
197
+ // expect parsed JSON *objects*. Decode via Schema:
198
+ // - object payload → use as-is
199
+ // - scalar / array / malformed → wrap as `{ output: <raw string> }` so the
200
+ // model still sees *some* response without crashing the request.
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
204
+ const decodeJsonObject = Schema.decodeResult(Schema.fromJsonString(JsonObject))
205
+
206
+ const parsedJsonObject =
207
+ (fallback: (raw: string) => Record<string, unknown>) =>
208
+ (encoded: string): Record<string, unknown> =>
209
+ pipe(
210
+ decodeJsonObject(encoded),
211
+ Result.match({
212
+ onSuccess: (v) => v,
213
+ onFailure: () => fallback(encoded),
214
+ }),
215
+ )
216
+
217
+ const parsedArgs = parsedJsonObject(() => ({}))
218
+ const parsedResponse = parsedJsonObject((raw) => ({ output: raw }))
219
+
220
+ /**
221
+ * `FunctionCallOutput` only carries `call_id`; Gemini's `functionResponse`
222
+ * requires the declared function `name`. Resolve the name by scanning prior
223
+ * `function_call` items in the history for a matching `call_id`. If we
224
+ * cannot resolve, fall back to `call_id` as the name - imperfect but
225
+ * preserves stream shape so the model sees *some* response.
226
+ */
227
+ const isFunctionCallItem = (item: Item): item is FunctionCall => item.type === "function_call"
228
+
229
+ const nameForCallId = (history: ReadonlyArray<Item>, call_id: string): Option.Option<string> =>
230
+ pipe(
231
+ history,
232
+ Arr.findFirst(
233
+ (item): item is FunctionCall => isFunctionCallItem(item) && item.call_id === call_id,
234
+ ),
235
+ Option.map((f) => f.name),
236
+ )
237
+
238
+ /**
239
+ * Extract the Gemini-3 wire id we stashed in `providerData` on the way out.
240
+ * Schema-driven so the shape lives in one place; failure → `Option.none()`.
241
+ */
242
+ const ProviderDataWithGeminiId = Schema.Struct({
243
+ gemini: Schema.Struct({ id: Schema.String }),
244
+ })
245
+ const decodeProviderId = Schema.decodeUnknownResult(ProviderDataWithGeminiId)
246
+
247
+ const providerIdFor = (item: FunctionCall): Option.Option<string> =>
248
+ pipe(
249
+ decodeProviderId(item.providerData),
250
+ Result.match({
251
+ onSuccess: (d) => Option.some(d.gemini.id),
252
+ onFailure: () => Option.none<string>(),
253
+ }),
254
+ )
255
+
256
+ const itemToContent =
257
+ (history: ReadonlyArray<Item>) =>
258
+ (item: Item): Result.Result<RequestContent, void> =>
259
+ Match.value(item).pipe(
260
+ Match.discriminatorsExhaustive("type")({
261
+ message: messageToContent,
262
+ function_call: (f) =>
263
+ Result.succeed({
264
+ role: "model" as const,
265
+ parts: [
266
+ {
267
+ functionCall: {
268
+ ...Option.match(providerIdFor(f), {
269
+ onSome: (id) => ({ id }),
270
+ onNone: () => ({}),
271
+ }),
272
+ name: f.name,
273
+ args: parsedArgs(f.arguments),
274
+ },
275
+ },
276
+ ],
277
+ }),
278
+ function_call_output: (o) =>
279
+ Result.succeed({
280
+ role: "user" as const,
281
+ parts: [
282
+ {
283
+ functionResponse: {
284
+ name: Option.getOrElse(nameForCallId(history, o.call_id), () => o.call_id),
285
+ response: parsedResponse(o.output),
286
+ },
287
+ },
288
+ ],
289
+ }),
290
+ reasoning: () => Result.failVoid,
291
+ }),
292
+ )
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Tool descriptors → Gemini `functionDeclarations`. Gemini accepts only a
296
+ // strict OpenAPI 3.0 subset for `parameters`; strip JSON-Schema keys it
297
+ // rejects (`$schema`, `$ref`, `additionalProperties`, `oneOf`,
298
+ // `definitions`).
299
+ // ---------------------------------------------------------------------------
300
+
301
+ const UNSUPPORTED_SCHEMA_KEYS: ReadonlySet<string> = new Set([
302
+ "$schema",
303
+ "$ref",
304
+ "$defs",
305
+ "definitions",
306
+ "additionalProperties",
307
+ "oneOf",
308
+ ])
309
+
310
+ const sanitizeSchema = (schema: unknown): unknown => {
311
+ if (Array.isArray(schema)) return schema.map(sanitizeSchema)
312
+ if (schema === null || typeof schema !== "object") return schema
313
+ return pipe(
314
+ Object.entries(schema as Record<string, unknown>),
315
+ Arr.filterMap(([k, v]) =>
316
+ UNSUPPORTED_SCHEMA_KEYS.has(k)
317
+ ? Result.failVoid
318
+ : Result.succeed([k, sanitizeSchema(v)] as const),
319
+ ),
320
+ Object.fromEntries,
321
+ )
322
+ }
323
+
324
+ const toolDescriptorsToTools = (tools: ReadonlyArray<ToolDescriptor>): ReadonlyArray<RequestTool> =>
325
+ tools.length === 0
326
+ ? []
327
+ : [
328
+ {
329
+ functionDeclarations: tools.map((t) => ({
330
+ name: t.name,
331
+ description: t.description,
332
+ parameters: sanitizeSchema(t.inputSchema) as Record<string, unknown>,
333
+ })),
334
+ },
335
+ ]
336
+
151
337
  export const buildRequestBody = (
152
338
  history: ReadonlyArray<Item>,
153
339
  generationConfig: Option.Option<GenerationConfig>,
340
+ tools: ReadonlyArray<ToolDescriptor> = [],
154
341
  ): RequestBody => {
155
- const msgs = messages(history)
156
- const systemTexts = pipe(msgs, Arr.filterMap(systemMessageText))
157
- const contents = pipe(msgs, Arr.filterMap(messageToContent))
342
+ const systemTexts = pipe(allMessages(history), Arr.filterMap(systemMessageText))
343
+ const contents = pipe(history, Arr.filterMap(itemToContent(history)))
344
+ const requestTools = toolDescriptorsToTools(tools)
158
345
  return {
159
346
  contents,
160
347
  ...(systemTexts.length > 0 && {
@@ -164,6 +351,10 @@ export const buildRequestBody = (
164
351
  onNone: () => ({}),
165
352
  onSome: (cfg) => ({ generationConfig: cfg }),
166
353
  }),
354
+ ...(requestTools.length > 0 && {
355
+ tools: requestTools,
356
+ toolConfig: { functionCallingConfig: { mode: "AUTO" as const } },
357
+ }),
167
358
  }
168
359
  }
169
360
 
@@ -180,9 +371,20 @@ const finishReasonToStop = (reason: Option.Option<string>): Turn["stop_reason"]
180
371
  onSome: (r) => (r === "MAX_TOKENS" ? ("max_tokens" as const) : ("stop" as const)),
181
372
  })
182
373
 
374
+ export type AccumulatedFunctionCall = {
375
+ /** Synthesized id-or-name we surface as `call_id` on the canonical item. */
376
+ readonly callId: string
377
+ readonly name: string
378
+ /** Wire id from Gemini 3, when present - echoed back on `functionResponse`. */
379
+ readonly providerId: Option.Option<string>
380
+ /** Args as JSON-encoded string, mirroring `Items.FunctionCall.arguments`. */
381
+ readonly arguments: string
382
+ }
383
+
183
384
  export type Accumulator = {
184
385
  readonly text: string
185
386
  readonly reasoning: string
387
+ readonly functionCalls: ReadonlyArray<AccumulatedFunctionCall>
186
388
  readonly finishReason: Option.Option<string>
187
389
  readonly usage: {
188
390
  readonly input_tokens?: number
@@ -196,18 +398,25 @@ export type Accumulator = {
196
398
  export const emptyAccumulator: Accumulator = {
197
399
  text: "",
198
400
  reasoning: "",
401
+ functionCalls: [],
199
402
  finishReason: Option.none(),
200
403
  usage: {},
201
404
  }
202
405
 
203
406
  /**
204
- * One part's worth of streamable text: either the model's answer
205
- * (`kind: "text"`) or chain-of-thought (`kind: "reasoning"`). Emitted
206
- * in wire order so consumers can interleave faithfully.
407
+ * One part's worth of streamable output. `text` and `reasoning` are
408
+ * incremental string deltas; `function_call` arrives whole-in-one-chunk
409
+ * (Gemini does not stream tool-call args).
207
410
  */
208
411
  export type ChunkPart =
209
412
  | { readonly kind: "text"; readonly text: string }
210
413
  | { readonly kind: "reasoning"; readonly text: string }
414
+ | {
415
+ readonly kind: "function_call"
416
+ readonly id: Option.Option<string>
417
+ readonly name: string
418
+ readonly args: unknown
419
+ }
211
420
 
212
421
  export type ChunkResult = {
213
422
  readonly accumulator: Accumulator
@@ -215,25 +424,40 @@ export type ChunkResult = {
215
424
  readonly finished: boolean
216
425
  }
217
426
 
427
+ const isTextPart = (p: typeof Part.Type): p is typeof TextPart.Type => "text" in p
428
+
429
+ const textToChunkParts = (p: typeof TextPart.Type): ReadonlyArray<ChunkPart> =>
430
+ p.text.length === 0 ? [] : [{ kind: p.thought === true ? "reasoning" : "text", text: p.text }]
431
+
432
+ const functionCallToChunkParts = (p: WireFunctionCallPart): ReadonlyArray<ChunkPart> => [
433
+ {
434
+ kind: "function_call",
435
+ id: Option.fromNullishOr(p.functionCall.id),
436
+ name: p.functionCall.name,
437
+ args: p.functionCall.args ?? {},
438
+ },
439
+ ]
440
+
441
+ const partToChunkParts = (p: typeof Part.Type): ReadonlyArray<ChunkPart> =>
442
+ isTextPart(p) ? textToChunkParts(p) : functionCallToChunkParts(p)
443
+
218
444
  const chunkParts = (chunk: WireChunk): ReadonlyArray<ChunkPart> =>
219
- pipe(
220
- chunk.candidates?.[0]?.content?.parts ?? [],
221
- Arr.filterMap((p) =>
222
- p.text.length === 0
223
- ? Result.failVoid
224
- : Result.succeed({
225
- kind: p.thought === true ? ("reasoning" as const) : ("text" as const),
226
- text: p.text,
227
- }),
228
- ),
229
- )
445
+ pipe(chunk.candidates?.[0]?.content?.parts ?? [], Arr.flatMap(partToChunkParts))
230
446
 
231
- const sumByKind = (parts: ReadonlyArray<ChunkPart>, kind: ChunkPart["kind"]): string =>
447
+ const sumStrings = (parts: ReadonlyArray<ChunkPart>, kind: "text" | "reasoning"): string =>
232
448
  pipe(
233
449
  parts,
234
450
  Arr.filterMap((p) => (p.kind === kind ? Result.succeed(p.text) : Result.failVoid)),
235
451
  ).join("")
236
452
 
453
+ const collectFunctionCalls = (
454
+ parts: ReadonlyArray<ChunkPart>,
455
+ ): ReadonlyArray<Extract<ChunkPart, { kind: "function_call" }>> =>
456
+ pipe(
457
+ parts,
458
+ Arr.filterMap((p) => (p.kind === "function_call" ? Result.succeed(p) : Result.failVoid)),
459
+ )
460
+
237
461
  const mergeUsage = (
238
462
  prev: Accumulator["usage"],
239
463
  next: WireChunk["usageMetadata"],
@@ -255,6 +479,41 @@ const mergeUsage = (
255
479
  }),
256
480
  }
257
481
 
482
+ /**
483
+ * Synthesize a stable `call_id` for a function call. Gemini 3 provides one
484
+ * via `functionCall.id`; older models do not, so we fall back to
485
+ * `<name>_<index>` based on prior calls' position in the accumulator.
486
+ */
487
+ const synthesizeCallId = (
488
+ call: Extract<ChunkPart, { kind: "function_call" }>,
489
+ priorCalls: ReadonlyArray<AccumulatedFunctionCall>,
490
+ ): string =>
491
+ Option.match(call.id, {
492
+ onSome: (id) => id,
493
+ onNone: () => `${call.name}_${priorCalls.length}`,
494
+ })
495
+
496
+ const chunkCallToAccumulated = (
497
+ prior: ReadonlyArray<AccumulatedFunctionCall>,
498
+ call: Extract<ChunkPart, { kind: "function_call" }>,
499
+ ): AccumulatedFunctionCall => ({
500
+ callId: synthesizeCallId(call, prior),
501
+ name: call.name,
502
+ providerId: call.id,
503
+ // `functionCallToChunkParts` already replaced null/undefined with `{}`,
504
+ // so `call.args` is always a JSON-encodable value here.
505
+ arguments: JSON.stringify(call.args),
506
+ })
507
+
508
+ const appendFunctionCalls = (
509
+ prior: ReadonlyArray<AccumulatedFunctionCall>,
510
+ fromChunk: ReadonlyArray<Extract<ChunkPart, { kind: "function_call" }>>,
511
+ ): ReadonlyArray<AccumulatedFunctionCall> =>
512
+ fromChunk.reduce<ReadonlyArray<AccumulatedFunctionCall>>(
513
+ (acc, call) => [...acc, chunkCallToAccumulated(acc, call)],
514
+ prior,
515
+ )
516
+
258
517
  export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult => {
259
518
  const parts = chunkParts(chunk)
260
519
  const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason)
@@ -262,27 +521,44 @@ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult =>
262
521
  parts,
263
522
  finished: Option.isSome(finishReason),
264
523
  accumulator: {
265
- text: acc.text + sumByKind(parts, "text"),
266
- reasoning: acc.reasoning + sumByKind(parts, "reasoning"),
524
+ text: acc.text + sumStrings(parts, "text"),
525
+ reasoning: acc.reasoning + sumStrings(parts, "reasoning"),
526
+ functionCalls: appendFunctionCalls(acc.functionCalls, collectFunctionCalls(parts)),
267
527
  finishReason: Option.orElse(finishReason, () => acc.finishReason),
268
528
  usage: mergeUsage(acc.usage, chunk.usageMetadata),
269
529
  },
270
530
  }
271
531
  }
272
532
 
533
+ const reasoningItems = (acc: Accumulator): ReadonlyArray<Item> =>
534
+ acc.reasoning.length > 0 ? [{ type: "reasoning", summary: acc.reasoning }] : []
535
+
536
+ const assistantMessageItems = (acc: Accumulator): ReadonlyArray<Item> =>
537
+ acc.text.length === 0
538
+ ? []
539
+ : [
540
+ {
541
+ type: "message",
542
+ role: "assistant",
543
+ content: [{ type: "output_text", text: acc.text }],
544
+ },
545
+ ]
546
+
547
+ const functionCallItems = (acc: Accumulator): ReadonlyArray<Item> =>
548
+ pipe(
549
+ acc.functionCalls,
550
+ Arr.map((c) => ({
551
+ type: "function_call" as const,
552
+ call_id: c.callId,
553
+ name: c.name,
554
+ arguments: c.arguments,
555
+ ...(Option.isSome(c.providerId) && { providerData: { gemini: { id: c.providerId.value } } }),
556
+ })),
557
+ )
558
+
273
559
  export const accumulatorToTurn = (acc: Accumulator): Turn => ({
274
- stop_reason: finishReasonToStop(acc.finishReason),
560
+ stop_reason:
561
+ acc.functionCalls.length > 0 ? ("tool_calls" as const) : finishReasonToStop(acc.finishReason),
275
562
  usage: { ...acc.usage },
276
- items: [
277
- ...(acc.reasoning.length > 0 ? [{ type: "reasoning" as const, summary: acc.reasoning }] : []),
278
- ...(acc.text.length === 0
279
- ? []
280
- : [
281
- {
282
- type: "message" as const,
283
- role: "assistant" as const,
284
- content: [{ type: "output_text" as const, text: acc.text }],
285
- },
286
- ]),
287
- ],
563
+ items: [...reasoningItems(acc), ...assistantMessageItems(acc), ...functionCallItems(acc)],
288
564
  })
@@ -0,0 +1,56 @@
1
+ import { Effect, Encoding } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import type { AudioSource } from "@effect-uai/core/Audio"
4
+ import { audioSourceToInlineData, wrapPcmAsWav } from "./geminiSpeechCodec.js"
5
+
6
+ describe("audioSourceToInlineData", () => {
7
+ it("encodes a `bytes` source to base64 with its MIME type", async () => {
8
+ const original = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
9
+ const source: AudioSource = { _tag: "bytes", bytes: original, mimeType: "audio/wav" }
10
+ const inline = await Effect.runPromise(audioSourceToInlineData(source))
11
+ expect(inline.mimeType).toBe("audio/wav")
12
+ expect(inline.data).toBe(Encoding.encodeBase64(original))
13
+ })
14
+
15
+ it("passes a `base64` source through unchanged", async () => {
16
+ const b64 = Encoding.encodeBase64(new Uint8Array([1, 2, 3]))
17
+ const source: AudioSource = { _tag: "base64", base64: b64, mimeType: "audio/mp3" }
18
+ const inline = await Effect.runPromise(audioSourceToInlineData(source))
19
+ expect(inline.mimeType).toBe("audio/mp3")
20
+ expect(inline.data).toBe(b64)
21
+ })
22
+
23
+ it("fails InvalidRequest for a URL source", async () => {
24
+ const source: AudioSource = { _tag: "url", url: "https://x/y.mp3", mimeType: "audio/mp3" }
25
+ const exit = await Effect.runPromiseExit(audioSourceToInlineData(source))
26
+ expect(exit._tag).toBe("Failure")
27
+ if (exit._tag === "Failure") {
28
+ expect(JSON.stringify(exit.cause)).toContain("InvalidRequest")
29
+ expect(JSON.stringify(exit.cause)).toContain("Files API")
30
+ }
31
+ })
32
+ })
33
+
34
+ describe("wrapPcmAsWav", () => {
35
+ it("produces a 44-byte RIFF/WAVE header for 16-bit mono PCM at 24 kHz", () => {
36
+ const pcm = new Uint8Array([0x00, 0x01, 0x00, 0x02])
37
+ const wav = wrapPcmAsWav(pcm, 24000, 1, 16)
38
+ expect(wav.length).toBe(44 + pcm.length)
39
+ const ascii = (start: number, len: number) =>
40
+ String.fromCharCode(...Array.from(wav.slice(start, start + len)))
41
+ expect(ascii(0, 4)).toBe("RIFF")
42
+ expect(ascii(8, 4)).toBe("WAVE")
43
+ expect(ascii(12, 4)).toBe("fmt ")
44
+ expect(ascii(36, 4)).toBe("data")
45
+ // RIFF chunk size = total - 8
46
+ const view = new DataView(wav.buffer)
47
+ expect(view.getUint32(4, true)).toBe(wav.length - 8)
48
+ expect(view.getUint16(20, true)).toBe(1) // PCM
49
+ expect(view.getUint16(22, true)).toBe(1) // mono
50
+ expect(view.getUint32(24, true)).toBe(24000) // sample rate
51
+ expect(view.getUint16(34, true)).toBe(16) // bits per sample
52
+ expect(view.getUint32(40, true)).toBe(pcm.length) // data size
53
+ // Payload preserved
54
+ expect(Array.from(wav.slice(44))).toEqual(Array.from(pcm))
55
+ })
56
+ })
@@ -0,0 +1,137 @@
1
+ import { Effect, Encoding, Match, Result } from "effect"
2
+ import * as AiError from "@effect-uai/core/AiError"
3
+ import type { AudioSource } from "@effect-uai/core/Audio"
4
+
5
+ /**
6
+ * Shared codec helpers for Gemini speech endpoints. The TTS and STT
7
+ * variants both ride on `:generateContent`, so the audio-in / audio-out
8
+ * shapes are siblings.
9
+ */
10
+
11
+ const urlNotSupported: AiError.AiError = new AiError.InvalidRequest({
12
+ provider: "gemini",
13
+ param: "audio",
14
+ raw: 'Gemini inline audio does not accept URL sources. Either fetch the URL yourself and pass `{ _tag: "bytes", bytes, mimeType }`, or upload via the Files API and pass a `fileData` URI (not yet wired here).',
15
+ })
16
+
17
+ /** Wire shape for an inline-audio part on `generateContent`. */
18
+ export type InlineAudioData = {
19
+ readonly mimeType: string
20
+ readonly data: string
21
+ }
22
+
23
+ /**
24
+ * Encode any `AudioSource` into the `inlineData` shape expected by
25
+ * Gemini's `generateContent`. URL variants are rejected with
26
+ * `InvalidRequest` — Files API upload is a separate flow.
27
+ *
28
+ * Caller is responsible for keeping the total request body under
29
+ * 20 MB. Beyond that, Gemini's Files API is the only path.
30
+ */
31
+ export const audioSourceToInlineData: (
32
+ audio: AudioSource,
33
+ ) => Effect.Effect<InlineAudioData, AiError.AiError> = Match.type<AudioSource>().pipe(
34
+ Match.tag("bytes", (a) =>
35
+ Effect.succeed<InlineAudioData>({
36
+ mimeType: a.mimeType,
37
+ data: Encoding.encodeBase64(a.bytes),
38
+ }),
39
+ ),
40
+ Match.tag("base64", (a) =>
41
+ Effect.succeed<InlineAudioData>({ mimeType: a.mimeType, data: a.base64 }),
42
+ ),
43
+ Match.tag("url", () => Effect.fail(urlNotSupported)),
44
+ Match.exhaustive,
45
+ )
46
+
47
+ /** Decode a base64-encoded audio payload into bytes. */
48
+ export const decodeBase64Audio = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
49
+ Result.match(Encoding.decodeBase64(b64), {
50
+ onSuccess: Effect.succeed,
51
+ onFailure: (cause) =>
52
+ Effect.fail(new AiError.InvalidRequest({ provider: "gemini", param: "audio", raw: cause })),
53
+ })
54
+
55
+ /**
56
+ * Wrap a raw 16-bit signed little-endian PCM buffer in a standard 44-byte
57
+ * RIFF/WAVE header. Gemini TTS returns PCM-only — for playback or for
58
+ * round-tripping into Gemini transcription (which accepts wav, not raw
59
+ * PCM), the bytes need a container.
60
+ */
61
+ export const wrapPcmAsWav = (
62
+ pcm: Uint8Array,
63
+ sampleRate: number,
64
+ channels: number,
65
+ bitsPerSample = 16,
66
+ ): Uint8Array => {
67
+ const dataSize = pcm.length
68
+ const blockAlign = (channels * bitsPerSample) / 8
69
+ const byteRate = sampleRate * blockAlign
70
+ const totalSize = 44 + dataSize
71
+ const buffer = new ArrayBuffer(totalSize)
72
+ const view = new DataView(buffer)
73
+ const writeAscii = (offset: number, s: string): void => {
74
+ for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i))
75
+ }
76
+ writeAscii(0, "RIFF")
77
+ view.setUint32(4, totalSize - 8, true)
78
+ writeAscii(8, "WAVE")
79
+ writeAscii(12, "fmt ")
80
+ view.setUint32(16, 16, true)
81
+ view.setUint16(20, 1, true)
82
+ view.setUint16(22, channels, true)
83
+ view.setUint32(24, sampleRate, true)
84
+ view.setUint32(28, byteRate, true)
85
+ view.setUint16(32, blockAlign, true)
86
+ view.setUint16(34, bitsPerSample, true)
87
+ writeAscii(36, "data")
88
+ view.setUint32(40, dataSize, true)
89
+ new Uint8Array(buffer, 44, dataSize).set(pcm)
90
+ return new Uint8Array(buffer)
91
+ }
92
+
93
+ /**
94
+ * HTTP status → typed `AiError`. Shared between synthesizer and
95
+ * transcriber.
96
+ */
97
+ export const httpStatusError: (status: number, body: string) => AiError.AiError = (status, body) =>
98
+ Match.value(status).pipe(
99
+ Match.when(
100
+ 429,
101
+ (): AiError.AiError => new AiError.RateLimited({ provider: "gemini", raw: body }),
102
+ ),
103
+ Match.whenOr(
104
+ 408,
105
+ 504,
106
+ (): AiError.AiError => new AiError.Timeout({ provider: "gemini", raw: body }),
107
+ ),
108
+ Match.when(
109
+ 401,
110
+ (): AiError.AiError =>
111
+ new AiError.AuthFailed({ provider: "gemini", subtype: "auth", raw: body }),
112
+ ),
113
+ Match.when(
114
+ 403,
115
+ (): AiError.AiError =>
116
+ new AiError.AuthFailed({ provider: "gemini", subtype: "permission", raw: body }),
117
+ ),
118
+ Match.when(
119
+ 402,
120
+ (): AiError.AiError =>
121
+ new AiError.AuthFailed({ provider: "gemini", subtype: "billing", raw: body }),
122
+ ),
123
+ Match.when(
124
+ 413,
125
+ (): AiError.AiError => new AiError.ContextLengthExceeded({ provider: "gemini", raw: body }),
126
+ ),
127
+ Match.when(
128
+ (n) => n >= 500,
129
+ (n): AiError.AiError => new AiError.Unavailable({ provider: "gemini", status: n, raw: body }),
130
+ ),
131
+ Match.orElse(
132
+ (): AiError.AiError => new AiError.InvalidRequest({ provider: "gemini", raw: body }),
133
+ ),
134
+ )
135
+
136
+ export const transportFailure = (cause: unknown): AiError.AiError =>
137
+ new AiError.Unavailable({ provider: "gemini", raw: cause })
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * as Gemini from "./Gemini.js"
2
2
  export * as GeminiEmbedding from "./GeminiEmbedding.js"
3
+ export * as GeminiSynthesizer from "./GeminiSynthesizer.js"
4
+ export * as GeminiTranscriber from "./GeminiTranscriber.js"
5
+ export * as LyriaGenerator from "./LyriaGenerator.js"
3
6
  export * as codec from "./codec.js"
4
7
  export * from "./models.js"