diff --git a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts index 7deaff2a..2de640ae 100644 --- a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts +++ b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts @@ -246,18 +246,7 @@ export async function extractInvoiceFields( return { data: emptyResult(), rawText: null } } - // Prefer BEDROCK_AWS_* over the plain AWS_* names: on Vercel/Lambda the - // platform injects its own reserved AWS_* execution-role vars that shadow - // whatever is configured, so a hosted deploy must use the BEDROCK_AWS_* names - // (see lib/agent/composer/client.ts for the full explanation). - const awsRegion = - process.env.BEDROCK_AWS_REGION || process.env.AWS_REGION || 'eu-north-1' - const awsAccessKey = - process.env.BEDROCK_AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID - const awsSecretKey = - process.env.BEDROCK_AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY - - if (!awsAccessKey || !awsSecretKey) { + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { log.warn('AWS Bedrock credentials missing: returning empty extraction', { file_name_hash: createHash('sha256').update(input.fileName).digest('hex').slice(0, 12), }) @@ -265,9 +254,9 @@ export async function extractInvoiceFields( } const client = new AnthropicBedrock({ - awsRegion, - awsAccessKey, - awsSecretKey, + awsRegion: process.env.AWS_REGION || 'eu-north-1', + awsAccessKey: process.env.AWS_ACCESS_KEY_ID, + awsSecretKey: process.env.AWS_SECRET_ACCESS_KEY, }) let rawText: string | null = null diff --git a/lib/agent/chat/run-turn.ts b/lib/agent/chat/run-turn.ts index b0ed0c35..8eada84c 100644 --- a/lib/agent/chat/run-turn.ts +++ b/lib/agent/chat/run-turn.ts @@ -301,11 +301,42 @@ export async function runChatTurn(args: RunTurnArgs): Promise { // Surface as a chat error so the UI clears its streaming state. Re-throw // to let the route's outer try/catch persist the failure if needed. // Normalize Bedrock throttling/timeout/5xx into a friendly Swedish line. + // + // Extract status/code/cause/stack explicitly: the logger keeps only + // name/message/code from an Error and drops the stack in production, so + // the real failure was invisible (every prod log just said "request ended + // without sending any chunks"). These fields tell us whether the empty + // stream is auth (403), bad model/region (400), throttling (429), or a + // genuine transport cut. No secrets: AWS/SDK errors carry none, and the + // logger still redacts personnummer/UUIDs from any string. + const bedrockErr = err as { + status?: number + code?: string + cause?: unknown + stack?: string + } + let errCause: string | undefined + try { + errCause = + bedrockErr?.cause != null + ? String( + bedrockErr.cause instanceof Error + ? `${bedrockErr.cause.name}: ${bedrockErr.cause.message}` + : bedrockErr.cause, + ).slice(0, 300) + : undefined + } catch { + errCause = '[uninspectable cause]' + } log.error('Bedrock stream failed', err, { conversationId, companyId, model, iterations, + errStatus: typeof bedrockErr?.status === 'number' ? bedrockErr.status : undefined, + errCode: typeof bedrockErr?.code === 'string' ? bedrockErr.code : undefined, + errCause, + errStack: typeof bedrockErr?.stack === 'string' ? bedrockErr.stack.slice(0, 1200) : undefined, }) emit({ kind: 'error', message: friendlyModelError(err) }) throw err diff --git a/lib/agent/composer/client.ts b/lib/agent/composer/client.ts index c05e07e9..71ffff1b 100644 --- a/lib/agent/composer/client.ts +++ b/lib/agent/composer/client.ts @@ -1,4 +1,7 @@ import AnthropicBedrock from '@anthropic-ai/bedrock-sdk' +import { createLogger } from '@/lib/logger' + +const log = createLogger('agent-bedrock-client') let cached: AnthropicBedrock | null = null @@ -20,19 +23,32 @@ let cached: AnthropicBedrock | null = null // API. export function getAnthropic(): AnthropicBedrock { if (cached) return cached - // Read Bedrock creds from BEDROCK_AWS_* first, falling back to the plain - // AWS_* names for local dev. On Vercel the functions run on AWS Lambda, whose - // runtime injects its OWN reserved AWS_REGION / AWS_ACCESS_KEY_ID / - // AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN for the platform execution role - // (wrong account, wrong region, no Bedrock access). Those shadow anything set - // in the dashboard, so a hosted deploy MUST use the BEDROCK_AWS_* names or the - // stream comes back empty ("request ended without sending any chunks"). - const awsRegion = - process.env.BEDROCK_AWS_REGION || process.env.AWS_REGION || 'eu-north-1' - const awsAccessKey = - process.env.BEDROCK_AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID - const awsSecretKey = - process.env.BEDROCK_AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY + const awsRegion = process.env.AWS_REGION || 'eu-north-1' + const awsAccessKey = process.env.AWS_ACCESS_KEY_ID + const awsSecretKey = process.env.AWS_SECRET_ACCESS_KEY + + // Startup diagnostic: make a hosted misconfiguration visible in the logs + // instead of it surfacing only as an opaque "request ended without sending + // any chunks" at stream time. Runs once per cold start (the client is cached). + // Never logs a secret: only the region, presence booleans, and the 4-char + // access-key-id PREFIX (AKIA = long-term IAM user key; ASIA = STS/temporary + // role credential, i.e. a platform-injected one rather than ours). + if (!awsAccessKey || !awsSecretKey) { + log.error('agent Bedrock credentials not loaded from env', undefined, { + region: awsRegion, + hasAccessKeyId: !!awsAccessKey, + hasSecretAccessKey: !!awsSecretKey, + regionFromEnv: !!process.env.AWS_REGION, + }) + } else { + log.info('agent Bedrock client init', { + region: awsRegion, + keyPrefix: awsAccessKey.slice(0, 4), + hasSessionToken: !!process.env.AWS_SESSION_TOKEN, + regionFromEnv: !!process.env.AWS_REGION, + }) + } + // When both static keys are present, pass them. Otherwise omit them so the // SDK falls back to the AWS credential provider chain (instance profile, // IRSA, EKS pod identity, ...). The two-overload SDK refuses a mix.