@effect-uai/google 0.13.0 → 0.14.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/codec.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { Array as Arr, Encoding, Match, Option, Result, Schema, pipe } from "effect"
2
+ import * as AiError from "@effect-uai/core/AiError"
3
+ import type { ImageSource } from "@effect-uai/core/Image"
4
+ import { imageBase64 } from "@effect-uai/core/Image"
2
5
  import type {
3
6
  ContentBlock,
4
7
  ToolCall,
@@ -43,8 +46,23 @@ const FunctionCallPart = Schema.Struct({
43
46
  thoughtSignature: Schema.optional(Schema.String),
44
47
  })
45
48
 
46
- const Part = Schema.Union([TextPart, FunctionCallPart])
49
+ /**
50
+ * An image the model drew, as a part of its own turn. Only image models
51
+ * emit these, and only when the request asked for the IMAGE modality.
52
+ * `thought: true` marks an interim image drawn while planning, which is
53
+ * not the answer and is dropped the same way thought text is.
54
+ */
55
+ const InlineDataPart = Schema.Struct({
56
+ inlineData: Schema.Struct({
57
+ mimeType: Schema.optional(Schema.String),
58
+ data: Schema.String,
59
+ }),
60
+ thought: Schema.optional(Schema.Boolean),
61
+ })
62
+
63
+ const Part = Schema.Union([TextPart, FunctionCallPart, InlineDataPart])
47
64
  type WireFunctionCallPart = typeof FunctionCallPart.Type
65
+ type WireInlineDataPart = typeof InlineDataPart.Type
48
66
 
49
67
  const Content = Schema.Struct({
50
68
  role: Schema.optional(Schema.String),
@@ -180,11 +198,19 @@ export type ThinkingConfig = {
180
198
  readonly thinkingBudget: number
181
199
  }
182
200
 
201
+ export type ImageConfig = {
202
+ readonly aspectRatio?: string
203
+ readonly imageSize?: string
204
+ }
205
+
183
206
  export type GenerationConfig = {
184
207
  readonly temperature?: number
185
208
  readonly maxOutputTokens?: number
186
209
  readonly topP?: number
187
210
  readonly thinkingConfig?: ThinkingConfig
211
+ /** Image models need `["TEXT", "IMAGE"]` or they answer in prose only. */
212
+ readonly responseModalities?: ReadonlyArray<"TEXT" | "IMAGE">
213
+ readonly imageConfig?: ImageConfig
188
214
  /** Set together with `responseJsonSchema` to constrain output to JSON. */
189
215
  readonly responseMimeType?: string
190
216
  /** JSON Schema constraint on the output. */
@@ -204,6 +230,7 @@ const blockText = Match.type<ContentBlock>().pipe(
204
230
  input_text: (b) => b.text,
205
231
  input_image: () => "",
206
232
  output_text: (b) => b.text,
233
+ output_image: () => "",
207
234
  refusal: (b) => b.text,
208
235
  }),
209
236
  )
@@ -232,6 +259,9 @@ const blockToParts = Match.type<ContentBlock>().pipe(
232
259
  input_text: (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),
233
260
  input_image: (b): ReadonlyArray<RequestPart> => imageSourceToParts(b.source),
234
261
  output_text: (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),
262
+ // An image the model drew goes back as a model-role `inlineData` part,
263
+ // which is how a multi-turn edit keeps its subject.
264
+ output_image: (b): ReadonlyArray<RequestPart> => imageSourceToParts(b.source),
235
265
  // Refusals are assistant-side content; they don't round-trip into Gemini's
236
266
  // request body as parts. Skip.
237
267
  refusal: (): ReadonlyArray<RequestPart> => [],
@@ -460,6 +490,47 @@ const toolChoiceToFunctionCallingConfig = (
460
490
  Match.orElse((tc) => ({ mode: "ANY" as const, allowedFunctionNames: [tc.name] })),
461
491
  )
462
492
 
493
+ // ---------------------------------------------------------------------------
494
+ // HTTP failure mapping, shared by every Google surface
495
+ // ---------------------------------------------------------------------------
496
+
497
+ /** `provider` names the surface that failed, so errors read `gemini` or `lyria`. */
498
+ export const transportFailure =
499
+ (provider: string) =>
500
+ (cause: unknown): AiError.AiError =>
501
+ new AiError.Unavailable({ provider, raw: cause })
502
+
503
+ /** 413 is the inline-payload cap, which is a context limit on every surface. */
504
+ export const httpStatusError =
505
+ (provider: string) =>
506
+ (status: number, body: string): AiError.AiError =>
507
+ Match.value(status).pipe(
508
+ Match.when(429, (): AiError.AiError => new AiError.RateLimited({ provider, raw: body })),
509
+ Match.whenOr(408, 504, (): AiError.AiError => new AiError.Timeout({ provider, raw: body })),
510
+ Match.when(
511
+ 401,
512
+ (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: "auth", raw: body }),
513
+ ),
514
+ Match.when(
515
+ 403,
516
+ (): AiError.AiError =>
517
+ new AiError.AuthFailed({ provider, subtype: "permission", raw: body }),
518
+ ),
519
+ Match.when(
520
+ 402,
521
+ (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: "billing", raw: body }),
522
+ ),
523
+ Match.when(
524
+ 413,
525
+ (): AiError.AiError => new AiError.ContextLengthExceeded({ provider, raw: body }),
526
+ ),
527
+ Match.when(
528
+ (n) => n >= 500,
529
+ (n): AiError.AiError => new AiError.Unavailable({ provider, status: n, raw: body }),
530
+ ),
531
+ Match.orElse((): AiError.AiError => new AiError.InvalidRequest({ provider, raw: body })),
532
+ )
533
+
463
534
  /**
464
535
  * URL-form images need Gemini's Files API (upload, then `fileData`); we
465
536
  * don't pre-upload, so such a part would otherwise vanish from the request.
@@ -530,6 +601,8 @@ export type AccumulatedFunctionCall = {
530
601
  export type Accumulator = {
531
602
  readonly text: string
532
603
  readonly reasoning: string
604
+ /** Images the model drew, in emission order. Image models only. */
605
+ readonly images: ReadonlyArray<ImageSource>
533
606
  readonly functionCalls: ReadonlyArray<AccumulatedFunctionCall>
534
607
  /** Grounding citations, accumulated (de-duped by url) across chunks. */
535
608
  readonly annotations: ReadonlyArray<Annotation>
@@ -546,6 +619,7 @@ export type Accumulator = {
546
619
  export const emptyAccumulator: Accumulator = {
547
620
  text: "",
548
621
  reasoning: "",
622
+ images: [],
549
623
  functionCalls: [],
550
624
  annotations: [],
551
625
  finishReason: Option.none(),
@@ -560,6 +634,7 @@ export const emptyAccumulator: Accumulator = {
560
634
  export type ChunkPart =
561
635
  | { readonly kind: "text"; readonly text: string }
562
636
  | { readonly kind: "reasoning"; readonly text: string }
637
+ | { readonly kind: "image"; readonly source: ImageSource }
563
638
  | {
564
639
  readonly kind: "function_call"
565
640
  readonly id: Option.Option<string>
@@ -574,11 +649,20 @@ export type ChunkResult = {
574
649
  readonly finished: boolean
575
650
  }
576
651
 
577
- const isTextPart = (p: typeof Part.Type): p is typeof TextPart.Type => "text" in p
578
-
579
652
  const textToChunkParts = (p: typeof TextPart.Type): ReadonlyArray<ChunkPart> =>
580
653
  p.text.length === 0 ? [] : [{ kind: p.thought === true ? "reasoning" : "text", text: p.text }]
581
654
 
655
+ /** Interim images drawn while planning are dropped, as thought text is. */
656
+ const inlineDataToChunkParts = (p: WireInlineDataPart): ReadonlyArray<ChunkPart> =>
657
+ p.thought === true
658
+ ? []
659
+ : [
660
+ {
661
+ kind: "image",
662
+ source: imageBase64(p.inlineData.data, p.inlineData.mimeType ?? "image/png"),
663
+ },
664
+ ]
665
+
582
666
  const functionCallToChunkParts = (p: WireFunctionCallPart): ReadonlyArray<ChunkPart> => [
583
667
  {
584
668
  kind: "function_call",
@@ -589,8 +673,15 @@ const functionCallToChunkParts = (p: WireFunctionCallPart): ReadonlyArray<ChunkP
589
673
  },
590
674
  ]
591
675
 
592
- const partToChunkParts = (p: typeof Part.Type): ReadonlyArray<ChunkPart> =>
593
- isTextPart(p) ? textToChunkParts(p) : functionCallToChunkParts(p)
676
+ /** Parts are distinguished by which key they carry, not by a discriminator. */
677
+ const partToChunkParts: (p: typeof Part.Type) => ReadonlyArray<ChunkPart> = Match.type<
678
+ typeof Part.Type
679
+ >().pipe(
680
+ Match.when({ text: Match.string }, textToChunkParts),
681
+ Match.when({ inlineData: Match.defined }, inlineDataToChunkParts),
682
+ Match.when({ functionCall: Match.defined }, functionCallToChunkParts),
683
+ Match.exhaustive,
684
+ )
594
685
 
595
686
  const chunkParts = (chunk: WireChunk): ReadonlyArray<ChunkPart> =>
596
687
  pipe(chunk.candidates?.[0]?.content?.parts ?? [], Arr.flatMap(partToChunkParts))
@@ -601,6 +692,12 @@ const sumStrings = (parts: ReadonlyArray<ChunkPart>, kind: "text" | "reasoning")
601
692
  Arr.filterMap((p) => (p.kind === kind ? Result.succeed(p.text) : Result.failVoid)),
602
693
  ).join("")
603
694
 
695
+ const collectImages = (parts: ReadonlyArray<ChunkPart>): ReadonlyArray<ImageSource> =>
696
+ pipe(
697
+ parts,
698
+ Arr.filterMap((p) => (p.kind === "image" ? Result.succeed(p.source) : Result.failVoid)),
699
+ )
700
+
604
701
  const collectFunctionCalls = (
605
702
  parts: ReadonlyArray<ChunkPart>,
606
703
  ): ReadonlyArray<Extract<ChunkPart, { kind: "function_call" }>> =>
@@ -683,6 +780,7 @@ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult =>
683
780
  accumulator: {
684
781
  text: acc.text + sumStrings(parts, "text"),
685
782
  reasoning: acc.reasoning + sumStrings(parts, "reasoning"),
783
+ images: Arr.appendAll(acc.images, collectImages(parts)),
686
784
  functionCalls: appendFunctionCalls(acc.functionCalls, collectFunctionCalls(parts)),
687
785
  annotations: mergeAnnotations(
688
786
  acc.annotations,
@@ -697,22 +795,25 @@ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult =>
697
795
  const reasoningItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
698
796
  acc.reasoning.length > 0 ? [{ type: "reasoning", summary: acc.reasoning }] : []
699
797
 
700
- const assistantMessageItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
701
- acc.text.length === 0
702
- ? []
703
- : [
704
- {
705
- type: "message",
706
- role: "assistant",
707
- content: [
708
- {
709
- type: "output_text",
710
- text: acc.text,
711
- ...(acc.annotations.length > 0 && { annotations: acc.annotations }),
712
- },
713
- ],
714
- },
715
- ]
798
+ /**
799
+ * Image models interleave text and image parts, and often answer with an
800
+ * image and no prose at all, so the message exists when either is present.
801
+ */
802
+ const assistantMessageItems = (acc: Accumulator): ReadonlyArray<HistoryItem> => {
803
+ const content: ReadonlyArray<ContentBlock> = [
804
+ ...(acc.text.length === 0
805
+ ? []
806
+ : [
807
+ {
808
+ type: "output_text" as const,
809
+ text: acc.text,
810
+ ...(acc.annotations.length > 0 && { annotations: acc.annotations }),
811
+ },
812
+ ]),
813
+ ...acc.images.map((source) => ({ type: "output_image" as const, source })),
814
+ ]
815
+ return content.length === 0 ? [] : [{ type: "message", role: "assistant", content }]
816
+ }
716
817
 
717
818
  const functionCallItems = (acc: Accumulator): ReadonlyArray<HistoryItem> =>
718
819
  pipe(
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * as Gemini from "./Gemini.js"
2
2
  export * as GeminiTools from "./GeminiTools.js"
3
3
  export * as GeminiEmbedding from "./GeminiEmbedding.js"
4
+ export * as GeminiImageGenerator from "./GeminiImageGenerator.js"
4
5
  export * as GeminiSynthesizer from "./GeminiSynthesizer.js"
5
6
  export * as GoogleDeepResearch from "./GoogleDeepResearch.js"
6
7
  export * as LyriaGenerator from "./LyriaGenerator.js"
package/src/models.ts CHANGED
@@ -14,6 +14,30 @@ export type GoogleModel =
14
14
  | "gemini-2.5-pro"
15
15
  | "gemini-2.5-flash"
16
16
  | "gemini-2.5-flash-lite"
17
+ // Image models answer in a chat turn like any other, with pictures among
18
+ // the parts. See {@link GeminiImageModel}.
19
+ | "gemini-3.1-flash-image"
20
+ | "gemini-3.1-flash-lite-image"
21
+ | "gemini-3-pro-image"
22
+ // eslint-disable-next-line @typescript-eslint/ban-types
23
+ | (string & {})
24
+
25
+ /**
26
+ * Known Gemini image-generation model identifiers. The `-preview` ids
27
+ * were shut down 2026-06-25 and are not listed; neither is the
28
+ * previous-generation `gemini-2.5-flash-image`, which the `(string & {})`
29
+ * tail still accepts.
30
+ *
31
+ * - `gemini-3.1-flash-image` — 512 / 1K / 2K / 4K, web and image search.
32
+ * - `gemini-3.1-flash-lite-image` — 1K only, cheapest, no search.
33
+ * - `gemini-3-pro-image` — 1K / 2K / 4K, the most reference images.
34
+ *
35
+ * Reference: https://ai.google.dev/gemini-api/docs/models
36
+ */
37
+ export type GeminiImageModel =
38
+ | "gemini-3.1-flash-image"
39
+ | "gemini-3.1-flash-lite-image"
40
+ | "gemini-3-pro-image"
17
41
  // eslint-disable-next-line @typescript-eslint/ban-types
18
42
  | (string & {})
19
43