Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ BEDROCK_ACCESS_KEY_ID=
BEDROCK_SECRET_ACCESS_KEY=
BEDROCK_SESSION_TOKEN=
BEDROCK_REGION=
BEDROCK_USE_CONTAINER_CREDENTIALS=
DEEPSEEK_API_KEY=
GEMINI_API_KEY=
GROQ_API_KEY=
Expand Down Expand Up @@ -109,6 +110,7 @@ requiring the user to enter an API key
| `BEDROCK_SECRET_ACCESS_KEY` | AWS IAM Secret Access Key for Bedrock | Optional, but if set `BEDROCK_ACCESS_KEY_ID` must also be set |
| `BEDROCK_SESSION_TOKEN` | AWS Session Token for temporary/STS credentials | Optional |
| `BEDROCK_REGION` | AWS region for Bedrock (e.g., `us-east-1`, `us-west-2`, `eu-west-1`) | Optional, defaults to `us-east-1` |
| `BEDROCK_USE_CONTAINER_CREDENTIALS` | Set to `true` to use ambient IAM credentials from the ECS/Fargate container credentials endpoint (task role) when no explicit Bedrock credentials are configured. Lowest priority in the credentials chain | Optional |
| `DEEPSEEK_API_KEY` | The API key for Deepseek AI | Optional |
| `GEMINI_API_KEY` | The API key for Google AI's Gemini | Optional |
| `GROQ_API_KEY` | The API key for Groq Cloud | Optional |
Expand Down
4 changes: 3 additions & 1 deletion src/modules/backend/backend.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { createTRPCRouter, publicProcedure } from '~/server/trpc/trpc.server';
import { env } from '~/server/env.server';
import { fetchJsonOrTRPCThrow } from '~/server/trpc/trpc.router.fetchers';

import { bedrockHasContainerCredentialsEndpoint } from '~/modules/llms/server/bedrock/bedrock.containerCredentials';

// critical to make sure we `import type` here
import type { BackendCapabilities } from './store-backend-capabilities';

Expand Down Expand Up @@ -51,7 +53,7 @@ export const backendRouter = createTRPCRouter({
hasLlmAlibaba: !!env.ALIBABA_API_KEY || !!env.ALIBABA_API_HOST,
hasLlmAnthropic: !!env.ANTHROPIC_API_KEY,
hasLlmAzureOpenAI: !!env.AZURE_OPENAI_API_KEY && !!env.AZURE_OPENAI_API_ENDPOINT,
hasLlmBedrock: !!env.BEDROCK_BEARER_TOKEN || (!!env.BEDROCK_ACCESS_KEY_ID && !!env.BEDROCK_SECRET_ACCESS_KEY),
hasLlmBedrock: !!env.BEDROCK_BEARER_TOKEN || (!!env.BEDROCK_ACCESS_KEY_ID && !!env.BEDROCK_SECRET_ACCESS_KEY) || (!!env.BEDROCK_USE_CONTAINER_CREDENTIALS && bedrockHasContainerCredentialsEndpoint()),
hasLlmDeepseek: !!env.DEEPSEEK_API_KEY,
hasLlmGemini: !!env.GEMINI_API_KEY,
hasLlmGroq: !!env.GROQ_API_KEY,
Expand Down
35 changes: 28 additions & 7 deletions src/modules/llms/server/bedrock/bedrock.access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
* UNSUPPORTED: Short-term keys (`bedrock-api-key-...`) only support runtime (not model listing).
* - **SigV4**: Traditional IAM credentials signing via aws4fetch
*
* Priority: client bearer > client IAM > server bearer > server IAM.
* SigV4 uses explicit AWS credentials only (no credential chain) for Edge Runtime compatibility.
* Priority: client bearer > client IAM > server bearer > server IAM > container credentials (opt-in).
* SigV4 uses explicit AWS credentials only (no SDK credential chain) for Edge Runtime compatibility;
* the optional container-credentials provider is likewise pure-fetch (see bedrock.containerCredentials.ts).
*/

import * as z from 'zod/v4';
Expand All @@ -22,6 +23,8 @@ import { AwsClient } from 'aws4fetch';

import { env } from '~/server/env.server';

import { bedrockContainerCredentialsOrNull } from './bedrock.containerCredentials';


// configuration
const DEFAULT_BEDROCK_REGION = 'us-east-1'; // default region for Bedrock, used if not provided by client or env
Expand All @@ -46,8 +49,13 @@ export const bedrockAccessSchema = z.object({
type BedrockAuthBearer = { type: 'bearer'; bearerToken: string; region: string };
type BedrockAuthSigV4 = { type: 'sigv4'; accessKeyId: string; secretAccessKey: string; sessionToken: string | undefined; region: string };

/** Resolve Bedrock authentication. */
function _bedrockResolveAuth(access: BedrockAccessSchema): BedrockAuthBearer | BedrockAuthSigV4 {
/** True when the client provided its own credentials (bearer or IAM pair). */
function _hasClientCredentials(access: BedrockAccessSchema): boolean {
return !!access.bedrockBearerToken || (!!access.bedrockAccessKeyId && !!access.bedrockSecretAccessKey);
}

/** Resolve Bedrock authentication. Async because ambient container credentials may need a fetch. */
async function _bedrockResolveAuthAsync(access: BedrockAccessSchema): Promise<BedrockAuthBearer | BedrockAuthSigV4> {

// 1. Client bearer token (highest priority)
let region = access.bedrockRegion || DEFAULT_BEDROCK_REGION; // client-provided region
Expand All @@ -67,15 +75,28 @@ function _bedrockResolveAuth(access: BedrockAccessSchema): BedrockAuthBearer | B
if (env.BEDROCK_ACCESS_KEY_ID && env.BEDROCK_SECRET_ACCESS_KEY)
return { type: 'sigv4', accessKeyId: env.BEDROCK_ACCESS_KEY_ID, secretAccessKey: env.BEDROCK_SECRET_ACCESS_KEY, sessionToken: env.BEDROCK_SESSION_TOKEN || undefined, region };

// 5. [opt-in] Ambient container credentials (ECS/Fargate task role) - short-lived, cached with refresh
if (env.BEDROCK_USE_CONTAINER_CREDENTIALS) {
const ambient = await bedrockContainerCredentialsOrNull();
if (ambient)
return { type: 'sigv4', accessKeyId: ambient.accessKeyId, secretAccessKey: ambient.secretAccessKey, sessionToken: ambient.sessionToken, region };
}

throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Missing AWS credentials. Add your Bedrock API Key or IAM Access Key on the UI (Models Setup) or server side (your deployment).',
});
}

/** Resolve the Bedrock region from access config. */
/**
* Resolve the Bedrock region from access config - deliberately sync and credential-free:
* client-provided credentials use the client region, server-side credentials (bearer, IAM
* env vars, or ambient container credentials) use the server region.
*/
export function bedrockResolveRegion(access: BedrockAccessSchema): string {
return _bedrockResolveAuth(access).region;
return _hasClientCredentials(access)
? access.bedrockRegion || DEFAULT_BEDROCK_REGION
: env.BEDROCK_REGION || DEFAULT_BEDROCK_REGION;
}


Expand Down Expand Up @@ -111,7 +132,7 @@ export async function bedrockAccessAsync(
body?: object,
): Promise<{ headers: HeadersInit; url: string }> {

const auth = _bedrockResolveAuth(access);
const auth = await _bedrockResolveAuthAsync(access);

// -- Bearer token: simple Authorization header --
if (auth.type === 'bearer')
Expand Down
113 changes: 113 additions & 0 deletions src/modules/llms/server/bedrock/bedrock.containerCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Ambient AWS credentials from the container credentials endpoint (ECS/Fargate task roles).
*
* Zero-dependency and Edge Runtime compatible: only `fetch` and env vars are used (no AWS
* SDK, no filesystem, no Node APIs). Opt-in via BEDROCK_USE_CONTAINER_CREDENTIALS=true.
*
* Supported (the "container" credential provider - https://docs.aws.amazon.com/sdkref/latest/guide/feature-container-credentials.html):
* - ECS/Fargate task roles: AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, served by the ECS agent
* at the link-local address http://169.254.170.2
* - AWS_CONTAINER_CREDENTIALS_FULL_URI, with the optional static AWS_CONTAINER_AUTHORIZATION_TOKEN
*
* NOT supported (would need more machinery than a fetch):
* - EC2 IMDSv2 (different token+discovery protocol)
* - EKS IRSA (requires an STS AssumeRoleWithWebIdentity call)
* - EKS Pod Identity (AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE rotates on disk; the Edge Runtime has no fs)
*
* Lifecycle: task-role credentials rotate (~hours), so we cache at module level, refresh with
* a safety margin before expiration, single-flight concurrent refreshes, and keep serving the
* cached credentials on refresh failures for as long as they are still valid.
*/

// configuration
const _ECS_CREDENTIALS_HOST = 'http://169.254.170.2'; // ECS agent, link-local
const _EXPIRY_MARGIN_MS = 5 * 60 * 1000; // refresh 5 minutes before expiration
const _FETCH_TIMEOUT_MS = 5 * 1000; // generous - the endpoint is link-local (measured <100ms)


export interface BedrockContainerCredentials {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string | undefined; // always present on ECS task roles, but not required for signing
expiresAt: number; // epoch ms
}

// module-level cache - persists across invocations within the server (or Edge sandbox) instance
let _cache: BedrockContainerCredentials | null = null;
let _inflightRefresh: Promise<BedrockContainerCredentials> | null = null;
let _loggedFirstUse = false;


/** True when the runtime exposes a container credentials endpoint (e.g. ECS/Fargate with a task role). */
export function bedrockHasContainerCredentialsEndpoint(): boolean {
return !!process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
}

/**
* Returns valid (cached or freshly fetched) container credentials, or null when unavailable,
* letting the caller fall through to its standard missing-credentials error.
*/
export async function bedrockContainerCredentialsOrNull(): Promise<BedrockContainerCredentials | null> {

// fresh cache hit
if (_cache && Date.now() < _cache.expiresAt - _EXPIRY_MARGIN_MS)
return _cache;

// single-flight: concurrent requests await the same refresh
if (!_inflightRefresh)
_inflightRefresh = _fetchContainerCredentials().finally(() => _inflightRefresh = null);

try {
_cache = await _inflightRefresh;
return _cache;
} catch (error: any) {
// stale-tolerant: on refresh failure keep serving cached credentials until they actually expire
if (_cache && Date.now() < _cache.expiresAt)
return _cache;
console.warn('[Bedrock] container credentials unavailable:', error?.message || error);
return null;
}
}

async function _fetchContainerCredentials(): Promise<BedrockContainerCredentials> {

// resolve the endpoint
const relativeUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
const url = relativeUri ? _ECS_CREDENTIALS_HOST + relativeUri : process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
if (!url)
throw new Error('no container credentials endpoint (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI/_FULL_URI unset)');

// optional static authorization token (the rotating _TOKEN_FILE variant is not supported - see header)
const authToken = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN;

const response = await fetch(url, {
headers: {
'Accept': 'application/json',
...(authToken ? { 'Authorization': authToken } : {}),
},
signal: AbortSignal.timeout(_FETCH_TIMEOUT_MS),
});
if (!response.ok)
throw new Error(`credentials endpoint returned HTTP ${response.status}`);

const wireCreds = await response.json() as { RoleArn?: string; AccessKeyId?: string; SecretAccessKey?: string; Token?: string; Expiration?: string };
if (!wireCreds?.AccessKeyId || !wireCreds.SecretAccessKey || !wireCreds.Expiration)
throw new Error('credentials endpoint returned an unexpected payload');

const expiresAt = Date.parse(wireCreds.Expiration);
if (isNaN(expiresAt))
throw new Error(`credentials endpoint returned an invalid Expiration: ${wireCreds.Expiration}`);

// log once, as operational evidence of the ambient auth mode
if (!_loggedFirstUse) {
_loggedFirstUse = true;
console.log(`[Bedrock] using container credentials${wireCreds.RoleArn ? ` (role: ${wireCreds.RoleArn})` : ''}, expiring ${wireCreds.Expiration}`);
}

return {
accessKeyId: wireCreds.AccessKeyId,
secretAccessKey: wireCreds.SecretAccessKey,
sessionToken: wireCreds.Token || undefined,
expiresAt,
};
}
1 change: 1 addition & 0 deletions src/server/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const env = createEnv({
BEDROCK_SECRET_ACCESS_KEY: z.string().optional(),
BEDROCK_SESSION_TOKEN: z.string().optional(), // required with the other 2 on corporate accounts sometimes
BEDROCK_REGION: z.string().optional(),
BEDROCK_USE_CONTAINER_CREDENTIALS: z.enum(['true']).optional(), // opt-in: ambient IAM via the ECS/Fargate container credentials endpoint (task role), lowest priority

// LLM: Cerebras
CEREBRAS_API_KEY: z.string().optional(),
Expand Down