From f77655421feab2435124899852f4eaf415c92a2b Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 13 Aug 2026 16:08:06 +0000 Subject: [PATCH 01/25] 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 1808d60e777..eaf9747b212 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 d8d20ba2e20..fe1d99e3699 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 83e5d82b830..2f1ce534169 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 237cbc64d3c..c47da63918d 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 00000000000..7606f6cda06 --- /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 00000000000..85afadbc11b --- /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 56465a0ee9a..ed491249136 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 ddb46e0f4d1..af356f0f4bc 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 160c39b7b26..c76d3a6f993 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/25] 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 85afadbc11b..c398dd27197 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 662afc012b0..f245ff0c6f6 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 ed491249136..b6306072a52 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/25] 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 7606f6cda06..13ee4b7cd5e 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 c398dd27197..f5abf5f67ca 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/25] 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 53374670f7e..b667e94c052 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 2d9bb9cea91..57ac05cbbdb 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 00000000000..a80999a7e2e --- /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 9ab7d813559..81f6a3eaa8c 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 c76d3a6f993..a97ec7af810 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/25] 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 3ce98dd521f..426da6335bd 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 4a5936a196b..1cb8cae34d3 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/25] 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 00000000000..989293fbdbb --- /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/25] 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 499e621b83d..ee44dac23a9 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/25] 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 426da6335bd..376f0fd2978 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 ee44dac23a9..3c18a84880d 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/25] 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 989293fbdbb..00000000000 --- 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/25] 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 376f0fd2978..fdea3df069f 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 3c18a84880d..87aa9f931a6 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/25] 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 57ac05cbbdb..5a7cdddd83a 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 a80999a7e2e..3f5bc084471 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/25] 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 3327611e4d0..b3e55ddfc6e 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/25] 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 f5abf5f67ca..d971d79bec6 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 f245ff0c6f6..ceab80a1143 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/25] 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 3f5bc084471..2423f7419f8 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/25] 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 fdea3df069f..376f0fd2978 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 87aa9f931a6..3c18a84880d 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/25] 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 d971d79bec6..eaee988f37b 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/25] 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 eaee988f37b..e9d914933f3 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/25] 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 a3b64b20826..483789b02e8 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 13ee4b7cd5e..ab9dec505a1 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 e9d914933f3..91568c5f1e8 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 ceab80a1143..87e49e1b51a 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/25] 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 91568c5f1e8..d1cfb9a9b30 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 87e49e1b51a..2f41354d0d8 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 = { From b126116181e2967f856f8a1b8be74570dc2640dc Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 17 Aug 2026 12:06:44 +0000 Subject: [PATCH 20/25] fix(dashboard-agent): detect legacy cache breakpoints without the discriminator Conversations persisted before __cacheBreakpoint existed carry a bare anthropic.cacheControl. Classify it by ttl and strip it on resume so legacy breakpoints don't slip past the 4-breakpoint limit. --- .../src/model-provider.test.ts | 21 +++++++++++++++++++ .../dashboard-agent/src/model-provider.ts | 18 ++++++++++++++-- .../dashboard-agent/src/step-cache.test.ts | 15 +++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts index ab9dec505a1..cd0a8d39e2d 100644 --- a/internal-packages/dashboard-agent/src/model-provider.test.ts +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -96,4 +96,25 @@ describe("cache breakpoints", () => { expect(isLongLivedCacheBreakpoint(bedrockPrefix)).toBe(true); expect(withoutCacheBreakpoint(bedrockStep)).toEqual({}); }); + + // Conversations persisted before the __cacheBreakpoint discriminator existed carry + // a bare anthropic.cacheControl. Detection must fall back to classifying its ttl. + it("classifies a legacy Anthropic cacheControl with no discriminator by its ttl", () => { + const legacyPrefix = { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } }; + const legacyStepWithTtl = { anthropic: { cacheControl: STEP_CACHE_CONTROL } }; + const legacyStepNoTtl = { anthropic: { cacheControl: { type: "ephemeral" } } }; + + expect(isLongLivedCacheBreakpoint(legacyPrefix)).toBe(true); + expect(isStepCacheBreakpoint(legacyPrefix)).toBe(false); + expect(isStepCacheBreakpoint(legacyStepWithTtl)).toBe(true); + expect(isLongLivedCacheBreakpoint(legacyStepWithTtl)).toBe(false); + expect(isStepCacheBreakpoint(legacyStepNoTtl)).toBe(true); + }); + + it("strips a legacy Anthropic cacheControl even while Bedrock is active", () => { + useBedrock(); + const legacyStep = { anthropic: { cacheControl: STEP_CACHE_CONTROL, keep: true } }; + + expect(withoutCacheBreakpoint(legacyStep)).toEqual({ anthropic: { keep: true } }); + }); }); diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index d1cfb9a9b30..074099640e4 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -69,7 +69,13 @@ type ProviderOptions = Record | undefined; const CACHE_BREAKPOINT_KEY = "__cacheBreakpoint"; function breakpointKind(providerOptions: ProviderOptions): CacheBreakpoint | undefined { - return providerOptions?.[CACHE_BREAKPOINT_KEY]?.kind; + const discriminated = providerOptions?.[CACHE_BREAKPOINT_KEY]?.kind; + if (discriminated) return discriminated; + // Conversations persisted before the discriminator existed carry a bare Anthropic + // cacheControl. Classify it by ttl: "1h" is the turn-wide prefix, anything else the step. + const legacyCacheControl = providerOptions?.anthropic?.cacheControl; + if (!legacyCacheControl) return undefined; + return legacyCacheControl.ttl === "1h" ? "prefix" : "step"; } function cacheOptions(breakpoint: CacheBreakpoint): Record { @@ -131,7 +137,15 @@ export function cacheUsageFromProviderMetadata(providerMetadata: unknown): { /** 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 hasDiscriminator = providerOptions?.[CACHE_BREAKPOINT_KEY] !== undefined; + // A legacy message keeps its native anthropic.cacheControl shape no matter which + // provider is active now, so strip that key rather than the current provider's. + const isLegacy = !hasDiscriminator && providerOptions?.anthropic?.cacheControl !== undefined; + const key = isLegacy + ? "anthropic" + : dashboardAgentProvider() === "anthropic" + ? "anthropic" + : "bedrock"; const field = key === "anthropic" ? "cacheControl" : "cachePoint"; const { [key]: provider, diff --git a/internal-packages/dashboard-agent/src/step-cache.test.ts b/internal-packages/dashboard-agent/src/step-cache.test.ts index 2f41354d0d8..b43c07401c4 100644 --- a/internal-packages/dashboard-agent/src/step-cache.test.ts +++ b/internal-packages/dashboard-agent/src/step-cache.test.ts @@ -131,6 +131,21 @@ describe("the step cache breakpoint", () => { const empty: Message[] = []; expect(markStepCacheBreakpoint(empty)).toBe(empty); }); + + // A conversation resumed after this branch shipped can still carry a step + // breakpoint in the pre-discriminator shape. It must be stripped like any other. + it("strips a legacy step breakpoint on resume", () => { + const legacyStep: Message = { + role: "tool", + content: "ok", + providerOptions: { anthropic: { cacheControl: STEP_CACHE_CONTROL, keep: true } }, + }; + const marked = markStepCacheBreakpoint([turnHistory(), legacyStep, toolResult(20)]); + + expect(ttlOf(marked[1])).toBeUndefined(); + expect(marked[1]!.providerOptions).toEqual({ anthropic: { keep: true } }); + expect(ttlOf(marked[0])).toBe("1h"); + }); }); describe("the step cache breakpoint on Bedrock", () => { From 068f6418a2e9da12e7212f72d03edb559d4c52d6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 17 Aug 2026 12:12:30 +0000 Subject: [PATCH 21/25] fix(rbac): typecheck the testcontainers-importing test file Split into tsconfig.src.json/tsconfig.test.json like run-engine and schedule-engine, so fallback.userActor.test.ts is type-checked instead of excluded. --- internal-packages/rbac/package.json | 2 +- internal-packages/rbac/tsconfig.json | 6 ++---- internal-packages/rbac/tsconfig.src.json | 19 +++++++++++++++++++ internal-packages/rbac/tsconfig.test.json | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 internal-packages/rbac/tsconfig.src.json create mode 100644 internal-packages/rbac/tsconfig.test.json diff --git a/internal-packages/rbac/package.json b/internal-packages/rbac/package.json index b667e94c052..e68e80e5c55 100644 --- a/internal-packages/rbac/package.json +++ b/internal-packages/rbac/package.json @@ -16,7 +16,7 @@ }, "scripts": { "clean": "rimraf dist", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit -p tsconfig.src.json && tsc --noEmit -p tsconfig.test.json", "build": "pnpm run clean && tsc -p tsconfig.build.json", "dev": "tsc --noEmit false --outDir dist --declaration --watch", "test": "vitest run", diff --git a/internal-packages/rbac/tsconfig.json b/internal-packages/rbac/tsconfig.json index 81f6a3eaa8c..b569e4415fb 100644 --- a/internal-packages/rbac/tsconfig.json +++ b/internal-packages/rbac/tsconfig.json @@ -1,4 +1,5 @@ { + "references": [{ "path": "./tsconfig.src.json" }, { "path": "./tsconfig.test.json" }], "compilerOptions": { "target": "ES2019", "lib": ["ES2019", "DOM"], @@ -13,8 +14,5 @@ "strict": true, "customConditions": ["@triggerdotdev/source"] }, - // 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"] + "exclude": ["node_modules", "dist"] } diff --git a/internal-packages/rbac/tsconfig.src.json b/internal-packages/rbac/tsconfig.src.json new file mode 100644 index 00000000000..fc3f31fd51c --- /dev/null +++ b/internal-packages/rbac/tsconfig.src.json @@ -0,0 +1,19 @@ +{ + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "types": ["node"], + "customConditions": ["@triggerdotdev/source"] + } +} diff --git a/internal-packages/rbac/tsconfig.test.json b/internal-packages/rbac/tsconfig.test.json new file mode 100644 index 00000000000..03817c4b125 --- /dev/null +++ b/internal-packages/rbac/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "include": ["src/**/*.test.ts"], + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "types": ["vitest/globals", "node"], + "customConditions": ["@triggerdotdev/source"] + } +} From 39b9bfbc361fc18b2fd1c0b893a5e2ac2b5a9ec0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 17 Aug 2026 12:14:59 +0000 Subject: [PATCH 22/25] feat(dashboard-agent): support dedicated non-global Bedrock credentials Add DASHBOARD_AGENT_AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY/REGION so the agent's Bedrock creds don't hijack the default AWS chain the ECR/STS deploy clients rely on. Falls back to the default chain and existing AWS_REGION/AWS_DEFAULT_REGION order when unset. --- apps/webapp/app/env.server.ts | 7 +++ ...jectParam.env.$envParam.dashboard-agent.ts | 2 +- .../src/model-provider.test.ts | 59 ++++++++++++++++++- .../dashboard-agent/src/model-provider.ts | 33 +++++++++-- 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 376f0fd2978..31b3210cb7a 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -180,6 +180,13 @@ const EnvironmentSchema = z AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_SESSION_TOKEN: z.string().optional(), AWS_BEARER_TOKEN_BEDROCK: z.string().optional(), + // Dedicated, non-global credentials for the dashboard agent's Bedrock calls (a + // Bedrock-invoke-only IAM user). Kept separate from AWS_ACCESS_KEY_ID/etc so + // injecting them can't hijack the default credential chain the ECR/STS deploy + // clients rely on. + DASHBOARD_AGENT_AWS_ACCESS_KEY_ID: z.string().optional(), + DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY: z.string().optional(), + DASHBOARD_AGENT_AWS_REGION: z.string().optional(), DIRECT_URL: z .string() .refine( 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 3c18a84880d..2f8d6fb6166 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.DASHBOARD_AGENT_AWS_REGION || 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 diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts index cd0a8d39e2d..6c34df50622 100644 --- a/internal-packages/dashboard-agent/src/model-provider.test.ts +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; import { BEDROCK_MODEL_IDS, + bedrockProviderSettings, + bedrockRegion, isLongLivedCacheBreakpoint, isStepCacheBreakpoint, resolveDashboardAgentModel, @@ -14,8 +16,17 @@ function useBedrock() { process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock"; } +const AWS_ENV_VARS = [ + "DASHBOARD_AGENT_MODEL_PROVIDER", + "DASHBOARD_AGENT_AWS_ACCESS_KEY_ID", + "DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY", + "DASHBOARD_AGENT_AWS_REGION", + "AWS_REGION", + "AWS_DEFAULT_REGION", +] as const; + afterEach(() => { - delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + for (const key of AWS_ENV_VARS) delete process.env[key]; }); describe("resolveDashboardAgentModel", () => { @@ -118,3 +129,49 @@ describe("cache breakpoints", () => { expect(withoutCacheBreakpoint(legacyStep)).toEqual({ anthropic: { keep: true } }); }); }); + +describe("Bedrock region and credential resolution", () => { + it("prefers DASHBOARD_AGENT_AWS_REGION over the global AWS region vars", () => { + process.env.AWS_REGION = "us-east-1"; + process.env.AWS_DEFAULT_REGION = "us-west-2"; + process.env.DASHBOARD_AGENT_AWS_REGION = "eu-west-1"; + expect(bedrockRegion()).toBe("eu-west-1"); + }); + + it("falls back to AWS_REGION, then AWS_DEFAULT_REGION", () => { + process.env.AWS_DEFAULT_REGION = "us-west-2"; + expect(bedrockRegion()).toBe("us-west-2"); + + process.env.AWS_REGION = "us-east-1"; + expect(bedrockRegion()).toBe("us-east-1"); + }); + + it("treats an empty region as unset at every tier", () => { + process.env.DASHBOARD_AGENT_AWS_REGION = ""; + process.env.AWS_REGION = ""; + process.env.AWS_DEFAULT_REGION = ""; + expect(bedrockRegion()).toBeUndefined(); + }); + + it("passes explicit credentials when the dedicated pair is set", () => { + process.env.DASHBOARD_AGENT_AWS_ACCESS_KEY_ID = "AKIA_DASHBOARD_AGENT"; + process.env.DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY = "secret"; + process.env.DASHBOARD_AGENT_AWS_REGION = "eu-west-1"; + + expect(bedrockProviderSettings()).toEqual({ + region: "eu-west-1", + accessKeyId: "AKIA_DASHBOARD_AGENT", + secretAccessKey: "secret", + }); + }); + + it("keeps the default credential chain when the dedicated pair is unset", () => { + process.env.AWS_REGION = "us-east-1"; + expect(bedrockProviderSettings()).toEqual({ region: "us-east-1" }); + }); + + it("keeps the default chain when only one half of the dedicated pair is set", () => { + process.env.DASHBOARD_AGENT_AWS_ACCESS_KEY_ID = "AKIA_DASHBOARD_AGENT"; + expect(bedrockProviderSettings()).toEqual({ region: undefined }); + }); +}); diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index 074099640e4..86136fa3c4b 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -21,11 +21,34 @@ export function dashboardAgentProvider(): DashboardAgentProvider { return process.env.DASHBOARD_AGENT_MODEL_PROVIDER === "bedrock" ? "bedrock" : "anthropic"; } -// 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 passed explicitly since the SDK reads only AWS_REGION. `||` treats an empty +// region as unset. DASHBOARD_AGENT_AWS_REGION takes priority over the global vars. +export function bedrockRegion(): string | undefined { + return ( + process.env.DASHBOARD_AGENT_AWS_REGION || + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION || + undefined + ); +} + +// Dedicated, non-global credentials only — the default chain (and the global +// AWS_ACCESS_KEY_ID/etc, if ever set) stays untouched for the ECR/STS deploy clients. +function bedrockCredentials(): { accessKeyId: string; secretAccessKey: string } | undefined { + const accessKeyId = process.env.DASHBOARD_AGENT_AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY; + return accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined; +} + +export function bedrockProviderSettings(): { + region?: string; + accessKeyId?: string; + secretAccessKey?: string; +} { + return { region: bedrockRegion(), ...bedrockCredentials() }; +} + +const bedrock = createAmazonBedrock(bedrockProviderSettings()); export const registry = createProviderRegistry({ anthropic, bedrock }); From efd963a0404d2b44e4dc701f26ae879aed0546de Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 17 Aug 2026 12:49:48 +0000 Subject: [PATCH 23/25] fix(rbac): drop dangling references from base tsconfig tsc -b failed: referenced src/test configs use noEmit without composite. Base tsconfig.json is now a plain IDE-default project, unrelated to the split -p configs the typecheck script uses. --- internal-packages/rbac/tsconfig.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal-packages/rbac/tsconfig.json b/internal-packages/rbac/tsconfig.json index b569e4415fb..2b8d67a9025 100644 --- a/internal-packages/rbac/tsconfig.json +++ b/internal-packages/rbac/tsconfig.json @@ -1,5 +1,4 @@ { - "references": [{ "path": "./tsconfig.src.json" }, { "path": "./tsconfig.test.json" }], "compilerOptions": { "target": "ES2019", "lib": ["ES2019", "DOM"], @@ -14,5 +13,7 @@ "strict": true, "customConditions": ["@triggerdotdev/source"] }, - "exclude": ["node_modules", "dist"] + // Excluded from this IDE-default project: needs ES2022 lib to type-check + // (see tsconfig.test.json). typecheck script still checks it. + "exclude": ["node_modules", "dist", "src/fallback.userActor.test.ts"] } From c8cdea6729b54e9f09643b08945d11af5c6f66c0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 17 Aug 2026 12:57:34 +0000 Subject: [PATCH 24/25] test(dashboard-agent): restore AWS/provider env vars after model-provider tests afterEach deleted these unconditionally, which could clobber CI-provided AWS config for later test files. Snapshot in beforeEach and restore the exact prior value (or delete if previously unset). --- .../dashboard-agent/src/model-provider.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts index 6c34df50622..42c2b22abda 100644 --- a/internal-packages/dashboard-agent/src/model-provider.test.ts +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; import { BEDROCK_MODEL_IDS, @@ -25,10 +25,20 @@ const AWS_ENV_VARS = [ "AWS_DEFAULT_REGION", ] as const; -afterEach(() => { +let priorEnv: Record; + +beforeEach(() => { + priorEnv = Object.fromEntries(AWS_ENV_VARS.map((key) => [key, process.env[key]])); for (const key of AWS_ENV_VARS) delete process.env[key]; }); +afterEach(() => { + for (const key of AWS_ENV_VARS) { + if (priorEnv[key] === undefined) delete process.env[key]; + else process.env[key] = priorEnv[key]; + } +}); + describe("resolveDashboardAgentModel", () => { it("resolves a canonical prompt string against Anthropic by default", () => { expect(resolveDashboardAgentModel("anthropic:claude-sonnet-4-6").modelId).toBe( From c51397bfc05364a7ec7fffd073f573ac6a76b7ca Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 17 Aug 2026 17:44:09 +0000 Subject: [PATCH 25/25] fix(dashboard-agent): treat empty model provider as unset, fix Sonnet Bedrock id DASHBOARD_AGENT_MODEL_PROVIDER="" rejected boot instead of falling back to the default. BEDROCK_MODEL_IDS mapped Sonnet 4.6 to a nonexistent -v1 suffix; Anthropic's official table has no -v1 for Sonnet. Pin exact ids in the test rather than a shape regex. --- apps/webapp/app/env.server.ts | 5 ++++- .../src/model-provider.test.ts | 19 ++++++++----------- .../dashboard-agent/src/model-provider.ts | 6 +++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index fa02c16a75d..1d6000caa24 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -211,7 +211,10 @@ const EnvironmentSchema = z 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"), + DASHBOARD_AGENT_MODEL_PROVIDER: z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : v), + 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). The // provider resolves credentials itself, so only the region is read here. diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts index 42c2b22abda..c238a6199ca 100644 --- a/internal-packages/dashboard-agent/src/model-provider.test.ts +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -49,7 +49,7 @@ describe("resolveDashboardAgentModel", () => { 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" + "us.anthropic.claude-sonnet-4-6" ); expect(resolveDashboardAgentModel("anthropic:claude-haiku-4-5").modelId).toBe( "us.anthropic.claude-haiku-4-5-20251001-v1:0" @@ -63,16 +63,13 @@ describe("resolveDashboardAgentModel", () => { ); }); - // 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+$/); - } + // Pinned to Anthropic's official Bedrock model table, not a shape regex — there is + // no shared suffix convention across models, so a well-formed id can still be wrong. + it("maps every model to its exact documented Bedrock id", () => { + expect(BEDROCK_MODEL_IDS).toEqual({ + "claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6", + "claude-haiku-4-5": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + }); }); }); diff --git a/internal-packages/dashboard-agent/src/model-provider.ts b/internal-packages/dashboard-agent/src/model-provider.ts index 86136fa3c4b..4d885906dff 100644 --- a/internal-packages/dashboard-agent/src/model-provider.ts +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -54,11 +54,11 @@ export const registry = createProviderRegistry({ anthropic, bedrock }); /** * 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. + * Anthropic's official Bedrock model table. No shared suffix convention across + * models — copy each id exactly rather than deriving it. */ export const BEDROCK_MODEL_IDS: Record = { - "claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6-v1", + "claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6", "claude-haiku-4-5": "us.anthropic.claude-haiku-4-5-20251001-v1:0", };