@kindgi/adapter-model-bedrock 0.0.0-stage → 0.1.7
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 +201 -0
- package/README.md +87 -2
- package/dist/config.d.ts +43 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +159 -0
- package/dist/config.js.map +1 -0
- package/dist/entry.d.ts +17 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +210 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/nova-thinking.d.ts +15 -0
- package/dist/nova-thinking.d.ts.map +1 -0
- package/dist/nova-thinking.js +28 -0
- package/dist/nova-thinking.js.map +1 -0
- package/package.json +51 -3
- package/src/config.ts +201 -0
- package/src/entry.ts +274 -0
- package/src/index.ts +20 -0
- package/src/nova-thinking.ts +41 -0
package/src/entry.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Amazon Bedrock adapter: a registration's models (model or inference-profile ids, such as
|
|
6
|
+
* `us.amazon.nova-pro-v1:0`) on Bedrock's Converse API, through `@kindgi/adapter-model-shared`
|
|
7
|
+
* (retries, typed errors, the reasoning state across a pause, usage and cost).
|
|
8
|
+
*
|
|
9
|
+
* Pinned, each with a test:
|
|
10
|
+
* - It signs in before each attempt (`beforeAttempt`), with the call's abort signal, and the
|
|
11
|
+
* attempt's request reads what it got (`attemptPrepared()`): calls at once never share one,
|
|
12
|
+
* and a failure is typed there (the library would report a credential failure as a plain
|
|
13
|
+
* error, and retry it).
|
|
14
|
+
* - `auth: aws-identity` (the default): requests signed with SigV4, with credentials from the
|
|
15
|
+
* runtime's AWS identity (`AdapterFactoryInput.identities.aws`), asked for every attempt (it
|
|
16
|
+
* refreshes them itself), within 10 seconds. `apiKey: ''` keeps a stray
|
|
17
|
+
* `AWS_BEARER_TOKEN_BEDROCK` from switching the provider to a bearer key. Only Bedrock's
|
|
18
|
+
* runtime in the region gets them (`isBedrockRuntimeHost`): any other host is refused.
|
|
19
|
+
* - `auth: api-key`: the key `secret_ref` names, read for every attempt and sent as the
|
|
20
|
+
* bearer token, so a rotated key takes effect on the next call. No SigV4, so no AWS
|
|
21
|
+
* credentials are looked for.
|
|
22
|
+
* - A failed sign-in (no credentials, no key, a blank one) is an `auth` error, never retried.
|
|
23
|
+
* - No request follows a redirect: the credential goes to the endpoint, nowhere else.
|
|
24
|
+
* - Prompt caching: cache points after the system prompt and, when the call will be sent
|
|
25
|
+
* again, the last message, for Claude and Nova models whose registration prices cache reads
|
|
26
|
+
* (`cachesPrompts`); none for any other model.
|
|
27
|
+
* - The region is the registration's (`metadata.region`), never `AWS_REGION`, and the endpoint
|
|
28
|
+
* is always given (the region's own, or `adapter_config.baseURL`), so
|
|
29
|
+
* `AWS_ENDPOINT_URL_BEDROCK_RUNTIME` and `AWS_ENDPOINT_URL` are never read.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
|
|
33
|
+
import { ModelProviderError, tokenCostUsd } from '@kindgi/adapter-model-shared';
|
|
34
|
+
import { attemptPrepared, createAiSdkModelProvider } from '@kindgi/adapter-model-shared/ai-sdk';
|
|
35
|
+
import {
|
|
36
|
+
type AdapterConfigCheckInput,
|
|
37
|
+
type AdapterConfigProblem,
|
|
38
|
+
type AdapterFactory,
|
|
39
|
+
type AdapterFactoryEntry,
|
|
40
|
+
type AwsCredentialClient,
|
|
41
|
+
type AwsCredentials,
|
|
42
|
+
type ModelInfo,
|
|
43
|
+
adapterConfigError,
|
|
44
|
+
identitiesPresent,
|
|
45
|
+
} from '@kindgi/capabilities';
|
|
46
|
+
import { bearerValueProblem } from '@kindgi/types';
|
|
47
|
+
|
|
48
|
+
import { BEDROCK_ADAPTER_ID, readBedrockConfig } from './config.js';
|
|
49
|
+
import { isNovaModel, withoutLeadingThinking } from './nova-thinking.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the provider is built with in `api-key` mode, so it sends a bearer token: never sent,
|
|
53
|
+
* since the current key replaces it on every request.
|
|
54
|
+
*/
|
|
55
|
+
const KEY_SET_PER_REQUEST = 'set-per-request';
|
|
56
|
+
|
|
57
|
+
/** How long the runtime's AWS identity may take before the call fails as `auth`. */
|
|
58
|
+
export const IDENTITY_TIMEOUT_MS = 10_000;
|
|
59
|
+
|
|
60
|
+
/** What's wrong with a registration, for `POST /v1/providers` and `GET …/check`. */
|
|
61
|
+
export function bedrockCheckConfig(
|
|
62
|
+
input: AdapterConfigCheckInput,
|
|
63
|
+
): readonly AdapterConfigProblem[] {
|
|
64
|
+
const read = readBedrockConfig(input);
|
|
65
|
+
return read.kind === 'ok' ? [] : read.problems;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const bedrockAdapterFactory: AdapterFactory = (input) => {
|
|
69
|
+
const { metadata } = input;
|
|
70
|
+
const read = readBedrockConfig({
|
|
71
|
+
metadata,
|
|
72
|
+
...(input.config !== undefined && { config: input.config }),
|
|
73
|
+
hasSecretRef: input.resolveApiKey !== undefined,
|
|
74
|
+
identities: identitiesPresent(input.identities),
|
|
75
|
+
});
|
|
76
|
+
if (read.kind === 'err') {
|
|
77
|
+
throw adapterConfigError(
|
|
78
|
+
BEDROCK_ADAPTER_ID,
|
|
79
|
+
metadata.id,
|
|
80
|
+
read.problems[0] as AdapterConfigProblem,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const { region, baseURL, auth } = read.config;
|
|
84
|
+
const identity = input.identities?.aws;
|
|
85
|
+
const resolveApiKey = input.resolveApiKey;
|
|
86
|
+
|
|
87
|
+
const provider = createAiSdkModelProvider({
|
|
88
|
+
metadata,
|
|
89
|
+
...(input.fetch !== undefined && { fetch: input.fetch }),
|
|
90
|
+
beforeAttempt: (signal): Promise<SignedIn> =>
|
|
91
|
+
auth === 'aws-identity'
|
|
92
|
+
? awsCredentials(identity as AwsCredentialClient, signal).then((credentials) => ({
|
|
93
|
+
credentials,
|
|
94
|
+
}))
|
|
95
|
+
: bedrockKey(resolveApiKey as () => Promise<string>).then((key) => ({ key })),
|
|
96
|
+
languageModel: (name, fetch) => {
|
|
97
|
+
const send = withoutRedirects(fetch);
|
|
98
|
+
return createAmazonBedrock({
|
|
99
|
+
region,
|
|
100
|
+
baseURL,
|
|
101
|
+
...(auth === 'aws-identity'
|
|
102
|
+
? {
|
|
103
|
+
apiKey: '',
|
|
104
|
+
// This attempt's, asked for in `beforeAttempt`: the library's own ask never fails.
|
|
105
|
+
credentialProvider: async () => signedIn().credentials as AwsCredentials,
|
|
106
|
+
fetch: send,
|
|
107
|
+
}
|
|
108
|
+
: { apiKey: KEY_SET_PER_REQUEST, fetch: withBearerKey(send) }),
|
|
109
|
+
})(name);
|
|
110
|
+
},
|
|
111
|
+
cost: (model, usage) => tokenCostUsd(model, usage),
|
|
112
|
+
cacheMark: (model) => (cachesPrompts(model) ? BEDROCK_CACHE_POINT : undefined),
|
|
113
|
+
explain: (error) => (error.status === 403 ? accessHint(auth, region) : undefined),
|
|
114
|
+
});
|
|
115
|
+
// Nova's chain of thought, written into its answer, is taken out (`nova-thinking.ts`).
|
|
116
|
+
return {
|
|
117
|
+
...provider,
|
|
118
|
+
invoke: async (call) => {
|
|
119
|
+
const result = await provider.invoke(call);
|
|
120
|
+
return isNovaModel(call.model) ? withoutLeadingThinking(result) : result;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Bedrock caches only the prompt prefixes a request marks with a cache point (5-minute TTL).
|
|
127
|
+
* Where the marks go: `withCacheMarks` in `@kindgi/adapter-model-shared`.
|
|
128
|
+
*/
|
|
129
|
+
const BEDROCK_CACHE_POINT = { bedrock: { cachePoint: { type: 'default' } } };
|
|
130
|
+
|
|
131
|
+
/** Anthropic's Claude and Amazon Nova, by model or inference-profile id (`us.`, `global.`, …). */
|
|
132
|
+
const CACHING_FAMILY = /^(?:[a-z-]+\.)?(?:anthropic\.claude|amazon\.nova)-/;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether the adapter marks a model's prompts for Bedrock's cache: the registration prices
|
|
136
|
+
* cache reads (a positive `cachedPromptMultiplier` or `promptCacheReadMultiplier`), and the
|
|
137
|
+
* model is Claude or Nova, the families whose caching was seen live on Bedrock (2026-10-10).
|
|
138
|
+
* Others, OpenAI's models on Bedrock or an application inference profile's ARN among them, stay
|
|
139
|
+
* unmarked until they are.
|
|
140
|
+
*/
|
|
141
|
+
export function cachesPrompts(model: ModelInfo): boolean {
|
|
142
|
+
const cost = model.cost as Partial<Record<string, unknown>>;
|
|
143
|
+
const read = cost.cachedPromptMultiplier ?? cost.promptCacheReadMultiplier;
|
|
144
|
+
return typeof read === 'number' && read > 0 && CACHING_FAMILY.test(model.name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The entry a runtime registers: the factory, and its static check. */
|
|
148
|
+
export const bedrockAdapterEntry: AdapterFactoryEntry = {
|
|
149
|
+
adapterId: BEDROCK_ADAPTER_ID,
|
|
150
|
+
capabilityKind: 'llm-inference',
|
|
151
|
+
factory: bedrockAdapterFactory,
|
|
152
|
+
checkConfig: bedrockCheckConfig,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** What a 403 from Bedrock can mean, beyond its own words, for how the adapter signed in. */
|
|
156
|
+
function accessHint(auth: 'aws-identity' | 'api-key', region: string): string {
|
|
157
|
+
const noAccess = `when the account has no access to the model in ${region} (model access, or a Marketplace agreement still being made: retry in 15 minutes)`;
|
|
158
|
+
return auth === 'aws-identity'
|
|
159
|
+
? `Bedrock refuses when the runtime's identity's policy lacks bedrock:InvokeModel on the model (for an inference profile, on the profile and on the foundation model in each of its Regions), ${noAccess}, or when the credentials have expired.`
|
|
160
|
+
: `Bedrock refuses when the IAM user the API key belongs to lacks bedrock:InvokeModel on the model (for an inference profile, on the profile and on the foundation model in each of its Regions), ${noAccess}, or when the key has expired or been revoked.`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What an attempt signed in with: the identity's credentials, or a key. */
|
|
164
|
+
interface SignedIn {
|
|
165
|
+
readonly credentials?: AwsCredentials;
|
|
166
|
+
readonly key?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** This attempt's sign-in, read by its request. */
|
|
170
|
+
function signedIn(): SignedIn {
|
|
171
|
+
const signed = attemptPrepared<SignedIn>();
|
|
172
|
+
if (signed === undefined) {
|
|
173
|
+
throw new ModelProviderError('auth', undefined, 'The request has no sign-in of its own.');
|
|
174
|
+
}
|
|
175
|
+
return signed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The runtime's AWS credentials for an attempt: bounded, stopped with the call, `auth` when none. */
|
|
179
|
+
async function awsCredentials(
|
|
180
|
+
identity: AwsCredentialClient,
|
|
181
|
+
signal: AbortSignal | undefined,
|
|
182
|
+
): Promise<AwsCredentials> {
|
|
183
|
+
let credentials: AwsCredentials;
|
|
184
|
+
try {
|
|
185
|
+
credentials = await bounded(identity(), IDENTITY_TIMEOUT_MS, signal);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// (A call stopped meanwhile ends with its own reason: the retries check the signal first.)
|
|
188
|
+
throw new ModelProviderError(
|
|
189
|
+
'auth',
|
|
190
|
+
undefined,
|
|
191
|
+
`The runtime's AWS identity gave no credentials: ${messageOf(error)}. KINDGI_AWS_IDENTITY says where they come from.`,
|
|
192
|
+
{ cause: error },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (credentials?.accessKeyId === undefined || credentials.accessKeyId === '') {
|
|
196
|
+
throw new ModelProviderError(
|
|
197
|
+
'auth',
|
|
198
|
+
undefined,
|
|
199
|
+
"The runtime's AWS identity returned no credentials. KINDGI_AWS_IDENTITY says where they come from.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return credentials;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The current key `secret_ref` names, for an attempt; `auth` when there's none. */
|
|
206
|
+
async function bedrockKey(resolveApiKey: () => Promise<string>): Promise<string> {
|
|
207
|
+
let key: string;
|
|
208
|
+
try {
|
|
209
|
+
key = await resolveApiKey();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
throw new ModelProviderError(
|
|
212
|
+
'auth',
|
|
213
|
+
undefined,
|
|
214
|
+
`The Bedrock API key secret_ref names couldn't be read: ${messageOf(error)}.`,
|
|
215
|
+
{ cause: error },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
// Whitespace around a key (a stored file's newline) is never the key's.
|
|
219
|
+
const trimmed = key.trim();
|
|
220
|
+
if (trimmed === '') {
|
|
221
|
+
throw new ModelProviderError(
|
|
222
|
+
'auth',
|
|
223
|
+
undefined,
|
|
224
|
+
'The Bedrock API key secret_ref names is empty.',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
// Sent as `Bearer <key>`, so checked as sent, before the request: fetch's own error would
|
|
228
|
+
// quote it.
|
|
229
|
+
const problem = bearerValueProblem(trimmed);
|
|
230
|
+
if (problem !== undefined) {
|
|
231
|
+
throw new ModelProviderError(
|
|
232
|
+
'auth',
|
|
233
|
+
undefined,
|
|
234
|
+
`The Bedrock API key secret_ref names has ${problem}, which a header can't carry: store the secret without it.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return trimmed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The bearer token on the request, from this attempt's key. */
|
|
241
|
+
function withBearerKey(fetch: typeof globalThis.fetch): typeof globalThis.fetch {
|
|
242
|
+
return async (url, init) => {
|
|
243
|
+
const headers = new Headers(init?.headers);
|
|
244
|
+
headers.set('authorization', `Bearer ${signedIn().key as string}`);
|
|
245
|
+
return fetch(url, { ...init, headers });
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** No request follows a redirect, so its credential reaches the endpoint and nothing else. */
|
|
250
|
+
function withoutRedirects(fetch: typeof globalThis.fetch): typeof globalThis.fetch {
|
|
251
|
+
return (url, init) => fetch(url, { ...init, redirect: 'error' });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The promise, or a failure after `ms`, or the signal's reason when the call stops first. */
|
|
255
|
+
function bounded<T>(promise: PromiseLike<T>, ms: number, signal?: AbortSignal): Promise<T> {
|
|
256
|
+
// An abort that has already happened fires no event.
|
|
257
|
+
if (signal?.aborted === true) return Promise.reject(signal.reason);
|
|
258
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
259
|
+
let onAbort: (() => void) | undefined;
|
|
260
|
+
const stop = new Promise<never>((_, reject) => {
|
|
261
|
+
timer = setTimeout(() => reject(new Error(`no answer in ${ms / 1000} s`)), ms);
|
|
262
|
+
if (signal !== undefined) {
|
|
263
|
+
onAbort = () => reject(signal.reason);
|
|
264
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return Promise.race([Promise.resolve(promise), stop]).finally(() => {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const messageOf = (error: unknown): string =>
|
|
274
|
+
error instanceof Error ? error.message : String(error);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
BEDROCK_ADAPTER_ID,
|
|
6
|
+
BEDROCK_AUTHS,
|
|
7
|
+
type BedrockAuth,
|
|
8
|
+
type BedrockConfig,
|
|
9
|
+
bedrockRuntimeEndpoint,
|
|
10
|
+
isBedrockRuntimeHost,
|
|
11
|
+
partitionDnsSuffix,
|
|
12
|
+
readBedrockConfig,
|
|
13
|
+
} from './config.js';
|
|
14
|
+
export {
|
|
15
|
+
IDENTITY_TIMEOUT_MS,
|
|
16
|
+
bedrockAdapterEntry,
|
|
17
|
+
bedrockAdapterFactory,
|
|
18
|
+
bedrockCheckConfig,
|
|
19
|
+
} from './entry.js';
|
|
20
|
+
export { NOVA_THINKING_REMOVED, isNovaModel, withoutLeadingThinking } from './nova-thinking.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Amazon Nova writes its chain of thought into the answer text when tools are in play:
|
|
6
|
+
* `<thinking>…</thinking>` before the reply. Other vendors keep reasoning apart, and a
|
|
7
|
+
* `ModelCallResult` has no slot for reasoning text, so for a Nova model one leading block is
|
|
8
|
+
* removed from the answer, with a warning saying so (the model-call record keeps the warning).
|
|
9
|
+
* Only an exact leading pair is touched: a block elsewhere in the text, or one that isn't
|
|
10
|
+
* closed, is left as it came.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelCallResult } from '@kindgi/capabilities';
|
|
14
|
+
|
|
15
|
+
/** The warning code a removed `<thinking>` block leaves on the result. */
|
|
16
|
+
export const NOVA_THINKING_REMOVED = 'reasoning-text-removed';
|
|
17
|
+
|
|
18
|
+
/** A Nova model id or inference profile (`us.amazon.nova-pro-v1:0`, or an ARN naming one). */
|
|
19
|
+
const NOVA = /(^|[.:/])amazon\.nova-/;
|
|
20
|
+
/** One block at the very start of the text (whitespace aside), closed. */
|
|
21
|
+
const LEADING_THINKING = /^\s*<thinking>[\s\S]*?<\/thinking>\s*/;
|
|
22
|
+
|
|
23
|
+
export const isNovaModel = (name: string): boolean => NOVA.test(name);
|
|
24
|
+
|
|
25
|
+
/** The result with one leading `<thinking>…</thinking>` block taken out of its answer. */
|
|
26
|
+
export function withoutLeadingThinking(result: ModelCallResult): ModelCallResult {
|
|
27
|
+
const content = result.message.content;
|
|
28
|
+
const found = LEADING_THINKING.exec(content);
|
|
29
|
+
if (found === null) return result;
|
|
30
|
+
return {
|
|
31
|
+
...result,
|
|
32
|
+
message: { ...result.message, content: content.slice(found[0].length) },
|
|
33
|
+
warnings: [
|
|
34
|
+
...(result.warnings ?? []),
|
|
35
|
+
{
|
|
36
|
+
code: NOVA_THINKING_REMOVED,
|
|
37
|
+
message: `${result.provider.model} wrote its reasoning into the answer: the leading <thinking> block (${found[0].trim().length} characters) was removed.`,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
}
|