@effect-uai/google 0.4.0 → 0.5.1
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 +6 -5
- package/dist/Gemini.d.mts.map +1 -1
- package/dist/Gemini.mjs +41 -21
- package/dist/Gemini.mjs.map +1 -1
- package/dist/GeminiEmbedding.d.mts.map +1 -1
- package/dist/GeminiEmbedding.mjs +1 -1
- package/dist/GeminiEmbedding.mjs.map +1 -1
- package/dist/GeminiSynthesizer.d.mts +3 -13
- package/dist/GeminiSynthesizer.d.mts.map +1 -1
- package/dist/GeminiSynthesizer.mjs +1 -1
- package/dist/GeminiSynthesizer.mjs.map +1 -1
- package/dist/GeminiTranscriber.d.mts +2 -2
- package/dist/GeminiTranscriber.d.mts.map +1 -1
- package/dist/GeminiTranscriber.mjs +1 -1
- package/dist/GeminiTranscriber.mjs.map +1 -1
- package/dist/LyriaGenerator.d.mts.map +1 -1
- package/dist/LyriaGenerator.mjs +5 -5
- package/dist/LyriaGenerator.mjs.map +1 -1
- package/dist/codec.d.mts +51 -6
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +150 -26
- package/dist/codec.mjs.map +1 -1
- package/package.json +2 -2
- package/src/Gemini.ts +53 -18
- package/src/GeminiEmbedding.ts +23 -5
- package/src/GeminiSynthesizer.ts +1 -1
- package/src/GeminiTranscriber.ts +1 -1
- package/src/LyriaGenerator.ts +15 -19
- package/src/codec.ts +312 -36
package/src/Gemini.ts
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
} from "@effect-uai/core/LanguageModel"
|
|
9
9
|
import { JsonParseError } from "@effect-uai/core/JSONL"
|
|
10
10
|
import * as SSE from "@effect-uai/core/SSE"
|
|
11
|
-
import
|
|
11
|
+
import { TurnEvent } from "@effect-uai/core/Turn"
|
|
12
12
|
import {
|
|
13
|
+
type ChunkPart,
|
|
13
14
|
type GenerationConfig,
|
|
14
15
|
WireChunk,
|
|
15
16
|
accumulatorToTurn,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
emptyAccumulator,
|
|
18
19
|
ingestChunk,
|
|
19
20
|
} from "./codec.js"
|
|
21
|
+
import { Match } from "effect"
|
|
20
22
|
import type { GoogleModel } from "./models.js"
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -119,11 +121,13 @@ const decodeChunk = Schema.decodeUnknownEffect(WireChunk)
|
|
|
119
121
|
* an `_unknown` variant, so JSON parse / schema decode failures flow
|
|
120
122
|
* through as transport errors rather than being silently dropped.
|
|
121
123
|
*/
|
|
124
|
+
const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
|
|
125
|
+
|
|
122
126
|
const sseEventToChunk = (ev: SSE.Event) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
parseJsonUnknown(ev.data).pipe(
|
|
128
|
+
Effect.mapError((cause) => new JsonParseError({ line: ev.data, cause })),
|
|
129
|
+
Effect.flatMap(decodeChunk),
|
|
130
|
+
)
|
|
127
131
|
|
|
128
132
|
// ---------------------------------------------------------------------------
|
|
129
133
|
// Service implementation
|
|
@@ -152,7 +156,7 @@ const buildNativeStream = (cfg: Config) => {
|
|
|
152
156
|
const client = yield* HttpClient.HttpClient
|
|
153
157
|
const url = `${baseUrl}/models/${request.model}:streamGenerateContent?alt=sse`
|
|
154
158
|
const generationConfig = buildGenerationConfig(request)
|
|
155
|
-
const body = buildRequestBody(request.history, generationConfig)
|
|
159
|
+
const body = buildRequestBody(request.history, generationConfig, request.tools ?? [])
|
|
156
160
|
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
157
161
|
HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
|
|
158
162
|
HttpClientRequest.bodyJsonUnsafe(body),
|
|
@@ -194,6 +198,33 @@ const buildNativeStream = (cfg: Config) => {
|
|
|
194
198
|
* Threads a fresh `Accumulator` so chunk-level text/usage merging works
|
|
195
199
|
* across the run.
|
|
196
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* Map one `ChunkPart` to the `TurnEvent`(s) it produces. Function calls
|
|
203
|
+
* emit two events back-to-back since Gemini delivers args whole: a
|
|
204
|
+
* `tool_call_start` immediately followed by a `tool_call_args_delta` with
|
|
205
|
+
* the full JSON-encoded args. The `call_id` is the synthesized id from
|
|
206
|
+
* the accumulator (Gemini-3 wire id when present, `<name>_<index>` otherwise).
|
|
207
|
+
*/
|
|
208
|
+
const partToTurnEvents = (
|
|
209
|
+
part: ChunkPart,
|
|
210
|
+
callIdAt: (name: string, indexFromTail: number) => string,
|
|
211
|
+
): ReadonlyArray<TurnEvent> =>
|
|
212
|
+
Match.value(part).pipe(
|
|
213
|
+
Match.discriminatorsExhaustive("kind")({
|
|
214
|
+
text: (p): ReadonlyArray<TurnEvent> => [TurnEvent.TextDelta({ text: p.text })],
|
|
215
|
+
reasoning: (p): ReadonlyArray<TurnEvent> => [
|
|
216
|
+
TurnEvent.ReasoningDelta({ text: p.text, kind: "trace" }),
|
|
217
|
+
],
|
|
218
|
+
function_call: (p): ReadonlyArray<TurnEvent> => {
|
|
219
|
+
const call_id = callIdAt(p.name, 0)
|
|
220
|
+
return [
|
|
221
|
+
TurnEvent.ToolCallStart({ call_id, name: p.name }),
|
|
222
|
+
TurnEvent.ToolCallArgsDelta({ call_id, delta: JSON.stringify(p.args ?? {}) }),
|
|
223
|
+
]
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
|
|
197
228
|
export const toCanonical = <E, R>(
|
|
198
229
|
s: Stream.Stream<ProviderEvent, E, R>,
|
|
199
230
|
): Stream.Stream<TurnEvent, E, R> =>
|
|
@@ -202,19 +233,23 @@ export const toCanonical = <E, R>(
|
|
|
202
233
|
() => emptyAccumulator,
|
|
203
234
|
(acc, chunk) => {
|
|
204
235
|
const result = ingestChunk(acc, chunk)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
236
|
+
// The accumulator already contains the synthesized call_ids for any
|
|
237
|
+
// function_call parts in *this* chunk - they are the tail. Walk back
|
|
238
|
+
// by chunk-local function-call index to recover them.
|
|
239
|
+
const newCalls = result.parts.filter((p) => p.kind === "function_call")
|
|
240
|
+
const tailCalls = result.accumulator.functionCalls.slice(
|
|
241
|
+
result.accumulator.functionCalls.length - newCalls.length,
|
|
242
|
+
)
|
|
243
|
+
let consumedCalls = 0
|
|
244
|
+
const partDeltas: ReadonlyArray<TurnEvent> = result.parts.flatMap((p) =>
|
|
245
|
+
partToTurnEvents(p, () => {
|
|
246
|
+
const id = tailCalls[consumedCalls]?.callId ?? p.kind
|
|
247
|
+
consumedCalls += 1
|
|
248
|
+
return id
|
|
249
|
+
}),
|
|
209
250
|
)
|
|
210
|
-
const deltas = result.finished
|
|
211
|
-
? [
|
|
212
|
-
...partDeltas,
|
|
213
|
-
{
|
|
214
|
-
type: "turn_complete" as const,
|
|
215
|
-
turn: accumulatorToTurn(result.accumulator),
|
|
216
|
-
},
|
|
217
|
-
]
|
|
251
|
+
const deltas: ReadonlyArray<TurnEvent> = result.finished
|
|
252
|
+
? [...partDeltas, TurnEvent.TurnComplete({ turn: accumulatorToTurn(result.accumulator) })]
|
|
218
253
|
: partDeltas
|
|
219
254
|
return [result.accumulator, deltas] as const
|
|
220
255
|
},
|
package/src/GeminiEmbedding.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { Context, Effect, Encoding, Layer, Match, Redacted, Schema } from "effect"
|
|
2
2
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
3
|
import * as AiError from "@effect-uai/core/AiError"
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
EmbedContentPart,
|
|
6
|
+
EmbedInput,
|
|
7
|
+
Float32Embedding,
|
|
8
|
+
Usage,
|
|
9
|
+
} from "@effect-uai/core/Embedding"
|
|
5
10
|
import {
|
|
6
11
|
type CommonEmbedManyRequest,
|
|
7
12
|
type CommonEmbedRequest,
|
|
13
|
+
type EmbedEncoding,
|
|
8
14
|
EmbeddingModel,
|
|
9
15
|
type EmbeddingModelService,
|
|
10
16
|
type EmbedManyResponse,
|
|
@@ -209,7 +215,7 @@ const WireBatchResponse = Schema.Struct({
|
|
|
209
215
|
embeddings: Schema.Array(WireEmbedding),
|
|
210
216
|
})
|
|
211
217
|
|
|
212
|
-
const valuesToEmbedding = (values: ReadonlyArray<number>):
|
|
218
|
+
const valuesToEmbedding = (values: ReadonlyArray<number>): Float32Embedding => ({
|
|
213
219
|
_tag: "float32",
|
|
214
220
|
vector: Float32Array.from(values),
|
|
215
221
|
})
|
|
@@ -256,7 +262,7 @@ const postJson = (
|
|
|
256
262
|
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
257
263
|
if (response.status >= 400) {
|
|
258
264
|
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
259
|
-
return yield*
|
|
265
|
+
return yield* httpStatusError(response.status, text)
|
|
260
266
|
}
|
|
261
267
|
return yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
262
268
|
})
|
|
@@ -342,8 +348,20 @@ export const layer = (
|
|
|
342
348
|
Effect.map(
|
|
343
349
|
make(cfg),
|
|
344
350
|
(s): EmbeddingModelService => ({
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
// Gemini's endpoints only emit float32; the cast is sound for the
|
|
352
|
+
// 99% case. A caller who asks for `encoding: "int8"` via the
|
|
353
|
+
// generic tag gets the type they requested but the runtime returns
|
|
354
|
+
// float32 (Gemini ignores the field).
|
|
355
|
+
embed: <E extends EmbedEncoding | undefined = undefined>(
|
|
356
|
+
req: Omit<CommonEmbedRequest, "encoding"> & { readonly encoding?: E },
|
|
357
|
+
) => s.embed(req as GeminiEmbedRequest) as Effect.Effect<EmbedResponse<E>, AiError.AiError>,
|
|
358
|
+
embedMany: <E extends EmbedEncoding | undefined = undefined>(
|
|
359
|
+
req: Omit<CommonEmbedManyRequest, "encoding"> & { readonly encoding?: E },
|
|
360
|
+
) =>
|
|
361
|
+
s.embedMany(req as GeminiEmbedManyRequest) as Effect.Effect<
|
|
362
|
+
EmbedManyResponse<E>,
|
|
363
|
+
AiError.AiError
|
|
364
|
+
>,
|
|
347
365
|
}),
|
|
348
366
|
),
|
|
349
367
|
)
|
package/src/GeminiSynthesizer.ts
CHANGED
|
@@ -135,7 +135,7 @@ const synthesizeImpl = (cfg: Config) => (request: GeminiSynthesizeRequest) =>
|
|
|
135
135
|
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
136
136
|
if (response.status >= 400) {
|
|
137
137
|
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
138
|
-
return yield*
|
|
138
|
+
return yield* httpStatusError(response.status, text)
|
|
139
139
|
}
|
|
140
140
|
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
141
141
|
const inline = yield* decodeWire(json).pipe(
|
package/src/GeminiTranscriber.ts
CHANGED
|
@@ -129,7 +129,7 @@ const transcribeImpl = (cfg: Config) => (request: GeminiTranscribeRequest) =>
|
|
|
129
129
|
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
130
130
|
if (response.status >= 400) {
|
|
131
131
|
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
132
|
-
return yield*
|
|
132
|
+
return yield* httpStatusError(response.status, text)
|
|
133
133
|
}
|
|
134
134
|
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
135
135
|
const text = yield* decodeWire(json).pipe(
|
package/src/LyriaGenerator.ts
CHANGED
|
@@ -254,13 +254,11 @@ const generateImpl =
|
|
|
254
254
|
// Clip is fixed 30 s mp3 — reject wav up front rather than sending
|
|
255
255
|
// a `responseFormat` that the live API ignores or rejects.
|
|
256
256
|
if (isClipModel(request.model) && mimeType === "audio/wav") {
|
|
257
|
-
return yield*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}),
|
|
263
|
-
)
|
|
257
|
+
return yield* new AiError.Unsupported({
|
|
258
|
+
provider: "lyria",
|
|
259
|
+
capability: "outputFormat",
|
|
260
|
+
reason: `lyria-3-clip-preview is fixed at audio/mp3. Use lyria-3-pro-preview for audio/wav output.`,
|
|
261
|
+
})
|
|
264
262
|
}
|
|
265
263
|
const promptText = buildPrompt(request)
|
|
266
264
|
const body = isClipModel(request.model)
|
|
@@ -281,7 +279,7 @@ const generateImpl =
|
|
|
281
279
|
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
282
280
|
if (response.status >= 400) {
|
|
283
281
|
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
284
|
-
return yield*
|
|
282
|
+
return yield* httpStatusError(response.status, text)
|
|
285
283
|
}
|
|
286
284
|
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
287
285
|
const wire = yield* decodeWire(json).pipe(
|
|
@@ -296,17 +294,15 @@ const generateImpl =
|
|
|
296
294
|
// (named-artist references, copyrighted lyrics, etc.). Surface the
|
|
297
295
|
// full response body — text parts, finish reasons, prompt-feedback
|
|
298
296
|
// blocks — so the caller can see why.
|
|
299
|
-
return yield*
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}),
|
|
309
|
-
)
|
|
297
|
+
return yield* new AiError.GenerationFailed({
|
|
298
|
+
provider: "lyria",
|
|
299
|
+
raw: {
|
|
300
|
+
message: "Lyria response had no inlineData audio part.",
|
|
301
|
+
hint: "Likely a prompt-filter rejection. Lyria filters references to real artists / copyrighted song lyrics. Rephrase with descriptive style language instead of artist names.",
|
|
302
|
+
textParts: text,
|
|
303
|
+
response: json,
|
|
304
|
+
},
|
|
305
|
+
})
|
|
310
306
|
}
|
|
311
307
|
const bytes = yield* decodeBase64ToBytes(inline.data)
|
|
312
308
|
return {
|