@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.
- package/dist/Gemini.d.mts +11 -2
- package/dist/Gemini.d.mts.map +1 -1
- package/dist/Gemini.mjs +18 -6
- package/dist/Gemini.mjs.map +1 -1
- package/dist/GeminiEmbedding.mjs +1 -1
- package/dist/GeminiEmbedding.mjs.map +1 -1
- package/dist/GeminiSynthesizer.d.mts.map +1 -1
- package/dist/GeminiSynthesizer.mjs +2 -2
- package/dist/GeminiSynthesizer.mjs.map +1 -1
- package/dist/GeminiSynthesizer.test.mjs +1 -1
- package/dist/GeminiTools.d.mts +48 -0
- package/dist/GeminiTools.d.mts.map +1 -0
- package/dist/GeminiTools.mjs +64 -0
- package/dist/GeminiTools.mjs.map +1 -0
- package/dist/GoogleDeepResearch.d.mts +122 -0
- package/dist/GoogleDeepResearch.d.mts.map +1 -0
- package/dist/GoogleDeepResearch.mjs +320 -0
- package/dist/GoogleDeepResearch.mjs.map +1 -0
- package/dist/GoogleDeepResearch.test.d.mts +1 -0
- package/dist/GoogleDeepResearch.test.mjs +66 -0
- package/dist/GoogleDeepResearch.test.mjs.map +1 -0
- package/dist/LyriaGenerator.mjs +1 -1
- package/dist/LyriaGenerator.mjs.map +1 -1
- package/dist/LyriaGenerator.test.mjs +1 -1
- package/dist/codec.d.mts +38 -6
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +30 -8
- package/dist/codec.mjs.map +1 -1
- package/dist/{dist-DMsg8YLb.mjs → dist-CWoAuwS9.mjs} +7 -7
- package/dist/{dist-DMsg8YLb.mjs.map → dist-CWoAuwS9.mjs.map} +1 -1
- package/dist/geminiSpeechCodec.test.mjs +1 -1
- package/dist/geminiTools.test.d.mts +1 -0
- package/dist/geminiTools.test.mjs +99 -0
- package/dist/geminiTools.test.mjs.map +1 -0
- package/dist/index.d.mts +4 -2
- package/dist/index.mjs +3 -1
- package/dist/models.d.mts +11 -1
- package/dist/models.d.mts.map +1 -1
- package/package.json +8 -4
- package/src/Gemini.ts +21 -3
- package/src/GeminiEmbedding.ts +1 -1
- package/src/GeminiSynthesizer.ts +4 -2
- package/src/GeminiTools.ts +95 -0
- package/src/GoogleDeepResearch.test.ts +80 -0
- package/src/GoogleDeepResearch.ts +545 -0
- package/src/LyriaGenerator.ts +1 -1
- package/src/codec.ts +114 -12
- package/src/geminiTools.test.ts +106 -0
- package/src/index.ts +2 -0
- 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
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
*
|