diff --git a/src/services/agents/claude-computer-use.ts b/src/services/agents/claude-computer-use.ts index 874d981..e658454 100644 --- a/src/services/agents/claude-computer-use.ts +++ b/src/services/agents/claude-computer-use.ts @@ -9,6 +9,7 @@ import { StartClaudeComputerUseTaskParams, StartClaudeComputerUseTaskResponse, } from "../../types/agents/claude-computer-use"; +import { validateCustomApiKeys } from "./validation"; export class ClaudeComputerUseService extends BaseService { /** @@ -19,6 +20,9 @@ export class ClaudeComputerUseService extends BaseService { params: StartClaudeComputerUseTaskParams ): Promise { try { + validateCustomApiKeys(params.useCustomApiKeys, params.apiKeys, { + anthropicBaseUrl: params.apiKeys?.anthropicBaseUrl, + }); return await this.request("/task/claude-computer-use", { method: "POST", body: JSON.stringify(params), diff --git a/src/services/agents/cua.ts b/src/services/agents/cua.ts index cb2080b..e6d7bcd 100644 --- a/src/services/agents/cua.ts +++ b/src/services/agents/cua.ts @@ -9,6 +9,7 @@ import { StartCuaTaskParams, StartCuaTaskResponse, } from "../../types/agents/cua"; +import { validateCustomApiKeys } from "./validation"; export class CuaService extends BaseService { /** @@ -17,6 +18,9 @@ export class CuaService extends BaseService { */ async start(params: StartCuaTaskParams): Promise { try { + validateCustomApiKeys(params.useCustomApiKeys, params.apiKeys, { + openaiBaseUrl: params.apiKeys?.openaiBaseUrl, + }); return await this.request("/task/cua", { method: "POST", body: JSON.stringify(params), diff --git a/src/services/agents/gemini-computer-use.ts b/src/services/agents/gemini-computer-use.ts index 48c5bcc..0af9b5a 100644 --- a/src/services/agents/gemini-computer-use.ts +++ b/src/services/agents/gemini-computer-use.ts @@ -9,6 +9,7 @@ import { StartGeminiComputerUseTaskParams, StartGeminiComputerUseTaskResponse, } from "../../types/agents/gemini-computer-use"; +import { validateCustomApiKeys } from "./validation"; export class GeminiComputerUseService extends BaseService { /** @@ -19,6 +20,9 @@ export class GeminiComputerUseService extends BaseService { params: StartGeminiComputerUseTaskParams ): Promise { try { + validateCustomApiKeys(params.useCustomApiKeys, params.apiKeys, { + googleBaseUrl: params.apiKeys?.googleBaseUrl, + }); return await this.request("/task/gemini-computer-use", { method: "POST", body: JSON.stringify(params), diff --git a/src/services/agents/validation.ts b/src/services/agents/validation.ts new file mode 100644 index 0000000..c910e51 --- /dev/null +++ b/src/services/agents/validation.ts @@ -0,0 +1,26 @@ +import { HyperbrowserError } from "../../client"; + +const isAbsoluteHttpUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}; + +export const validateCustomApiKeys = ( + useCustomApiKeys: boolean | undefined, + apiKeys: object | undefined, + baseUrls: Record +): void => { + if (useCustomApiKeys && !apiKeys) { + throw new HyperbrowserError("apiKeys must be provided when useCustomApiKeys is true"); + } + + Object.entries(baseUrls).forEach(([field, value]) => { + if (value !== undefined && !isAbsoluteHttpUrl(value)) { + throw new HyperbrowserError(`${field} must be an absolute http or https URL`); + } + }); +}; diff --git a/src/types/agents/claude-computer-use.ts b/src/types/agents/claude-computer-use.ts index 37181c8..d011bb0 100644 --- a/src/types/agents/claude-computer-use.ts +++ b/src/types/agents/claude-computer-use.ts @@ -3,6 +3,7 @@ import { CreateSessionParams } from "../session"; export interface ClaudeComputerUseApiKeys { anthropic?: string; + anthropicBaseUrl?: string; } export interface StartClaudeComputerUseTaskParams { diff --git a/src/types/agents/cua.ts b/src/types/agents/cua.ts index 916b190..51523d4 100644 --- a/src/types/agents/cua.ts +++ b/src/types/agents/cua.ts @@ -3,6 +3,7 @@ import { CreateSessionParams } from "../session"; export interface CuaApiKeys { openai?: string; + openaiBaseUrl?: string; } export interface StartCuaTaskParams { diff --git a/src/types/agents/gemini-computer-use.ts b/src/types/agents/gemini-computer-use.ts index 090cfce..4e17677 100644 --- a/src/types/agents/gemini-computer-use.ts +++ b/src/types/agents/gemini-computer-use.ts @@ -3,6 +3,7 @@ import { CreateSessionParams } from "../session"; export interface GeminiComputerUseApiKeys { google?: string; + googleBaseUrl?: string; } export interface StartGeminiComputerUseTaskParams { diff --git a/tests/sandbox/e2e/computer-use-contract.test.ts b/tests/sandbox/e2e/computer-use-contract.test.ts new file mode 100644 index 0000000..74fcc8e --- /dev/null +++ b/tests/sandbox/e2e/computer-use-contract.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test, vi } from "vitest"; +import { HyperbrowserError } from "../../../src/client"; +import { ClaudeComputerUseService } from "../../../src/services/agents/claude-computer-use"; +import { CuaService } from "../../../src/services/agents/cua"; +import { GeminiComputerUseService } from "../../../src/services/agents/gemini-computer-use"; + +describe("computer use agent control contracts", () => { + test("CUA forwards custom OpenAI API key and base URL", async () => { + const service = new CuaService("test-key", "https://api.example.com", 30_000); + const payload = { jobId: "job_123", liveUrl: null }; + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(payload); + + const response = await service.start({ + task: "go to example.com", + useCustomApiKeys: true, + apiKeys: { + openai: "sk-test", + openaiBaseUrl: "https://openai-compatible.example.com/v1", + }, + }); + + expect(requestSpy).toHaveBeenCalledWith("/task/cua", { + method: "POST", + body: JSON.stringify({ + task: "go to example.com", + useCustomApiKeys: true, + apiKeys: { + openai: "sk-test", + openaiBaseUrl: "https://openai-compatible.example.com/v1", + }, + }), + }); + expect(response).toEqual(payload); + }); + + test("Claude Computer Use forwards custom Anthropic API key and base URL", async () => { + const service = new ClaudeComputerUseService("test-key", "https://api.example.com", 30_000); + const payload = { jobId: "job_123", liveUrl: null }; + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(payload); + + const response = await service.start({ + task: "go to example.com", + useCustomApiKeys: true, + apiKeys: { + anthropic: "sk-ant-test", + anthropicBaseUrl: "https://anthropic-compatible.example.com", + }, + }); + + expect(requestSpy).toHaveBeenCalledWith("/task/claude-computer-use", { + method: "POST", + body: JSON.stringify({ + task: "go to example.com", + useCustomApiKeys: true, + apiKeys: { + anthropic: "sk-ant-test", + anthropicBaseUrl: "https://anthropic-compatible.example.com", + }, + }), + }); + expect(response).toEqual(payload); + }); + + test("Gemini Computer Use forwards custom Google API key and base URL", async () => { + const service = new GeminiComputerUseService("test-key", "https://api.example.com", 30_000); + const payload = { jobId: "job_123", liveUrl: null }; + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(payload); + + const response = await service.start({ + task: "go to example.com", + useCustomApiKeys: true, + apiKeys: { + google: "google-test", + googleBaseUrl: "https://gemini-compatible.example.com", + }, + }); + + expect(requestSpy).toHaveBeenCalledWith("/task/gemini-computer-use", { + method: "POST", + body: JSON.stringify({ + task: "go to example.com", + useCustomApiKeys: true, + apiKeys: { + google: "google-test", + googleBaseUrl: "https://gemini-compatible.example.com", + }, + }), + }); + expect(response).toEqual(payload); + }); + + test("custom API key mode requires apiKeys", async () => { + const service = new CuaService("test-key", "https://api.example.com", 30_000); + + await expect( + service.start({ + task: "go to example.com", + useCustomApiKeys: true, + }) + ).rejects.toThrow(HyperbrowserError); + }); + + test("provider base URLs must be absolute http or https URLs", async () => { + const service = new CuaService("test-key", "https://api.example.com", 30_000); + + await expect( + service.start({ + task: "go to example.com", + apiKeys: { + openaiBaseUrl: "localhost:3000/v1", + }, + }) + ).rejects.toThrow("openaiBaseUrl must be an absolute http or https URL"); + }); +});