From b3b1146b67178378749527bb2ff7a12477e4876c Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 23 Apr 2026 12:38:38 -0700 Subject: [PATCH 1/7] feat: support hosted model.providerOptions for bedrock and vertex --- .changeset/tasty-lobsters-learn.md | 5 + packages/core/lib/v3/api.ts | 87 ++++- packages/core/lib/v3/modelProviderOptions.ts | 299 ++++++++++++++++++ packages/core/lib/v3/types/public/api.ts | 216 +++++++++++-- packages/core/lib/v3/types/public/model.ts | 27 +- packages/core/lib/v3/v3.ts | 12 +- .../unit/api-client-model-config.test.ts | 188 +++++++++++ .../unit/api-provider-config-schema.test.ts | 69 ++++ .../tests/unit/model-provider-options.test.ts | 122 +++++++ .../unit/public-api/llm-and-agents.test.ts | 33 +- 10 files changed, 1015 insertions(+), 43 deletions(-) create mode 100644 .changeset/tasty-lobsters-learn.md create mode 100644 packages/core/lib/v3/modelProviderOptions.ts create mode 100644 packages/core/tests/unit/api-client-model-config.test.ts create mode 100644 packages/core/tests/unit/api-provider-config-schema.test.ts create mode 100644 packages/core/tests/unit/model-provider-options.test.ts diff --git a/.changeset/tasty-lobsters-learn.md b/.changeset/tasty-lobsters-learn.md new file mode 100644 index 000000000..970ef1489 --- /dev/null +++ b/.changeset/tasty-lobsters-learn.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": minor +--- + +Support hosted Stagehand `model.providerOptions` for Bedrock and Vertex model configuration. diff --git a/packages/core/lib/v3/api.ts b/packages/core/lib/v3/api.ts index bc4d5977f..591a878f4 100644 --- a/packages/core/lib/v3/api.ts +++ b/packages/core/lib/v3/api.ts @@ -29,7 +29,14 @@ import type { SerializableResponse, AgentCacheTransferPayload, } from "./types/private/index.js"; -import type { ModelConfiguration } from "./types/public/model.js"; +import type { + ClientOptions, + ModelConfiguration, +} from "./types/public/model.js"; +import { + normalizeClientOptionsForModel, + toApiModelClientOptions, +} from "./modelProviderOptions.js"; import { toJsonSchema } from "./zodCompat.js"; import type { StagehandZodSchema } from "./zodCompat.js"; @@ -97,15 +104,17 @@ interface StagehandAPIConstructorParams { /** * Parameters for starting a session via the API client. - * Extends Api.SessionStartRequest with client-specific field (modelApiKey). * * Wire format: Api.SessionStartRequest (modelApiKey sent via header, not body) */ -interface ClientSessionStartParams extends Api.SessionStartRequest { +interface ClientSessionStartParams + extends Omit { /** Model API key - sent via x-model-api-key header, not in request body. * Optional: when omitted, requests are sent without the x-model-api-key header * and the server is expected to handle model authentication on its own. */ modelApiKey?: string; + /** SDK model client options serialized into the hosted API wire format. */ + modelClientOptions?: ClientOptions; } /** @@ -180,6 +189,8 @@ export class StagehandAPIClient { private sessionId?: string; private modelApiKey?: string; private modelProvider?: string; + /** Serialized session model config, resent on each hosted action when needed. */ + private sessionModelConfig?: Api.ModelConfig; private region?: BrowserbaseRegion; private logger: (message: LogLine) => void; private fetchWithCookies; @@ -204,6 +215,7 @@ export class StagehandAPIClient { async init({ modelName, modelApiKey, + modelClientOptions, domSettleTimeoutMs, verbose, systemPrompt, @@ -213,6 +225,22 @@ export class StagehandAPIClient { // browser, TODO for local browsers }: ClientSessionStartParams): Promise { this.modelApiKey = modelApiKey; + + const serializedModelClientOptions = this.toSessionStartModelClientOptions( + modelClientOptions, + modelName, + ); + if ( + modelName && + serializedModelClientOptions && + Object.keys(serializedModelClientOptions).length > 0 + ) { + this.sessionModelConfig = { + modelName, + ...serializedModelClientOptions, + } as Api.ModelConfig; + } + // Extract provider from modelName (e.g., "openai/gpt-5-nano" -> "openai") this.modelProvider = modelName?.includes("/") ? modelName.split("/")[0] @@ -230,6 +258,7 @@ export class StagehandAPIClient { // Build wire-format request body (Api.SessionStartRequest shape) const requestBody: Api.SessionStartRequest = { modelName, + modelClientOptions: serializedModelClientOptions, domSettleTimeoutMs, verbose, systemPrompt, @@ -294,6 +323,7 @@ export class StagehandAPIClient { wireOptions = restOptions as unknown as Api.ActRequest["options"]; } } + wireOptions = this.ensureModelConfig(wireOptions); // Build wire-format request body const requestBody: Api.ActRequest = { @@ -332,6 +362,7 @@ export class StagehandAPIClient { wireOptions = restOptions as unknown as Api.ExtractRequest["options"]; } } + wireOptions = this.ensureModelConfig(wireOptions); // Build wire-format request body const requestBody: Api.ExtractRequest = { @@ -367,6 +398,7 @@ export class StagehandAPIClient { wireOptions = restOptions as unknown as Api.ObserveRequest["options"]; } } + wireOptions = this.ensureModelConfig(wireOptions); // Build wire-format request body const requestBody: Api.ObserveRequest = { @@ -424,7 +456,7 @@ export class StagehandAPIClient { cua: agentConfig.mode === undefined ? agentConfig.cua : undefined, model: agentConfig.model ? this.prepareModelConfig(agentConfig.model) - : undefined, + : this.sessionModelConfig, executionModel: agentConfig.executionModel ? this.prepareModelConfig(agentConfig.executionModel) : undefined, @@ -606,7 +638,7 @@ export class StagehandAPIClient { */ private prepareModelConfig( model: ModelConfiguration, - ): { modelName: string; apiKey?: string } & Record { + ): { modelName: string } & Record { if (typeof model === "string") { // Extract provider from model string (e.g., "openai/gpt-5-nano" -> "openai") const provider = model.includes("/") ? model.split("/")[0] : undefined; @@ -620,7 +652,14 @@ export class StagehandAPIClient { }; } - if (!model.apiKey) { + const normalizedModel = { + modelName: model.modelName, + ...(this.toSessionStartModelClientOptions(model, model.modelName) ?? {}), + }; + + const normalizedApiKey = (normalizedModel as Record) + .apiKey; + if (typeof normalizedApiKey !== "string" || !normalizedApiKey) { const provider = model.modelName?.includes("/") ? model.modelName.split("/")[0] : undefined; @@ -629,15 +668,41 @@ export class StagehandAPIClient { ? (loadApiKeyFromEnv(provider, this.logger) ?? this.modelApiKey) : this.modelApiKey; return { - ...model, + ...normalizedModel, ...(apiKey ? { apiKey } : {}), }; } - return model as { modelName: string; apiKey: string } & Record< - string, - unknown - >; + return normalizedModel as { modelName: string } & Record; + } + + /** + * If no model config is present in the wire options, inject the session + * default model config so hosted deployments receive provider-native auth on + * every action. + */ + private ensureModelConfig( + wireOptions: T, + ): T { + if (!this.sessionModelConfig || wireOptions?.model) { + return wireOptions; + } + + return { + ...(wireOptions ?? {}), + model: this.sessionModelConfig, + } as T; + } + + private toSessionStartModelClientOptions( + options?: ClientOptions, + modelName?: string, + ): Api.ModelClientOptions | undefined { + const normalizedOptions = normalizeClientOptionsForModel( + options, + modelName, + ); + return toApiModelClientOptions(normalizedOptions, modelName); } private consumeFinishedEventData(): T | null { diff --git a/packages/core/lib/v3/modelProviderOptions.ts b/packages/core/lib/v3/modelProviderOptions.ts new file mode 100644 index 000000000..60bd13f20 --- /dev/null +++ b/packages/core/lib/v3/modelProviderOptions.ts @@ -0,0 +1,299 @@ +import { StagehandInvalidArgumentError } from "./types/public/sdkErrors.js"; +import type { + BedrockProviderOptions, + ClientOptions, + GoogleVertexProviderSettings, +} from "./types/public/model.js"; +import type { Api } from "./types/public/index.js"; + +type VertexCompatibleClientOptions = ClientOptions & + Partial; + +function hasValue(value: T | undefined | null): value is T { + return value !== undefined && value !== null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toSerializableHeaders( + headers: unknown, +): Record | undefined { + if (typeof Headers !== "undefined" && headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + + if (!isRecord(headers)) { + return undefined; + } + + if (Object.values(headers).some((value) => typeof value !== "string")) { + return undefined; + } + + return headers as Record; +} + +export function getProviderFromModelName( + modelName?: string, +): string | undefined { + return typeof modelName === "string" && modelName.includes("/") + ? modelName.split("/", 1)[0] + : undefined; +} + +function getLegacyVertexOptions( + options?: ClientOptions, +): GoogleVertexProviderSettings | undefined { + if (!options) { + return undefined; + } + + const vertexOptions = options as VertexCompatibleClientOptions; + const legacyVertexOptions: GoogleVertexProviderSettings = {}; + + if (hasValue(vertexOptions.project)) { + legacyVertexOptions.project = vertexOptions.project; + } + if (hasValue(vertexOptions.location)) { + legacyVertexOptions.location = vertexOptions.location; + } + if (hasValue(vertexOptions.googleAuthOptions)) { + legacyVertexOptions.googleAuthOptions = vertexOptions.googleAuthOptions; + } + + const headers = toSerializableHeaders(options.headers); + if (headers) { + legacyVertexOptions.headers = headers; + } + + return Object.keys(legacyVertexOptions).length > 0 + ? legacyVertexOptions + : undefined; +} + +function getNormalizedVertexProviderOptions( + options?: ClientOptions, +): GoogleVertexProviderSettings | undefined { + if (!options) { + return undefined; + } + + const legacyVertexOptions = getLegacyVertexOptions(options); + const rawProviderOptions = options.providerOptions; + const providerOptions = isRecord(rawProviderOptions) + ? (rawProviderOptions as GoogleVertexProviderSettings) + : undefined; + + if (!legacyVertexOptions && !providerOptions) { + return undefined; + } + + const mergedHeaders = toSerializableHeaders(providerOptions?.headers); + + return { + ...(legacyVertexOptions ?? {}), + ...(providerOptions ?? {}), + ...(mergedHeaders ? { headers: mergedHeaders } : {}), + }; +} + +function getBedrockProviderOptions( + options?: ClientOptions, +): BedrockProviderOptions | undefined { + if (!options || !isRecord(options.providerOptions)) { + return undefined; + } + + return options.providerOptions as BedrockProviderOptions; +} + +function getProviderConfig( + options: ClientOptions, + modelName?: string, +): { provider: string; options: Record } | undefined { + const modelProvider = getProviderFromModelName(modelName); + + if (!options.providerOptions) { + if (modelProvider === "vertex") { + const vertexOptions = getNormalizedVertexProviderOptions(options); + if (vertexOptions) { + return { + provider: "vertex", + options: vertexOptions as Record, + }; + } + } + return undefined; + } + + if (modelProvider === "bedrock") { + const bedrockOptions = getBedrockProviderOptions(options); + if (!bedrockOptions) { + return undefined; + } + + return { + provider: "bedrock", + options: bedrockOptions as Record, + }; + } + + if (modelProvider === "vertex") { + const vertexOptions = getNormalizedVertexProviderOptions(options); + if (!vertexOptions) { + return undefined; + } + + return { + provider: "vertex", + options: vertexOptions as Record, + }; + } + + throw new StagehandInvalidArgumentError( + `providerOptions is only supported for bedrock/... and vertex/... models. Received "${modelName ?? "unknown"}".`, + ); +} + +export function normalizeClientOptionsForModel( + options?: ClientOptions, + modelName?: string, +): ClientOptions | undefined { + if (!options) { + return undefined; + } + + const normalizedOptions = { ...options } as ClientOptions & { + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + sessionToken?: string; + }; + const modelProvider = getProviderFromModelName(modelName); + const serializedHeaders = toSerializableHeaders(options.headers); + + if (serializedHeaders) { + normalizedOptions.headers = serializedHeaders; + } + + if (modelProvider === "bedrock") { + const bedrockOptions = getBedrockProviderOptions(options); + if (bedrockOptions) { + if (normalizedOptions.region === undefined) { + normalizedOptions.region = bedrockOptions.region; + } + if (normalizedOptions.accessKeyId === undefined) { + normalizedOptions.accessKeyId = bedrockOptions.accessKeyId; + } + if (normalizedOptions.secretAccessKey === undefined) { + normalizedOptions.secretAccessKey = bedrockOptions.secretAccessKey; + } + if (normalizedOptions.sessionToken === undefined) { + normalizedOptions.sessionToken = bedrockOptions.sessionToken; + } + } + } + + if (modelProvider === "vertex") { + const vertexOptions = getNormalizedVertexProviderOptions(options); + if (vertexOptions) { + const normalizedVertexOptions = + normalizedOptions as VertexCompatibleClientOptions & { + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + sessionToken?: string; + }; + + if (vertexOptions.project !== undefined) { + normalizedVertexOptions.project = vertexOptions.project; + } + if (vertexOptions.location !== undefined) { + normalizedVertexOptions.location = vertexOptions.location; + } + if (vertexOptions.googleAuthOptions !== undefined) { + normalizedVertexOptions.googleAuthOptions = + vertexOptions.googleAuthOptions; + } + if (vertexOptions.headers !== undefined) { + normalizedVertexOptions.headers = vertexOptions.headers; + } + } + } + + return normalizedOptions; +} + +export function toApiModelClientOptions( + options?: ClientOptions, + modelName?: string, +): Api.ModelClientOptions | undefined { + if (!options) { + return undefined; + } + + const normalizedOptions = normalizeClientOptionsForModel(options, modelName); + if (!normalizedOptions) { + return undefined; + } + + const providerConfig = getProviderConfig(normalizedOptions, modelName); + const requestOptions = { + ...normalizedOptions, + } as Record; + + delete requestOptions.provider; + delete requestOptions.providerOptions; + + if (providerConfig?.provider === "bedrock") { + delete requestOptions.region; + delete requestOptions.accessKeyId; + delete requestOptions.secretAccessKey; + delete requestOptions.sessionToken; + } + + if (providerConfig?.provider === "vertex") { + delete requestOptions.project; + delete requestOptions.location; + delete requestOptions.googleAuthOptions; + delete requestOptions.headers; + } + + if (providerConfig) { + requestOptions.providerConfig = { + ...providerConfig, + options: { ...providerConfig.options }, + }; + } + + const headers = toSerializableHeaders(requestOptions.headers); + if (headers) { + requestOptions.headers = headers; + } else { + delete requestOptions.headers; + } + + const providerHeaders = toSerializableHeaders( + isRecord(requestOptions.providerConfig) + ? (requestOptions.providerConfig as { options?: Record }) + .options?.headers + : undefined, + ); + if (isRecord(requestOptions.providerConfig)) { + const config = requestOptions.providerConfig as { + options?: Record; + }; + + if (config.options) { + if (providerHeaders) { + config.options.headers = providerHeaders; + } else { + delete config.options.headers; + } + } + } + + return requestOptions as Api.ModelClientOptions; +} diff --git a/packages/core/lib/v3/types/public/api.ts b/packages/core/lib/v3/types/public/api.ts index 4332053f5..cbdbd92ef 100644 --- a/packages/core/lib/v3/types/public/api.ts +++ b/packages/core/lib/v3/types/public/api.ts @@ -55,37 +55,193 @@ export const LocalBrowserLaunchOptionsSchema = z .strict() .meta({ id: "LocalBrowserLaunchOptions" }); -/** Detailed model configuration object */ -export const ModelConfigObjectSchema = z +export const ProviderConfigSchema = z .object({ - provider: z - .enum(["openai", "anthropic", "google", "microsoft", "bedrock"]) - .optional() - .meta({ - description: - "AI provider for the model (or provide a baseURL endpoint instead)", - example: "openai", - }), - modelName: z.string().meta({ + provider: z.string().meta({ description: - "Model name string with provider prefix (e.g., 'openai/gpt-5-nano')", - example: "openai/gpt-5.4-mini", - }), - apiKey: z.string().optional().meta({ - description: "API key for the model provider", - example: "sk-some-openai-api-key", - }), - baseURL: z.string().url().optional().meta({ - description: "Base URL for the model provider", - example: "https://api.openai.com/v1", + "Provider identifier derived from modelName (for example: bedrock or vertex)", + example: "bedrock", }), - headers: z.record(z.string(), z.string()).optional().meta({ + options: z.record(z.string(), z.unknown()).optional().meta({ description: - "Custom headers sent with every request to the model provider", + "Provider-native options forwarded to the server/runtime. This is wire-only; the public SDK constructor uses model.providerOptions instead.", }), }) + .passthrough() + .meta({ id: "ProviderConfig" }); + +function getProviderFromModelName(modelName?: string): string | undefined { + return typeof modelName === "string" && modelName.includes("/") + ? modelName.split("/", 1)[0] + : undefined; +} + +function getProviderConfigMismatchMessage({ + modelName, + providerConfig, +}: { + modelName?: string; + providerConfig?: { provider?: string }; +}): string | undefined { + const modelProvider = getProviderFromModelName(modelName); + const provider = + typeof providerConfig?.provider === "string" + ? providerConfig.provider + : undefined; + + if (modelProvider && provider && provider !== modelProvider) { + return `providerConfig.provider "${provider}" must match the model provider "${modelProvider}"`; + } + + return undefined; +} + +function addBedrockAuthIssues( + providerConfig: { options?: Record } | undefined, + ctx: z.RefinementCtx, +) { + const providerOptions = providerConfig?.options; + const region = + typeof providerOptions?.region === "string" ? providerOptions.region : ""; + const accessKeyId = + typeof providerOptions?.accessKeyId === "string" + ? providerOptions.accessKeyId + : ""; + const secretAccessKey = + typeof providerOptions?.secretAccessKey === "string" + ? providerOptions.secretAccessKey + : ""; + const sessionToken = + typeof providerOptions?.sessionToken === "string" + ? providerOptions.sessionToken + : ""; + + if (!region) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Bedrock configs require providerConfig.options.region.", + path: ["providerConfig", "options", "region"], + }); + } + + const hasAccessKeyId = accessKeyId.length > 0; + const hasSecretAccessKey = secretAccessKey.length > 0; + const hasSessionToken = sessionToken.length > 0; + + if (hasAccessKeyId !== hasSecretAccessKey) { + if (!hasAccessKeyId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Bedrock AWS credentials require providerConfig.options.accessKeyId when secretAccessKey is provided.", + path: ["providerConfig", "options", "accessKeyId"], + }); + } + if (!hasSecretAccessKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Bedrock AWS credentials require providerConfig.options.secretAccessKey when accessKeyId is provided.", + path: ["providerConfig", "options", "secretAccessKey"], + }); + } + } + + if (hasSessionToken && (!hasAccessKeyId || !hasSecretAccessKey)) { + if (!hasAccessKeyId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Bedrock sessionToken requires providerConfig.options.accessKeyId.", + path: ["providerConfig", "options", "accessKeyId"], + }); + } + if (!hasSecretAccessKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Bedrock sessionToken requires providerConfig.options.secretAccessKey.", + path: ["providerConfig", "options", "secretAccessKey"], + }); + } + } +} + +const modelConfigSharedShape = { + provider: z + .enum(["openai", "anthropic", "google", "microsoft", "bedrock", "vertex"]) + .optional() + .meta({ + description: + "AI provider for the model (or provide a baseURL endpoint instead)", + example: "openai", + }), + modelName: z.string().meta({ + description: + "Model name string with provider prefix (e.g., 'openai/gpt-5-nano')", + example: "openai/gpt-5.4-mini", + }), + apiKey: z.string().optional().meta({ + description: "API key for the model provider", + example: "sk-some-openai-api-key", + }), + baseURL: z.string().url().optional().meta({ + description: "Base URL for the model provider", + example: "https://api.openai.com/v1", + }), + headers: z.record(z.string(), z.string()).optional().meta({ + description: "Custom headers sent with every request to the model provider", + }), + providerConfig: ProviderConfigSchema.optional(), +} as const; + +function validateProviderConfig( + value: { + modelName?: string; + providerConfig?: { provider?: string; options?: Record }; + }, + ctx: z.RefinementCtx, +) { + const mismatchMessage = getProviderConfigMismatchMessage(value); + + if (mismatchMessage) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: mismatchMessage, + path: ["providerConfig", "provider"], + }); + } + + const modelProvider = getProviderFromModelName(value.modelName); + const provider = + typeof value.providerConfig?.provider === "string" + ? value.providerConfig.provider + : undefined; + + if (provider === "bedrock" || modelProvider === "bedrock") { + addBedrockAuthIssues(value.providerConfig, ctx); + } +} + +/** Detailed model configuration object */ +export const ModelConfigObjectSchema = z + .object(modelConfigSharedShape) + .passthrough() + .superRefine((value, ctx) => validateProviderConfig(value, ctx)) .meta({ id: "ModelConfigObject" }); +/** Session-level model client options (wire-only). */ +export const ModelClientOptionsSchema = z + .object({ + provider: modelConfigSharedShape.provider, + apiKey: modelConfigSharedShape.apiKey, + baseURL: modelConfigSharedShape.baseURL, + headers: modelConfigSharedShape.headers, + providerConfig: modelConfigSharedShape.providerConfig, + }) + .passthrough() + .meta({ id: "ModelClientOptions" }); + /** Model configuration */ export const ModelConfigSchema = ModelConfigObjectSchema.meta({ id: "ModelConfig", @@ -319,6 +475,10 @@ export const SessionStartRequestSchema = z description: "Model name to use for AI operations", example: "openai/gpt-5.4-mini", }), + modelClientOptions: ModelClientOptionsSchema.optional().meta({ + description: + "Hosted-session model options. The public Stagehand constructor fills this from model.providerOptions/apiKey when env='BROWSERBASE'.", + }), domSettleTimeoutMs: z.number().optional().meta({ description: "Timeout in ms to wait for DOM to settle", example: 5000, @@ -361,6 +521,15 @@ export const SessionStartRequestSchema = z description: "Timeout in ms for act operations (deprecated, v2 only)", }), }) + .superRefine((value, ctx) => + validateProviderConfig( + { + modelName: value.modelName, + providerConfig: value.modelClientOptions?.providerConfig, + }, + ctx, + ), + ) .meta({ id: "SessionStartRequest" }); export const SessionStartResultSchema = z @@ -1081,6 +1250,7 @@ export const Operations = { // Shared types export type Action = z.infer; export type ModelConfig = z.infer; +export type ModelClientOptions = z.infer; export type BrowserConfig = z.infer; export type SessionIdParams = z.infer; diff --git a/packages/core/lib/v3/types/public/model.ts b/packages/core/lib/v3/types/public/model.ts index 0bc88365a..7a35e479e 100644 --- a/packages/core/lib/v3/types/public/model.ts +++ b/packages/core/lib/v3/types/public/model.ts @@ -1,4 +1,5 @@ import type { ClientOptions as AnthropicClientOptionsBase } from "@anthropic-ai/sdk"; +import type { AmazonBedrockProviderSettings as AmazonBedrockProviderSettingsBase } from "@ai-sdk/amazon-bedrock"; import type { GoogleVertexProviderSettings as GoogleVertexProviderSettingsBase } from "@ai-sdk/google-vertex"; import type { LanguageModelV2, @@ -31,15 +32,29 @@ export interface GoogleServiceAccountCredentials { universe_domain?: string; } -export type GoogleVertexProviderSettings = Pick< - GoogleVertexProviderSettingsBase, - "project" | "location" | "headers" +export type GoogleVertexProviderSettings = Omit< + Partial< + Pick + >, + "headers" > & { + headers?: Record; googleAuthOptions?: { credentials?: GoogleServiceAccountCredentials; }; }; +export type BedrockProviderOptions = Partial< + Pick< + AmazonBedrockProviderSettingsBase, + "region" | "accessKeyId" | "secretAccessKey" | "sessionToken" + > +>; + +export type ProviderOptions = + | BedrockProviderOptions + | GoogleVertexProviderSettings; + export type AnthropicJsonSchemaObject = { definitions?: { MySchema?: { @@ -116,6 +131,12 @@ export type ClientOptions = ( apiKey?: string; provider?: AgentProviderType; baseURL?: string; + /** + * Provider-native auth/options for providers that do not fit the generic + * apiKey/baseURL shape. Stagehand normalizes these for local runs and + * serializes them to the hosted API wire format when env="BROWSERBASE". + */ + providerOptions?: ProviderOptions; /** OpenAI organization ID */ organization?: string; /** Delay between agent actions in ms */ diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index b59eb5c06..87d5fcd1a 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -84,6 +84,7 @@ import { V3Context } from "./understudy/context.js"; import { Page } from "./understudy/page.js"; import { resolveModel } from "../modelUtils.js"; import { StagehandAPIClient } from "./api.js"; +import { normalizeClientOptionsForModel } from "./modelProviderOptions.js"; import { validateExperimentalFeatures } from "./agent/utils/validateExperimentalFeatures.js"; import { flattenVariables } from "./agent/utils/variables.js"; import { FlowLogger, type FlowLoggerContext } from "./flowlogger/FlowLogger.js"; @@ -122,7 +123,10 @@ export function resolveModelConfiguration( } return { modelName, - clientOptions: clientOptions as ClientOptions, + clientOptions: normalizeClientOptionsForModel( + clientOptions as ClientOptions, + modelName, + ), middleware, }; } @@ -550,7 +554,10 @@ export class V3 { } else { const { modelName: overrideModelName, middleware, ...rest } = model; modelName = overrideModelName; - clientOptions = rest as ClientOptions; + clientOptions = normalizeClientOptionsForModel( + rest as ClientOptions, + overrideModelName, + ); perCallMiddleware = middleware; } @@ -1078,6 +1085,7 @@ export class V3 { const { sessionId, available } = await this.apiClient.init({ modelName: this.modelName, modelApiKey: this.modelClientOptions.apiKey, + modelClientOptions: this.modelClientOptions, domSettleTimeoutMs: this.domSettleTimeoutMs, verbose: this.verbose, systemPrompt: this.opts.systemPrompt, diff --git a/packages/core/tests/unit/api-client-model-config.test.ts b/packages/core/tests/unit/api-client-model-config.test.ts new file mode 100644 index 000000000..f0bfa8fcd --- /dev/null +++ b/packages/core/tests/unit/api-client-model-config.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest"; + +import { StagehandAPIClient } from "../../lib/v3/api.js"; + +describe("StagehandAPIClient model config handling", () => { + it("starts Bedrock sessions without x-model-api-key when providerOptions carry auth", async () => { + const client = new StagehandAPIClient({ + apiKey: "bb-api-key", + projectId: "bb-project-id", + logger: () => {}, + }); + const fetchWithCookies = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + available: true, + sessionId: "session-id", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + ( + client as unknown as { fetchWithCookies: typeof fetchWithCookies } + ).fetchWithCookies = fetchWithCookies; + + await client.init({ + modelName: "bedrock/us.amazon.nova-lite-v1:0", + modelClientOptions: { + providerOptions: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + }); + + expect(fetchWithCookies).toHaveBeenCalledTimes(1); + const [, requestInit] = fetchWithCookies.mock.calls[0] as [ + string, + RequestInit, + ]; + expect(requestInit.headers).not.toHaveProperty("x-model-api-key"); + expect(JSON.parse(String(requestInit.body))).toMatchObject({ + modelName: "bedrock/us.amazon.nova-lite-v1:0", + modelClientOptions: { + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + }, + }); + }); + + it("normalizes legacy Vertex settings into providerConfig on session start", async () => { + const client = new StagehandAPIClient({ + apiKey: "bb-api-key", + projectId: "bb-project-id", + logger: () => {}, + }); + const fetchWithCookies = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + available: true, + sessionId: "session-id", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + ( + client as unknown as { fetchWithCookies: typeof fetchWithCookies } + ).fetchWithCookies = fetchWithCookies; + + await client.init({ + modelName: "vertex/gemini-2.5-pro", + modelClientOptions: { + project: "test-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "test@example.com", + }, + }, + }, + }); + + const [, requestInit] = fetchWithCookies.mock.calls[0] as [ + string, + RequestInit, + ]; + expect(JSON.parse(String(requestInit.body))).toMatchObject({ + modelName: "vertex/gemini-2.5-pro", + modelClientOptions: { + providerConfig: { + provider: "vertex", + options: { + project: "test-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "test@example.com", + }, + }, + }, + }, + }, + }); + }); + + it("resends the session Bedrock model config on act calls without explicit model", async () => { + const client = new StagehandAPIClient({ + apiKey: "bb-api-key", + projectId: "bb-project-id", + logger: () => {}, + }); + const fetchWithCookies = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + available: true, + sessionId: "session-id", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + ( + client as unknown as { fetchWithCookies: typeof fetchWithCookies } + ).fetchWithCookies = fetchWithCookies; + + await client.init({ + modelName: "bedrock/us.amazon.nova-lite-v1:0", + modelClientOptions: { + providerOptions: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + }); + + const execute = vi.fn().mockResolvedValue({ + actions: [], + actionDescription: "noop", + message: "ok", + success: true, + }); + + (client as unknown as { execute: typeof execute }).execute = execute; + + await client.act({ input: "click the login button" }); + + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + method: "act", + args: { + input: "click the login button", + options: { + model: { + modelName: "bedrock/us.amazon.nova-lite-v1:0", + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + }, + }, + frameId: undefined, + }, + }), + ); + }); +}); diff --git a/packages/core/tests/unit/api-provider-config-schema.test.ts b/packages/core/tests/unit/api-provider-config-schema.test.ts new file mode 100644 index 000000000..c6335fca1 --- /dev/null +++ b/packages/core/tests/unit/api-provider-config-schema.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { Api } from "../../lib/v3/types/public/index.js"; + +const bedrockModelName = "bedrock/us.amazon.nova-lite-v1:0"; + +describe("API providerConfig schemas", () => { + it("rejects Bedrock session start payloads without a region", () => { + const result = Api.SessionStartRequestSchema.safeParse({ + modelName: bedrockModelName, + modelClientOptions: { + providerConfig: { + provider: "bedrock", + options: {}, + }, + }, + }); + + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain( + "Bedrock configs require providerConfig.options.region.", + ); + }); + + it("rejects Bedrock model configs with only one AWS credential", () => { + const result = Api.ActRequestSchema.safeParse({ + input: "click the submit button", + options: { + model: { + modelName: bedrockModelName, + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + accessKeyId: "AKIATEST", + }, + }, + }, + }, + }); + + expect(result.success).toBe(false); + const issues = JSON.stringify(result.error?.issues); + expect(issues).toContain("providerConfig"); + expect(issues).toContain("secretAccessKey"); + }); + + it("rejects mismatched providerConfig providers", () => { + const result = Api.ActRequestSchema.safeParse({ + input: "click the submit button", + options: { + model: { + modelName: "openai/gpt-4.1-mini", + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + }, + }, + }, + }, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe( + 'providerConfig.provider "bedrock" must match the model provider "openai"', + ); + }); +}); diff --git a/packages/core/tests/unit/model-provider-options.test.ts b/packages/core/tests/unit/model-provider-options.test.ts new file mode 100644 index 000000000..882473860 --- /dev/null +++ b/packages/core/tests/unit/model-provider-options.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeClientOptionsForModel, + toApiModelClientOptions, +} from "../../lib/v3/modelProviderOptions.js"; +import { StagehandInvalidArgumentError } from "../../lib/v3/types/public/sdkErrors.js"; + +describe("modelProviderOptions", () => { + it("promotes Bedrock providerOptions into runtime client options", () => { + const result = normalizeClientOptionsForModel( + { + providerOptions: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + "bedrock/us.amazon.nova-lite-v1:0", + ); + + expect(result).toMatchObject({ + providerOptions: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }); + }); + + it("serializes Bedrock providerOptions into providerConfig for hosted API", () => { + const result = toApiModelClientOptions( + { + providerOptions: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + "bedrock/us.amazon.nova-lite-v1:0", + ); + + expect(result).toEqual({ + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + }); + }); + + it("merges legacy Vertex settings with providerOptions", () => { + const result = normalizeClientOptionsForModel( + { + project: "legacy-project", + providerOptions: { + location: "us-central1", + headers: new Headers({ + "x-vertex-priority": "high", + }) as unknown as Record, + }, + }, + "vertex/gemini-2.5-pro", + ); + + expect(result).toMatchObject({ + project: "legacy-project", + location: "us-central1", + headers: { + "x-vertex-priority": "high", + }, + providerOptions: { + location: "us-central1", + }, + }); + }); + + it("serializes Vertex settings into providerConfig and strips top-level legacy fields", () => { + const result = toApiModelClientOptions( + { + project: "legacy-project", + location: "global", + headers: { + "x-top-level": "kept-in-provider-config", + }, + }, + "vertex/gemini-2.5-pro", + ); + + expect(result).toEqual({ + providerConfig: { + provider: "vertex", + options: { + project: "legacy-project", + location: "global", + headers: { + "x-top-level": "kept-in-provider-config", + }, + }, + }, + }); + }); + + it("rejects providerOptions for unsupported providers", () => { + expect(() => + toApiModelClientOptions( + { + providerOptions: { + region: "us-east-1", + }, + }, + "openai/gpt-4.1-mini", + ), + ).toThrow(StagehandInvalidArgumentError); + }); +}); diff --git a/packages/core/tests/unit/public-api/llm-and-agents.test.ts b/packages/core/tests/unit/public-api/llm-and-agents.test.ts index baf5f3665..3f09e0ff0 100644 --- a/packages/core/tests/unit/public-api/llm-and-agents.test.ts +++ b/packages/core/tests/unit/public-api/llm-and-agents.test.ts @@ -3,9 +3,22 @@ import * as Stagehand from "@browserbasehq/stagehand"; describe("LLM and Agents public API types", () => { describe("ModelConfiguration", () => { - it("accepts Vertex headers in model config", () => { - const googleConfig = { - modelName: "google/gemini-3-flash-preview", + it("accepts providerOptions for Bedrock model config", () => { + const bedrockConfig = { + modelName: "bedrock/us.amazon.nova-lite-v1:0", + providerOptions: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + } satisfies Stagehand.ModelConfiguration; + + void bedrockConfig; + }); + + it("accepts legacy top-level Vertex settings in model config", () => { + const vertexConfig = { + modelName: "vertex/gemini-2.5-pro", project: "test-project", location: "global", headers: { @@ -13,7 +26,19 @@ describe("LLM and Agents public API types", () => { }, } satisfies Stagehand.ModelConfiguration; - void googleConfig; + void vertexConfig; + }); + + it("accepts providerOptions for Vertex model config", () => { + const vertexConfig = { + modelName: "vertex/gemini-2.5-pro", + providerOptions: { + project: "test-project", + location: "global", + }, + } satisfies Stagehand.ModelConfiguration; + + void vertexConfig; }); }); From 7a1d83e6c8b4c8e9f0906049bd0013a6724d87da Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 23 Apr 2026 17:48:50 -0700 Subject: [PATCH 2/7] fix: tighten hosted provider option allowlists --- packages/core/lib/v3/modelProviderOptions.ts | 161 +++++++++- packages/core/lib/v3/types/public/api.ts | 289 ++++++++++++++++++ packages/core/lib/v3/types/public/model.ts | 3 + .../unit/api-provider-config-schema.test.ts | 50 +++ .../tests/unit/model-provider-options.test.ts | 69 +++++ 5 files changed, 560 insertions(+), 12 deletions(-) diff --git a/packages/core/lib/v3/modelProviderOptions.ts b/packages/core/lib/v3/modelProviderOptions.ts index 60bd13f20..04d4b1bba 100644 --- a/packages/core/lib/v3/modelProviderOptions.ts +++ b/packages/core/lib/v3/modelProviderOptions.ts @@ -9,6 +9,17 @@ import type { Api } from "./types/public/index.js"; type VertexCompatibleClientOptions = ClientOptions & Partial; +type SerializableGoogleServiceAccountCredentials = NonNullable< + NonNullable["credentials"] +>; + +type SerializableGoogleAuthOptions = { + credentials?: SerializableGoogleServiceAccountCredentials; + scopes?: string | string[]; + projectId?: string; + universeDomain?: string; +}; + function hasValue(value: T | undefined | null): value is T { return value !== undefined && value !== null; } @@ -17,6 +28,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function getRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + function toSerializableHeaders( headers: unknown, ): Record | undefined { @@ -35,6 +50,89 @@ function toSerializableHeaders( return headers as Record; } +function withStringProperty( + target: Record, + source: Record | undefined, + key: T, +) { + const value = source?.[key]; + if (typeof value === "string") { + target[key] = value; + } +} + +function toSerializableGoogleServiceAccountCredentials( + value: unknown, +): SerializableGoogleServiceAccountCredentials | undefined { + if (!isRecord(value)) { + return undefined; + } + + const credentials: Record = {}; + for (const key of [ + "type", + "project_id", + "private_key_id", + "private_key", + "client_email", + "client_id", + "auth_uri", + "token_uri", + "auth_provider_x509_cert_url", + "client_x509_cert_url", + "universe_domain", + ] as const) { + withStringProperty(credentials, value, key); + } + + return Object.keys(credentials).length > 0 + ? (credentials as SerializableGoogleServiceAccountCredentials) + : undefined; +} + +function toSerializableGoogleAuthOptions( + value: unknown, +): SerializableGoogleAuthOptions | undefined { + if (!isRecord(value)) { + return undefined; + } + + const credentials = toSerializableGoogleServiceAccountCredentials( + value.credentials, + ); + const scopes = + typeof value.scopes === "string" + ? value.scopes + : Array.isArray(value.scopes) && + value.scopes.every((item) => typeof item === "string") + ? value.scopes + : undefined; + const projectId = + typeof value.projectId === "string" ? value.projectId : undefined; + const universeDomain = + typeof value.universeDomain === "string" + ? value.universeDomain + : undefined; + + const googleAuthOptions: SerializableGoogleAuthOptions = {}; + if (credentials) { + googleAuthOptions.credentials = credentials; + } + if (scopes) { + googleAuthOptions.scopes = scopes; + } + if (projectId) { + googleAuthOptions.projectId = projectId; + } + if (universeDomain) { + googleAuthOptions.universeDomain = universeDomain; + } + + return Object.keys(googleAuthOptions).length > 0 + ? googleAuthOptions + : undefined; +} + export function getProviderFromModelName( modelName?: string, ): string | undefined { @@ -59,8 +157,11 @@ function getLegacyVertexOptions( if (hasValue(vertexOptions.location)) { legacyVertexOptions.location = vertexOptions.location; } - if (hasValue(vertexOptions.googleAuthOptions)) { - legacyVertexOptions.googleAuthOptions = vertexOptions.googleAuthOptions; + const googleAuthOptions = toSerializableGoogleAuthOptions( + vertexOptions.googleAuthOptions, + ); + if (googleAuthOptions) { + legacyVertexOptions.googleAuthOptions = googleAuthOptions; } const headers = toSerializableHeaders(options.headers); @@ -82,31 +183,67 @@ function getNormalizedVertexProviderOptions( const legacyVertexOptions = getLegacyVertexOptions(options); const rawProviderOptions = options.providerOptions; - const providerOptions = isRecord(rawProviderOptions) - ? (rawProviderOptions as GoogleVertexProviderSettings) - : undefined; + const providerOptions = getRecord(rawProviderOptions); if (!legacyVertexOptions && !providerOptions) { return undefined; } - const mergedHeaders = toSerializableHeaders(providerOptions?.headers); - - return { + const normalizedVertexOptions: GoogleVertexProviderSettings = { ...(legacyVertexOptions ?? {}), - ...(providerOptions ?? {}), - ...(mergedHeaders ? { headers: mergedHeaders } : {}), }; + + const providerProject = providerOptions?.project; + if (typeof providerProject === "string") { + normalizedVertexOptions.project = providerProject; + } + + const providerLocation = providerOptions?.location; + if (typeof providerLocation === "string") { + normalizedVertexOptions.location = providerLocation; + } + + const providerGoogleAuthOptions = toSerializableGoogleAuthOptions( + providerOptions?.googleAuthOptions, + ); + if (providerGoogleAuthOptions) { + normalizedVertexOptions.googleAuthOptions = providerGoogleAuthOptions; + } + + const providerHeaders = toSerializableHeaders(providerOptions?.headers); + if (providerHeaders) { + normalizedVertexOptions.headers = providerHeaders; + } + + return Object.keys(normalizedVertexOptions).length > 0 + ? normalizedVertexOptions + : undefined; } function getBedrockProviderOptions( options?: ClientOptions, ): BedrockProviderOptions | undefined { - if (!options || !isRecord(options.providerOptions)) { + const rawProviderOptions = getRecord(options?.providerOptions); + if (!options || !rawProviderOptions) { return undefined; } + const bedrockProviderOptions: BedrockProviderOptions = {}; + + for (const key of [ + "region", + "accessKeyId", + "secretAccessKey", + "sessionToken", + ] as const) { + const value = rawProviderOptions[key]; + if (typeof value === "string") { + bedrockProviderOptions[key] = value; + } + } - return options.providerOptions as BedrockProviderOptions; + return Object.keys(bedrockProviderOptions).length > 0 + ? bedrockProviderOptions + : undefined; } function getProviderConfig( diff --git a/packages/core/lib/v3/types/public/api.ts b/packages/core/lib/v3/types/public/api.ts index cbdbd92ef..d928274c3 100644 --- a/packages/core/lib/v3/types/public/api.ts +++ b/packages/core/lib/v3/types/public/api.ts @@ -70,6 +70,114 @@ export const ProviderConfigSchema = z .passthrough() .meta({ id: "ProviderConfig" }); +const BEDROCK_ALLOWED_PROVIDER_OPTION_KEYS = new Set([ + "region", + "apiKey", + "accessKeyId", + "secretAccessKey", + "sessionToken", + "baseURL", + "headers", +]); + +const VERTEX_ALLOWED_PROVIDER_OPTION_KEYS = new Set([ + "project", + "location", + "baseURL", + "headers", + "googleAuthOptions", +]); + +const VERTEX_GOOGLE_AUTH_ALLOWED_KEYS = new Set([ + "credentials", + "scopes", + "projectId", + "universeDomain", +]); + +const GOOGLE_SERVICE_ACCOUNT_ALLOWED_KEYS = new Set([ + "type", + "project_id", + "private_key_id", + "private_key", + "client_email", + "client_id", + "auth_uri", + "token_uri", + "auth_provider_x509_cert_url", + "client_x509_cert_url", + "universe_domain", +]); + +function getRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function getStringRecord( + value: unknown, +): Record | undefined { + const record = getRecord(value); + if (!record) { + return undefined; + } + + if (Object.values(record).some((item) => typeof item !== "string")) { + return undefined; + } + + return record as Record; +} + +function addUnsupportedOptionIssues({ + providerName, + options, + allowedKeys, + ctx, + pathPrefix, +}: { + providerName: string; + options: Record | undefined; + allowedKeys: Set; + ctx: z.RefinementCtx; + pathPrefix: (string | number)[]; +}) { + if (!options) { + return; + } + + for (const key of Object.keys(options)) { + if (allowedKeys.has(key)) { + continue; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${providerName} configs do not support ${[ + ...pathPrefix, + key, + ].join(".")}.`, + path: [...pathPrefix, key], + }); + } +} + +function addExpectedStringIssue( + value: unknown, + path: (string | number)[], + message: string, + ctx: z.RefinementCtx, +) { + if (value !== undefined && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path, + }); + } +} + function getProviderFromModelName(modelName?: string): string | undefined { return typeof modelName === "string" && modelName.includes("/") ? modelName.split("/", 1)[0] @@ -167,6 +275,181 @@ function addBedrockAuthIssues( } } +function addBedrockValidationIssues( + providerOptions: Record | undefined, + ctx: z.RefinementCtx, +) { + addUnsupportedOptionIssues({ + providerName: "Bedrock", + options: providerOptions, + allowedKeys: BEDROCK_ALLOWED_PROVIDER_OPTION_KEYS, + ctx, + pathPrefix: ["providerConfig", "options"], + }); + + const headers = providerOptions?.headers; + if (headers !== undefined && !getStringRecord(headers)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Bedrock providerConfig.options.headers must be a string-to-string record.", + path: ["providerConfig", "options", "headers"], + }); + } + + addExpectedStringIssue( + providerOptions?.apiKey, + ["providerConfig", "options", "apiKey"], + "Bedrock providerConfig.options.apiKey must be a string.", + ctx, + ); + addExpectedStringIssue( + providerOptions?.baseURL, + ["providerConfig", "options", "baseURL"], + "Bedrock providerConfig.options.baseURL must be a string.", + ctx, + ); +} + +function addVertexValidationIssues( + providerOptions: Record | undefined, + ctx: z.RefinementCtx, +) { + addUnsupportedOptionIssues({ + providerName: "Vertex", + options: providerOptions, + allowedKeys: VERTEX_ALLOWED_PROVIDER_OPTION_KEYS, + ctx, + pathPrefix: ["providerConfig", "options"], + }); + + addExpectedStringIssue( + providerOptions?.project, + ["providerConfig", "options", "project"], + "Vertex providerConfig.options.project must be a string.", + ctx, + ); + addExpectedStringIssue( + providerOptions?.location, + ["providerConfig", "options", "location"], + "Vertex providerConfig.options.location must be a string.", + ctx, + ); + addExpectedStringIssue( + providerOptions?.baseURL, + ["providerConfig", "options", "baseURL"], + "Vertex providerConfig.options.baseURL must be a string.", + ctx, + ); + + const headers = providerOptions?.headers; + if (headers !== undefined && !getStringRecord(headers)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Vertex providerConfig.options.headers must be a string-to-string record.", + path: ["providerConfig", "options", "headers"], + }); + } + + const googleAuthOptions = providerOptions?.googleAuthOptions; + if (googleAuthOptions === undefined) { + return; + } + + const googleAuthRecord = getRecord(googleAuthOptions); + if (!googleAuthRecord) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Vertex providerConfig.options.googleAuthOptions must be an object.", + path: ["providerConfig", "options", "googleAuthOptions"], + }); + return; + } + + addUnsupportedOptionIssues({ + providerName: "Vertex", + options: googleAuthRecord, + allowedKeys: VERTEX_GOOGLE_AUTH_ALLOWED_KEYS, + ctx, + pathPrefix: ["providerConfig", "options", "googleAuthOptions"], + }); + + const credentials = googleAuthRecord.credentials; + if (credentials !== undefined) { + const credentialRecord = getRecord(credentials); + if (!credentialRecord) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Vertex providerConfig.options.googleAuthOptions.credentials must be an object.", + path: ["providerConfig", "options", "googleAuthOptions", "credentials"], + }); + } else { + addUnsupportedOptionIssues({ + providerName: "Vertex", + options: credentialRecord, + allowedKeys: GOOGLE_SERVICE_ACCOUNT_ALLOWED_KEYS, + ctx, + pathPrefix: [ + "providerConfig", + "options", + "googleAuthOptions", + "credentials", + ], + }); + + for (const [key, value] of Object.entries(credentialRecord)) { + if ( + GOOGLE_SERVICE_ACCOUNT_ALLOWED_KEYS.has(key) && + value !== undefined && + typeof value !== "string" + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Vertex providerConfig.options.googleAuthOptions.credentials.${key} must be a string.`, + path: [ + "providerConfig", + "options", + "googleAuthOptions", + "credentials", + key, + ], + }); + } + } + } + } + + const scopes = googleAuthRecord.scopes; + if ( + scopes !== undefined && + typeof scopes !== "string" && + !(Array.isArray(scopes) && scopes.every((item) => typeof item === "string")) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Vertex providerConfig.options.googleAuthOptions.scopes must be a string or string array.", + path: ["providerConfig", "options", "googleAuthOptions", "scopes"], + }); + } + + addExpectedStringIssue( + googleAuthRecord.projectId, + ["providerConfig", "options", "googleAuthOptions", "projectId"], + "Vertex providerConfig.options.googleAuthOptions.projectId must be a string.", + ctx, + ); + addExpectedStringIssue( + googleAuthRecord.universeDomain, + ["providerConfig", "options", "googleAuthOptions", "universeDomain"], + "Vertex providerConfig.options.googleAuthOptions.universeDomain must be a string.", + ctx, + ); +} + const modelConfigSharedShape = { provider: z .enum(["openai", "anthropic", "google", "microsoft", "bedrock", "vertex"]) @@ -217,9 +500,15 @@ function validateProviderConfig( typeof value.providerConfig?.provider === "string" ? value.providerConfig.provider : undefined; + const providerOptions = getRecord(value.providerConfig?.options); if (provider === "bedrock" || modelProvider === "bedrock") { addBedrockAuthIssues(value.providerConfig, ctx); + addBedrockValidationIssues(providerOptions, ctx); + } + + if (provider === "vertex" || modelProvider === "vertex") { + addVertexValidationIssues(providerOptions, ctx); } } diff --git a/packages/core/lib/v3/types/public/model.ts b/packages/core/lib/v3/types/public/model.ts index 7a35e479e..640e17e46 100644 --- a/packages/core/lib/v3/types/public/model.ts +++ b/packages/core/lib/v3/types/public/model.ts @@ -41,6 +41,9 @@ export type GoogleVertexProviderSettings = Omit< headers?: Record; googleAuthOptions?: { credentials?: GoogleServiceAccountCredentials; + scopes?: string | string[]; + projectId?: string; + universeDomain?: string; }; }; diff --git a/packages/core/tests/unit/api-provider-config-schema.test.ts b/packages/core/tests/unit/api-provider-config-schema.test.ts index c6335fca1..906937161 100644 --- a/packages/core/tests/unit/api-provider-config-schema.test.ts +++ b/packages/core/tests/unit/api-provider-config-schema.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { Api } from "../../lib/v3/types/public/index.js"; const bedrockModelName = "bedrock/us.amazon.nova-lite-v1:0"; +const vertexModelName = "vertex/gemini-2.5-pro"; describe("API providerConfig schemas", () => { it("rejects Bedrock session start payloads without a region", () => { @@ -66,4 +67,53 @@ describe("API providerConfig schemas", () => { 'providerConfig.provider "bedrock" must match the model provider "openai"', ); }); + + it("rejects unsupported Bedrock providerConfig options", () => { + const result = Api.SessionStartRequestSchema.safeParse({ + modelName: bedrockModelName, + modelClientOptions: { + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + fetch: "nope", + }, + }, + }, + }); + + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain( + "Bedrock configs do not support providerConfig.options.fetch.", + ); + }); + + it("rejects unsupported Vertex googleAuthOptions fields", () => { + const result = Api.SessionStartRequestSchema.safeParse({ + modelName: vertexModelName, + modelClientOptions: { + providerConfig: { + provider: "vertex", + options: { + project: "vertex-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "vertex@example.com", + private_key: "private-key", + }, + authClient: { + nope: true, + }, + }, + }, + }, + }, + }); + + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain( + "Vertex configs do not support providerConfig.options.googleAuthOptions.authClient.", + ); + }); }); diff --git a/packages/core/tests/unit/model-provider-options.test.ts b/packages/core/tests/unit/model-provider-options.test.ts index 882473860..09b3196f2 100644 --- a/packages/core/tests/unit/model-provider-options.test.ts +++ b/packages/core/tests/unit/model-provider-options.test.ts @@ -55,6 +55,32 @@ describe("modelProviderOptions", () => { }); }); + it("drops unsupported Bedrock providerOptions fields from hosted API payloads", () => { + const result = toApiModelClientOptions( + { + providerOptions: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + fetch: "should-not-pass-through", + credentialProvider: "also-ignored", + } as unknown as Record, + }, + "bedrock/us.amazon.nova-lite-v1:0", + ); + + expect(result).toEqual({ + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + accessKeyId: "AKIATEST", + secretAccessKey: "secret-test", + }, + }, + }); + }); + it("merges legacy Vertex settings with providerOptions", () => { const result = normalizeClientOptionsForModel( { @@ -107,6 +133,49 @@ describe("modelProviderOptions", () => { }); }); + it("keeps only serializable hosted-safe Vertex auth options", () => { + const result = toApiModelClientOptions( + { + providerOptions: { + project: "vertex-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "vertex@example.com", + private_key: "private-key", + }, + scopes: ["scope-a", "scope-b"], + projectId: "override-project", + universeDomain: "googleapis.com", + authClient: { nope: true }, + keyFilename: "/tmp/should-not-pass.json", + }, + fetch: "should-not-pass-through", + } as unknown as Record, + }, + "vertex/gemini-2.5-pro", + ); + + expect(result).toEqual({ + providerConfig: { + provider: "vertex", + options: { + project: "vertex-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "vertex@example.com", + private_key: "private-key", + }, + scopes: ["scope-a", "scope-b"], + projectId: "override-project", + universeDomain: "googleapis.com", + }, + }, + }, + }); + }); + it("rejects providerOptions for unsupported providers", () => { expect(() => toApiModelClientOptions( From 0476275106a8dff298fc72be2e824e6e0bcbd67a Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 23 Apr 2026 18:04:23 -0700 Subject: [PATCH 3/7] fix: resend hosted provider config on navigate --- packages/core/lib/v3/api.ts | 29 +++- packages/core/lib/v3/llm/LLMProvider.ts | 32 ++++- packages/core/lib/v3/modelProviderOptions.ts | 31 +++++ packages/core/lib/v3/types/public/api.ts | 1 + packages/core/lib/v3/types/public/model.ts | 28 +++- .../unit/api-client-model-config.test.ts | 128 ++++++++++++++++++ .../unit/llm-provider-hosted-vertex.test.ts | 39 ++++++ .../tests/unit/model-provider-options.test.ts | 12 ++ 8 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 packages/core/tests/unit/llm-provider-hosted-vertex.test.ts diff --git a/packages/core/lib/v3/api.ts b/packages/core/lib/v3/api.ts index 591a878f4..87bb70ab5 100644 --- a/packages/core/lib/v3/api.ts +++ b/packages/core/lib/v3/api.ts @@ -212,6 +212,22 @@ export class StagehandAPIClient { this.fetchWithCookies = makeFetchCookie(fetch); } + private shouldSendModelApiKeyHeader( + modelClientOptions?: Api.ModelClientOptions, + ): boolean { + const providerConfig = + modelClientOptions?.providerConfig && + typeof modelClientOptions.providerConfig === "object" && + !Array.isArray(modelClientOptions.providerConfig) + ? modelClientOptions.providerConfig + : undefined; + + return ( + providerConfig?.provider !== "bedrock" && + providerConfig?.provider !== "vertex" + ); + } + async init({ modelName, modelApiKey, @@ -224,12 +240,15 @@ export class StagehandAPIClient { browserbaseSessionID, // browser, TODO for local browsers }: ClientSessionStartParams): Promise { - this.modelApiKey = modelApiKey; - const serializedModelClientOptions = this.toSessionStartModelClientOptions( modelClientOptions, modelName, ); + this.modelApiKey = this.shouldSendModelApiKeyHeader( + serializedModelClientOptions, + ) + ? modelApiKey + : undefined; if ( modelName && serializedModelClientOptions && @@ -419,7 +438,11 @@ export class StagehandAPIClient { options?: Api.NavigateRequest["options"], frameId?: string, ): Promise { - const requestBody: Api.NavigateRequest = { url, options, frameId }; + const requestBody: Api.NavigateRequest = { + url, + options: this.ensureModelConfig(options), + frameId, + }; return this.execute({ method: "navigate", diff --git a/packages/core/lib/v3/llm/LLMProvider.ts b/packages/core/lib/v3/llm/LLMProvider.ts index 0056e2d70..82f11804c 100644 --- a/packages/core/lib/v3/llm/LLMProvider.ts +++ b/packages/core/lib/v3/llm/LLMProvider.ts @@ -9,6 +9,7 @@ import { LogLine } from "../types/public/logs.js"; import { AvailableModel, ClientOptions, + GoogleVertexProviderSettings, ModelProvider, } from "../types/public/model.js"; import { AISdkClient } from "./aisdk.js"; @@ -100,6 +101,34 @@ const modelToProviderMap: { [key in AvailableModel]: ModelProvider } = { "gemini-2.5-pro-preview-03-25": "google", }; +function isStringRecord( + value: unknown, +): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((item) => typeof item === "string") + ); +} + +function hasHostedVertexClientOptions( + clientOptions?: ClientOptions, +): boolean { + const vertexOptions = + clientOptions as Partial | undefined; + return Boolean( + vertexOptions && + (typeof vertexOptions.project === "string" || + typeof vertexOptions.location === "string" || + typeof vertexOptions.baseURL === "string" || + isStringRecord(vertexOptions.headers) || + (typeof vertexOptions.googleAuthOptions === "object" && + vertexOptions.googleAuthOptions !== null && + Object.keys(vertexOptions.googleAuthOptions).length > 0)), + ); +} + export function getAISDKLanguageModel( subProvider: string, subModelName: string, @@ -166,7 +195,8 @@ export class LLMProvider { if ( subProvider === "vertex" && !options?.disableAPI && - !options?.experimental + !options?.experimental && + !hasHostedVertexClientOptions(clientOptions) ) { throw new ExperimentalNotConfiguredError("Vertex provider"); } diff --git a/packages/core/lib/v3/modelProviderOptions.ts b/packages/core/lib/v3/modelProviderOptions.ts index 04d4b1bba..22077d103 100644 --- a/packages/core/lib/v3/modelProviderOptions.ts +++ b/packages/core/lib/v3/modelProviderOptions.ts @@ -157,6 +157,9 @@ function getLegacyVertexOptions( if (hasValue(vertexOptions.location)) { legacyVertexOptions.location = vertexOptions.location; } + if (hasValue(vertexOptions.baseURL)) { + legacyVertexOptions.baseURL = vertexOptions.baseURL; + } const googleAuthOptions = toSerializableGoogleAuthOptions( vertexOptions.googleAuthOptions, ); @@ -203,6 +206,11 @@ function getNormalizedVertexProviderOptions( normalizedVertexOptions.location = providerLocation; } + const providerBaseURL = providerOptions?.baseURL; + if (typeof providerBaseURL === "string") { + normalizedVertexOptions.baseURL = providerBaseURL; + } + const providerGoogleAuthOptions = toSerializableGoogleAuthOptions( providerOptions?.googleAuthOptions, ); @@ -234,6 +242,8 @@ function getBedrockProviderOptions( "accessKeyId", "secretAccessKey", "sessionToken", + "apiKey", + "baseURL", ] as const) { const value = rawProviderOptions[key]; if (typeof value === "string") { @@ -241,6 +251,11 @@ function getBedrockProviderOptions( } } + const headers = toSerializableHeaders(rawProviderOptions.headers); + if (headers) { + bedrockProviderOptions.headers = headers; + } + return Object.keys(bedrockProviderOptions).length > 0 ? bedrockProviderOptions : undefined; @@ -330,6 +345,15 @@ export function normalizeClientOptionsForModel( if (normalizedOptions.sessionToken === undefined) { normalizedOptions.sessionToken = bedrockOptions.sessionToken; } + if (normalizedOptions.apiKey === undefined) { + normalizedOptions.apiKey = bedrockOptions.apiKey; + } + if (normalizedOptions.baseURL === undefined) { + normalizedOptions.baseURL = bedrockOptions.baseURL; + } + if (normalizedOptions.headers === undefined) { + normalizedOptions.headers = bedrockOptions.headers; + } } } @@ -350,6 +374,9 @@ export function normalizeClientOptionsForModel( if (vertexOptions.location !== undefined) { normalizedVertexOptions.location = vertexOptions.location; } + if (vertexOptions.baseURL !== undefined) { + normalizedVertexOptions.baseURL = vertexOptions.baseURL; + } if (vertexOptions.googleAuthOptions !== undefined) { normalizedVertexOptions.googleAuthOptions = vertexOptions.googleAuthOptions; @@ -389,11 +416,15 @@ export function toApiModelClientOptions( delete requestOptions.accessKeyId; delete requestOptions.secretAccessKey; delete requestOptions.sessionToken; + delete requestOptions.apiKey; + delete requestOptions.baseURL; + delete requestOptions.headers; } if (providerConfig?.provider === "vertex") { delete requestOptions.project; delete requestOptions.location; + delete requestOptions.baseURL; delete requestOptions.googleAuthOptions; delete requestOptions.headers; } diff --git a/packages/core/lib/v3/types/public/api.ts b/packages/core/lib/v3/types/public/api.ts index d928274c3..2bbe8ff37 100644 --- a/packages/core/lib/v3/types/public/api.ts +++ b/packages/core/lib/v3/types/public/api.ts @@ -1237,6 +1237,7 @@ export const AgentExecuteResponseSchema = wrapResponse( export const NavigateOptionsSchema = z .object({ + model: ModelConfigSchema.optional(), referer: z.string().optional().meta({ description: "Referer header to send with the request", }), diff --git a/packages/core/lib/v3/types/public/model.ts b/packages/core/lib/v3/types/public/model.ts index 640e17e46..60d958be3 100644 --- a/packages/core/lib/v3/types/public/model.ts +++ b/packages/core/lib/v3/types/public/model.ts @@ -34,7 +34,10 @@ export interface GoogleServiceAccountCredentials { export type GoogleVertexProviderSettings = Omit< Partial< - Pick + Pick< + GoogleVertexProviderSettingsBase, + "project" | "location" | "baseURL" | "headers" + > >, "headers" > & { @@ -47,12 +50,23 @@ export type GoogleVertexProviderSettings = Omit< }; }; -export type BedrockProviderOptions = Partial< - Pick< - AmazonBedrockProviderSettingsBase, - "region" | "accessKeyId" | "secretAccessKey" | "sessionToken" - > ->; +export type BedrockProviderOptions = Omit< + Partial< + Pick< + AmazonBedrockProviderSettingsBase, + | "region" + | "accessKeyId" + | "secretAccessKey" + | "sessionToken" + | "apiKey" + | "baseURL" + | "headers" + > + >, + "headers" +> & { + headers?: Record; +}; export type ProviderOptions = | BedrockProviderOptions diff --git a/packages/core/tests/unit/api-client-model-config.test.ts b/packages/core/tests/unit/api-client-model-config.test.ts index f0bfa8fcd..2df20236c 100644 --- a/packages/core/tests/unit/api-client-model-config.test.ts +++ b/packages/core/tests/unit/api-client-model-config.test.ts @@ -58,6 +58,59 @@ describe("StagehandAPIClient model config handling", () => { }); }); + it("keeps Bedrock bearer tokens in providerConfig instead of x-model-api-key", async () => { + const client = new StagehandAPIClient({ + apiKey: "bb-api-key", + projectId: "bb-project-id", + logger: () => {}, + }); + const fetchWithCookies = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + available: true, + sessionId: "session-id", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + ( + client as unknown as { fetchWithCookies: typeof fetchWithCookies } + ).fetchWithCookies = fetchWithCookies; + + await client.init({ + modelName: "bedrock/us.amazon.nova-lite-v1:0", + modelApiKey: "bedrock-bearer-token", + modelClientOptions: { + providerOptions: { + region: "us-east-1", + apiKey: "bedrock-bearer-token", + }, + }, + }); + + const [, requestInit] = fetchWithCookies.mock.calls[0] as [ + string, + RequestInit, + ]; + expect(requestInit.headers).not.toHaveProperty("x-model-api-key"); + expect(JSON.parse(String(requestInit.body))).toMatchObject({ + modelName: "bedrock/us.amazon.nova-lite-v1:0", + modelClientOptions: { + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + apiKey: "bedrock-bearer-token", + }, + }, + }, + }); + }); + it("normalizes legacy Vertex settings into providerConfig on session start", async () => { const client = new StagehandAPIClient({ apiKey: "bb-api-key", @@ -185,4 +238,79 @@ describe("StagehandAPIClient model config handling", () => { }), ); }); + + it("resends the session Vertex model config on navigate calls", async () => { + const client = new StagehandAPIClient({ + apiKey: "bb-api-key", + projectId: "bb-project-id", + logger: () => {}, + }); + const fetchWithCookies = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + available: true, + sessionId: "session-id", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + ( + client as unknown as { fetchWithCookies: typeof fetchWithCookies } + ).fetchWithCookies = fetchWithCookies; + + await client.init({ + modelName: "vertex/gemini-2.5-pro", + modelClientOptions: { + providerOptions: { + project: "vertex-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "vertex@example.com", + private_key: "private-key", + }, + }, + }, + }, + }); + + const execute = vi.fn().mockResolvedValue(null); + + (client as unknown as { execute: typeof execute }).execute = execute; + + await client.goto("https://example.com", { + waitUntil: "domcontentloaded", + }); + + expect(execute).toHaveBeenCalledWith({ + method: "navigate", + args: { + url: "https://example.com", + options: { + waitUntil: "domcontentloaded", + model: { + modelName: "vertex/gemini-2.5-pro", + providerConfig: { + provider: "vertex", + options: { + project: "vertex-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "vertex@example.com", + private_key: "private-key", + }, + }, + }, + }, + }, + }, + frameId: undefined, + }, + }); + }); }); diff --git a/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts b/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts new file mode 100644 index 000000000..29649e8d8 --- /dev/null +++ b/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { LLMProvider } from "../../lib/v3/llm/LLMProvider.js"; +import { ExperimentalNotConfiguredError } from "../../lib/v3/types/public/sdkErrors.js"; + +describe("LLMProvider hosted vertex gating", () => { + it("allows hosted Vertex configs when API mode is enabled", () => { + const llmProvider = new LLMProvider(() => {}); + + expect(() => + llmProvider.getClient( + "vertex/gemini-2.5-pro", + { + project: "vertex-project", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "vertex@example.com", + private_key: "private-key", + }, + }, + }, + { disableAPI: false, experimental: false }, + ), + ).not.toThrow(); + }); + + it("keeps requiring experimental mode for bare Vertex configs in API mode", () => { + const llmProvider = new LLMProvider(() => {}); + + expect(() => + llmProvider.getClient( + "vertex/gemini-2.5-pro", + undefined, + { disableAPI: false, experimental: false }, + ), + ).toThrow(ExperimentalNotConfiguredError); + }); +}); diff --git a/packages/core/tests/unit/model-provider-options.test.ts b/packages/core/tests/unit/model-provider-options.test.ts index 09b3196f2..af77d462d 100644 --- a/packages/core/tests/unit/model-provider-options.test.ts +++ b/packages/core/tests/unit/model-provider-options.test.ts @@ -62,6 +62,11 @@ describe("modelProviderOptions", () => { region: "us-east-1", accessKeyId: "AKIATEST", secretAccessKey: "secret-test", + apiKey: "bedrock-bearer-token", + baseURL: "https://bedrock.example.com", + headers: { + "x-bedrock-header": "present", + }, fetch: "should-not-pass-through", credentialProvider: "also-ignored", } as unknown as Record, @@ -76,6 +81,11 @@ describe("modelProviderOptions", () => { region: "us-east-1", accessKeyId: "AKIATEST", secretAccessKey: "secret-test", + apiKey: "bedrock-bearer-token", + baseURL: "https://bedrock.example.com", + headers: { + "x-bedrock-header": "present", + }, }, }, }); @@ -139,6 +149,7 @@ describe("modelProviderOptions", () => { providerOptions: { project: "vertex-project", location: "us-central1", + baseURL: "https://vertex.example.com", googleAuthOptions: { credentials: { client_email: "vertex@example.com", @@ -162,6 +173,7 @@ describe("modelProviderOptions", () => { options: { project: "vertex-project", location: "us-central1", + baseURL: "https://vertex.example.com", googleAuthOptions: { credentials: { client_email: "vertex@example.com", From 437f97fd63e070a10e9942b4a21d169228cf7379 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 23 Apr 2026 18:58:24 -0700 Subject: [PATCH 4/7] docs: document hosted providerOptions --- packages/docs/v3/configuration/models.mdx | 71 ++++++++++++++++------- packages/docs/v3/references/stagehand.mdx | 11 +++- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/packages/docs/v3/configuration/models.mdx b/packages/docs/v3/configuration/models.mdx index 0ad7f4da3..f8e38e259 100644 --- a/packages/docs/v3/configuration/models.mdx +++ b/packages/docs/v3/configuration/models.mdx @@ -141,26 +141,22 @@ await stagehand.init(); - - -Google Vertex requires `experimental: true` in the Stagehand constructor. - - ```typescript TypeScript import { Stagehand } from "@browserbasehq/stagehand"; const stagehand = new Stagehand({ env: "BROWSERBASE", - experimental: true, // required for Vertex model: { modelName: "vertex/gemini-3-flash-preview", - project: "your-gcp-project-id", - location: "us-central1", - googleAuthOptions: { - credentials: { - client_email: "your-sa@project.iam.gserviceaccount.com", - private_key: process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY, + providerOptions: { + project: "your-gcp-project-id", + location: "us-central1", + googleAuthOptions: { + credentials: { + client_email: "your-sa@project.iam.gserviceaccount.com", + private_key: process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY, + }, }, }, }, @@ -173,13 +169,47 @@ await stagehand.init(); The `model` object accepts: - `modelName` — The Vertex model, prefixed with `vertex/` (e.g. `vertex/gemini-3-flash-preview`) -- `project` — Your GCP project ID -- `location` — Your Vertex AI region (e.g. `us-central1`) -- `googleAuthOptions.credentials` — Service account credentials with `client_email` and `private_key` +- `providerOptions.project` — Your GCP project ID +- `providerOptions.location` — Your Vertex AI region (e.g. `us-central1`) +- `providerOptions.googleAuthOptions.credentials` — Service account credentials with `client_email` and `private_key` +- `providerOptions.baseURL`, `providerOptions.headers`, and `providerOptions.googleAuthOptions.{scopes, projectId, universeDomain}` — Optional advanced overrides [View all supported Vertex AI models →](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models) + + +```typescript TypeScript +import { Stagehand } from "@browserbasehq/stagehand"; + +const stagehand = new Stagehand({ + env: "BROWSERBASE", + model: { + modelName: "bedrock/us.amazon.nova-lite-v1:0", + providerOptions: { + region: "us-east-1", + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + sessionToken: process.env.AWS_SESSION_TOKEN, // optional + }, + }, +}); + +await stagehand.init(); +``` + + + +The `model` object accepts: +- `modelName` — The Bedrock model, prefixed with `bedrock/` (e.g. `bedrock/us.amazon.nova-lite-v1:0`) +- `providerOptions.region` — The AWS region for the Bedrock runtime +- `providerOptions.accessKeyId` and `providerOptions.secretAccessKey` — AWS credentials for SigV4 auth +- `providerOptions.sessionToken` — Optional temporary session token +- `providerOptions.apiKey`, `providerOptions.baseURL`, and `providerOptions.headers` — Optional bearer-token or endpoint overrides + +[View all supported Bedrock models →](https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html) + + @@ -396,7 +426,7 @@ await stagehand.init(); Amazon Bedrock, Cohere, all [first class models](/v3/configuration/models#first-class-models), and any model from [the Vercel AI SDK](https://sdk.vercel.ai/providers) is supported. -Use this configuration for custom endpoints and custom retry or caching logic. +Use this configuration for local runs, custom provider middleware, and providers that do not have direct hosted `model` support. If you're using `env: "BROWSERBASE"`, Bedrock and Vertex can also be passed directly with `model.providerOptions` as shown above. We'll use Amazon Bedrock and Google as examples below. @@ -720,8 +750,8 @@ const stagehand = new Stagehand({ ``` - -For all other providers, use `llmClient`. Here's an example with Hugging Face: + +For providers that do not support direct hosted `model` configuration, use `llmClient`. Here's an example with Hugging Face: ```typescript // pnpm add @ai-sdk/huggingface @@ -922,11 +952,12 @@ The following models work without the `provider/` prefix in the model parameter - Ensure environment variables are loaded (use `dotenv`) - Restart your application after updating `.env` file -| Provider | Environment Variable | +| Provider | Credential / Configuration | | ---------- | ------------------------------ | | Model Gateway | `BROWSERBASE_API_KEY` (no provider key needed) | | Google | `GOOGLE_GENERATIVE_AI_API_KEY` or `GEMINI_API_KEY` | -| Vertex | Service account credentials (see [setup](#first-class-models)) | +| Vertex | `model.providerOptions.project`, `model.providerOptions.location`, and `model.providerOptions.googleAuthOptions.credentials` | +| Bedrock | `model.providerOptions.region` plus AWS credentials or `model.providerOptions.apiKey` | | Anthropic | `ANTHROPIC_API_KEY` | | OpenAI | `OPENAI_API_KEY` | | Azure | `AZURE_API_KEY` | diff --git a/packages/docs/v3/references/stagehand.mdx b/packages/docs/v3/references/stagehand.mdx index 45674b4b9..f2a000116 100644 --- a/packages/docs/v3/references/stagehand.mdx +++ b/packages/docs/v3/references/stagehand.mdx @@ -161,7 +161,7 @@ interface V3Options { - The model name (e.g., "gpt-4o", "claude-sonnet-4-6", "gemini-2.5-flash") + The model name in `"provider/model"` format (e.g., `"openai/gpt-5"`, `"vertex/gemini-3-flash-preview"`, `"bedrock/us.amazon.nova-lite-v1:0"`) API key for the model provider (overrides environment variables) @@ -169,6 +169,13 @@ interface V3Options { Base URL for the API endpoint (for custom endpoints or proxies) + + Provider-native configuration for models that do not fit the generic `apiKey` shape. In Browserbase-hosted mode, Stagehand serializes these fields into the API wire format automatically. + + For Bedrock, use `providerOptions.region` plus AWS credentials (`accessKeyId`, `secretAccessKey`, optional `sessionToken`) or `providerOptions.apiKey`. + + For Vertex, use `providerOptions.project`, `providerOptions.location`, and `providerOptions.googleAuthOptions.credentials`. Optional advanced fields include `baseURL`, `headers`, and `googleAuthOptions.{scopes, projectId, universeDomain}`. + @@ -497,7 +504,7 @@ import { Stagehand } from "@browserbasehq/stagehand"; const stagehand = new Stagehand({ env: "LOCAL", model: { - modelName: "gpt-4o", + modelName: "openai/gpt-4o", apiKey: process.env.OPENAI_API_KEY, baseURL: "https://custom-proxy.com/v1" }, From 47826d5a3859db42b01bb9f920e539cfde755b07 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 24 Apr 2026 14:08:35 -0700 Subject: [PATCH 5/7] fix: address providerOptions PR feedback --- packages/core/lib/v3/llm/LLMProvider.ts | 1 + packages/core/lib/v3/types/public/api.ts | 101 ++++++++++++------ .../unit/api-provider-config-schema.test.ts | 21 +++- .../unit/llm-provider-hosted-vertex.test.ts | 12 +++ 4 files changed, 101 insertions(+), 34 deletions(-) diff --git a/packages/core/lib/v3/llm/LLMProvider.ts b/packages/core/lib/v3/llm/LLMProvider.ts index 82f11804c..80eed63e5 100644 --- a/packages/core/lib/v3/llm/LLMProvider.ts +++ b/packages/core/lib/v3/llm/LLMProvider.ts @@ -108,6 +108,7 @@ function isStringRecord( typeof value === "object" && value !== null && !Array.isArray(value) && + Object.keys(value).length > 0 && Object.values(value).every((item) => typeof item === "string") ); } diff --git a/packages/core/lib/v3/types/public/api.ts b/packages/core/lib/v3/types/public/api.ts index 2bbe8ff37..2f54d8ca5 100644 --- a/packages/core/lib/v3/types/public/api.ts +++ b/packages/core/lib/v3/types/public/api.ts @@ -178,6 +178,13 @@ function addExpectedStringIssue( } } +function prefixedPath( + pathPrefix: (string | number)[], + path: (string | number)[], +): (string | number)[] { + return [...pathPrefix, ...path]; +} + function getProviderFromModelName(modelName?: string): string | undefined { return typeof modelName === "string" && modelName.includes("/") ? modelName.split("/", 1)[0] @@ -207,6 +214,7 @@ function getProviderConfigMismatchMessage({ function addBedrockAuthIssues( providerConfig: { options?: Record } | undefined, ctx: z.RefinementCtx, + providerConfigPath: (string | number)[] = ["providerConfig"], ) { const providerOptions = providerConfig?.options; const region = @@ -228,7 +236,7 @@ function addBedrockAuthIssues( ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Bedrock configs require providerConfig.options.region.", - path: ["providerConfig", "options", "region"], + path: prefixedPath(providerConfigPath, ["options", "region"]), }); } @@ -242,7 +250,7 @@ function addBedrockAuthIssues( code: z.ZodIssueCode.custom, message: "Bedrock AWS credentials require providerConfig.options.accessKeyId when secretAccessKey is provided.", - path: ["providerConfig", "options", "accessKeyId"], + path: prefixedPath(providerConfigPath, ["options", "accessKeyId"]), }); } if (!hasSecretAccessKey) { @@ -250,7 +258,7 @@ function addBedrockAuthIssues( code: z.ZodIssueCode.custom, message: "Bedrock AWS credentials require providerConfig.options.secretAccessKey when accessKeyId is provided.", - path: ["providerConfig", "options", "secretAccessKey"], + path: prefixedPath(providerConfigPath, ["options", "secretAccessKey"]), }); } } @@ -261,7 +269,7 @@ function addBedrockAuthIssues( code: z.ZodIssueCode.custom, message: "Bedrock sessionToken requires providerConfig.options.accessKeyId.", - path: ["providerConfig", "options", "accessKeyId"], + path: prefixedPath(providerConfigPath, ["options", "accessKeyId"]), }); } if (!hasSecretAccessKey) { @@ -269,7 +277,7 @@ function addBedrockAuthIssues( code: z.ZodIssueCode.custom, message: "Bedrock sessionToken requires providerConfig.options.secretAccessKey.", - path: ["providerConfig", "options", "secretAccessKey"], + path: prefixedPath(providerConfigPath, ["options", "secretAccessKey"]), }); } } @@ -278,13 +286,14 @@ function addBedrockAuthIssues( function addBedrockValidationIssues( providerOptions: Record | undefined, ctx: z.RefinementCtx, + providerConfigPath: (string | number)[] = ["providerConfig"], ) { addUnsupportedOptionIssues({ providerName: "Bedrock", options: providerOptions, allowedKeys: BEDROCK_ALLOWED_PROVIDER_OPTION_KEYS, ctx, - pathPrefix: ["providerConfig", "options"], + pathPrefix: prefixedPath(providerConfigPath, ["options"]), }); const headers = providerOptions?.headers; @@ -293,19 +302,19 @@ function addBedrockValidationIssues( code: z.ZodIssueCode.custom, message: "Bedrock providerConfig.options.headers must be a string-to-string record.", - path: ["providerConfig", "options", "headers"], + path: prefixedPath(providerConfigPath, ["options", "headers"]), }); } addExpectedStringIssue( providerOptions?.apiKey, - ["providerConfig", "options", "apiKey"], + prefixedPath(providerConfigPath, ["options", "apiKey"]), "Bedrock providerConfig.options.apiKey must be a string.", ctx, ); addExpectedStringIssue( providerOptions?.baseURL, - ["providerConfig", "options", "baseURL"], + prefixedPath(providerConfigPath, ["options", "baseURL"]), "Bedrock providerConfig.options.baseURL must be a string.", ctx, ); @@ -314,30 +323,31 @@ function addBedrockValidationIssues( function addVertexValidationIssues( providerOptions: Record | undefined, ctx: z.RefinementCtx, + providerConfigPath: (string | number)[] = ["providerConfig"], ) { addUnsupportedOptionIssues({ providerName: "Vertex", options: providerOptions, allowedKeys: VERTEX_ALLOWED_PROVIDER_OPTION_KEYS, ctx, - pathPrefix: ["providerConfig", "options"], + pathPrefix: prefixedPath(providerConfigPath, ["options"]), }); addExpectedStringIssue( providerOptions?.project, - ["providerConfig", "options", "project"], + prefixedPath(providerConfigPath, ["options", "project"]), "Vertex providerConfig.options.project must be a string.", ctx, ); addExpectedStringIssue( providerOptions?.location, - ["providerConfig", "options", "location"], + prefixedPath(providerConfigPath, ["options", "location"]), "Vertex providerConfig.options.location must be a string.", ctx, ); addExpectedStringIssue( providerOptions?.baseURL, - ["providerConfig", "options", "baseURL"], + prefixedPath(providerConfigPath, ["options", "baseURL"]), "Vertex providerConfig.options.baseURL must be a string.", ctx, ); @@ -348,7 +358,7 @@ function addVertexValidationIssues( code: z.ZodIssueCode.custom, message: "Vertex providerConfig.options.headers must be a string-to-string record.", - path: ["providerConfig", "options", "headers"], + path: prefixedPath(providerConfigPath, ["options", "headers"]), }); } @@ -363,7 +373,7 @@ function addVertexValidationIssues( code: z.ZodIssueCode.custom, message: "Vertex providerConfig.options.googleAuthOptions must be an object.", - path: ["providerConfig", "options", "googleAuthOptions"], + path: prefixedPath(providerConfigPath, ["options", "googleAuthOptions"]), }); return; } @@ -373,7 +383,10 @@ function addVertexValidationIssues( options: googleAuthRecord, allowedKeys: VERTEX_GOOGLE_AUTH_ALLOWED_KEYS, ctx, - pathPrefix: ["providerConfig", "options", "googleAuthOptions"], + pathPrefix: prefixedPath(providerConfigPath, [ + "options", + "googleAuthOptions", + ]), }); const credentials = googleAuthRecord.credentials; @@ -384,7 +397,11 @@ function addVertexValidationIssues( code: z.ZodIssueCode.custom, message: "Vertex providerConfig.options.googleAuthOptions.credentials must be an object.", - path: ["providerConfig", "options", "googleAuthOptions", "credentials"], + path: prefixedPath(providerConfigPath, [ + "options", + "googleAuthOptions", + "credentials", + ]), }); } else { addUnsupportedOptionIssues({ @@ -392,12 +409,11 @@ function addVertexValidationIssues( options: credentialRecord, allowedKeys: GOOGLE_SERVICE_ACCOUNT_ALLOWED_KEYS, ctx, - pathPrefix: [ - "providerConfig", + pathPrefix: prefixedPath(providerConfigPath, [ "options", "googleAuthOptions", "credentials", - ], + ]), }); for (const [key, value] of Object.entries(credentialRecord)) { @@ -410,10 +426,11 @@ function addVertexValidationIssues( code: z.ZodIssueCode.custom, message: `Vertex providerConfig.options.googleAuthOptions.credentials.${key} must be a string.`, path: [ - "providerConfig", - "options", - "googleAuthOptions", - "credentials", + ...prefixedPath(providerConfigPath, [ + "options", + "googleAuthOptions", + "credentials", + ]), key, ], }); @@ -432,19 +449,31 @@ function addVertexValidationIssues( code: z.ZodIssueCode.custom, message: "Vertex providerConfig.options.googleAuthOptions.scopes must be a string or string array.", - path: ["providerConfig", "options", "googleAuthOptions", "scopes"], + path: prefixedPath(providerConfigPath, [ + "options", + "googleAuthOptions", + "scopes", + ]), }); } addExpectedStringIssue( googleAuthRecord.projectId, - ["providerConfig", "options", "googleAuthOptions", "projectId"], + prefixedPath(providerConfigPath, [ + "options", + "googleAuthOptions", + "projectId", + ]), "Vertex providerConfig.options.googleAuthOptions.projectId must be a string.", ctx, ); addExpectedStringIssue( googleAuthRecord.universeDomain, - ["providerConfig", "options", "googleAuthOptions", "universeDomain"], + prefixedPath(providerConfigPath, [ + "options", + "googleAuthOptions", + "universeDomain", + ]), "Vertex providerConfig.options.googleAuthOptions.universeDomain must be a string.", ctx, ); @@ -484,6 +513,7 @@ function validateProviderConfig( providerConfig?: { provider?: string; options?: Record }; }, ctx: z.RefinementCtx, + providerConfigPath: (string | number)[] = ["providerConfig"], ) { const mismatchMessage = getProviderConfigMismatchMessage(value); @@ -491,7 +521,7 @@ function validateProviderConfig( ctx.addIssue({ code: z.ZodIssueCode.custom, message: mismatchMessage, - path: ["providerConfig", "provider"], + path: prefixedPath(providerConfigPath, ["provider"]), }); } @@ -503,12 +533,12 @@ function validateProviderConfig( const providerOptions = getRecord(value.providerConfig?.options); if (provider === "bedrock" || modelProvider === "bedrock") { - addBedrockAuthIssues(value.providerConfig, ctx); - addBedrockValidationIssues(providerOptions, ctx); + addBedrockAuthIssues(value.providerConfig, ctx, providerConfigPath); + addBedrockValidationIssues(providerOptions, ctx, providerConfigPath); } if (provider === "vertex" || modelProvider === "vertex") { - addVertexValidationIssues(providerOptions, ctx); + addVertexValidationIssues(providerOptions, ctx, providerConfigPath); } } @@ -817,6 +847,7 @@ export const SessionStartRequestSchema = z providerConfig: value.modelClientOptions?.providerConfig, }, ctx, + ["modelClientOptions", "providerConfig"], ), ) .meta({ id: "SessionStartRequest" }); @@ -1237,7 +1268,13 @@ export const AgentExecuteResponseSchema = wrapResponse( export const NavigateOptionsSchema = z .object({ - model: ModelConfigSchema.optional(), + model: z + .union([ModelConfigSchema, z.string()]) + .optional() + .meta({ + description: + "Model configuration object or model name string (e.g., 'openai/gpt-5-nano')", + }), referer: z.string().optional().meta({ description: "Referer header to send with the request", }), diff --git a/packages/core/tests/unit/api-provider-config-schema.test.ts b/packages/core/tests/unit/api-provider-config-schema.test.ts index 906937161..6577b7b8f 100644 --- a/packages/core/tests/unit/api-provider-config-schema.test.ts +++ b/packages/core/tests/unit/api-provider-config-schema.test.ts @@ -21,6 +21,12 @@ describe("API providerConfig schemas", () => { expect(JSON.stringify(result.error?.issues)).toContain( "Bedrock configs require providerConfig.options.region.", ); + expect(result.error?.issues[0]?.path).toEqual([ + "modelClientOptions", + "providerConfig", + "options", + "region", + ]); }); it("rejects Bedrock model configs with only one AWS credential", () => { @@ -84,7 +90,7 @@ describe("API providerConfig schemas", () => { expect(result.success).toBe(false); expect(JSON.stringify(result.error?.issues)).toContain( - "Bedrock configs do not support providerConfig.options.fetch.", + "Bedrock configs do not support modelClientOptions.providerConfig.options.fetch.", ); }); @@ -113,7 +119,18 @@ describe("API providerConfig schemas", () => { expect(result.success).toBe(false); expect(JSON.stringify(result.error?.issues)).toContain( - "Vertex configs do not support providerConfig.options.googleAuthOptions.authClient.", + "Vertex configs do not support modelClientOptions.providerConfig.options.googleAuthOptions.authClient.", ); }); + + it("accepts string-form navigate models", () => { + const result = Api.NavigateRequestSchema.safeParse({ + url: "https://example.com", + options: { + model: "openai/gpt-5-nano", + }, + }); + + expect(result.success).toBe(true); + }); }); diff --git a/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts b/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts index 29649e8d8..ed6bf0201 100644 --- a/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts +++ b/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts @@ -36,4 +36,16 @@ describe("LLMProvider hosted vertex gating", () => { ), ).toThrow(ExperimentalNotConfiguredError); }); + + it("does not treat empty header objects as hosted Vertex config", () => { + const llmProvider = new LLMProvider(() => {}); + + expect(() => + llmProvider.getClient( + "vertex/gemini-2.5-pro", + { headers: {} }, + { disableAPI: false, experimental: false }, + ), + ).toThrow(ExperimentalNotConfiguredError); + }); }); From 5bcb27d7c67814ac8a1a7bc9e84de52ff43f8fbd Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 24 Apr 2026 15:58:20 -0700 Subject: [PATCH 6/7] fix: normalize hosted model config in server-v3 routes --- packages/server-v3/src/lib/model.ts | 188 ++++++++++++++++++ .../src/routes/v1/sessions/_id/act.ts | 11 +- .../routes/v1/sessions/_id/agentExecute.ts | 12 +- .../src/routes/v1/sessions/_id/extract.ts | 11 +- .../src/routes/v1/sessions/_id/observe.ts | 11 +- .../unit/normalizeApiModelConfig.test.ts | 100 ++++++++++ 6 files changed, 297 insertions(+), 36 deletions(-) create mode 100644 packages/server-v3/src/lib/model.ts create mode 100644 packages/server-v3/tests/unit/normalizeApiModelConfig.test.ts diff --git a/packages/server-v3/src/lib/model.ts b/packages/server-v3/src/lib/model.ts new file mode 100644 index 000000000..ba1449aa8 --- /dev/null +++ b/packages/server-v3/src/lib/model.ts @@ -0,0 +1,188 @@ +import type { + Api, + BedrockProviderOptions, + ClientOptions, + GoogleVertexProviderSettings, + ModelConfiguration, +} from "@browserbasehq/stagehand"; + +const DEFAULT_MODEL_NAME = "gpt-4o"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function copyStringProperty( + target: Record, + source: Record, + key: T, +) { + const value = source[key]; + if (typeof value === "string") { + target[key] = value; + } +} + +function toStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + if (Object.values(value).some((item) => typeof item !== "string")) { + return undefined; + } + + return value as Record; +} + +function toGoogleAuthOptions( + value: unknown, +): GoogleVertexProviderSettings["googleAuthOptions"] | undefined { + if (!isRecord(value)) { + return undefined; + } + + const googleAuthOptions: NonNullable< + GoogleVertexProviderSettings["googleAuthOptions"] + > = {}; + + if (isRecord(value.credentials)) { + const credentials: Record = {}; + for (const key of [ + "type", + "project_id", + "private_key_id", + "private_key", + "client_email", + "client_id", + "auth_uri", + "token_uri", + "auth_provider_x509_cert_url", + "client_x509_cert_url", + "universe_domain", + ] as const) { + copyStringProperty(credentials, value.credentials, key); + } + + if (Object.keys(credentials).length > 0) { + googleAuthOptions.credentials = credentials; + } + } + + if (typeof value.scopes === "string") { + googleAuthOptions.scopes = value.scopes; + } else if ( + Array.isArray(value.scopes) && + value.scopes.every((item) => typeof item === "string") + ) { + googleAuthOptions.scopes = value.scopes; + } + + if (typeof value.projectId === "string") { + googleAuthOptions.projectId = value.projectId; + } + + if (typeof value.universeDomain === "string") { + googleAuthOptions.universeDomain = value.universeDomain; + } + + return Object.keys(googleAuthOptions).length > 0 + ? googleAuthOptions + : undefined; +} + +function toVertexProviderOptions( + value: unknown, +): GoogleVertexProviderSettings | undefined { + if (!isRecord(value)) { + return undefined; + } + + const providerOptions: GoogleVertexProviderSettings = {}; + for (const key of ["project", "location", "baseURL"] as const) { + copyStringProperty(providerOptions as Record, value, key); + } + + const headers = toStringRecord(value.headers); + if (headers) { + providerOptions.headers = headers; + } + + const googleAuthOptions = toGoogleAuthOptions(value.googleAuthOptions); + if (googleAuthOptions) { + providerOptions.googleAuthOptions = googleAuthOptions; + } + + return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; +} + +function toBedrockProviderOptions( + value: unknown, +): BedrockProviderOptions | undefined { + if (!isRecord(value)) { + return undefined; + } + + const providerOptions: BedrockProviderOptions = {}; + for (const key of [ + "region", + "accessKeyId", + "secretAccessKey", + "sessionToken", + "apiKey", + "baseURL", + ] as const) { + copyStringProperty(providerOptions as Record, value, key); + } + + const headers = toStringRecord(value.headers); + if (headers) { + providerOptions.headers = headers; + } + + return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; +} + +export function normalizeApiModelConfig( + model: Api.ModelConfig | string | undefined, +): ModelConfiguration | undefined { + if (!model) { + return undefined; + } + + if (typeof model === "string") { + return { modelName: model }; + } + + const { providerConfig, ...modelWithoutProviderConfig } = model; + const normalizedModel = { + ...(modelWithoutProviderConfig as ClientOptions & { + modelName?: string; + provider?: string; + }), + modelName: model.modelName ?? DEFAULT_MODEL_NAME, + } as ClientOptions & { + modelName: string; + provider?: string; + }; + + delete normalizedModel.provider; + + if (isRecord(providerConfig)) { + if (providerConfig.provider === "bedrock") { + const providerOptions = toBedrockProviderOptions(providerConfig.options); + if (providerOptions) { + normalizedModel.providerOptions = providerOptions; + } + } + + if (providerConfig.provider === "vertex") { + const providerOptions = toVertexProviderOptions(providerConfig.options); + if (providerOptions) { + normalizedModel.providerOptions = providerOptions; + } + } + } + + return normalizedModel; +} diff --git a/packages/server-v3/src/routes/v1/sessions/_id/act.ts b/packages/server-v3/src/routes/v1/sessions/_id/act.ts index 3a64c8336..431742246 100644 --- a/packages/server-v3/src/routes/v1/sessions/_id/act.ts +++ b/packages/server-v3/src/routes/v1/sessions/_id/act.ts @@ -6,6 +6,7 @@ import { Api } from "@browserbasehq/stagehand"; import { authMiddleware } from "../../../../lib/auth.js"; import { AppError, withErrorHandling } from "../../../../lib/errorHandler.js"; +import { normalizeApiModelConfig } from "../../../../lib/model.js"; import { createStreamingResponse } from "../../../../lib/stream.js"; import { getSessionStore } from "../../../../lib/sessionStoreManager.js"; @@ -51,17 +52,9 @@ const actRouteHandler: RouteHandlerMethod = withErrorHandling( ); } - const modelOpt = data.options?.model; - const normalizedModel = - typeof modelOpt === "string" - ? { modelName: modelOpt } - : modelOpt - ? { ...modelOpt, modelName: modelOpt.modelName ?? "gpt-4o" } - : undefined; - const safeOptions = { ...data.options, - model: normalizedModel, + model: normalizeApiModelConfig(data.options?.model), page, }; diff --git a/packages/server-v3/src/routes/v1/sessions/_id/agentExecute.ts b/packages/server-v3/src/routes/v1/sessions/_id/agentExecute.ts index 8e16f71fb..209a4cbee 100644 --- a/packages/server-v3/src/routes/v1/sessions/_id/agentExecute.ts +++ b/packages/server-v3/src/routes/v1/sessions/_id/agentExecute.ts @@ -5,6 +5,7 @@ import { Api } from "@browserbasehq/stagehand"; import { authMiddleware } from "../../../../lib/auth.js"; import { AppError, withErrorHandling } from "../../../../lib/errorHandler.js"; +import { normalizeApiModelConfig } from "../../../../lib/model.js"; import { createStreamingResponse } from "../../../../lib/stream.js"; import { getSessionStore } from "../../../../lib/sessionStoreManager.js"; @@ -51,15 +52,8 @@ const agentExecuteRouteHandler: RouteHandlerMethod = withErrorHandling( } const normalizedAgentConfig = { ...agentConfig, - model: - typeof agentConfig.model === "string" - ? { modelName: agentConfig.model } - : agentConfig.model - ? { - ...agentConfig.model, - modelName: agentConfig.model.modelName ?? "gpt-4o", - } - : undefined, + model: normalizeApiModelConfig(agentConfig.model), + executionModel: normalizeApiModelConfig(agentConfig.executionModel), }; const { instruction, ...restExecuteOptions } = executeOptions; diff --git a/packages/server-v3/src/routes/v1/sessions/_id/extract.ts b/packages/server-v3/src/routes/v1/sessions/_id/extract.ts index 47e54d77c..f812e5fee 100644 --- a/packages/server-v3/src/routes/v1/sessions/_id/extract.ts +++ b/packages/server-v3/src/routes/v1/sessions/_id/extract.ts @@ -6,6 +6,7 @@ import { Api } from "@browserbasehq/stagehand"; import { authMiddleware } from "../../../../lib/auth.js"; import { AppError, withErrorHandling } from "../../../../lib/errorHandler.js"; +import { normalizeApiModelConfig } from "../../../../lib/model.js"; import { createStreamingResponse } from "../../../../lib/stream.js"; import { jsonSchemaToZod } from "../../../../lib/utils.js"; import { getSessionStore } from "../../../../lib/sessionStoreManager.js"; @@ -52,17 +53,9 @@ const extractRouteHandler: RouteHandlerMethod = withErrorHandling( ); } - const modelOpt = data.options?.model; - const normalizedModel = - typeof modelOpt === "string" - ? { modelName: modelOpt } - : modelOpt - ? { ...modelOpt, modelName: modelOpt.modelName ?? "gpt-4o" } - : undefined; - const safeOptions = { ...data.options, - model: normalizedModel, + model: normalizeApiModelConfig(data.options?.model), page, }; diff --git a/packages/server-v3/src/routes/v1/sessions/_id/observe.ts b/packages/server-v3/src/routes/v1/sessions/_id/observe.ts index 01d0936e2..3f7ced6cf 100644 --- a/packages/server-v3/src/routes/v1/sessions/_id/observe.ts +++ b/packages/server-v3/src/routes/v1/sessions/_id/observe.ts @@ -6,6 +6,7 @@ import { Api } from "@browserbasehq/stagehand"; import { authMiddleware } from "../../../../lib/auth.js"; import { AppError, withErrorHandling } from "../../../../lib/errorHandler.js"; +import { normalizeApiModelConfig } from "../../../../lib/model.js"; import { createStreamingResponse } from "../../../../lib/stream.js"; import { getSessionStore } from "../../../../lib/sessionStoreManager.js"; @@ -53,15 +54,7 @@ const observeRouteHandler: RouteHandlerMethod = withErrorHandling( const safeOptions: ObserveOptions = { ...data.options, - model: - typeof data.options?.model === "string" - ? { modelName: data.options.model } - : data.options?.model - ? { - ...data.options.model, - modelName: data.options.model.modelName ?? "gpt-4o", - } - : undefined, + model: normalizeApiModelConfig(data.options?.model), page, }; diff --git a/packages/server-v3/tests/unit/normalizeApiModelConfig.test.ts b/packages/server-v3/tests/unit/normalizeApiModelConfig.test.ts new file mode 100644 index 000000000..b3d2e4df1 --- /dev/null +++ b/packages/server-v3/tests/unit/normalizeApiModelConfig.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Api } from "@browserbasehq/stagehand"; + +import { normalizeApiModelConfig } from "../../src/lib/model.js"; + +describe("normalizeApiModelConfig", () => { + it("keeps string model names as-is", () => { + assert.deepEqual(normalizeApiModelConfig("openai/gpt-5"), { + modelName: "openai/gpt-5", + }); + }); + + it("maps bedrock providerConfig to providerOptions and strips hosted-only fields", () => { + const normalized = normalizeApiModelConfig({ + modelName: "bedrock/us.amazon.nova-lite-v1:0", + provider: "bedrock", + providerConfig: { + provider: "bedrock", + options: { + region: "us-east-1", + accessKeyId: "test-access-key", + secretAccessKey: "test-secret", + sessionToken: "test-session-token", + apiKey: "test-bearer-token", + baseURL: "https://bedrock-proxy.example.com", + headers: { + "x-test-header": "ok", + }, + fetch: "should-not-survive", + }, + }, + }); + + assert.deepEqual(normalized, { + modelName: "bedrock/us.amazon.nova-lite-v1:0", + providerOptions: { + region: "us-east-1", + accessKeyId: "test-access-key", + secretAccessKey: "test-secret", + sessionToken: "test-session-token", + apiKey: "test-bearer-token", + baseURL: "https://bedrock-proxy.example.com", + headers: { + "x-test-header": "ok", + }, + }, + }); + }); + + it("maps vertex providerConfig to providerOptions and keeps only serializable auth fields", () => { + const normalized = normalizeApiModelConfig({ + providerConfig: { + provider: "vertex", + options: { + project: "demo-project", + location: "us-central1", + baseURL: "https://vertex-proxy.example.com", + headers: { + "x-vertex-header": "ok", + }, + googleAuthOptions: { + credentials: { + client_email: "stagehand@test.iam.gserviceaccount.com", + private_key: "private-key", + universe_domain: "googleapis.com", + }, + scopes: ["scope-a", "scope-b"], + projectId: "demo-project", + universeDomain: "googleapis.com", + fetch: "should-not-survive", + }, + fetch: "should-not-survive", + }, + }, + } as unknown as Api.ModelConfig); + + assert.deepEqual(normalized, { + modelName: "gpt-4o", + providerOptions: { + project: "demo-project", + location: "us-central1", + baseURL: "https://vertex-proxy.example.com", + headers: { + "x-vertex-header": "ok", + }, + googleAuthOptions: { + credentials: { + client_email: "stagehand@test.iam.gserviceaccount.com", + private_key: "private-key", + universe_domain: "googleapis.com", + }, + scopes: ["scope-a", "scope-b"], + projectId: "demo-project", + universeDomain: "googleapis.com", + }, + }, + }); + }); +}); From 6f62d1f5875ad37e425607cd14d7a8a6f58ac2bb Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 24 Apr 2026 16:04:24 -0700 Subject: [PATCH 7/7] style: fix providerOptions formatting --- packages/core/lib/v3/llm/LLMProvider.ts | 13 +++++-------- packages/core/lib/v3/modelProviderOptions.ts | 4 +--- packages/core/lib/v3/types/public/api.ts | 15 +++++---------- .../tests/unit/llm-provider-hosted-vertex.test.ts | 9 ++++----- 4 files changed, 15 insertions(+), 26 deletions(-) diff --git a/packages/core/lib/v3/llm/LLMProvider.ts b/packages/core/lib/v3/llm/LLMProvider.ts index 80eed63e5..3e4d12233 100644 --- a/packages/core/lib/v3/llm/LLMProvider.ts +++ b/packages/core/lib/v3/llm/LLMProvider.ts @@ -101,9 +101,7 @@ const modelToProviderMap: { [key in AvailableModel]: ModelProvider } = { "gemini-2.5-pro-preview-03-25": "google", }; -function isStringRecord( - value: unknown, -): value is Record { +function isStringRecord(value: unknown): value is Record { return ( typeof value === "object" && value !== null && @@ -113,11 +111,10 @@ function isStringRecord( ); } -function hasHostedVertexClientOptions( - clientOptions?: ClientOptions, -): boolean { - const vertexOptions = - clientOptions as Partial | undefined; +function hasHostedVertexClientOptions(clientOptions?: ClientOptions): boolean { + const vertexOptions = clientOptions as + | Partial + | undefined; return Boolean( vertexOptions && (typeof vertexOptions.project === "string" || diff --git a/packages/core/lib/v3/modelProviderOptions.ts b/packages/core/lib/v3/modelProviderOptions.ts index 22077d103..cf03f3c4d 100644 --- a/packages/core/lib/v3/modelProviderOptions.ts +++ b/packages/core/lib/v3/modelProviderOptions.ts @@ -110,9 +110,7 @@ function toSerializableGoogleAuthOptions( const projectId = typeof value.projectId === "string" ? value.projectId : undefined; const universeDomain = - typeof value.universeDomain === "string" - ? value.universeDomain - : undefined; + typeof value.universeDomain === "string" ? value.universeDomain : undefined; const googleAuthOptions: SerializableGoogleAuthOptions = {}; if (credentials) { diff --git a/packages/core/lib/v3/types/public/api.ts b/packages/core/lib/v3/types/public/api.ts index 2f54d8ca5..012e8e803 100644 --- a/packages/core/lib/v3/types/public/api.ts +++ b/packages/core/lib/v3/types/public/api.ts @@ -115,9 +115,7 @@ function getRecord(value: unknown): Record | undefined { : undefined; } -function getStringRecord( - value: unknown, -): Record | undefined { +function getStringRecord(value: unknown): Record | undefined { const record = getRecord(value); if (!record) { return undefined; @@ -1268,13 +1266,10 @@ export const AgentExecuteResponseSchema = wrapResponse( export const NavigateOptionsSchema = z .object({ - model: z - .union([ModelConfigSchema, z.string()]) - .optional() - .meta({ - description: - "Model configuration object or model name string (e.g., 'openai/gpt-5-nano')", - }), + model: z.union([ModelConfigSchema, z.string()]).optional().meta({ + description: + "Model configuration object or model name string (e.g., 'openai/gpt-5-nano')", + }), referer: z.string().optional().meta({ description: "Referer header to send with the request", }), diff --git a/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts b/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts index ed6bf0201..06659079c 100644 --- a/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts +++ b/packages/core/tests/unit/llm-provider-hosted-vertex.test.ts @@ -29,11 +29,10 @@ describe("LLMProvider hosted vertex gating", () => { const llmProvider = new LLMProvider(() => {}); expect(() => - llmProvider.getClient( - "vertex/gemini-2.5-pro", - undefined, - { disableAPI: false, experimental: false }, - ), + llmProvider.getClient("vertex/gemini-2.5-pro", undefined, { + disableAPI: false, + experimental: false, + }), ).toThrow(ExperimentalNotConfiguredError); });