@effect-uai/google 0.4.0 → 0.5.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/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 type { TurnEvent } from "@effect-uai/core/Turn"
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
- Effect.try({
124
- try: () => JSON.parse(ev.data) as unknown,
125
- catch: (cause) => new JsonParseError({ line: ev.data, cause }),
126
- }).pipe(Effect.flatMap(decodeChunk))
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
- const partDeltas = result.parts.map((p) =>
206
- p.kind === "text"
207
- ? { type: "text_delta" as const, text: p.text }
208
- : { type: "reasoning_delta" as const, text: p.text, kind: "trace" as const },
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
  },
@@ -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 { Embedding, EmbedContentPart, EmbedInput, Usage } from "@effect-uai/core/Embedding"
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>): Embedding => ({
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* Effect.fail(httpStatusError(response.status, text))
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
- embed: (req: CommonEmbedRequest) => s.embed(req as GeminiEmbedRequest),
346
- embedMany: (req: CommonEmbedManyRequest) => s.embedMany(req as GeminiEmbedManyRequest),
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
  )
@@ -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* Effect.fail(httpStatusError(response.status, text))
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(
@@ -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* Effect.fail(httpStatusError(response.status, text))
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(
@@ -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* Effect.fail(
258
- new AiError.Unsupported({
259
- provider: "lyria",
260
- capability: "outputFormat",
261
- reason: `lyria-3-clip-preview is fixed at audio/mp3. Use lyria-3-pro-preview for audio/wav output.`,
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* Effect.fail(httpStatusError(response.status, text))
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* Effect.fail(
300
- new AiError.GenerationFailed({
301
- provider: "lyria",
302
- raw: {
303
- message: "Lyria response had no inlineData audio part.",
304
- hint: "Likely a prompt-filter rejection. Lyria filters references to real artists / copyrighted song lyrics. Rephrase with descriptive style language instead of artist names.",
305
- textParts: text,
306
- response: json,
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 {