Skip to content

Commit 85f1168

Browse files
committed
feat(ai): add Google Vertex AI provider
1 parent ec5c505 commit 85f1168

16 files changed

Lines changed: 874 additions & 4 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"$schema": "../../../node_modules/@effect/docgen/schema.json",
3+
"exclude": [
4+
"src/internal/**/*.ts"
5+
],
6+
"srcLink": "https://github.com/Effect-TS/effect/tree/main/packages/ai/google-vertex/src/",
7+
"examplesCompilerOptions": {
8+
"noEmit": true,
9+
"strict": true,
10+
"skipLibCheck": true,
11+
"moduleResolution": "Bundler",
12+
"module": "ES2022",
13+
"target": "ES2022",
14+
"lib": [
15+
"ES2022",
16+
"DOM",
17+
"DOM.Iterable"
18+
],
19+
"paths": {
20+
"effect": ["../../../../effect/src/index.js"],
21+
"effect/*": ["../../../../effect/src/*.js"],
22+
"@effect/experimental": ["../../../../experimental/src/index.js"],
23+
"@effect/experimental/*": ["../../../../experimental/src/*.js"],
24+
"@effect/platform": ["../../../../platform/src/index.js"],
25+
"@effect/platform/*": ["../../../../platform/src/*.js"],
26+
"@effect/ai": ["../../../ai/src/index.js"],
27+
"@effect/ai/*": ["../../../ai/src/*.js"],
28+
"@effect/ai-google": ["../../../google/src/index.js"],
29+
"@effect/ai-google/*": ["../../../google/src/*.js"],
30+
"@effect/ai-google-vertex": ["../../../google-vertex/src/index.js"],
31+
"@effect/ai-google-vertex/*": ["../../../google-vertex/src/*.js"]
32+
}
33+
}
34+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"name": "@effect/ai-google-vertex",
3+
"type": "module",
4+
"version": "0.1.0",
5+
"license": "MIT",
6+
"description": "Effect modules for working with Google Vertex AI APIs",
7+
"homepage": "https://effect.website",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/Effect-TS/effect.git",
11+
"directory": "packages/ai/google-vertex"
12+
},
13+
"bugs": {
14+
"url": "https://github.com/Effect-TS/effect/issues"
15+
},
16+
"tags": [
17+
"typescript",
18+
"algebraic-data-types",
19+
"functional-programming"
20+
],
21+
"keywords": [
22+
"typescript",
23+
"algebraic-data-types",
24+
"functional-programming"
25+
],
26+
"publishConfig": {
27+
"access": "public",
28+
"provenance": true,
29+
"directory": "dist",
30+
"linkDirectory": false
31+
},
32+
"exports": {
33+
"./package.json": "./package.json",
34+
".": "./src/index.ts",
35+
"./*": "./src/*.ts",
36+
"./internal/*": null
37+
},
38+
"scripts": {
39+
"codegen": "build-utils prepare-v3",
40+
"build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v3",
41+
"build-esm": "tsc -b tsconfig.build.json",
42+
"build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps",
43+
"build-annotate": "babel build/esm --plugins annotate-pure-calls --out-dir build/esm --source-maps",
44+
"check": "tsc -b tsconfig.json",
45+
"test": "vitest",
46+
"coverage": "vitest --coverage"
47+
},
48+
"peerDependencies": {
49+
"@effect/ai": "workspace:^",
50+
"@effect/ai-google": "workspace:^",
51+
"@effect/experimental": "workspace:^",
52+
"@effect/platform": "workspace:^",
53+
"effect": "workspace:^"
54+
},
55+
"devDependencies": {
56+
"@effect/ai": "workspace:^",
57+
"@effect/ai-google": "workspace:^",
58+
"@effect/experimental": "workspace:^",
59+
"@effect/platform": "workspace:^",
60+
"@effect/platform-node": "workspace:^",
61+
"effect": "workspace:^"
62+
}
63+
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* @since 1.0.0
3+
*/
4+
import * as Generated from "@effect/ai-google/Generated"
5+
import * as AiError from "@effect/ai/AiError"
6+
import * as Sse from "@effect/experimental/Sse"
7+
import * as Headers from "@effect/platform/Headers"
8+
import * as HttpBody from "@effect/platform/HttpBody"
9+
import * as HttpClient from "@effect/platform/HttpClient"
10+
import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
11+
import * as UrlParams from "@effect/platform/UrlParams"
12+
import * as Arr from "effect/Array"
13+
import * as Chunk from "effect/Chunk"
14+
import * as Config from "effect/Config"
15+
import type { ConfigError } from "effect/ConfigError"
16+
import * as Context from "effect/Context"
17+
import * as Effect from "effect/Effect"
18+
import { identity } from "effect/Function"
19+
import * as Layer from "effect/Layer"
20+
import * as Predicate from "effect/Predicate"
21+
import type * as Redacted from "effect/Redacted"
22+
import * as Schema from "effect/Schema"
23+
import type * as Scope from "effect/Scope"
24+
import * as Stream from "effect/Stream"
25+
import { GoogleVertexConfig } from "./GoogleVertexConfig.js"
26+
27+
/**
28+
* @since 1.0.0
29+
* @category Context
30+
*/
31+
export class GoogleVertexClient extends Context.Tag(
32+
"@effect/ai-google-vertex/GoogleVertexClient"
33+
)<GoogleVertexClient, Service>() {}
34+
35+
/**
36+
* @since 1.0.0
37+
* @category Models
38+
*/
39+
export interface Service {
40+
readonly streamRequest: <A, I, R>(
41+
request: HttpClientRequest.HttpClientRequest,
42+
schema: Schema.Schema<A, I, R>
43+
) => Stream.Stream<A, AiError.AiError, R>
44+
45+
readonly generateContent: (
46+
request: typeof Generated.GenerateContentRequest.Encoded
47+
) => Effect.Effect<Generated.GenerateContentResponse, AiError.AiError>
48+
49+
readonly generateContentStream: (
50+
request: typeof Generated.GenerateContentRequest.Encoded
51+
) => Stream.Stream<Generated.GenerateContentResponse, AiError.AiError>
52+
}
53+
54+
/**
55+
* @since 1.0.0
56+
* @category Constructors
57+
*/
58+
export const make = (options: {
59+
/**
60+
* The GCP project ID.
61+
*/
62+
readonly project: string
63+
64+
/**
65+
* The GCP location / region (e.g. `"us-central1"`).
66+
*/
67+
readonly location: string
68+
69+
/**
70+
* An OAuth2 access token for authenticating with Vertex AI.
71+
*
72+
* Sent as `Authorization: Bearer <token>`. When omitted, authentication
73+
* must be handled via `transformClient` (e.g. injecting a fresh token on
74+
* each request from ADC or a token-refresh Effect).
75+
*/
76+
readonly accessToken?: Redacted.Redacted | undefined
77+
78+
/**
79+
* The Vertex AI API version.
80+
*
81+
* Defaults to `"v1"`.
82+
*/
83+
readonly apiVersion?: "v1" | "v1beta1" | undefined
84+
85+
/**
86+
* Override the base URL for the Vertex AI API.
87+
*
88+
* Defaults to the regional endpoint:
89+
* `https://{location}-aiplatform.googleapis.com`
90+
*/
91+
readonly apiUrl?: string | undefined
92+
93+
/**
94+
* A method which can be used to transform the underlying `HttpClient` which
95+
* will be used to communicate with the Vertex AI API.
96+
*/
97+
readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
98+
}): Effect.Effect<Service, never, HttpClient.HttpClient | Scope.Scope> =>
99+
Effect.gen(function*() {
100+
const authHeader = "authorization"
101+
102+
yield* Effect.locallyScopedWith(Headers.currentRedactedNames, Arr.append(authHeader))
103+
104+
const baseUrl = options.apiUrl ?? `https://${options.location}-aiplatform.googleapis.com`
105+
const apiVersion = options.apiVersion ?? "v1"
106+
const pathPrefix =
107+
`/${apiVersion}/projects/${options.project}/locations/${options.location}/publishers/google/models`
108+
109+
let httpClient = (yield* HttpClient.HttpClient).pipe(
110+
HttpClient.mapRequest((request) =>
111+
request.pipe(
112+
HttpClientRequest.prependUrl(baseUrl),
113+
options.accessToken
114+
? (r) => HttpClientRequest.bearerToken(r, options.accessToken!)
115+
: identity,
116+
HttpClientRequest.acceptJson
117+
)
118+
)
119+
)
120+
121+
httpClient = options.transformClient ? options.transformClient(httpClient) : httpClient
122+
123+
const httpClientOk = HttpClient.filterStatusOk(httpClient)
124+
125+
const streamRequest = <A, I, R>(
126+
request: HttpClientRequest.HttpClientRequest,
127+
schema: Schema.Schema<A, I, R>
128+
): Stream.Stream<A, AiError.AiError, R> => {
129+
const decodeEvents = Schema.decode(Schema.ChunkFromSelf(Schema.parseJson(schema)))
130+
return httpClientOk.execute(request).pipe(
131+
Effect.map((r) => r.stream),
132+
Stream.unwrap,
133+
Stream.decodeText(),
134+
Stream.pipeThroughChannel(Sse.makeChannel()),
135+
Stream.mapChunksEffect((chunk) => decodeEvents(Chunk.map(chunk, (event) => event.data))),
136+
Stream.catchTags({
137+
RequestError: (error) =>
138+
AiError.HttpRequestError.fromRequestError({
139+
module: "GoogleVertexClient",
140+
method: "streamRequest",
141+
error
142+
}),
143+
ResponseError: (error) =>
144+
AiError.HttpResponseError.fromResponseError({
145+
module: "GoogleVertexClient",
146+
method: "streamRequest",
147+
error
148+
}),
149+
ParseError: (error) =>
150+
AiError.MalformedOutput.fromParseError({
151+
module: "GoogleVertexClient",
152+
method: "streamRequest",
153+
error
154+
})
155+
})
156+
)
157+
}
158+
159+
const decodeResponse = Schema.decodeUnknown(Generated.GenerateContentResponse)
160+
161+
const generateContent: (
162+
request: typeof Generated.GenerateContentRequest.Encoded
163+
) => Effect.Effect<Generated.GenerateContentResponse, AiError.AiError> = Effect.fnUntraced(
164+
function*(request) {
165+
const config = yield* GoogleVertexConfig.getOrUndefined
166+
const effectiveClient = config?.transformClient
167+
? HttpClient.filterStatusOk(config.transformClient(httpClient))
168+
: httpClientOk
169+
const url = `${pathPrefix}/${request.model}:generateContent`
170+
const httpRequest = HttpClientRequest.post(url, {
171+
body: HttpBody.unsafeJson(request)
172+
})
173+
return yield* effectiveClient.execute(httpRequest).pipe(
174+
Effect.flatMap((r) => r.json),
175+
Effect.flatMap(decodeResponse),
176+
Effect.scoped,
177+
Effect.catchTags({
178+
RequestError: (error) =>
179+
AiError.HttpRequestError.fromRequestError({
180+
module: "GoogleVertexClient",
181+
method: "generateContent",
182+
error
183+
}),
184+
ResponseError: (error) =>
185+
AiError.HttpResponseError.fromResponseError({
186+
module: "GoogleVertexClient",
187+
method: "generateContent",
188+
error
189+
}),
190+
ParseError: (error) =>
191+
AiError.MalformedOutput.fromParseError({
192+
module: "GoogleVertexClient",
193+
method: "generateContent",
194+
error
195+
})
196+
})
197+
)
198+
}
199+
)
200+
201+
const generateContentStream = (
202+
request: typeof Generated.GenerateContentRequest.Encoded
203+
): Stream.Stream<Generated.GenerateContentResponse, AiError.AiError> => {
204+
const url = `${pathPrefix}/${request.model}:streamGenerateContent`
205+
const httpRequest = HttpClientRequest.post(url, {
206+
urlParams: UrlParams.fromInput({ "alt": "sse" }),
207+
body: HttpBody.unsafeJson(request)
208+
})
209+
return streamRequest(httpRequest, Generated.GenerateContentResponse).pipe(
210+
Stream.takeUntil(hasFinishReason)
211+
)
212+
}
213+
214+
return GoogleVertexClient.of({
215+
streamRequest,
216+
generateContent,
217+
generateContentStream
218+
})
219+
})
220+
221+
/**
222+
* @since 1.0.0
223+
* @category Layers
224+
*/
225+
export const layer = (options: {
226+
readonly project: string
227+
readonly location: string
228+
readonly accessToken?: Redacted.Redacted | undefined
229+
readonly apiVersion?: "v1" | "v1beta1" | undefined
230+
readonly apiUrl?: string | undefined
231+
readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
232+
}): Layer.Layer<
233+
GoogleVertexClient,
234+
never,
235+
HttpClient.HttpClient
236+
> => Layer.scoped(GoogleVertexClient, make(options))
237+
238+
/**
239+
* @since 1.0.0
240+
* @category Layers
241+
*/
242+
export const layerConfig = (
243+
options: {
244+
readonly project: Config.Config<string>
245+
readonly location: Config.Config<string>
246+
readonly accessToken?: Config.Config<Redacted.Redacted | undefined> | undefined
247+
readonly apiVersion?: Config.Config<"v1" | "v1beta1" | undefined> | undefined
248+
readonly apiUrl?: Config.Config<string | undefined> | undefined
249+
readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
250+
}
251+
): Layer.Layer<GoogleVertexClient, ConfigError, HttpClient.HttpClient> => {
252+
const { transformClient, ...configs } = options
253+
return Config.all(configs).pipe(
254+
Effect.flatMap((configs) => make({ ...configs, transformClient })),
255+
Layer.scoped(GoogleVertexClient)
256+
)
257+
}
258+
259+
// =============================================================================
260+
// Utilities
261+
// =============================================================================
262+
263+
const hasFinishReason = (event: Generated.GenerateContentResponse): boolean =>
264+
Predicate.isNotUndefined(event.candidates) &&
265+
event.candidates.some((candidate) => Predicate.isNotUndefined(candidate.finishReason))

0 commit comments

Comments
 (0)