@effect-uai/google 0.11.0 → 0.12.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.
@@ -1,6 +1,20 @@
1
- import { Context, Effect, Layer, Match, Redacted, Ref, Schema, Stream } from "effect"
1
+ import {
2
+ Array as Arr,
3
+ Context,
4
+ Effect,
5
+ Layer,
6
+ Match,
7
+ Option,
8
+ Redacted,
9
+ Ref,
10
+ Result,
11
+ Schema,
12
+ Stream,
13
+ pipe,
14
+ } from "effect"
2
15
  import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
16
  import * as AiError from "@effect-uai/core/AiError"
17
+ import type { Source } from "@effect-uai/core/Citation"
4
18
  import {
5
19
  DeepResearch,
6
20
  type DeepResearchService,
@@ -13,6 +27,7 @@ import * as Job from "@effect-uai/core/Job"
13
27
  import type { ResearchJobRef, ResearchRequest, ResearchState } from "@effect-uai/core/Research"
14
28
  import * as SSE from "@effect-uai/core/SSE"
15
29
  import { type Turn, TurnEvent } from "@effect-uai/core/Turn"
30
+ import { GroundingMetadata, groundingToAnnotations } from "./codec.js"
16
31
  import type { GoogleDeepResearchModel } from "./models.js"
17
32
 
18
33
  // ---------------------------------------------------------------------------
@@ -61,23 +76,28 @@ const decodeSubmit = Schema.decodeUnknownEffect(WireSubmit)
61
76
 
62
77
  // A completed interaction's text lives at `outputs[].text` (SDK `outputs`) or,
63
78
  // in some doc examples, `steps[].content[].text`. Decode both defensively and
64
- // extract from whichever is populated. Citations are undocumented on this
65
- // preview surface, so none are mapped yet (the report text carries inline
66
- // `[n]` markers); enrich once a live response confirms the shape.
79
+ // extract from whichever is populated. Grounding sources ride Google's
80
+ // `groundingMetadata.groundingChunks[]`; this preview surface is undocumented,
81
+ // so decode it at every plausible level (interaction / output / step) and map
82
+ // whichever is populated onto `url_citation` annotations. The report text also
83
+ // carries the sources as an inline `[n]` / `**Sources:**` list.
67
84
  const WireTextPart = Schema.Struct({
68
85
  type: Schema.optional(Schema.NullOr(Schema.String)),
69
86
  text: Schema.optional(Schema.NullOr(Schema.String)),
87
+ groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
70
88
  })
71
89
  const WireStep = Schema.Struct({
72
90
  type: Schema.optional(Schema.NullOr(Schema.String)),
73
91
  text: Schema.optional(Schema.NullOr(Schema.String)),
74
92
  content: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
93
+ groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
75
94
  })
76
95
  export const WireInteraction = Schema.Struct({
77
96
  id: Schema.optional(Schema.NullOr(Schema.String)),
78
97
  status: Schema.optional(Schema.NullOr(Schema.String)),
79
98
  outputs: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
80
99
  steps: Schema.optional(Schema.NullOr(Schema.Array(WireStep))),
100
+ groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
81
101
  error: Schema.optional(
82
102
  Schema.NullOr(
83
103
  Schema.Struct({
@@ -104,18 +124,94 @@ const reportText = (wire: WireInteraction): string => {
104
124
  return fromStep.length > 0 ? fromStep : (lastStep?.text ?? "")
105
125
  }
106
126
 
107
- const turnFromInteraction = (wire: WireInteraction): Turn => ({
108
- items: [
109
- {
110
- type: "message",
111
- role: "assistant",
112
- content: [{ type: "output_text", text: reportText(wire) }],
113
- providerData: wire,
114
- },
115
- ],
116
- usage: {},
117
- stop_reason: "stop",
127
+ /**
128
+ * The subset of the domain `Citation.Source` this provider can populate.
129
+ * Declared here rather than derived from the wire schema, so the exported
130
+ * shape is ours and a wire change cannot silently alter it.
131
+ */
132
+ const SourceSchema = Schema.Struct({
133
+ url: Schema.String,
134
+ title: Schema.optional(Schema.String),
135
+ })
136
+
137
+ /**
138
+ * The research trace: what the model did at each step and which sources it
139
+ * consulted there. The `Turn` keeps only the final report and the deduped
140
+ * union of all sources, so the per-step breakdown lives here or nowhere.
141
+ *
142
+ * Lands on `providerData.gemini`; read it with {@link researchDataOf}.
143
+ */
144
+ export const GeminiResearchData = Schema.Struct({
145
+ steps: Schema.Array(
146
+ Schema.Struct({
147
+ text: Schema.String,
148
+ sources: Schema.Array(SourceSchema),
149
+ }),
150
+ ),
118
151
  })
152
+ export type GeminiResearchData = typeof GeminiResearchData.Type
153
+
154
+ const decodeResearchData = Schema.decodeUnknownResult(Schema.Struct({ gemini: GeminiResearchData }))
155
+
156
+ /** Read this provider's data off an item, if it is there and ours. */
157
+ export const researchDataOf = (item: Items.HistoryItem): Option.Option<GeminiResearchData> =>
158
+ pipe(
159
+ decodeResearchData(item.providerData),
160
+ Result.match({
161
+ onSuccess: (d) => Option.some(d.gemini),
162
+ onFailure: () => Option.none<GeminiResearchData>(),
163
+ }),
164
+ )
165
+
166
+ const sourcesOf = (meta: GroundingMetadata | null | undefined): ReadonlyArray<Source> =>
167
+ (meta?.groundingChunks ?? []).flatMap((chunk) =>
168
+ chunk.web?.uri == null
169
+ ? []
170
+ : [{ url: chunk.web.uri, ...(chunk.web.title != null && { title: chunk.web.title }) }],
171
+ )
172
+
173
+ // A step contributes to the trace if it said something or consulted something.
174
+ const researchSteps = (wire: WireInteraction): GeminiResearchData["steps"] =>
175
+ (wire.steps ?? []).flatMap((step) => {
176
+ const text = textOfParts(step.content) || (step.text ?? "")
177
+ const sources = [
178
+ ...sourcesOf(step.groundingMetadata),
179
+ ...(step.content ?? []).flatMap((p) => sourcesOf(p.groundingMetadata)),
180
+ ]
181
+ return text.length === 0 && sources.length === 0 ? [] : [{ text, sources }]
182
+ })
183
+
184
+ // Grounding can live at any level of the interaction envelope; gather it from
185
+ // all three (interaction / output part / step) and let `groundingToAnnotations`
186
+ // de-dupe by url.
187
+ const interactionAnnotations = (wire: WireInteraction): ReadonlyArray<Items.Annotation> =>
188
+ groundingToAnnotations(
189
+ wire.groundingMetadata,
190
+ ...Arr.map(wire.outputs ?? [], (o) => o.groundingMetadata),
191
+ ...Arr.map(wire.steps ?? [], (s) => s.groundingMetadata),
192
+ )
193
+
194
+ const turnFromInteraction = (wire: WireInteraction): Turn => {
195
+ const annotations = interactionAnnotations(wire)
196
+ return {
197
+ items: [
198
+ {
199
+ type: "message",
200
+ role: "assistant",
201
+ content: [
202
+ {
203
+ type: "output_text",
204
+ text: reportText(wire),
205
+ ...(annotations.length > 0 && { annotations }),
206
+ },
207
+ ],
208
+ providerData: { gemini: { steps: researchSteps(wire) } },
209
+ },
210
+ ],
211
+ usage: {},
212
+ stop_reason: "stop",
213
+ }
214
+ }
119
215
 
120
216
  // Map the interaction status onto the generic `JobState`.
121
217
  export const jobStateOf = (wire: WireInteraction): ResearchState =>
@@ -226,6 +322,9 @@ const pollJob = (
226
322
  return yield* httpStatusError(response.status, text)
227
323
  }
228
324
  const json = yield* response.json.pipe(Effect.mapError(transportFailure))
325
+ // Preview surface: log the raw interaction so the (undocumented) grounding
326
+ // path can be confirmed against a live completed job at LOG_LEVEL=Debug.
327
+ yield* Effect.logDebug("google interactions poll response", { interaction: json })
229
328
  const wire = yield* decodeInteraction(json).pipe(Effect.mapError(transportFailure))
230
329
  return jobStateOf(wire)
231
330
  })
@@ -284,7 +383,7 @@ const completedTurn = (wire: WireInteraction | null | undefined, streamed: strin
284
383
  type: "message",
285
384
  role: "assistant",
286
385
  content: [{ type: "output_text", text: streamed }],
287
- ...(wire != null && { providerData: wire }),
386
+ ...(wire != null && { providerData: { gemini: { steps: researchSteps(wire) } } }),
288
387
  },
289
388
  ],
290
389
  usage: {},
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
  }
@@ -292,6 +341,20 @@ const geminiField = (
292
341
  )
293
342
 
294
343
  const providerIdFor = (item: ToolCall): Option.Option<string> => geminiField(item, (g) => g.id)
344
+
345
+ // The originating call's Gemini id, looked up from history by our `call_id`.
346
+ // Gemini 3 maps a `functionResponse` back to its call by this id, so parallel
347
+ // calls to the same function are mis-paired if we drop it.
348
+ const providerIdForCallId = (
349
+ history: ReadonlyArray<HistoryItem>,
350
+ call_id: string,
351
+ ): Option.Option<string> =>
352
+ pipe(
353
+ history,
354
+ Arr.findFirst((item): item is ToolCall => isFunctionCallItem(item) && item.call_id === call_id),
355
+ Option.flatMap(providerIdFor),
356
+ )
357
+
295
358
  const signatureFor = (item: ToolCall): Option.Option<string> =>
296
359
  geminiField(item, (g) => g.thoughtSignature)
297
360
 
@@ -327,6 +390,10 @@ const itemToContent =
327
390
  parts: [
328
391
  {
329
392
  functionResponse: {
393
+ ...Option.match(providerIdForCallId(history, o.call_id), {
394
+ onSome: (id) => ({ id }),
395
+ onNone: () => ({}),
396
+ }),
330
397
  name: Option.getOrElse(nameForCallId(history, o.call_id), () => o.call_id),
331
398
  response: parsedResponse(o.output),
332
399
  },
@@ -466,6 +533,8 @@ export type Accumulator = {
466
533
  readonly text: string
467
534
  readonly reasoning: string
468
535
  readonly functionCalls: ReadonlyArray<AccumulatedFunctionCall>
536
+ /** Grounding citations, accumulated (de-duped by url) across chunks. */
537
+ readonly annotations: ReadonlyArray<Annotation>
469
538
  readonly finishReason: Option.Option<string>
470
539
  readonly usage: {
471
540
  readonly input_tokens?: number
@@ -480,6 +549,7 @@ export const emptyAccumulator: Accumulator = {
480
549
  text: "",
481
550
  reasoning: "",
482
551
  functionCalls: [],
552
+ annotations: [],
483
553
  finishReason: Option.none(),
484
554
  usage: {},
485
555
  }
@@ -598,6 +668,14 @@ const appendFunctionCalls = (
598
668
  prior,
599
669
  )
600
670
 
671
+ // Grounding usually arrives on the final chunk, but merge across chunks and
672
+ // de-dupe by url so a mid-stream metadata block is not lost or double-counted.
673
+ const mergeAnnotations = (
674
+ prev: ReadonlyArray<Annotation>,
675
+ next: ReadonlyArray<Annotation>,
676
+ ): ReadonlyArray<Annotation> =>
677
+ next.length === 0 ? prev : Arr.dedupeWith(Arr.appendAll(prev, next), sameUrlCitation)
678
+
601
679
  export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult => {
602
680
  const parts = chunkParts(chunk)
603
681
  const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason)
@@ -608,6 +686,10 @@ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult =>
608
686
  text: acc.text + sumStrings(parts, "text"),
609
687
  reasoning: acc.reasoning + sumStrings(parts, "reasoning"),
610
688
  functionCalls: appendFunctionCalls(acc.functionCalls, collectFunctionCalls(parts)),
689
+ annotations: mergeAnnotations(
690
+ acc.annotations,
691
+ groundingToAnnotations(chunk.candidates?.[0]?.groundingMetadata),
692
+ ),
611
693
  finishReason: Option.orElse(finishReason, () => acc.finishReason),
612
694
  usage: mergeUsage(acc.usage, chunk.usageMetadata),
613
695
  },
@@ -624,7 +706,13 @@ const assistantMessageItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
624
706
  {
625
707
  type: "message",
626
708
  role: "assistant",
627
- content: [{ type: "output_text", text: acc.text }],
709
+ content: [
710
+ {
711
+ type: "output_text",
712
+ text: acc.text,
713
+ ...(acc.annotations.length > 0 && { annotations: acc.annotations }),
714
+ },
715
+ ],
628
716
  },
629
717
  ]
630
718
 
@@ -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
  })