@effect-uai/google 0.10.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.
Files changed (50) hide show
  1. package/dist/Gemini.d.mts +11 -2
  2. package/dist/Gemini.d.mts.map +1 -1
  3. package/dist/Gemini.mjs +18 -6
  4. package/dist/Gemini.mjs.map +1 -1
  5. package/dist/GeminiEmbedding.mjs +1 -1
  6. package/dist/GeminiEmbedding.mjs.map +1 -1
  7. package/dist/GeminiSynthesizer.d.mts.map +1 -1
  8. package/dist/GeminiSynthesizer.mjs +2 -2
  9. package/dist/GeminiSynthesizer.mjs.map +1 -1
  10. package/dist/GeminiSynthesizer.test.mjs +1 -1
  11. package/dist/GeminiTools.d.mts +48 -0
  12. package/dist/GeminiTools.d.mts.map +1 -0
  13. package/dist/GeminiTools.mjs +64 -0
  14. package/dist/GeminiTools.mjs.map +1 -0
  15. package/dist/GoogleDeepResearch.d.mts +122 -0
  16. package/dist/GoogleDeepResearch.d.mts.map +1 -0
  17. package/dist/GoogleDeepResearch.mjs +320 -0
  18. package/dist/GoogleDeepResearch.mjs.map +1 -0
  19. package/dist/GoogleDeepResearch.test.d.mts +1 -0
  20. package/dist/GoogleDeepResearch.test.mjs +66 -0
  21. package/dist/GoogleDeepResearch.test.mjs.map +1 -0
  22. package/dist/LyriaGenerator.mjs +1 -1
  23. package/dist/LyriaGenerator.mjs.map +1 -1
  24. package/dist/LyriaGenerator.test.mjs +1 -1
  25. package/dist/codec.d.mts +38 -6
  26. package/dist/codec.d.mts.map +1 -1
  27. package/dist/codec.mjs +30 -8
  28. package/dist/codec.mjs.map +1 -1
  29. package/dist/{dist-DMsg8YLb.mjs → dist-CWoAuwS9.mjs} +7 -7
  30. package/dist/{dist-DMsg8YLb.mjs.map → dist-CWoAuwS9.mjs.map} +1 -1
  31. package/dist/geminiSpeechCodec.test.mjs +1 -1
  32. package/dist/geminiTools.test.d.mts +1 -0
  33. package/dist/geminiTools.test.mjs +99 -0
  34. package/dist/geminiTools.test.mjs.map +1 -0
  35. package/dist/index.d.mts +4 -2
  36. package/dist/index.mjs +3 -1
  37. package/dist/models.d.mts +11 -1
  38. package/dist/models.d.mts.map +1 -1
  39. package/package.json +8 -4
  40. package/src/Gemini.ts +21 -3
  41. package/src/GeminiEmbedding.ts +1 -1
  42. package/src/GeminiSynthesizer.ts +4 -2
  43. package/src/GeminiTools.ts +95 -0
  44. package/src/GoogleDeepResearch.test.ts +80 -0
  45. package/src/GoogleDeepResearch.ts +545 -0
  46. package/src/LyriaGenerator.ts +1 -1
  47. package/src/codec.ts +114 -12
  48. package/src/geminiTools.test.ts +106 -0
  49. package/src/index.ts +2 -0
  50. package/src/models.ts +15 -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
  }
@@ -101,14 +150,24 @@ type RequestSystemInstruction = {
101
150
  readonly parts: ReadonlyArray<{ readonly text: string }>
102
151
  }
103
152
 
104
- /** Gemini's tool declaration. We translate one `ToolDescriptor` per entry. */
105
- type RequestTool = {
106
- readonly functionDeclarations: ReadonlyArray<{
107
- readonly name: string
108
- readonly description: string
109
- readonly parameters: Record<string, unknown>
110
- }>
111
- }
153
+ /**
154
+ * Gemini's tool declaration. A `tools[]` entry is either a function-declaration
155
+ * block (translated from `ToolDescriptor`s) or one of the native hosted-tool
156
+ * entries. The native entries are empty objects keyed camelCase per the REST
157
+ * v1beta `Tool` object, and sit as siblings of the declarations in the same
158
+ * `tools` array.
159
+ */
160
+ export type RequestTool =
161
+ | {
162
+ readonly functionDeclarations: ReadonlyArray<{
163
+ readonly name: string
164
+ readonly description: string
165
+ readonly parameters: Record<string, unknown>
166
+ }>
167
+ }
168
+ | { readonly googleSearch: {} }
169
+ | { readonly urlContext: {} }
170
+ | { readonly codeExecution: {} }
112
171
 
113
172
  type RequestToolConfig = {
114
173
  readonly functionCallingConfig: {
@@ -282,6 +341,20 @@ const geminiField = (
282
341
  )
283
342
 
284
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
+
285
358
  const signatureFor = (item: ToolCall): Option.Option<string> =>
286
359
  geminiField(item, (g) => g.thoughtSignature)
287
360
 
@@ -317,6 +390,10 @@ const itemToContent =
317
390
  parts: [
318
391
  {
319
392
  functionResponse: {
393
+ ...Option.match(providerIdForCallId(history, o.call_id), {
394
+ onSome: (id) => ({ id }),
395
+ onNone: () => ({}),
396
+ }),
320
397
  name: Option.getOrElse(nameForCallId(history, o.call_id), () => o.call_id),
321
398
  response: parsedResponse(o.output),
322
399
  },
@@ -402,11 +479,13 @@ export const buildRequestBody = (
402
479
  history: ReadonlyArray<HistoryItem>,
403
480
  generationConfig: Option.Option<GenerationConfig>,
404
481
  tools: ReadonlyArray<ToolDescriptor> = [],
482
+ providerToolEntries: ReadonlyArray<RequestTool> = [],
405
483
  toolChoice: CommonRequest["toolChoice"] = undefined,
406
484
  ): RequestBody => {
407
485
  const systemTexts = pipe(allMessages(history), Arr.filterMap(systemMessageText))
408
486
  const contents = pipe(history, Arr.filterMap(itemToContent(history)))
409
- const requestTools = toolDescriptorsToTools(tools)
487
+ const functionTools = toolDescriptorsToTools(tools)
488
+ const requestTools = [...functionTools, ...providerToolEntries]
410
489
  return {
411
490
  contents,
412
491
  ...(systemTexts.length > 0 && {
@@ -416,8 +495,10 @@ export const buildRequestBody = (
416
495
  onNone: () => ({}),
417
496
  onSome: (cfg) => ({ generationConfig: cfg }),
418
497
  }),
419
- ...(requestTools.length > 0 && {
420
- tools: requestTools,
498
+ ...(requestTools.length > 0 && { tools: requestTools }),
499
+ // Only force a function-calling mode when there are function declarations;
500
+ // a grounding-only request must keep the API default.
501
+ ...(functionTools.length > 0 && {
421
502
  toolConfig: { functionCallingConfig: toolChoiceToFunctionCallingConfig(toolChoice) },
422
503
  }),
423
504
  }
@@ -452,6 +533,8 @@ export type Accumulator = {
452
533
  readonly text: string
453
534
  readonly reasoning: string
454
535
  readonly functionCalls: ReadonlyArray<AccumulatedFunctionCall>
536
+ /** Grounding citations, accumulated (de-duped by url) across chunks. */
537
+ readonly annotations: ReadonlyArray<Annotation>
455
538
  readonly finishReason: Option.Option<string>
456
539
  readonly usage: {
457
540
  readonly input_tokens?: number
@@ -466,6 +549,7 @@ export const emptyAccumulator: Accumulator = {
466
549
  text: "",
467
550
  reasoning: "",
468
551
  functionCalls: [],
552
+ annotations: [],
469
553
  finishReason: Option.none(),
470
554
  usage: {},
471
555
  }
@@ -584,6 +668,14 @@ const appendFunctionCalls = (
584
668
  prior,
585
669
  )
586
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
+
587
679
  export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult => {
588
680
  const parts = chunkParts(chunk)
589
681
  const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason)
@@ -594,6 +686,10 @@ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult =>
594
686
  text: acc.text + sumStrings(parts, "text"),
595
687
  reasoning: acc.reasoning + sumStrings(parts, "reasoning"),
596
688
  functionCalls: appendFunctionCalls(acc.functionCalls, collectFunctionCalls(parts)),
689
+ annotations: mergeAnnotations(
690
+ acc.annotations,
691
+ groundingToAnnotations(chunk.candidates?.[0]?.groundingMetadata),
692
+ ),
597
693
  finishReason: Option.orElse(finishReason, () => acc.finishReason),
598
694
  usage: mergeUsage(acc.usage, chunk.usageMetadata),
599
695
  },
@@ -610,7 +706,13 @@ const assistantMessageItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
610
706
  {
611
707
  type: "message",
612
708
  role: "assistant",
613
- 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
+ ],
614
716
  },
615
717
  ]
616
718
 
@@ -0,0 +1,106 @@
1
+ import { Effect, Option, Result, Schema } from "effect"
2
+ import * as Tool from "@effect-uai/core/Tool"
3
+ import { descriptorsOf, providerToolsOf } from "@effect-uai/core/Tool"
4
+ import * as Turn from "@effect-uai/core/Turn"
5
+ import { describe, expect, it } from "vitest"
6
+ import {
7
+ WireChunk,
8
+ accumulatorToTurn,
9
+ buildRequestBody,
10
+ emptyAccumulator,
11
+ ingestChunk,
12
+ } from "./codec.js"
13
+ import { codeExecutionTool, googleSearchTool, renderProviderTools } from "./GeminiTools.js"
14
+
15
+ const localTool = Tool.make({
16
+ name: "get_weather",
17
+ description: "Look up the weather.",
18
+ inputSchema: Tool.fromEffectSchema(Schema.Struct({ city: Schema.String })),
19
+ run: ({ city }) => Effect.succeed(`sunny in ${city}`),
20
+ })
21
+
22
+ const build = (toolkit: Record<string, Tool.AnyTool>) => {
23
+ const entries = Result.getOrThrow(renderProviderTools(providerToolsOf(toolkit)))
24
+ return buildRequestBody([], Option.none(), descriptorsOf(toolkit), entries)
25
+ }
26
+
27
+ describe("Gemini provider tools", () => {
28
+ it("renders google_search to the native googleSearch entry", () => {
29
+ const body = build({ google_search: googleSearchTool })
30
+ expect(body.tools).toEqual([{ googleSearch: {} }])
31
+ })
32
+
33
+ it("renders a mixed toolkit to both a functionDeclarations entry and the native entry", () => {
34
+ const body = build({ get_weather: localTool, code_execution: codeExecutionTool })
35
+ expect(body.tools).toEqual([
36
+ {
37
+ functionDeclarations: [
38
+ expect.objectContaining({ name: "get_weather", description: "Look up the weather." }),
39
+ ],
40
+ },
41
+ { codeExecution: {} },
42
+ ])
43
+ })
44
+
45
+ it("omits toolConfig for a grounding-only request (no forced function-calling mode)", () => {
46
+ const body = build({ google_search: googleSearchTool })
47
+ expect(body.toolConfig).toBeUndefined()
48
+ })
49
+
50
+ it("sets toolConfig when a function declaration is present", () => {
51
+ const body = build({ get_weather: localTool, google_search: googleSearchTool })
52
+ expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: "AUTO" } })
53
+ })
54
+
55
+ it("fails on a foreign provider tool", () => {
56
+ const foreign = Tool.provider({
57
+ name: "web_search",
58
+ description: "Anthropic web search.",
59
+ inputSchema: Tool.noInput,
60
+ provider: "anthropic",
61
+ config: { type: "web_search_20250305" },
62
+ })
63
+ const result = renderProviderTools(providerToolsOf({ web_search: foreign }))
64
+ expect(Result.isFailure(result)).toBe(true)
65
+ })
66
+
67
+ it("fails on an unrecognized config kind", () => {
68
+ const bogus = Tool.provider({
69
+ name: "mystery",
70
+ description: "Unknown Gemini tool.",
71
+ inputSchema: Tool.noInput,
72
+ provider: "gemini",
73
+ config: { kind: "telepathy" },
74
+ })
75
+ const result = renderProviderTools(providerToolsOf({ mystery: bogus }))
76
+ expect(Result.isFailure(result)).toBe(true)
77
+ })
78
+
79
+ it("maps groundingMetadata chunks to url_citation annotations on the turn", () => {
80
+ const grounded = {
81
+ candidates: [
82
+ {
83
+ content: { role: "model", parts: [{ text: "Grounded answer." }] },
84
+ groundingMetadata: {
85
+ webSearchQueries: ["effect ts"],
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
+ ],
92
+ },
93
+ finishReason: "STOP",
94
+ },
95
+ ],
96
+ }
97
+ const chunk = Schema.decodeUnknownSync(WireChunk)(grounded)
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
+ ])
105
+ })
106
+ })
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * as Gemini from "./Gemini.js"
2
+ export * as GeminiTools from "./GeminiTools.js"
2
3
  export * as GeminiEmbedding from "./GeminiEmbedding.js"
3
4
  export * as GeminiSynthesizer from "./GeminiSynthesizer.js"
5
+ export * as GoogleDeepResearch from "./GoogleDeepResearch.js"
4
6
  export * as LyriaGenerator from "./LyriaGenerator.js"
5
7
  export * as codec from "./codec.js"
6
8
  export * from "./models.js"
package/src/models.ts CHANGED
@@ -17,6 +17,21 @@ export type GoogleModel =
17
17
  // eslint-disable-next-line @typescript-eslint/ban-types
18
18
  | (string & {})
19
19
 
20
+ /**
21
+ * Deep-research agent identifiers for the Interactions API (`POST
22
+ * /v1beta/interactions`). `-preview` is the fast tier, `-max-` the
23
+ * comprehensive one. Preview surface: verify against live docs before relying on
24
+ * exact ids. The `(string & {})` tail accepts any string so a renamed agent
25
+ * works without an SDK update.
26
+ *
27
+ * Reference: https://ai.google.dev/gemini-api/docs/interactions/deep-research
28
+ */
29
+ export type GoogleDeepResearchModel =
30
+ | "deep-research-preview-04-2026"
31
+ | "deep-research-max-preview-04-2026"
32
+ // eslint-disable-next-line @typescript-eslint/ban-types
33
+ | (string & {})
34
+
20
35
  /**
21
36
  * Known Gemini embedding model identifiers.
22
37
  *