@wmaurer/effect-ai-amazon-bedrock 0.1.0 → 0.2.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.
@@ -0,0 +1,319 @@
1
+ import { AwsV4Signer } from "aws4fetch"
2
+ import * as AiError from "effect/ai/AiError"
3
+ import * as Clock from "effect/Clock"
4
+ import * as Context from "effect/Context"
5
+ import * as Effect from "effect/Effect"
6
+ import * as Exit from "effect/Exit"
7
+ import * as Headers from "effect/http/Headers"
8
+ import type * as HttpBody from "effect/http/HttpBody"
9
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
10
+ import * as Redacted from "effect/Redacted"
11
+ import * as Result from "effect/Result"
12
+ import type { CredentialProvider, Credentials } from "../AmazonBedrockClient.ts"
13
+
14
+ // =============================================================================
15
+ // Models
16
+ // =============================================================================
17
+
18
+ /**
19
+ * The authentication fields of the client's options. Every field is optional
20
+ * here, because a caller in untyped JavaScript can mix the three ways to
21
+ * authenticate that the public `Options` type keeps apart.
22
+ *
23
+ * @internal
24
+ */
25
+ export interface AuthenticationOptions<R> {
26
+ readonly accessKeyId?: string | undefined
27
+ readonly secretAccessKey?: Redacted.Redacted<string> | undefined
28
+ readonly sessionToken?: Redacted.Redacted<string> | undefined
29
+ readonly credentialProvider?: CredentialProvider<R> | undefined
30
+ readonly apiKey?: Redacted.Redacted<string> | undefined
31
+ }
32
+
33
+ /**
34
+ * What one request is authenticated with.
35
+ *
36
+ * @internal
37
+ */
38
+ export type RequestCredentials = Bearer | SigV4
39
+
40
+ /** @internal */
41
+ export interface Bearer {
42
+ readonly _tag: "Bearer"
43
+ readonly apiKey: Redacted.Redacted<string>
44
+ }
45
+
46
+ /** @internal */
47
+ export interface SigV4 {
48
+ readonly _tag: "SigV4"
49
+ readonly accessKeyId: string
50
+ readonly secretAccessKey: Redacted.Redacted<string>
51
+ readonly sessionToken: Redacted.Redacted<string> | undefined
52
+ readonly expiration: Date | undefined
53
+ }
54
+
55
+ type Authentication<R> =
56
+ | { readonly _tag: "ApiKey"; readonly credentials: Bearer }
57
+ | { readonly _tag: "Static"; readonly credentials: SigV4 }
58
+ | { readonly _tag: "Provider"; readonly provider: CredentialProvider<R> }
59
+
60
+ /** @internal */
61
+ export interface Authenticator {
62
+ /**
63
+ * The credentials for the next request. Only a credential provider can
64
+ * fail here.
65
+ */
66
+ readonly resolve: Effect.Effect<RequestCredentials, AiError.AiError | AiError.AiErrorReason>
67
+ /**
68
+ * Drops `rejected` from the cache if they are still the latest credentials
69
+ * from the provider, so that the next `resolve` asks the provider again with
70
+ * `forceRefresh`. Rejections of credentials that are no longer the latest do
71
+ * nothing, so many rejections of one set of credentials cause one refetch.
72
+ */
73
+ readonly invalidate: (rejected: RequestCredentials) => Effect.Effect<void>
74
+ }
75
+
76
+ // =============================================================================
77
+ // Constructor
78
+ // =============================================================================
79
+
80
+ /** @internal */
81
+ export const make = <R = never>(options: AuthenticationOptions<R>): Effect.Effect<Authenticator, never, R> =>
82
+ Effect.flatMap(authentication(options), (authentication): Effect.Effect<Authenticator, never, R> => {
83
+ switch (authentication._tag) {
84
+ case "ApiKey":
85
+ case "Static": {
86
+ return Effect.succeed({
87
+ resolve: Effect.succeed(authentication.credentials),
88
+ invalidate: () => Effect.void
89
+ })
90
+ }
91
+ case "Provider": {
92
+ return fromProvider(authentication.provider)
93
+ }
94
+ }
95
+ })
96
+
97
+ const authentication = <R>(options: AuthenticationOptions<R>): Effect.Effect<Authentication<R>> => {
98
+ const { accessKeyId, apiKey, credentialProvider, secretAccessKey, sessionToken } = options
99
+ const staticKeys = accessKeyId !== undefined || secretAccessKey !== undefined || sessionToken !== undefined
100
+ const ways = [staticKeys, credentialProvider !== undefined, apiKey !== undefined].filter(Boolean).length
101
+ if (ways === 1) {
102
+ if (apiKey !== undefined) {
103
+ return Effect.succeed({ _tag: "ApiKey", credentials: { _tag: "Bearer", apiKey } })
104
+ }
105
+ if (credentialProvider !== undefined) {
106
+ return Effect.succeed({ _tag: "Provider", provider: credentialProvider })
107
+ }
108
+ if (accessKeyId !== undefined && secretAccessKey !== undefined) {
109
+ return Effect.succeed({
110
+ _tag: "Static",
111
+ credentials: sigV4({ accessKeyId, secretAccessKey, sessionToken, expiration: undefined })
112
+ })
113
+ }
114
+ }
115
+ return Effect.die(
116
+ new Error(
117
+ "AmazonBedrockClient takes exactly one of `apiKey`, `credentialProvider`, or `accessKeyId` with `secretAccessKey`"
118
+ )
119
+ )
120
+ }
121
+
122
+ const sigV4 = (credentials: Omit<SigV4, "_tag">): SigV4 => ({
123
+ _tag: "SigV4",
124
+ ...credentials,
125
+ sessionToken: credentials.sessionToken !== undefined && Redacted.value(credentials.sessionToken) !== ""
126
+ ? credentials.sessionToken
127
+ : undefined
128
+ })
129
+
130
+ // =============================================================================
131
+ // Credential Provider
132
+ // =============================================================================
133
+
134
+ /** Matches the refresh window of the AWS SDK's `memoizeChain`. */
135
+ const refreshBeforeExpiration = 5 * 60 * 1000
136
+ /** Keeps a provider without a cache of its own from running on every request. */
137
+ const minimumTimeToLive = 10 * 1000
138
+ /**
139
+ * How long the last good credentials are served while the provider fails, or
140
+ * until they expire if that is sooner.
141
+ */
142
+ const fallbackTimeToLive = 30 * 1000
143
+
144
+ const timeToLive = (expiration: Date | undefined, now: number): number => {
145
+ if (expiration === undefined) {
146
+ return Infinity
147
+ }
148
+ const remaining = expiration.getTime() - now
149
+ return Math.max(0, remaining - refreshBeforeExpiration, Math.min(minimumTimeToLive, remaining))
150
+ }
151
+
152
+ const parse = (credentials: Credentials): Effect.Effect<SigV4, AiError.AuthenticationError> =>
153
+ credentials.accessKeyId && credentials.secretAccessKey
154
+ ? Effect.succeed(sigV4({
155
+ accessKeyId: credentials.accessKeyId,
156
+ secretAccessKey: Redacted.make(credentials.secretAccessKey),
157
+ sessionToken: credentials.sessionToken !== undefined ? Redacted.make(credentials.sessionToken) : undefined,
158
+ expiration: credentials.expiration
159
+ }))
160
+ : Effect.fail(
161
+ new AiError.AuthenticationError({
162
+ kind: "MissingKey",
163
+ description: "The credential provider returned an empty access key ID or secret access key"
164
+ })
165
+ )
166
+
167
+ const fromProvider = <R>(provider: CredentialProvider<R>): Effect.Effect<Authenticator, never, R> =>
168
+ Effect.gen(function*() {
169
+ // As in Toolkit, the services of the request fiber win, so that its span is
170
+ // the parent of the provider's spans. The services of the build fill in R.
171
+ const services = yield* Effect.context<R>()
172
+ let lastGood: SigV4 | undefined
173
+ let forceNext = false
174
+
175
+ const fetch = (forceRefresh: boolean): Effect.Effect<Credentials, AiError.AiError | AiError.AiErrorReason> =>
176
+ Effect.isEffect(provider)
177
+ ? Effect.updateContext(provider, (input: Context.Context<never>) => Context.merge(services, input))
178
+ : Effect.tryPromise({
179
+ try: () => forceRefresh ? provider({ forceRefresh }) : provider(),
180
+ catch: (cause) =>
181
+ new AiError.AuthenticationError({
182
+ kind: "Unknown",
183
+ description: `The credential provider failed: ${cause instanceof Error ? cause.message : String(cause)}`
184
+ })
185
+ })
186
+
187
+ const load = Effect.gen(function*() {
188
+ const forceRefresh = forceNext
189
+ forceNext = false
190
+ const result = yield* Effect.result(Effect.flatMap(fetch(forceRefresh), parse))
191
+ const now = yield* Clock.currentTimeMillis
192
+ if (Result.isSuccess(result)) {
193
+ lastGood = result.success
194
+ return { credentials: result.success, timeToLive: timeToLive(result.success.expiration, now) }
195
+ }
196
+ if (lastGood !== undefined && (lastGood.expiration === undefined || now < lastGood.expiration.getTime())) {
197
+ return {
198
+ credentials: lastGood,
199
+ timeToLive: lastGood.expiration === undefined
200
+ ? fallbackTimeToLive
201
+ : Math.min(fallbackTimeToLive, lastGood.expiration.getTime() - now)
202
+ }
203
+ }
204
+ return yield* result.failure
205
+ })
206
+
207
+ const [cached, clear] = yield* Effect.cachedInvalidateWithTTL(
208
+ load,
209
+ (exit) => Exit.isSuccess(exit) ? exit.value.timeToLive : 0
210
+ )
211
+
212
+ return {
213
+ resolve: Effect.map(cached, ({ credentials }) => credentials),
214
+ invalidate: (rejected) =>
215
+ Effect.suspend(() => {
216
+ if (rejected !== lastGood) {
217
+ return Effect.void
218
+ }
219
+ lastGood = undefined
220
+ forceNext = true
221
+ return clear
222
+ })
223
+ }
224
+ })
225
+
226
+ // =============================================================================
227
+ // Requests
228
+ // =============================================================================
229
+
230
+ /**
231
+ * The credentials of the request in flight. The client resolves them before
232
+ * `HttpClient.execute` and provides them around it, so that a resolution
233
+ * failure is not an `HttpClientError` that a retry policy would retry, and so
234
+ * that every retry inside `execute` signs again with them.
235
+ *
236
+ * @internal
237
+ */
238
+ export const CurrentRequestCredentials = Context.Reference<RequestCredentials | undefined>(
239
+ "@wmaurer/effect-ai-amazon-bedrock/internal/credentials/CurrentRequestCredentials",
240
+ { defaultValue: () => undefined }
241
+ )
242
+
243
+ /**
244
+ * Signs the request with SigV4, or sets the bearer header of an API key, from
245
+ * {@link CurrentRequestCredentials}.
246
+ *
247
+ * @internal
248
+ */
249
+ export const authenticateRequest = (region: string) =>
250
+ Effect.fnUntraced(function*(request: HttpClientRequest.HttpClientRequest) {
251
+ const credentials = yield* CurrentRequestCredentials
252
+ if (credentials === undefined) {
253
+ return yield* Effect.die(new Error("AmazonBedrockClient sent a request without resolving its credentials"))
254
+ }
255
+ switch (credentials._tag) {
256
+ case "Bearer": {
257
+ return HttpClientRequest.bearerToken(request, credentials.apiKey)
258
+ }
259
+ case "SigV4": {
260
+ const signer = new AwsV4Signer({
261
+ service: "bedrock",
262
+ url: request.url,
263
+ method: request.method,
264
+ headers: Object.entries(request.headers),
265
+ body: prepareBody(request.body),
266
+ region,
267
+ accessKeyId: credentials.accessKeyId,
268
+ secretAccessKey: Redacted.value(credentials.secretAccessKey),
269
+ ...(credentials.sessionToken !== undefined ? { sessionToken: Redacted.value(credentials.sessionToken) } : {})
270
+ })
271
+ const { headers } = yield* Effect.promise(() => signer.sign())
272
+ return HttpClientRequest.setHeaders(request, Headers.merge(request.headers, Headers.fromInput(headers)))
273
+ }
274
+ }
275
+ })
276
+
277
+ /**
278
+ * Kinds of `AuthenticationError` that say the credentials themselves were
279
+ * rejected. `InsufficientPermissions` is not one of them, because new
280
+ * credentials for the same role do not change its IAM policy.
281
+ */
282
+ const rejectedKinds: ReadonlySet<AiError.AuthenticationError["kind"]> = new Set([
283
+ "ExpiredKey",
284
+ "InvalidKey",
285
+ "MissingKey"
286
+ ])
287
+
288
+ /**
289
+ * Invalidates `credentials` when `error` says Bedrock rejected them.
290
+ *
291
+ * @internal
292
+ */
293
+ export const invalidateIfRejected =
294
+ (authenticator: Authenticator, credentials: RequestCredentials) => (error: AiError.AiError): Effect.Effect<void> =>
295
+ error.reason._tag === "AuthenticationError" && rejectedKinds.has(error.reason.kind)
296
+ ? authenticator.invalidate(credentials)
297
+ : Effect.void
298
+
299
+ const prepareBody = (body: HttpBody.HttpBody): string => {
300
+ switch (body._tag) {
301
+ case "Empty": {
302
+ return ""
303
+ }
304
+ case "Raw":
305
+ case "Uint8Array": {
306
+ if (typeof body.body === "string") {
307
+ return body.body
308
+ }
309
+ if (body.body instanceof Uint8Array) {
310
+ return new TextDecoder().decode(body.body)
311
+ }
312
+ if (body.body instanceof ArrayBuffer) {
313
+ return new TextDecoder().decode(body.body)
314
+ }
315
+ return JSON.stringify(body.body)
316
+ }
317
+ }
318
+ throw new Error("Unsupported HttpBody: " + body._tag)
319
+ }
@@ -105,6 +105,11 @@ export const mapHttpClientError = dual<
105
105
  }
106
106
  })
107
107
 
108
+ /** @internal */
109
+ export const mapCredentialProviderError =
110
+ (method: string) => (error: AiError.AiError | AiError.AiErrorReason): AiError.AiError =>
111
+ AiError.isAiError(error) ? error : AiError.make({ module: "AmazonBedrockClient", method, reason: error })
112
+
108
113
  // Bedrock REST errors are FLAT `{ "message": "..." }` (AWS uses both casings),
109
114
  // with the exception type carried in the `x-amzn-errortype` header — NOT
110
115
  // Anthropic's nested `{ error: { type, message } }`. Schema-guarded so a