@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 +21 -0
- package/README.md +42 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/index.d.mts +214 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +289 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/Gemini.ts +263 -0
- package/src/codec.ts +281 -0
- package/src/index.ts +3 -0
- package/src/models.ts +18 -0
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 };
|
package/dist/index.d.mts
ADDED
|
@@ -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
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 & {})
|