From f77655421feab2435124899852f4eaf415c92a2b Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:08:06 +0000 Subject: [PATCH 01/19] feat(dashboard-agent): run model calls through Bedrock behind an env switch DASHBOARD_AGENT_MODEL_PROVIDER=bedrock routes the agent's model calls through AWS Bedrock; the default stays direct Anthropic. Managed prompts keep canonical "anthropic:" strings, resolved per provider in one place alongside the provider-shaped prompt-cache options. --- .../dashboard-agent/package.json | 4 +- .../dashboard-agent/src/agent-runtime.ts | 26 ++--- .../dashboard-agent/src/compaction.ts | 4 +- .../dashboard-agent/src/dashboard-agent.ts | 14 +-- .../src/model-provider.test.ts | 68 ++++++++++++ .../dashboard-agent/src/model-provider.ts | 100 ++++++++++++++++++ .../dashboard-agent/src/step-cache.ts | 43 ++------ .../dashboard-agent/src/watch-actions.ts | 12 +-- pnpm-lock.yaml | 39 ++++++- 9 files changed, 239 insertions(+), 71 deletions(-) create mode 100644 internal-packages/dashboard-agent/src/model-provider.test.ts create mode 100644 internal-packages/dashboard-agent/src/model-provider.ts diff --git a/internal-packages/dashboard-agent/package.json b/internal-packages/dashboard-agent/package.json index 1808d60e77..eaf9747b21 100644 --- a/internal-packages/dashboard-agent/package.json +++ b/internal-packages/dashboard-agent/package.json @@ -9,9 +9,11 @@ ".": "./src/index.ts", "./tool-curation": "./src/tool-curation.ts", "./tool-schemas": "./src/tool-schemas.ts", - "./prompt-prefix": "./src/prompt-prefix.ts" + "./prompt-prefix": "./src/prompt-prefix.ts", + "./model-provider": "./src/model-provider.ts" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "4.0.117", "@ai-sdk/anthropic": "^3.0.0", "@internal/dashboard-agent-contracts": "workspace:*", "@internal/dashboard-agent-db": "workspace:*", diff --git a/internal-packages/dashboard-agent/src/agent-runtime.ts b/internal-packages/dashboard-agent/src/agent-runtime.ts index d8d20ba2e2..fe1d99e369 100644 --- a/internal-packages/dashboard-agent/src/agent-runtime.ts +++ b/internal-packages/dashboard-agent/src/agent-runtime.ts @@ -1,4 +1,3 @@ -import { anthropic } from "@ai-sdk/anthropic"; import { appendChatMessageOnce, createDashboardAgentDb, @@ -18,13 +17,7 @@ import { type UpsertInvestigationResult, } from "@internal/dashboard-agent-db"; import { locals, logger } from "@trigger.dev/sdk"; -import { - createProviderRegistry, - type LanguageModel, - type ModelMessage, - type ToolSet, - type UIMessage, -} from "ai"; +import { type LanguageModel, type ModelMessage, type ToolSet, type UIMessage } from "ai"; import { z } from "zod"; import { agentPageContextSchema, @@ -32,8 +25,8 @@ import { investigationStateSchema, type InvestigationState, } from "@internal/dashboard-agent-contracts"; +import { withCacheBreakpoint } from "./model-provider"; import { codeSystemPrompt, systemPrompt } from "./prompts"; -import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; import { buildDashboardAgentTools } from "./tools"; /** @@ -63,8 +56,8 @@ function getDb(): DashboardAgentDbClient { } // Resolves the `"provider:model-id"` strings on our managed prompts to AI SDK -// models. Add another @ai-sdk/* provider here to allow it on a prompt. -export const registry = createProviderRegistry({ anthropic }); +// models, against whichever provider is switched on. +export { registry, resolveDashboardAgentModel } from "./model-provider"; // The agent's persistence, behind an interface so tests can inject a fake via // `locals` and never need a real database. @@ -354,7 +347,7 @@ export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessa }) as ModelMessage[]; } -// Same Anthropic breakpoint `prepareMessages` rolls onto a turn's last message. +// Same breakpoint `prepareMessages` rolls onto a turn's last message. export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] { if (messages.length === 0) return messages; const last = messages[messages.length - 1]!; @@ -362,12 +355,9 @@ export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessag ...messages.slice(0, -1), { ...last, - providerOptions: { - ...last.providerOptions, - // Merged, not replaced: the breakpoint is one Anthropic option among any - // others the message already carries. - anthropic: { ...last.providerOptions?.anthropic, cacheControl: PROMPT_CACHE_CONTROL }, - }, + // Merged, not replaced: the breakpoint is one provider option among any + // others the message already carries. + providerOptions: withCacheBreakpoint(last.providerOptions, "prefix"), }, ]; } diff --git a/internal-packages/dashboard-agent/src/compaction.ts b/internal-packages/dashboard-agent/src/compaction.ts index 83e5d82b83..2f1ce53416 100644 --- a/internal-packages/dashboard-agent/src/compaction.ts +++ b/internal-packages/dashboard-agent/src/compaction.ts @@ -5,7 +5,7 @@ import { generateText, type ModelMessage, type UIMessage } from "ai"; import { dashboardAgentModelKey, latestCards, - registry, + resolveDashboardAgentModel, sanitizeReplayedToolInputs, } from "./agent-runtime"; @@ -271,7 +271,7 @@ export function renderTranscriptForSummary(messages: ModelMessage[]): string { async function summarizeConversation(event: SummarizeEvent): Promise { const { text } = await generateText({ - model: locals.get(dashboardAgentModelKey) ?? registry.languageModel(SUMMARY_MODEL), + model: locals.get(dashboardAgentModelKey) ?? resolveDashboardAgentModel(SUMMARY_MODEL), system: SUMMARY_INSTRUCTION, prompt: renderTranscriptForSummary(event.messages), maxOutputTokens: SUMMARY_MAX_OUTPUT_TOKENS, diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.ts b/internal-packages/dashboard-agent/src/dashboard-agent.ts index 237cbc64d3..c47da63918 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.ts @@ -16,7 +16,7 @@ import { getStore, getSystemPrompt, modeFor, - registry, + resolveDashboardAgentModel, sanitizeReplayedToolInputs, settlementCardMessages, clearOpenInvestigations, @@ -25,7 +25,7 @@ import { type DashboardAgentStore, } from "./agent-runtime"; import { titlePrompt } from "./prompts"; -import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; +import { withCacheBreakpoint } from "./model-provider"; import { recordPromptCacheUsage, stepCachePrepareStep } from "./step-cache"; import { dashboardAgentActionSchema, handleWatchAction } from "./watch-actions"; import { dashboardAgentCompaction, withDurableState } from "./compaction"; @@ -309,9 +309,7 @@ async function generateAndSaveTitle( const { text } = await generateText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-haiku-4-5") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-haiku-4-5"), system: resolved.text, prompt: userText, ...resolved.toAISDKTelemetry(), @@ -428,7 +426,7 @@ export const dashboardAgent = chat.agent({ // prompt; the resolve is cached per process. The cache breakpoint on the system // block carries through toStreamTextOptions() and survives suspend/resume. chat.prompt.set(await getSystemPrompt(modeFor(clientData)), { - providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } }, + providerOptions: withCacheBreakpoint(undefined, "prefix"), }); }, @@ -581,9 +579,7 @@ export const dashboardAgent = chat.agent({ ...options, model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"), messages, abortSignal: signal, prepareStep: stepCachePrepareStep(options) as never, diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts new file mode 100644 index 0000000000..7606f6cda0 --- /dev/null +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; +import { + isLongLivedCacheBreakpoint, + isStepCacheBreakpoint, + resolveDashboardAgentModel, + STEP_CACHE_CONTROL, + withCacheBreakpoint, + withoutCacheBreakpoint, +} from "./model-provider"; + +function useBedrock() { + process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; +} + +afterEach(() => { + delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; +}); + +describe("resolveDashboardAgentModel", () => { + it("resolves a canonical prompt string against Anthropic by default", () => { + expect(resolveDashboardAgentModel("anthropic:claude-sonnet-4-6").modelId).toBe( + "claude-sonnet-4-6" + ); + }); + + it("maps the same canonical string to a Bedrock inference profile", () => { + useBedrock(); + expect(resolveDashboardAgentModel("anthropic:claude-sonnet-4-6").modelId).toBe( + "us.anthropic.claude-sonnet-4-6-v1" + ); + expect(resolveDashboardAgentModel("anthropic:claude-haiku-4-5").modelId).toBe( + "us.anthropic.claude-haiku-4-5-20251001-v1:0" + ); + }); +}); + +describe("cache breakpoints", () => { + it("emits Anthropic cache control by default", () => { + expect(withCacheBreakpoint({ openai: { store: false } }, "prefix")).toEqual({ + openai: { store: false }, + anthropic: { cacheControl: PROMPT_CACHE_CONTROL }, + }); + expect(withCacheBreakpoint(undefined, "step")).toEqual({ + anthropic: { cacheControl: STEP_CACHE_CONTROL }, + }); + }); + + it("emits a Bedrock cache point when the switch is on", () => { + useBedrock(); + expect(withCacheBreakpoint(undefined, "prefix")).toEqual({ + bedrock: { cachePoint: { type: "default" } }, + }); + }); + + it("classifies and strips the active provider's breakpoint", () => { + const anthropicStep = withCacheBreakpoint({ anthropic: { keep: true } }, "step"); + expect(isStepCacheBreakpoint(anthropicStep)).toBe(true); + expect(isLongLivedCacheBreakpoint(withCacheBreakpoint(undefined, "prefix"))).toBe(true); + expect(withoutCacheBreakpoint(anthropicStep)).toEqual({ anthropic: { keep: true } }); + + useBedrock(); + const bedrockStep = withCacheBreakpoint(undefined, "step"); + expect(isStepCacheBreakpoint(bedrockStep)).toBe(true); + expect(isLongLivedCacheBreakpoint(bedrockStep)).toBe(false); + expect(withoutCacheBreakpoint(bedrockStep)).toEqual({}); + }); +}); diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts new file mode 100644 index 0000000000..85afadbc11 --- /dev/null +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -0,0 +1,100 @@ +import { bedrock } from "@ai-sdk/amazon-bedrock"; +import { anthropic } from "@ai-sdk/anthropic"; +import { createProviderRegistry } from "ai"; +import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; + +/** + * Which provider the agent's model calls go through, and the two things that + * differ between them: the model id, and the shape of the prompt-cache options. + * + * Managed prompts stay canonical `"anthropic:"` strings whichever + * provider is active, so a stored or dashboard-overridden prompt keeps meaning + * the same model. + * + * Kept free of the SDK runtime so the webapp's head-start path can import it. + */ + +export type DashboardAgentProvider = "anthropic" | "bedrock"; + +/** Global switch, read per call so it can be set per environment. */ +export function dashboardAgentProvider(): DashboardAgentProvider { + return process.env.DASHBOARD_AGENT_MODEL_PROVIDER === "bedrock" ? "bedrock" : "anthropic"; +} + +export const registry = createProviderRegistry({ anthropic, bedrock }); + +/** Canonical model id -> Bedrock inference profile. */ +const BEDROCK_MODEL_IDS: Record = { + "claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6-v1", + "claude-haiku-4-5": "us.anthropic.claude-haiku-4-5-20251001-v1:0", +}; + +/** Resolve a canonical `"anthropic:"` string against the active provider. */ +export function resolveDashboardAgentModel(model: string) { + const id = model.startsWith("anthropic:") ? model.slice("anthropic:".length) : model; + if (dashboardAgentProvider() === "anthropic") { + return registry.languageModel(`anthropic:${id}` as `anthropic:${string}`); + } + const bedrockId = BEDROCK_MODEL_IDS[id] ?? `us.anthropic.${id}`; + return registry.languageModel(`bedrock:${bedrockId}` as `bedrock:${string}`); +} + +/** + * The two breakpoints a turn sets: the prefix one that spans the turn, and the + * rolling per-step one. + */ +export type CacheBreakpoint = "prefix" | "step"; + +export const STEP_CACHE_CONTROL = { type: "ephemeral", ttl: "5m" } as const; + +type ProviderOptions = Record | undefined; + +function cacheOptions(breakpoint: CacheBreakpoint): Record { + if (dashboardAgentProvider() === "anthropic") { + return { + anthropic: { + cacheControl: breakpoint === "prefix" ? PROMPT_CACHE_CONTROL : STEP_CACHE_CONTROL, + }, + }; + } + // No ttl: Bedrock's 1h cache is not supported on every model we run, and its + // default 5m applies to both breakpoints. + return { bedrock: { cachePoint: { type: "default" } } }; +} + +/** Merge the active provider's breakpoint into a message's provider options. */ +export function withCacheBreakpoint( + providerOptions: ProviderOptions, + breakpoint: CacheBreakpoint +): Record { + const [key, options] = Object.entries(cacheOptions(breakpoint))[0]!; + return { ...providerOptions, [key]: { ...providerOptions?.[key], ...options } }; +} + +/** + * Whether these options carry the rolling step breakpoint. On Bedrock a cache + * point carries no ttl, so any breakpoint on a message counts as the rolling one. + */ +export function isStepCacheBreakpoint(providerOptions: ProviderOptions): boolean { + if (dashboardAgentProvider() === "anthropic") { + return providerOptions?.anthropic?.cacheControl?.ttl === STEP_CACHE_CONTROL.ttl; + } + return providerOptions?.bedrock?.cachePoint !== undefined; +} + +/** Whether these options carry a breakpoint that outlives a step. */ +export function isLongLivedCacheBreakpoint(providerOptions: ProviderOptions): boolean { + if (dashboardAgentProvider() !== "anthropic") return false; + const ttl = providerOptions?.anthropic?.cacheControl?.ttl; + return typeof ttl === "string" && ttl !== STEP_CACHE_CONTROL.ttl; +} + +/** The same options with the active provider's breakpoint removed. */ +export function withoutCacheBreakpoint(providerOptions: ProviderOptions): Record { + const key = dashboardAgentProvider() === "anthropic" ? "anthropic" : "bedrock"; + const field = key === "anthropic" ? "cacheControl" : "cachePoint"; + const { [key]: provider, ...rest } = (providerOptions ?? {}) as Record; + const { [field]: _dropped, ...providerRest } = (provider ?? {}) as Record; + // An empty provider entry is not the same as no options for it, so drop the key. + return Object.keys(providerRest).length > 0 ? { ...rest, [key]: providerRest } : rest; +} diff --git a/internal-packages/dashboard-agent/src/step-cache.ts b/internal-packages/dashboard-agent/src/step-cache.ts index 56465a0ee9..ed49124913 100644 --- a/internal-packages/dashboard-agent/src/step-cache.ts +++ b/internal-packages/dashboard-agent/src/step-cache.ts @@ -1,5 +1,11 @@ import { logger } from "@trigger.dev/sdk"; import type { ModelMessage, ToolSet } from "ai"; +import { + isLongLivedCacheBreakpoint, + isStepCacheBreakpoint, + withCacheBreakpoint, + withoutCacheBreakpoint, +} from "./model-provider"; import { describePromptPrefix, promptCacheAttributes, @@ -15,36 +21,16 @@ import { * its accumulated tool outputs uncached on every step. */ -export const STEP_CACHE_CONTROL = { type: "ephemeral", ttl: "5m" } as const; +export { STEP_CACHE_CONTROL } from "./model-provider"; // Anthropic silently refuses to cache a prefix shorter than roughly 1024 tokens. export const MIN_STEP_CACHE_CHARS = 4_096; type MaybeCached = { providerOptions?: Record }; -function cacheControlTtl(message: MaybeCached): string | undefined { - const anthropic = message.providerOptions?.anthropic as - | { cacheControl?: { ttl?: unknown } } - | undefined; - const ttl = anthropic?.cacheControl?.ttl; - return typeof ttl === "string" ? ttl : undefined; -} - -function anthropicOptions(message: MaybeCached): Record { - const anthropic = message.providerOptions?.anthropic; - return typeof anthropic === "object" && anthropic !== null - ? (anthropic as Record) - : {}; -} - function withoutStepBreakpoint(message: T): T { - if (cacheControlTtl(message) !== STEP_CACHE_CONTROL.ttl) return message; - const { anthropic, ...rest } = message.providerOptions as Record; - const { cacheControl: _dropped, ...anthropicRest } = anthropic as Record; - // An empty `anthropic` is not the same as no Anthropic options, so drop the key. - const providerOptions = - Object.keys(anthropicRest).length > 0 ? { ...rest, anthropic: anthropicRest } : rest; - return { ...message, providerOptions }; + if (!isStepCacheBreakpoint(message.providerOptions)) return message; + return { ...message, providerOptions: withoutCacheBreakpoint(message.providerOptions) }; } // Only ever one step breakpoint at a time: Anthropic allows four in total, and the @@ -54,8 +40,7 @@ export function markStepCacheBreakpoint(messages: T[]): T let lastLongLived = -1; messages.forEach((message, index) => { - const ttl = cacheControlTtl(message); - if (ttl !== undefined && ttl !== STEP_CACHE_CONTROL.ttl) lastLongLived = index; + if (isLongLivedCacheBreakpoint(message.providerOptions)) lastLongLived = index; }); const tail = messages.slice(lastLongLived + 1); if ((JSON.stringify(tail)?.length ?? 0) < MIN_STEP_CACHE_CHARS) { @@ -66,13 +51,7 @@ export function markStepCacheBreakpoint(messages: T[]): T const last = stripped[stripped.length - 1]!; return [ ...stripped.slice(0, -1), - { - ...last, - providerOptions: { - ...last.providerOptions, - anthropic: { ...anthropicOptions(last), cacheControl: STEP_CACHE_CONTROL }, - }, - }, + { ...last, providerOptions: withCacheBreakpoint(last.providerOptions, "step") }, ]; } diff --git a/internal-packages/dashboard-agent/src/watch-actions.ts b/internal-packages/dashboard-agent/src/watch-actions.ts index ddb46e0f4d..af356f0f4b 100644 --- a/internal-packages/dashboard-agent/src/watch-actions.ts +++ b/internal-packages/dashboard-agent/src/watch-actions.ts @@ -28,7 +28,7 @@ import { getStore, getSystemPrompt, modeFor, - registry, + resolveDashboardAgentModel, latestCards, sanitizeReplayedToolInputs, clearOpenInvestigations, @@ -428,7 +428,7 @@ async function narrateWithPlan(input: { ? streamText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel("anthropic:claude-haiku-4-5"), + resolveDashboardAgentModel("anthropic:claude-haiku-4-5"), system: HAIKU_WAKE_BRIEF, // Bounded on purpose: the wake alone, no conversation and no tools. messages: [ @@ -442,9 +442,7 @@ async function narrateWithPlan(input: { : streamText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"), system: resolved.text, // No tools: a wake reports what the check already established, and carries no // delegated token to read with. The breakpoint goes on the last message of the @@ -782,9 +780,7 @@ async function conductWatchInvestigation(args: { const result = streamText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"), system: resolved.text, tools, // Ten steps of accumulating tool output is exactly what the rolling breakpoint diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 160c39b7b2..c76d3a6f99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1018,6 +1018,9 @@ importers: internal-packages/dashboard-agent: dependencies: + '@ai-sdk/amazon-bedrock': + specifier: 4.0.117 + version: 4.0.117(zod@3.25.76) '@ai-sdk/anthropic': specifier: ^3.0.0 version: 3.0.84(zod@3.25.76) @@ -2301,6 +2304,12 @@ importers: packages: + '@ai-sdk/amazon-bedrock@4.0.117': + resolution: {integrity: sha512-MebXAEsdvNdzKZCbVxFK0668KhYrs6W3mMH5fWT5Yc0TMSSTbwPI9fIw0sOzxLWd9TE9cJz73vVPjQjtG3vj/Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/anthropic@3.0.84': resolution: {integrity: sha512-BIDaHmCHs6Sr5VUsEkTbbVlAN4GWjg97X9x/IfXyviLtzsXvffui9XIcZugkAi1Ri6FnvI5T5qDGh5YLnSuzRg==} engines: {node: '>=18'} @@ -2331,6 +2340,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@3.0.71': + resolution: {integrity: sha512-j6eBAa5oHFZ4U5CxpIV3T4zXNM/BviodNCZCL1qHkA4aqkwK9iQ18TWYz2DZcXpw4BO5pikKzqpXORxb1EnZGA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/otel@1.0.0-beta.6': resolution: {integrity: sha512-K5VikyO3EKQkNk77ew9oMjM8FInKF+WWar599LmP8rQ0x0iB+P/DVS+h6zQvmecxMNPtQOOyt0uDQFx/AA0DGw==} engines: {node: '>=18'} @@ -9011,6 +9026,9 @@ packages: aws4fetch@1.0.18: resolution: {integrity: sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ==} + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios@1.16.1: resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} @@ -16055,6 +16073,17 @@ packages: snapshots: + '@ai-sdk/amazon-bedrock@4.0.117(zod@3.25.76)': + dependencies: + '@ai-sdk/anthropic': 3.0.84(zod@3.25.76) + '@ai-sdk/openai': 3.0.71(zod@3.25.76) + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.29(zod@3.25.76) + '@smithy/eventstream-codec': 4.2.5 + '@smithy/util-utf8': 4.2.0 + aws4fetch: 1.0.20 + zod: 3.25.76 + '@ai-sdk/anthropic@3.0.84(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -16088,6 +16117,12 @@ snapshots: '@ai-sdk/provider-utils': 4.0.29(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai@3.0.71(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.29(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/otel@1.0.0-beta.6(zod@3.25.76)': dependencies: '@ai-sdk/provider': 4.0.0-beta.5 @@ -24029,6 +24064,8 @@ snapshots: aws4fetch@1.0.18: {} + aws4fetch@1.0.20: {} + axios@1.16.1: dependencies: follow-redirects: 1.16.0 @@ -28563,7 +28600,7 @@ snapshots: node-abi@3.89.0: dependencies: - semver: 7.8.5 + semver: 7.8.1 optional: true node-abort-controller@3.1.1: {} From 2e6e26fdcc56e2117230350d2e718803f7cd0cd3 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:11:26 +0000 Subject: [PATCH 02/19] feat(dashboard-agent): report cache token usage on both providers Bedrock reports its cache write on the call's provider metadata and its cache read only on the call's usage, so the per-step cache telemetry reads whichever the active provider populates. --- .../dashboard-agent/src/model-provider.ts | 19 +++++++++++++++++++ .../dashboard-agent/src/step-cache.test.ts | 19 +++++++++++++++++++ .../dashboard-agent/src/step-cache.ts | 17 +++++++++-------- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index 85afadbc11..c398dd2719 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -89,6 +89,25 @@ export function isLongLivedCacheBreakpoint(providerOptions: ProviderOptions): bo return typeof ttl === "string" && ttl !== STEP_CACHE_CONTROL.ttl; } +/** + * The cache token counts the active provider reports on a call's metadata. + * Bedrock puts only the write there; its read count reaches the call's usage. + */ +export function cacheUsageFromProviderMetadata(providerMetadata: unknown): { + write?: number; + read?: number; +} { + const metadata = providerMetadata as Record | undefined; + const count = (value: unknown) => (typeof value === "number" ? value : undefined); + if (dashboardAgentProvider() === "anthropic") { + return { + write: count(metadata?.anthropic?.cacheCreationInputTokens), + read: count(metadata?.anthropic?.cacheReadInputTokens), + }; + } + return { write: count(metadata?.bedrock?.usage?.cacheWriteInputTokens) }; +} + /** The same options with the active provider's breakpoint removed. */ export function withoutCacheBreakpoint(providerOptions: ProviderOptions): Record { const key = dashboardAgentProvider() === "anthropic" ? "anthropic" : "bedrock"; diff --git a/internal-packages/dashboard-agent/src/step-cache.test.ts b/internal-packages/dashboard-agent/src/step-cache.test.ts index 662afc012b..f245ff0c6f 100644 --- a/internal-packages/dashboard-agent/src/step-cache.test.ts +++ b/internal-packages/dashboard-agent/src/step-cache.test.ts @@ -189,6 +189,25 @@ describe("per-step cache telemetry", () => { }); }); + it("reports Bedrock's write from its metadata and its read from the call's usage", () => { + process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; + try { + expect( + stepCacheAttributes( + 2, + { bedrock: { usage: { cacheWriteInputTokens: 8_000 } } }, + { inputTokenDetails: { cacheReadTokens: 12_000 } } + ) + ).toEqual({ + "dashboard_agent.step": 2, + "gen_ai.usage.cache_creation_input_tokens": 8_000, + "gen_ai.usage.cache_read_input_tokens": 12_000, + }); + } finally { + delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + } + }); + it("reports null rather than zero when the provider said nothing", () => { expect(stepCacheAttributes(0, undefined)).toEqual({ "dashboard_agent.step": 0, diff --git a/internal-packages/dashboard-agent/src/step-cache.ts b/internal-packages/dashboard-agent/src/step-cache.ts index ed49124913..b6306072a5 100644 --- a/internal-packages/dashboard-agent/src/step-cache.ts +++ b/internal-packages/dashboard-agent/src/step-cache.ts @@ -1,6 +1,7 @@ import { logger } from "@trigger.dev/sdk"; import type { ModelMessage, ToolSet } from "ai"; import { + cacheUsageFromProviderMetadata, isLongLivedCacheBreakpoint, isStepCacheBreakpoint, withCacheBreakpoint, @@ -76,16 +77,16 @@ export function stepCachePrepareStep(options: unknown): PrepareStepFn { export function stepCacheAttributes( step: number | undefined, - providerMetadata: unknown + providerMetadata: unknown, + usage?: PromptCacheUsage ): Record { - const anthropic = (providerMetadata as { anthropic?: Record } | undefined) - ?.anthropic; - const write = anthropic?.cacheCreationInputTokens; - const read = anthropic?.cacheReadInputTokens; + const { write, read } = cacheUsageFromProviderMetadata(providerMetadata); return { "dashboard_agent.step": step ?? null, - "gen_ai.usage.cache_creation_input_tokens": typeof write === "number" ? write : null, - "gen_ai.usage.cache_read_input_tokens": typeof read === "number" ? read : null, + "gen_ai.usage.cache_creation_input_tokens": + write ?? usage?.inputTokenDetails?.cacheWriteTokens ?? null, + "gen_ai.usage.cache_read_input_tokens": + read ?? usage?.inputTokenDetails?.cacheReadTokens ?? null, }; } @@ -110,7 +111,7 @@ export function recordPromptCacheUsage(args: { usage: args.usage, prefix: describePromptPrefix({ system: args.system, tools: args.tools }), }), - ...stepCacheAttributes(args.step, args.providerMetadata), + ...stepCacheAttributes(args.step, args.providerMetadata, args.usage), }); } catch (error) { // Measurement must never fail a turn. From bfb3a59a4383026c70a117e2c86697449cc373fb Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:20:56 +0000 Subject: [PATCH 03/19] fix(dashboard-agent): guard Bedrock model-id shape and drop the guessed fallback Confirm both Bedrock profiles against the SDK model-id union, throw on an unmapped id instead of guessing an unqualified profile, and add a structural shape test. --- .../src/model-provider.test.ts | 20 +++++++++++++++++++ .../dashboard-agent/src/model-provider.ts | 15 +++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts index 7606f6cda0..13ee4b7cd5 100644 --- a/internal-packages/dashboard-agent/src/model-provider.test.ts +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; import { + BEDROCK_MODEL_IDS, isLongLivedCacheBreakpoint, isStepCacheBreakpoint, resolveDashboardAgentModel, @@ -33,6 +34,25 @@ describe("resolveDashboardAgentModel", () => { "us.anthropic.claude-haiku-4-5-20251001-v1:0" ); }); + + it("throws rather than guessing a profile for an unmapped id", () => { + useBedrock(); + expect(() => resolveDashboardAgentModel("anthropic:claude-made-up-9-9")).toThrow( + /No Bedrock model mapping/ + ); + }); + + // Structural, not an echo of the table: an AWS us cross-region Anthropic profile + // is either dated with a `:N` suffix, or an undated `-vN` (the 4-6 generation). A + // dated id must never drop its `:N`, and every id must end in a version. + it("every Bedrock-mapped id has a well-formed AWS inference-profile shape", () => { + const dated = /^us\.anthropic\.claude-[a-z]+(?:-\d+)+-\d{8}-v\d+:\d+$/; + const undated = /^us\.anthropic\.claude-[a-z]+(?:-\d+)+-v\d+$/; + for (const id of Object.values(BEDROCK_MODEL_IDS)) { + expect(dated.test(id) || undated.test(id), id).toBe(true); + if (/-\d{8}-/.test(id)) expect(id, id).toMatch(/:\d+$/); + } + }); }); describe("cache breakpoints", () => { diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index c398dd2719..f5abf5f67c 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -23,8 +23,12 @@ export function dashboardAgentProvider(): DashboardAgentProvider { export const registry = createProviderRegistry({ anthropic, bedrock }); -/** Canonical model id -> Bedrock inference profile. */ -const BEDROCK_MODEL_IDS: Record = { +/** + * Canonical model id -> Bedrock us cross-region inference profile, verbatim from + * the @ai-sdk/amazon-bedrock model-id union. The 4-6 generation profiles are + * undated `-vN`; 4-5 and older carry a date and a `:N` suffix. + */ +export const BEDROCK_MODEL_IDS: Record = { "claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6-v1", "claude-haiku-4-5": "us.anthropic.claude-haiku-4-5-20251001-v1:0", }; @@ -35,7 +39,12 @@ export function resolveDashboardAgentModel(model: string) { if (dashboardAgentProvider() === "anthropic") { return registry.languageModel(`anthropic:${id}` as `anthropic:${string}`); } - const bedrockId = BEDROCK_MODEL_IDS[id] ?? `us.anthropic.${id}`; + const bedrockId = BEDROCK_MODEL_IDS[id]; + if (!bedrockId) { + // No Bedrock profile can be guessed from the canonical id — a made-up one is a + // guaranteed 404, so fail loudly instead. + throw new Error(`No Bedrock model mapping for "${id}"`); + } return registry.languageModel(`bedrock:${bedrockId}` as `bedrock:${string}`); } From de36f494526d9a004973acb112d176daac07ce03 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:23:44 +0000 Subject: [PATCH 04/19] fix(rbac): deny non-member user-actor tokens at the ability layer authenticateUserActor now applies the same membership floor as authenticateSession: a delegated token for a user who is not a member of the scoped org/project is denied, instead of being handed a usable (read:all-by-default) ability. Platform admins stay exempt. --- internal-packages/rbac/package.json | 1 + internal-packages/rbac/src/fallback.ts | 19 ++++++ .../rbac/src/fallback.userActor.test.ts | 65 +++++++++++++++++++ internal-packages/rbac/tsconfig.json | 5 +- pnpm-lock.yaml | 3 + 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 internal-packages/rbac/src/fallback.userActor.test.ts diff --git a/internal-packages/rbac/package.json b/internal-packages/rbac/package.json index 53374670f7..b667e94c05 100644 --- a/internal-packages/rbac/package.json +++ b/internal-packages/rbac/package.json @@ -9,6 +9,7 @@ "@trigger.dev/plugins": "workspace:*" }, "devDependencies": { + "@internal/testcontainers": "workspace:*", "@trigger.dev/database": "workspace:*", "@types/node": "^24.13.3", "rimraf": "6.0.1" diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 2d9bb9cea9..57ac05cbbd 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -260,6 +260,25 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { if (!claims) { return { ok: false, status: 401, error: "Invalid user-actor token" }; } + + // Same tenant floor as authenticateSession: a non-member's delegated token is + // denied here, not handed a usable ability (even for reads). Admins are exempt. + const user = await this.replica.user.findFirst({ + where: { id: claims.userId }, + select: { id: true, admin: true }, + }); + if (!user) { + return { ok: false, status: 401, error: "Invalid user-actor token" }; + } + if (!user.admin) { + const denied = await this.deniedByMembership( + context.organizationId, + context.projectId, + user.id + ); + if (denied) return { ok: false, status: 403, error: "Unauthorized" }; + } + return { ok: true, userId: claims.userId, diff --git a/internal-packages/rbac/src/fallback.userActor.test.ts b/internal-packages/rbac/src/fallback.userActor.test.ts new file mode 100644 index 0000000000..a80999a7e2 --- /dev/null +++ b/internal-packages/rbac/src/fallback.userActor.test.ts @@ -0,0 +1,65 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { signUserActorToken } from "@trigger.dev/plugins"; +import { postgresTest } from "@internal/testcontainers"; +import { expect } from "vitest"; +import { RoleBaseAccessFallback } from "./fallback.js"; + +const SECRET = "test-user-actor-secret"; + +function uatRequest(token: string): Request { + return new Request("https://example.test", { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +async function seedUser(prisma: PrismaClient, email: string, admin = false) { + return prisma.user.create({ + data: { email, authenticationMethod: "MAGIC_LINK", admin }, + }); +} + +postgresTest( + "authenticateUserActor: member is allowed, non-member is denied", + async ({ prisma }) => { + const org = await prisma.organization.create({ + data: { slug: `org-${Date.now()}`, title: "Org" }, + }); + const member = await seedUser(prisma as PrismaClient, "member@example.test"); + const stranger = await seedUser(prisma as PrismaClient, "stranger@example.test"); + await prisma.orgMember.create({ + data: { organizationId: org.id, userId: member.id }, + }); + + const controller = new RoleBaseAccessFallback(prisma as PrismaClient, { + userActorSecret: SECRET, + }).create(); + + const memberToken = await signUserActorToken(SECRET, { + userId: member.id, + client: "test", + }); + const strangerToken = await signUserActorToken(SECRET, { + userId: stranger.id, + client: "test", + }); + + // Member with a capless token keeps the read:all default. + const memberResult = await controller.authenticateUserActor(uatRequest(memberToken), { + organizationId: org.id, + }); + expect(memberResult.ok).toBe(true); + if (memberResult.ok) { + expect(memberResult.ability.can("read", { type: "runs", id: "run_x" })).toBe(true); + } + + // Non-member is denied at the ability layer, not handed a usable ability. + const strangerResult = await controller.authenticateUserActor(uatRequest(strangerToken), { + organizationId: org.id, + }); + expect(strangerResult.ok).toBe(false); + if (!strangerResult.ok) { + expect(strangerResult.status).toBe(403); + } + }, + 120_000 +); diff --git a/internal-packages/rbac/tsconfig.json b/internal-packages/rbac/tsconfig.json index 9ab7d81355..81f6a3eaa8 100644 --- a/internal-packages/rbac/tsconfig.json +++ b/internal-packages/rbac/tsconfig.json @@ -13,5 +13,8 @@ "strict": true, "customConditions": ["@triggerdotdev/source"] }, - "exclude": ["node_modules", "dist"] + // Excluded from tsc: it imports @internal/testcontainers, whose source + // (pulled in via customConditions) doesn't type-check under this config. + // vitest still runs and transpiles it. + "exclude": ["node_modules", "dist", "src/fallback.userActor.test.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c76d3a6f99..a97ec7af81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1235,6 +1235,9 @@ importers: specifier: workspace:* version: link:../../packages/plugins devDependencies: + '@internal/testcontainers': + specifier: workspace:* + version: link:../testcontainers '@trigger.dev/database': specifier: workspace:* version: link:../database From b938d4dad3a69d075fda20cd1c0eecfde2fdbd70 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:27:32 +0000 Subject: [PATCH 05/19] fix(webapp): route dashboard agent head-start through the shared model provider --- apps/webapp/app/env.server.ts | 7 +++++++ .../app/services/dashboardAgentHeadStart.server.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 3ce98dd521..426da6335b 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -168,6 +168,13 @@ const EnvironmentSchema = z // uses its own key on the Trigger side. When unset, Head Start is disabled // and the first turn falls back to the normal cold-start path. ANTHROPIC_API_KEY: z.string().optional(), + // AWS credentials for the dashboard agent's Bedrock provider (only used when + // DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). + AWS_REGION: z.string().optional(), + AWS_ACCESS_KEY_ID: z.string().optional(), + AWS_SECRET_ACCESS_KEY: z.string().optional(), + AWS_SESSION_TOKEN: z.string().optional(), + AWS_BEARER_TOKEN_BEDROCK: z.string().optional(), DIRECT_URL: z .string() .refine( diff --git a/apps/webapp/app/services/dashboardAgentHeadStart.server.ts b/apps/webapp/app/services/dashboardAgentHeadStart.server.ts index 4a5936a196..1cb8cae34d 100644 --- a/apps/webapp/app/services/dashboardAgentHeadStart.server.ts +++ b/apps/webapp/app/services/dashboardAgentHeadStart.server.ts @@ -1,4 +1,3 @@ -import { createAnthropic } from "@ai-sdk/anthropic"; import { DASHBOARD_AGENT_CODE_SYSTEM_PROMPT, DASHBOARD_AGENT_MODEL, @@ -8,9 +7,12 @@ import { } from "@internal/dashboard-agent/tool-schemas"; import { describePromptPrefix, - PROMPT_CACHE_CONTROL, promptCacheAttributes, } from "@internal/dashboard-agent/prompt-prefix"; +import { + resolveDashboardAgentModel, + withCacheBreakpoint, +} from "@internal/dashboard-agent/model-provider"; import { ApiClient, SessionStreamInstance, writeTurnCompleteRecord } from "@trigger.dev/core/v3"; import { chat as chatServer } from "@trigger.dev/sdk/chat-server"; import { streamText, type UIMessage, type UIMessageChunk } from "ai"; @@ -23,8 +25,6 @@ import { logger } from "~/services/logger.server"; const TASK_ID = "dashboard-agent"; -const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }); - /** Shown when the warm first turn produced nothing. The provider error is only logged. */ export const HEAD_START_FAILURE_ERROR_TEXT = "The assistant couldn't start this response. Please send your message again."; @@ -113,16 +113,16 @@ export async function startDashboardAgentHeadStart(params: { run: async ({ chat: helper }) => streamText({ ...helper.toStreamTextOptions({ tools }), - model: anthropic(DASHBOARD_AGENT_MODEL), + model: resolveDashboardAgentModel(DASHBOARD_AGENT_MODEL), // A structured system message, not a bare string: without provider options - // Anthropic neither writes nor reads the cache, so this call paid full price + // the provider neither writes nor reads the cache, so this call paid full price // for the prefix and the agent's step 2 then paid for a fresh write. The tool // key order is frozen (see `tool-schemas.ts`) so both prefixes are identical // — the logged fingerprint is how a drift becomes visible. system: { role: "system", content: system, - providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } }, + providerOptions: withCacheBreakpoint(undefined, "prefix"), }, onStepFinish: (step) => { logger.info( From 4f93ff11eac0d9e0053efec48cba990c33f29d50 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:29:38 +0000 Subject: [PATCH 06/19] docs: add server changes note for dashboard agent auth hardening --- .server-changes/dashboard-agent-auth-hardening.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/dashboard-agent-auth-hardening.md diff --git a/.server-changes/dashboard-agent-auth-hardening.md b/.server-changes/dashboard-agent-auth-hardening.md new file mode 100644 index 0000000000..989293fbdb --- /dev/null +++ b/.server-changes/dashboard-agent-auth-hardening.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Internal security improvements for dashboard assistant. From 0a54744ae613a85cb59d17e5de1f7e01de24c4e1 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:32:27 +0000 Subject: [PATCH 07/19] fix(webapp): make dashboard agent head-start gate provider-aware --- ....projects.$projectParam.env.$envParam.dashboard-agent.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index 499e621b83..ee44dac23a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -17,6 +17,7 @@ import { softDeleteChat, } from "@internal/dashboard-agent-db"; import { watchDraftSchema, type WatchDraft } from "@internal/dashboard-agent-contracts"; +import { dashboardAgentProvider } from "@internal/dashboard-agent/model-provider"; import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic"; import type { UIMessage } from "ai"; import { z } from "zod"; @@ -329,7 +330,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const chatId = generateFriendlyId("chat"); try { const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id); - const headStarted = Boolean(env.ANTHROPIC_API_KEY); + const headStarted = + dashboardAgentProvider() === "bedrock" + ? Boolean(env.AWS_REGION && (env.AWS_ACCESS_KEY_ID || env.AWS_BEARER_TOKEN_BEDROCK)) + : Boolean(env.ANTHROPIC_API_KEY); // The lookups and the mint all run before the chat row exists, so a failure here can't // leave an empty chat behind in the user's history. From 87851ad640a3ec64713f81e1a96a469d356d99f4 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:39:32 +0000 Subject: [PATCH 08/19] fix(webapp): gate bedrock head-start on region and validate provider env --- apps/webapp/app/env.server.ts | 7 ++++++- ...projects.$projectParam.env.$envParam.dashboard-agent.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 426da6335b..376f0fd297 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -168,9 +168,14 @@ const EnvironmentSchema = z // uses its own key on the Trigger side. When unset, Head Start is disabled // and the first turn falls back to the normal cold-start path. ANTHROPIC_API_KEY: z.string().optional(), + // Selects the dashboard agent's LLM provider (default anthropic). The internal + // seam reads process.env directly; this entry validates the value webapp-side. + DASHBOARD_AGENT_MODEL_PROVIDER: z.enum(["anthropic", "bedrock"]).default("anthropic"), // AWS credentials for the dashboard agent's Bedrock provider (only used when - // DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). + // DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). The + // provider resolves credentials itself, so only the region is read here. AWS_REGION: z.string().optional(), + AWS_DEFAULT_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(), AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_SESSION_TOKEN: z.string().optional(), diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index ee44dac23a..3c18a84880 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -332,7 +332,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id); const headStarted = dashboardAgentProvider() === "bedrock" - ? Boolean(env.AWS_REGION && (env.AWS_ACCESS_KEY_ID || env.AWS_BEARER_TOKEN_BEDROCK)) + ? Boolean(env.AWS_REGION || env.AWS_DEFAULT_REGION) : Boolean(env.ANTHROPIC_API_KEY); // The lookups and the mint all run before the chat row exists, so a failure here can't From 66cfdfb45b97f8b00b38884b22ed2b94c4127136 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:44:55 +0000 Subject: [PATCH 09/19] chore: drop server-changes note --- .server-changes/dashboard-agent-auth-hardening.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .server-changes/dashboard-agent-auth-hardening.md diff --git a/.server-changes/dashboard-agent-auth-hardening.md b/.server-changes/dashboard-agent-auth-hardening.md deleted file mode 100644 index 989293fbdb..0000000000 --- a/.server-changes/dashboard-agent-auth-hardening.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Internal security improvements for dashboard assistant. From f4ba8d3cdaa369364d0bea766e5fb26e9dabb837 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:02:45 +0000 Subject: [PATCH 10/19] fix(webapp): gate bedrock head-start on AWS_REGION only --- apps/webapp/app/env.server.ts | 1 - ...Slug.projects.$projectParam.env.$envParam.dashboard-agent.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 376f0fd297..fdea3df069 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -175,7 +175,6 @@ const EnvironmentSchema = z // DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). The // provider resolves credentials itself, so only the region is read here. AWS_REGION: z.string().optional(), - AWS_DEFAULT_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(), AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_SESSION_TOKEN: z.string().optional(), diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index 3c18a84880..87aa9f931a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -332,7 +332,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id); const headStarted = dashboardAgentProvider() === "bedrock" - ? Boolean(env.AWS_REGION || env.AWS_DEFAULT_REGION) + ? Boolean(env.AWS_REGION) : Boolean(env.ANTHROPIC_API_KEY); // The lookups and the mint all run before the chat row exists, so a failure here can't From df2a1bbe7a0865f79be1cf2ef79d28aa6bc397f9 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:02:46 +0000 Subject: [PATCH 11/19] fix(rbac): gate user-actor membership floor on a scoped context Only load the user and run the membership floor when organizationId or projectId is present, restoring pre-P5 behavior for unscoped tokens (no user query, no denial). The user lookup falls back to the primary before failing closed, so replica lag can't spuriously 401 a just-created member. --- internal-packages/rbac/src/fallback.ts | 36 ++++---- .../rbac/src/fallback.userActor.test.ts | 83 ++++++++++++++----- 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 57ac05cbbd..5a7cdddd83 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -261,22 +261,26 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { return { ok: false, status: 401, error: "Invalid user-actor token" }; } - // Same tenant floor as authenticateSession: a non-member's delegated token is - // denied here, not handed a usable ability (even for reads). Admins are exempt. - const user = await this.replica.user.findFirst({ - where: { id: claims.userId }, - select: { id: true, admin: true }, - }); - if (!user) { - return { ok: false, status: 401, error: "Invalid user-actor token" }; - } - if (!user.admin) { - const denied = await this.deniedByMembership( - context.organizationId, - context.projectId, - user.id - ); - if (denied) return { ok: false, status: 403, error: "Unauthorized" }; + // Same tenant floor as authenticateSession: in a scoped context a non-member's + // delegated token is denied here, not handed a usable ability (even for reads). + // Admins are exempt. An unscoped context is not a tenant claim — skip the lookup + // entirely and keep the prior behavior (no user query, no denial). + if (context.organizationId || context.projectId) { + const where = { id: claims.userId }; + const user = + (await this.replica.user.findFirst({ where, select: { id: true, admin: true } })) ?? + (await this.prisma.user.findFirst({ where, select: { id: true, admin: true } })); + if (!user) { + return { ok: false, status: 401, error: "Invalid user-actor token" }; + } + if (!user.admin) { + const denied = await this.deniedByMembership( + context.organizationId, + context.projectId, + user.id + ); + if (denied) return { ok: false, status: 403, error: "Unauthorized" }; + } } return { diff --git a/internal-packages/rbac/src/fallback.userActor.test.ts b/internal-packages/rbac/src/fallback.userActor.test.ts index a80999a7e2..3f5bc08447 100644 --- a/internal-packages/rbac/src/fallback.userActor.test.ts +++ b/internal-packages/rbac/src/fallback.userActor.test.ts @@ -18,33 +18,34 @@ async function seedUser(prisma: PrismaClient, email: string, admin = false) { }); } +async function uat(userId: string) { + return signUserActorToken(SECRET, { userId, client: "test" }); +} + postgresTest( - "authenticateUserActor: member is allowed, non-member is denied", + "authenticateUserActor: scoped membership floor", async ({ prisma }) => { - const org = await prisma.organization.create({ + const p = prisma as PrismaClient; + const org = await p.organization.create({ data: { slug: `org-${Date.now()}`, title: "Org" }, }); - const member = await seedUser(prisma as PrismaClient, "member@example.test"); - const stranger = await seedUser(prisma as PrismaClient, "stranger@example.test"); - await prisma.orgMember.create({ - data: { organizationId: org.id, userId: member.id }, + const project = await p.project.create({ + data: { + slug: `proj-${Date.now()}`, + name: "Project", + externalRef: `ref-${Date.now()}`, + organizationId: org.id, + }, }); + const member = await seedUser(p, "member@example.test"); + const stranger = await seedUser(p, "stranger@example.test"); + const admin = await seedUser(p, "admin@example.test", true); + await p.orgMember.create({ data: { organizationId: org.id, userId: member.id } }); - const controller = new RoleBaseAccessFallback(prisma as PrismaClient, { - userActorSecret: SECRET, - }).create(); - - const memberToken = await signUserActorToken(SECRET, { - userId: member.id, - client: "test", - }); - const strangerToken = await signUserActorToken(SECRET, { - userId: stranger.id, - client: "test", - }); + const controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create(); // Member with a capless token keeps the read:all default. - const memberResult = await controller.authenticateUserActor(uatRequest(memberToken), { + const memberResult = await controller.authenticateUserActor(uatRequest(await uat(member.id)), { organizationId: org.id, }); expect(memberResult.ok).toBe(true); @@ -53,12 +54,48 @@ postgresTest( } // Non-member is denied at the ability layer, not handed a usable ability. - const strangerResult = await controller.authenticateUserActor(uatRequest(strangerToken), { + const strangerResult = await controller.authenticateUserActor( + uatRequest(await uat(stranger.id)), + { organizationId: org.id } + ); + expect(strangerResult.ok).toBe(false); + if (!strangerResult.ok) expect(strangerResult.status).toBe(403); + + // A token for a user that no longer exists fails closed. + const ghostResult = await controller.authenticateUserActor(uatRequest(await uat("usr_ghost")), { organizationId: org.id, }); - expect(strangerResult.ok).toBe(false); - if (!strangerResult.ok) { - expect(strangerResult.status).toBe(403); + expect(ghostResult.ok).toBe(false); + if (!ghostResult.ok) expect(ghostResult.status).toBe(401); + + // A platform admin is exempt from the membership floor. + const adminResult = await controller.authenticateUserActor(uatRequest(await uat(admin.id)), { + organizationId: org.id, + }); + expect(adminResult.ok).toBe(true); + + // A project-only scope resolves through the project's org: non-member denied. + const projectResult = await controller.authenticateUserActor( + uatRequest(await uat(stranger.id)), + { projectId: project.id } + ); + expect(projectResult.ok).toBe(false); + if (!projectResult.ok) expect(projectResult.status).toBe(403); + }, + 120_000 +); + +postgresTest( + "authenticateUserActor: unscoped context skips the floor and never queries the user", + async ({ prisma }) => { + const p = prisma as PrismaClient; + const stranger = await seedUser(p, "unscoped@example.test"); + const controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create(); + + const result = await controller.authenticateUserActor(uatRequest(await uat(stranger.id)), {}); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ability.can("read", { type: "runs", id: "run_x" })).toBe(true); } }, 120_000 From fde70113ed7c151f190c1d2965d07f6d60c7487d Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:26:10 +0000 Subject: [PATCH 12/19] fix(dashboard-agent): route the runtime eval judge through the provider switch The sampled-turn eval task resolved its judge with Anthropic directly, so under the Bedrock switch with no Anthropic key every sampled eval failed. It now follows the same provider seam as the agent. --- internal-packages/dashboard-agent/src/eval-turn.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-packages/dashboard-agent/src/eval-turn.ts b/internal-packages/dashboard-agent/src/eval-turn.ts index 3327611e4d..b3e55ddfc6 100644 --- a/internal-packages/dashboard-agent/src/eval-turn.ts +++ b/internal-packages/dashboard-agent/src/eval-turn.ts @@ -1,4 +1,3 @@ -import { anthropic } from "@ai-sdk/anthropic"; import { createDashboardAgentDb, insertTurnEval, @@ -6,6 +5,7 @@ import { } from "@internal/dashboard-agent-db"; import { logger, task } from "@trigger.dev/sdk"; import { EVAL_ERROR_CATEGORIES, redactedEvalOutputErrored } from "./eval-policy"; +import { resolveDashboardAgentModel } from "./model-provider"; import { generateObject } from "ai"; import { z } from "zod"; @@ -164,7 +164,7 @@ export const evalTurn = task({ id: "dashboard-agent-eval-turn", run: async (payload: EvalTurnPayload, { ctx }) => { const { object } = await generateObject({ - model: anthropic(JUDGE_MODEL), + model: resolveDashboardAgentModel(`anthropic:${JUDGE_MODEL}`), schema: TurnEval, system: JUDGE_SYSTEM, prompt: [ From c094a767cd9272eba845bb594ef7f492aa9e5cad Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:26:17 +0000 Subject: [PATCH 13/19] fix(dashboard-agent): keep the Bedrock prefix cachePoint on short conversations On Bedrock the turn-wide prefix marker and the per-step marker were byte-identical, so the step-strip pass removed the prefix on short chats, leaving the history uncached every step. The step marker now carries an explicit ttl so the prefix survives, matching the Anthropic path. --- .../dashboard-agent/src/model-provider.ts | 29 ++++++++----- .../dashboard-agent/src/step-cache.test.ts | 43 ++++++++++++++++++- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index f5abf5f67c..d971d79bec 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -66,9 +66,15 @@ function cacheOptions(breakpoint: CacheBreakpoint): Record { }, }; } - // No ttl: Bedrock's 1h cache is not supported on every model we run, and its - // default 5m applies to both breakpoints. - return { bedrock: { cachePoint: { type: "default" } } }; + // Bedrock's 1h cache is not supported on every model we run, so both breakpoints + // are the default 5m. The step marker still carries an explicit `ttl` so it stays + // distinguishable from the turn-wide prefix marker (which the step-strip pass must + // preserve) — without it the two are byte-identical and the prefix is stripped too. + return { + bedrock: { + cachePoint: breakpoint === "prefix" ? { type: "default" } : { type: "default", ttl: "5m" }, + }, + }; } /** Merge the active provider's breakpoint into a message's provider options. */ @@ -81,21 +87,24 @@ export function withCacheBreakpoint( } /** - * Whether these options carry the rolling step breakpoint. On Bedrock a cache - * point carries no ttl, so any breakpoint on a message counts as the rolling one. + * Whether these options carry the rolling step breakpoint — the one the step-strip + * pass rolls off. On both providers the step marker is the one tagged with the 5m ttl. */ export function isStepCacheBreakpoint(providerOptions: ProviderOptions): boolean { if (dashboardAgentProvider() === "anthropic") { return providerOptions?.anthropic?.cacheControl?.ttl === STEP_CACHE_CONTROL.ttl; } - return providerOptions?.bedrock?.cachePoint !== undefined; + return providerOptions?.bedrock?.cachePoint?.ttl === STEP_CACHE_CONTROL.ttl; } -/** Whether these options carry a breakpoint that outlives a step. */ +/** Whether these options carry a breakpoint that outlives a step (the turn-wide prefix). */ export function isLongLivedCacheBreakpoint(providerOptions: ProviderOptions): boolean { - if (dashboardAgentProvider() !== "anthropic") return false; - const ttl = providerOptions?.anthropic?.cacheControl?.ttl; - return typeof ttl === "string" && ttl !== STEP_CACHE_CONTROL.ttl; + if (dashboardAgentProvider() === "anthropic") { + const ttl = providerOptions?.anthropic?.cacheControl?.ttl; + return typeof ttl === "string" && ttl !== STEP_CACHE_CONTROL.ttl; + } + const cachePoint = providerOptions?.bedrock?.cachePoint; + return cachePoint !== undefined && cachePoint.ttl !== STEP_CACHE_CONTROL.ttl; } /** diff --git a/internal-packages/dashboard-agent/src/step-cache.test.ts b/internal-packages/dashboard-agent/src/step-cache.test.ts index f245ff0c6f..ceab80a114 100644 --- a/internal-packages/dashboard-agent/src/step-cache.test.ts +++ b/internal-packages/dashboard-agent/src/step-cache.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { markStepCacheBreakpoint, MIN_STEP_CACHE_CHARS, @@ -7,6 +7,7 @@ import { withStepCacheBreakpoint, } from "./step-cache"; import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; +import { withCacheBreakpoint } from "./model-provider"; type Message = { role: string; @@ -127,6 +128,46 @@ describe("the step cache breakpoint", () => { }); }); +describe("the step cache breakpoint on Bedrock", () => { + afterEach(() => { + delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + }); + + function bedrockCachePoint(message: Message | undefined): { ttl?: unknown } | undefined { + return (message?.providerOptions?.bedrock as { cachePoint?: { ttl?: unknown } } | undefined) + ?.cachePoint; + } + + // The turn-wide prefix marker sits on the last message; a short conversation never + // earns a step marker, so stripping the prefix would leave the history uncached. + it("keeps the turn-wide prefix cachePoint on a short conversation", () => { + process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; + const last: Message = { + role: "user", + content: "why did run_1 fail?", + providerOptions: withCacheBreakpoint(undefined, "prefix"), + }; + const marked = markStepCacheBreakpoint([last]); + + const cachePoint = bedrockCachePoint(marked.at(-1)); + expect(cachePoint).toEqual({ type: "default" }); + expect(cachePoint?.ttl).toBeUndefined(); + }); + + it("rolls the per-step cachePoint onto the tail once it is worth caching", () => { + process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; + const prefix: Message = { + role: "user", + content: "why did run_1 fail?", + providerOptions: withCacheBreakpoint(undefined, "prefix"), + }; + const marked = markStepCacheBreakpoint([prefix, toolResult(MIN_STEP_CACHE_CHARS)]); + + expect(bedrockCachePoint(marked[0])).toEqual({ type: "default" }); + expect(bedrockCachePoint(marked.at(-1))).toEqual({ type: "default", ttl: "5m" }); + }); +}); + describe("wrapping the SDK's prepareStep", () => { it("marks whatever the inner prepareStep returned", async () => { const compactedTo = [turnHistory(), toolResult(MIN_STEP_CACHE_CHARS)]; From 85bd73e3ac3fad5e06a3341cd5e9bc2425934fb2 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:34:42 +0000 Subject: [PATCH 14/19] test(rbac): prove unscoped user-actor path skips the user lookup Use a nonexistent userId for the unscoped case so success can only hold if the lookup is skipped. --- internal-packages/rbac/src/fallback.userActor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-packages/rbac/src/fallback.userActor.test.ts b/internal-packages/rbac/src/fallback.userActor.test.ts index 3f5bc08447..2423f7419f 100644 --- a/internal-packages/rbac/src/fallback.userActor.test.ts +++ b/internal-packages/rbac/src/fallback.userActor.test.ts @@ -89,10 +89,10 @@ postgresTest( "authenticateUserActor: unscoped context skips the floor and never queries the user", async ({ prisma }) => { const p = prisma as PrismaClient; - const stranger = await seedUser(p, "unscoped@example.test"); const controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create(); - const result = await controller.authenticateUserActor(uatRequest(await uat(stranger.id)), {}); + // A user that doesn't exist: if the unscoped path ran the lookup this would 401. + const result = await controller.authenticateUserActor(uatRequest(await uat("usr_ghost")), {}); expect(result.ok).toBe(true); if (result.ok) { expect(result.ability.can("read", { type: "runs", id: "run_x" })).toBe(true); From cbfa5f8409d01026f1adeae8475a9a0d6c325e4e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:48:29 +0000 Subject: [PATCH 15/19] fix(webapp): gate bedrock head-start on AWS_REGION or AWS_DEFAULT_REGION --- apps/webapp/app/env.server.ts | 1 + ...Slug.projects.$projectParam.env.$envParam.dashboard-agent.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index fdea3df069..376f0fd297 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -175,6 +175,7 @@ const EnvironmentSchema = z // DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). The // provider resolves credentials itself, so only the region is read here. AWS_REGION: z.string().optional(), + AWS_DEFAULT_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(), AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_SESSION_TOKEN: z.string().optional(), diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index 87aa9f931a..3c18a84880 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -332,7 +332,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id); const headStarted = dashboardAgentProvider() === "bedrock" - ? Boolean(env.AWS_REGION) + ? Boolean(env.AWS_REGION || env.AWS_DEFAULT_REGION) : Boolean(env.ANTHROPIC_API_KEY); // The lookups and the mint all run before the chat row exists, so a failure here can't From 642d8a3856132245b7d559d27755e3e5e5e26b8b Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:54:24 +0000 Subject: [PATCH 16/19] fix(dashboard-agent): resolve Bedrock region from AWS_REGION or AWS_DEFAULT_REGION The Bedrock SDK reads only AWS_REGION; pass region explicitly so AWS_DEFAULT_REGION (common in AWS deploys) works too. Credentials stay on the SDK's own chain. --- internal-packages/dashboard-agent/src/model-provider.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index d971d79bec..eaee988f37 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -1,4 +1,4 @@ -import { bedrock } from "@ai-sdk/amazon-bedrock"; +import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"; import { anthropic } from "@ai-sdk/anthropic"; import { createProviderRegistry } from "ai"; import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; @@ -21,6 +21,13 @@ export function dashboardAgentProvider(): DashboardAgentProvider { return process.env.DASHBOARD_AGENT_MODEL_PROVIDER === "bedrock" ? "bedrock" : "anthropic"; } +// Region threaded explicitly so both AWS_REGION and AWS_DEFAULT_REGION work; the SDK +// itself only reads AWS_REGION. Credentials are left to the SDK's own chain (IAM +// role / static keys / session token / bearer) — only region is passed. +const bedrock = createAmazonBedrock({ + region: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION, +}); + export const registry = createProviderRegistry({ anthropic, bedrock }); /** From 89cd03452ebb0d2f486aa3e5065c8e0a935c66ca Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 17:57:21 +0000 Subject: [PATCH 17/19] refactor(dashboard-agent): treat empty Bedrock region as unset, matching the webapp gate --- internal-packages/dashboard-agent/src/model-provider.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index eaee988f37..e9d914933f 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -21,11 +21,10 @@ export function dashboardAgentProvider(): DashboardAgentProvider { return process.env.DASHBOARD_AGENT_MODEL_PROVIDER === "bedrock" ? "bedrock" : "anthropic"; } -// Region threaded explicitly so both AWS_REGION and AWS_DEFAULT_REGION work; the SDK -// itself only reads AWS_REGION. Credentials are left to the SDK's own chain (IAM -// role / static keys / session token / bearer) — only region is passed. +// Region passed explicitly since the SDK reads only AWS_REGION; credentials stay on +// its own chain. `||` treats an empty region as unset, matching the webapp gate. const bedrock = createAmazonBedrock({ - region: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION, + region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION, }); export const registry = createProviderRegistry({ anthropic, bedrock }); From cbf7e3be46b16a129e8e87405c204810b3dfd9df Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 18:32:06 +0000 Subject: [PATCH 18/19] fix(dashboard-agent): stop leaking an undocumented ttl to AWS Bedrock The Bedrock step marker discriminated itself with a ttl inside cachePoint, which the SDK copies verbatim into the Converse request. Move the prefix/step discriminator to a non-serialized top-level providerOptions key so the wire cachePoint is a plain {type:"default"}. The key's value is an object because the AI SDK validates message providerOptions as records. --- .../src/cache-breakpoint.test.ts | 2 + .../src/model-provider.test.ts | 23 ++++-- .../dashboard-agent/src/model-provider.ts | 50 +++++++------ .../dashboard-agent/src/step-cache.test.ts | 73 +++++++++++++------ 4 files changed, 98 insertions(+), 50 deletions(-) diff --git a/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts b/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts index a3b64b2082..483789b02e 100644 --- a/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts +++ b/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts @@ -35,6 +35,7 @@ describe("withCacheBreakpointOnLast", () => { const prepared = withCacheBreakpointOnLast(lastMessageWithAnthropicOptions()); expect(prepared[1]!.providerOptions).toEqual({ + __cacheBreakpoint: { kind: "prefix" }, anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } }, openai: { store: false }, }); @@ -54,6 +55,7 @@ describe("prepareTurnMessages", () => { }); expect(prepared[1]!.providerOptions).toEqual({ + __cacheBreakpoint: { kind: "prefix" }, anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } }, openai: { store: false }, }); diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts index 13ee4b7cd5..ab9dec505a 100644 --- a/internal-packages/dashboard-agent/src/model-provider.test.ts +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -56,33 +56,44 @@ describe("resolveDashboardAgentModel", () => { }); describe("cache breakpoints", () => { - it("emits Anthropic cache control by default", () => { + it("keeps the Anthropic cacheControl ttls intact, tagged with the discriminator", () => { expect(withCacheBreakpoint({ openai: { store: false } }, "prefix")).toEqual({ + __cacheBreakpoint: { kind: "prefix" }, openai: { store: false }, anthropic: { cacheControl: PROMPT_CACHE_CONTROL }, }); expect(withCacheBreakpoint(undefined, "step")).toEqual({ + __cacheBreakpoint: { kind: "step" }, anthropic: { cacheControl: STEP_CACHE_CONTROL }, }); }); - it("emits a Bedrock cache point when the switch is on", () => { + it("emits a plain Bedrock cachePoint with no ttl for either marker", () => { useBedrock(); - expect(withCacheBreakpoint(undefined, "prefix")).toEqual({ - bedrock: { cachePoint: { type: "default" } }, - }); + for (const breakpoint of ["prefix", "step"] as const) { + const options = withCacheBreakpoint(undefined, breakpoint); + // The only thing the SDK serialises to AWS is bedrock.cachePoint — it must be plain. + expect(options.bedrock.cachePoint).toEqual({ type: "default" }); + expect(options.bedrock.cachePoint).not.toHaveProperty("ttl"); + expect(options.__cacheBreakpoint).toEqual({ kind: breakpoint }); + } }); - it("classifies and strips the active provider's breakpoint", () => { + it("classifies and strips the active provider's breakpoint via the discriminator", () => { const anthropicStep = withCacheBreakpoint({ anthropic: { keep: true } }, "step"); expect(isStepCacheBreakpoint(anthropicStep)).toBe(true); expect(isLongLivedCacheBreakpoint(withCacheBreakpoint(undefined, "prefix"))).toBe(true); + // The strip removes both the provider field and the top-level discriminator. expect(withoutCacheBreakpoint(anthropicStep)).toEqual({ anthropic: { keep: true } }); useBedrock(); const bedrockStep = withCacheBreakpoint(undefined, "step"); + const bedrockPrefix = withCacheBreakpoint(undefined, "prefix"); + // The two Bedrock markers are byte-identical on the wire — only the tag tells them apart. + expect(bedrockStep.bedrock).toEqual(bedrockPrefix.bedrock); expect(isStepCacheBreakpoint(bedrockStep)).toBe(true); expect(isLongLivedCacheBreakpoint(bedrockStep)).toBe(false); + expect(isLongLivedCacheBreakpoint(bedrockPrefix)).toBe(true); expect(withoutCacheBreakpoint(bedrockStep)).toEqual({}); }); }); diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index e9d914933f..91568c5f1e 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -64,6 +64,17 @@ export const STEP_CACHE_CONTROL = { type: "ephemeral", ttl: "5m" } as const; type ProviderOptions = Record | undefined; +// Which breakpoint a marker is, kept under a top-level key no provider registers so +// neither converter serialises it. The value is an object because the AI SDK validates +// message providerOptions as Record> — a bare string +// would be rejected. The `cacheControl`/`cachePoint` fields carry only real provider +// data, never a discriminator; the step pass reads this tag instead. +const CACHE_BREAKPOINT_KEY = "__cacheBreakpoint"; + +function breakpointKind(providerOptions: ProviderOptions): CacheBreakpoint | undefined { + return providerOptions?.[CACHE_BREAKPOINT_KEY]?.kind; +} + function cacheOptions(breakpoint: CacheBreakpoint): Record { if (dashboardAgentProvider() === "anthropic") { return { @@ -72,15 +83,8 @@ function cacheOptions(breakpoint: CacheBreakpoint): Record { }, }; } - // Bedrock's 1h cache is not supported on every model we run, so both breakpoints - // are the default 5m. The step marker still carries an explicit `ttl` so it stays - // distinguishable from the turn-wide prefix marker (which the step-strip pass must - // preserve) — without it the two are byte-identical and the prefix is stripped too. - return { - bedrock: { - cachePoint: breakpoint === "prefix" ? { type: "default" } : { type: "default", ttl: "5m" }, - }, - }; + // Plain, documented cachePoint for both markers — nothing undocumented reaches AWS. + return { bedrock: { cachePoint: { type: "default" } } }; } /** Merge the active provider's breakpoint into a message's provider options. */ @@ -89,28 +93,24 @@ export function withCacheBreakpoint( breakpoint: CacheBreakpoint ): Record { const [key, options] = Object.entries(cacheOptions(breakpoint))[0]!; - return { ...providerOptions, [key]: { ...providerOptions?.[key], ...options } }; + return { + ...providerOptions, + [CACHE_BREAKPOINT_KEY]: { kind: breakpoint }, + [key]: { ...providerOptions?.[key], ...options }, + }; } /** * Whether these options carry the rolling step breakpoint — the one the step-strip - * pass rolls off. On both providers the step marker is the one tagged with the 5m ttl. + * pass rolls off. */ export function isStepCacheBreakpoint(providerOptions: ProviderOptions): boolean { - if (dashboardAgentProvider() === "anthropic") { - return providerOptions?.anthropic?.cacheControl?.ttl === STEP_CACHE_CONTROL.ttl; - } - return providerOptions?.bedrock?.cachePoint?.ttl === STEP_CACHE_CONTROL.ttl; + return breakpointKind(providerOptions) === "step"; } /** Whether these options carry a breakpoint that outlives a step (the turn-wide prefix). */ export function isLongLivedCacheBreakpoint(providerOptions: ProviderOptions): boolean { - if (dashboardAgentProvider() === "anthropic") { - const ttl = providerOptions?.anthropic?.cacheControl?.ttl; - return typeof ttl === "string" && ttl !== STEP_CACHE_CONTROL.ttl; - } - const cachePoint = providerOptions?.bedrock?.cachePoint; - return cachePoint !== undefined && cachePoint.ttl !== STEP_CACHE_CONTROL.ttl; + return breakpointKind(providerOptions) === "prefix"; } /** @@ -132,11 +132,15 @@ export function cacheUsageFromProviderMetadata(providerMetadata: unknown): { return { write: count(metadata?.bedrock?.usage?.cacheWriteInputTokens) }; } -/** The same options with the active provider's breakpoint removed. */ +/** The same options with the active provider's breakpoint and its discriminator removed. */ export function withoutCacheBreakpoint(providerOptions: ProviderOptions): Record { const key = dashboardAgentProvider() === "anthropic" ? "anthropic" : "bedrock"; const field = key === "anthropic" ? "cacheControl" : "cachePoint"; - const { [key]: provider, ...rest } = (providerOptions ?? {}) as Record; + const { + [key]: provider, + [CACHE_BREAKPOINT_KEY]: _tag, + ...rest + } = (providerOptions ?? {}) as Record; const { [field]: _dropped, ...providerRest } = (provider ?? {}) as Record; // An empty provider entry is not the same as no options for it, so drop the key. return Object.keys(providerRest).length > 0 ? { ...rest, [key]: providerRest } : rest; diff --git a/internal-packages/dashboard-agent/src/step-cache.test.ts b/internal-packages/dashboard-agent/src/step-cache.test.ts index ceab80a114..87e49e1b51 100644 --- a/internal-packages/dashboard-agent/src/step-cache.test.ts +++ b/internal-packages/dashboard-agent/src/step-cache.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { markStepCacheBreakpoint, MIN_STEP_CACHE_CHARS, @@ -23,7 +23,10 @@ function turnHistory(): Message { return { role: "user", content: "why did run_1 fail?", - providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } }, + providerOptions: { + __cacheBreakpoint: { kind: "prefix" }, + anthropic: { cacheControl: PROMPT_CACHE_CONTROL }, + }, }; } @@ -32,6 +35,7 @@ function stepBreakpointWith(otherAnthropicOptions: Record): Mes role: "tool", content: "ok", providerOptions: { + __cacheBreakpoint: { kind: "step" }, anthropic: { cacheControl: STEP_CACHE_CONTROL, ...otherAnthropicOptions }, openai: { store: false }, }, @@ -108,6 +112,7 @@ describe("the step cache breakpoint", () => { ]); expect(marked.at(-1)!.providerOptions).toEqual({ + __cacheBreakpoint: { kind: "step" }, anthropic: { anotherOption: "keep", cacheControl: STEP_CACHE_CONTROL }, openai: { store: false }, }); @@ -129,8 +134,14 @@ describe("the step cache breakpoint", () => { }); describe("the step cache breakpoint on Bedrock", () => { + let priorProvider: string | undefined; + beforeEach(() => { + priorProvider = process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; + }); afterEach(() => { - delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + if (priorProvider === undefined) delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + else process.env.DASHBOARD_AGENT_MODEL_PROVIDER = priorProvider; }); function bedrockCachePoint(message: Message | undefined): { ttl?: unknown } | undefined { @@ -138,33 +149,50 @@ describe("the step cache breakpoint on Bedrock", () => { ?.cachePoint; } - // The turn-wide prefix marker sits on the last message; a short conversation never - // earns a step marker, so stripping the prefix would leave the history uncached. - it("keeps the turn-wide prefix cachePoint on a short conversation", () => { - process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; - const last: Message = { + function breakpointTag(message: Message | undefined): unknown { + return (message?.providerOptions?.__cacheBreakpoint as { kind?: unknown } | undefined)?.kind; + } + + function prefixMarker(): Message { + return { role: "user", content: "why did run_1 fail?", providerOptions: withCacheBreakpoint(undefined, "prefix"), }; - const marked = markStepCacheBreakpoint([last]); + } - const cachePoint = bedrockCachePoint(marked.at(-1)); - expect(cachePoint).toEqual({ type: "default" }); - expect(cachePoint?.ttl).toBeUndefined(); + // Nothing undocumented reaches AWS: the wire cachePoint (which the SDK copies verbatim) + // is a plain `{type:"default"}` for both markers — no `ttl`. The prefix/step distinction + // lives only in the non-serialized `__cacheBreakpoint` tag. + it("emits a plain cachePoint with no ttl for either marker", () => { + expect(bedrockCachePoint(prefixMarker())).toEqual({ type: "default" }); + const step: Message = { + role: "tool", + content: "ok", + providerOptions: withCacheBreakpoint(undefined, "step"), + }; + expect(bedrockCachePoint(step)).toEqual({ type: "default" }); + expect(bedrockCachePoint(step)).not.toHaveProperty("ttl"); + expect(breakpointTag(prefixMarker())).toBe("prefix"); + expect(breakpointTag(step)).toBe("step"); + }); + + // The turn-wide prefix marker sits on the last message; a short conversation never + // earns a step marker, so stripping the prefix would leave the history uncached. + it("keeps the turn-wide prefix cachePoint on a short conversation", () => { + const marked = markStepCacheBreakpoint([prefixMarker()]); + + expect(bedrockCachePoint(marked.at(-1))).toEqual({ type: "default" }); + expect(breakpointTag(marked.at(-1))).toBe("prefix"); }); it("rolls the per-step cachePoint onto the tail once it is worth caching", () => { - process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; - const prefix: Message = { - role: "user", - content: "why did run_1 fail?", - providerOptions: withCacheBreakpoint(undefined, "prefix"), - }; - const marked = markStepCacheBreakpoint([prefix, toolResult(MIN_STEP_CACHE_CHARS)]); + const marked = markStepCacheBreakpoint([prefixMarker(), toolResult(MIN_STEP_CACHE_CHARS)]); expect(bedrockCachePoint(marked[0])).toEqual({ type: "default" }); - expect(bedrockCachePoint(marked.at(-1))).toEqual({ type: "default", ttl: "5m" }); + expect(breakpointTag(marked[0])).toBe("prefix"); + expect(bedrockCachePoint(marked.at(-1))).toEqual({ type: "default" }); + expect(breakpointTag(marked.at(-1))).toBe("step"); }); }); @@ -203,6 +231,7 @@ describe("wrapping the SDK's prepareStep", () => { const prepared = await withStepCacheBreakpoint(inner as never)({ messages: [] } as never); expect((prepared!.messages!.at(-1) as Message).providerOptions).toEqual({ + __cacheBreakpoint: { kind: "step" }, anthropic: { anotherOption: "keep", cacheControl: STEP_CACHE_CONTROL }, }); }); @@ -231,6 +260,7 @@ describe("per-step cache telemetry", () => { }); it("reports Bedrock's write from its metadata and its read from the call's usage", () => { + const prior = process.env.DASHBOARD_AGENT_MODEL_PROVIDER; process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; try { expect( @@ -245,7 +275,8 @@ describe("per-step cache telemetry", () => { "gen_ai.usage.cache_read_input_tokens": 12_000, }); } finally { - delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + if (prior === undefined) delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + else process.env.DASHBOARD_AGENT_MODEL_PROVIDER = prior; } }); From b6a73d5d2a9da98199021ebab395a128eed1a752 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 18:37:37 +0000 Subject: [PATCH 19/19] docs(dashboard-agent): trim the cache-breakpoint comments --- internal-packages/dashboard-agent/src/model-provider.ts | 7 ++----- internal-packages/dashboard-agent/src/step-cache.test.ts | 5 ++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index 91568c5f1e..d1cfb9a9b3 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -64,11 +64,8 @@ export const STEP_CACHE_CONTROL = { type: "ephemeral", ttl: "5m" } as const; type ProviderOptions = Record | undefined; -// Which breakpoint a marker is, kept under a top-level key no provider registers so -// neither converter serialises it. The value is an object because the AI SDK validates -// message providerOptions as Record> — a bare string -// would be rejected. The `cacheControl`/`cachePoint` fields carry only real provider -// data, never a discriminator; the step pass reads this tag instead. +// Breakpoint discriminator under a top-level key no provider serialises. Value is an +// object because the AI SDK validates providerOptions as records, rejecting a bare string. const CACHE_BREAKPOINT_KEY = "__cacheBreakpoint"; function breakpointKind(providerOptions: ProviderOptions): CacheBreakpoint | undefined { diff --git a/internal-packages/dashboard-agent/src/step-cache.test.ts b/internal-packages/dashboard-agent/src/step-cache.test.ts index 87e49e1b51..2f41354d0d 100644 --- a/internal-packages/dashboard-agent/src/step-cache.test.ts +++ b/internal-packages/dashboard-agent/src/step-cache.test.ts @@ -161,9 +161,8 @@ describe("the step cache breakpoint on Bedrock", () => { }; } - // Nothing undocumented reaches AWS: the wire cachePoint (which the SDK copies verbatim) - // is a plain `{type:"default"}` for both markers — no `ttl`. The prefix/step distinction - // lives only in the non-serialized `__cacheBreakpoint` tag. + // Nothing undocumented reaches AWS: the wire cachePoint is a plain `{type:"default"}` + // for both markers. The prefix/step distinction lives only in the `__cacheBreakpoint` tag. it("emits a plain cachePoint with no ttl for either marker", () => { expect(bedrockCachePoint(prefixMarker())).toEqual({ type: "default" }); const step: Message = {