@effect-uai/google 0.9.0 → 0.11.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 (46) hide show
  1. package/dist/Gemini.d.mts +4 -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 +42 -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 +70 -0
  16. package/dist/GoogleDeepResearch.d.mts.map +1 -0
  17. package/dist/GoogleDeepResearch.mjs +269 -0
  18. package/dist/GoogleDeepResearch.mjs.map +1 -0
  19. package/dist/LyriaGenerator.mjs +1 -1
  20. package/dist/LyriaGenerator.mjs.map +1 -1
  21. package/dist/LyriaGenerator.test.mjs +1 -1
  22. package/dist/codec.d.mts +22 -5
  23. package/dist/codec.d.mts.map +1 -1
  24. package/dist/codec.mjs +52 -30
  25. package/dist/codec.mjs.map +1 -1
  26. package/dist/{dist-DMsg8YLb.mjs → dist-CWoAuwS9.mjs} +7 -7
  27. package/dist/{dist-DMsg8YLb.mjs.map → dist-CWoAuwS9.mjs.map} +1 -1
  28. package/dist/geminiSpeechCodec.test.mjs +1 -1
  29. package/dist/geminiTools.test.d.mts +1 -0
  30. package/dist/geminiTools.test.mjs +80 -0
  31. package/dist/geminiTools.test.mjs.map +1 -0
  32. package/dist/index.d.mts +4 -2
  33. package/dist/index.mjs +3 -1
  34. package/dist/models.d.mts +11 -1
  35. package/dist/models.d.mts.map +1 -1
  36. package/package.json +8 -4
  37. package/src/Gemini.ts +21 -3
  38. package/src/GeminiEmbedding.ts +1 -1
  39. package/src/GeminiSynthesizer.ts +4 -2
  40. package/src/GeminiTools.ts +95 -0
  41. package/src/GoogleDeepResearch.ts +446 -0
  42. package/src/LyriaGenerator.ts +1 -1
  43. package/src/codec.ts +81 -28
  44. package/src/geminiTools.test.ts +88 -0
  45. package/src/index.ts +2 -0
  46. package/src/models.ts +15 -0
@@ -0,0 +1,446 @@
1
+ import { Context, Effect, Layer, Match, Redacted, Ref, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import {
5
+ DeepResearch,
6
+ type DeepResearchService,
7
+ type DeepResearchServiceShape,
8
+ fromJob,
9
+ type ResearchJobOps,
10
+ } from "@effect-uai/core/DeepResearch"
11
+ import * as Items from "@effect-uai/core/Items"
12
+ import * as Job from "@effect-uai/core/Job"
13
+ import type { ResearchJobRef, ResearchRequest, ResearchState } from "@effect-uai/core/Research"
14
+ import * as SSE from "@effect-uai/core/SSE"
15
+ import { type Turn, TurnEvent } from "@effect-uai/core/Turn"
16
+ import type { GoogleDeepResearchModel } from "./models.js"
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Public types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Gemini-typed research request. Narrows `model` to the deep-research agent ids.
24
+ * Provider-portable code uses the generic `DeepResearch` tag instead.
25
+ */
26
+ export type GoogleResearchRequest = Omit<ResearchRequest, "model"> & {
27
+ readonly model?: GoogleDeepResearchModel
28
+ }
29
+
30
+ export type GoogleDeepResearchService = DeepResearchServiceShape<GoogleResearchRequest>
31
+
32
+ /**
33
+ * Provider-typed tag for Gemini deep research over the Interactions API
34
+ * (`POST /v1beta/interactions`, `agent: deep-research-*`). Yield this for the
35
+ * agent-id knob; yield the generic `DeepResearch` tag for portable code.
36
+ *
37
+ * `research` / `submit` / `collect` drive a background job by polling;
38
+ * `researchStream` consumes the real Interactions SSE (`step.delta` text /
39
+ * thought, terminating in `interaction.completed`). Preview surface: the SSE
40
+ * event shapes and the completed-interaction citation shape are decoded
41
+ * defensively and best confirmed against a live response.
42
+ */
43
+ export class GoogleDeepResearch extends Context.Service<
44
+ GoogleDeepResearch,
45
+ GoogleDeepResearchService
46
+ >()("@betalyra/effect-uai/providers/google/GoogleDeepResearch") {}
47
+
48
+ export type Config = {
49
+ readonly apiKey: Redacted.Redacted
50
+ readonly baseUrl?: string
51
+ /** Poll cadence / overall timeout for the derived poll loops. */
52
+ readonly job?: Job.JobConfig
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Wire codec
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const WireSubmit = Schema.Struct({ id: Schema.String })
60
+ const decodeSubmit = Schema.decodeUnknownEffect(WireSubmit)
61
+
62
+ // A completed interaction's text lives at `outputs[].text` (SDK `outputs`) or,
63
+ // 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.
67
+ const WireTextPart = Schema.Struct({
68
+ type: Schema.optional(Schema.NullOr(Schema.String)),
69
+ text: Schema.optional(Schema.NullOr(Schema.String)),
70
+ })
71
+ const WireStep = Schema.Struct({
72
+ type: Schema.optional(Schema.NullOr(Schema.String)),
73
+ text: Schema.optional(Schema.NullOr(Schema.String)),
74
+ content: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
75
+ })
76
+ export const WireInteraction = Schema.Struct({
77
+ id: Schema.optional(Schema.NullOr(Schema.String)),
78
+ status: Schema.optional(Schema.NullOr(Schema.String)),
79
+ outputs: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
80
+ steps: Schema.optional(Schema.NullOr(Schema.Array(WireStep))),
81
+ error: Schema.optional(
82
+ Schema.NullOr(
83
+ Schema.Struct({
84
+ message: Schema.optional(Schema.NullOr(Schema.String)),
85
+ code: Schema.optional(Schema.NullOr(Schema.String)),
86
+ }),
87
+ ),
88
+ ),
89
+ })
90
+ export type WireInteraction = typeof WireInteraction.Type
91
+ const decodeInteraction = Schema.decodeUnknownEffect(WireInteraction)
92
+
93
+ const textOfParts = (parts: ReadonlyArray<typeof WireTextPart.Type> | null | undefined): string =>
94
+ (parts ?? [])
95
+ .filter((p) => (p.type ?? "text") === "text" && p.text != null)
96
+ .map((p) => p.text)
97
+ .join("")
98
+
99
+ const reportText = (wire: WireInteraction): string => {
100
+ const fromOutputs = textOfParts(wire.outputs)
101
+ if (fromOutputs.length > 0) return fromOutputs
102
+ const lastStep = (wire.steps ?? []).at(-1)
103
+ const fromStep = textOfParts(lastStep?.content)
104
+ return fromStep.length > 0 ? fromStep : (lastStep?.text ?? "")
105
+ }
106
+
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",
118
+ })
119
+
120
+ // Map the interaction status onto the generic `JobState`.
121
+ export const jobStateOf = (wire: WireInteraction): ResearchState =>
122
+ Match.value(wire.status ?? "").pipe(
123
+ Match.whenOr("queued", "pending", (): ResearchState => ({ _tag: "Pending" })),
124
+ Match.whenOr(
125
+ "in_progress",
126
+ "running",
127
+ "processing",
128
+ (): ResearchState => ({ _tag: "Running" }),
129
+ ),
130
+ Match.whenOr(
131
+ "completed",
132
+ "succeeded",
133
+ (): ResearchState => ({ _tag: "Succeeded", result: turnFromInteraction(wire) }),
134
+ ),
135
+ Match.whenOr(
136
+ "failed",
137
+ "error",
138
+ "cancelled",
139
+ (): ResearchState => ({
140
+ _tag: "Failed",
141
+ ...(wire.error?.message != null && { reason: wire.error.message }),
142
+ raw: wire,
143
+ }),
144
+ ),
145
+ Match.orElse((): ResearchState => ({ _tag: "Running" })),
146
+ )
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // HTTP
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const baseUrl = (cfg: Config): string =>
153
+ cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
154
+
155
+ const authHeader = (cfg: Config) =>
156
+ HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey))
157
+
158
+ const transportFailure = (cause: unknown): AiError.AiError =>
159
+ new AiError.Unavailable({ provider: "google", raw: cause })
160
+
161
+ const httpStatusError = (status: number, body: string): AiError.AiError => {
162
+ const provider = "google"
163
+ const raw = body
164
+ if (status === 429) return new AiError.RateLimited({ provider, raw })
165
+ if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
166
+ if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
167
+ if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
168
+ if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
169
+ if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
170
+ if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
171
+ return new AiError.InvalidRequest({ provider, raw })
172
+ }
173
+
174
+ // Deep research takes one question. Concatenate the user turns' text into the
175
+ // Interactions `input` string.
176
+ const inputText = (history: ReadonlyArray<Items.HistoryItem>): string =>
177
+ history
178
+ .filter(Items.isMessage)
179
+ .filter((m) => m.role === "user")
180
+ .flatMap((m) => m.content)
181
+ .filter(Items.isInputText)
182
+ .map((b) => b.text)
183
+ .join("\n")
184
+
185
+ const submitBody = (request: GoogleResearchRequest): Record<string, unknown> => ({
186
+ agent: request.model ?? "deep-research-preview-04-2026",
187
+ input: inputText(request.history),
188
+ background: true,
189
+ store: true,
190
+ agent_config: { type: "deep-research", thinking_summaries: "auto" },
191
+ tools: [{ type: "google_search" }],
192
+ })
193
+
194
+ const submitJob = (
195
+ cfg: Config,
196
+ request: GoogleResearchRequest,
197
+ ): Effect.Effect<ResearchJobRef, AiError.AiError, HttpClient.HttpClient> =>
198
+ Effect.gen(function* () {
199
+ const client = yield* HttpClient.HttpClient
200
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/interactions`).pipe(
201
+ authHeader(cfg),
202
+ HttpClientRequest.bodyJsonUnsafe(submitBody(request)),
203
+ )
204
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
205
+ if (response.status >= 400) {
206
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
207
+ return yield* httpStatusError(response.status, text)
208
+ }
209
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
210
+ const wire = yield* decodeSubmit(json).pipe(Effect.mapError(transportFailure))
211
+ return Job.jobRef<Turn>("google", wire.id)
212
+ })
213
+
214
+ const pollJob = (
215
+ cfg: Config,
216
+ ref: ResearchJobRef,
217
+ ): Effect.Effect<ResearchState, AiError.AiError, HttpClient.HttpClient> =>
218
+ Effect.gen(function* () {
219
+ const client = yield* HttpClient.HttpClient
220
+ const httpRequest = HttpClientRequest.get(`${baseUrl(cfg)}/interactions/${ref.id}`).pipe(
221
+ authHeader(cfg),
222
+ )
223
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
224
+ if (response.status >= 400) {
225
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
226
+ return yield* httpStatusError(response.status, text)
227
+ }
228
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
229
+ const wire = yield* decodeInteraction(json).pipe(Effect.mapError(transportFailure))
230
+ return jobStateOf(wire)
231
+ })
232
+
233
+ // No verified cancel endpoint on the preview Interactions surface.
234
+ const unsupported = (capability: string): AiError.Unsupported =>
235
+ new AiError.Unsupported({
236
+ provider: "google",
237
+ capability,
238
+ reason: "Gemini deep research (preview) exposes no verified cancel endpoint.",
239
+ })
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Streaming (Interactions SSE)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ // The Interactions stream emits `step.delta` events in two shapes: the report
246
+ // body arrives as `delta: { type: "text", text }`, while a thinking summary
247
+ // arrives as `delta: { type: "thought_summary", content: { text } }` (and a
248
+ // content-free `thought_signature` we skip). Terminates in `interaction.completed`
249
+ // / `interaction.error`, then an OpenAI-style `[DONE]` sentinel we drop upstream.
250
+ const WireDelta = Schema.Struct({
251
+ type: Schema.optional(Schema.NullOr(Schema.String)),
252
+ text: Schema.optional(Schema.NullOr(Schema.String)),
253
+ content: Schema.optional(
254
+ Schema.NullOr(
255
+ Schema.Struct({
256
+ type: Schema.optional(Schema.NullOr(Schema.String)),
257
+ text: Schema.optional(Schema.NullOr(Schema.String)),
258
+ }),
259
+ ),
260
+ ),
261
+ })
262
+ const WireStreamEvent = Schema.Struct({
263
+ event_type: Schema.optional(Schema.NullOr(Schema.String)),
264
+ delta: Schema.optional(Schema.NullOr(WireDelta)),
265
+ interaction: Schema.optional(Schema.NullOr(WireInteraction)),
266
+ error: Schema.optional(
267
+ Schema.NullOr(Schema.Struct({ message: Schema.optional(Schema.NullOr(Schema.String)) })),
268
+ ),
269
+ })
270
+ type WireStreamEvent = typeof WireStreamEvent.Type
271
+
272
+ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
273
+ const decodeStreamEvent = Schema.decodeUnknownEffect(WireStreamEvent)
274
+
275
+ // Prefer the completed interaction's decoded text; when the terminal event is
276
+ // bare (or its text decodes empty) fall back to the accumulated stream deltas,
277
+ // so `TurnComplete.turn` always carries the report that streamed.
278
+ const completedTurn = (wire: WireInteraction | null | undefined, streamed: string): Turn =>
279
+ wire != null && reportText(wire).length > 0
280
+ ? turnFromInteraction(wire)
281
+ : {
282
+ items: [
283
+ {
284
+ type: "message",
285
+ role: "assistant",
286
+ content: [{ type: "output_text", text: streamed }],
287
+ ...(wire != null && { providerData: wire }),
288
+ },
289
+ ],
290
+ usage: {},
291
+ stop_reason: "stop",
292
+ }
293
+
294
+ // Map one wire event onto `TurnEvent`s, appending text deltas to `acc` (the
295
+ // `completedTurn` fallback) and lifting `interaction.error` to a typed failure.
296
+ const streamEventToTurnEvents = (
297
+ acc: Ref.Ref<string>,
298
+ ev: WireStreamEvent,
299
+ ): Effect.Effect<ReadonlyArray<TurnEvent>, AiError.AiError> => {
300
+ if (ev.event_type === "interaction.error") {
301
+ return Effect.fail(
302
+ new AiError.GenerationFailed({
303
+ provider: "google",
304
+ ...(ev.error?.message != null && { message: ev.error.message }),
305
+ raw: ev,
306
+ }),
307
+ )
308
+ }
309
+ if (ev.event_type === "step.delta") {
310
+ const delta = ev.delta
311
+ // Report body: `delta.text`. Accumulate it so the terminal `TurnComplete`
312
+ // carries the report even when `interaction.completed` arrives text-free.
313
+ if (delta?.text != null) {
314
+ const text = delta.text
315
+ return Effect.as(
316
+ Ref.update(acc, (streamed) => streamed + text),
317
+ [TurnEvent.TextDelta({ text })],
318
+ )
319
+ }
320
+ // Thinking summary: nested under `delta.content.text`. Surfaced as reasoning,
321
+ // not accumulated into the report.
322
+ if (delta?.content?.text != null) {
323
+ return Effect.succeed([
324
+ TurnEvent.ReasoningDelta({ text: delta.content.text, kind: "summary" }),
325
+ ])
326
+ }
327
+ return Effect.succeed([])
328
+ }
329
+ if (ev.event_type === "interaction.completed") {
330
+ return Effect.map(Ref.get(acc), (streamed) => [
331
+ TurnEvent.TurnComplete({ turn: completedTurn(ev.interaction, streamed) }),
332
+ ])
333
+ }
334
+ return Effect.succeed([])
335
+ }
336
+
337
+ // A streaming interaction create (`?stream=true`): consume the SSE directly,
338
+ // mapping each event onto `TurnEvent`s via {@link streamEventToTurnEvents}.
339
+ const streamCreate = (
340
+ cfg: Config,
341
+ request: GoogleResearchRequest,
342
+ ): Stream.Stream<TurnEvent, AiError.AiError, HttpClient.HttpClient> =>
343
+ Stream.unwrap(
344
+ Effect.gen(function* () {
345
+ const client = yield* HttpClient.HttpClient
346
+ const acc = yield* Ref.make("")
347
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/interactions?stream=true`).pipe(
348
+ authHeader(cfg),
349
+ HttpClientRequest.bodyJsonUnsafe({ ...submitBody(request), stream: true }),
350
+ HttpClientRequest.accept("text/event-stream"),
351
+ )
352
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
353
+ // Preview surface: log the response status + content-type so an
354
+ // unexpected `application/json` (i.e. `?stream=true` not triggering SSE)
355
+ // is visible at LOG_LEVEL=Debug rather than presenting as a silent hang.
356
+ yield* Effect.logDebug("google interactions stream response", {
357
+ status: response.status,
358
+ contentType: response.headers["content-type"],
359
+ })
360
+ if (response.status >= 400) {
361
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
362
+ return Stream.fail(httpStatusError(response.status, text))
363
+ }
364
+ return response.stream.pipe(
365
+ Stream.mapError(transportFailure),
366
+ SSE.fromBytes,
367
+ // Log each raw frame before decode: the ground truth for the (unverified)
368
+ // event_type / delta shapes this codec keys on.
369
+ Stream.tap((ev) =>
370
+ Effect.logDebug("google interactions sse frame", { event: ev.event, data: ev.data }),
371
+ ),
372
+ // The stream ends with an OpenAI-style `[DONE]` sentinel that is not JSON;
373
+ // drop it (and any blank frame) before the JSON decode.
374
+ Stream.filter((ev) => ev.event !== "done" && ev.data.trim() !== "[DONE]"),
375
+ Stream.mapEffect((ev) =>
376
+ parseJsonUnknown(ev.data).pipe(
377
+ Effect.flatMap(decodeStreamEvent),
378
+ Effect.mapError(transportFailure),
379
+ Effect.flatMap((wire) => streamEventToTurnEvents(acc, wire)),
380
+ ),
381
+ ),
382
+ Stream.flatMap(Stream.fromIterable),
383
+ )
384
+ }),
385
+ )
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Constructors
389
+ // ---------------------------------------------------------------------------
390
+
391
+ const jobOps = (
392
+ cfg: Config,
393
+ client: HttpClient.HttpClient,
394
+ ): ResearchJobOps<GoogleResearchRequest> => {
395
+ const withClient = <A>(e: Effect.Effect<A, AiError.AiError, HttpClient.HttpClient>) =>
396
+ Effect.provideService(e, HttpClient.HttpClient, client)
397
+ return {
398
+ submit: (request) => withClient(submitJob(cfg, request)),
399
+ poll: (ref) => withClient(pollJob(cfg, ref)),
400
+ cancel: () => Effect.fail(unsupported("cancel")),
401
+ }
402
+ }
403
+
404
+ // fromJob's default surface, with `researchStream` overridden to the real
405
+ // Interactions SSE (its submit-then-attach default only synthesizes progress).
406
+ const buildService = <Req extends ResearchRequest>(
407
+ cfg: Config,
408
+ client: HttpClient.HttpClient,
409
+ ops: ResearchJobOps<Req>,
410
+ toGoogle: (request: Req) => GoogleResearchRequest,
411
+ ): DeepResearchServiceShape<Req> => ({
412
+ ...fromJob(ops, cfg.job),
413
+ researchStream: (request) =>
414
+ Stream.provideService(streamCreate(cfg, toGoogle(request)), HttpClient.HttpClient, client),
415
+ })
416
+
417
+ /** Build a `GoogleDeepResearchService`. For Layer setup, prefer {@link layer}. */
418
+ export const make = (
419
+ cfg: Config,
420
+ ): Effect.Effect<GoogleDeepResearchService, never, HttpClient.HttpClient> =>
421
+ Effect.map(HttpClient.HttpClient, (client) =>
422
+ buildService(cfg, client, jobOps(cfg, client), (request) => request),
423
+ )
424
+
425
+ /**
426
+ * Layer registering the provider-typed `GoogleDeepResearch` tag and the generic
427
+ * `DeepResearch` tag. No capability markers: the surface (poll, collect,
428
+ * synthesized stream) is universal.
429
+ */
430
+ export const layer = (
431
+ cfg: Config,
432
+ ): Layer.Layer<GoogleDeepResearch | DeepResearch, never, HttpClient.HttpClient> => {
433
+ const typed = Layer.effect(GoogleDeepResearch, make(cfg))
434
+ const generic = Layer.effect(
435
+ DeepResearch,
436
+ Effect.map(HttpClient.HttpClient, (client): DeepResearchService => {
437
+ const ops = jobOps(cfg, client)
438
+ const genericOps: ResearchJobOps<ResearchRequest> = {
439
+ ...ops,
440
+ submit: (request) => ops.submit(request as GoogleResearchRequest),
441
+ }
442
+ return buildService(cfg, client, genericOps, (request) => request as GoogleResearchRequest)
443
+ }),
444
+ )
445
+ return Layer.merge(typed, generic)
446
+ }
@@ -371,7 +371,7 @@ const streamGenerationFromUnsupported = <E, R>(
371
371
  export const make = (
372
372
  cfg: Config,
373
373
  ): Effect.Effect<LyriaGeneratorService, never, HttpClient.HttpClient> =>
374
- Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
374
+ Effect.map(HttpClient.HttpClient, (client) => ({
375
375
  generate: (request) =>
376
376
  generateImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
377
377
  streamGeneration: (request) =>
package/src/codec.ts CHANGED
@@ -37,6 +37,9 @@ const FunctionCallPart = Schema.Struct({
37
37
  name: Schema.String,
38
38
  args: Schema.optional(Schema.Unknown),
39
39
  }),
40
+ // Gemini 3 thinking models attach an opaque signature to each function-call
41
+ // part that must be replayed on the next request, or the call is rejected.
42
+ thoughtSignature: Schema.optional(Schema.String),
40
43
  })
41
44
 
42
45
  const Part = Schema.Union([TextPart, FunctionCallPart])
@@ -74,7 +77,14 @@ export type WireChunk = typeof WireChunk.Type
74
77
  type RequestPart =
75
78
  | { readonly text: string }
76
79
  | { readonly inlineData: { readonly mimeType: string; readonly data: string } }
77
- | { readonly functionCall: { readonly name: string; readonly args: unknown } }
80
+ | {
81
+ readonly functionCall: {
82
+ readonly id?: string
83
+ readonly name: string
84
+ readonly args: unknown
85
+ }
86
+ readonly thoughtSignature?: string
87
+ }
78
88
  | {
79
89
  readonly functionResponse: {
80
90
  readonly name: string
@@ -91,14 +101,24 @@ type RequestSystemInstruction = {
91
101
  readonly parts: ReadonlyArray<{ readonly text: string }>
92
102
  }
93
103
 
94
- /** Gemini's tool declaration. We translate one `ToolDescriptor` per entry. */
95
- type RequestTool = {
96
- readonly functionDeclarations: ReadonlyArray<{
97
- readonly name: string
98
- readonly description: string
99
- readonly parameters: Record<string, unknown>
100
- }>
101
- }
104
+ /**
105
+ * Gemini's tool declaration. A `tools[]` entry is either a function-declaration
106
+ * block (translated from `ToolDescriptor`s) or one of the native hosted-tool
107
+ * entries. The native entries are empty objects keyed camelCase per the REST
108
+ * v1beta `Tool` object, and sit as siblings of the declarations in the same
109
+ * `tools` array.
110
+ */
111
+ export type RequestTool =
112
+ | {
113
+ readonly functionDeclarations: ReadonlyArray<{
114
+ readonly name: string
115
+ readonly description: string
116
+ readonly parameters: Record<string, unknown>
117
+ }>
118
+ }
119
+ | { readonly googleSearch: {} }
120
+ | { readonly urlContext: {} }
121
+ | { readonly codeExecution: {} }
102
122
 
103
123
  type RequestToolConfig = {
104
124
  readonly functionCallingConfig: {
@@ -244,23 +264,37 @@ const nameForCallId = (
244
264
  )
245
265
 
246
266
  /**
247
- * Extract the Gemini-3 wire id we stashed in `providerData` on the way out.
248
- * Schema-driven so the shape lives in one place; failure → `Option.none()`.
267
+ * Extract the Gemini-3 wire id and thought signature we stashed in
268
+ * `providerData` on the way out. Schema-driven so the shape lives in one
269
+ * place; failure or absence → `Option.none()`.
249
270
  */
250
- const ProviderDataWithGeminiId = Schema.Struct({
251
- gemini: Schema.Struct({ id: Schema.String }),
271
+ const ProviderDataGemini = Schema.Struct({
272
+ gemini: Schema.Struct({
273
+ id: Schema.optional(Schema.String),
274
+ thoughtSignature: Schema.optional(Schema.String),
275
+ }),
252
276
  })
253
- const decodeProviderId = Schema.decodeUnknownResult(ProviderDataWithGeminiId)
254
-
255
- const providerIdFor = (item: ToolCall): Option.Option<string> =>
277
+ const decodeGemini = Schema.decodeUnknownResult(ProviderDataGemini)
278
+
279
+ const geminiField = (
280
+ item: ToolCall,
281
+ pick: (g: {
282
+ readonly id?: string | undefined
283
+ readonly thoughtSignature?: string | undefined
284
+ }) => string | undefined,
285
+ ): Option.Option<string> =>
256
286
  pipe(
257
- decodeProviderId(item.providerData),
287
+ decodeGemini(item.providerData),
258
288
  Result.match({
259
- onSuccess: (d) => Option.some(d.gemini.id),
289
+ onSuccess: (d) => Option.fromNullishOr(pick(d.gemini)),
260
290
  onFailure: () => Option.none<string>(),
261
291
  }),
262
292
  )
263
293
 
294
+ const providerIdFor = (item: ToolCall): Option.Option<string> => geminiField(item, (g) => g.id)
295
+ const signatureFor = (item: ToolCall): Option.Option<string> =>
296
+ geminiField(item, (g) => g.thoughtSignature)
297
+
264
298
  const itemToContent =
265
299
  (history: ReadonlyArray<HistoryItem>) =>
266
300
  (item: HistoryItem): Result.Result<RequestContent, void> =>
@@ -272,6 +306,10 @@ const itemToContent =
272
306
  role: "model" as const,
273
307
  parts: [
274
308
  {
309
+ ...Option.match(signatureFor(f), {
310
+ onSome: (thoughtSignature) => ({ thoughtSignature }),
311
+ onNone: () => ({}),
312
+ }),
275
313
  functionCall: {
276
314
  ...Option.match(providerIdFor(f), {
277
315
  onSome: (id) => ({ id }),
@@ -374,11 +412,13 @@ export const buildRequestBody = (
374
412
  history: ReadonlyArray<HistoryItem>,
375
413
  generationConfig: Option.Option<GenerationConfig>,
376
414
  tools: ReadonlyArray<ToolDescriptor> = [],
415
+ providerToolEntries: ReadonlyArray<RequestTool> = [],
377
416
  toolChoice: CommonRequest["toolChoice"] = undefined,
378
417
  ): RequestBody => {
379
418
  const systemTexts = pipe(allMessages(history), Arr.filterMap(systemMessageText))
380
419
  const contents = pipe(history, Arr.filterMap(itemToContent(history)))
381
- const requestTools = toolDescriptorsToTools(tools)
420
+ const functionTools = toolDescriptorsToTools(tools)
421
+ const requestTools = [...functionTools, ...providerToolEntries]
382
422
  return {
383
423
  contents,
384
424
  ...(systemTexts.length > 0 && {
@@ -388,8 +428,10 @@ export const buildRequestBody = (
388
428
  onNone: () => ({}),
389
429
  onSome: (cfg) => ({ generationConfig: cfg }),
390
430
  }),
391
- ...(requestTools.length > 0 && {
392
- tools: requestTools,
431
+ ...(requestTools.length > 0 && { tools: requestTools }),
432
+ // Only force a function-calling mode when there are function declarations;
433
+ // a grounding-only request must keep the API default.
434
+ ...(functionTools.length > 0 && {
393
435
  toolConfig: { functionCallingConfig: toolChoiceToFunctionCallingConfig(toolChoice) },
394
436
  }),
395
437
  }
@@ -414,6 +456,8 @@ export type AccumulatedFunctionCall = {
414
456
  readonly name: string
415
457
  /** Wire id from Gemini 3, when present - echoed back on `functionResponse`. */
416
458
  readonly providerId: Option.Option<string>
459
+ /** Thought signature from Gemini 3, replayed on the next request's part. */
460
+ readonly signature: Option.Option<string>
417
461
  /** Args as JSON-encoded string, mirroring `Items.ToolCall.arguments`. */
418
462
  readonly arguments: string
419
463
  }
@@ -451,6 +495,7 @@ export type ChunkPart =
451
495
  | {
452
496
  readonly kind: "function_call"
453
497
  readonly id: Option.Option<string>
498
+ readonly signature: Option.Option<string>
454
499
  readonly name: string
455
500
  readonly args: unknown
456
501
  }
@@ -470,6 +515,7 @@ const functionCallToChunkParts = (p: WireFunctionCallPart): ReadonlyArray<ChunkP
470
515
  {
471
516
  kind: "function_call",
472
517
  id: Option.fromNullishOr(p.functionCall.id),
518
+ signature: Option.fromNullishOr(p.thoughtSignature),
473
519
  name: p.functionCall.name,
474
520
  args: p.functionCall.args ?? {},
475
521
  },
@@ -537,6 +583,7 @@ const chunkCallToAccumulated = (
537
583
  callId: synthesizeCallId(call, prior),
538
584
  name: call.name,
539
585
  providerId: call.id,
586
+ signature: call.signature,
540
587
  // `functionCallToChunkParts` already replaced null/undefined with `{}`,
541
588
  // so `call.args` is always a JSON-encodable value here.
542
589
  arguments: JSON.stringify(call.args),
@@ -584,13 +631,19 @@ const assistantMessageItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
584
631
  const functionCallItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
585
632
  pipe(
586
633
  acc.functionCalls,
587
- Arr.map((c) => ({
588
- type: "function_call" as const,
589
- call_id: c.callId,
590
- name: c.name,
591
- arguments: c.arguments,
592
- ...(Option.isSome(c.providerId) && { providerData: { gemini: { id: c.providerId.value } } }),
593
- })),
634
+ Arr.map((c) => {
635
+ const gemini = {
636
+ ...(Option.isSome(c.providerId) && { id: c.providerId.value }),
637
+ ...(Option.isSome(c.signature) && { thoughtSignature: c.signature.value }),
638
+ }
639
+ return {
640
+ type: "function_call" as const,
641
+ call_id: c.callId,
642
+ name: c.name,
643
+ arguments: c.arguments,
644
+ ...(Object.keys(gemini).length > 0 && { providerData: { gemini } }),
645
+ }
646
+ }),
594
647
  )
595
648
 
596
649
  export const accumulatorToTurn = (acc: Accumulator): Turn => ({