@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/dist/index.d.mts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { t as codec_d_exports } from "./codec.mjs";
2
2
  import { t as GeminiTools_d_exports } from "./GeminiTools.mjs";
3
- import { GeminiTtsModel, GeminiVoiceName, GoogleDeepResearchModel, GoogleEmbeddingModel, GoogleModel, LyriaModel } from "./models.mjs";
3
+ import { GeminiImageModel, GeminiTtsModel, GeminiVoiceName, GoogleDeepResearchModel, GoogleEmbeddingModel, GoogleModel, LyriaModel } from "./models.mjs";
4
4
  import { t as Gemini_d_exports } from "./Gemini.mjs";
5
5
  import { t as GeminiEmbedding_d_exports } from "./GeminiEmbedding.mjs";
6
+ import { t as GeminiImageGenerator_d_exports } from "./GeminiImageGenerator.mjs";
6
7
  import { t as GeminiSynthesizer_d_exports } from "./GeminiSynthesizer.mjs";
7
8
  import { t as GoogleDeepResearch_d_exports } from "./GoogleDeepResearch.mjs";
8
9
  import { t as LyriaGenerator_d_exports } from "./LyriaGenerator.mjs";
9
- export { Gemini_d_exports as Gemini, GeminiEmbedding_d_exports as GeminiEmbedding, GeminiSynthesizer_d_exports as GeminiSynthesizer, GeminiTools_d_exports as GeminiTools, GeminiTtsModel, GeminiVoiceName, GoogleDeepResearch_d_exports as GoogleDeepResearch, GoogleDeepResearchModel, GoogleEmbeddingModel, GoogleModel, LyriaGenerator_d_exports as LyriaGenerator, LyriaModel, codec_d_exports as codec };
10
+ export { Gemini_d_exports as Gemini, GeminiEmbedding_d_exports as GeminiEmbedding, GeminiImageGenerator_d_exports as GeminiImageGenerator, GeminiImageModel, GeminiSynthesizer_d_exports as GeminiSynthesizer, GeminiTools_d_exports as GeminiTools, GeminiTtsModel, GeminiVoiceName, GoogleDeepResearch_d_exports as GoogleDeepResearch, GoogleDeepResearchModel, GoogleEmbeddingModel, GoogleModel, LyriaGenerator_d_exports as LyriaGenerator, LyriaModel, codec_d_exports as codec };
package/dist/index.mjs CHANGED
@@ -2,8 +2,9 @@ import { t as GeminiTools_exports } from "./GeminiTools.mjs";
2
2
  import { t as codec_exports } from "./codec.mjs";
3
3
  import { t as Gemini_exports } from "./Gemini.mjs";
4
4
  import { t as GeminiEmbedding_exports } from "./GeminiEmbedding.mjs";
5
+ import { t as GeminiImageGenerator_exports } from "./GeminiImageGenerator.mjs";
5
6
  import { t as GeminiSynthesizer_exports } from "./GeminiSynthesizer.mjs";
6
7
  import { t as GoogleDeepResearch_exports } from "./GoogleDeepResearch.mjs";
7
8
  import { t as LyriaGenerator_exports } from "./LyriaGenerator.mjs";
8
9
  import "./models.mjs";
9
- export { Gemini_exports as Gemini, GeminiEmbedding_exports as GeminiEmbedding, GeminiSynthesizer_exports as GeminiSynthesizer, GeminiTools_exports as GeminiTools, GoogleDeepResearch_exports as GoogleDeepResearch, LyriaGenerator_exports as LyriaGenerator, codec_exports as codec };
10
+ export { Gemini_exports as Gemini, GeminiEmbedding_exports as GeminiEmbedding, GeminiImageGenerator_exports as GeminiImageGenerator, GeminiSynthesizer_exports as GeminiSynthesizer, GeminiTools_exports as GeminiTools, GoogleDeepResearch_exports as GoogleDeepResearch, LyriaGenerator_exports as LyriaGenerator, codec_exports as codec };
package/dist/models.d.mts CHANGED
@@ -6,7 +6,20 @@
6
6
  *
7
7
  * Reference: https://ai.google.dev/gemini-api/docs/models
8
8
  */
9
- type GoogleModel = "gemini-3.1-pro-preview" | "gemini-3-flash-preview" | "gemini-3.1-flash-lite-preview" | "gemini-3.1-flash-live-preview" | "gemini-3.1-flash-tts-preview" | "gemini-2.5-pro" | "gemini-2.5-flash" | "gemini-2.5-flash-lite" | (string & {});
9
+ type GoogleModel = "gemini-3.1-pro-preview" | "gemini-3-flash-preview" | "gemini-3.1-flash-lite-preview" | "gemini-3.1-flash-live-preview" | "gemini-3.1-flash-tts-preview" | "gemini-2.5-pro" | "gemini-2.5-flash" | "gemini-2.5-flash-lite" | "gemini-3.1-flash-image" | "gemini-3.1-flash-lite-image" | "gemini-3-pro-image" | (string & {});
10
+ /**
11
+ * Known Gemini image-generation model identifiers. The `-preview` ids
12
+ * were shut down 2026-06-25 and are not listed; neither is the
13
+ * previous-generation `gemini-2.5-flash-image`, which the `(string & {})`
14
+ * tail still accepts.
15
+ *
16
+ * - `gemini-3.1-flash-image` — 512 / 1K / 2K / 4K, web and image search.
17
+ * - `gemini-3.1-flash-lite-image` — 1K only, cheapest, no search.
18
+ * - `gemini-3-pro-image` — 1K / 2K / 4K, the most reference images.
19
+ *
20
+ * Reference: https://ai.google.dev/gemini-api/docs/models
21
+ */
22
+ type GeminiImageModel = "gemini-3.1-flash-image" | "gemini-3.1-flash-lite-image" | "gemini-3-pro-image" | (string & {});
10
23
  /**
11
24
  * Deep-research agent identifiers for the Interactions API (`POST
12
25
  * /v1beta/interactions`). `-preview` is the fast tier, `-max-` the
@@ -58,5 +71,5 @@ type GeminiVoiceName = "Zephyr" | "Puck" | "Charon" | "Kore" | "Fenrir" | "Leda"
58
71
  type LyriaModel = "lyria-3-clip-preview" | "lyria-3-pro-preview" | (string & {});
59
72
  type GoogleEmbeddingModel = "gemini-embedding-2" | "gemini-embedding-001" | (string & {});
60
73
  //#endregion
61
- export { GeminiTtsModel, GeminiVoiceName, GoogleDeepResearchModel, GoogleEmbeddingModel, GoogleModel, LyriaModel };
74
+ export { GeminiImageModel, GeminiTtsModel, GeminiVoiceName, GoogleDeepResearchModel, GoogleEmbeddingModel, GoogleModel, LyriaModel };
62
75
  //# sourceMappingURL=models.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"models.d.mts","names":[],"sources":["../src/models.ts"],"mappings":";;;;;;;;KAOY;;;;;;;;;;KAqBA;;;;;;;;;;;;;;;;;;;;;KA0BA;;;;;;;;KAcA;;;;;;;;;;KAyCA;KAMA"}
1
+ {"version":3,"file":"models.d.mts","names":[],"sources":["../src/models.ts"],"mappings":";;;;;;;;KAOY;;;;;;;;;;;;;KA6BA;;;;;;;;;;KAgBA;;;;;;;;;;;;;;;;;;;;;KA0BA;;;;;;;;KAcA;;;;;;;;;;KAyCA;KAMA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/google",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Google Gemini provider for @effect-uai/core.",
5
5
  "keywords": [
6
6
  "ai",
@@ -42,6 +42,10 @@
42
42
  "types": "./dist/GeminiEmbedding.d.mts",
43
43
  "import": "./dist/GeminiEmbedding.mjs"
44
44
  },
45
+ "./GeminiImageGenerator": {
46
+ "types": "./dist/GeminiImageGenerator.d.mts",
47
+ "import": "./dist/GeminiImageGenerator.mjs"
48
+ },
45
49
  "./GeminiSynthesizer": {
46
50
  "types": "./dist/GeminiSynthesizer.d.mts",
47
51
  "import": "./dist/GeminiSynthesizer.mjs"
@@ -61,7 +65,7 @@
61
65
  "devDependencies": {
62
66
  "effect": "4.0.0-rc.111",
63
67
  "typescript": "^7.0.2",
64
- "@effect-uai/core": "0.13.0"
68
+ "@effect-uai/core": "0.14.0"
65
69
  },
66
70
  "peerDependencies": {
67
71
  "@effect-uai/core": ">=0.2.0 <1",
package/src/Gemini.ts CHANGED
@@ -17,11 +17,13 @@ export { codeExecutionTool, googleSearchTool, urlContextTool } from "./GeminiToo
17
17
  import {
18
18
  type ChunkPart,
19
19
  type GenerationConfig,
20
+ type ImageConfig,
20
21
  WireChunk,
21
22
  accumulatorToTurn,
22
23
  buildRequestBody,
23
24
  emptyAccumulator,
24
25
  hasUrlImageSource,
26
+ httpStatusError as sharedHttpStatusError,
25
27
  ingestChunk,
26
28
  } from "./codec.js"
27
29
  import { Match } from "effect"
@@ -52,8 +54,25 @@ export type GeminiRequest = Omit<CommonRequest, "model"> & {
52
54
  * `generationConfig.thinkingConfig.thinkingBudget`.
53
55
  */
54
56
  readonly thinkingBudget?: number
57
+ /**
58
+ * What the model may answer with. Image models default to
59
+ * `["TEXT", "IMAGE"]`; set this to ask a non-image model for images, or
60
+ * to hold an image model to prose.
61
+ */
62
+ readonly responseModalities?: ReadonlyArray<"TEXT" | "IMAGE">
63
+ /** Shape and size of images the model draws. Image models only. */
64
+ readonly imageConfig?: ImageConfig
55
65
  }
56
66
 
67
+ /**
68
+ * Image models answer in prose unless the IMAGE modality is asked for,
69
+ * which is a silent disappointment rather than an error, so the adapter
70
+ * asks on their behalf. Detection is Google's naming convention, which
71
+ * holds across the family and keeps a future id working without an SDK
72
+ * update; an explicit `responseModalities` always wins.
73
+ */
74
+ const drawsImages = (model: string): boolean => model.endsWith("-image")
75
+
57
76
  export type GeminiService = {
58
77
  /**
59
78
  * Stream the provider's native event vocabulary (post-SSE-decode). For
@@ -103,7 +122,11 @@ export type Config = {
103
122
  // Request body
104
123
  // ---------------------------------------------------------------------------
105
124
 
125
+ const TEXT_AND_IMAGE: ReadonlyArray<"TEXT" | "IMAGE"> = ["TEXT", "IMAGE"]
126
+
106
127
  const buildGenerationConfig = (request: GeminiRequest): Option.Option<GenerationConfig> => {
128
+ const modalities =
129
+ request.responseModalities ?? (drawsImages(request.model) ? TEXT_AND_IMAGE : undefined)
107
130
  const cfg: GenerationConfig = {
108
131
  ...(request.temperature !== undefined && { temperature: request.temperature }),
109
132
  ...(request.maxOutputTokens !== undefined && { maxOutputTokens: request.maxOutputTokens }),
@@ -111,6 +134,8 @@ const buildGenerationConfig = (request: GeminiRequest): Option.Option<Generation
111
134
  ...(request.thinkingBudget !== undefined && {
112
135
  thinkingConfig: { thinkingBudget: request.thinkingBudget },
113
136
  }),
137
+ ...(modalities !== undefined && { responseModalities: modalities }),
138
+ ...(request.imageConfig !== undefined && { imageConfig: request.imageConfig }),
114
139
  ...(request.structured !== undefined && {
115
140
  responseMimeType: "application/json",
116
141
  responseJsonSchema: request.structured.schema["~standard"].jsonSchema.input({
@@ -139,25 +164,21 @@ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema
139
164
  const sseEventToChunk = (ev: SSE.Event) =>
140
165
  parseJsonUnknown(ev.data).pipe(
141
166
  Effect.mapError((cause) => new JsonParseError({ line: ev.data, cause })),
142
- Effect.flatMap(decodeChunk),
167
+ Effect.flatMap((json) =>
168
+ // A `SchemaError` alone says nothing about which part shape we failed
169
+ // to read, and image models add part shapes text-only ones never send.
170
+ // Carry the line so the mismatch is legible.
171
+ decodeChunk(json).pipe(
172
+ Effect.mapError((cause) => new JsonParseError({ line: ev.data.slice(0, 2000), cause })),
173
+ ),
174
+ ),
143
175
  )
144
176
 
145
177
  // ---------------------------------------------------------------------------
146
178
  // Service implementation
147
179
  // ---------------------------------------------------------------------------
148
180
 
149
- const httpStatusError = (status: number, body: string): AiError.AiError => {
150
- const provider = "gemini"
151
- const raw = body
152
- if (status === 429) return new AiError.RateLimited({ provider, raw })
153
- if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
154
- if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
155
- if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
156
- if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
157
- if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
158
- if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
159
- return new AiError.InvalidRequest({ provider, raw })
160
- }
181
+ const httpStatusError = sharedHttpStatusError("gemini")
161
182
 
162
183
  const buildNativeStream = (cfg: Config) => {
163
184
  const baseUrl = cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
@@ -256,6 +277,9 @@ const partToTurnEvents = (
256
277
  reasoning: (p): ReadonlyArray<TurnEvent> => [
257
278
  TurnEvent.ReasoningDelta({ text: p.text, kind: "trace" }),
258
279
  ],
280
+ // Gemini sends the image whole, so there is no `partialIndex`: the
281
+ // one event that arrives is the finished picture.
282
+ image: (p): ReadonlyArray<TurnEvent> => [TurnEvent.ImageOutput({ image: p.source })],
259
283
  function_call: (p): ReadonlyArray<TurnEvent> => {
260
284
  const call_id = callIdAt(p.name, 0)
261
285
  return [
@@ -0,0 +1,390 @@
1
+ import { Context, Effect, Encoding, Layer, Match, Redacted, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import * as Capabilities from "@effect-uai/core/Capabilities"
5
+ import type {
6
+ GeneratedImage,
7
+ ImageBase64Source,
8
+ ImageBytesSource,
9
+ ImageSource,
10
+ } from "@effect-uai/core/Image"
11
+ import { imageBase64 } from "@effect-uai/core/Image"
12
+ import type {
13
+ CommonImageEditRequest,
14
+ CommonImageGenerateRequest,
15
+ CommonStreamImageEditRequest,
16
+ CommonStreamImageRequest,
17
+ ImageGeneratorService,
18
+ ImageResponse,
19
+ ImageStreamEvent,
20
+ ImageUsage,
21
+ } from "@effect-uai/core/ImageGenerator"
22
+ import { ImageGenerator } from "@effect-uai/core/ImageGenerator"
23
+ import { httpStatusError, transportFailure } from "./codec.js"
24
+ import type { GeminiImageModel } from "./models.js"
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Wire knobs `generateContent` accepts alongside the portable request. */
31
+ type GeminiImageKnobs = {
32
+ /**
33
+ * Sent as `imageConfig.imageSize`. Takes precedence over `resolution`,
34
+ * which is then warn-dropped. `"512"` is Flash-only.
35
+ */
36
+ readonly imageSize?: "512" | "1K" | "2K" | "4K"
37
+ /** Thinking is always on for image models; this only sets how long. */
38
+ readonly thinkingLevel?: "minimal" | "low" | "medium" | "high"
39
+ /** Ground the image in web results. Flash and Pro only. */
40
+ readonly googleSearch?: boolean
41
+ }
42
+
43
+ export type GeminiImageGenerateRequest = Omit<CommonImageGenerateRequest, "model"> & {
44
+ readonly model: GeminiImageModel
45
+ } & GeminiImageKnobs
46
+
47
+ /**
48
+ * Reference images for an edit. Narrowed off `ImageSource`: references
49
+ * ride in the request body as base64 `inlineData`, so a `url` variant has
50
+ * no wire representation short of pre-uploading through the Files API.
51
+ * A compile-time error rather than a 400; fetch it yourself and pass the
52
+ * bytes.
53
+ */
54
+ export type GeminiImageRef = ImageBase64Source | ImageBytesSource
55
+
56
+ export type GeminiImageEditRequest = Omit<CommonImageEditRequest, "model" | "images"> & {
57
+ readonly model: GeminiImageModel
58
+ /** 14 total, split between object / character / style refs per model. */
59
+ readonly images: ReadonlyArray<GeminiImageRef>
60
+ } & GeminiImageKnobs
61
+
62
+ export type GeminiImageGeneratorService = {
63
+ readonly generate: (
64
+ request: GeminiImageGenerateRequest,
65
+ ) => Effect.Effect<ImageResponse, AiError.AiError>
66
+ readonly edit: (request: GeminiImageEditRequest) => Effect.Effect<ImageResponse, AiError.AiError>
67
+ /** Both always `Unsupported`: `ImageStreaming` is not registered by {@link layer}. */
68
+ readonly streamGeneration: ImageGeneratorService["streamGeneration"]
69
+ readonly streamEdit: ImageGeneratorService["streamEdit"]
70
+ }
71
+
72
+ /**
73
+ * Provider-typed service tag. Yield this for `imageSize`, `thinkingLevel`
74
+ * and search grounding; yield the generic `ImageGenerator` tag for
75
+ * provider-portable code. Both are registered by {@link layer}.
76
+ */
77
+ export class GeminiImageGenerator extends Context.Service<
78
+ GeminiImageGenerator,
79
+ GeminiImageGeneratorService
80
+ >()("@betalyra/effect-uai/providers/google/GeminiImageGenerator") {}
81
+
82
+ export type Config = {
83
+ readonly apiKey: Redacted.Redacted
84
+ readonly baseUrl?: string
85
+ }
86
+
87
+ const PROVIDER = "gemini"
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Codec - request
91
+ //
92
+ // Both ratio and tier are native here, so nothing is derived. Whether a
93
+ // given model accepts the pair is the server's call: per-model limits
94
+ // churn and we do not table them.
95
+ // ---------------------------------------------------------------------------
96
+
97
+ type ImageConfig = {
98
+ readonly aspectRatio?: string
99
+ readonly imageSize?: string
100
+ }
101
+
102
+ type RequestPart =
103
+ | { readonly text: string }
104
+ | { readonly inlineData: { readonly mimeType: string; readonly data: string } }
105
+
106
+ export type WireBody = {
107
+ readonly contents: ReadonlyArray<{
108
+ readonly role: "user"
109
+ readonly parts: ReadonlyArray<RequestPart>
110
+ }>
111
+ readonly generationConfig: {
112
+ readonly responseModalities: ReadonlyArray<"TEXT" | "IMAGE">
113
+ readonly imageConfig?: ImageConfig
114
+ readonly thinkingConfig?: { readonly thinkingLevel: string }
115
+ }
116
+ readonly tools?: ReadonlyArray<{ readonly googleSearch: {} }>
117
+ }
118
+
119
+ const urlRefNotSupported: AiError.AiError = new AiError.InvalidRequest({
120
+ provider: PROVIDER,
121
+ param: "images",
122
+ raw: 'Reference images ride in the request body as base64, so a URL reference cannot be sent without pre-uploading it through the Files API. Fetch the URL yourself and pass `{ _tag: "bytes", bytes, mimeType }`.',
123
+ })
124
+
125
+ const referencePart: (image: ImageSource) => Effect.Effect<RequestPart, AiError.AiError> =
126
+ Match.type<ImageSource>().pipe(
127
+ Match.tag("base64", (i) =>
128
+ Effect.succeed<RequestPart>({ inlineData: { mimeType: i.mimeType, data: i.base64 } }),
129
+ ),
130
+ Match.tag("bytes", (i) =>
131
+ Effect.succeed<RequestPart>({
132
+ inlineData: { mimeType: i.mimeType, data: Encoding.encodeBase64(i.bytes) },
133
+ }),
134
+ ),
135
+ Match.tag("url", () => Effect.fail(urlRefNotSupported)),
136
+ Match.exhaustive,
137
+ )
138
+
139
+ /**
140
+ * More than one image per call is bucket 1: `candidateCount` is a generic
141
+ * `generationConfig` field whose behaviour on image models is undocumented,
142
+ * and silently returning one image where several were asked for is a
143
+ * different result, not a smaller one.
144
+ */
145
+ const rejectMultiple = (n: number | undefined): Effect.Effect<void, AiError.AiError> =>
146
+ n === undefined || n <= 1
147
+ ? Effect.void
148
+ : Effect.fail(
149
+ new AiError.Unsupported({
150
+ provider: PROVIDER,
151
+ capability: "n",
152
+ reason: `Gemini image models return one image per call; \`n: ${n}\` has no wire field. Run the request ${n} times, concurrently if you want them at once.`,
153
+ }),
154
+ )
155
+
156
+ /** The typed `imageSize` wins over the portable tier, which is then dropped. */
157
+ export const imageSizeOf = (
158
+ request: Pick<GeminiImageGenerateRequest, "imageSize" | "resolution">,
159
+ ): Effect.Effect<string | undefined> =>
160
+ request.imageSize === undefined
161
+ ? Effect.succeed(request.resolution)
162
+ : Effect.as(
163
+ Capabilities.warnDroppedWhen(request.resolution, {
164
+ provider: PROVIDER,
165
+ capability: "resolution",
166
+ field: "resolution",
167
+ reason: `\`imageSize: "${request.imageSize}"\` sets the tier directly.`,
168
+ }),
169
+ request.imageSize,
170
+ )
171
+
172
+ export const buildBody = (
173
+ request: GeminiImageGenerateRequest,
174
+ images: ReadonlyArray<ImageSource>,
175
+ ): Effect.Effect<WireBody, AiError.AiError> =>
176
+ Effect.gen(function* () {
177
+ yield* rejectMultiple(request.n)
178
+ const imageSize = yield* imageSizeOf(request)
179
+ const refs = yield* Effect.forEach(images, referencePart)
180
+ const imageConfig: ImageConfig = {
181
+ ...(request.aspectRatio !== undefined && { aspectRatio: request.aspectRatio }),
182
+ ...(imageSize !== undefined && { imageSize }),
183
+ }
184
+ return {
185
+ contents: [{ role: "user", parts: [{ text: request.prompt }, ...refs] }],
186
+ generationConfig: {
187
+ responseModalities: ["TEXT", "IMAGE"],
188
+ ...(Object.keys(imageConfig).length > 0 && { imageConfig }),
189
+ ...(request.thinkingLevel !== undefined && {
190
+ thinkingConfig: { thinkingLevel: request.thinkingLevel.toUpperCase() },
191
+ }),
192
+ },
193
+ ...(request.googleSearch === true && { tools: [{ googleSearch: {} }] }),
194
+ }
195
+ })
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Codec - response
199
+ // ---------------------------------------------------------------------------
200
+
201
+ const InlineData = Schema.Struct({
202
+ mimeType: Schema.optional(Schema.String),
203
+ data: Schema.String,
204
+ })
205
+
206
+ const Part = Schema.Struct({
207
+ text: Schema.optional(Schema.String),
208
+ inlineData: Schema.optional(InlineData),
209
+ /** Interim images the model drew while thinking. Not the answer. */
210
+ thought: Schema.optional(Schema.Boolean),
211
+ })
212
+
213
+ const Candidate = Schema.Struct({
214
+ content: Schema.optional(Schema.Struct({ parts: Schema.optional(Schema.Array(Part)) })),
215
+ finishReason: Schema.optional(Schema.String),
216
+ })
217
+
218
+ const UsageMetadata = Schema.Struct({
219
+ promptTokenCount: Schema.optional(Schema.Number),
220
+ candidatesTokenCount: Schema.optional(Schema.Number),
221
+ totalTokenCount: Schema.optional(Schema.Number),
222
+ })
223
+ type UsageMetadata = typeof UsageMetadata.Type
224
+
225
+ const Wire = Schema.Struct({
226
+ candidates: Schema.optional(Schema.Array(Candidate)),
227
+ promptFeedback: Schema.optional(Schema.Struct({ blockReason: Schema.optional(Schema.String) })),
228
+ usageMetadata: Schema.optional(UsageMetadata),
229
+ })
230
+ type Wire = typeof Wire.Type
231
+
232
+ export const decodeWire = Schema.decodeUnknownEffect(Wire)
233
+
234
+ /**
235
+ * Finish reasons that are a content decision rather than a failure. The
236
+ * generic safety codes appear when the prompt trips a filter, the
237
+ * `IMAGE_`-prefixed ones when the drawn image does. `NO_IMAGE` and
238
+ * `IMAGE_OTHER` are not here: they say the model produced nothing, not
239
+ * that it refused.
240
+ */
241
+ const FILTERED_FINISH_REASONS: ReadonlySet<string> = new Set([
242
+ "SAFETY",
243
+ "RECITATION",
244
+ "BLOCKLIST",
245
+ "PROHIBITED_CONTENT",
246
+ "SPII",
247
+ "IMAGE_SAFETY",
248
+ "IMAGE_PROHIBITED_CONTENT",
249
+ "IMAGE_RECITATION",
250
+ ])
251
+
252
+ const usageOf = (usage: UsageMetadata | undefined): ImageUsage => ({
253
+ ...(usage?.promptTokenCount !== undefined && { inputTokens: usage.promptTokenCount }),
254
+ ...(usage?.candidatesTokenCount !== undefined && { outputTokens: usage.candidatesTokenCount }),
255
+ ...(usage?.totalTokenCount !== undefined && { totalTokens: usage.totalTokenCount }),
256
+ })
257
+
258
+ /** Every Gemini image carries SynthID; Lite adds a C2PA manifest on top. */
259
+ const generatedImage = (inline: typeof InlineData.Type): GeneratedImage => ({
260
+ image: imageBase64(inline.data, inline.mimeType ?? "image/png"),
261
+ watermark: "synthid",
262
+ })
263
+
264
+ export const toResponse = (wire: Wire): Effect.Effect<ImageResponse, AiError.AiError> => {
265
+ const blockReason = wire.promptFeedback?.blockReason
266
+ const finishReason = wire.candidates?.[0]?.finishReason
267
+ // A prompt-level block names itself; an output-level one shows up as the
268
+ // candidate's finish reason with no parts attached.
269
+ const filtered =
270
+ blockReason ??
271
+ (finishReason !== undefined && FILTERED_FINISH_REASONS.has(finishReason)
272
+ ? finishReason
273
+ : undefined)
274
+ if (filtered !== undefined) {
275
+ return Effect.fail(
276
+ new AiError.ContentFiltered({ provider: PROVIDER, reason: filtered, raw: wire }),
277
+ )
278
+ }
279
+ const images = (wire.candidates ?? [])
280
+ .flatMap((candidate) => candidate.content?.parts ?? [])
281
+ .flatMap((part) =>
282
+ part.thought === true || part.inlineData === undefined
283
+ ? []
284
+ : [generatedImage(part.inlineData)],
285
+ )
286
+ return images.length === 0
287
+ ? Effect.fail(
288
+ new AiError.GenerationFailed({
289
+ provider: PROVIDER,
290
+ ...(finishReason !== undefined && { code: finishReason }),
291
+ message: "Gemini returned no image part.",
292
+ raw: wire,
293
+ }),
294
+ )
295
+ : Effect.succeed({ images, usage: usageOf(wire.usageMetadata) })
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // HTTP
300
+ // ---------------------------------------------------------------------------
301
+
302
+ const host = (cfg: Config): string =>
303
+ cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
304
+
305
+ const requestImages = (
306
+ cfg: Config,
307
+ request: GeminiImageGenerateRequest,
308
+ images: ReadonlyArray<ImageSource>,
309
+ ): Effect.Effect<ImageResponse, AiError.AiError, HttpClient.HttpClient> =>
310
+ Effect.gen(function* () {
311
+ const client = yield* HttpClient.HttpClient
312
+ const body = yield* buildBody(request, images)
313
+ const response = yield* client
314
+ .execute(
315
+ HttpClientRequest.post(`${host(cfg)}/models/${request.model}:generateContent`).pipe(
316
+ HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
317
+ HttpClientRequest.bodyJsonUnsafe(body),
318
+ ),
319
+ )
320
+ .pipe(Effect.mapError(transportFailure(PROVIDER)))
321
+ if (response.status >= 400) {
322
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
323
+ return yield* httpStatusError(PROVIDER)(response.status, text)
324
+ }
325
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure(PROVIDER)))
326
+ const wire = yield* decodeWire(json).pipe(Effect.mapError(transportFailure(PROVIDER)))
327
+ return yield* toResponse(wire)
328
+ })
329
+
330
+ /**
331
+ * `generateContent` has no partial-image wire, so this never succeeds and
332
+ * {@link layer} does not register `ImageStreaming`. Code calling the
333
+ * top-level `streamGeneration` helper against this Layer alone fails to
334
+ * typecheck, which is the intended UX; this branch covers the service
335
+ * being used directly.
336
+ */
337
+ const streamUnsupported = (capability: string): Stream.Stream<ImageStreamEvent, AiError.AiError> =>
338
+ Stream.fail(
339
+ new AiError.Unsupported({
340
+ provider: PROVIDER,
341
+ capability,
342
+ reason:
343
+ "Gemini image models have no partial-image wire; the image arrives whole. Use `generate` or `edit` and render when it resolves.",
344
+ }),
345
+ )
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Constructors
349
+ // ---------------------------------------------------------------------------
350
+
351
+ export const make = (
352
+ cfg: Config,
353
+ ): Effect.Effect<GeminiImageGeneratorService, never, HttpClient.HttpClient> =>
354
+ Effect.map(HttpClient.HttpClient, (client): GeminiImageGeneratorService => ({
355
+ generate: (request) =>
356
+ requestImages(cfg, request, []).pipe(Effect.provideService(HttpClient.HttpClient, client)),
357
+ edit: (request) =>
358
+ requestImages(cfg, request, request.images).pipe(
359
+ Effect.provideService(HttpClient.HttpClient, client),
360
+ ),
361
+ streamGeneration: () => streamUnsupported("streamGeneration"),
362
+ streamEdit: () => streamUnsupported("streamEdit"),
363
+ }))
364
+
365
+ /**
366
+ * Layer registering the provider-typed tag and the generic
367
+ * `ImageGenerator`. A `CommonImageGenerateRequest` is structurally a
368
+ * `GeminiImageGenerateRequest` with no vendor knobs set, so the generic
369
+ * registration forwards directly; a URL reference slipping through the
370
+ * generic `edit` fails `InvalidRequest` at encode time.
371
+ *
372
+ * Does NOT register `ImageStreaming`.
373
+ */
374
+ export const layer = (
375
+ cfg: Config,
376
+ ): Layer.Layer<GeminiImageGenerator | ImageGenerator, never, HttpClient.HttpClient> =>
377
+ Layer.merge(
378
+ Layer.effect(GeminiImageGenerator, make(cfg)),
379
+ Layer.effect(
380
+ ImageGenerator,
381
+ Effect.map(make(cfg), (s): ImageGeneratorService => ({
382
+ generate: (request: CommonImageGenerateRequest) =>
383
+ s.generate(request as GeminiImageGenerateRequest),
384
+ edit: (request: CommonImageEditRequest) => s.edit(request as GeminiImageEditRequest),
385
+ streamGeneration: (_request: CommonStreamImageRequest) =>
386
+ streamUnsupported("streamGeneration"),
387
+ streamEdit: (_request: CommonStreamImageEditRequest) => streamUnsupported("streamEdit"),
388
+ })),
389
+ ),
390
+ )
@@ -24,6 +24,10 @@ import type {
24
24
  } from "@effect-uai/core/Music"
25
25
  import { singleVariant } from "@effect-uai/core/Music"
26
26
  import { MusicGenerator, type MusicGeneratorService } from "@effect-uai/core/MusicGenerator"
27
+ import {
28
+ httpStatusError as sharedHttpStatusError,
29
+ transportFailure as sharedTransportFailure,
30
+ } from "./codec.js"
27
31
  import type { LyriaModel } from "./models.js"
28
32
 
29
33
  // ---------------------------------------------------------------------------
@@ -198,43 +202,9 @@ const decodeWire = Schema.decodeUnknownEffect(Wire)
198
202
  const baseUrl = (cfg: Config): string =>
199
203
  cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
200
204
 
201
- const transportFailure = (cause: unknown): AiError.AiError =>
202
- new AiError.Unavailable({ provider: "lyria", raw: cause })
205
+ const transportFailure = sharedTransportFailure("lyria")
203
206
 
204
- const httpStatusError: (status: number, body: string) => AiError.AiError = (status, body) =>
205
- Match.value(status).pipe(
206
- Match.when(
207
- 429,
208
- (): AiError.AiError => new AiError.RateLimited({ provider: "lyria", raw: body }),
209
- ),
210
- Match.whenOr(
211
- 408,
212
- 504,
213
- (): AiError.AiError => new AiError.Timeout({ provider: "lyria", raw: body }),
214
- ),
215
- Match.when(
216
- 401,
217
- (): AiError.AiError =>
218
- new AiError.AuthFailed({ provider: "lyria", subtype: "auth", raw: body }),
219
- ),
220
- Match.when(
221
- 403,
222
- (): AiError.AiError =>
223
- new AiError.AuthFailed({ provider: "lyria", subtype: "permission", raw: body }),
224
- ),
225
- Match.when(
226
- 402,
227
- (): AiError.AiError =>
228
- new AiError.AuthFailed({ provider: "lyria", subtype: "billing", raw: body }),
229
- ),
230
- Match.when(
231
- (n) => n >= 500,
232
- (n): AiError.AiError => new AiError.Unavailable({ provider: "lyria", status: n, raw: body }),
233
- ),
234
- Match.orElse(
235
- (): AiError.AiError => new AiError.InvalidRequest({ provider: "lyria", raw: body }),
236
- ),
237
- )
207
+ const httpStatusError = sharedHttpStatusError("lyria")
238
208
 
239
209
  const decodeBase64ToBytes = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
240
210
  Result.match(Encoding.decodeBase64(b64), {