Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/chatcompletions-provider.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/core/lib/v3/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -202,6 +205,7 @@ export class StagehandAPIClient {
async init({
modelName,
modelApiKey,
modelBaseURL,
domSettleTimeoutMs,
verbose,
systemPrompt,
Expand All @@ -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]
Expand Down Expand Up @@ -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,
};
Expand Down
18 changes: 13 additions & 5 deletions packages/core/lib/v3/llm/LLMProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const AISDKProviders: Record<string, AISDKProvider> = {
ollama,
vertex,
gateway,
chatcompletions: openai,
};
const AISDKProvidersWithAPIKey: Record<string, AISDKCustomProvider> = {
openai: createOpenAI,
Expand All @@ -67,6 +68,7 @@ const AISDKProvidersWithAPIKey: Record<string, AISDKCustomProvider> = {
perplexity: createPerplexity,
ollama: createOllama,
gateway: createGateway,
chatcompletions: createOpenAI,
};

const modelToProviderMap: { [key in AvailableModel]: ModelProvider } = {
Expand Down Expand Up @@ -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) {
Expand All @@ -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<typeof createOpenAI>).chat(subModelName);
}
return provider(subModelName);
}

export class LLMProvider {
Expand Down
177 changes: 118 additions & 59 deletions packages/core/lib/v3/llm/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Exception and error message sanitization

Generic new Error() with unsanitized Zod error message that may reflect sensitive prompt data back to the caller. Per the error-sanitization rule, use a typed error class and strip or redact the raw Zod message (which can contain actual field values from the model response).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/llm/aisdk.ts, line 291:

<comment>Generic `new Error()` with unsanitized Zod error message that may reflect sensitive prompt data back to the caller. Per the error-sanitization rule, use a typed error class and strip or redact the raw Zod message (which can contain actual field values from the model response).</comment>

<file context>
@@ -172,115 +172,129 @@ You must respond in JSON format. respond WITH JSON. Do not include any other tex
+          // 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}`,
+            );
</file context>
Fix with Cubic

`Model response could not be coerced into the expected schema: ${secondTry.error.message}`,
);
}
parsed = secondTry.data;
}
throw err;
objectResponse = { ...noSchemaResponse, object: parsed };
}

const result = {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/lib/v3/types/public/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
1 change: 1 addition & 0 deletions packages/core/lib/v3/v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/server-v3/openapi.v3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/server-v3/src/lib/InMemorySessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/server-v3/src/lib/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 17 additions & 0 deletions packages/server-v3/src/lib/header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
const options = body?.options as Record<string, unknown> | undefined;
const model = options?.model as Record<string, unknown> | 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.
Expand Down
3 changes: 3 additions & 0 deletions packages/server-v3/src/lib/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from "zod/v4";
import { AppError } from "./errorHandler.js";
import {
getModelApiKey,
getModelBaseURL,
getOptionalHeader,
shouldRespondWithSSE,
} from "./header.js";
Expand Down Expand Up @@ -36,6 +37,7 @@ export async function createStreamingResponse<TV3>({
}: StreamingResponseOptions<TV3>) {
const shouldStreamResponse = shouldRespondWithSSE(request);
const modelApiKey = getModelApiKey(request);
const modelBaseURL = getModelBaseURL(request);

const sessionStore = getSessionStore();
const sessionConfig = await sessionStore.getSessionConfig(sessionId);
Expand Down Expand Up @@ -117,6 +119,7 @@ export async function createStreamingResponse<TV3>({

const requestContext: RequestContext = {
modelApiKey,
modelBaseURL,
logger: shouldStreamResponse
? (message) => {
sendData("log", { status: "running", message });
Expand Down
Loading