@effect-uai/google 0.11.0 → 0.12.1

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 (63) hide show
  1. package/dist/Gemini.d.mts +8 -2
  2. package/dist/Gemini.d.mts.map +1 -1
  3. package/dist/Gemini.mjs +1 -1
  4. package/dist/Gemini.mjs.map +1 -1
  5. package/dist/GeminiEmbedding.d.mts +2 -3
  6. package/dist/GeminiEmbedding.d.mts.map +1 -1
  7. package/dist/GeminiEmbedding.mjs +1 -1
  8. package/dist/GeminiEmbedding.mjs.map +1 -1
  9. package/dist/GeminiSynthesizer.d.mts +14 -6
  10. package/dist/GeminiSynthesizer.d.mts.map +1 -1
  11. package/dist/GeminiSynthesizer.mjs +13 -12
  12. package/dist/GeminiSynthesizer.mjs.map +1 -1
  13. package/dist/GeminiSynthesizer.test.d.mts +1 -1
  14. package/dist/GeminiSynthesizer.test.mjs +1 -1
  15. package/dist/GeminiSynthesizer.test.mjs.map +1 -1
  16. package/dist/GeminiTools.d.mts +9 -5
  17. package/dist/GeminiTools.d.mts.map +1 -1
  18. package/dist/GeminiTools.mjs +1 -1
  19. package/dist/GeminiTools.mjs.map +1 -1
  20. package/dist/GoogleDeepResearch.d.mts +58 -7
  21. package/dist/GoogleDeepResearch.d.mts.map +1 -1
  22. package/dist/GoogleDeepResearch.mjs +77 -23
  23. package/dist/GoogleDeepResearch.mjs.map +1 -1
  24. package/dist/GoogleDeepResearch.test.d.mts +1 -0
  25. package/dist/GoogleDeepResearch.test.mjs +66 -0
  26. package/dist/GoogleDeepResearch.test.mjs.map +1 -0
  27. package/dist/LyriaGenerator.d.mts +0 -2
  28. package/dist/LyriaGenerator.d.mts.map +1 -1
  29. package/dist/LyriaGenerator.mjs +9 -6
  30. package/dist/LyriaGenerator.mjs.map +1 -1
  31. package/dist/LyriaGenerator.test.d.mts +1 -1
  32. package/dist/LyriaGenerator.test.mjs +153 -8
  33. package/dist/LyriaGenerator.test.mjs.map +1 -1
  34. package/dist/codec.d.mts +36 -11
  35. package/dist/codec.d.mts.map +1 -1
  36. package/dist/codec.mjs +27 -4
  37. package/dist/codec.mjs.map +1 -1
  38. package/dist/geminiSpeechCodec.d.mts +0 -1
  39. package/dist/geminiSpeechCodec.d.mts.map +1 -1
  40. package/dist/geminiSpeechCodec.mjs.map +1 -1
  41. package/dist/geminiSpeechCodec.test.d.mts +1 -1
  42. package/dist/geminiSpeechCodec.test.mjs +1 -1
  43. package/dist/geminiSpeechCodec.test.mjs.map +1 -1
  44. package/dist/geminiTools.test.d.mts +1 -1
  45. package/dist/geminiTools.test.mjs +44 -19
  46. package/dist/geminiTools.test.mjs.map +1 -1
  47. package/dist/{magic-string.es-CaWgF-3p.mjs → magic-string.es-CUAnjKYC.mjs} +14 -13
  48. package/dist/{magic-string.es-CaWgF-3p.mjs.map → magic-string.es-CUAnjKYC.mjs.map} +1 -1
  49. package/dist/models.d.mts.map +1 -1
  50. package/dist/{dist-CWoAuwS9.mjs → test.DNmyFkvJ-N3QqMRwZ.mjs} +257 -381
  51. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +1 -0
  52. package/package.json +5 -5
  53. package/src/Gemini.ts +4 -7
  54. package/src/GeminiEmbedding.ts +36 -48
  55. package/src/GeminiSynthesizer.ts +20 -27
  56. package/src/GeminiTools.ts +4 -6
  57. package/src/GoogleDeepResearch.test.ts +80 -0
  58. package/src/GoogleDeepResearch.ts +127 -37
  59. package/src/LyriaGenerator.ts +19 -23
  60. package/src/codec.ts +98 -12
  61. package/src/geminiTools.test.ts +21 -3
  62. package/dist/dist-CWoAuwS9.mjs.map +0 -1
  63. /package/dist/{chunk-uyGKjUfl.mjs → rolldown-runtime-EAsCxrDo.mjs} +0 -0
package/src/codec.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  InputImage,
6
6
  HistoryItem,
7
7
  Message,
8
+ Annotation,
8
9
  } from "@effect-uai/core/Items"
9
10
  import type { ToolDescriptor } from "@effect-uai/core/Tool"
10
11
  import type { Turn } from "@effect-uai/core/Turn"
@@ -50,8 +51,55 @@ const Content = Schema.Struct({
50
51
  parts: Schema.optional(Schema.Array(Part)),
51
52
  })
52
53
 
54
+ // Google grounding metadata. On a grounded response the candidate carries
55
+ // `groundingChunks[].web.{uri,title}` - the structured form of the inline `[n]`
56
+ // / trailing `**Sources:**` list the model renders into the answer text.
57
+ // Decoded defensively (every field optional) as it is absent on ungrounded
58
+ // turns. Shared with the Interactions deep-research surface via `codec.ts`.
59
+ const GroundingChunk = Schema.Struct({
60
+ web: Schema.optional(
61
+ Schema.NullOr(
62
+ Schema.Struct({
63
+ uri: Schema.optional(Schema.NullOr(Schema.String)),
64
+ title: Schema.optional(Schema.NullOr(Schema.String)),
65
+ }),
66
+ ),
67
+ ),
68
+ })
69
+ export const GroundingMetadata = Schema.Struct({
70
+ groundingChunks: Schema.optional(Schema.NullOr(Schema.Array(GroundingChunk))),
71
+ })
72
+ export type GroundingMetadata = typeof GroundingMetadata.Type
73
+
74
+ // Two citations are the same source when they point at the same url.
75
+ const sameUrlCitation = (a: Annotation, b: Annotation): boolean =>
76
+ a.type === "url_citation" && b.type === "url_citation" && a.url === b.url
77
+
78
+ // Grounding chunks → `url_citation` annotations, de-duped by url across every
79
+ // metadata block passed. `web.uri` is a `grounding-api-redirect` URL and
80
+ // `web.title` the bare domain; both are kept as-is (the redirect resolves to
81
+ // the real page).
82
+ export const groundingToAnnotations = (
83
+ ...metas: ReadonlyArray<GroundingMetadata | null | undefined>
84
+ ): ReadonlyArray<Annotation> =>
85
+ pipe(
86
+ metas,
87
+ Arr.flatMap((meta) => meta?.groundingChunks ?? []),
88
+ Arr.filterMap((chunk) =>
89
+ chunk.web?.uri == null
90
+ ? Result.failVoid
91
+ : Result.succeed<Annotation>({
92
+ type: "url_citation",
93
+ url: chunk.web.uri,
94
+ title: chunk.web.title ?? chunk.web.uri,
95
+ }),
96
+ ),
97
+ Arr.dedupeWith(sameUrlCitation),
98
+ )
99
+
53
100
  const Candidate = Schema.Struct({
54
101
  content: Schema.optional(Content),
102
+ groundingMetadata: Schema.optional(GroundingMetadata),
55
103
  finishReason: Schema.optional(Schema.String),
56
104
  index: Schema.optional(Schema.Number),
57
105
  })
@@ -87,6 +135,7 @@ type RequestPart =
87
135
  }
88
136
  | {
89
137
  readonly functionResponse: {
138
+ readonly id?: string
90
139
  readonly name: string
91
140
  readonly response: Record<string, unknown>
92
141
  }
@@ -169,16 +218,12 @@ const messageText = (message: Message): string => message.content.map(blockText)
169
218
  */
170
219
  const imageSourceToParts = Match.type<InputImage["source"]>().pipe(
171
220
  Match.tag("url", (): ReadonlyArray<RequestPart> => []),
172
- Match.tag(
173
- "base64",
174
- (s): ReadonlyArray<RequestPart> => [{ inlineData: { mimeType: s.mimeType, data: s.base64 } }],
175
- ),
176
- Match.tag(
177
- "bytes",
178
- (s): ReadonlyArray<RequestPart> => [
179
- { inlineData: { mimeType: s.mimeType, data: Encoding.encodeBase64(s.bytes) } },
180
- ],
181
- ),
221
+ Match.tag("base64", (s): ReadonlyArray<RequestPart> => [
222
+ { inlineData: { mimeType: s.mimeType, data: s.base64 } },
223
+ ]),
224
+ Match.tag("bytes", (s): ReadonlyArray<RequestPart> => [
225
+ { inlineData: { mimeType: s.mimeType, data: Encoding.encodeBase64(s.bytes) } },
226
+ ]),
182
227
  Match.exhaustive,
183
228
  )
184
229
 
@@ -292,6 +337,20 @@ const geminiField = (
292
337
  )
293
338
 
294
339
  const providerIdFor = (item: ToolCall): Option.Option<string> => geminiField(item, (g) => g.id)
340
+
341
+ // The originating call's Gemini id, looked up from history by our `call_id`.
342
+ // Gemini 3 maps a `functionResponse` back to its call by this id, so parallel
343
+ // calls to the same function are mis-paired if we drop it.
344
+ const providerIdForCallId = (
345
+ history: ReadonlyArray<HistoryItem>,
346
+ call_id: string,
347
+ ): Option.Option<string> =>
348
+ pipe(
349
+ history,
350
+ Arr.findFirst((item): item is ToolCall => isFunctionCallItem(item) && item.call_id === call_id),
351
+ Option.flatMap(providerIdFor),
352
+ )
353
+
295
354
  const signatureFor = (item: ToolCall): Option.Option<string> =>
296
355
  geminiField(item, (g) => g.thoughtSignature)
297
356
 
@@ -327,6 +386,10 @@ const itemToContent =
327
386
  parts: [
328
387
  {
329
388
  functionResponse: {
389
+ ...Option.match(providerIdForCallId(history, o.call_id), {
390
+ onSome: (id) => ({ id }),
391
+ onNone: () => ({}),
392
+ }),
330
393
  name: Option.getOrElse(nameForCallId(history, o.call_id), () => o.call_id),
331
394
  response: parsedResponse(o.output),
332
395
  },
@@ -367,7 +430,9 @@ const sanitizeSchema = (schema: unknown): unknown => {
367
430
  )
368
431
  }
369
432
 
370
- const toolDescriptorsToTools = (tools: ReadonlyArray<ToolDescriptor>): ReadonlyArray<RequestTool> =>
433
+ const toolDescriptorsToTools = (
434
+ tools: ReadonlyArray<ToolDescriptor>,
435
+ ): ReadonlyArray<RequestTool> =>
371
436
  tools.length === 0
372
437
  ? []
373
438
  : [
@@ -466,6 +531,8 @@ export type Accumulator = {
466
531
  readonly text: string
467
532
  readonly reasoning: string
468
533
  readonly functionCalls: ReadonlyArray<AccumulatedFunctionCall>
534
+ /** Grounding citations, accumulated (de-duped by url) across chunks. */
535
+ readonly annotations: ReadonlyArray<Annotation>
469
536
  readonly finishReason: Option.Option<string>
470
537
  readonly usage: {
471
538
  readonly input_tokens?: number
@@ -480,6 +547,7 @@ export const emptyAccumulator: Accumulator = {
480
547
  text: "",
481
548
  reasoning: "",
482
549
  functionCalls: [],
550
+ annotations: [],
483
551
  finishReason: Option.none(),
484
552
  usage: {},
485
553
  }
@@ -598,6 +666,14 @@ const appendFunctionCalls = (
598
666
  prior,
599
667
  )
600
668
 
669
+ // Grounding usually arrives on the final chunk, but merge across chunks and
670
+ // de-dupe by url so a mid-stream metadata block is not lost or double-counted.
671
+ const mergeAnnotations = (
672
+ prev: ReadonlyArray<Annotation>,
673
+ next: ReadonlyArray<Annotation>,
674
+ ): ReadonlyArray<Annotation> =>
675
+ next.length === 0 ? prev : Arr.dedupeWith(Arr.appendAll(prev, next), sameUrlCitation)
676
+
601
677
  export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult => {
602
678
  const parts = chunkParts(chunk)
603
679
  const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason)
@@ -608,6 +684,10 @@ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult =>
608
684
  text: acc.text + sumStrings(parts, "text"),
609
685
  reasoning: acc.reasoning + sumStrings(parts, "reasoning"),
610
686
  functionCalls: appendFunctionCalls(acc.functionCalls, collectFunctionCalls(parts)),
687
+ annotations: mergeAnnotations(
688
+ acc.annotations,
689
+ groundingToAnnotations(chunk.candidates?.[0]?.groundingMetadata),
690
+ ),
611
691
  finishReason: Option.orElse(finishReason, () => acc.finishReason),
612
692
  usage: mergeUsage(acc.usage, chunk.usageMetadata),
613
693
  },
@@ -624,7 +704,13 @@ const assistantMessageItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
624
704
  {
625
705
  type: "message",
626
706
  role: "assistant",
627
- content: [{ type: "output_text", text: acc.text }],
707
+ content: [
708
+ {
709
+ type: "output_text",
710
+ text: acc.text,
711
+ ...(acc.annotations.length > 0 && { annotations: acc.annotations }),
712
+ },
713
+ ],
628
714
  },
629
715
  ]
630
716
 
@@ -1,8 +1,15 @@
1
1
  import { Effect, Option, Result, Schema } from "effect"
2
2
  import * as Tool from "@effect-uai/core/Tool"
3
3
  import { descriptorsOf, providerToolsOf } from "@effect-uai/core/Tool"
4
+ import * as Turn from "@effect-uai/core/Turn"
4
5
  import { describe, expect, it } from "vitest"
5
- import { WireChunk, buildRequestBody } from "./codec.js"
6
+ import {
7
+ WireChunk,
8
+ accumulatorToTurn,
9
+ buildRequestBody,
10
+ emptyAccumulator,
11
+ ingestChunk,
12
+ } from "./codec.js"
6
13
  import { codeExecutionTool, googleSearchTool, renderProviderTools } from "./GeminiTools.js"
7
14
 
8
15
  const localTool = Tool.make({
@@ -69,14 +76,19 @@ describe("Gemini provider tools", () => {
69
76
  expect(Result.isFailure(result)).toBe(true)
70
77
  })
71
78
 
72
- it("decodes a grounded chunk without throwing (groundingMetadata is dropped)", () => {
79
+ it("maps groundingMetadata chunks to url_citation annotations on the turn", () => {
73
80
  const grounded = {
74
81
  candidates: [
75
82
  {
76
83
  content: { role: "model", parts: [{ text: "Grounded answer." }] },
77
84
  groundingMetadata: {
78
85
  webSearchQueries: ["effect ts"],
79
- groundingChunks: [{ web: { uri: "https://example.com", title: "Example" } }],
86
+ groundingChunks: [
87
+ { web: { uri: "https://example.com", title: "Example" } },
88
+ // Duplicate uri (de-duped) and a bare-domain title fallback.
89
+ { web: { uri: "https://example.com", title: "Example" } },
90
+ { web: { uri: "https://effect.website" } },
91
+ ],
80
92
  },
81
93
  finishReason: "STOP",
82
94
  },
@@ -84,5 +96,11 @@ describe("Gemini provider tools", () => {
84
96
  }
85
97
  const chunk = Schema.decodeUnknownSync(WireChunk)(grounded)
86
98
  expect(chunk.candidates?.[0]?.content?.parts?.[0]).toEqual({ text: "Grounded answer." })
99
+
100
+ const turn = accumulatorToTurn(ingestChunk(emptyAccumulator, chunk).accumulator)
101
+ expect(Turn.citations(turn)).toEqual([
102
+ { type: "url_citation", url: "https://example.com", title: "Example" },
103
+ { type: "url_citation", url: "https://effect.website", title: "https://effect.website" },
104
+ ])
87
105
  })
88
106
  })