@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.
- package/CHANGELOG.md +14 -0
- package/README.md +77 -9
- package/dist/AmazonBedrockClient.d.ts +135 -23
- package/dist/AmazonBedrockClient.d.ts.map +1 -1
- package/dist/AmazonBedrockClient.js +36 -92
- package/dist/AmazonBedrockClient.js.map +1 -1
- package/dist/AmazonBedrockConfig.d.ts +6 -6
- package/dist/AmazonBedrockConfig.js +4 -4
- package/dist/AmazonBedrockEventStream.d.ts +2 -2
- package/dist/AmazonBedrockEventStream.js +3 -3
- package/dist/AmazonBedrockLanguageModel.d.ts +22 -22
- package/dist/AmazonBedrockLanguageModel.js +7 -7
- package/dist/AmazonBedrockSchema.d.ts +52 -52
- package/dist/AmazonBedrockSchema.js +49 -49
- package/dist/index.d.ts +6 -6
- package/dist/index.js +6 -6
- package/dist/internal/credentials.d.ts +79 -0
- package/dist/internal/credentials.d.ts.map +1 -0
- package/dist/internal/credentials.js +241 -0
- package/dist/internal/credentials.js.map +1 -0
- package/dist/internal/errors.d.ts +2 -0
- package/dist/internal/errors.d.ts.map +1 -1
- package/dist/internal/errors.js +6 -0
- package/dist/internal/errors.js.map +1 -1
- package/package.json +1 -1
- package/src/AmazonBedrockClient.ts +184 -95
- package/src/AmazonBedrockConfig.ts +6 -6
- package/src/AmazonBedrockEventStream.ts +3 -3
- package/src/AmazonBedrockLanguageModel.ts +23 -23
- package/src/AmazonBedrockSchema.ts +52 -52
- package/src/index.ts +6 -6
- package/src/internal/credentials.ts +319 -0
- package/src/internal/errors.ts +5 -0
|
@@ -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
|
+
}
|
package/src/internal/errors.ts
CHANGED
|
@@ -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
|