diff --git a/.changeset/chatcompletions-provider.md b/.changeset/chatcompletions-provider.md new file mode 100644 index 000000000..0fa07cfcd --- /dev/null +++ b/.changeset/chatcompletions-provider.md @@ -0,0 +1,9 @@ +--- +"@browserbasehq/stagehand": minor +"@browserbasehq/stagehand-server-v3": minor +"@browserbasehq/stagehand-server-v4": minor +--- + +feat: add `chatcompletions` provider prefix and `modelBaseURL` support for OpenAI-compatible endpoints + +Adds a `chatcompletions/` model name prefix that forces the Chat Completions API (`/chat/completions`) instead of the Responses API (`/responses`), enabling support for OpenAI-compatible providers like ZhipuAI GLM. Also adds `modelBaseURL` support end-to-end: client SDK sends `x-model-base-url` header, both server-v3 and server-v4 extract and thread it, and Stainless generates it as an optional parameter across all language SDKs. diff --git a/packages/core/lib/v3/api.ts b/packages/core/lib/v3/api.ts index 9fe289208..541026f76 100644 --- a/packages/core/lib/v3/api.ts +++ b/packages/core/lib/v3/api.ts @@ -104,6 +104,8 @@ interface StagehandAPIConstructorParams { interface ClientSessionStartParams extends Api.SessionStartRequest { /** Model API key - sent via x-model-api-key header, not in request body */ modelApiKey: string; + /** Model base URL - sent via x-model-base-url header, not in request body */ + modelBaseURL?: string; } /** @@ -177,6 +179,7 @@ export class StagehandAPIClient { private projectId?: string; private sessionId?: string; private modelApiKey: string; + private modelBaseURL?: string; private modelProvider?: string; private region?: BrowserbaseRegion; private logger: (message: LogLine) => void; @@ -202,6 +205,7 @@ export class StagehandAPIClient { async init({ modelName, modelApiKey, + modelBaseURL, domSettleTimeoutMs, verbose, systemPrompt, @@ -214,6 +218,7 @@ export class StagehandAPIClient { throw new StagehandAPIError("modelApiKey is required"); } this.modelApiKey = modelApiKey; + this.modelBaseURL = modelBaseURL; // Extract provider from modelName (e.g., "openai/gpt-5-nano" -> "openai") this.modelProvider = modelName?.includes("/") ? modelName.split("/")[0] @@ -849,6 +854,7 @@ export class StagehandAPIClient { // we want real-time logs, so we stream the response "x-stream-response": "true", "x-model-api-key": this.modelApiKey, + ...(this.modelBaseURL ? { "x-model-base-url": this.modelBaseURL } : {}), "x-language": "typescript", "x-sdk-version": STAGEHAND_VERSION, }; diff --git a/packages/core/lib/v3/llm/LLMProvider.ts b/packages/core/lib/v3/llm/LLMProvider.ts index 11986d3cb..402cb6c3c 100644 --- a/packages/core/lib/v3/llm/LLMProvider.ts +++ b/packages/core/lib/v3/llm/LLMProvider.ts @@ -50,6 +50,7 @@ const AISDKProviders: Record = { ollama, vertex, gateway, + chatcompletions: openai, }; const AISDKProvidersWithAPIKey: Record = { openai: createOpenAI, @@ -67,6 +68,7 @@ const AISDKProvidersWithAPIKey: Record = { perplexity: createPerplexity, ollama: createOllama, gateway: createGateway, + chatcompletions: createOpenAI, }; const modelToProviderMap: { [key in AvailableModel]: ModelProvider } = { @@ -108,6 +110,7 @@ export function getAISDKLanguageModel( clientOptions && Object.values(clientOptions).some((v) => v !== undefined && v !== null); + let provider; if (hasValidOptions) { const creator = AISDKProvidersWithAPIKey[subProvider]; if (!creator) { @@ -116,19 +119,24 @@ export function getAISDKLanguageModel( Object.keys(AISDKProvidersWithAPIKey), ); } - const provider = creator(clientOptions); - // Get the specific model from the provider - return provider(subModelName); + provider = creator(clientOptions); } else { - const provider = AISDKProviders[subProvider]; + provider = AISDKProviders[subProvider]; if (!provider) { throw new UnsupportedAISDKModelProviderError( subProvider, Object.keys(AISDKProviders), ); } - return provider(subModelName); } + + // "chatcompletions" uses the Chat Completions API (/chat/completions) + // instead of the Responses API (/responses), for OpenAI-compatible + // endpoints that don't support /responses. + if (subProvider === "chatcompletions") { + return (provider as ReturnType).chat(subModelName); + } + return provider(subModelName); } export class LLMProvider { diff --git a/packages/core/lib/v3/llm/aisdk.ts b/packages/core/lib/v3/llm/aisdk.ts index 6db3058fc..c5108fecd 100644 --- a/packages/core/lib/v3/llm/aisdk.ts +++ b/packages/core/lib/v3/llm/aisdk.ts @@ -172,70 +172,129 @@ You must respond in JSON format. respond WITH JSON. Do not include any other tex }); } - try { - objectResponse = await generateObject({ + // chatcompletions/ models (provider: "openai.chat") can't do structured + // output — skip schema entirely to avoid a wasted LLM call. + // Other fallback models (deepseek, kimi) succeed with schema. + let useNoSchema = + needsPromptJsonFallback && this.model.provider === "openai.chat"; + + if (!useNoSchema) { + try { + objectResponse = await generateObject({ + model: this.model, + messages: formattedMessages, + schema: options.response_model.schema, + temperature, + providerOptions: isGPT5 + ? { + openai: { + textVerbosity: isCodex ? "medium" : "low", // codex models only support 'medium' + reasoningEffort: isCodex + ? "medium" + : usesLowReasoningEffort + ? "low" + : "minimal", + }, + } + : undefined, + }); + } catch (err) { + if (needsPromptJsonFallback) { + useNoSchema = true; + } else { + // Log error response to maintain request/response pairing + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.model.modelId, + operation: "generateObject", + output: `[error: ${err instanceof Error ? err.message : "unknown"}]`, + }); + + if (NoObjectGeneratedError.isInstance(err)) { + this.logger?.({ + category: "AISDK error", + message: err.message, + level: 0, + auxiliary: { + cause: { + value: JSON.stringify(err.cause ?? {}), + type: "object", + }, + text: { + value: err.text ?? "", + type: "string", + }, + response: { + value: JSON.stringify(err.response ?? {}), + type: "object", + }, + usage: { + value: JSON.stringify(err.usage ?? {}), + type: "object", + }, + finishReason: { + value: err.finishReason ?? "unknown", + type: "string", + }, + requestId: { + value: options.requestId, + type: "string", + }, + }, + }); + } + + throw err; + } + } + } + + // No-schema fallback for models that can't do structured output. + // Pipeline: call LLM → fix strings → fix missing arrays → validate + if (useNoSchema) { + // 1. Call LLM without schema (prompt instruction guides JSON output) + const noSchemaResponse = await generateObject({ model: this.model, messages: formattedMessages, - schema: options.response_model.schema, + output: "no-schema", temperature, - providerOptions: isGPT5 - ? { - openai: { - textVerbosity: isCodex ? "medium" : "low", // codex models only support 'medium' - reasoningEffort: isCodex - ? "medium" - : usesLowReasoningEffort - ? "low" - : "minimal", - }, - } - : undefined, - }); - } catch (err) { - // Log error response to maintain request/response pairing - SessionFileLogger.logLlmResponse({ - requestId: llmRequestId, - model: this.model.modelId, - operation: "generateObject", - output: `[error: ${err instanceof Error ? err.message : "unknown"}]`, }); - - if (NoObjectGeneratedError.isInstance(err)) { - this.logger?.({ - category: "AISDK error", - message: err.message, - level: 0, - auxiliary: { - cause: { - value: JSON.stringify(err.cause ?? {}), - type: "object", - }, - text: { - value: err.text ?? "", - type: "string", - }, - response: { - value: JSON.stringify(err.response ?? {}), - type: "object", - }, - usage: { - value: JSON.stringify(err.usage ?? {}), - type: "object", - }, - finishReason: { - value: err.finishReason ?? "unknown", - type: "string", - }, - requestId: { - value: options.requestId, - type: "string", - }, - }, - }); - - throw err; + // 2. Fix strings — models may return "[]" instead of [] + const raw = noSchemaResponse.object as Record; + for (const [k, v] of Object.entries(raw)) { + if (typeof v === "string") { + try { + raw[k] = JSON.parse(v); + } catch { + // keep as string + } + } + } + // 3. Fix missing arrays — models may omit empty array fields entirely + let parsed: unknown; + const firstTry = options.response_model.schema.safeParse(raw); + if (firstTry.success) { + parsed = firstTry.data; + } else { + for (const issue of firstTry.error.issues) { + if ( + issue.code === "invalid_type" && + issue.expected === "array" && + issue.path.length === 1 + ) { + raw[issue.path[0] as string] = []; + } + } + // 4. Validate against schema + const secondTry = options.response_model.schema.safeParse(raw); + if (!secondTry.success) { + throw new Error( + `Model response could not be coerced into the expected schema: ${secondTry.error.message}`, + ); + } + parsed = secondTry.data; } - throw err; + objectResponse = { ...noSchemaResponse, object: parsed }; } const result = { diff --git a/packages/core/lib/v3/types/public/api.ts b/packages/core/lib/v3/types/public/api.ts index 33ac3f73a..fad48dc06 100644 --- a/packages/core/lib/v3/types/public/api.ts +++ b/packages/core/lib/v3/types/public/api.ts @@ -947,6 +947,13 @@ export const openApiSecuritySchemes = { name: "x-model-api-key", description: "API key for the AI model provider (OpenAI, Anthropic, etc.)", }, + ModelBaseUrl: { + type: "apiKey", + in: "header", + name: "x-model-base-url", + description: + "Base URL override for the AI model provider (for OpenAI-compatible endpoints)", + }, } as const; /** OpenAPI links for session operations (used in SessionStart response) */ diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index cde254843..109b82132 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -931,6 +931,7 @@ export class V3 { const { sessionId, available } = await this.apiClient.init({ modelName: this.modelName, modelApiKey: this.modelClientOptions.apiKey, + modelBaseURL: this.modelClientOptions.baseURL, domSettleTimeoutMs: this.domSettleTimeoutMs, verbose: this.verbose, systemPrompt: this.opts.systemPrompt, diff --git a/packages/server-v3/openapi.v3.yaml b/packages/server-v3/openapi.v3.yaml index 98f26f365..e41fd7e92 100644 --- a/packages/server-v3/openapi.v3.yaml +++ b/packages/server-v3/openapi.v3.yaml @@ -38,6 +38,11 @@ components: in: header name: x-model-api-key description: API key for the AI model provider (OpenAI, Anthropic, etc.) + ModelBaseUrl: + type: apiKey + in: header + name: x-model-base-url + description: Base URL override for the AI model provider (for OpenAI-compatible endpoints) links: SessionAct: operationId: SessionAct diff --git a/packages/server-v3/src/lib/InMemorySessionStore.ts b/packages/server-v3/src/lib/InMemorySessionStore.ts index 5c8be3e86..8d9c2dc42 100644 --- a/packages/server-v3/src/lib/InMemorySessionStore.ts +++ b/packages/server-v3/src/lib/InMemorySessionStore.ts @@ -211,6 +211,7 @@ export class InMemorySessionStore implements SessionStore { model: { modelName: params.modelName, apiKey: ctx.modelApiKey, + baseURL: ctx.modelBaseURL, }, verbose: params.verbose, systemPrompt: params.systemPrompt, diff --git a/packages/server-v3/src/lib/SessionStore.ts b/packages/server-v3/src/lib/SessionStore.ts index 387cb856f..a042d094e 100644 --- a/packages/server-v3/src/lib/SessionStore.ts +++ b/packages/server-v3/src/lib/SessionStore.ts @@ -67,6 +67,8 @@ export interface CreateSessionParams { export interface RequestContext { /** Model API key (from x-model-api-key header) */ modelApiKey?: string; + /** Model base URL override (from x-model-base-url header) */ + modelBaseURL?: string; /** Logger function for this request */ logger?: (message: LogLine) => void; } diff --git a/packages/server-v3/src/lib/header.ts b/packages/server-v3/src/lib/header.ts index daf1f6b62..c7f2b4224 100644 --- a/packages/server-v3/src/lib/header.ts +++ b/packages/server-v3/src/lib/header.ts @@ -76,6 +76,23 @@ export function getModelApiKey(request: FastifyRequest): string | undefined { return getOptionalHeader(request, "x-model-api-key"); } +/** + * Extracts the model base URL with precedence: + * 1. Per-request body baseURL (V3: body.options.model.baseURL) + * 2. Per-request header x-model-base-url + */ +export function getModelBaseURL(request: FastifyRequest): string | undefined { + const body = request.body as Record | undefined; + const options = body?.options as Record | undefined; + const model = options?.model as Record | undefined; + + if (typeof model?.baseURL === "string" && model.baseURL) { + return model.baseURL; + } + + return getOptionalHeader(request, "x-model-base-url"); +} + /** * Extracts the stream response value from either the request header or body. * Body parameter takes precedence over header. diff --git a/packages/server-v3/src/lib/stream.ts b/packages/server-v3/src/lib/stream.ts index 4866e41f1..a451101b4 100644 --- a/packages/server-v3/src/lib/stream.ts +++ b/packages/server-v3/src/lib/stream.ts @@ -7,6 +7,7 @@ import { z } from "zod/v4"; import { AppError } from "./errorHandler.js"; import { getModelApiKey, + getModelBaseURL, getOptionalHeader, shouldRespondWithSSE, } from "./header.js"; @@ -36,6 +37,7 @@ export async function createStreamingResponse({ }: StreamingResponseOptions) { const shouldStreamResponse = shouldRespondWithSSE(request); const modelApiKey = getModelApiKey(request); + const modelBaseURL = getModelBaseURL(request); const sessionStore = getSessionStore(); const sessionConfig = await sessionStore.getSessionConfig(sessionId); @@ -117,6 +119,7 @@ export async function createStreamingResponse({ const requestContext: RequestContext = { modelApiKey, + modelBaseURL, logger: shouldStreamResponse ? (message) => { sendData("log", { status: "running", message }); diff --git a/packages/server-v3/src/routes/v1/sessions/start.ts b/packages/server-v3/src/routes/v1/sessions/start.ts index 6aa022175..f55843eab 100644 --- a/packages/server-v3/src/routes/v1/sessions/start.ts +++ b/packages/server-v3/src/routes/v1/sessions/start.ts @@ -8,7 +8,11 @@ import { z } from "zod/v4"; import { authMiddleware } from "../../../lib/auth.js"; import { withErrorHandling } from "../../../lib/errorHandler.js"; -import { getModelApiKey, getOptionalHeader } from "../../../lib/header.js"; +import { + getModelApiKey, + getModelBaseURL, + getOptionalHeader, +} from "../../../lib/header.js"; import { error, success } from "../../../lib/response.js"; import { getSessionStore } from "../../../lib/sessionStoreManager.js"; import { AISDK_PROVIDERS } from "../../../types/model.js"; @@ -205,10 +209,11 @@ const startRouteHandler: RouteHandler = withErrorHandling( let finalCdpUrl = connectUrl ?? session.cdpUrl ?? ""; if (browserType === "local" && browser?.launchOptions && !browser?.cdpUrl) { const modelApiKey = getModelApiKey(request); + const modelBaseURL = getModelBaseURL(request); try { const stagehand = await sessionStore.getOrCreateStagehand( session.sessionId, - { modelApiKey }, + { modelApiKey, modelBaseURL }, ); finalCdpUrl = stagehand.connectURL(); } catch (err) { diff --git a/packages/server-v3/src/types/model.ts b/packages/server-v3/src/types/model.ts index 491b699b6..eb9409da4 100644 --- a/packages/server-v3/src/types/model.ts +++ b/packages/server-v3/src/types/model.ts @@ -13,6 +13,7 @@ export const AISDK_PROVIDERS = [ "ollama", "vertex", "bedrock", + "chatcompletions", ] as const; export type AISDKProvider = (typeof AISDK_PROVIDERS)[number]; diff --git a/packages/server-v4/openapi.v4.yaml b/packages/server-v4/openapi.v4.yaml index b7f443f44..6fa619eab 100644 --- a/packages/server-v4/openapi.v4.yaml +++ b/packages/server-v4/openapi.v4.yaml @@ -38,6 +38,11 @@ components: in: header name: x-model-api-key description: API key for the AI model provider (OpenAI, Anthropic, etc.) + ModelBaseUrl: + type: apiKey + in: header + name: x-model-base-url + description: Base URL override for the AI model provider (for OpenAI-compatible endpoints) links: SessionAct: operationId: SessionAct diff --git a/packages/server-v4/src/lib/InMemorySessionStore.ts b/packages/server-v4/src/lib/InMemorySessionStore.ts index 5c8be3e86..8d9c2dc42 100644 --- a/packages/server-v4/src/lib/InMemorySessionStore.ts +++ b/packages/server-v4/src/lib/InMemorySessionStore.ts @@ -211,6 +211,7 @@ export class InMemorySessionStore implements SessionStore { model: { modelName: params.modelName, apiKey: ctx.modelApiKey, + baseURL: ctx.modelBaseURL, }, verbose: params.verbose, systemPrompt: params.systemPrompt, diff --git a/packages/server-v4/src/lib/SessionStore.ts b/packages/server-v4/src/lib/SessionStore.ts index 387cb856f..a042d094e 100644 --- a/packages/server-v4/src/lib/SessionStore.ts +++ b/packages/server-v4/src/lib/SessionStore.ts @@ -67,6 +67,8 @@ export interface CreateSessionParams { export interface RequestContext { /** Model API key (from x-model-api-key header) */ modelApiKey?: string; + /** Model base URL override (from x-model-base-url header) */ + modelBaseURL?: string; /** Logger function for this request */ logger?: (message: LogLine) => void; } diff --git a/packages/server-v4/src/lib/header.ts b/packages/server-v4/src/lib/header.ts index daf1f6b62..c7f2b4224 100644 --- a/packages/server-v4/src/lib/header.ts +++ b/packages/server-v4/src/lib/header.ts @@ -76,6 +76,23 @@ export function getModelApiKey(request: FastifyRequest): string | undefined { return getOptionalHeader(request, "x-model-api-key"); } +/** + * Extracts the model base URL with precedence: + * 1. Per-request body baseURL (V3: body.options.model.baseURL) + * 2. Per-request header x-model-base-url + */ +export function getModelBaseURL(request: FastifyRequest): string | undefined { + const body = request.body as Record | undefined; + const options = body?.options as Record | undefined; + const model = options?.model as Record | undefined; + + if (typeof model?.baseURL === "string" && model.baseURL) { + return model.baseURL; + } + + return getOptionalHeader(request, "x-model-base-url"); +} + /** * Extracts the stream response value from either the request header or body. * Body parameter takes precedence over header. diff --git a/packages/server-v4/src/lib/stream.ts b/packages/server-v4/src/lib/stream.ts index 4866e41f1..a451101b4 100644 --- a/packages/server-v4/src/lib/stream.ts +++ b/packages/server-v4/src/lib/stream.ts @@ -7,6 +7,7 @@ import { z } from "zod/v4"; import { AppError } from "./errorHandler.js"; import { getModelApiKey, + getModelBaseURL, getOptionalHeader, shouldRespondWithSSE, } from "./header.js"; @@ -36,6 +37,7 @@ export async function createStreamingResponse({ }: StreamingResponseOptions) { const shouldStreamResponse = shouldRespondWithSSE(request); const modelApiKey = getModelApiKey(request); + const modelBaseURL = getModelBaseURL(request); const sessionStore = getSessionStore(); const sessionConfig = await sessionStore.getSessionConfig(sessionId); @@ -117,6 +119,7 @@ export async function createStreamingResponse({ const requestContext: RequestContext = { modelApiKey, + modelBaseURL, logger: shouldStreamResponse ? (message) => { sendData("log", { status: "running", message }); diff --git a/packages/server-v4/src/routes/v4/sessions/start.ts b/packages/server-v4/src/routes/v4/sessions/start.ts index 2019af456..1797eef21 100644 --- a/packages/server-v4/src/routes/v4/sessions/start.ts +++ b/packages/server-v4/src/routes/v4/sessions/start.ts @@ -8,7 +8,11 @@ import { z } from "zod/v4"; import { authMiddleware } from "../../../lib/auth.js"; import { withErrorHandling } from "../../../lib/errorHandler.js"; -import { getModelApiKey, getOptionalHeader } from "../../../lib/header.js"; +import { + getModelApiKey, + getModelBaseURL, + getOptionalHeader, +} from "../../../lib/header.js"; import { error, success } from "../../../lib/response.js"; import { getSessionStore } from "../../../lib/sessionStoreManager.js"; import { AISDK_PROVIDERS } from "../../../types/model.js"; @@ -205,10 +209,11 @@ const startRouteHandler: RouteHandler = withErrorHandling( let finalCdpUrl = connectUrl ?? session.cdpUrl ?? ""; if (browserType === "local" && browser?.launchOptions && !browser?.cdpUrl) { const modelApiKey = getModelApiKey(request); + const modelBaseURL = getModelBaseURL(request); try { const stagehand = await sessionStore.getOrCreateStagehand( session.sessionId, - { modelApiKey }, + { modelApiKey, modelBaseURL }, ); finalCdpUrl = stagehand.connectURL(); } catch (err) { diff --git a/stainless.yml b/stainless.yml index 0270ad4d4..3055aa2c4 100644 --- a/stainless.yml +++ b/stainless.yml @@ -242,6 +242,13 @@ client_settings: nullable: false auth: security_scheme: LLMModelApiKeyAuth + MODEL_BASE_URL: + type: string + read_env: MODEL_BASE_URL + description: Base URL override for the AI model provider (for OpenAI-compatible endpoints) + nullable: true + auth: + security_scheme: LLMModelBaseUrlAuth security_schemes: BBApiKeyAuth: @@ -256,6 +263,10 @@ security_schemes: type: apiKey in: header name: x-model-api-key + LLMModelBaseUrlAuth: + type: apiKey + in: header + name: x-model-base-url security: - BBApiKeyAuth: []