@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.
- package/dist/Gemini.d.mts +4 -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 +42 -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 +70 -0
- package/dist/GoogleDeepResearch.d.mts.map +1 -0
- package/dist/GoogleDeepResearch.mjs +269 -0
- package/dist/GoogleDeepResearch.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 +22 -5
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +52 -30
- 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 +80 -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.ts +446 -0
- package/src/LyriaGenerator.ts +1 -1
- package/src/codec.ts +81 -28
- package/src/geminiTools.test.ts +88 -0
- package/src/index.ts +2 -0
- 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
|
+
}
|
package/src/LyriaGenerator.ts
CHANGED
|
@@ -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
|
|
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
|
-
| {
|
|
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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
248
|
-
* Schema-driven so the shape lives in one
|
|
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
|
|
251
|
-
gemini: Schema.Struct({
|
|
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
|
|
254
|
-
|
|
255
|
-
const
|
|
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
|
-
|
|
287
|
+
decodeGemini(item.providerData),
|
|
258
288
|
Result.match({
|
|
259
|
-
onSuccess: (d) => Option.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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 => ({
|