@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
@@ -0,0 +1,545 @@
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"
15
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
16
+ import * as AiError from "@effect-uai/core/AiError"
17
+ import type { Source } from "@effect-uai/core/Citation"
18
+ import {
19
+ DeepResearch,
20
+ type DeepResearchService,
21
+ type DeepResearchServiceShape,
22
+ fromJob,
23
+ type ResearchJobOps,
24
+ } from "@effect-uai/core/DeepResearch"
25
+ import * as Items from "@effect-uai/core/Items"
26
+ import * as Job from "@effect-uai/core/Job"
27
+ import type { ResearchJobRef, ResearchRequest, ResearchState } from "@effect-uai/core/Research"
28
+ import * as SSE from "@effect-uai/core/SSE"
29
+ import { type Turn, TurnEvent } from "@effect-uai/core/Turn"
30
+ import { GroundingMetadata, groundingToAnnotations } from "./codec.js"
31
+ import type { GoogleDeepResearchModel } from "./models.js"
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Public types
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Gemini-typed research request. Narrows `model` to the deep-research agent ids.
39
+ * Provider-portable code uses the generic `DeepResearch` tag instead.
40
+ */
41
+ export type GoogleResearchRequest = Omit<ResearchRequest, "model"> & {
42
+ readonly model?: GoogleDeepResearchModel
43
+ }
44
+
45
+ export type GoogleDeepResearchService = DeepResearchServiceShape<GoogleResearchRequest>
46
+
47
+ /**
48
+ * Provider-typed tag for Gemini deep research over the Interactions API
49
+ * (`POST /v1beta/interactions`, `agent: deep-research-*`). Yield this for the
50
+ * agent-id knob; yield the generic `DeepResearch` tag for portable code.
51
+ *
52
+ * `research` / `submit` / `collect` drive a background job by polling;
53
+ * `researchStream` consumes the real Interactions SSE (`step.delta` text /
54
+ * thought, terminating in `interaction.completed`). Preview surface: the SSE
55
+ * event shapes and the completed-interaction citation shape are decoded
56
+ * defensively and best confirmed against a live response.
57
+ */
58
+ export class GoogleDeepResearch extends Context.Service<
59
+ GoogleDeepResearch,
60
+ GoogleDeepResearchService
61
+ >()("@betalyra/effect-uai/providers/google/GoogleDeepResearch") {}
62
+
63
+ export type Config = {
64
+ readonly apiKey: Redacted.Redacted
65
+ readonly baseUrl?: string
66
+ /** Poll cadence / overall timeout for the derived poll loops. */
67
+ readonly job?: Job.JobConfig
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Wire codec
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const WireSubmit = Schema.Struct({ id: Schema.String })
75
+ const decodeSubmit = Schema.decodeUnknownEffect(WireSubmit)
76
+
77
+ // A completed interaction's text lives at `outputs[].text` (SDK `outputs`) or,
78
+ // in some doc examples, `steps[].content[].text`. Decode both defensively and
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.
84
+ const WireTextPart = Schema.Struct({
85
+ type: Schema.optional(Schema.NullOr(Schema.String)),
86
+ text: Schema.optional(Schema.NullOr(Schema.String)),
87
+ groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
88
+ })
89
+ const WireStep = Schema.Struct({
90
+ type: Schema.optional(Schema.NullOr(Schema.String)),
91
+ text: Schema.optional(Schema.NullOr(Schema.String)),
92
+ content: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
93
+ groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
94
+ })
95
+ export const WireInteraction = Schema.Struct({
96
+ id: Schema.optional(Schema.NullOr(Schema.String)),
97
+ status: Schema.optional(Schema.NullOr(Schema.String)),
98
+ outputs: Schema.optional(Schema.NullOr(Schema.Array(WireTextPart))),
99
+ steps: Schema.optional(Schema.NullOr(Schema.Array(WireStep))),
100
+ groundingMetadata: Schema.optional(Schema.NullOr(GroundingMetadata)),
101
+ error: Schema.optional(
102
+ Schema.NullOr(
103
+ Schema.Struct({
104
+ message: Schema.optional(Schema.NullOr(Schema.String)),
105
+ code: Schema.optional(Schema.NullOr(Schema.String)),
106
+ }),
107
+ ),
108
+ ),
109
+ })
110
+ export type WireInteraction = typeof WireInteraction.Type
111
+ const decodeInteraction = Schema.decodeUnknownEffect(WireInteraction)
112
+
113
+ const textOfParts = (parts: ReadonlyArray<typeof WireTextPart.Type> | null | undefined): string =>
114
+ (parts ?? [])
115
+ .filter((p) => (p.type ?? "text") === "text" && p.text != null)
116
+ .map((p) => p.text)
117
+ .join("")
118
+
119
+ const reportText = (wire: WireInteraction): string => {
120
+ const fromOutputs = textOfParts(wire.outputs)
121
+ if (fromOutputs.length > 0) return fromOutputs
122
+ const lastStep = (wire.steps ?? []).at(-1)
123
+ const fromStep = textOfParts(lastStep?.content)
124
+ return fromStep.length > 0 ? fromStep : (lastStep?.text ?? "")
125
+ }
126
+
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
+ ),
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
+ }
215
+
216
+ // Map the interaction status onto the generic `JobState`.
217
+ export const jobStateOf = (wire: WireInteraction): ResearchState =>
218
+ Match.value(wire.status ?? "").pipe(
219
+ Match.whenOr("queued", "pending", (): ResearchState => ({ _tag: "Pending" })),
220
+ Match.whenOr(
221
+ "in_progress",
222
+ "running",
223
+ "processing",
224
+ (): ResearchState => ({ _tag: "Running" }),
225
+ ),
226
+ Match.whenOr(
227
+ "completed",
228
+ "succeeded",
229
+ (): ResearchState => ({ _tag: "Succeeded", result: turnFromInteraction(wire) }),
230
+ ),
231
+ Match.whenOr(
232
+ "failed",
233
+ "error",
234
+ "cancelled",
235
+ (): ResearchState => ({
236
+ _tag: "Failed",
237
+ ...(wire.error?.message != null && { reason: wire.error.message }),
238
+ raw: wire,
239
+ }),
240
+ ),
241
+ Match.orElse((): ResearchState => ({ _tag: "Running" })),
242
+ )
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // HTTP
246
+ // ---------------------------------------------------------------------------
247
+
248
+ const baseUrl = (cfg: Config): string =>
249
+ cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
250
+
251
+ const authHeader = (cfg: Config) =>
252
+ HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey))
253
+
254
+ const transportFailure = (cause: unknown): AiError.AiError =>
255
+ new AiError.Unavailable({ provider: "google", raw: cause })
256
+
257
+ const httpStatusError = (status: number, body: string): AiError.AiError => {
258
+ const provider = "google"
259
+ const raw = body
260
+ if (status === 429) return new AiError.RateLimited({ provider, raw })
261
+ if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
262
+ if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
263
+ if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
264
+ if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
265
+ if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
266
+ if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
267
+ return new AiError.InvalidRequest({ provider, raw })
268
+ }
269
+
270
+ // Deep research takes one question. Concatenate the user turns' text into the
271
+ // Interactions `input` string.
272
+ const inputText = (history: ReadonlyArray<Items.HistoryItem>): string =>
273
+ history
274
+ .filter(Items.isMessage)
275
+ .filter((m) => m.role === "user")
276
+ .flatMap((m) => m.content)
277
+ .filter(Items.isInputText)
278
+ .map((b) => b.text)
279
+ .join("\n")
280
+
281
+ const submitBody = (request: GoogleResearchRequest): Record<string, unknown> => ({
282
+ agent: request.model ?? "deep-research-preview-04-2026",
283
+ input: inputText(request.history),
284
+ background: true,
285
+ store: true,
286
+ agent_config: { type: "deep-research", thinking_summaries: "auto" },
287
+ tools: [{ type: "google_search" }],
288
+ })
289
+
290
+ const submitJob = (
291
+ cfg: Config,
292
+ request: GoogleResearchRequest,
293
+ ): Effect.Effect<ResearchJobRef, AiError.AiError, HttpClient.HttpClient> =>
294
+ Effect.gen(function* () {
295
+ const client = yield* HttpClient.HttpClient
296
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/interactions`).pipe(
297
+ authHeader(cfg),
298
+ HttpClientRequest.bodyJsonUnsafe(submitBody(request)),
299
+ )
300
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
301
+ if (response.status >= 400) {
302
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
303
+ return yield* httpStatusError(response.status, text)
304
+ }
305
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
306
+ const wire = yield* decodeSubmit(json).pipe(Effect.mapError(transportFailure))
307
+ return Job.jobRef<Turn>("google", wire.id)
308
+ })
309
+
310
+ const pollJob = (
311
+ cfg: Config,
312
+ ref: ResearchJobRef,
313
+ ): Effect.Effect<ResearchState, AiError.AiError, HttpClient.HttpClient> =>
314
+ Effect.gen(function* () {
315
+ const client = yield* HttpClient.HttpClient
316
+ const httpRequest = HttpClientRequest.get(`${baseUrl(cfg)}/interactions/${ref.id}`).pipe(
317
+ authHeader(cfg),
318
+ )
319
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
320
+ if (response.status >= 400) {
321
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
322
+ return yield* httpStatusError(response.status, text)
323
+ }
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 })
328
+ const wire = yield* decodeInteraction(json).pipe(Effect.mapError(transportFailure))
329
+ return jobStateOf(wire)
330
+ })
331
+
332
+ // No verified cancel endpoint on the preview Interactions surface.
333
+ const unsupported = (capability: string): AiError.Unsupported =>
334
+ new AiError.Unsupported({
335
+ provider: "google",
336
+ capability,
337
+ reason: "Gemini deep research (preview) exposes no verified cancel endpoint.",
338
+ })
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Streaming (Interactions SSE)
342
+ // ---------------------------------------------------------------------------
343
+
344
+ // The Interactions stream emits `step.delta` events in two shapes: the report
345
+ // body arrives as `delta: { type: "text", text }`, while a thinking summary
346
+ // arrives as `delta: { type: "thought_summary", content: { text } }` (and a
347
+ // content-free `thought_signature` we skip). Terminates in `interaction.completed`
348
+ // / `interaction.error`, then an OpenAI-style `[DONE]` sentinel we drop upstream.
349
+ const WireDelta = Schema.Struct({
350
+ type: Schema.optional(Schema.NullOr(Schema.String)),
351
+ text: Schema.optional(Schema.NullOr(Schema.String)),
352
+ content: Schema.optional(
353
+ Schema.NullOr(
354
+ Schema.Struct({
355
+ type: Schema.optional(Schema.NullOr(Schema.String)),
356
+ text: Schema.optional(Schema.NullOr(Schema.String)),
357
+ }),
358
+ ),
359
+ ),
360
+ })
361
+ const WireStreamEvent = Schema.Struct({
362
+ event_type: Schema.optional(Schema.NullOr(Schema.String)),
363
+ delta: Schema.optional(Schema.NullOr(WireDelta)),
364
+ interaction: Schema.optional(Schema.NullOr(WireInteraction)),
365
+ error: Schema.optional(
366
+ Schema.NullOr(Schema.Struct({ message: Schema.optional(Schema.NullOr(Schema.String)) })),
367
+ ),
368
+ })
369
+ type WireStreamEvent = typeof WireStreamEvent.Type
370
+
371
+ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
372
+ const decodeStreamEvent = Schema.decodeUnknownEffect(WireStreamEvent)
373
+
374
+ // Prefer the completed interaction's decoded text; when the terminal event is
375
+ // bare (or its text decodes empty) fall back to the accumulated stream deltas,
376
+ // so `TurnComplete.turn` always carries the report that streamed.
377
+ const completedTurn = (wire: WireInteraction | null | undefined, streamed: string): Turn =>
378
+ wire != null && reportText(wire).length > 0
379
+ ? turnFromInteraction(wire)
380
+ : {
381
+ items: [
382
+ {
383
+ type: "message",
384
+ role: "assistant",
385
+ content: [{ type: "output_text", text: streamed }],
386
+ ...(wire != null && { providerData: { gemini: { steps: researchSteps(wire) } } }),
387
+ },
388
+ ],
389
+ usage: {},
390
+ stop_reason: "stop",
391
+ }
392
+
393
+ // Map one wire event onto `TurnEvent`s, appending text deltas to `acc` (the
394
+ // `completedTurn` fallback) and lifting `interaction.error` to a typed failure.
395
+ const streamEventToTurnEvents = (
396
+ acc: Ref.Ref<string>,
397
+ ev: WireStreamEvent,
398
+ ): Effect.Effect<ReadonlyArray<TurnEvent>, AiError.AiError> => {
399
+ if (ev.event_type === "interaction.error") {
400
+ return Effect.fail(
401
+ new AiError.GenerationFailed({
402
+ provider: "google",
403
+ ...(ev.error?.message != null && { message: ev.error.message }),
404
+ raw: ev,
405
+ }),
406
+ )
407
+ }
408
+ if (ev.event_type === "step.delta") {
409
+ const delta = ev.delta
410
+ // Report body: `delta.text`. Accumulate it so the terminal `TurnComplete`
411
+ // carries the report even when `interaction.completed` arrives text-free.
412
+ if (delta?.text != null) {
413
+ const text = delta.text
414
+ return Effect.as(
415
+ Ref.update(acc, (streamed) => streamed + text),
416
+ [TurnEvent.TextDelta({ text })],
417
+ )
418
+ }
419
+ // Thinking summary: nested under `delta.content.text`. Surfaced as reasoning,
420
+ // not accumulated into the report.
421
+ if (delta?.content?.text != null) {
422
+ return Effect.succeed([
423
+ TurnEvent.ReasoningDelta({ text: delta.content.text, kind: "summary" }),
424
+ ])
425
+ }
426
+ return Effect.succeed([])
427
+ }
428
+ if (ev.event_type === "interaction.completed") {
429
+ return Effect.map(Ref.get(acc), (streamed) => [
430
+ TurnEvent.TurnComplete({ turn: completedTurn(ev.interaction, streamed) }),
431
+ ])
432
+ }
433
+ return Effect.succeed([])
434
+ }
435
+
436
+ // A streaming interaction create (`?stream=true`): consume the SSE directly,
437
+ // mapping each event onto `TurnEvent`s via {@link streamEventToTurnEvents}.
438
+ const streamCreate = (
439
+ cfg: Config,
440
+ request: GoogleResearchRequest,
441
+ ): Stream.Stream<TurnEvent, AiError.AiError, HttpClient.HttpClient> =>
442
+ Stream.unwrap(
443
+ Effect.gen(function* () {
444
+ const client = yield* HttpClient.HttpClient
445
+ const acc = yield* Ref.make("")
446
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/interactions?stream=true`).pipe(
447
+ authHeader(cfg),
448
+ HttpClientRequest.bodyJsonUnsafe({ ...submitBody(request), stream: true }),
449
+ HttpClientRequest.accept("text/event-stream"),
450
+ )
451
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
452
+ // Preview surface: log the response status + content-type so an
453
+ // unexpected `application/json` (i.e. `?stream=true` not triggering SSE)
454
+ // is visible at LOG_LEVEL=Debug rather than presenting as a silent hang.
455
+ yield* Effect.logDebug("google interactions stream response", {
456
+ status: response.status,
457
+ contentType: response.headers["content-type"],
458
+ })
459
+ if (response.status >= 400) {
460
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
461
+ return Stream.fail(httpStatusError(response.status, text))
462
+ }
463
+ return response.stream.pipe(
464
+ Stream.mapError(transportFailure),
465
+ SSE.fromBytes,
466
+ // Log each raw frame before decode: the ground truth for the (unverified)
467
+ // event_type / delta shapes this codec keys on.
468
+ Stream.tap((ev) =>
469
+ Effect.logDebug("google interactions sse frame", { event: ev.event, data: ev.data }),
470
+ ),
471
+ // The stream ends with an OpenAI-style `[DONE]` sentinel that is not JSON;
472
+ // drop it (and any blank frame) before the JSON decode.
473
+ Stream.filter((ev) => ev.event !== "done" && ev.data.trim() !== "[DONE]"),
474
+ Stream.mapEffect((ev) =>
475
+ parseJsonUnknown(ev.data).pipe(
476
+ Effect.flatMap(decodeStreamEvent),
477
+ Effect.mapError(transportFailure),
478
+ Effect.flatMap((wire) => streamEventToTurnEvents(acc, wire)),
479
+ ),
480
+ ),
481
+ Stream.flatMap(Stream.fromIterable),
482
+ )
483
+ }),
484
+ )
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Constructors
488
+ // ---------------------------------------------------------------------------
489
+
490
+ const jobOps = (
491
+ cfg: Config,
492
+ client: HttpClient.HttpClient,
493
+ ): ResearchJobOps<GoogleResearchRequest> => {
494
+ const withClient = <A>(e: Effect.Effect<A, AiError.AiError, HttpClient.HttpClient>) =>
495
+ Effect.provideService(e, HttpClient.HttpClient, client)
496
+ return {
497
+ submit: (request) => withClient(submitJob(cfg, request)),
498
+ poll: (ref) => withClient(pollJob(cfg, ref)),
499
+ cancel: () => Effect.fail(unsupported("cancel")),
500
+ }
501
+ }
502
+
503
+ // fromJob's default surface, with `researchStream` overridden to the real
504
+ // Interactions SSE (its submit-then-attach default only synthesizes progress).
505
+ const buildService = <Req extends ResearchRequest>(
506
+ cfg: Config,
507
+ client: HttpClient.HttpClient,
508
+ ops: ResearchJobOps<Req>,
509
+ toGoogle: (request: Req) => GoogleResearchRequest,
510
+ ): DeepResearchServiceShape<Req> => ({
511
+ ...fromJob(ops, cfg.job),
512
+ researchStream: (request) =>
513
+ Stream.provideService(streamCreate(cfg, toGoogle(request)), HttpClient.HttpClient, client),
514
+ })
515
+
516
+ /** Build a `GoogleDeepResearchService`. For Layer setup, prefer {@link layer}. */
517
+ export const make = (
518
+ cfg: Config,
519
+ ): Effect.Effect<GoogleDeepResearchService, never, HttpClient.HttpClient> =>
520
+ Effect.map(HttpClient.HttpClient, (client) =>
521
+ buildService(cfg, client, jobOps(cfg, client), (request) => request),
522
+ )
523
+
524
+ /**
525
+ * Layer registering the provider-typed `GoogleDeepResearch` tag and the generic
526
+ * `DeepResearch` tag. No capability markers: the surface (poll, collect,
527
+ * synthesized stream) is universal.
528
+ */
529
+ export const layer = (
530
+ cfg: Config,
531
+ ): Layer.Layer<GoogleDeepResearch | DeepResearch, never, HttpClient.HttpClient> => {
532
+ const typed = Layer.effect(GoogleDeepResearch, make(cfg))
533
+ const generic = Layer.effect(
534
+ DeepResearch,
535
+ Effect.map(HttpClient.HttpClient, (client): DeepResearchService => {
536
+ const ops = jobOps(cfg, client)
537
+ const genericOps: ResearchJobOps<ResearchRequest> = {
538
+ ...ops,
539
+ submit: (request) => ops.submit(request as GoogleResearchRequest),
540
+ }
541
+ return buildService(cfg, client, genericOps, (request) => request as GoogleResearchRequest)
542
+ }),
543
+ )
544
+ return Layer.merge(typed, generic)
545
+ }
@@ -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) =>