@effect-uai/google 0.12.1 → 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/Gemini.d.mts +15 -1
- package/dist/Gemini.d.mts.map +1 -1
- package/dist/Gemini.mjs +19 -42
- package/dist/Gemini.mjs.map +1 -1
- package/dist/GeminiImageGenerator.d.mts +149 -0
- package/dist/GeminiImageGenerator.d.mts.map +1 -0
- package/dist/GeminiImageGenerator.mjs +198 -0
- package/dist/GeminiImageGenerator.mjs.map +1 -0
- package/dist/LyriaGenerator.d.mts.map +1 -1
- package/dist/LyriaGenerator.mjs +3 -30
- package/dist/LyriaGenerator.mjs.map +1 -1
- package/dist/codec.d.mts +26 -2
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +87 -11
- package/dist/codec.mjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +2 -1
- package/dist/models.d.mts +15 -2
- package/dist/models.d.mts.map +1 -1
- package/package.json +6 -2
- package/src/Gemini.ts +37 -13
- package/src/GeminiImageGenerator.ts +390 -0
- package/src/LyriaGenerator.ts +6 -36
- package/src/codec.ts +122 -21
- package/src/index.ts +1 -0
- package/src/models.ts +24 -0
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
|
package/dist/models.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"models.d.mts","names":[],"sources":["../src/models.ts"],"mappings":";;;;;;;;KAOY;;;;;;;;;;
|
|
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.
|
|
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.
|
|
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(
|
|
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 = (
|
|
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
|
+
)
|
package/src/LyriaGenerator.ts
CHANGED
|
@@ -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 = (
|
|
202
|
-
new AiError.Unavailable({ provider: "lyria", raw: cause })
|
|
205
|
+
const transportFailure = sharedTransportFailure("lyria")
|
|
203
206
|
|
|
204
|
-
const httpStatusError
|
|
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), {
|