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/orcarouter-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@browserbasehq/stagehand": minor
"@browserbasehq/stagehand-extension": minor
"@browserbasehq/stagehand-protocol": minor
"@browserbasehq/stagehand-python": minor
"@browserbasehq/stagehand-go": minor
---

feat(providers): add OrcaRouter as a first-class model provider
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
OPENAI_API_KEY=""
BROWSERBASE_API_KEY=""

# OrcaRouter (OpenAI-compatible gateway): https://www.orcarouter.ai
# Use model names like "orcarouter/auto" with an ORCAROUTER_API_KEY.
# ORCAROUTER_API_KEY="sk-orca-..."

# Optional: set this only if Chrome cannot be detected automatically.
# CHROME_PATH=""
75 changes: 72 additions & 3 deletions packages/docs/v4/configuration/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ defer func() { err = errors.Join(err, client.Close(ctx)) }()
</Tabs>

<Note>
Model names for the providers below carry a `provider/` prefix, and the provider must be one of the five. Stagehand ships a list of known model IDs per provider and validates the full name when you call `Stagehand.create()` or pass a per-call override, so a name it does not recognize fails before any request reaches the provider. Upgrade the SDK to pick up newly released models. The prefix is never optional: to reach an Azure OpenAI deployment, a self-hosted model, or anything else outside those five providers, use the [bring-your-own-LLM callback](#custom-models).
Model names for the providers below carry a `provider/` prefix, and the provider must be one of the first-class providers listed on this page. Stagehand ships a list of known model IDs per provider and validates the full name when you call `Stagehand.create()` or pass a per-call override, so a name it does not recognize fails before any request reaches the provider. Upgrade the SDK to pick up newly released models. The prefix is never optional: to reach an Azure OpenAI deployment, a self-hosted model, or anything else outside those first-class providers, use the [bring-your-own-LLM callback](#custom-models).
</Note>


Expand Down Expand Up @@ -668,6 +668,74 @@ Commonly used: `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507
[View all supported Cerebras models →](https://inference-docs.cerebras.ai/models/overview)
</Tab>

<Tab title="OrcaRouter">

<Tabs>
<Tab title="TypeScript">
```typescript
import { browserbase, Stagehand } from "@browserbasehq/stagehand";

const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "orcarouter/auto",
apiKey: process.env.ORCAROUTER_API_KEY,
},
});
```
</Tab>

<Tab title="Python">
```python
import os

from stagehand import Stagehand, browserbase

browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])

stagehand = await Stagehand.create(
browser=browser,
model="orcarouter/auto",
model_api_key=os.environ["ORCAROUTER_API_KEY"],
)
```
</Tab>

<Tab title="Go">
```go
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("ORCAROUTER_API_KEY")
model := stagehand.ModelConfig{
ModelName: "orcarouter/auto",
APIKey: &modelAPIKey,
}

browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}

client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
</Tab>
</Tabs>

[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible gateway that fronts hundreds of frontier and open-weight models behind a single `sk-orca-` key. Use a gateway routing alias like `orcarouter/auto` or a vendor-prefixed model like `orcarouter/deepseek/deepseek-v4-pro`. It also runs gateway-level, zero-trust security for AI agents on the same endpoint — screening every prompt/response and governing every tool call on a default-deny basis, with no application code changes.

Commonly used: `orcarouter/auto`, `orcarouter/fusion`, `orcarouter/fusion-flash`, `orcarouter/fusion-mini`, `orcarouter/deepseek/deepseek-v4-pro`.

[View all supported OrcaRouter models →](https://www.orcarouter.ai)
</Tab>

</Tabs>

---
Expand Down Expand Up @@ -1570,7 +1638,7 @@ defer func() { err = errors.Join(err, client.Close(ctx)) }()
</Tabs>

<Note>
There is no base URL option. A model configuration always names one of the five supported providers, so Azure OpenAI deployments, self-hosted models, and any other custom endpoint go through the [bring-your-own-LLM callback](#custom-models) instead, where you own the client, the transport, and the credentials.
There is no base URL option. A model configuration always names one of the first-class providers, so Azure OpenAI deployments, self-hosted models, and any other custom endpoint go through the [bring-your-own-LLM callback](#custom-models) instead, where you own the client, the transport, and the credentials.
</Note>

---
Expand Down Expand Up @@ -1746,6 +1814,7 @@ You pinned a `model` but gave it no `apiKey`, and the browser is not a Browserba
| OpenAI | `OPENAI_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| OrcaRouter | `ORCAROUTER_API_KEY` |
| Bring your own LLM | None; your callback owns its credentials |

</Accordion>
Expand All @@ -1756,7 +1825,7 @@ You pinned a `model` but gave it no `apiKey`, and the browser is not a Browserba
**Solutions:**

- Use the `provider/model` format: `openai/gpt-5`. The prefix is required; bare model names are rejected
- Use one of the five supported providers: `openai`, `anthropic`, `google`, `groq`, `cerebras`
- Use one of the first-class providers: `openai`, `anthropic`, `google`, `groq`, `cerebras`, `orcarouter`
- Check the model ID against the lists on this page. Stagehand validates the whole name, so a typo fails at `Stagehand.create()` rather than on the first inference
- Upgrade the SDK if the model shipped after your installed version
- For a provider outside that list, use the [bring-your-own-LLM callback](#custom-models)
Expand Down
1 change: 1 addition & 0 deletions packages/evals/initStagehand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const PROVIDER_API_KEY_ENV: Record<string, string[]> = {
google: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
groq: ["GROQ_API_KEY"],
cerebras: ["CEREBRAS_API_KEY"],
orcarouter: ["ORCAROUTER_API_KEY"],
};

type StagehandLogEvent = Parameters<NonNullable<StagehandClientLoggingConfig["onLog"]>>[0];
Expand Down
7 changes: 7 additions & 0 deletions packages/evals/tests/tui/welcomeStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const PROVIDER_KEYS = [
"BB_API_KEY",
"BB_PROJECT_ID",
"BRAINTRUST_API_KEY",
"ORCAROUTER_API_KEY",
];

const savedEnv: Record<string, string | undefined> = {};
Expand Down Expand Up @@ -160,6 +161,12 @@ describe("hasZeroProviderKeys", () => {
__resetPackageEnvCacheForTests();
expect(hasZeroProviderKeys(snapshotEnv())).toBe(false);
});

it("false with only OrcaRouter set", () => {
process.env.ORCAROUTER_API_KEY = "sk-orca-test";
__resetPackageEnvCacheForTests();
expect(hasZeroProviderKeys(snapshotEnv())).toBe(false);
});
});

describe("renderInlineWarning", () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/evals/tui/welcomeStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type EnvSnapshot = {
google: GoogleKeyEntry;
browserbase: BrowserbaseKeyEntry;
braintrust: ProviderKeyEntry;
orcarouter: ProviderKeyEntry;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -166,6 +167,7 @@ export function snapshotEnv(): EnvSnapshot {
google: googleEntry(),
browserbase: browserbaseEntry(),
braintrust: providerEntry("BRAINTRUST_API_KEY"),
orcarouter: providerEntry("ORCAROUTER_API_KEY"),
};
}

Expand All @@ -177,7 +179,10 @@ export function snapshotEnv(): EnvSnapshot {

export function hasZeroProviderKeys(s: EnvSnapshot): boolean {
return (
s.openai.state === "missing" && s.anthropic.state === "missing" && s.google.state === "missing"
s.openai.state === "missing" &&
s.anthropic.state === "missing" &&
s.google.state === "missing" &&
s.orcarouter.state === "missing"
);
}

Expand Down
24 changes: 22 additions & 2 deletions packages/extension/llm/LLMProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ const AISDKProviderFactories: Record<ModelProvider, AISDKProviderFactory> = {
google: createGoogleGenerativeAI as AISDKProviderFactory,
groq: createGroq as AISDKProviderFactory,
cerebras: createCerebras as AISDKProviderFactory,
// OrcaRouter is an OpenAI-compatible gateway; the createOpenAI factory talks
// to it once baseURL points at the gateway.
orcarouter: createOpenAI as AISDKProviderFactory,
};

// Default base URL for each OpenAI-compatible gateway provider, applied when the
// caller does not set one explicitly.
const DEFAULT_BASE_URL: Partial<Record<ModelProvider, string>> = {
orcarouter: "https://api.orcarouter.ai/v1",
};

type AISDKProviderClientOptions = ClientOptions & Record<string, unknown>;
Expand All @@ -30,7 +39,7 @@ function parseClientOptions(clientOptions?: ClientOptions): ClientOptions {
}

export function toAISDKClientOptions(
_subProvider: ModelProvider,
subProvider: ModelProvider,
clientOptions?: ClientOptions,
): AISDKProviderClientOptions | undefined {
const { auth, providerOptions: _providerOptions, ...rest } = parseClientOptions(clientOptions);
Expand All @@ -41,6 +50,12 @@ export function toAISDKClientOptions(
...apiKeyOption,
};

// OpenAI-compatible gateways route through the Responses API; give them a
// sensible base URL when the caller did not pin one.
if (DEFAULT_BASE_URL[subProvider] && !options.baseURL) {
options.baseURL = DEFAULT_BASE_URL[subProvider];
}

return Object.values(options).some((value) => value !== undefined && value !== null)
? options
: undefined;
Expand All @@ -63,7 +78,12 @@ export function getAISDKLanguageModel(
const model =
subProvider === "openai"
? (provider as ReturnType<typeof createOpenAI>).responses(subModelName)
: provider(subModelName);
: subProvider === "orcarouter"
? // OrcaRouter routes on the full gateway-qualified model name
// (`orcarouter/fusion`, `orcarouter/deepseek/deepseek-v4-pro`), so
// send the complete model name rather than the stripped suffix.
(provider as ReturnType<typeof createOpenAI>).responses(`orcarouter/${subModelName}`)
: provider(subModelName);

if (middleware) {
return wrapLanguageModel({ model: model as never, middleware });
Expand Down
13 changes: 13 additions & 0 deletions packages/extension/llm/aiSdkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
LLMGenerateResultSchema,
ModelProviderSchema,
OpenAIModelIdSchema,
OrcaRouterModelNameSchema,
} from "../../protocol/schemas.js";
import type { LLMGenerateParams, LLMGenerateResult, ModelConfig } from "../../protocol/types.js";

Expand Down Expand Up @@ -163,6 +164,18 @@ export function createAiSdkLanguageModel(
return createGroq(connection)(GroqModelIdSchema.parse(modelId));
case "cerebras":
return createCerebras(connection)(CerebrasModelIdSchema.parse(modelId));
case "orcarouter": {
const orcaRouter = createOpenAI({
...connection,
baseURL: "https://api.orcarouter.ai/v1",
});
// OrcaRouter routes on the full gateway-qualified model name
// (`orcarouter/fusion`), so re-attach the provider prefix.
const orcaRouterModelName = OrcaRouterModelNameSchema.parse(config.modelName);
return params?.stopSequences?.length
? orcaRouter.chat(orcaRouterModelName)
: orcaRouter.responses(orcaRouterModelName);
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/extension/llm/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ export class AISdkClient extends LLMClient {
strictJsonSchema: true,
};
break;
case "orcarouter":
// OrcaRouter is an OpenAI-compatible gateway served through the
// createOpenAI factory, so its provider options ride under the openai
// key and the Responses API applies strict JSON schema.
providerOptions.openai = {
strictJsonSchema: true,
};
break;
case "mistral":
providerOptions.mistral = {
structuredOutputs: true,
Expand Down
21 changes: 21 additions & 0 deletions packages/extension/tests/ai-sdk-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ describe("AI SDK language models", () => {
modelId: "gpt-oss-120b",
provider: "cerebras.chat",
},
{
name: "OrcaRouter",
modelName: "orcarouter/auto" as const,
modelId: "orcarouter/auto",
provider: "openai.responses",
},
])("creates a direct $name model from its validated configuration", (testCase) => {
const model = createAiSdkLanguageModel({
modelName: testCase.modelName,
Expand Down Expand Up @@ -75,6 +81,21 @@ describe("AI SDK language models", () => {
});
});

it("uses Chat Completions for OrcaRouter requests with stop sequences", () => {
const model = createAiSdkLanguageModel(
{
modelName: "orcarouter/fusion",
apiKey: "provider-secret",
},
{ stopSequences: ["STOP"] },
);

expect(model).toMatchObject({
provider: "openai.chat",
modelId: "orcarouter/fusion",
});
});

it("routes a configured provider model through the AI SDK client", async () => {
vi.mocked(generateText).mockResolvedValue({
text: "Four",
Expand Down
7 changes: 7 additions & 0 deletions packages/extension/tests/model-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ModelConfigSchema,
ModelNameSchema,
OpenAIModelIdSchema,
OrcaRouterModelIdSchema,
} from "../../protocol/schemas.js";
import type { StagehandInitParams, StagehandResultMetadata } from "../../protocol/types.js";
import { createStagehandController } from "../controllers/stagehandController.js";
Expand Down Expand Up @@ -64,6 +65,7 @@ describe("model configuration", () => {
["google", GoogleModelIdSchema.options],
["groq", GroqModelIdSchema.options],
["cerebras", CerebrasModelIdSchema.options],
["orcarouter", OrcaRouterModelIdSchema.options],
] as const;

for (const [provider, modelIds] of providers) {
Expand All @@ -77,6 +79,11 @@ describe("model configuration", () => {
expect(ModelNameSchema.safeParse("groq/openai/gpt-oss-120b").success).toBe(true);
});

it("accepts an OrcaRouter gateway alias and a vendor-prefixed model", () => {
expect(ModelNameSchema.safeParse("orcarouter/auto").success).toBe(true);
expect(ModelNameSchema.safeParse("orcarouter/deepseek/deepseek-v4-pro").success).toBe(true);
});

it("rejects a model from an unsupported provider", () => {
expect(ModelNameSchema.safeParse("bedrock/anthropic.claude-sonnet-v1:0").success).toBe(false);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/core/src/facade/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ function providerApiKey(provider: string | undefined, env: NodeJS.ProcessEnv): s
return nonEmpty(env.GROQ_API_KEY);
case "cerebras":
return nonEmpty(env.CEREBRAS_API_KEY);
case "orcarouter":
return nonEmpty(env.ORCAROUTER_API_KEY);
default:
return undefined;
}
Expand Down
21 changes: 21 additions & 0 deletions packages/integrations/core/tests/facade-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,25 @@ describe("Stagehand facade contract", () => {
}).success,
).toBe(false);
});

it("resolves an OrcaRouter model key from STAGEHAND_MODEL_NAME", () => {
const config = stagehandFacadeConfigFromEnv({
BROWSERBASE_API_KEY: "bb-test",
STAGEHAND_MODEL_NAME: "orcarouter/auto",
ORCAROUTER_API_KEY: "sk-orca-test",
});
expect(config.stagehand.model).toMatchObject({
modelName: "orcarouter/auto",
apiKey: "sk-orca-test",
});
});

it("rejects an unsupported OrcaRouter model name", () => {
expect(() =>
stagehandFacadeConfigFromEnv({
BROWSERBASE_API_KEY: "bb-test",
STAGEHAND_MODEL_NAME: "orcarouter/not-a-real-model",
}),
).toThrow(/Unsupported STAGEHAND_MODEL_NAME/);
});
});
Loading
Loading