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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ E2B_API_KEY=your_e2b_api_key
OPENAI_API_KEY=your_openai_api_key
```

To route the same AI workflow through a LiteLLM proxy instead:

```env
AI_PROVIDER=litellm
LITELLM_API_KEY=your_proxy_key
LITELLM_BASE_URL=http://localhost:4000/v1
LITELLM_MODEL=your_proxy_model_alias
```

`LITELLM_BASE_URL` defaults to `http://localhost:4000/v1`. The OpenAI setup
remains the default when `AI_PROVIDER` is unset. The configured proxy alias
must support the OpenAI Responses API and the request tools used by Surf.
Provider-specific tool formats that are not OpenAI-compatible may not work.

4. **Start the development server**
```bash
npm run dev
Expand Down
41 changes: 41 additions & 0 deletions lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { getAIModel, getAIProvider, getOpenAIClientOptions } from "./config";

describe("LiteLLM configuration", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("normalizes the proxy URL and returns the configured key and alias", () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_API_KEY", "sk-proxy");
vi.stubEnv("LITELLM_BASE_URL", "https://proxy.example/v1/");
vi.stubEnv("LITELLM_MODEL", "claude-fallback");

expect(getAIProvider()).toBe("litellm");
expect(getOpenAIClientOptions()).toEqual({
apiKey: "sk-proxy",
baseURL: "https://proxy.example/v1",
});
expect(getAIModel()).toBe("claude-fallback");
});

it("rejects an unsupported provider", () => {
vi.stubEnv("AI_PROVIDER", "unknown");
expect(() => getAIProvider()).toThrow("AI_PROVIDER must be either");
});

it("requires a dedicated LiteLLM API key", () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("OPENAI_API_KEY", "must-not-be-used");
vi.stubEnv("LITELLM_API_KEY", "");
expect(() => getOpenAIClientOptions()).toThrow("LITELLM_API_KEY is required");
});

it("requires an explicit LiteLLM model alias", () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_MODEL", "");
expect(() => getAIModel()).toThrow("LITELLM_MODEL is required");
});
});
43 changes: 41 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,44 @@ export const MIN_RESOLUTION_HEIGHT = 480;
// otherwise it will be scaled automatically
export const DEFAULT_RESOLUTION: [number, number] = [1024, 720];

// Model identifier
export const OPENAI_MODEL = "gpt-5.4";
export type AIProvider = "openai" | "litellm";

export function getAIProvider(): AIProvider {
const provider = (process.env.AI_PROVIDER || "openai").toLowerCase();
if (provider !== "openai" && provider !== "litellm") {
throw new Error("AI_PROVIDER must be either 'openai' or 'litellm'");
}
return provider;
}

export function getOpenAIClientOptions(): {
apiKey?: string;
baseURL?: string;
} {
if (getAIProvider() === "litellm") {
const apiKey = process.env.LITELLM_API_KEY;
if (!apiKey) {
throw new Error("LITELLM_API_KEY is required when AI_PROVIDER=litellm");
}
return {
apiKey,
baseURL: (process.env.LITELLM_BASE_URL || "http://localhost:4000/v1").replace(
/\/$/,
""
),
};
}

return {};
}

export function getAIModel(): string {
if (getAIProvider() === "litellm") {
const model = process.env.LITELLM_MODEL;
if (!model) {
throw new Error("LITELLM_MODEL is required when AI_PROVIDER=litellm");
}
return model;
}
return process.env.OPENAI_MODEL || "gpt-5.4";
}
138 changes: 138 additions & 0 deletions lib/streaming/openai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import OpenAI from "openai";
import { afterEach, describe, expect, it, vi } from "vitest";

import { SSEEventType } from "@/types/api";
import { OpenAIComputerStreamer } from "./openai";

function response(text = "OK") {
return {
id: "resp-test",
output: [
{
id: "msg-test",
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text, annotations: [] }],
},
],
output_text: text,
};
}

async function collect(streamer: OpenAIComputerStreamer) {
const events = [];
for await (const event of streamer.stream({
messages: [{ role: "user", content: "hello" }],
signal: new AbortController().signal,
})) {
events.push(event);
}
return events;
}

function streamerWith(create: ReturnType<typeof vi.fn>) {
const client = { responses: { create } } as unknown as OpenAI;
return new OpenAIComputerStreamer({} as never, [1024, 720], client);
}

describe("LiteLLM Responses streamer", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("passes the proxy alias and consumes the final response structure", async () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_MODEL", "proxy-model");
const create = vi.fn().mockResolvedValue(response());

const events = await collect(streamerWith(create));

expect(create).toHaveBeenCalledWith(
expect.objectContaining({ model: "proxy-model" }),
);
expect(events).toEqual([
{ type: SSEEventType.REASONING, content: "OK" },
{ type: SSEEventType.DONE },
]);
});

it.each([
[401, "invalid API key"],
[404, "model not found"],
[400, "context window exceeded"],
])("gracefully reports API status %i: %s", async (status) => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_MODEL", "proxy-model");
const error = new OpenAI.APIError(status, { message: "rejected" }, "rejected", {});

const events = await collect(streamerWith(vi.fn().mockRejectedValue(error)));

expect(events).toEqual([
{
type: SSEEventType.ERROR,
content: "An error occurred with the AI service. Please try again.",
},
]);
});

it("reports rate limiting with the existing quota guidance", async () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_MODEL", "proxy-model");
const error = new OpenAI.APIError(429, { message: "rate limited" }, "rate limited", {});

const events = await collect(streamerWith(vi.fn().mockRejectedValue(error)));

expect(events[0]).toMatchObject({ type: SSEEventType.ERROR });
expect(events[0]).toHaveProperty("content", expect.stringContaining("quota"));
expect(events[1]).toEqual({ type: SSEEventType.DONE });
});

it("rejects an empty provider response", async () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_MODEL", "proxy-model");
const empty = { id: "resp-empty", output: [], output_text: "" };

const events = await collect(
streamerWith(vi.fn().mockResolvedValue(empty)),
);

expect(events).toEqual([
{
type: SSEEventType.ERROR,
content: "An error occurred with the AI service. Please try again.",
},
]);
});

it("gracefully reports timeout and malformed-response failures", async () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_MODEL", "proxy-model");
for (const error of [new Error("request timed out"), new Error("malformed response")]) {
const events = await collect(
streamerWith(vi.fn().mockRejectedValue(error)),
);
expect(events[0]).toMatchObject({ type: SSEEventType.ERROR });
}
});
});

describe.skipIf(
!process.env.LITELLM_E2E_BASE_URL ||
!process.env.LITELLM_E2E_API_KEY ||
!process.env.LITELLM_E2E_MODEL,
)("LiteLLM live E2E", () => {
it("consumes a real proxy Responses API result", async () => {
vi.stubEnv("AI_PROVIDER", "litellm");
vi.stubEnv("LITELLM_API_KEY", process.env.LITELLM_E2E_API_KEY);
vi.stubEnv("LITELLM_BASE_URL", process.env.LITELLM_E2E_BASE_URL);
vi.stubEnv("LITELLM_MODEL", process.env.LITELLM_E2E_MODEL);

const events = await collect(
new OpenAIComputerStreamer({} as never, [1024, 720]),
);

expect(events.some((event) => event.type === SSEEventType.REASONING)).toBe(true);
expect(events.at(-1)).toEqual({ type: SSEEventType.DONE });
}, 60_000);
});
20 changes: 14 additions & 6 deletions lib/streaming/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@/lib/streaming";
import { ActionResponse } from "@/types/api";
import { logDebug, logError, logWarning } from "../logger";
import { OPENAI_MODEL } from "../config";
import { getAIModel, getOpenAIClientOptions } from "../config";
import {
NormalizedOpenAIComputerCall,
OpenAIComputerAction,
Expand Down Expand Up @@ -196,10 +196,14 @@ export class OpenAIComputerStreamer

private openai: OpenAI;

constructor(desktop: Sandbox, resolution: [number, number]) {
constructor(
desktop: Sandbox,
resolution: [number, number],
openaiClient?: OpenAI
) {
this.desktop = desktop;
this.resolution = resolution;
this.openai = new OpenAI();
this.openai = openaiClient ?? new OpenAI(getOpenAIClientOptions());
this.instructions = INSTRUCTIONS;
}

Expand Down Expand Up @@ -337,6 +341,7 @@ export class OpenAIComputerStreamer
props: ComputerInteractionStreamerFacadeStreamProps
): AsyncGenerator<SSEEvent> {
const { messages, signal } = props;
const model = getAIModel();
const traceId = `openai-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
Expand All @@ -350,7 +355,7 @@ export class OpenAIComputerStreamer

logDebug("OPENAI_COMPUTER_STREAM_START", {
traceId,
model: OPENAI_MODEL,
model,
resolution: this.resolution,
message_count: messages.length,
last_user_message_preview:
Expand All @@ -361,7 +366,7 @@ export class OpenAIComputerStreamer
});

let response = await this.openai.responses.create({
model: OPENAI_MODEL,
model,
tools: [computerTool],
input: [...(messages as ResponseInput)],
truncation: "auto",
Expand Down Expand Up @@ -406,6 +411,9 @@ export class OpenAIComputerStreamer
});

if (computerCalls.length === 0) {
if (!response.output_text?.trim()) {
throw new Error("AI response did not include text or actions");
}
logDebug("OPENAI_RESPONSE_FINAL", {
traceId,
turnIndex,
Expand Down Expand Up @@ -579,7 +587,7 @@ export class OpenAIComputerStreamer
});

response = await this.openai.responses.create({
model: OPENAI_MODEL,
model,
previous_response_id: response.id,
instructions: this.instructions,
tools: [computerTool],
Expand Down
Loading