@effect-uai/google 0.14.0 → 0.16.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/README.md +11 -2
- package/dist/Gemini.d.mts +1 -1
- package/dist/GeminiEmbedding.d.mts +1 -1
- package/dist/GeminiImageGenerator.d.mts +1 -1
- package/dist/GeminiLiveSession.d.mts +17 -0
- package/dist/GeminiLiveSession.d.mts.map +1 -0
- package/dist/GeminiLiveSession.mjs +26 -0
- package/dist/GeminiLiveSession.mjs.map +1 -0
- package/dist/GeminiSynthesizer.d.mts +1 -1
- package/dist/GeminiSynthesizer.test.mjs +1 -1
- package/dist/GoogleDeepResearch.d.mts +1 -1
- package/dist/GoogleDeepResearch.test.mjs +1 -1
- package/dist/LyriaGenerator.d.mts +1 -1
- package/dist/LyriaGenerator.test.mjs +1 -1
- package/dist/codec.d.mts +5 -2
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +4 -1
- package/dist/codec.mjs.map +1 -1
- package/dist/geminiSpeechCodec.test.mjs +1 -1
- package/dist/geminiTools.test.mjs +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +2 -1
- package/dist/models-BVSKJwCx.d.mts +87 -0
- package/dist/models-BVSKJwCx.d.mts.map +1 -0
- package/dist/models.d.mts +2 -75
- package/dist/realtimeSession-7HSIjWh4.d.mts +62 -0
- package/dist/realtimeSession-7HSIjWh4.d.mts.map +1 -0
- package/dist/realtimeSession.d.mts +2 -0
- package/dist/realtimeSession.mjs +406 -0
- package/dist/realtimeSession.mjs.map +1 -0
- package/dist/realtimeSession.test.d.mts +1 -0
- package/dist/realtimeSession.test.mjs +505 -0
- package/dist/realtimeSession.test.mjs.map +1 -0
- package/dist/{test.DNmyFkvJ-N3QqMRwZ.mjs → test.DNmyFkvJ-BPz3G1HS.mjs} +531 -22
- package/dist/test.DNmyFkvJ-BPz3G1HS.mjs.map +1 -0
- package/package.json +6 -2
- package/src/GeminiLiveSession.ts +45 -0
- package/src/codec.ts +3 -2
- package/src/index.ts +1 -0
- package/src/models.ts +19 -0
- package/src/realtimeSession.test.ts +415 -0
- package/src/realtimeSession.ts +807 -0
- package/dist/models.d.mts.map +0 -1
- package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-uai/google",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Google Gemini provider for @effect-uai/core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"types": "./dist/GeminiImageGenerator.d.mts",
|
|
47
47
|
"import": "./dist/GeminiImageGenerator.mjs"
|
|
48
48
|
},
|
|
49
|
+
"./GeminiLiveSession": {
|
|
50
|
+
"types": "./dist/GeminiLiveSession.d.mts",
|
|
51
|
+
"import": "./dist/GeminiLiveSession.mjs"
|
|
52
|
+
},
|
|
49
53
|
"./GeminiSynthesizer": {
|
|
50
54
|
"types": "./dist/GeminiSynthesizer.d.mts",
|
|
51
55
|
"import": "./dist/GeminiSynthesizer.mjs"
|
|
@@ -65,7 +69,7 @@
|
|
|
65
69
|
"devDependencies": {
|
|
66
70
|
"effect": "4.0.0-rc.111",
|
|
67
71
|
"typescript": "^7.0.2",
|
|
68
|
-
"@effect-uai/core": "0.
|
|
72
|
+
"@effect-uai/core": "0.16.0"
|
|
69
73
|
},
|
|
70
74
|
"peerDependencies": {
|
|
71
75
|
"@effect-uai/core": ">=0.2.0 <1",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Live speech-to-speech Layer. Registers the typed `GeminiLiveSession`
|
|
3
|
+
* tag, the generic `RealtimeSession` tag, and the `RealtimeVideoInput` marker,
|
|
4
|
+
* which makes `sendVideoFrame` compile against this provider and not against
|
|
5
|
+
* one without video.
|
|
6
|
+
*
|
|
7
|
+
* Auth rides on the URL, so this runs anywhere a `WebSocket` global exists:
|
|
8
|
+
* Node, Bun, Deno and the browser, with no peer dependency.
|
|
9
|
+
*/
|
|
10
|
+
import { Context, Effect, Layer, type Scope } from "effect"
|
|
11
|
+
import type * as AiError from "@effect-uai/core/AiError"
|
|
12
|
+
import type { CommonSessionRequest } from "@effect-uai/core/Realtime"
|
|
13
|
+
import {
|
|
14
|
+
RealtimeSession,
|
|
15
|
+
type RealtimeSessionHandle,
|
|
16
|
+
type RealtimeSessionService,
|
|
17
|
+
RealtimeVideoInput,
|
|
18
|
+
} from "@effect-uai/core/RealtimeSession"
|
|
19
|
+
import { type Config, type GeminiLiveRequest, openSession } from "./realtimeSession.js"
|
|
20
|
+
|
|
21
|
+
export type { Config, GeminiLiveRequest } from "./realtimeSession.js"
|
|
22
|
+
|
|
23
|
+
export type GeminiLiveSessionService = {
|
|
24
|
+
readonly open: (
|
|
25
|
+
request: GeminiLiveRequest,
|
|
26
|
+
) => Effect.Effect<RealtimeSessionHandle, AiError.AiError, Scope.Scope>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class GeminiLiveSession extends Context.Service<
|
|
30
|
+
GeminiLiveSession,
|
|
31
|
+
GeminiLiveSessionService
|
|
32
|
+
>()("@betalyra/effect-uai/providers/google/GeminiLiveSession") {}
|
|
33
|
+
|
|
34
|
+
export const make = (cfg: Config): GeminiLiveSessionService => ({ open: openSession(cfg) })
|
|
35
|
+
|
|
36
|
+
export const layer = (
|
|
37
|
+
cfg: Config,
|
|
38
|
+
): Layer.Layer<GeminiLiveSession | RealtimeSession | RealtimeVideoInput> =>
|
|
39
|
+
Layer.mergeAll(
|
|
40
|
+
Layer.succeed(GeminiLiveSession, make(cfg)),
|
|
41
|
+
Layer.succeed(RealtimeSession, {
|
|
42
|
+
open: (request: CommonSessionRequest) => make(cfg).open(request as GeminiLiveRequest),
|
|
43
|
+
} satisfies RealtimeSessionService),
|
|
44
|
+
Layer.succeed(RealtimeVideoInput, undefined),
|
|
45
|
+
)
|
package/src/codec.ts
CHANGED
|
@@ -317,7 +317,8 @@ const parsedJsonObject =
|
|
|
317
317
|
)
|
|
318
318
|
|
|
319
319
|
const parsedArgs = parsedJsonObject(() => ({}))
|
|
320
|
-
|
|
320
|
+
/** A `ToolCallOutput.output` string as the object `functionResponse` wants. */
|
|
321
|
+
export const parsedResponse = parsedJsonObject((raw) => ({ output: raw }))
|
|
321
322
|
|
|
322
323
|
/**
|
|
323
324
|
* `ToolCallOutput` only carries `call_id`; Gemini's `functionResponse`
|
|
@@ -460,7 +461,7 @@ const sanitizeSchema = (schema: unknown): unknown => {
|
|
|
460
461
|
)
|
|
461
462
|
}
|
|
462
463
|
|
|
463
|
-
const toolDescriptorsToTools = (
|
|
464
|
+
export const toolDescriptorsToTools = (
|
|
464
465
|
tools: ReadonlyArray<ToolDescriptor>,
|
|
465
466
|
): ReadonlyArray<RequestTool> =>
|
|
466
467
|
tools.length === 0
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * as Gemini from "./Gemini.js"
|
|
|
2
2
|
export * as GeminiTools from "./GeminiTools.js"
|
|
3
3
|
export * as GeminiEmbedding from "./GeminiEmbedding.js"
|
|
4
4
|
export * as GeminiImageGenerator from "./GeminiImageGenerator.js"
|
|
5
|
+
export * as GeminiLiveSession from "./GeminiLiveSession.js"
|
|
5
6
|
export * as GeminiSynthesizer from "./GeminiSynthesizer.js"
|
|
6
7
|
export * as GoogleDeepResearch from "./GoogleDeepResearch.js"
|
|
7
8
|
export * as LyriaGenerator from "./LyriaGenerator.js"
|
package/src/models.ts
CHANGED
|
@@ -83,6 +83,25 @@ export type GeminiTtsModel =
|
|
|
83
83
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
84
84
|
| (string & {})
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Live API models, for `BidiGenerateContent` speech-to-speech sessions. Every
|
|
88
|
+
* current one is native audio: output is audio only, and text comes from
|
|
89
|
+
* output transcription.
|
|
90
|
+
*
|
|
91
|
+
* `gemini-3.8-live` is the default for a voice agent. Its
|
|
92
|
+
* `-extended-thinking` sibling reasons in the background during a live
|
|
93
|
+
* exchange, which costs the latency a voice turn is most sensitive to.
|
|
94
|
+
*
|
|
95
|
+
* Reference: https://ai.google.dev/gemini-api/docs/live-api
|
|
96
|
+
*/
|
|
97
|
+
export type GeminiLiveModel =
|
|
98
|
+
| "gemini-3.8-live"
|
|
99
|
+
| "gemini-3.8-live-extended-thinking"
|
|
100
|
+
| "gemini-3.1-flash-live-preview"
|
|
101
|
+
| "gemini-2.5-flash-native-audio-preview-12-2025"
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
103
|
+
| (string & {})
|
|
104
|
+
|
|
86
105
|
/**
|
|
87
106
|
* 30 prebuilt voice names for Gemini TTS. No custom-voice / cloning path
|
|
88
107
|
* is exposed on this surface, so the tail is stock-only (no
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Duration, Effect, Encoding, Fiber, Logger, Redacted, Ref, Stream } from "effect"
|
|
3
|
+
import { expect } from "vitest"
|
|
4
|
+
import type { RealtimeEvent } from "@effect-uai/core/Realtime"
|
|
5
|
+
import { RealtimeInput } from "@effect-uai/core/Realtime"
|
|
6
|
+
import type { RealtimeSessionHandle } from "@effect-uai/core/RealtimeSession"
|
|
7
|
+
import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
|
|
8
|
+
import { type GeminiLiveRequest, openSession } from "./realtimeSession.js"
|
|
9
|
+
|
|
10
|
+
const request: GeminiLiveRequest = {
|
|
11
|
+
model: "gemini-3.1-flash-live-preview",
|
|
12
|
+
instructions: "be terse",
|
|
13
|
+
voiceId: "Kore",
|
|
14
|
+
inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000, channels: 1 },
|
|
15
|
+
outputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 24000, channels: 1 },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cfg = { apiKey: Redacted.make("test-key") }
|
|
19
|
+
|
|
20
|
+
/** The client speaks first here, so the handshake answers `setup`. */
|
|
21
|
+
const gemini = FakeWebSocket.make({
|
|
22
|
+
reply: (frame) =>
|
|
23
|
+
Effect.succeed(Object.hasOwn(frame as object, "setup") ? [{ setupComplete: {} }] : []),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/** Let the socket's own turn and the adapter's fibers run. */
|
|
27
|
+
const tick = Effect.sleep("5 millis")
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Events are recorded as they arrive rather than collected at the end, so a
|
|
31
|
+
* run that ends in a failure still says what the caller saw before it.
|
|
32
|
+
*/
|
|
33
|
+
const transcript = (
|
|
34
|
+
server: FakeWebSocket.FakeWebSocketServer,
|
|
35
|
+
script: (handle: RealtimeSessionHandle) => Effect.Effect<void, unknown>,
|
|
36
|
+
options?: { readonly closeCode?: number; readonly request?: GeminiLiveRequest },
|
|
37
|
+
) =>
|
|
38
|
+
Effect.gen(function* () {
|
|
39
|
+
const handle = yield* openSession({ ...cfg, webSocket: server.connect })(
|
|
40
|
+
options?.request ?? request,
|
|
41
|
+
)
|
|
42
|
+
const seen = yield* Ref.make<ReadonlyArray<RealtimeEvent>>([])
|
|
43
|
+
const collector = yield* Effect.forkChild(
|
|
44
|
+
Effect.exit(Stream.runForEach(handle.events, (e) => Ref.update(seen, (xs) => [...xs, e]))),
|
|
45
|
+
)
|
|
46
|
+
yield* script(handle)
|
|
47
|
+
yield* tick
|
|
48
|
+
yield* server.close(options?.closeCode ?? 1000)
|
|
49
|
+
const exit = yield* Fiber.join(collector)
|
|
50
|
+
return { exit, events: yield* Ref.get(seen) }
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const tags = (events: ReadonlyArray<RealtimeEvent>) => events.map((e) => e._tag)
|
|
54
|
+
|
|
55
|
+
const audioPart = (bytes: ReadonlyArray<number>) => ({
|
|
56
|
+
inlineData: {
|
|
57
|
+
mimeType: "audio/pcm;rate=24000",
|
|
58
|
+
data: Encoding.encodeBase64(new Uint8Array(bytes)),
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("Gemini live session turns", () => {
|
|
63
|
+
it.live("mints one turn id for a whole answer, and reports its usage at the end", () =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const server = yield* gemini
|
|
66
|
+
const { events } = yield* transcript(server, () =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
// 3.1 packs several parts, and their transcription, into one message.
|
|
69
|
+
yield* server.push({
|
|
70
|
+
serverContent: {
|
|
71
|
+
modelTurn: { parts: [audioPart([1, 2, 3]), { thought: true, text: "planning" }] },
|
|
72
|
+
outputTranscription: { text: "hi" },
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
yield* server.push({
|
|
76
|
+
serverContent: { modelTurn: { parts: [audioPart([4, 5])] } },
|
|
77
|
+
usageMetadata: { promptTokenCount: 10, responseTokenCount: 20, totalTokenCount: 30 },
|
|
78
|
+
})
|
|
79
|
+
yield* server.push({ serverContent: { turnComplete: true } })
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// The thought part is the model reasoning, not something to speak.
|
|
84
|
+
expect(tags(events)).toEqual([
|
|
85
|
+
"ResponseStarted",
|
|
86
|
+
"AudioDelta",
|
|
87
|
+
"OutputTranscriptDelta",
|
|
88
|
+
"AudioDelta",
|
|
89
|
+
"ResponseDone",
|
|
90
|
+
])
|
|
91
|
+
// The wire carries no response id, so every event of the turn shares one.
|
|
92
|
+
expect(new Set(events.map((e) => ("responseId" in e ? e.responseId : "")))).toEqual(
|
|
93
|
+
new Set(["turn_1"]),
|
|
94
|
+
)
|
|
95
|
+
const done = events[4]
|
|
96
|
+
expect(done?._tag === "ResponseDone" && done.reason).toBe("complete")
|
|
97
|
+
expect(done?._tag === "ResponseDone" && done.usage?.total_tokens).toBe(30)
|
|
98
|
+
}).pipe(Effect.scoped),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
it.live("ends an interrupted turn once, and numbers the next one after it", () =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
const server = yield* gemini
|
|
104
|
+
const { events } = yield* transcript(server, () =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
yield* server.push({ serverContent: { modelTurn: { parts: [audioPart([1])] } } })
|
|
107
|
+
yield* server.push({ serverContent: { interrupted: true } })
|
|
108
|
+
// The server follows an interruption with `turnComplete`, which
|
|
109
|
+
// would otherwise end the same turn a second time.
|
|
110
|
+
yield* server.push({ serverContent: { turnComplete: true } })
|
|
111
|
+
yield* server.push({ serverContent: { modelTurn: { parts: [audioPart([2])] } } })
|
|
112
|
+
yield* server.push({ serverContent: { turnComplete: true } })
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
expect(tags(events)).toEqual([
|
|
117
|
+
"ResponseStarted",
|
|
118
|
+
"AudioDelta",
|
|
119
|
+
"Interrupted",
|
|
120
|
+
"ResponseDone",
|
|
121
|
+
"ResponseStarted",
|
|
122
|
+
"AudioDelta",
|
|
123
|
+
"ResponseDone",
|
|
124
|
+
])
|
|
125
|
+
const interrupted = events[3]
|
|
126
|
+
expect(interrupted?._tag === "ResponseDone" && interrupted.reason).toBe("interrupted")
|
|
127
|
+
const next = events[4]
|
|
128
|
+
expect(next?._tag === "ResponseStarted" && next.responseId).toBe("turn_2")
|
|
129
|
+
}).pipe(Effect.scoped),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
it.live("marks the user's words interim until the utterance is transcribed", () =>
|
|
133
|
+
Effect.gen(function* () {
|
|
134
|
+
const server = yield* gemini
|
|
135
|
+
const { events } = yield* transcript(server, () =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
yield* server.push({ serverContent: { interimInputTranscription: { text: "weather" } } })
|
|
138
|
+
yield* server.push({
|
|
139
|
+
serverContent: { inputTranscription: { text: "weather in Lisbon" } },
|
|
140
|
+
})
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// A transcript belongs to no turn, so none is started for it.
|
|
145
|
+
expect(events).toEqual([
|
|
146
|
+
{ _tag: "InputTranscript", text: "weather", final: false },
|
|
147
|
+
{ _tag: "InputTranscript", text: "weather in Lisbon", final: true },
|
|
148
|
+
])
|
|
149
|
+
}).pipe(Effect.scoped),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
it.live("ends the stream as an incomplete turn when a turn is still running", () =>
|
|
153
|
+
Effect.gen(function* () {
|
|
154
|
+
const server = yield* gemini
|
|
155
|
+
const { exit } = yield* transcript(server, () =>
|
|
156
|
+
server.push({ serverContent: { modelTurn: { parts: [audioPart([1])] } } }),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// A close must never look like a finished turn.
|
|
160
|
+
expect(exit._tag).toBe("Failure")
|
|
161
|
+
expect(JSON.stringify(exit)).toContain("IncompleteTurn")
|
|
162
|
+
}).pipe(Effect.scoped),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
it.live("fails when the socket closes before the configuration is accepted", () =>
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const mute = yield* FakeWebSocket.make()
|
|
168
|
+
const opened = yield* Effect.forkChild(
|
|
169
|
+
Effect.exit(openSession({ ...cfg, webSocket: mute.connect })(request)),
|
|
170
|
+
)
|
|
171
|
+
yield* tick
|
|
172
|
+
yield* mute.close(1000)
|
|
173
|
+
|
|
174
|
+
const exit = yield* Fiber.join(opened)
|
|
175
|
+
expect(exit._tag).toBe("Failure")
|
|
176
|
+
expect(JSON.stringify(exit)).toContain("Unavailable")
|
|
177
|
+
}).pipe(Effect.scoped),
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("Gemini live session tools", () => {
|
|
182
|
+
it.live("answers a call with the name the server gave it", () =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const server = yield* gemini
|
|
185
|
+
const { events } = yield* transcript(server, (handle) =>
|
|
186
|
+
Effect.gen(function* () {
|
|
187
|
+
yield* server.push({
|
|
188
|
+
toolCall: {
|
|
189
|
+
functionCalls: [{ id: "fc_1", name: "get_weather", args: { city: "Lisbon" } }],
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
yield* tick
|
|
193
|
+
yield* handle.send(
|
|
194
|
+
RealtimeInput.ToolResult({
|
|
195
|
+
output: {
|
|
196
|
+
type: "function_call_output",
|
|
197
|
+
call_id: "fc_1",
|
|
198
|
+
output: '{"celsius":32}',
|
|
199
|
+
providerData: undefined,
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
)
|
|
203
|
+
yield* server.push({ serverContent: { turnComplete: true } })
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const call = events.find((e) => e._tag === "ToolCall")
|
|
208
|
+
expect(call?._tag === "ToolCall" && call.call.arguments).toBe('{"city":"Lisbon"}')
|
|
209
|
+
// A call arrives with no turn open, so it gets one of its own.
|
|
210
|
+
expect(call?._tag === "ToolCall" && call.responseId).toBe("turn_1")
|
|
211
|
+
|
|
212
|
+
const sent = yield* server.sent
|
|
213
|
+
const response = sent.find((f: any) => f.toolResponse !== undefined) as any
|
|
214
|
+
// The name comes from the call, and the payload has to be an object.
|
|
215
|
+
// `WHEN_IDLE` by default: a model that kept talking while the tool ran
|
|
216
|
+
// finishes the sentence rather than cutting itself off to report.
|
|
217
|
+
expect(response.toolResponse.functionResponses).toEqual([
|
|
218
|
+
{
|
|
219
|
+
id: "fc_1",
|
|
220
|
+
name: "get_weather",
|
|
221
|
+
response: { celsius: 32 },
|
|
222
|
+
scheduling: "WHEN_IDLE",
|
|
223
|
+
},
|
|
224
|
+
])
|
|
225
|
+
}).pipe(Effect.scoped),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
it.live("reports a result at once when the caller asks it to interrupt", () =>
|
|
229
|
+
Effect.gen(function* () {
|
|
230
|
+
const server = yield* gemini
|
|
231
|
+
yield* transcript(
|
|
232
|
+
server,
|
|
233
|
+
(handle) =>
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
yield* server.push({
|
|
236
|
+
toolCall: { functionCalls: [{ id: "fc_1", name: "get_weather", args: {} }] },
|
|
237
|
+
})
|
|
238
|
+
yield* tick
|
|
239
|
+
yield* handle.send(
|
|
240
|
+
RealtimeInput.ToolResult({
|
|
241
|
+
output: {
|
|
242
|
+
type: "function_call_output",
|
|
243
|
+
call_id: "fc_1",
|
|
244
|
+
output: "{}",
|
|
245
|
+
providerData: undefined,
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
}),
|
|
250
|
+
{ request: { ...request, toolScheduling: "interrupt" } },
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const sent = yield* server.sent
|
|
254
|
+
const response = sent.find((f: any) => f.toolResponse !== undefined) as any
|
|
255
|
+
expect(response.toolResponse.functionResponses[0].scheduling).toBe("INTERRUPT")
|
|
256
|
+
}).pipe(Effect.scoped),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
it.live("rejects a result for a call this session never made", () =>
|
|
260
|
+
Effect.gen(function* () {
|
|
261
|
+
const server = yield* gemini
|
|
262
|
+
const handle = yield* openSession({ ...cfg, webSocket: server.connect })(request)
|
|
263
|
+
const failure = yield* Effect.exit(
|
|
264
|
+
handle.send(
|
|
265
|
+
RealtimeInput.ToolResult({
|
|
266
|
+
output: {
|
|
267
|
+
type: "function_call_output",
|
|
268
|
+
call_id: "fc_unknown",
|
|
269
|
+
output: "{}",
|
|
270
|
+
providerData: undefined,
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
expect(failure._tag).toBe("Failure")
|
|
277
|
+
expect(JSON.stringify(failure)).toContain("InvalidRequest")
|
|
278
|
+
expect((yield* server.sent).some((f: any) => f.toolResponse !== undefined)).toBe(false)
|
|
279
|
+
}).pipe(Effect.scoped),
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe("Gemini live session lifetime", () => {
|
|
284
|
+
it.live("keeps only a resumable handle, and reads the closing warning as a duration", () =>
|
|
285
|
+
Effect.gen(function* () {
|
|
286
|
+
const server = yield* gemini
|
|
287
|
+
const { events, exit } = yield* transcript(server, () =>
|
|
288
|
+
Effect.gen(function* () {
|
|
289
|
+
// Sent while a tool runs: no handle, and the stored one must stand.
|
|
290
|
+
yield* server.push({ sessionResumptionUpdate: { newHandle: "", resumable: false } })
|
|
291
|
+
yield* server.push({ sessionResumptionUpdate: { newHandle: "h1", resumable: true } })
|
|
292
|
+
yield* server.push({ goAway: { timeLeft: "60s" } })
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
expect(tags(events)).toEqual(["ResumptionHandle", "SessionEnding"])
|
|
297
|
+
const handle = events[0]
|
|
298
|
+
expect(handle?._tag === "ResumptionHandle" && handle.handle).toBe("h1")
|
|
299
|
+
const ending = events[1]
|
|
300
|
+
// A proto duration string, not a number.
|
|
301
|
+
expect(ending?._tag === "SessionEnding" && ending.timeLeft).toEqual(Duration.seconds(60))
|
|
302
|
+
// The close `goAway` announced is the lifetime cap, not a clean end, so
|
|
303
|
+
// a caller can tell "reconnect" from "we are done here".
|
|
304
|
+
expect(JSON.stringify(exit)).toContain("SessionExpired")
|
|
305
|
+
}).pipe(Effect.scoped),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
it.live("ends cleanly when the close was not announced", () =>
|
|
309
|
+
Effect.gen(function* () {
|
|
310
|
+
const server = yield* gemini
|
|
311
|
+
const { exit } = yield* transcript(server, () =>
|
|
312
|
+
server.push({ serverContent: { turnComplete: true } }),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
expect(exit._tag).toBe("Success")
|
|
316
|
+
}).pipe(Effect.scoped),
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/** The `CapabilityWarning` payloads a run produced, in order. */
|
|
321
|
+
const warningCollector = () => {
|
|
322
|
+
const seen: Array<{ readonly field: string; readonly value?: unknown }> = []
|
|
323
|
+
const logger = Logger.make((options) => {
|
|
324
|
+
const [label, warning] = options.message as ReadonlyArray<any>
|
|
325
|
+
if (label === "Capability dropped") seen.push(warning)
|
|
326
|
+
})
|
|
327
|
+
return { seen, layer: Logger.layer([logger]) }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const userText = (text: string) =>
|
|
331
|
+
({
|
|
332
|
+
type: "message",
|
|
333
|
+
role: "user",
|
|
334
|
+
content: [{ type: "input_text", text, providerData: undefined }],
|
|
335
|
+
providerData: undefined,
|
|
336
|
+
}) as const
|
|
337
|
+
|
|
338
|
+
const call = (id: string) =>
|
|
339
|
+
({
|
|
340
|
+
type: "function_call",
|
|
341
|
+
call_id: id,
|
|
342
|
+
name: "get_time",
|
|
343
|
+
arguments: "{}",
|
|
344
|
+
providerData: undefined,
|
|
345
|
+
}) as const
|
|
346
|
+
|
|
347
|
+
describe("Gemini live session history", () => {
|
|
348
|
+
it.live("names each kind of item the text-only seed cannot carry, once", () =>
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
const server = yield* gemini
|
|
351
|
+
const warnings = warningCollector()
|
|
352
|
+
|
|
353
|
+
yield* openSession({ ...cfg, webSocket: server.connect })({
|
|
354
|
+
...request,
|
|
355
|
+
history: [
|
|
356
|
+
userText("what time is it"),
|
|
357
|
+
call("fc_1"),
|
|
358
|
+
call("fc_2"),
|
|
359
|
+
{
|
|
360
|
+
type: "function_call_output",
|
|
361
|
+
call_id: "fc_1",
|
|
362
|
+
output: '{"now":"12:00"}',
|
|
363
|
+
providerData: undefined,
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
}).pipe(Effect.provide(warnings.layer))
|
|
367
|
+
yield* tick
|
|
368
|
+
|
|
369
|
+
// Two calls, one kind: a long tool history is not a wall of warnings.
|
|
370
|
+
expect(warnings.seen.map((w) => w.value)).toEqual(["function_call", "function_call_output"])
|
|
371
|
+
expect(warnings.seen.every((w) => w.field === "history")).toBe(true)
|
|
372
|
+
|
|
373
|
+
const sent = yield* server.sent
|
|
374
|
+
const seed = sent.find((f: any) => f.clientContent !== undefined) as any
|
|
375
|
+
expect(seed.clientContent.turns).toEqual([
|
|
376
|
+
{ role: "user", parts: [{ text: "what time is it" }] },
|
|
377
|
+
])
|
|
378
|
+
}).pipe(Effect.scoped),
|
|
379
|
+
)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe("Gemini live session video", () => {
|
|
383
|
+
it.live("sends frame bytes inline, and refuses a frame it would have to fetch", () =>
|
|
384
|
+
Effect.gen(function* () {
|
|
385
|
+
const server = yield* gemini
|
|
386
|
+
yield* transcript(server, (handle) =>
|
|
387
|
+
Effect.gen(function* () {
|
|
388
|
+
yield* handle.send(
|
|
389
|
+
RealtimeInput.VideoFrame({
|
|
390
|
+
frame: { _tag: "bytes", bytes: new Uint8Array([1, 2]), mimeType: "image/jpeg" },
|
|
391
|
+
}),
|
|
392
|
+
)
|
|
393
|
+
// A URL would need an upload through the Files API first.
|
|
394
|
+
const exit = yield* Effect.exit(
|
|
395
|
+
handle.send(
|
|
396
|
+
RealtimeInput.VideoFrame({
|
|
397
|
+
frame: { _tag: "url", url: "https://example.com/frame.jpg" },
|
|
398
|
+
}),
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
expect(JSON.stringify(exit)).toContain("Unsupported")
|
|
402
|
+
}),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
const sent = yield* server.sent
|
|
406
|
+
expect(sent.filter((f: any) => f.realtimeInput?.video !== undefined)).toEqual([
|
|
407
|
+
{
|
|
408
|
+
realtimeInput: {
|
|
409
|
+
video: { data: Encoding.encodeBase64(new Uint8Array([1, 2])), mimeType: "image/jpeg" },
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
])
|
|
413
|
+
}).pipe(Effect.scoped),
|
|
414
|
+
)
|
|
415
|
+
})
|