@effect-uai/google 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Betalyra Sociedade Unipessoal Lda.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @effect-uai/google
2
+
3
+ Google Gemini provider for [`@effect-uai/core`](https://www.npmjs.com/package/@effect-uai/core).
4
+
5
+ Implements the `LanguageModel` contract against Google's Gemini API
6
+ with SSE streaming.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pnpm add @effect-uai/google @effect-uai/core effect
12
+ ```
13
+
14
+ ESM-only. Requires `effect@4.x` and `@effect-uai/core` as peers.
15
+
16
+ ## Usage
17
+
18
+ ```ts
19
+ import { Config, Effect, Layer } from "effect"
20
+ import { FetchHttpClient } from "effect/unstable/http"
21
+ import { layer as googleLayer } from "@effect-uai/google"
22
+
23
+ const provider = Layer.unwrap(
24
+ Effect.gen(function* () {
25
+ const apiKey = yield* Config.redacted("GOOGLE_API_KEY")
26
+ return googleLayer({ apiKey })
27
+ }),
28
+ )
29
+
30
+ const layer = Layer.provide(provider, FetchHttpClient.layer)
31
+ ```
32
+
33
+ The layer registers both the provider-typed `Gemini` tag and the
34
+ generic `LanguageModel` tag.
35
+
36
+ ## Docs
37
+
38
+ <https://effect-uai.betalyra.com/providers/gemini/>
39
+
40
+ ## License
41
+
42
+ MIT
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };
@@ -0,0 +1,214 @@
1
+ import { Context, Effect, Layer, Option, Redacted, Schema, Stream } from "effect";
2
+ import { HttpClient } from "effect/unstable/http";
3
+ import * as AiError from "@effect-uai/core/AiError";
4
+ import { CommonRequest, LanguageModel } from "@effect-uai/core/LanguageModel";
5
+ import { Turn, TurnEvent } from "@effect-uai/core/Turn";
6
+ import { Item } from "@effect-uai/core/Items";
7
+
8
+ //#region src/codec.d.ts
9
+ declare namespace codec_d_exports {
10
+ export { Accumulator, ChunkPart, ChunkResult, GenerationConfig, RequestBody, ThinkingConfig, WireChunk, accumulatorToTurn, buildRequestBody, emptyAccumulator, ingestChunk };
11
+ }
12
+ declare const WireChunk: Schema.Struct<{
13
+ readonly candidates: Schema.optional<Schema.$Array<Schema.Struct<{
14
+ readonly content: Schema.optional<Schema.Struct<{
15
+ readonly role: Schema.optional<Schema.String>;
16
+ readonly parts: Schema.optional<Schema.$Array<Schema.Union<readonly [Schema.Struct<{
17
+ readonly text: Schema.String;
18
+ /**
19
+ * Gemini's flag for chain-of-thought parts. When `true`, the part is
20
+ * reasoning trace, not the model's user-facing answer. Maps onto
21
+ * `reasoning_delta { kind: "trace" }` in the canonical view.
22
+ */
23
+ readonly thought: Schema.optional<Schema.Boolean>;
24
+ }>]>>>;
25
+ }>>;
26
+ readonly finishReason: Schema.optional<Schema.String>;
27
+ readonly index: Schema.optional<Schema.Number>;
28
+ }>>>;
29
+ readonly usageMetadata: Schema.optional<Schema.Struct<{
30
+ readonly promptTokenCount: Schema.optional<Schema.Number>;
31
+ readonly candidatesTokenCount: Schema.optional<Schema.Number>;
32
+ readonly totalTokenCount: Schema.optional<Schema.Number>;
33
+ readonly cachedContentTokenCount: Schema.optional<Schema.Number>;
34
+ readonly thoughtsTokenCount: Schema.optional<Schema.Number>;
35
+ }>>;
36
+ }>;
37
+ type WireChunk = typeof WireChunk.Type;
38
+ type RequestPart = {
39
+ readonly text: string;
40
+ } | {
41
+ readonly inlineData: {
42
+ readonly mimeType: string;
43
+ readonly data: string;
44
+ };
45
+ };
46
+ interface RequestContent {
47
+ readonly role: "user" | "model";
48
+ readonly parts: ReadonlyArray<RequestPart>;
49
+ }
50
+ interface RequestSystemInstruction {
51
+ readonly parts: ReadonlyArray<{
52
+ readonly text: string;
53
+ }>;
54
+ }
55
+ interface ThinkingConfig {
56
+ readonly thinkingBudget: number;
57
+ }
58
+ interface GenerationConfig {
59
+ readonly temperature?: number;
60
+ readonly maxOutputTokens?: number;
61
+ readonly topP?: number;
62
+ readonly thinkingConfig?: ThinkingConfig;
63
+ /** Set together with `responseJsonSchema` to constrain output to JSON. */
64
+ readonly responseMimeType?: string;
65
+ /** JSON Schema constraint on the output. */
66
+ readonly responseJsonSchema?: Record<string, unknown>;
67
+ }
68
+ interface RequestBody {
69
+ readonly contents: ReadonlyArray<RequestContent>;
70
+ readonly systemInstruction?: RequestSystemInstruction;
71
+ readonly generationConfig?: GenerationConfig;
72
+ }
73
+ declare const buildRequestBody: (history: ReadonlyArray<Item>, generationConfig: Option.Option<GenerationConfig>) => RequestBody;
74
+ interface Accumulator {
75
+ readonly text: string;
76
+ readonly reasoning: string;
77
+ readonly finishReason: Option.Option<string>;
78
+ readonly usage: {
79
+ readonly input_tokens?: number;
80
+ readonly output_tokens?: number;
81
+ readonly total_tokens?: number;
82
+ readonly input_tokens_details?: {
83
+ readonly cached_tokens?: number;
84
+ };
85
+ readonly output_tokens_details?: {
86
+ readonly reasoning_tokens?: number;
87
+ };
88
+ };
89
+ }
90
+ declare const emptyAccumulator: Accumulator;
91
+ /**
92
+ * One part's worth of streamable text: either the model's answer
93
+ * (`kind: "text"`) or chain-of-thought (`kind: "reasoning"`). Emitted
94
+ * in wire order so consumers can interleave faithfully.
95
+ */
96
+ type ChunkPart = {
97
+ readonly kind: "text";
98
+ readonly text: string;
99
+ } | {
100
+ readonly kind: "reasoning";
101
+ readonly text: string;
102
+ };
103
+ interface ChunkResult {
104
+ readonly accumulator: Accumulator;
105
+ readonly parts: ReadonlyArray<ChunkPart>;
106
+ readonly finished: boolean;
107
+ }
108
+ declare const ingestChunk: (acc: Accumulator, chunk: WireChunk) => ChunkResult;
109
+ declare const accumulatorToTurn: (acc: Accumulator) => Turn;
110
+ //#endregion
111
+ //#region src/models.d.ts
112
+ /**
113
+ * Known Gemini model identifiers (as of April 2026). The `(string & {})`
114
+ * tail keeps autocomplete on the literals while still accepting any string,
115
+ * so newly-released models work without an SDK update.
116
+ *
117
+ * Reference: https://ai.google.dev/gemini-api/docs/models
118
+ */
119
+ 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 & {});
120
+ //#endregion
121
+ //#region src/Gemini.d.ts
122
+ /**
123
+ * Gemini's native event vocabulary. Aliased from `WireChunk` so the public
124
+ * surface matches `@effect-uai/responses` and `@effect-uai/anthropic`. Each
125
+ * `ProviderEvent` is a full `GenerateContentResponse` chunk (not a per-field
126
+ * delta) - that's how Gemini's SSE works.
127
+ */
128
+ declare const ProviderEvent: Schema.Struct<{
129
+ readonly candidates: Schema.optional<Schema.$Array<Schema.Struct<{
130
+ readonly content: Schema.optional<Schema.Struct<{
131
+ readonly role: Schema.optional<Schema.String>;
132
+ readonly parts: Schema.optional<Schema.$Array<Schema.Union<readonly [Schema.Struct<{
133
+ readonly text: Schema.String;
134
+ readonly thought: Schema.optional<Schema.Boolean>;
135
+ }>]>>>;
136
+ }>>;
137
+ readonly finishReason: Schema.optional<Schema.String>;
138
+ readonly index: Schema.optional<Schema.Number>;
139
+ }>>>;
140
+ readonly usageMetadata: Schema.optional<Schema.Struct<{
141
+ readonly promptTokenCount: Schema.optional<Schema.Number>;
142
+ readonly candidatesTokenCount: Schema.optional<Schema.Number>;
143
+ readonly totalTokenCount: Schema.optional<Schema.Number>;
144
+ readonly cachedContentTokenCount: Schema.optional<Schema.Number>;
145
+ readonly thoughtsTokenCount: Schema.optional<Schema.Number>;
146
+ }>>;
147
+ }>;
148
+ type ProviderEvent = WireChunk;
149
+ interface GeminiRequest extends Omit<CommonRequest, "model"> {
150
+ /**
151
+ * Narrows `CommonRequest.model` (`string`) to the typed `GoogleModel`
152
+ * literal union for autocomplete.
153
+ */
154
+ readonly model: GoogleModel;
155
+ /**
156
+ * Gemini 2.5 thinking budget. `0` disables thinking entirely (lowest
157
+ * latency); higher values let the model think longer. Forwarded as
158
+ * `generationConfig.thinkingConfig.thinkingBudget`.
159
+ */
160
+ readonly thinkingBudget?: number;
161
+ }
162
+ interface GeminiService {
163
+ /**
164
+ * Stream the provider's native event vocabulary (post-SSE-decode). For
165
+ * Gemini, each event is a full `GenerateContentResponse` chunk. Use this
166
+ * when you need access to fields the canonical view doesn't surface
167
+ * (`thoughtSignature`, granular `safetyRatings`, etc.). For
168
+ * provider-portable code, use `streamTurn`.
169
+ */
170
+ readonly streamNative: (request: GeminiRequest) => Stream.Stream<ProviderEvent, AiError.AiError>;
171
+ /**
172
+ * Stream canonical `TurnEvent`s. Implemented as
173
+ * `streamNative |> toCanonical`.
174
+ */
175
+ readonly streamTurn: (request: GeminiRequest) => Stream.Stream<TurnEvent, AiError.AiError>;
176
+ /**
177
+ * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
178
+ * Threads a fresh `Accumulator` so chunk-level text/usage merging happens
179
+ * per stream.
180
+ */
181
+ readonly toCanonical: <E, R>(s: Stream.Stream<ProviderEvent, E, R>) => Stream.Stream<TurnEvent, E, R>;
182
+ }
183
+ declare const Gemini_base: Context.ServiceClass<Gemini, "@betalyra/effect-uai/providers/google/Gemini", GeminiService>;
184
+ /**
185
+ * Provider-typed service tag. Yield this when you want Gemini-specific
186
+ * options (`thinkingBudget`); yield the generic `LanguageModel` tag for
187
+ * provider-portable code. Both are registered by `layer`.
188
+ */
189
+ declare class Gemini extends Gemini_base {}
190
+ interface Config {
191
+ readonly apiKey: Redacted.Redacted;
192
+ readonly baseUrl?: string;
193
+ }
194
+ /**
195
+ * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
196
+ * Threads a fresh `Accumulator` so chunk-level text/usage merging works
197
+ * across the run.
198
+ */
199
+ declare const toCanonical: <E, R>(s: Stream.Stream<ProviderEvent, E, R>) => Stream.Stream<TurnEvent, E, R>;
200
+ /**
201
+ * Build a `GeminiService` value. For Layer-based setup, prefer `layer`.
202
+ */
203
+ declare const make: (cfg: Config) => Effect.Effect<GeminiService, never, HttpClient.HttpClient>;
204
+ /**
205
+ * Layer that registers both the provider-specific `Gemini` tag and the
206
+ * generic `LanguageModel` tag, sharing one underlying implementation.
207
+ *
208
+ * The generic tag accepts `CommonRequest`; the typed tag accepts the full
209
+ * `GeminiRequest` surface.
210
+ */
211
+ declare const layer: (cfg: Config) => Layer.Layer<Gemini | LanguageModel, never, HttpClient.HttpClient>;
212
+ //#endregion
213
+ export { Config, Gemini, GeminiRequest, GeminiService, GoogleModel, ProviderEvent, codec_d_exports as codec, layer, make, toCanonical };
214
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
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 ADDED
@@ -0,0 +1,289 @@
1
+ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
+ import { Array, Context, Effect, Layer, Match, Option, Redacted, Result, Schema, Stream, pipe } from "effect";
3
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4
+ import * as AiError from "@effect-uai/core/AiError";
5
+ import { LanguageModel } from "@effect-uai/core/LanguageModel";
6
+ import { JsonParseError } from "@effect-uai/core/JSONL";
7
+ import * as SSE from "@effect-uai/core/SSE";
8
+ import { matchType } from "@effect-uai/core/Match";
9
+ //#region src/codec.ts
10
+ var codec_exports = /* @__PURE__ */ __exportAll({
11
+ WireChunk: () => WireChunk,
12
+ accumulatorToTurn: () => accumulatorToTurn,
13
+ buildRequestBody: () => buildRequestBody,
14
+ emptyAccumulator: () => emptyAccumulator,
15
+ ingestChunk: () => ingestChunk
16
+ });
17
+ const TextPart = Schema.Struct({
18
+ text: Schema.String,
19
+ /**
20
+ * Gemini's flag for chain-of-thought parts. When `true`, the part is
21
+ * reasoning trace, not the model's user-facing answer. Maps onto
22
+ * `reasoning_delta { kind: "trace" }` in the canonical view.
23
+ */
24
+ thought: Schema.optional(Schema.Boolean)
25
+ });
26
+ const Part = Schema.Union([TextPart]);
27
+ const Content = Schema.Struct({
28
+ role: Schema.optional(Schema.String),
29
+ parts: Schema.optional(Schema.Array(Part))
30
+ });
31
+ const Candidate = Schema.Struct({
32
+ content: Schema.optional(Content),
33
+ finishReason: Schema.optional(Schema.String),
34
+ index: Schema.optional(Schema.Number)
35
+ });
36
+ const UsageMetadata = Schema.Struct({
37
+ promptTokenCount: Schema.optional(Schema.Number),
38
+ candidatesTokenCount: Schema.optional(Schema.Number),
39
+ totalTokenCount: Schema.optional(Schema.Number),
40
+ cachedContentTokenCount: Schema.optional(Schema.Number),
41
+ thoughtsTokenCount: Schema.optional(Schema.Number)
42
+ });
43
+ const WireChunk = Schema.Struct({
44
+ candidates: Schema.optional(Schema.Array(Candidate)),
45
+ usageMetadata: Schema.optional(UsageMetadata)
46
+ });
47
+ const blockText = Match.type().pipe(matchType("input_text", (b) => b.text), matchType("input_image", () => ""), matchType("output_text", (b) => b.text), matchType("refusal", (b) => b.text), Match.exhaustive);
48
+ const messageText = (message) => message.content.map(blockText).join("");
49
+ const blockToParts = Match.type().pipe(matchType("input_text", (b) => b.text.length === 0 ? [] : [{ text: b.text }]), matchType("input_image", (b) => b.source._tag === "base64" ? [{ inlineData: {
50
+ mimeType: b.source.media_type,
51
+ data: b.source.data
52
+ } }] : []), matchType("output_text", (b) => b.text.length === 0 ? [] : [{ text: b.text }]), matchType("refusal", () => []), Match.exhaustive);
53
+ const messageToContent = (message) => {
54
+ const parts = pipe(message.content, Array.flatMap(blockToParts));
55
+ if (parts.length === 0) return Result.failVoid;
56
+ return Match.value(message.role).pipe(Match.when("user", () => Result.succeed({
57
+ role: "user",
58
+ parts
59
+ })), Match.when("assistant", () => Result.succeed({
60
+ role: "model",
61
+ parts
62
+ })), Match.when("system", () => Result.failVoid), Match.exhaustive);
63
+ };
64
+ const systemMessageText = (message) => {
65
+ if (message.role !== "system") return Result.failVoid;
66
+ const text = messageText(message);
67
+ return text.length === 0 ? Result.failVoid : Result.succeed(text);
68
+ };
69
+ const messages = (history) => pipe(history, Array.filterMap((item) => item.type === "message" ? Result.succeed(item) : Result.failVoid));
70
+ const buildRequestBody = (history, generationConfig) => {
71
+ const msgs = messages(history);
72
+ const systemTexts = pipe(msgs, Array.filterMap(systemMessageText));
73
+ return {
74
+ contents: pipe(msgs, Array.filterMap(messageToContent)),
75
+ ...systemTexts.length > 0 && { systemInstruction: { parts: [{ text: systemTexts.join("\n") }] } },
76
+ ...Option.match(generationConfig, {
77
+ onNone: () => ({}),
78
+ onSome: (cfg) => ({ generationConfig: cfg })
79
+ })
80
+ };
81
+ };
82
+ const finishReasonToStop = (reason) => Option.match(reason, {
83
+ onNone: () => "stop",
84
+ onSome: (r) => r === "MAX_TOKENS" ? "max_tokens" : "stop"
85
+ });
86
+ const emptyAccumulator = {
87
+ text: "",
88
+ reasoning: "",
89
+ finishReason: Option.none(),
90
+ usage: {}
91
+ };
92
+ const chunkParts = (chunk) => pipe(chunk.candidates?.[0]?.content?.parts ?? [], Array.filterMap((p) => p.text.length === 0 ? Result.failVoid : Result.succeed({
93
+ kind: p.thought === true ? "reasoning" : "text",
94
+ text: p.text
95
+ })));
96
+ const sumByKind = (parts, kind) => pipe(parts, Array.filterMap((p) => p.kind === kind ? Result.succeed(p.text) : Result.failVoid)).join("");
97
+ const mergeUsage = (prev, next) => next === void 0 ? prev : {
98
+ ...prev,
99
+ ...next.promptTokenCount !== void 0 && { input_tokens: next.promptTokenCount },
100
+ ...next.candidatesTokenCount !== void 0 && { output_tokens: next.candidatesTokenCount },
101
+ ...next.totalTokenCount !== void 0 && { total_tokens: next.totalTokenCount },
102
+ ...next.cachedContentTokenCount !== void 0 && { input_tokens_details: { cached_tokens: next.cachedContentTokenCount } },
103
+ ...next.thoughtsTokenCount !== void 0 && { output_tokens_details: { reasoning_tokens: next.thoughtsTokenCount } }
104
+ };
105
+ const ingestChunk = (acc, chunk) => {
106
+ const parts = chunkParts(chunk);
107
+ const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason);
108
+ return {
109
+ parts,
110
+ finished: Option.isSome(finishReason),
111
+ accumulator: {
112
+ text: acc.text + sumByKind(parts, "text"),
113
+ reasoning: acc.reasoning + sumByKind(parts, "reasoning"),
114
+ finishReason: Option.orElse(finishReason, () => acc.finishReason),
115
+ usage: mergeUsage(acc.usage, chunk.usageMetadata)
116
+ }
117
+ };
118
+ };
119
+ const accumulatorToTurn = (acc) => ({
120
+ stop_reason: finishReasonToStop(acc.finishReason),
121
+ usage: { ...acc.usage },
122
+ items: [...acc.reasoning.length > 0 ? [{
123
+ type: "reasoning",
124
+ summary: acc.reasoning
125
+ }] : [], ...acc.text.length === 0 ? [] : [{
126
+ type: "message",
127
+ role: "assistant",
128
+ content: [{
129
+ type: "output_text",
130
+ text: acc.text
131
+ }]
132
+ }]]
133
+ });
134
+ //#endregion
135
+ //#region src/Gemini.ts
136
+ /**
137
+ * Gemini's native event vocabulary. Aliased from `WireChunk` so the public
138
+ * surface matches `@effect-uai/responses` and `@effect-uai/anthropic`. Each
139
+ * `ProviderEvent` is a full `GenerateContentResponse` chunk (not a per-field
140
+ * delta) - that's how Gemini's SSE works.
141
+ */
142
+ const ProviderEvent = WireChunk;
143
+ /**
144
+ * Provider-typed service tag. Yield this when you want Gemini-specific
145
+ * options (`thinkingBudget`); yield the generic `LanguageModel` tag for
146
+ * provider-portable code. Both are registered by `layer`.
147
+ */
148
+ var Gemini = class extends Context.Service()("@betalyra/effect-uai/providers/google/Gemini") {};
149
+ const buildGenerationConfig = (request) => {
150
+ const cfg = {
151
+ ...request.temperature !== void 0 && { temperature: request.temperature },
152
+ ...request.maxOutputTokens !== void 0 && { maxOutputTokens: request.maxOutputTokens },
153
+ ...request.topP !== void 0 && { topP: request.topP },
154
+ ...request.thinkingBudget !== void 0 && { thinkingConfig: { thinkingBudget: request.thinkingBudget } },
155
+ ...request.structured !== void 0 && {
156
+ responseMimeType: "application/json",
157
+ responseJsonSchema: request.structured.schema["~standard"].jsonSchema.input({ target: "draft-2020-12" })
158
+ }
159
+ };
160
+ return Object.keys(cfg).length === 0 ? Option.none() : Option.some(cfg);
161
+ };
162
+ const decodeChunk = Schema.decodeUnknownEffect(WireChunk);
163
+ /**
164
+ * Parse one SSE event's `data` payload into a `WireChunk`. Unlike the other
165
+ * providers, Gemini's wire vocabulary is non-discriminated (a single
166
+ * permissive struct, all fields optional) - there's no natural shape for
167
+ * an `_unknown` variant, so JSON parse / schema decode failures flow
168
+ * through as transport errors rather than being silently dropped.
169
+ */
170
+ const sseEventToChunk = (ev) => Effect.try({
171
+ try: () => JSON.parse(ev.data),
172
+ catch: (cause) => new JsonParseError({
173
+ line: ev.data,
174
+ cause
175
+ })
176
+ }).pipe(Effect.flatMap(decodeChunk));
177
+ const httpStatusError = (status, body) => {
178
+ const provider = "gemini";
179
+ const raw = body;
180
+ if (status === 429) return new AiError.RateLimited({
181
+ provider,
182
+ raw
183
+ });
184
+ if (status === 408 || status === 504) return new AiError.Timeout({
185
+ provider,
186
+ raw
187
+ });
188
+ if (status === 401) return new AiError.AuthFailed({
189
+ provider,
190
+ subtype: "auth",
191
+ raw
192
+ });
193
+ if (status === 403) return new AiError.AuthFailed({
194
+ provider,
195
+ subtype: "permission",
196
+ raw
197
+ });
198
+ if (status === 402) return new AiError.AuthFailed({
199
+ provider,
200
+ subtype: "billing",
201
+ raw
202
+ });
203
+ if (status === 413) return new AiError.ContextLengthExceeded({
204
+ provider,
205
+ raw
206
+ });
207
+ if (status >= 500) return new AiError.Unavailable({
208
+ provider,
209
+ status,
210
+ raw
211
+ });
212
+ return new AiError.InvalidRequest({
213
+ provider,
214
+ raw
215
+ });
216
+ };
217
+ const buildNativeStream = (cfg) => {
218
+ const baseUrl = cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
219
+ return (request) => Stream.unwrap(Effect.gen(function* () {
220
+ const client = yield* HttpClient.HttpClient;
221
+ const url = `${baseUrl}/models/${request.model}:streamGenerateContent?alt=sse`;
222
+ const generationConfig = buildGenerationConfig(request);
223
+ const body = buildRequestBody(request.history, generationConfig);
224
+ const httpRequest = HttpClientRequest.post(url).pipe(HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)), HttpClientRequest.bodyJsonUnsafe(body), HttpClientRequest.accept("text/event-stream"));
225
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError((cause) => new AiError.Unavailable({
226
+ provider: "gemini",
227
+ raw: cause
228
+ })));
229
+ if (response.status >= 400) {
230
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""));
231
+ return Stream.fail(httpStatusError(response.status, text));
232
+ }
233
+ return response.stream.pipe(Stream.mapError((cause) => new AiError.Unavailable({
234
+ provider: "gemini",
235
+ raw: cause
236
+ })), SSE.fromBytes, Stream.mapEffect((ev) => sseEventToChunk(ev).pipe(Effect.mapError((cause) => new AiError.Unavailable({
237
+ provider: "gemini",
238
+ raw: cause
239
+ })))));
240
+ }));
241
+ };
242
+ /**
243
+ * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
244
+ * Threads a fresh `Accumulator` so chunk-level text/usage merging works
245
+ * across the run.
246
+ */
247
+ const toCanonical = (s) => s.pipe(Stream.mapAccum(() => emptyAccumulator, (acc, chunk) => {
248
+ const result = ingestChunk(acc, chunk);
249
+ const partDeltas = result.parts.map((p) => p.kind === "text" ? {
250
+ type: "text_delta",
251
+ text: p.text
252
+ } : {
253
+ type: "reasoning_delta",
254
+ text: p.text,
255
+ kind: "trace"
256
+ });
257
+ const deltas = result.finished ? [...partDeltas, {
258
+ type: "turn_complete",
259
+ turn: accumulatorToTurn(result.accumulator)
260
+ }] : partDeltas;
261
+ return [result.accumulator, deltas];
262
+ }));
263
+ /**
264
+ * Build a `GeminiService` value. For Layer-based setup, prefer `layer`.
265
+ */
266
+ const make = (cfg) => Effect.map(HttpClient.HttpClient.asEffect(), (client) => {
267
+ const streamNative = (request) => buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client));
268
+ return {
269
+ streamNative,
270
+ streamTurn: (request) => toCanonical(streamNative(request)),
271
+ toCanonical
272
+ };
273
+ });
274
+ /**
275
+ * Layer that registers both the provider-specific `Gemini` tag and the
276
+ * generic `LanguageModel` tag, sharing one underlying implementation.
277
+ *
278
+ * The generic tag accepts `CommonRequest`; the typed tag accepts the full
279
+ * `GeminiRequest` surface.
280
+ */
281
+ const layer = (cfg) => {
282
+ const typed = Layer.effect(Gemini, make(cfg));
283
+ const generic = Layer.effect(LanguageModel, Effect.map(make(cfg), (s) => ({ streamTurn: (request) => s.streamTurn(request) })));
284
+ return Layer.merge(typed, generic);
285
+ };
286
+ //#endregion
287
+ export { Gemini, ProviderEvent, codec_exports as codec, layer, make, toCanonical };
288
+
289
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
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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"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@effect-uai/google",
3
+ "version": "0.1.0",
4
+ "description": "Google Gemini provider for @effect-uai/core.",
5
+ "keywords": [
6
+ "ai",
7
+ "effect",
8
+ "gemini",
9
+ "google",
10
+ "llm"
11
+ ],
12
+ "homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/google",
13
+ "bugs": {
14
+ "url": "https://github.com/betalyra/effect-uai/issues"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Betalyra",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/betalyra/effect-uai",
21
+ "directory": "packages/providers/google"
22
+ },
23
+ "type": "module",
24
+ "main": "./dist/index.mjs",
25
+ "types": "./dist/index.d.mts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.mts",
29
+ "import": "./dist/index.mjs"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "peerDependencies": {
42
+ "effect": "4.0.0-beta.57",
43
+ "@effect-uai/core": "^0.1.0"
44
+ },
45
+ "devDependencies": {
46
+ "effect": "4.0.0-beta.57",
47
+ "typescript": "^6.0.3",
48
+ "@effect-uai/core": "0.1.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsdown",
52
+ "typecheck": "tsc --noEmit"
53
+ }
54
+ }
package/src/Gemini.ts ADDED
@@ -0,0 +1,263 @@
1
+ import { Context, Effect, Layer, Option, Redacted, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import {
5
+ type CommonRequest,
6
+ LanguageModel,
7
+ type LanguageModelService,
8
+ } from "@effect-uai/core/LanguageModel"
9
+ import { JsonParseError } from "@effect-uai/core/JSONL"
10
+ import * as SSE from "@effect-uai/core/SSE"
11
+ import type { TurnEvent } from "@effect-uai/core/Turn"
12
+ import {
13
+ type GenerationConfig,
14
+ WireChunk,
15
+ accumulatorToTurn,
16
+ buildRequestBody,
17
+ emptyAccumulator,
18
+ ingestChunk,
19
+ } from "./codec.js"
20
+ import type { GoogleModel } from "./models.js"
21
+
22
+ /**
23
+ * Gemini's native event vocabulary. Aliased from `WireChunk` so the public
24
+ * surface matches `@effect-uai/responses` and `@effect-uai/anthropic`. Each
25
+ * `ProviderEvent` is a full `GenerateContentResponse` chunk (not a per-field
26
+ * delta) - that's how Gemini's SSE works.
27
+ */
28
+ export const ProviderEvent = WireChunk
29
+ export type ProviderEvent = WireChunk
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Public types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface GeminiRequest extends Omit<CommonRequest, "model"> {
36
+ /**
37
+ * Narrows `CommonRequest.model` (`string`) to the typed `GoogleModel`
38
+ * literal union for autocomplete.
39
+ */
40
+ readonly model: GoogleModel
41
+ /**
42
+ * Gemini 2.5 thinking budget. `0` disables thinking entirely (lowest
43
+ * latency); higher values let the model think longer. Forwarded as
44
+ * `generationConfig.thinkingConfig.thinkingBudget`.
45
+ */
46
+ readonly thinkingBudget?: number
47
+ }
48
+
49
+ export interface GeminiService {
50
+ /**
51
+ * Stream the provider's native event vocabulary (post-SSE-decode). For
52
+ * Gemini, each event is a full `GenerateContentResponse` chunk. Use this
53
+ * when you need access to fields the canonical view doesn't surface
54
+ * (`thoughtSignature`, granular `safetyRatings`, etc.). For
55
+ * provider-portable code, use `streamTurn`.
56
+ */
57
+ readonly streamNative: (request: GeminiRequest) => Stream.Stream<ProviderEvent, AiError.AiError>
58
+ /**
59
+ * Stream canonical `TurnEvent`s. Implemented as
60
+ * `streamNative |> toCanonical`.
61
+ */
62
+ readonly streamTurn: (request: GeminiRequest) => Stream.Stream<TurnEvent, AiError.AiError>
63
+ /**
64
+ * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
65
+ * Threads a fresh `Accumulator` so chunk-level text/usage merging happens
66
+ * per stream.
67
+ */
68
+ readonly toCanonical: <E, R>(
69
+ s: Stream.Stream<ProviderEvent, E, R>,
70
+ ) => Stream.Stream<TurnEvent, E, R>
71
+ }
72
+
73
+ /**
74
+ * Provider-typed service tag. Yield this when you want Gemini-specific
75
+ * options (`thinkingBudget`); yield the generic `LanguageModel` tag for
76
+ * provider-portable code. Both are registered by `layer`.
77
+ */
78
+ export class Gemini extends Context.Service<Gemini, GeminiService>()(
79
+ "@betalyra/effect-uai/providers/google/Gemini",
80
+ ) {}
81
+
82
+ export interface Config {
83
+ readonly apiKey: Redacted.Redacted
84
+ readonly baseUrl?: string
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Request body
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const buildGenerationConfig = (request: GeminiRequest): Option.Option<GenerationConfig> => {
92
+ const cfg: GenerationConfig = {
93
+ ...(request.temperature !== undefined && { temperature: request.temperature }),
94
+ ...(request.maxOutputTokens !== undefined && { maxOutputTokens: request.maxOutputTokens }),
95
+ ...(request.topP !== undefined && { topP: request.topP }),
96
+ ...(request.thinkingBudget !== undefined && {
97
+ thinkingConfig: { thinkingBudget: request.thinkingBudget },
98
+ }),
99
+ ...(request.structured !== undefined && {
100
+ responseMimeType: "application/json",
101
+ responseJsonSchema: request.structured.schema["~standard"].jsonSchema.input({
102
+ target: "draft-2020-12",
103
+ }),
104
+ }),
105
+ }
106
+ return Object.keys(cfg).length === 0 ? Option.none() : Option.some(cfg)
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // SSE event → wire chunk
111
+ // ---------------------------------------------------------------------------
112
+
113
+ const decodeChunk = Schema.decodeUnknownEffect(WireChunk)
114
+
115
+ /**
116
+ * Parse one SSE event's `data` payload into a `WireChunk`. Unlike the other
117
+ * providers, Gemini's wire vocabulary is non-discriminated (a single
118
+ * permissive struct, all fields optional) - there's no natural shape for
119
+ * an `_unknown` variant, so JSON parse / schema decode failures flow
120
+ * through as transport errors rather than being silently dropped.
121
+ */
122
+ const sseEventToChunk = (ev: SSE.Event) =>
123
+ Effect.try({
124
+ try: () => JSON.parse(ev.data) as unknown,
125
+ catch: (cause) => new JsonParseError({ line: ev.data, cause }),
126
+ }).pipe(Effect.flatMap(decodeChunk))
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Service implementation
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const httpStatusError = (status: number, body: string): AiError.AiError => {
133
+ const provider = "gemini"
134
+ const raw = body
135
+ if (status === 429) return new AiError.RateLimited({ provider, raw })
136
+ if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
137
+ if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
138
+ if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
139
+ if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
140
+ if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
141
+ if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
142
+ return new AiError.InvalidRequest({ provider, raw })
143
+ }
144
+
145
+ const buildNativeStream = (cfg: Config) => {
146
+ const baseUrl = cfg.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta"
147
+ return (
148
+ request: GeminiRequest,
149
+ ): Stream.Stream<ProviderEvent, AiError.AiError, HttpClient.HttpClient> =>
150
+ Stream.unwrap(
151
+ Effect.gen(function* () {
152
+ const client = yield* HttpClient.HttpClient
153
+ const url = `${baseUrl}/models/${request.model}:streamGenerateContent?alt=sse`
154
+ const generationConfig = buildGenerationConfig(request)
155
+ const body = buildRequestBody(request.history, generationConfig)
156
+ const httpRequest = HttpClientRequest.post(url).pipe(
157
+ HttpClientRequest.setHeader("x-goog-api-key", Redacted.value(cfg.apiKey)),
158
+ HttpClientRequest.bodyJsonUnsafe(body),
159
+ HttpClientRequest.accept("text/event-stream"),
160
+ )
161
+ const response = yield* client
162
+ .execute(httpRequest)
163
+ .pipe(
164
+ Effect.mapError(
165
+ (cause): AiError.AiError =>
166
+ new AiError.Unavailable({ provider: "gemini", raw: cause }),
167
+ ),
168
+ )
169
+ if (response.status >= 400) {
170
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
171
+ return Stream.fail(httpStatusError(response.status, text))
172
+ }
173
+
174
+ return response.stream.pipe(
175
+ Stream.mapError(
176
+ (cause): AiError.AiError => new AiError.Unavailable({ provider: "gemini", raw: cause }),
177
+ ),
178
+ SSE.fromBytes,
179
+ Stream.mapEffect((ev) =>
180
+ sseEventToChunk(ev).pipe(
181
+ Effect.mapError(
182
+ (cause): AiError.AiError =>
183
+ new AiError.Unavailable({ provider: "gemini", raw: cause }),
184
+ ),
185
+ ),
186
+ ),
187
+ )
188
+ }),
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
194
+ * Threads a fresh `Accumulator` so chunk-level text/usage merging works
195
+ * across the run.
196
+ */
197
+ export const toCanonical = <E, R>(
198
+ s: Stream.Stream<ProviderEvent, E, R>,
199
+ ): Stream.Stream<TurnEvent, E, R> =>
200
+ s.pipe(
201
+ Stream.mapAccum(
202
+ () => emptyAccumulator,
203
+ (acc, chunk) => {
204
+ const result = ingestChunk(acc, chunk)
205
+ const partDeltas = result.parts.map((p) =>
206
+ p.kind === "text"
207
+ ? { type: "text_delta" as const, text: p.text }
208
+ : { type: "reasoning_delta" as const, text: p.text, kind: "trace" as const },
209
+ )
210
+ const deltas = result.finished
211
+ ? [
212
+ ...partDeltas,
213
+ {
214
+ type: "turn_complete" as const,
215
+ turn: accumulatorToTurn(result.accumulator),
216
+ },
217
+ ]
218
+ : partDeltas
219
+ return [result.accumulator, deltas] as const
220
+ },
221
+ ),
222
+ )
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Constructors
226
+ // ---------------------------------------------------------------------------
227
+
228
+ /**
229
+ * Build a `GeminiService` value. For Layer-based setup, prefer `layer`.
230
+ */
231
+ export const make = (cfg: Config): Effect.Effect<GeminiService, never, HttpClient.HttpClient> =>
232
+ Effect.map(HttpClient.HttpClient.asEffect(), (client) => {
233
+ const streamNative: GeminiService["streamNative"] = (request) =>
234
+ buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
235
+ return {
236
+ streamNative,
237
+ streamTurn: (request) => toCanonical(streamNative(request)),
238
+ toCanonical,
239
+ }
240
+ })
241
+
242
+ /**
243
+ * Layer that registers both the provider-specific `Gemini` tag and the
244
+ * generic `LanguageModel` tag, sharing one underlying implementation.
245
+ *
246
+ * The generic tag accepts `CommonRequest`; the typed tag accepts the full
247
+ * `GeminiRequest` surface.
248
+ */
249
+ export const layer = (
250
+ cfg: Config,
251
+ ): Layer.Layer<Gemini | LanguageModel, never, HttpClient.HttpClient> => {
252
+ const typed = Layer.effect(Gemini, make(cfg))
253
+ const generic = Layer.effect(
254
+ LanguageModel,
255
+ Effect.map(
256
+ make(cfg),
257
+ (s): LanguageModelService => ({
258
+ streamTurn: (request) => s.streamTurn(request as GeminiRequest),
259
+ }),
260
+ ),
261
+ )
262
+ return Layer.merge(typed, generic)
263
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,281 @@
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"
4
+ import type { Turn } from "@effect-uai/core/Turn"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Wire schemas - the subset of Gemini's streamGenerateContent payload we
8
+ // consume. Reference: https://ai.google.dev/api/generate-content
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const TextPart = Schema.Struct({
12
+ text: Schema.String,
13
+ /**
14
+ * Gemini's flag for chain-of-thought parts. When `true`, the part is
15
+ * reasoning trace, not the model's user-facing answer. Maps onto
16
+ * `reasoning_delta { kind: "trace" }` in the canonical view.
17
+ */
18
+ thought: Schema.optional(Schema.Boolean),
19
+ })
20
+
21
+ const Part = Schema.Union([TextPart])
22
+
23
+ const Content = Schema.Struct({
24
+ role: Schema.optional(Schema.String),
25
+ parts: Schema.optional(Schema.Array(Part)),
26
+ })
27
+
28
+ const Candidate = Schema.Struct({
29
+ content: Schema.optional(Content),
30
+ finishReason: Schema.optional(Schema.String),
31
+ index: Schema.optional(Schema.Number),
32
+ })
33
+
34
+ const UsageMetadata = Schema.Struct({
35
+ promptTokenCount: Schema.optional(Schema.Number),
36
+ candidatesTokenCount: Schema.optional(Schema.Number),
37
+ totalTokenCount: Schema.optional(Schema.Number),
38
+ cachedContentTokenCount: Schema.optional(Schema.Number),
39
+ thoughtsTokenCount: Schema.optional(Schema.Number),
40
+ })
41
+
42
+ export const WireChunk = Schema.Struct({
43
+ candidates: Schema.optional(Schema.Array(Candidate)),
44
+ usageMetadata: Schema.optional(UsageMetadata),
45
+ })
46
+ export type WireChunk = typeof WireChunk.Type
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // History → request body
50
+ // ---------------------------------------------------------------------------
51
+
52
+ type RequestPart =
53
+ | { readonly text: string }
54
+ | { readonly inlineData: { readonly mimeType: string; readonly data: string } }
55
+
56
+ interface RequestContent {
57
+ readonly role: "user" | "model"
58
+ readonly parts: ReadonlyArray<RequestPart>
59
+ }
60
+
61
+ interface RequestSystemInstruction {
62
+ readonly parts: ReadonlyArray<{ readonly text: string }>
63
+ }
64
+
65
+ export interface ThinkingConfig {
66
+ readonly thinkingBudget: number
67
+ }
68
+
69
+ export interface GenerationConfig {
70
+ readonly temperature?: number
71
+ readonly maxOutputTokens?: number
72
+ readonly topP?: number
73
+ readonly thinkingConfig?: ThinkingConfig
74
+ /** Set together with `responseJsonSchema` to constrain output to JSON. */
75
+ readonly responseMimeType?: string
76
+ /** JSON Schema constraint on the output. */
77
+ readonly responseJsonSchema?: Record<string, unknown>
78
+ }
79
+
80
+ export interface RequestBody {
81
+ readonly contents: ReadonlyArray<RequestContent>
82
+ readonly systemInstruction?: RequestSystemInstruction
83
+ readonly generationConfig?: GenerationConfig
84
+ }
85
+
86
+ const blockText = Match.type<ContentBlock>().pipe(
87
+ matchType("input_text", (b) => b.text),
88
+ matchType("input_image", () => ""),
89
+ matchType("output_text", (b) => b.text),
90
+ matchType("refusal", (b) => b.text),
91
+ Match.exhaustive,
92
+ )
93
+
94
+ const messageText = (message: Message): string => message.content.map(blockText).join("")
95
+
96
+ const blockToParts = Match.type<ContentBlock>().pipe(
97
+ matchType(
98
+ "input_text",
99
+ (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),
100
+ ),
101
+ matchType(
102
+ "input_image",
103
+ (b): ReadonlyArray<RequestPart> =>
104
+ // Gemini's `fileData` form expects a pre-uploaded Files API URI, not an
105
+ // arbitrary HTTPS URL - we'd need to fetch + re-upload to support that.
106
+ // Skip URL-form images for now; document as a follow-up.
107
+ b.source._tag === "base64"
108
+ ? [{ inlineData: { mimeType: b.source.media_type, data: b.source.data } }]
109
+ : [],
110
+ ),
111
+ matchType(
112
+ "output_text",
113
+ (b): ReadonlyArray<RequestPart> => (b.text.length === 0 ? [] : [{ text: b.text }]),
114
+ ),
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
+ Match.exhaustive,
119
+ )
120
+
121
+ const messageToContent = (message: Message): Result.Result<RequestContent, void> => {
122
+ const parts = pipe(message.content, Arr.flatMap(blockToParts))
123
+ if (parts.length === 0) return Result.failVoid
124
+ return Match.value(message.role).pipe(
125
+ Match.when("user", () => Result.succeed({ role: "user" as const, parts })),
126
+ Match.when("assistant", () => Result.succeed({ role: "model" as const, parts })),
127
+ Match.when("system", () => Result.failVoid),
128
+ Match.exhaustive,
129
+ )
130
+ }
131
+
132
+ const systemMessageText = (message: Message): Result.Result<string, void> => {
133
+ if (message.role !== "system") return Result.failVoid
134
+ const text = messageText(message)
135
+ return text.length === 0 ? Result.failVoid : Result.succeed(text)
136
+ }
137
+
138
+ const messages = (history: ReadonlyArray<Item>): ReadonlyArray<Message> =>
139
+ pipe(
140
+ history,
141
+ Arr.filterMap((item) => (item.type === "message" ? Result.succeed(item) : Result.failVoid)),
142
+ )
143
+
144
+ export const buildRequestBody = (
145
+ history: ReadonlyArray<Item>,
146
+ generationConfig: Option.Option<GenerationConfig>,
147
+ ): RequestBody => {
148
+ const msgs = messages(history)
149
+ const systemTexts = pipe(msgs, Arr.filterMap(systemMessageText))
150
+ const contents = pipe(msgs, Arr.filterMap(messageToContent))
151
+ return {
152
+ contents,
153
+ ...(systemTexts.length > 0 && {
154
+ systemInstruction: { parts: [{ text: systemTexts.join("\n") }] },
155
+ }),
156
+ ...Option.match(generationConfig, {
157
+ onNone: () => ({}),
158
+ onSome: (cfg) => ({ generationConfig: cfg }),
159
+ }),
160
+ }
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Stream-level state - accumulate chunk text + final usage/finish.
165
+ //
166
+ // `Accumulator` is immutable; `ingestChunk` returns a fresh one per chunk.
167
+ // Drive it via `Stream.mapAccum` in the consumer.
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const finishReasonToStop = (reason: Option.Option<string>): Turn["stop_reason"] =>
171
+ Option.match(reason, {
172
+ onNone: () => "stop" as const,
173
+ onSome: (r) => (r === "MAX_TOKENS" ? ("max_tokens" as const) : ("stop" as const)),
174
+ })
175
+
176
+ export interface Accumulator {
177
+ readonly text: string
178
+ readonly reasoning: string
179
+ readonly finishReason: Option.Option<string>
180
+ readonly usage: {
181
+ readonly input_tokens?: number
182
+ readonly output_tokens?: number
183
+ readonly total_tokens?: number
184
+ readonly input_tokens_details?: { readonly cached_tokens?: number }
185
+ readonly output_tokens_details?: { readonly reasoning_tokens?: number }
186
+ }
187
+ }
188
+
189
+ export const emptyAccumulator: Accumulator = {
190
+ text: "",
191
+ reasoning: "",
192
+ finishReason: Option.none(),
193
+ usage: {},
194
+ }
195
+
196
+ /**
197
+ * One part's worth of streamable text: either the model's answer
198
+ * (`kind: "text"`) or chain-of-thought (`kind: "reasoning"`). Emitted
199
+ * in wire order so consumers can interleave faithfully.
200
+ */
201
+ export type ChunkPart =
202
+ | { readonly kind: "text"; readonly text: string }
203
+ | { readonly kind: "reasoning"; readonly text: string }
204
+
205
+ export interface ChunkResult {
206
+ readonly accumulator: Accumulator
207
+ readonly parts: ReadonlyArray<ChunkPart>
208
+ readonly finished: boolean
209
+ }
210
+
211
+ const chunkParts = (chunk: WireChunk): ReadonlyArray<ChunkPart> =>
212
+ pipe(
213
+ chunk.candidates?.[0]?.content?.parts ?? [],
214
+ Arr.filterMap((p) =>
215
+ p.text.length === 0
216
+ ? Result.failVoid
217
+ : Result.succeed({
218
+ kind: p.thought === true ? ("reasoning" as const) : ("text" as const),
219
+ text: p.text,
220
+ }),
221
+ ),
222
+ )
223
+
224
+ const sumByKind = (parts: ReadonlyArray<ChunkPart>, kind: ChunkPart["kind"]): string =>
225
+ pipe(
226
+ parts,
227
+ Arr.filterMap((p) => (p.kind === kind ? Result.succeed(p.text) : Result.failVoid)),
228
+ ).join("")
229
+
230
+ const mergeUsage = (
231
+ prev: Accumulator["usage"],
232
+ next: WireChunk["usageMetadata"],
233
+ ): Accumulator["usage"] =>
234
+ next === undefined
235
+ ? prev
236
+ : {
237
+ ...prev,
238
+ ...(next.promptTokenCount !== undefined && { input_tokens: next.promptTokenCount }),
239
+ ...(next.candidatesTokenCount !== undefined && {
240
+ output_tokens: next.candidatesTokenCount,
241
+ }),
242
+ ...(next.totalTokenCount !== undefined && { total_tokens: next.totalTokenCount }),
243
+ ...(next.cachedContentTokenCount !== undefined && {
244
+ input_tokens_details: { cached_tokens: next.cachedContentTokenCount },
245
+ }),
246
+ ...(next.thoughtsTokenCount !== undefined && {
247
+ output_tokens_details: { reasoning_tokens: next.thoughtsTokenCount },
248
+ }),
249
+ }
250
+
251
+ export const ingestChunk = (acc: Accumulator, chunk: WireChunk): ChunkResult => {
252
+ const parts = chunkParts(chunk)
253
+ const finishReason = Option.fromNullishOr(chunk.candidates?.[0]?.finishReason)
254
+ return {
255
+ parts,
256
+ finished: Option.isSome(finishReason),
257
+ accumulator: {
258
+ text: acc.text + sumByKind(parts, "text"),
259
+ reasoning: acc.reasoning + sumByKind(parts, "reasoning"),
260
+ finishReason: Option.orElse(finishReason, () => acc.finishReason),
261
+ usage: mergeUsage(acc.usage, chunk.usageMetadata),
262
+ },
263
+ }
264
+ }
265
+
266
+ export const accumulatorToTurn = (acc: Accumulator): Turn => ({
267
+ stop_reason: finishReasonToStop(acc.finishReason),
268
+ usage: { ...acc.usage },
269
+ items: [
270
+ ...(acc.reasoning.length > 0 ? [{ type: "reasoning" as const, summary: acc.reasoning }] : []),
271
+ ...(acc.text.length === 0
272
+ ? []
273
+ : [
274
+ {
275
+ type: "message" as const,
276
+ role: "assistant" as const,
277
+ content: [{ type: "output_text" as const, text: acc.text }],
278
+ },
279
+ ]),
280
+ ],
281
+ })
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./Gemini.js"
2
+ export * from "./models.js"
3
+ export * as codec from "./codec.js"
package/src/models.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Known Gemini model identifiers (as of April 2026). The `(string & {})`
3
+ * tail keeps autocomplete on the literals while still accepting any string,
4
+ * so newly-released models work without an SDK update.
5
+ *
6
+ * Reference: https://ai.google.dev/gemini-api/docs/models
7
+ */
8
+ export type GoogleModel =
9
+ | "gemini-3.1-pro-preview"
10
+ | "gemini-3-flash-preview"
11
+ | "gemini-3.1-flash-lite-preview"
12
+ | "gemini-3.1-flash-live-preview"
13
+ | "gemini-3.1-flash-tts-preview"
14
+ | "gemini-2.5-pro"
15
+ | "gemini-2.5-flash"
16
+ | "gemini-2.5-flash-lite"
17
+ // eslint-disable-next-line @typescript-eslint/ban-types
18
+ | (string & {})