@effect-uai/google 0.2.0 → 0.3.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 +105 -0
- package/dist/Gemini.d.mts.map +1 -0
- package/dist/Gemini.mjs +170 -0
- package/dist/Gemini.mjs.map +1 -0
- package/dist/GeminiEmbedding.d.mts +64 -0
- package/dist/GeminiEmbedding.d.mts.map +1 -0
- package/dist/GeminiEmbedding.mjs +167 -0
- package/dist/GeminiEmbedding.mjs.map +1 -0
- package/dist/codec.d.mts +107 -0
- package/dist/codec.d.mts.map +1 -0
- package/dist/codec.mjs +151 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/index.d.mts +5 -214
- package/dist/index.mjs +5 -289
- package/dist/models.d.mts +27 -0
- package/dist/models.d.mts.map +1 -0
- package/dist/models.mjs +1 -0
- package/package.json +20 -12
- package/src/Gemini.ts +3 -3
- package/src/GeminiEmbedding.ts +351 -0
- package/src/codec.ts +42 -35
- package/src/index.ts +3 -2
- package/src/models.ts +20 -0
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { Context, Effect, Encoding, Layer, Match, Redacted, Schema } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
4
|
+
import type { Embedding, EmbedContentPart, EmbedInput, Usage } from "@effect-uai/core/Embedding"
|
|
5
|
+
import {
|
|
6
|
+
type CommonEmbedManyRequest,
|
|
7
|
+
type CommonEmbedRequest,
|
|
8
|
+
EmbeddingModel,
|
|
9
|
+
type EmbeddingModelService,
|
|
10
|
+
type EmbedManyResponse,
|
|
11
|
+
type EmbedResponse,
|
|
12
|
+
} from "@effect-uai/core/EmbeddingModel"
|
|
13
|
+
import type { ImageSource } from "@effect-uai/core/Image"
|
|
14
|
+
import type { GoogleEmbeddingModel } from "./models.js"
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Public types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Gemini's task-type enum, exposed under our friendly names. Mapped to the
|
|
22
|
+
* wire `taskType` values inside the codec. Honoured by
|
|
23
|
+
* `gemini-embedding-001`; ignored by `gemini-embedding-2` (which expects
|
|
24
|
+
* task instructions in the prompt text instead).
|
|
25
|
+
*/
|
|
26
|
+
export type GoogleEmbeddingTask =
|
|
27
|
+
| "query"
|
|
28
|
+
| "document"
|
|
29
|
+
| "similarity"
|
|
30
|
+
| "classification"
|
|
31
|
+
| "clustering"
|
|
32
|
+
| "qa"
|
|
33
|
+
| "fact_verification"
|
|
34
|
+
| "code_query"
|
|
35
|
+
|
|
36
|
+
export type GeminiEmbedRequest = Omit<CommonEmbedRequest, "model" | "task" | "encoding"> & {
|
|
37
|
+
/** Narrows `CommonEmbedRequest.model` to the typed Google union. */
|
|
38
|
+
readonly model: GoogleEmbeddingModel
|
|
39
|
+
/**
|
|
40
|
+
* Widens the cross-provider `"query" | "document"` to the full Google
|
|
41
|
+
* task enum. Optional. Honoured by `gemini-embedding-001`; ignored by
|
|
42
|
+
* `gemini-embedding-2`.
|
|
43
|
+
*/
|
|
44
|
+
readonly task?: GoogleEmbeddingTask
|
|
45
|
+
/**
|
|
46
|
+
* Optional document title. Only meaningful for `RETRIEVAL_DOCUMENT`
|
|
47
|
+
* tasks on `gemini-embedding-001`.
|
|
48
|
+
*/
|
|
49
|
+
readonly title?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type GeminiEmbedManyRequest = Omit<GeminiEmbedRequest, "input"> & {
|
|
53
|
+
readonly inputs: ReadonlyArray<EmbedInput>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type GeminiEmbeddingService = {
|
|
57
|
+
readonly embed: (request: GeminiEmbedRequest) => Effect.Effect<EmbedResponse, AiError.AiError>
|
|
58
|
+
readonly embedMany: (
|
|
59
|
+
request: GeminiEmbedManyRequest,
|
|
60
|
+
) => Effect.Effect<EmbedManyResponse, AiError.AiError>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Provider-typed service tag. Yield this when you want Gemini-specific
|
|
65
|
+
* options (full `task` enum, `title`); yield the generic `EmbeddingModel`
|
|
66
|
+
* tag for provider-portable code. Both are registered by `layer`.
|
|
67
|
+
*/
|
|
68
|
+
export class GeminiEmbedding extends Context.Service<GeminiEmbedding, GeminiEmbeddingService>()(
|
|
69
|
+
"@betalyra/effect-uai/providers/google/GeminiEmbedding",
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
export type Config = {
|
|
73
|
+
readonly apiKey: Redacted.Redacted
|
|
74
|
+
readonly baseUrl?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Codec - request body
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const taskToWire: Record<GoogleEmbeddingTask, string> = {
|
|
82
|
+
query: "RETRIEVAL_QUERY",
|
|
83
|
+
document: "RETRIEVAL_DOCUMENT",
|
|
84
|
+
similarity: "SEMANTIC_SIMILARITY",
|
|
85
|
+
classification: "CLASSIFICATION",
|
|
86
|
+
clustering: "CLUSTERING",
|
|
87
|
+
qa: "QUESTION_ANSWERING",
|
|
88
|
+
fact_verification: "FACT_VERIFICATION",
|
|
89
|
+
code_query: "CODE_RETRIEVAL_QUERY",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type WireTextPart = {
|
|
93
|
+
readonly text: string
|
|
94
|
+
}
|
|
95
|
+
type WireInlineDataPart = {
|
|
96
|
+
readonly inlineData: { readonly mimeType: string; readonly data: string }
|
|
97
|
+
}
|
|
98
|
+
type WirePart = WireTextPart | WireInlineDataPart
|
|
99
|
+
type WireContent = {
|
|
100
|
+
readonly parts: ReadonlyArray<WirePart>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const urlRejected = (param: string): AiError.AiError =>
|
|
104
|
+
new AiError.InvalidRequest({
|
|
105
|
+
provider: "gemini",
|
|
106
|
+
param,
|
|
107
|
+
raw: "URL-form image inputs aren't supported; pass base64 or bytes",
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Pre-uploading via Google's Files API isn't free, so we reject `url`
|
|
112
|
+
* sources up front rather than silently dropping them. Users with HTTPS
|
|
113
|
+
* URLs should fetch + pass `bytes` (or `base64`).
|
|
114
|
+
*/
|
|
115
|
+
const imageSourceToPart = (param: string) =>
|
|
116
|
+
Match.type<ImageSource>().pipe(
|
|
117
|
+
Match.tag(
|
|
118
|
+
"base64",
|
|
119
|
+
(s): Effect.Effect<WirePart, AiError.AiError> =>
|
|
120
|
+
Effect.succeed({ inlineData: { mimeType: s.mimeType, data: s.base64 } }),
|
|
121
|
+
),
|
|
122
|
+
Match.tag(
|
|
123
|
+
"bytes",
|
|
124
|
+
(s): Effect.Effect<WirePart, AiError.AiError> =>
|
|
125
|
+
Effect.succeed({
|
|
126
|
+
inlineData: { mimeType: s.mimeType, data: Encoding.encodeBase64(s.bytes) },
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
Match.tag(
|
|
130
|
+
"url",
|
|
131
|
+
(): Effect.Effect<WirePart, AiError.AiError> => Effect.fail(urlRejected(param)),
|
|
132
|
+
),
|
|
133
|
+
Match.exhaustive,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const contentPartToPart = (
|
|
137
|
+
param: string,
|
|
138
|
+
part: EmbedContentPart,
|
|
139
|
+
): Effect.Effect<WirePart, AiError.AiError> =>
|
|
140
|
+
"text" in part ? Effect.succeed({ text: part.text }) : imageSourceToPart(param)(part.image)
|
|
141
|
+
|
|
142
|
+
const inputToContent = (input: EmbedInput): Effect.Effect<WireContent, AiError.AiError> => {
|
|
143
|
+
if (typeof input === "string") return Effect.succeed({ parts: [{ text: input }] })
|
|
144
|
+
if ("text" in input) return Effect.succeed({ parts: [{ text: input.text }] })
|
|
145
|
+
if ("image" in input) {
|
|
146
|
+
return imageSourceToPart("input.image")(input.image).pipe(
|
|
147
|
+
Effect.map((part) => ({ parts: [part] })),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
return Effect.forEach(input.content, (p) => contentPartToPart("input.content[].image", p)).pipe(
|
|
151
|
+
Effect.map((parts) => ({ parts })),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type WireSingleBody = {
|
|
156
|
+
readonly content: WireContent
|
|
157
|
+
readonly taskType?: string
|
|
158
|
+
readonly outputDimensionality?: number
|
|
159
|
+
readonly title?: string
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const wireOptions = (
|
|
163
|
+
request: Pick<GeminiEmbedRequest, "task" | "dimensions" | "title">,
|
|
164
|
+
): Omit<WireSingleBody, "content"> => ({
|
|
165
|
+
...(request.task !== undefined && { taskType: taskToWire[request.task] }),
|
|
166
|
+
...(request.dimensions !== undefined && { outputDimensionality: request.dimensions }),
|
|
167
|
+
...(request.title !== undefined && { title: request.title }),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const buildSingleBody = (
|
|
171
|
+
request: GeminiEmbedRequest,
|
|
172
|
+
): Effect.Effect<WireSingleBody, AiError.AiError> =>
|
|
173
|
+
inputToContent(request.input).pipe(
|
|
174
|
+
Effect.map((content) => ({ content, ...wireOptions(request) })),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
type WireBatchEntry = WireSingleBody & {
|
|
178
|
+
readonly model: string
|
|
179
|
+
}
|
|
180
|
+
type WireBatchBody = {
|
|
181
|
+
readonly requests: ReadonlyArray<WireBatchEntry>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const buildBatchBody = (
|
|
185
|
+
request: GeminiEmbedManyRequest,
|
|
186
|
+
): Effect.Effect<WireBatchBody, AiError.AiError> => {
|
|
187
|
+
const modelPath = `models/${request.model}`
|
|
188
|
+
const options = wireOptions(request)
|
|
189
|
+
return Effect.forEach(request.inputs, (input) =>
|
|
190
|
+
inputToContent(input).pipe(
|
|
191
|
+
Effect.map((content): WireBatchEntry => ({ model: modelPath, content, ...options })),
|
|
192
|
+
),
|
|
193
|
+
).pipe(Effect.map((requests) => ({ requests })))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Codec - response
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
const WireEmbedding = Schema.Struct({
|
|
201
|
+
values: Schema.Array(Schema.Number),
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const WireSingleResponse = Schema.Struct({
|
|
205
|
+
embedding: WireEmbedding,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const WireBatchResponse = Schema.Struct({
|
|
209
|
+
embeddings: Schema.Array(WireEmbedding),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const valuesToEmbedding = (values: ReadonlyArray<number>): Embedding => ({
|
|
213
|
+
_tag: "float32",
|
|
214
|
+
vector: Float32Array.from(values),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Gemini's embed endpoints don't return token-count metadata. Estimating
|
|
218
|
+
// client-side would be provider-specific cruft; honest `undefined` is better.
|
|
219
|
+
const emptyUsage: Usage = {}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// HTTP errors
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const httpStatusError = (status: number, body: string): AiError.AiError => {
|
|
226
|
+
const provider = "gemini"
|
|
227
|
+
const raw = body
|
|
228
|
+
if (status === 429) return new AiError.RateLimited({ provider, raw })
|
|
229
|
+
if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
|
|
230
|
+
if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
|
|
231
|
+
if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
|
|
232
|
+
if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
|
|
233
|
+
if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
|
|
234
|
+
if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
|
|
235
|
+
return new AiError.InvalidRequest({ provider, raw })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const transportFailure = (cause: unknown): AiError.AiError =>
|
|
239
|
+
new AiError.Unavailable({ provider: "gemini", raw: cause })
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// HTTP plumbing - send a JSON body, error-map status, return parsed JSON.
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
const postJson = (
|
|
246
|
+
cfg: Config,
|
|
247
|
+
url: string,
|
|
248
|
+
body: unknown,
|
|
249
|
+
): Effect.Effect<unknown, AiError.AiError, HttpClient.HttpClient> =>
|
|
250
|
+
Effect.gen(function* () {
|
|
251
|
+
const client = yield* HttpClient.HttpClient
|
|
252
|
+
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
253
|
+
HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
|
|
254
|
+
HttpClientRequest.bodyJsonUnsafe(body),
|
|
255
|
+
)
|
|
256
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
257
|
+
if (response.status >= 400) {
|
|
258
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
259
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
260
|
+
}
|
|
261
|
+
return yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const decodeSingle = (json: unknown) =>
|
|
265
|
+
Schema.decodeUnknownEffect(WireSingleResponse)(json).pipe(Effect.mapError(transportFailure))
|
|
266
|
+
|
|
267
|
+
const decodeBatch = (json: unknown) =>
|
|
268
|
+
Schema.decodeUnknownEffect(WireBatchResponse)(json).pipe(Effect.mapError(transportFailure))
|
|
269
|
+
|
|
270
|
+
const baseUrl = (cfg: Config): string =>
|
|
271
|
+
cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Service implementation
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
const embedImpl =
|
|
278
|
+
(cfg: Config) =>
|
|
279
|
+
(
|
|
280
|
+
request: GeminiEmbedRequest,
|
|
281
|
+
): Effect.Effect<EmbedResponse, AiError.AiError, HttpClient.HttpClient> =>
|
|
282
|
+
buildSingleBody(request).pipe(
|
|
283
|
+
Effect.flatMap((body) =>
|
|
284
|
+
postJson(cfg, `${baseUrl(cfg)}/models/${request.model}:embedContent`, body),
|
|
285
|
+
),
|
|
286
|
+
Effect.flatMap(decodeSingle),
|
|
287
|
+
Effect.map(
|
|
288
|
+
(decoded): EmbedResponse => ({
|
|
289
|
+
embedding: valuesToEmbedding(decoded.embedding.values),
|
|
290
|
+
usage: emptyUsage,
|
|
291
|
+
}),
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
const embedManyImpl =
|
|
296
|
+
(cfg: Config) =>
|
|
297
|
+
(
|
|
298
|
+
request: GeminiEmbedManyRequest,
|
|
299
|
+
): Effect.Effect<EmbedManyResponse, AiError.AiError, HttpClient.HttpClient> =>
|
|
300
|
+
buildBatchBody(request).pipe(
|
|
301
|
+
Effect.flatMap((body) =>
|
|
302
|
+
postJson(cfg, `${baseUrl(cfg)}/models/${request.model}:batchEmbedContents`, body),
|
|
303
|
+
),
|
|
304
|
+
Effect.flatMap(decodeBatch),
|
|
305
|
+
Effect.map(
|
|
306
|
+
(decoded): EmbedManyResponse => ({
|
|
307
|
+
embeddings: decoded.embeddings.map((e) => valuesToEmbedding(e.values)),
|
|
308
|
+
usage: emptyUsage,
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Constructors
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Build a `GeminiEmbeddingService` value. For Layer-based setup, prefer
|
|
319
|
+
* `layer`.
|
|
320
|
+
*/
|
|
321
|
+
export const make = (
|
|
322
|
+
cfg: Config,
|
|
323
|
+
): Effect.Effect<GeminiEmbeddingService, never, HttpClient.HttpClient> =>
|
|
324
|
+
Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
|
|
325
|
+
embed: (request) =>
|
|
326
|
+
embedImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
327
|
+
embedMany: (request) =>
|
|
328
|
+
embedManyImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
329
|
+
}))
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Layer that registers both the provider-specific `GeminiEmbedding` tag
|
|
333
|
+
* and the generic `EmbeddingModel` tag, sharing one underlying
|
|
334
|
+
* implementation.
|
|
335
|
+
*/
|
|
336
|
+
export const layer = (
|
|
337
|
+
cfg: Config,
|
|
338
|
+
): Layer.Layer<GeminiEmbedding | EmbeddingModel, never, HttpClient.HttpClient> => {
|
|
339
|
+
const typed = Layer.effect(GeminiEmbedding, make(cfg))
|
|
340
|
+
const generic = Layer.effect(
|
|
341
|
+
EmbeddingModel,
|
|
342
|
+
Effect.map(
|
|
343
|
+
make(cfg),
|
|
344
|
+
(s): EmbeddingModelService => ({
|
|
345
|
+
embed: (req: CommonEmbedRequest) => s.embed(req as GeminiEmbedRequest),
|
|
346
|
+
embedMany: (req: CommonEmbedManyRequest) => s.embedMany(req as GeminiEmbedManyRequest),
|
|
347
|
+
}),
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
return Layer.merge(typed, generic)
|
|
351
|
+
}
|
package/src/codec.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { Array as Arr, Match, Option, Result, Schema, pipe } from "effect"
|
|
2
|
-
import type { ContentBlock, Item, Message } from "@effect-uai/core/Items"
|
|
3
|
-
import { matchType } from "@effect-uai/core/Match"
|
|
1
|
+
import { Array as Arr, Encoding, Match, Option, Result, Schema, pipe } from "effect"
|
|
2
|
+
import type { ContentBlock, InputImage, Item, Message } from "@effect-uai/core/Items"
|
|
4
3
|
import type { Turn } from "@effect-uai/core/Turn"
|
|
5
4
|
|
|
6
5
|
// ---------------------------------------------------------------------------
|
|
@@ -53,20 +52,20 @@ type RequestPart =
|
|
|
53
52
|
| { readonly text: string }
|
|
54
53
|
| { readonly inlineData: { readonly mimeType: string; readonly data: string } }
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
type RequestContent = {
|
|
57
56
|
readonly role: "user" | "model"
|
|
58
57
|
readonly parts: ReadonlyArray<RequestPart>
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
type RequestSystemInstruction = {
|
|
62
61
|
readonly parts: ReadonlyArray<{ readonly text: string }>
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
export
|
|
64
|
+
export type ThinkingConfig = {
|
|
66
65
|
readonly thinkingBudget: number
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
export
|
|
68
|
+
export type GenerationConfig = {
|
|
70
69
|
readonly temperature?: number
|
|
71
70
|
readonly maxOutputTokens?: number
|
|
72
71
|
readonly topP?: number
|
|
@@ -77,47 +76,55 @@ export interface GenerationConfig {
|
|
|
77
76
|
readonly responseJsonSchema?: Record<string, unknown>
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
export
|
|
79
|
+
export type RequestBody = {
|
|
81
80
|
readonly contents: ReadonlyArray<RequestContent>
|
|
82
81
|
readonly systemInstruction?: RequestSystemInstruction
|
|
83
82
|
readonly generationConfig?: GenerationConfig
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
const blockText = Match.type<ContentBlock>().pipe(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
Match.discriminatorsExhaustive("type")({
|
|
87
|
+
input_text: (b) => b.text,
|
|
88
|
+
input_image: () => "",
|
|
89
|
+
output_text: (b) => b.text,
|
|
90
|
+
refusal: (b) => b.text,
|
|
91
|
+
}),
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
const messageText = (message: Message): string => message.content.map(blockText).join("")
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
b.source._tag === "base64"
|
|
108
|
-
? [{ inlineData: { mimeType: b.source.media_type, data: b.source.data } }]
|
|
109
|
-
: [],
|
|
96
|
+
/**
|
|
97
|
+
* Gemini's `inlineData` form expects a base64 payload. URL-form images
|
|
98
|
+
* would need to go through Gemini's Files API (upload then `fileData`
|
|
99
|
+
* with the returned URI); pre-uploading isn't free, so we skip those for
|
|
100
|
+
* now and document as a follow-up.
|
|
101
|
+
*/
|
|
102
|
+
const imageSourceToParts = Match.type<InputImage["source"]>().pipe(
|
|
103
|
+
Match.tag("url", (): ReadonlyArray<RequestPart> => []),
|
|
104
|
+
Match.tag(
|
|
105
|
+
"base64",
|
|
106
|
+
(s): ReadonlyArray<RequestPart> => [{ inlineData: { mimeType: s.mimeType, data: s.base64 } }],
|
|
110
107
|
),
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
(
|
|
108
|
+
Match.tag(
|
|
109
|
+
"bytes",
|
|
110
|
+
(s): ReadonlyArray<RequestPart> => [
|
|
111
|
+
{ inlineData: { mimeType: s.mimeType, data: Encoding.encodeBase64(s.bytes) } },
|
|
112
|
+
],
|
|
114
113
|
),
|
|
115
|
-
// Refusals are assistant-side content; they don't round-trip into Gemini's
|
|
116
|
-
// request body as parts. Skip.
|
|
117
|
-
matchType("refusal", (): ReadonlyArray<RequestPart> => []),
|
|
118
114
|
Match.exhaustive,
|
|
119
115
|
)
|
|
120
116
|
|
|
117
|
+
const blockToParts = Match.type<ContentBlock>().pipe(
|
|
118
|
+
Match.discriminatorsExhaustive("type")({
|
|
119
|
+
input_text: (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),
|
|
120
|
+
input_image: (b): ReadonlyArray<RequestPart> => imageSourceToParts(b.source),
|
|
121
|
+
output_text: (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),
|
|
122
|
+
// Refusals are assistant-side content; they don't round-trip into Gemini's
|
|
123
|
+
// request body as parts. Skip.
|
|
124
|
+
refusal: (): ReadonlyArray<RequestPart> => [],
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
|
|
121
128
|
const messageToContent = (message: Message): Result.Result<RequestContent, void> => {
|
|
122
129
|
const parts = pipe(message.content, Arr.flatMap(blockToParts))
|
|
123
130
|
if (parts.length === 0) return Result.failVoid
|
|
@@ -173,7 +180,7 @@ const finishReasonToStop = (reason: Option.Option<string>): Turn["stop_reason"]
|
|
|
173
180
|
onSome: (r) => (r === "MAX_TOKENS" ? ("max_tokens" as const) : ("stop" as const)),
|
|
174
181
|
})
|
|
175
182
|
|
|
176
|
-
export
|
|
183
|
+
export type Accumulator = {
|
|
177
184
|
readonly text: string
|
|
178
185
|
readonly reasoning: string
|
|
179
186
|
readonly finishReason: Option.Option<string>
|
|
@@ -202,7 +209,7 @@ export type ChunkPart =
|
|
|
202
209
|
| { readonly kind: "text"; readonly text: string }
|
|
203
210
|
| { readonly kind: "reasoning"; readonly text: string }
|
|
204
211
|
|
|
205
|
-
export
|
|
212
|
+
export type ChunkResult = {
|
|
206
213
|
readonly accumulator: Accumulator
|
|
207
214
|
readonly parts: ReadonlyArray<ChunkPart>
|
|
208
215
|
readonly finished: boolean
|
package/src/index.ts
CHANGED
package/src/models.ts
CHANGED
|
@@ -16,3 +16,23 @@ export type GoogleModel =
|
|
|
16
16
|
| "gemini-2.5-flash-lite"
|
|
17
17
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
18
18
|
| (string & {})
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Known Gemini embedding model identifiers.
|
|
22
|
+
*
|
|
23
|
+
* - `gemini-embedding-2` — natively multimodal (text, image, audio, video,
|
|
24
|
+
* PDF). Does *not* honour the `taskType` field; instead prepend a task
|
|
25
|
+
* instruction to the text yourself.
|
|
26
|
+
* - `gemini-embedding-001` — text-only, supports the full `taskType` enum
|
|
27
|
+
* (`RETRIEVAL_QUERY` / `RETRIEVAL_DOCUMENT` / classification / etc.).
|
|
28
|
+
*
|
|
29
|
+
* The `(string & {})` tail accepts any string so newly-released models
|
|
30
|
+
* work without an SDK update.
|
|
31
|
+
*
|
|
32
|
+
* Reference: https://ai.google.dev/gemini-api/docs/embeddings
|
|
33
|
+
*/
|
|
34
|
+
export type GoogleEmbeddingModel =
|
|
35
|
+
| "gemini-embedding-2"
|
|
36
|
+
| "gemini-embedding-001"
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
38
|
+
| (string & {})
|
package/dist/index.d.mts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/codec.ts","../src/models.ts","../src/Gemini.ts"],"mappings":"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"}
|
package/dist/index.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["Arr"],"sources":["../src/codec.ts","../src/Gemini.ts"],"sourcesContent":["import { Array as Arr, Match, Option, Result, Schema, pipe } from \"effect\"\nimport type { ContentBlock, Item, Message } from \"@effect-uai/core/Items\"\nimport { matchType } from \"@effect-uai/core/Match\"\nimport type { Turn } from \"@effect-uai/core/Turn\"\n\n// ---------------------------------------------------------------------------\n// Wire schemas - the subset of Gemini's streamGenerateContent payload we\n// consume. Reference: https://ai.google.dev/api/generate-content\n// ---------------------------------------------------------------------------\n\nconst TextPart = Schema.Struct({\n text: Schema.String,\n /**\n * Gemini's flag for chain-of-thought parts. When `true`, the part is\n * reasoning trace, not the model's user-facing answer. Maps onto\n * `reasoning_delta { kind: \"trace\" }` in the canonical view.\n */\n thought: Schema.optional(Schema.Boolean),\n})\n\nconst Part = Schema.Union([TextPart])\n\nconst Content = Schema.Struct({\n role: Schema.optional(Schema.String),\n parts: Schema.optional(Schema.Array(Part)),\n})\n\nconst Candidate = Schema.Struct({\n content: Schema.optional(Content),\n finishReason: Schema.optional(Schema.String),\n index: Schema.optional(Schema.Number),\n})\n\nconst UsageMetadata = Schema.Struct({\n promptTokenCount: Schema.optional(Schema.Number),\n candidatesTokenCount: Schema.optional(Schema.Number),\n totalTokenCount: Schema.optional(Schema.Number),\n cachedContentTokenCount: Schema.optional(Schema.Number),\n thoughtsTokenCount: Schema.optional(Schema.Number),\n})\n\nexport const WireChunk = Schema.Struct({\n candidates: Schema.optional(Schema.Array(Candidate)),\n usageMetadata: Schema.optional(UsageMetadata),\n})\nexport type WireChunk = typeof WireChunk.Type\n\n// ---------------------------------------------------------------------------\n// History → request body\n// ---------------------------------------------------------------------------\n\ntype RequestPart =\n | { readonly text: string }\n | { readonly inlineData: { readonly mimeType: string; readonly data: string } }\n\ninterface RequestContent {\n readonly role: \"user\" | \"model\"\n readonly parts: ReadonlyArray<RequestPart>\n}\n\ninterface RequestSystemInstruction {\n readonly parts: ReadonlyArray<{ readonly text: string }>\n}\n\nexport interface ThinkingConfig {\n readonly thinkingBudget: number\n}\n\nexport interface GenerationConfig {\n readonly temperature?: number\n readonly maxOutputTokens?: number\n readonly topP?: number\n readonly thinkingConfig?: ThinkingConfig\n /** Set together with `responseJsonSchema` to constrain output to JSON. */\n readonly responseMimeType?: string\n /** JSON Schema constraint on the output. */\n readonly responseJsonSchema?: Record<string, unknown>\n}\n\nexport interface RequestBody {\n readonly contents: ReadonlyArray<RequestContent>\n readonly systemInstruction?: RequestSystemInstruction\n readonly generationConfig?: GenerationConfig\n}\n\nconst blockText = Match.type<ContentBlock>().pipe(\n matchType(\"input_text\", (b) => b.text),\n matchType(\"input_image\", () => \"\"),\n matchType(\"output_text\", (b) => b.text),\n matchType(\"refusal\", (b) => b.text),\n Match.exhaustive,\n)\n\nconst messageText = (message: Message): string => message.content.map(blockText).join(\"\")\n\nconst blockToParts = Match.type<ContentBlock>().pipe(\n matchType(\n \"input_text\",\n (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),\n ),\n matchType(\n \"input_image\",\n (b): ReadonlyArray<RequestPart> =>\n // Gemini's `fileData` form expects a pre-uploaded Files API URI, not an\n // arbitrary HTTPS URL - we'd need to fetch + re-upload to support that.\n // Skip URL-form images for now; document as a follow-up.\n b.source._tag === \"base64\"\n ? [{ inlineData: { mimeType: b.source.media_type, data: b.source.data } }]\n : [],\n ),\n matchType(\n \"output_text\",\n (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),\n ),\n // Refusals are assistant-side content; they don't round-trip into Gemini's\n // request body as parts. Skip.\n matchType(\"refusal\", (): ReadonlyArray<RequestPart> => []),\n Match.exhaustive,\n)\n\nconst messageToContent = (message: Message): Result.Result<RequestContent, void> => {\n const parts = pipe(message.content, Arr.flatMap(blockToParts))\n if (parts.length === 0) return Result.failVoid\n return Match.value(message.role).pipe(\n Match.when(\"user\", () => Result.succeed({ role: \"user\" as const, parts })),\n Match.when(\"assistant\", () => Result.succeed({ role: \"model\" as const, parts })),\n Match.when(\"system\", () => Result.failVoid),\n Match.exhaustive,\n )\n}\n\nconst systemMessageText = (message: Message): Result.Result<string, void> => {\n if (message.role !== \"system\") return Result.failVoid\n const text = messageText(message)\n return text.length === 0 ? Result.failVoid : Result.succeed(text)\n}\n\nconst messages = (history: ReadonlyArray<Item>): ReadonlyArray<Message> =>\n pipe(\n history,\n Arr.filterMap((item) => (item.type === \"message\" ? Result.succeed(item) : Result.failVoid)),\n )\n\nexport const buildRequestBody = (\n history: ReadonlyArray<Item>,\n generationConfig: Option.Option<GenerationConfig>,\n): RequestBody => {\n const msgs = messages(history)\n const systemTexts = pipe(msgs, Arr.filterMap(systemMessageText))\n const contents = pipe(msgs, Arr.filterMap(messageToContent))\n return {\n contents,\n ...(systemTexts.length > 0 && {\n systemInstruction: { parts: [{ text: systemTexts.join(\"\\n\") }] },\n }),\n ...Option.match(generationConfig, {\n onNone: () => ({}),\n onSome: (cfg) => ({ generationConfig: cfg }),\n }),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Stream-level state - accumulate chunk text + final usage/finish.\n//\n// `Accumulator` is immutable; `ingestChunk` returns a fresh one per chunk.\n// Drive it via `Stream.mapAccum` in the consumer.\n// ---------------------------------------------------------------------------\n\nconst finishReasonToStop = (reason: Option.Option<string>): Turn[\"stop_reason\"] =>\n Option.match(reason, {\n onNone: () => \"stop\" as const,\n onSome: (r) => (r === \"MAX_TOKENS\" ? (\"max_tokens\" as const) : (\"stop\" as const)),\n })\n\nexport interface Accumulator {\n readonly text: string\n readonly reasoning: string\n readonly finishReason: Option.Option<string>\n readonly usage: {\n readonly input_tokens?: number\n readonly output_tokens?: number\n readonly total_tokens?: number\n readonly input_tokens_details?: { readonly cached_tokens?: number }\n readonly output_tokens_details?: { readonly reasoning_tokens?: number }\n }\n}\n\nexport const emptyAccumulator: Accumulator = {\n text: \"\",\n reasoning: \"\",\n finishReason: Option.none(),\n usage: {},\n}\n\n/**\n * One part's worth of streamable text: either the model's answer\n * (`kind: \"text\"`) or chain-of-thought (`kind: \"reasoning\"`). Emitted\n * in wire order so consumers can interleave faithfully.\n */\nexport type ChunkPart =\n | { readonly kind: \"text\"; readonly text: string }\n | { readonly kind: \"reasoning\"; readonly text: string }\n\nexport interface ChunkResult {\n readonly accumulator: Accumulator\n readonly parts: ReadonlyArray<ChunkPart>\n readonly finished: boolean\n}\n\nconst chunkParts = (chunk: WireChunk): ReadonlyArray<ChunkPart> =>\n pipe(\n chunk.candidates?.[0]?.content?.parts ?? [],\n Arr.filterMap((p) =>\n p.text.length === 0\n ? Result.failVoid\n : Result.succeed({\n kind: p.thought === true ? (\"reasoning\" as const) : (\"text\" as const),\n text: p.text,\n }),\n ),\n )\n\nconst sumByKind = (parts: ReadonlyArray<ChunkPart>, kind: ChunkPart[\"kind\"]): string =>\n pipe(\n parts,\n Arr.filterMap((p) => (p.kind === kind ? Result.succeed(p.text) : Result.failVoid)),\n ).join(\"\")\n\nconst mergeUsage = (\n prev: Accumulator[\"usage\"],\n next: WireChunk[\"usageMetadata\"],\n): Accumulator[\"usage\"] =>\n next === undefined\n ? prev\n : {\n ...prev,\n ...(next.promptTokenCount !== undefined && { input_tokens: next.promptTokenCount }),\n ...(next.candidatesTokenCount !== undefined && {\n output_tokens: next.candidatesTokenCount,\n }),\n ...(next.totalTokenCount !== undefined && { total_tokens: next.totalTokenCount }),\n ...(next.cachedContentTokenCount !== undefined && {\n input_tokens_details: { cached_tokens: next.cachedContentTokenCount },\n }),\n ...(next.thoughtsTokenCount !== undefined && {\n output_tokens_details: { reasoning_tokens: next.thoughtsTokenCount },\n }),\n }\n\nexport const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult => {\n const parts = chunkParts(chunk)\n const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason)\n return {\n parts,\n finished: Option.isSome(finishReason),\n accumulator: {\n text: acc.text + sumByKind(parts, \"text\"),\n reasoning: acc.reasoning + sumByKind(parts, \"reasoning\"),\n finishReason: Option.orElse(finishReason, () => acc.finishReason),\n usage: mergeUsage(acc.usage, chunk.usageMetadata),\n },\n }\n}\n\nexport const accumulatorToTurn = (acc: Accumulator): Turn => ({\n stop_reason: finishReasonToStop(acc.finishReason),\n usage: { ...acc.usage },\n items: [\n ...(acc.reasoning.length > 0 ? [{ type: \"reasoning\" as const, summary: acc.reasoning }] : []),\n ...(acc.text.length === 0\n ? []\n : [\n {\n type: \"message\" as const,\n role: \"assistant\" as const,\n content: [{ type: \"output_text\" as const, text: acc.text }],\n },\n ]),\n ],\n})\n","import { Context, Effect, Layer, Option, Redacted, Schema, Stream } from \"effect\"\nimport { HttpClient, HttpClientRequest } from \"effect/unstable/http\"\nimport * as AiError from \"@effect-uai/core/AiError\"\nimport {\n type CommonRequest,\n LanguageModel,\n type LanguageModelService,\n} from \"@effect-uai/core/LanguageModel\"\nimport { JsonParseError } from \"@effect-uai/core/JSONL\"\nimport * as SSE from \"@effect-uai/core/SSE\"\nimport type { TurnEvent } from \"@effect-uai/core/Turn\"\nimport {\n type GenerationConfig,\n WireChunk,\n accumulatorToTurn,\n buildRequestBody,\n emptyAccumulator,\n ingestChunk,\n} from \"./codec.js\"\nimport type { GoogleModel } from \"./models.js\"\n\n/**\n * Gemini's native event vocabulary. Aliased from `WireChunk` so the public\n * surface matches `@effect-uai/responses` and `@effect-uai/anthropic`. Each\n * `ProviderEvent` is a full `GenerateContentResponse` chunk (not a per-field\n * delta) - that's how Gemini's SSE works.\n */\nexport const ProviderEvent = WireChunk\nexport type ProviderEvent = WireChunk\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface GeminiRequest extends Omit<CommonRequest, \"model\"> {\n /**\n * Narrows `CommonRequest.model` (`string`) to the typed `GoogleModel`\n * literal union for autocomplete.\n */\n readonly model: GoogleModel\n /**\n * Gemini 2.5 thinking budget. `0` disables thinking entirely (lowest\n * latency); higher values let the model think longer. Forwarded as\n * `generationConfig.thinkingConfig.thinkingBudget`.\n */\n readonly thinkingBudget?: number\n}\n\nexport interface GeminiService {\n /**\n * Stream the provider's native event vocabulary (post-SSE-decode). For\n * Gemini, each event is a full `GenerateContentResponse` chunk. Use this\n * when you need access to fields the canonical view doesn't surface\n * (`thoughtSignature`, granular `safetyRatings`, etc.). For\n * provider-portable code, use `streamTurn`.\n */\n readonly streamNative: (request: GeminiRequest) => Stream.Stream<ProviderEvent, AiError.AiError>\n /**\n * Stream canonical `TurnEvent`s. Implemented as\n * `streamNative |> toCanonical`.\n */\n readonly streamTurn: (request: GeminiRequest) => Stream.Stream<TurnEvent, AiError.AiError>\n /**\n * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.\n * Threads a fresh `Accumulator` so chunk-level text/usage merging happens\n * per stream.\n */\n readonly toCanonical: <E, R>(\n s: Stream.Stream<ProviderEvent, E, R>,\n ) => Stream.Stream<TurnEvent, E, R>\n}\n\n/**\n * Provider-typed service tag. Yield this when you want Gemini-specific\n * options (`thinkingBudget`); yield the generic `LanguageModel` tag for\n * provider-portable code. Both are registered by `layer`.\n */\nexport class Gemini extends Context.Service<Gemini, GeminiService>()(\n \"@betalyra/effect-uai/providers/google/Gemini\",\n) {}\n\nexport interface Config {\n readonly apiKey: Redacted.Redacted\n readonly baseUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// Request body\n// ---------------------------------------------------------------------------\n\nconst buildGenerationConfig = (request: GeminiRequest): Option.Option<GenerationConfig> => {\n const cfg: GenerationConfig = {\n ...(request.temperature !== undefined && { temperature: request.temperature }),\n ...(request.maxOutputTokens !== undefined && { maxOutputTokens: request.maxOutputTokens }),\n ...(request.topP !== undefined && { topP: request.topP }),\n ...(request.thinkingBudget !== undefined && {\n thinkingConfig: { thinkingBudget: request.thinkingBudget },\n }),\n ...(request.structured !== undefined && {\n responseMimeType: \"application/json\",\n responseJsonSchema: request.structured.schema[\"~standard\"].jsonSchema.input({\n target: \"draft-2020-12\",\n }),\n }),\n }\n return Object.keys(cfg).length === 0 ? Option.none() : Option.some(cfg)\n}\n\n// ---------------------------------------------------------------------------\n// SSE event → wire chunk\n// ---------------------------------------------------------------------------\n\nconst decodeChunk = Schema.decodeUnknownEffect(WireChunk)\n\n/**\n * Parse one SSE event's `data` payload into a `WireChunk`. Unlike the other\n * providers, Gemini's wire vocabulary is non-discriminated (a single\n * permissive struct, all fields optional) - there's no natural shape for\n * an `_unknown` variant, so JSON parse / schema decode failures flow\n * through as transport errors rather than being silently dropped.\n */\nconst sseEventToChunk = (ev: SSE.Event) =>\n Effect.try({\n try: () => JSON.parse(ev.data) as unknown,\n catch: (cause) => new JsonParseError({ line: ev.data, cause }),\n }).pipe(Effect.flatMap(decodeChunk))\n\n// ---------------------------------------------------------------------------\n// Service implementation\n// ---------------------------------------------------------------------------\n\nconst httpStatusError = (status: number, body: string): AiError.AiError => {\n const provider = \"gemini\"\n const raw = body\n if (status === 429) return new AiError.RateLimited({ provider, raw })\n if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })\n if (status === 401) return new AiError.AuthFailed({ provider, subtype: \"auth\", raw })\n if (status === 403) return new AiError.AuthFailed({ provider, subtype: \"permission\", raw })\n if (status === 402) return new AiError.AuthFailed({ provider, subtype: \"billing\", raw })\n if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })\n if (status >= 500) return new AiError.Unavailable({ provider, status, raw })\n return new AiError.InvalidRequest({ provider, raw })\n}\n\nconst buildNativeStream = (cfg: Config) => {\n const baseUrl = cfg.baseUrl ?? \"https://generativelanguage.googleapis.com/v1beta\"\n return (\n request: GeminiRequest,\n ): Stream.Stream<ProviderEvent, AiError.AiError, HttpClient.HttpClient> =>\n Stream.unwrap(\n Effect.gen(function* () {\n const client = yield* HttpClient.HttpClient\n const url = `${baseUrl}/models/${request.model}:streamGenerateContent?alt=sse`\n const generationConfig = buildGenerationConfig(request)\n const body = buildRequestBody(request.history, generationConfig)\n const httpRequest = HttpClientRequest.post(url).pipe(\n HttpClientRequest.setHeader(\"x-goog-api-key\", Redacted.value(cfg.apiKey)),\n HttpClientRequest.bodyJsonUnsafe(body),\n HttpClientRequest.accept(\"text/event-stream\"),\n )\n const response = yield* client\n .execute(httpRequest)\n .pipe(\n Effect.mapError(\n (cause): AiError.AiError =>\n new AiError.Unavailable({ provider: \"gemini\", raw: cause }),\n ),\n )\n if (response.status >= 400) {\n const text = yield* response.text.pipe(Effect.orElseSucceed(() => \"\"))\n return Stream.fail(httpStatusError(response.status, text))\n }\n\n return response.stream.pipe(\n Stream.mapError(\n (cause): AiError.AiError => new AiError.Unavailable({ provider: \"gemini\", raw: cause }),\n ),\n SSE.fromBytes,\n Stream.mapEffect((ev) =>\n sseEventToChunk(ev).pipe(\n Effect.mapError(\n (cause): AiError.AiError =>\n new AiError.Unavailable({ provider: \"gemini\", raw: cause }),\n ),\n ),\n ),\n )\n }),\n )\n}\n\n/**\n * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.\n * Threads a fresh `Accumulator` so chunk-level text/usage merging works\n * across the run.\n */\nexport const toCanonical = <E, R>(\n s: Stream.Stream<ProviderEvent, E, R>,\n): Stream.Stream<TurnEvent, E, R> =>\n s.pipe(\n Stream.mapAccum(\n () => emptyAccumulator,\n (acc, chunk) => {\n const result = ingestChunk(acc, chunk)\n const partDeltas = result.parts.map((p) =>\n p.kind === \"text\"\n ? { type: \"text_delta\" as const, text: p.text }\n : { type: \"reasoning_delta\" as const, text: p.text, kind: \"trace\" as const },\n )\n const deltas = result.finished\n ? [\n ...partDeltas,\n {\n type: \"turn_complete\" as const,\n turn: accumulatorToTurn(result.accumulator),\n },\n ]\n : partDeltas\n return [result.accumulator, deltas] as const\n },\n ),\n )\n\n// ---------------------------------------------------------------------------\n// Constructors\n// ---------------------------------------------------------------------------\n\n/**\n * Build a `GeminiService` value. For Layer-based setup, prefer `layer`.\n */\nexport const make = (cfg: Config): Effect.Effect<GeminiService, never, HttpClient.HttpClient> =>\n Effect.map(HttpClient.HttpClient.asEffect(), (client) => {\n const streamNative: GeminiService[\"streamNative\"] = (request) =>\n buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))\n return {\n streamNative,\n streamTurn: (request) => toCanonical(streamNative(request)),\n toCanonical,\n }\n })\n\n/**\n * Layer that registers both the provider-specific `Gemini` tag and the\n * generic `LanguageModel` tag, sharing one underlying implementation.\n *\n * The generic tag accepts `CommonRequest`; the typed tag accepts the full\n * `GeminiRequest` surface.\n */\nexport const layer = (\n cfg: Config,\n): Layer.Layer<Gemini | LanguageModel, never, HttpClient.HttpClient> => {\n const typed = Layer.effect(Gemini, make(cfg))\n const generic = Layer.effect(\n LanguageModel,\n Effect.map(\n make(cfg),\n (s): LanguageModelService => ({\n streamTurn: (request) => s.streamTurn(request as GeminiRequest),\n }),\n ),\n )\n return Layer.merge(typed, generic)\n}\n"],"mappings":"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"}
|