diff --git a/.env.example b/.env.example index 3b5e099..9141722 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,9 @@ OPENAI_API_KEY=your_openai_api_key_here # Optional: point the OpenAI provider at an OpenAI-compatible endpoint # (Azure, a gateway, local server...) # OPENAI_BASE_URL=https://api.openai.com/v1 + +# Requesty API key (optional). Only used when you opt in with +# `--provider requesty` / ZEROLEAKS_PROVIDER=requesty or a `requesty/` model id +# prefix. Keys: https://app.requesty.ai/api-keys +# REQUESTY_API_KEY=your_requesty_api_key_here +# ZEROLEAKS_PROVIDER=requesty diff --git a/AGENTS.md b/AGENTS.md index 0383730..b2d41a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,8 @@ Copy `.env.example` to `.env` and set: - `OPENROUTER_API_KEY` - default provider for all models - `OPENAI_API_KEY` - optional; routes `openai/*` and `gpt-*`/`o*` model ids to the OpenAI API - `OPENAI_BASE_URL` - optional; override the OpenAI endpoint (Azure, gateway, local) +- `REQUESTY_API_KEY` - optional; key for the Requesty router (`--provider requesty` or `requesty/*` model ids) +- `ZEROLEAKS_PROVIDER` - optional; `openrouter` (default) or `requesty`, same as the CLI `--provider` flag At least one key is required. @@ -56,7 +58,7 @@ src/ ├── knowledge/ # Attack techniques & bypass methods ├── probes/ # Attack probes (injection corpus + extraction arsenal) ├── index.ts # Main exports (public API) -├── provider.ts # Model provider resolver (OpenRouter default, OpenAI direct) +├── provider.ts # Model provider resolver (OpenRouter default, OpenAI direct, Requesty opt-in) ├── types.ts # TypeScript type definitions ├── ui.ts # Terminal output helpers (colors, score bar, boxes) └── utils.ts # Utility functions @@ -66,6 +68,8 @@ src/ Agents never construct a provider directly — they call `resolveModel(modelId, { openrouterApiKey })`. It routes OpenAI-style ids (`openai/*`, `gpt-*`, `o1/o3/o4-*`) to the OpenAI API when `OPENAI_API_KEY` is set, and everything else (plus OpenAI ids with no OpenAI key) through OpenRouter. To add another provider, extend `resolveModel` — don't reintroduce `createOpenRouter` in the agents. +Requesty is the one alternative router and is opt-in only: `resolveModel` sends a model to Requesty (`https://router.requesty.ai/v1`, `REQUESTY_API_KEY`) when the id has a `requesty/` prefix, when `opts.provider` is `"requesty"`, or when `ZEROLEAKS_PROVIDER=requesty` (set by the CLI from `--provider`). Because every agent goes through `resolveModel`, this covers the attacker, evaluator, strategist, mutator, inspector, injection evaluator and target alike. `tests/provider.test.ts` covers the selection rules and the CLI validation. + ### Agent System (`src/agents/`) The scanner uses a multi-agent architecture: diff --git a/README.md b/README.md index e47c21c..353bdca 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ zeroleaks techniques | `--max-probes ` | Cap injection probes (0 = all, default 20; severity-ordered) | | `--no-multi-turn` | Skip multi-turn grooming probes | | `--injection-model ` | Model for the compliance judge (defaults to the evaluator model) | +| `--provider ` | Router for non-OpenAI models: `openrouter` (default) or `requesty` | +| `--requesty-api-key ` | Requesty key (or `REQUESTY_API_KEY`); used with `--provider requesty` or `requesty/*` ids | | `-o, --output ` | Write the full JSON result to a file | | `--json` | Print the result as JSON to stdout | | `--no-color` / `-q, --quiet` | Disable color / suppress the progress spinner | @@ -248,6 +250,17 @@ zeroleaks scan -f ./prompt.txt \ --attacker-model "anthropic/claude-opus-4.8" ``` +[Requesty](https://requesty.ai) is supported as an alternative router. It is never picked automatically: pass `--provider requesty` (or set `ZEROLEAKS_PROVIDER=requesty`) to send every non-OpenAI model through Requesty, or prefix a single model id with `requesty/` to route just that agent. Model ids use the same `provider/model` format, see the [model library](https://app.requesty.ai/model-library). + +```bash +# Every agent through Requesty +export REQUESTY_API_KEY=... +zeroleaks scan -f ./prompt.txt --provider requesty + +# Only the target through Requesty, everything else stays on OpenRouter +zeroleaks scan -f ./prompt.txt --target-model "requesty/anthropic/claude-sonnet-4-5" +``` + ## Environment Variables | Variable | Description | @@ -255,8 +268,10 @@ zeroleaks scan -f ./prompt.txt \ | `OPENROUTER_API_KEY` | OpenRouter key; used for all models by default | | `OPENAI_API_KEY` | Optional. Routes `openai/*` and `gpt-*`/`o*` models to the OpenAI API | | `OPENAI_BASE_URL` | Optional. Override the OpenAI endpoint (e.g. Azure) | +| `REQUESTY_API_KEY` | Optional. Requesty key; used with `--provider requesty` or `requesty/*` model ids | +| `ZEROLEAKS_PROVIDER` | Optional. `openrouter` (default) or `requesty`; same as `--provider` | -At least one key is required. Get an OpenRouter key at [openrouter.ai](https://openrouter.ai). +At least one key is required. Get an OpenRouter key at [openrouter.ai](https://openrouter.ai) or a Requesty key at [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys). ## Research references diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 64906e9..462745a 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -4,6 +4,7 @@ import { writeFileSync } from "fs"; import { Command } from "commander"; import ora from "ora"; import { runSecurityScan } from "../agents"; +import { parseProvider, type RouterProvider } from "../provider"; import type { InjectionTestResult, ScanResult } from "../types"; import { BANNER, @@ -69,6 +70,14 @@ program "--openai-api-key ", "OpenAI API key (or set OPENAI_API_KEY); openai/* and gpt-* models route to the OpenAI API", ) + .option( + "--requesty-api-key ", + "Requesty API key (or set REQUESTY_API_KEY); used with --provider requesty or requesty/* model ids", + ) + .option( + "--provider ", + "Router for models not sent to the OpenAI API: openrouter (default) or requesty", + ) .option( "--attacker-model ", `Model for the attacker agent (default: ${DEFAULT_MODELS.attacker})`, @@ -126,18 +135,40 @@ program process.exit(1); } + let provider: RouterProvider; + try { + provider = parseProvider( + options.provider ?? process.env.ZEROLEAKS_PROVIDER, + ); + } catch (err) { + console.error(c.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + const apiKey = options.apiKey || process.env.OPENROUTER_API_KEY; const openaiApiKey = options.openaiApiKey || process.env.OPENAI_API_KEY; - if (!apiKey && !openaiApiKey) { + const requestyApiKey = + options.requestyApiKey || process.env.REQUESTY_API_KEY; + if (!apiKey && !openaiApiKey && !requestyApiKey) { + console.error( + c.red( + "Error: no API key. Set OPENROUTER_API_KEY (--api-key), OPENAI_API_KEY (--openai-api-key) and/or REQUESTY_API_KEY (--requesty-api-key).", + ), + ); + process.exit(1); + } + if (provider === "requesty" && !requestyApiKey) { console.error( c.red( - "Error: no API key. Set OPENROUTER_API_KEY (--api-key) and/or OPENAI_API_KEY (--openai-api-key).", + "Error: --provider requesty needs a Requesty API key. Set REQUESTY_API_KEY or use --requesty-api-key.", ), ); process.exit(1); } if (apiKey) process.env.OPENROUTER_API_KEY = apiKey; if (openaiApiKey) process.env.OPENAI_API_KEY = openaiApiKey; + if (requestyApiKey) process.env.REQUESTY_API_KEY = requestyApiKey; + process.env.ZEROLEAKS_PROVIDER = provider; const mode = (options.mode || "dual") as | "extraction" diff --git a/src/provider.ts b/src/provider.ts index c7e02f8..4dd5b53 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -2,9 +2,26 @@ import { createOpenAI } from "@ai-sdk/openai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import type { LanguageModelV1 } from "ai"; +/** Routers that can serve the model ids not sent to the OpenAI API directly. */ +export type RouterProvider = "openrouter" | "requesty"; + +export const ROUTER_PROVIDERS: readonly RouterProvider[] = [ + "openrouter", + "requesty", +]; + +export const REQUESTY_BASE_URL = "https://router.requesty.ai/v1"; + export interface ProviderKeys { openrouterApiKey?: string; openaiApiKey?: string; + requestyApiKey?: string; + /** + * Router for ids that do not go to the OpenAI API. Defaults to OpenRouter; + * falls back to `ZEROLEAKS_PROVIDER` when unset. Requesty is only used when + * selected here, via that env var, or via a `requesty/` model id prefix. + */ + provider?: RouterProvider; } /** @@ -20,6 +37,31 @@ export function isOpenAiModel(model: string): boolean { ); } +/** + * Does this model id carry an explicit `requesty/` prefix? The prefix is + * stripped before the id is sent to Requesty, so `requesty/openai/gpt-5` + * becomes `openai/gpt-5` on the Requesty side. + */ +export function isRequestyModel(model: string): boolean { + return model.trim().toLowerCase().startsWith("requesty/"); +} + +/** + * Validate a router provider name (CLI flag or `ZEROLEAKS_PROVIDER`). + * An empty value means the default, OpenRouter; anything unknown throws so a + * typo does not silently fall back to another provider. + */ +export function parseProvider(value?: string): RouterProvider { + const v = (value ?? "").trim().toLowerCase(); + if (v === "") return "openrouter"; + if ((ROUTER_PROVIDERS as readonly string[]).includes(v)) { + return v as RouterProvider; + } + throw new Error( + `Unknown provider "${value}". Valid values: ${ROUTER_PROVIDERS.join(", ")}`, + ); +} + /** * Resolve a model id to an AI SDK model. * @@ -28,6 +70,10 @@ export function isOpenAiModel(model: string): boolean { * ids with no OpenAI key — goes through OpenRouter, preserving prior behavior. * Set `OPENAI_BASE_URL` to target an OpenAI-compatible endpoint (Azure, a * gateway, a local server). + * + * Requesty replaces OpenRouter as that router only when chosen explicitly: + * `opts.provider`, `ZEROLEAKS_PROVIDER=requesty`, or a `requesty/` model id + * prefix. Its key comes from `opts.requestyApiKey` or `REQUESTY_API_KEY`. */ export function resolveModel( model: string, @@ -43,6 +89,19 @@ export function resolveModel( return openai(model.replace(/^openai\//, "")); } + const provider = + opts.provider ?? parseProvider(process.env.ZEROLEAKS_PROVIDER); + + if (provider === "requesty" || isRequestyModel(model)) { + const requesty = createOpenAI({ + name: "requesty", + apiKey: opts.requestyApiKey ?? process.env.REQUESTY_API_KEY, + baseURL: REQUESTY_BASE_URL, + compatibility: "compatible", + }); + return requesty.chat(model.trim().replace(/^requesty\//i, "")); + } + const openrouter = createOpenRouter({ apiKey: opts.openrouterApiKey ?? process.env.OPENROUTER_API_KEY, }); diff --git a/tests/provider.test.ts b/tests/provider.test.ts new file mode 100644 index 0000000..959a713 --- /dev/null +++ b/tests/provider.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { isRequestyModel, parseProvider, resolveModel } from "../src/provider"; + +const ENV_KEYS = [ + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "REQUESTY_API_KEY", + "ZEROLEAKS_PROVIDER", +] as const; +const saved: Record = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } +}); + +describe("parseProvider", () => { + test("defaults to openrouter and accepts known names", () => { + expect(parseProvider(undefined)).toBe("openrouter"); + expect(parseProvider("")).toBe("openrouter"); + expect(parseProvider(" OpenRouter ")).toBe("openrouter"); + expect(parseProvider("requesty")).toBe("requesty"); + }); + + test("rejects unknown providers instead of falling back", () => { + expect(() => parseProvider("gpt")).toThrow(/Unknown provider "gpt"/); + }); +}); + +describe("resolveModel", () => { + test("uses OpenRouter by default, even when only a Requesty key is set", () => { + process.env.REQUESTY_API_KEY = "rq-test"; + const model = resolveModel("anthropic/claude-sonnet-5"); + expect(model.provider).toStartWith("openrouter"); + expect(model.modelId).toBe("anthropic/claude-sonnet-5"); + }); + + test("requesty/ prefix routes that model to Requesty and strips the prefix", () => { + process.env.OPENROUTER_API_KEY = "sk-or-test"; + process.env.REQUESTY_API_KEY = "rq-test"; + expect(isRequestyModel("anthropic/claude-sonnet-5")).toBe(false); + const model = resolveModel("requesty/anthropic/claude-sonnet-4-5"); + expect(model.provider).toStartWith("requesty"); + expect(model.modelId).toBe("anthropic/claude-sonnet-4-5"); + }); + + test("provider option or ZEROLEAKS_PROVIDER routes every agent model to Requesty", () => { + process.env.REQUESTY_API_KEY = "rq-test"; + const viaOption = resolveModel("anthropic/claude-sonnet-5", { + provider: "requesty", + }); + expect(viaOption.provider).toStartWith("requesty"); + expect(viaOption.modelId).toBe("anthropic/claude-sonnet-5"); + + process.env.ZEROLEAKS_PROVIDER = "requesty"; + for (const id of ["anthropic/claude-opus-4.8", "x-ai/grok-4"]) { + expect(resolveModel(id).provider).toStartWith("requesty"); + } + }); + + test("OpenAI ids keep the OpenAI direct path when an OpenAI key is set", () => { + process.env.REQUESTY_API_KEY = "rq-test"; + const routed = resolveModel("openai/gpt-5", { provider: "requesty" }); + expect(routed.provider).toStartWith("requesty"); + expect(routed.modelId).toBe("openai/gpt-5"); + + process.env.OPENAI_API_KEY = "sk-test"; + const direct = resolveModel("openai/gpt-5", { provider: "requesty" }); + expect(direct.provider).toStartWith("openai"); + expect(direct.modelId).toBe("gpt-5"); + }); +}); + +describe("cli provider selection", () => { + const runCli = (...args: string[]) => { + const env: Record = { ...process.env }; + for (const key of ENV_KEYS) delete env[key]; + return Bun.spawnSync({ + cmd: ["bun", "src/bin/cli.ts", "scan", "--prompt", "test", ...args], + cwd: `${import.meta.dir}/..`, + env, + stderr: "pipe", + stdout: "pipe", + }); + }; + + test("rejects an unknown --provider", () => { + const result = runCli("--provider", "gpt", "--api-key", "sk-or-test"); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain('Unknown provider "gpt"'); + }); + + test("requires a Requesty key with --provider requesty", () => { + const result = runCli("--provider", "requesty", "--api-key", "sk-or-test"); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Requesty API key"); + }); +});