Skip to content

Commit e768d0a

Browse files
authored
feat(webapp): run the dashboard agent through AWS Bedrock behind an env switch (#4609)
## What & why The dashboard agent can now run its model calls through AWS Bedrock instead of the direct Anthropic API, chosen by a single env switch. It's **off by default** (`DASHBOARD_AGENT_MODEL_PROVIDER` unset ⇒ `anthropic`), so merging changes nothing at runtime — the Bedrock path is a dormant branch until an operator sets the switch and AWS config. The default Anthropic path is byte-for-byte unchanged. This also carries a related tenant-isolation hardening for the agent's delegated token (kept together deliberately — both land the agent on Bedrock for HIPAA readiness). Refs: TRI-13251, TRI-11032. ## What's inside **Provider seam** — `internal-packages/dashboard-agent/src/model-provider.ts`: the registry now holds both `anthropic` and `bedrock`; `resolveDashboardAgentModel()` maps the canonical `"anthropic:<id>"` strings the managed prompts carry to the active provider, and the cache-breakpoint helpers emit the active provider's shape — Anthropic `cacheControl` vs Bedrock `cachePoint`. Managed prompt strings stay canonical, so stored prompts don't change meaning. Unmapped model ids throw rather than shipping a guaranteed-404 profile. All agent, watch, compaction and title callsites route through the resolver; the `dashboardAgentModelKey` locals override (test mock injection) is preserved. **Cache telemetry** — `step-cache.ts`: cache token usage is read from the active provider (Anthropic reports it on provider metadata; Bedrock reports the write on metadata and the read via standard usage), so `gen_ai.usage.cache_*` is populated on both. This also fixes a latent ordering bug where step attributes could null-overwrite the prompt-cache read count. **Webapp callsites** — `dashboardAgentHeadStart.server.ts` and the head-start route resolve the model and the cache breakpoint through the same seam, so the warm-up prefix and the following turn share one provider. The head-start firing gate is provider-aware: on Bedrock it gates on `AWS_REGION` and lets the SDK resolve credentials (IAM role / static keys / session token / bearer), so a role-based deploy still warms; on Anthropic it stays `Boolean(ANTHROPIC_API_KEY)`. `app/env.server.ts` gains the optional AWS vars and validates `DASHBOARD_AGENT_MODEL_PROVIDER`. `ANTHROPIC_API_KEY` is untouched and not required on a Bedrock deploy. **Tenant-isolation hardening** — `internal-packages/rbac/src/fallback.ts`: for a **scoped** context, the OSS `authenticateUserActor` now applies the same membership floor as the session path — a delegated user-actor token whose user is not a member of the scoped org/project is denied (403). Unscoped tokens keep their prior behavior (no tenant claim, no lookup). The user lookup falls back replica→primary so replication lag can't spuriously 401 a just-joined member. Members and admins are unaffected. Previously this invariant held only through per-route discipline; this makes it structural. ## Enabling Bedrock (later, ops) - Set `DASHBOARD_AGENT_MODEL_PROVIDER=bedrock` **identically** in both the webapp and the agent task container — the webapp warms the cache prefix and the task reads it, so a split would silently miss the cache. - Set `AWS_REGION` and provide credentials the Bedrock SDK can resolve (IAM role preferred). For v1 this runs **without** an Anthropic API key. Note: with no Anthropic key set, rollback is "turn the agent off", not "unset the switch" (unsetting falls back to the Anthropic provider, which then has no key). - Two things to confirm before rollout: the Sonnet inference-profile id is validated against the SDK's own model-id union but still warrants a live smoke test; and Bedrock prompt caching for Sonnet is a 5-minute window (not Anthropic's 1h), so input-token cost rises when flipped. ## Testing Unit tests cover both provider paths: the provider switch and per-provider cache shapes, a structural regex asserting Bedrock ids are real inference profiles (not an echo of the table), the split-metadata cache telemetry, and real-Postgres RBAC tests — member allowed, scoped non-member denied (org-only and project-only), missing user → 401, admin non-member exempt, unscoped success. `typecheck --filter webapp` and the dashboard-agent + rbac suites pass.
1 parent b331976 commit e768d0a

21 files changed

Lines changed: 761 additions & 94 deletions

apps/webapp/app/env.server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,28 @@ const EnvironmentSchema = z
209209
// uses its own key on the Trigger side. When unset, Head Start is disabled
210210
// and the first turn falls back to the normal cold-start path.
211211
ANTHROPIC_API_KEY: z.string().optional(),
212+
// Selects the dashboard agent's LLM provider (default anthropic). The internal
213+
// seam reads process.env directly; this entry validates the value webapp-side.
214+
DASHBOARD_AGENT_MODEL_PROVIDER: z.preprocess(
215+
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
216+
z.enum(["anthropic", "bedrock"]).default("anthropic")
217+
),
218+
// AWS credentials for the dashboard agent's Bedrock provider (only used when
219+
// DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). The
220+
// provider resolves credentials itself, so only the region is read here.
221+
AWS_REGION: z.string().optional(),
222+
AWS_DEFAULT_REGION: z.string().optional(),
223+
AWS_ACCESS_KEY_ID: z.string().optional(),
224+
AWS_SECRET_ACCESS_KEY: z.string().optional(),
225+
AWS_SESSION_TOKEN: z.string().optional(),
226+
AWS_BEARER_TOKEN_BEDROCK: z.string().optional(),
227+
// Dedicated, non-global credentials for the dashboard agent's Bedrock calls (a
228+
// Bedrock-invoke-only IAM user). Kept separate from AWS_ACCESS_KEY_ID/etc so
229+
// injecting them can't hijack the default credential chain the ECR/STS deploy
230+
// clients rely on.
231+
DASHBOARD_AGENT_AWS_ACCESS_KEY_ID: z.string().optional(),
232+
DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY: z.string().optional(),
233+
DASHBOARD_AGENT_AWS_REGION: z.string().optional(),
212234
DIRECT_URL: z
213235
.string()
214236
.refine(

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
softDeleteChat,
1818
} from "@internal/dashboard-agent-db";
1919
import { watchDraftSchema, type WatchDraft } from "@internal/dashboard-agent-contracts";
20+
import { dashboardAgentProvider } from "@internal/dashboard-agent/model-provider";
2021
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
2122
import type { UIMessage } from "ai";
2223
import { z } from "zod";
@@ -329,7 +330,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
329330
const chatId = generateFriendlyId("chat");
330331
try {
331332
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
332-
const headStarted = Boolean(env.ANTHROPIC_API_KEY);
333+
const headStarted =
334+
dashboardAgentProvider() === "bedrock"
335+
? Boolean(env.DASHBOARD_AGENT_AWS_REGION || env.AWS_REGION || env.AWS_DEFAULT_REGION)
336+
: Boolean(env.ANTHROPIC_API_KEY);
333337

334338
// The lookups and the mint all run before the chat row exists, so a failure here can't
335339
// leave an empty chat behind in the user's history.

apps/webapp/app/services/dashboardAgentHeadStart.server.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createAnthropic } from "@ai-sdk/anthropic";
21
import {
32
DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
43
DASHBOARD_AGENT_MODEL,
@@ -8,9 +7,12 @@ import {
87
} from "@internal/dashboard-agent/tool-schemas";
98
import {
109
describePromptPrefix,
11-
PROMPT_CACHE_CONTROL,
1210
promptCacheAttributes,
1311
} from "@internal/dashboard-agent/prompt-prefix";
12+
import {
13+
resolveDashboardAgentModel,
14+
withCacheBreakpoint,
15+
} from "@internal/dashboard-agent/model-provider";
1416
import { ApiClient, SessionStreamInstance, writeTurnCompleteRecord } from "@trigger.dev/core/v3";
1517
import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
1618
import { streamText, type UIMessage, type UIMessageChunk } from "ai";
@@ -23,8 +25,6 @@ import { logger } from "~/services/logger.server";
2325

2426
const TASK_ID = "dashboard-agent";
2527

26-
const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY });
27-
2828
/** Shown when the warm first turn produced nothing. The provider error is only logged. */
2929
export const HEAD_START_FAILURE_ERROR_TEXT =
3030
"The assistant couldn't start this response. Please send your message again.";
@@ -113,16 +113,16 @@ export async function startDashboardAgentHeadStart(params: {
113113
run: async ({ chat: helper }) =>
114114
streamText({
115115
...helper.toStreamTextOptions({ tools }),
116-
model: anthropic(DASHBOARD_AGENT_MODEL),
116+
model: resolveDashboardAgentModel(DASHBOARD_AGENT_MODEL),
117117
// A structured system message, not a bare string: without provider options
118-
// Anthropic neither writes nor reads the cache, so this call paid full price
118+
// the provider neither writes nor reads the cache, so this call paid full price
119119
// for the prefix and the agent's step 2 then paid for a fresh write. The tool
120120
// key order is frozen (see `tool-schemas.ts`) so both prefixes are identical
121121
// — the logged fingerprint is how a drift becomes visible.
122122
system: {
123123
role: "system",
124124
content: system,
125-
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
125+
providerOptions: withCacheBreakpoint(undefined, "prefix"),
126126
},
127127
onStepFinish: (step) => {
128128
logger.info(

internal-packages/dashboard-agent/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
".": "./src/index.ts",
1010
"./tool-curation": "./src/tool-curation.ts",
1111
"./tool-schemas": "./src/tool-schemas.ts",
12-
"./prompt-prefix": "./src/prompt-prefix.ts"
12+
"./prompt-prefix": "./src/prompt-prefix.ts",
13+
"./model-provider": "./src/model-provider.ts"
1314
},
1415
"dependencies": {
16+
"@ai-sdk/amazon-bedrock": "4.0.117",
1517
"@ai-sdk/anthropic": "^3.0.0",
1618
"@internal/dashboard-agent-contracts": "workspace:*",
1719
"@internal/dashboard-agent-db": "workspace:*",

internal-packages/dashboard-agent/src/agent-runtime.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { anthropic } from "@ai-sdk/anthropic";
21
import {
32
appendChatMessageOnce,
43
createDashboardAgentDb,
@@ -18,22 +17,16 @@ import {
1817
type UpsertInvestigationResult,
1918
} from "@internal/dashboard-agent-db";
2019
import { locals, logger } from "@trigger.dev/sdk";
21-
import {
22-
createProviderRegistry,
23-
type LanguageModel,
24-
type ModelMessage,
25-
type ToolSet,
26-
type UIMessage,
27-
} from "ai";
20+
import { type LanguageModel, type ModelMessage, type ToolSet, type UIMessage } from "ai";
2821
import { z } from "zod";
2922
import {
3023
agentPageContextSchema,
3124
forceSettledInvestigationState,
3225
investigationStateSchema,
3326
type InvestigationState,
3427
} from "@internal/dashboard-agent-contracts";
28+
import { withCacheBreakpoint } from "./model-provider";
3529
import { codeSystemPrompt, systemPrompt } from "./prompts";
36-
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
3730
import { buildDashboardAgentTools } from "./tools";
3831

3932
/**
@@ -63,8 +56,8 @@ function getDb(): DashboardAgentDbClient {
6356
}
6457

6558
// Resolves the `"provider:model-id"` strings on our managed prompts to AI SDK
66-
// models. Add another @ai-sdk/* provider here to allow it on a prompt.
67-
export const registry = createProviderRegistry({ anthropic });
59+
// models, against whichever provider is switched on.
60+
export { registry, resolveDashboardAgentModel } from "./model-provider";
6861

6962
// The agent's persistence, behind an interface so tests can inject a fake via
7063
// `locals` and never need a real database.
@@ -354,20 +347,17 @@ export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessa
354347
}) as ModelMessage[];
355348
}
356349

357-
// Same Anthropic breakpoint `prepareMessages` rolls onto a turn's last message.
350+
// Same breakpoint `prepareMessages` rolls onto a turn's last message.
358351
export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] {
359352
if (messages.length === 0) return messages;
360353
const last = messages[messages.length - 1]!;
361354
return [
362355
...messages.slice(0, -1),
363356
{
364357
...last,
365-
providerOptions: {
366-
...last.providerOptions,
367-
// Merged, not replaced: the breakpoint is one Anthropic option among any
368-
// others the message already carries.
369-
anthropic: { ...last.providerOptions?.anthropic, cacheControl: PROMPT_CACHE_CONTROL },
370-
},
358+
// Merged, not replaced: the breakpoint is one provider option among any
359+
// others the message already carries.
360+
providerOptions: withCacheBreakpoint(last.providerOptions, "prefix"),
371361
},
372362
];
373363
}

internal-packages/dashboard-agent/src/cache-breakpoint.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ describe("withCacheBreakpointOnLast", () => {
3535
const prepared = withCacheBreakpointOnLast(lastMessageWithAnthropicOptions());
3636

3737
expect(prepared[1]!.providerOptions).toEqual({
38+
__cacheBreakpoint: { kind: "prefix" },
3839
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
3940
openai: { store: false },
4041
});
@@ -54,6 +55,7 @@ describe("prepareTurnMessages", () => {
5455
});
5556

5657
expect(prepared[1]!.providerOptions).toEqual({
58+
__cacheBreakpoint: { kind: "prefix" },
5759
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
5860
openai: { store: false },
5961
});

internal-packages/dashboard-agent/src/compaction.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { generateText, type ModelMessage, type UIMessage } from "ai";
55
import {
66
dashboardAgentModelKey,
77
latestCards,
8-
registry,
8+
resolveDashboardAgentModel,
99
sanitizeReplayedToolInputs,
1010
} from "./agent-runtime";
1111

@@ -271,7 +271,7 @@ export function renderTranscriptForSummary(messages: ModelMessage[]): string {
271271

272272
async function summarizeConversation(event: SummarizeEvent): Promise<string> {
273273
const { text } = await generateText({
274-
model: locals.get(dashboardAgentModelKey) ?? registry.languageModel(SUMMARY_MODEL),
274+
model: locals.get(dashboardAgentModelKey) ?? resolveDashboardAgentModel(SUMMARY_MODEL),
275275
system: SUMMARY_INSTRUCTION,
276276
prompt: renderTranscriptForSummary(event.messages),
277277
maxOutputTokens: SUMMARY_MAX_OUTPUT_TOKENS,

internal-packages/dashboard-agent/src/dashboard-agent.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
getStore,
1717
getSystemPrompt,
1818
modeFor,
19-
registry,
19+
resolveDashboardAgentModel,
2020
sanitizeReplayedToolInputs,
2121
settlementCardMessages,
2222
clearOpenInvestigations,
@@ -25,7 +25,7 @@ import {
2525
type DashboardAgentStore,
2626
} from "./agent-runtime";
2727
import { titlePrompt } from "./prompts";
28-
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
28+
import { withCacheBreakpoint } from "./model-provider";
2929
import { recordPromptCacheUsage, stepCachePrepareStep } from "./step-cache";
3030
import { dashboardAgentActionSchema, handleWatchAction } from "./watch-actions";
3131
import { dashboardAgentCompaction, withDurableState } from "./compaction";
@@ -309,9 +309,7 @@ async function generateAndSaveTitle(
309309
const { text } = await generateText({
310310
model:
311311
locals.get(dashboardAgentModelKey) ??
312-
registry.languageModel(
313-
(resolved.model ?? "anthropic:claude-haiku-4-5") as `anthropic:${string}`
314-
),
312+
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-haiku-4-5"),
315313
system: resolved.text,
316314
prompt: userText,
317315
...resolved.toAISDKTelemetry(),
@@ -428,7 +426,7 @@ export const dashboardAgent = chat.agent({
428426
// prompt; the resolve is cached per process. The cache breakpoint on the system
429427
// block carries through toStreamTextOptions() and survives suspend/resume.
430428
chat.prompt.set(await getSystemPrompt(modeFor(clientData)), {
431-
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
429+
providerOptions: withCacheBreakpoint(undefined, "prefix"),
432430
});
433431
},
434432

@@ -581,9 +579,7 @@ export const dashboardAgent = chat.agent({
581579
...options,
582580
model:
583581
locals.get(dashboardAgentModelKey) ??
584-
registry.languageModel(
585-
(resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}`
586-
),
582+
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"),
587583
messages,
588584
abortSignal: signal,
589585
prepareStep: stepCachePrepareStep(options) as never,

internal-packages/dashboard-agent/src/eval-turn.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { anthropic } from "@ai-sdk/anthropic";
21
import {
32
createDashboardAgentDb,
43
insertTurnEval,
54
type DashboardAgentDbClient,
65
} from "@internal/dashboard-agent-db";
76
import { logger, task } from "@trigger.dev/sdk";
87
import { EVAL_ERROR_CATEGORIES, redactedEvalOutputErrored } from "./eval-policy";
8+
import { resolveDashboardAgentModel } from "./model-provider";
99
import { generateObject } from "ai";
1010
import { z } from "zod";
1111

@@ -164,7 +164,7 @@ export const evalTurn = task({
164164
id: "dashboard-agent-eval-turn",
165165
run: async (payload: EvalTurnPayload, { ctx }) => {
166166
const { object } = await generateObject({
167-
model: anthropic(JUDGE_MODEL),
167+
model: resolveDashboardAgentModel(`anthropic:${JUDGE_MODEL}`),
168168
schema: TurnEval,
169169
system: JUDGE_SYSTEM,
170170
prompt: [

0 commit comments

Comments
 (0)