diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 3ce98dd521..376f0fd297 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -168,6 +168,18 @@ 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). 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(), + AWS_BEARER_TOKEN_BEDROCK: 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 499e621b83..3c18a84880 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -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_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 // leave an empty chat behind in the user's history. diff --git a/apps/webapp/app/services/dashboardAgentHeadStart.server.ts b/apps/webapp/app/services/dashboardAgentHeadStart.server.ts index 4a5936a196..1cb8cae34d 100644 --- a/apps/webapp/app/services/dashboardAgentHeadStart.server.ts +++ b/apps/webapp/app/services/dashboardAgentHeadStart.server.ts @@ -1,4 +1,3 @@ -import { createAnthropic } from "@ai-sdk/anthropic"; import { DASHBOARD_AGENT_CODE_SYSTEM_PROMPT, DASHBOARD_AGENT_MODEL, @@ -8,9 +7,12 @@ import { } from "@internal/dashboard-agent/tool-schemas"; import { describePromptPrefix, - PROMPT_CACHE_CONTROL, promptCacheAttributes, } from "@internal/dashboard-agent/prompt-prefix"; +import { + resolveDashboardAgentModel, + withCacheBreakpoint, +} from "@internal/dashboard-agent/model-provider"; import { ApiClient, SessionStreamInstance, writeTurnCompleteRecord } from "@trigger.dev/core/v3"; import { chat as chatServer } from "@trigger.dev/sdk/chat-server"; import { streamText, type UIMessage, type UIMessageChunk } from "ai"; @@ -23,8 +25,6 @@ import { logger } from "~/services/logger.server"; const TASK_ID = "dashboard-agent"; -const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }); - /** Shown when the warm first turn produced nothing. The provider error is only logged. */ export const HEAD_START_FAILURE_ERROR_TEXT = "The assistant couldn't start this response. Please send your message again."; @@ -113,16 +113,16 @@ export async function startDashboardAgentHeadStart(params: { run: async ({ chat: helper }) => streamText({ ...helper.toStreamTextOptions({ tools }), - model: anthropic(DASHBOARD_AGENT_MODEL), + model: resolveDashboardAgentModel(DASHBOARD_AGENT_MODEL), // A structured system message, not a bare string: without provider options - // Anthropic neither writes nor reads the cache, so this call paid full price + // the provider neither writes nor reads the cache, so this call paid full price // for the prefix and the agent's step 2 then paid for a fresh write. The tool // key order is frozen (see `tool-schemas.ts`) so both prefixes are identical // — the logged fingerprint is how a drift becomes visible. system: { role: "system", content: system, - providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } }, + providerOptions: withCacheBreakpoint(undefined, "prefix"), }, onStepFinish: (step) => { logger.info( diff --git a/internal-packages/dashboard-agent/package.json b/internal-packages/dashboard-agent/package.json index 1808d60e77..eaf9747b21 100644 --- a/internal-packages/dashboard-agent/package.json +++ b/internal-packages/dashboard-agent/package.json @@ -9,9 +9,11 @@ ".": "./src/index.ts", "./tool-curation": "./src/tool-curation.ts", "./tool-schemas": "./src/tool-schemas.ts", - "./prompt-prefix": "./src/prompt-prefix.ts" + "./prompt-prefix": "./src/prompt-prefix.ts", + "./model-provider": "./src/model-provider.ts" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "4.0.117", "@ai-sdk/anthropic": "^3.0.0", "@internal/dashboard-agent-contracts": "workspace:*", "@internal/dashboard-agent-db": "workspace:*", diff --git a/internal-packages/dashboard-agent/src/agent-runtime.ts b/internal-packages/dashboard-agent/src/agent-runtime.ts index d8d20ba2e2..fe1d99e369 100644 --- a/internal-packages/dashboard-agent/src/agent-runtime.ts +++ b/internal-packages/dashboard-agent/src/agent-runtime.ts @@ -1,4 +1,3 @@ -import { anthropic } from "@ai-sdk/anthropic"; import { appendChatMessageOnce, createDashboardAgentDb, @@ -18,13 +17,7 @@ import { type UpsertInvestigationResult, } from "@internal/dashboard-agent-db"; import { locals, logger } from "@trigger.dev/sdk"; -import { - createProviderRegistry, - type LanguageModel, - type ModelMessage, - type ToolSet, - type UIMessage, -} from "ai"; +import { type LanguageModel, type ModelMessage, type ToolSet, type UIMessage } from "ai"; import { z } from "zod"; import { agentPageContextSchema, @@ -32,8 +25,8 @@ import { investigationStateSchema, type InvestigationState, } from "@internal/dashboard-agent-contracts"; +import { withCacheBreakpoint } from "./model-provider"; import { codeSystemPrompt, systemPrompt } from "./prompts"; -import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; import { buildDashboardAgentTools } from "./tools"; /** @@ -63,8 +56,8 @@ function getDb(): DashboardAgentDbClient { } // Resolves the `"provider:model-id"` strings on our managed prompts to AI SDK -// models. Add another @ai-sdk/* provider here to allow it on a prompt. -export const registry = createProviderRegistry({ anthropic }); +// models, against whichever provider is switched on. +export { registry, resolveDashboardAgentModel } from "./model-provider"; // The agent's persistence, behind an interface so tests can inject a fake via // `locals` and never need a real database. @@ -354,7 +347,7 @@ export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessa }) as ModelMessage[]; } -// Same Anthropic breakpoint `prepareMessages` rolls onto a turn's last message. +// Same breakpoint `prepareMessages` rolls onto a turn's last message. export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] { if (messages.length === 0) return messages; const last = messages[messages.length - 1]!; @@ -362,12 +355,9 @@ export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessag ...messages.slice(0, -1), { ...last, - providerOptions: { - ...last.providerOptions, - // Merged, not replaced: the breakpoint is one Anthropic option among any - // others the message already carries. - anthropic: { ...last.providerOptions?.anthropic, cacheControl: PROMPT_CACHE_CONTROL }, - }, + // Merged, not replaced: the breakpoint is one provider option among any + // others the message already carries. + providerOptions: withCacheBreakpoint(last.providerOptions, "prefix"), }, ]; } diff --git a/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts b/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts index a3b64b2082..483789b02e 100644 --- a/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts +++ b/internal-packages/dashboard-agent/src/cache-breakpoint.test.ts @@ -35,6 +35,7 @@ describe("withCacheBreakpointOnLast", () => { const prepared = withCacheBreakpointOnLast(lastMessageWithAnthropicOptions()); expect(prepared[1]!.providerOptions).toEqual({ + __cacheBreakpoint: { kind: "prefix" }, anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } }, openai: { store: false }, }); @@ -54,6 +55,7 @@ describe("prepareTurnMessages", () => { }); expect(prepared[1]!.providerOptions).toEqual({ + __cacheBreakpoint: { kind: "prefix" }, anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } }, openai: { store: false }, }); diff --git a/internal-packages/dashboard-agent/src/compaction.ts b/internal-packages/dashboard-agent/src/compaction.ts index 83e5d82b83..2f1ce53416 100644 --- a/internal-packages/dashboard-agent/src/compaction.ts +++ b/internal-packages/dashboard-agent/src/compaction.ts @@ -5,7 +5,7 @@ import { generateText, type ModelMessage, type UIMessage } from "ai"; import { dashboardAgentModelKey, latestCards, - registry, + resolveDashboardAgentModel, sanitizeReplayedToolInputs, } from "./agent-runtime"; @@ -271,7 +271,7 @@ export function renderTranscriptForSummary(messages: ModelMessage[]): string { async function summarizeConversation(event: SummarizeEvent): Promise { const { text } = await generateText({ - model: locals.get(dashboardAgentModelKey) ?? registry.languageModel(SUMMARY_MODEL), + model: locals.get(dashboardAgentModelKey) ?? resolveDashboardAgentModel(SUMMARY_MODEL), system: SUMMARY_INSTRUCTION, prompt: renderTranscriptForSummary(event.messages), maxOutputTokens: SUMMARY_MAX_OUTPUT_TOKENS, diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.ts b/internal-packages/dashboard-agent/src/dashboard-agent.ts index 237cbc64d3..c47da63918 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.ts @@ -16,7 +16,7 @@ import { getStore, getSystemPrompt, modeFor, - registry, + resolveDashboardAgentModel, sanitizeReplayedToolInputs, settlementCardMessages, clearOpenInvestigations, @@ -25,7 +25,7 @@ import { type DashboardAgentStore, } from "./agent-runtime"; import { titlePrompt } from "./prompts"; -import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; +import { withCacheBreakpoint } from "./model-provider"; import { recordPromptCacheUsage, stepCachePrepareStep } from "./step-cache"; import { dashboardAgentActionSchema, handleWatchAction } from "./watch-actions"; import { dashboardAgentCompaction, withDurableState } from "./compaction"; @@ -309,9 +309,7 @@ async function generateAndSaveTitle( const { text } = await generateText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-haiku-4-5") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-haiku-4-5"), system: resolved.text, prompt: userText, ...resolved.toAISDKTelemetry(), @@ -428,7 +426,7 @@ export const dashboardAgent = chat.agent({ // prompt; the resolve is cached per process. The cache breakpoint on the system // block carries through toStreamTextOptions() and survives suspend/resume. chat.prompt.set(await getSystemPrompt(modeFor(clientData)), { - providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } }, + providerOptions: withCacheBreakpoint(undefined, "prefix"), }); }, @@ -581,9 +579,7 @@ export const dashboardAgent = chat.agent({ ...options, model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"), messages, abortSignal: signal, prepareStep: stepCachePrepareStep(options) as never, diff --git a/internal-packages/dashboard-agent/src/eval-turn.ts b/internal-packages/dashboard-agent/src/eval-turn.ts index 3327611e4d..b3e55ddfc6 100644 --- a/internal-packages/dashboard-agent/src/eval-turn.ts +++ b/internal-packages/dashboard-agent/src/eval-turn.ts @@ -1,4 +1,3 @@ -import { anthropic } from "@ai-sdk/anthropic"; import { createDashboardAgentDb, insertTurnEval, @@ -6,6 +5,7 @@ import { } from "@internal/dashboard-agent-db"; import { logger, task } from "@trigger.dev/sdk"; import { EVAL_ERROR_CATEGORIES, redactedEvalOutputErrored } from "./eval-policy"; +import { resolveDashboardAgentModel } from "./model-provider"; import { generateObject } from "ai"; import { z } from "zod"; @@ -164,7 +164,7 @@ export const evalTurn = task({ id: "dashboard-agent-eval-turn", run: async (payload: EvalTurnPayload, { ctx }) => { const { object } = await generateObject({ - model: anthropic(JUDGE_MODEL), + model: resolveDashboardAgentModel(`anthropic:${JUDGE_MODEL}`), schema: TurnEval, system: JUDGE_SYSTEM, prompt: [ diff --git a/internal-packages/dashboard-agent/src/model-provider.test.ts b/internal-packages/dashboard-agent/src/model-provider.test.ts new file mode 100644 index 0000000000..ab9dec505a --- /dev/null +++ b/internal-packages/dashboard-agent/src/model-provider.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { PROMPT_CACHE_CONTROL } from "./prompt-prefix"; +import { + BEDROCK_MODEL_IDS, + 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" + ); + }); + + 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", () => { + 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 plain Bedrock cachePoint with no ttl for either marker", () => { + useBedrock(); + 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 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 new file mode 100644 index 0000000000..d1cfb9a9b3 --- /dev/null +++ b/internal-packages/dashboard-agent/src/model-provider.ts @@ -0,0 +1,144 @@ +import { createAmazonBedrock } 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"; +} + +// 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, +}); + +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. + */ +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", +}; + +/** 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]; + 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}`); +} + +/** + * 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; + +// 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 { + return providerOptions?.[CACHE_BREAKPOINT_KEY]?.kind; +} + +function cacheOptions(breakpoint: CacheBreakpoint): Record { + if (dashboardAgentProvider() === "anthropic") { + return { + anthropic: { + cacheControl: breakpoint === "prefix" ? PROMPT_CACHE_CONTROL : STEP_CACHE_CONTROL, + }, + }; + } + // 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. */ +export function withCacheBreakpoint( + providerOptions: ProviderOptions, + breakpoint: CacheBreakpoint +): Record { + const [key, options] = Object.entries(cacheOptions(breakpoint))[0]!; + 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. + */ +export function isStepCacheBreakpoint(providerOptions: ProviderOptions): boolean { + return breakpointKind(providerOptions) === "step"; +} + +/** Whether these options carry a breakpoint that outlives a step (the turn-wide prefix). */ +export function isLongLivedCacheBreakpoint(providerOptions: ProviderOptions): boolean { + return breakpointKind(providerOptions) === "prefix"; +} + +/** + * 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 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, + [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 662afc012b..2f41354d0d 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, beforeEach, 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; @@ -22,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 }, + }, }; } @@ -31,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 }, }, @@ -107,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 }, }); @@ -127,6 +133,68 @@ 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(() => { + 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 { + return (message?.providerOptions?.bedrock as { cachePoint?: { ttl?: unknown } } | undefined) + ?.cachePoint; + } + + 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"), + }; + } + + // 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 = { + 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", () => { + const marked = markStepCacheBreakpoint([prefixMarker(), toolResult(MIN_STEP_CACHE_CHARS)]); + + expect(bedrockCachePoint(marked[0])).toEqual({ type: "default" }); + expect(breakpointTag(marked[0])).toBe("prefix"); + expect(bedrockCachePoint(marked.at(-1))).toEqual({ type: "default" }); + expect(breakpointTag(marked.at(-1))).toBe("step"); + }); +}); + describe("wrapping the SDK's prepareStep", () => { it("marks whatever the inner prepareStep returned", async () => { const compactedTo = [turnHistory(), toolResult(MIN_STEP_CACHE_CHARS)]; @@ -162,6 +230,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 }, }); }); @@ -189,6 +258,27 @@ 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( + 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 { + if (prior === undefined) delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER; + else process.env.DASHBOARD_AGENT_MODEL_PROVIDER = prior; + } + }); + 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 56465a0ee9..b6306072a5 100644 --- a/internal-packages/dashboard-agent/src/step-cache.ts +++ b/internal-packages/dashboard-agent/src/step-cache.ts @@ -1,5 +1,12 @@ import { logger } from "@trigger.dev/sdk"; import type { ModelMessage, ToolSet } from "ai"; +import { + cacheUsageFromProviderMetadata, + isLongLivedCacheBreakpoint, + isStepCacheBreakpoint, + withCacheBreakpoint, + withoutCacheBreakpoint, +} from "./model-provider"; import { describePromptPrefix, promptCacheAttributes, @@ -15,36 +22,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 +41,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 +52,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") }, ]; } @@ -97,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, }; } @@ -131,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. diff --git a/internal-packages/dashboard-agent/src/watch-actions.ts b/internal-packages/dashboard-agent/src/watch-actions.ts index ddb46e0f4d..af356f0f4b 100644 --- a/internal-packages/dashboard-agent/src/watch-actions.ts +++ b/internal-packages/dashboard-agent/src/watch-actions.ts @@ -28,7 +28,7 @@ import { getStore, getSystemPrompt, modeFor, - registry, + resolveDashboardAgentModel, latestCards, sanitizeReplayedToolInputs, clearOpenInvestigations, @@ -428,7 +428,7 @@ async function narrateWithPlan(input: { ? streamText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel("anthropic:claude-haiku-4-5"), + resolveDashboardAgentModel("anthropic:claude-haiku-4-5"), system: HAIKU_WAKE_BRIEF, // Bounded on purpose: the wake alone, no conversation and no tools. messages: [ @@ -442,9 +442,7 @@ async function narrateWithPlan(input: { : streamText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"), system: resolved.text, // No tools: a wake reports what the check already established, and carries no // delegated token to read with. The breakpoint goes on the last message of the @@ -782,9 +780,7 @@ async function conductWatchInvestigation(args: { const result = streamText({ model: locals.get(dashboardAgentModelKey) ?? - registry.languageModel( - (resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}` - ), + resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"), system: resolved.text, tools, // Ten steps of accumulating tool output is exactly what the rolling breakpoint diff --git a/internal-packages/rbac/package.json b/internal-packages/rbac/package.json index 53374670f7..b667e94c05 100644 --- a/internal-packages/rbac/package.json +++ b/internal-packages/rbac/package.json @@ -9,6 +9,7 @@ "@trigger.dev/plugins": "workspace:*" }, "devDependencies": { + "@internal/testcontainers": "workspace:*", "@trigger.dev/database": "workspace:*", "@types/node": "^24.13.3", "rimraf": "6.0.1" diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 2d9bb9cea9..5a7cdddd83 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -260,6 +260,29 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { if (!claims) { return { ok: false, status: 401, error: "Invalid user-actor token" }; } + + // 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 { ok: true, userId: claims.userId, diff --git a/internal-packages/rbac/src/fallback.userActor.test.ts b/internal-packages/rbac/src/fallback.userActor.test.ts new file mode 100644 index 0000000000..2423f7419f --- /dev/null +++ b/internal-packages/rbac/src/fallback.userActor.test.ts @@ -0,0 +1,102 @@ +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 }, + }); +} + +async function uat(userId: string) { + return signUserActorToken(SECRET, { userId, client: "test" }); +} + +postgresTest( + "authenticateUserActor: scoped membership floor", + async ({ prisma }) => { + const p = prisma as PrismaClient; + const org = await p.organization.create({ + data: { slug: `org-${Date.now()}`, title: "Org" }, + }); + 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(p, { userActorSecret: SECRET }).create(); + + // Member with a capless token keeps the read:all default. + const memberResult = await controller.authenticateUserActor(uatRequest(await uat(member.id)), { + 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(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(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 controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create(); + + // 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); + } + }, + 120_000 +); diff --git a/internal-packages/rbac/tsconfig.json b/internal-packages/rbac/tsconfig.json index 9ab7d81355..81f6a3eaa8 100644 --- a/internal-packages/rbac/tsconfig.json +++ b/internal-packages/rbac/tsconfig.json @@ -13,5 +13,8 @@ "strict": true, "customConditions": ["@triggerdotdev/source"] }, - "exclude": ["node_modules", "dist"] + // Excluded from tsc: it imports @internal/testcontainers, whose source + // (pulled in via customConditions) doesn't type-check under this config. + // vitest still runs and transpiles it. + "exclude": ["node_modules", "dist", "src/fallback.userActor.test.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 160c39b7b2..a97ec7af81 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) @@ -1232,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 @@ -2301,6 +2307,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 +2343,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 +9029,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 +16076,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 +16120,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 +24067,8 @@ snapshots: aws4fetch@1.0.18: {} + aws4fetch@1.0.20: {} + axios@1.16.1: dependencies: follow-redirects: 1.16.0 @@ -28563,7 +28603,7 @@ snapshots: node-abi@3.89.0: dependencies: - semver: 7.8.5 + semver: 7.8.1 optional: true node-abort-controller@3.1.1: {}