From 94efded0ca119ce7a006552a1f8ef63d87db8d73 Mon Sep 17 00:00:00 2001 From: Thibault Jaigu Date: Wed, 1 Jul 2026 12:16:51 +0100 Subject: [PATCH] Add Requesty as an opt-in provider option Requesty (https://router.requesty.ai/v1) is an OpenAI-compatible LLM router. It is wired into resolveModel, the single place every agent (attacker, evaluator, strategist, mutator, inspector, injection evaluator and target) gets its model from, so no agent code changes. Requesty is never picked automatically. It is used only when selected explicitly: the CLI flag --provider requesty (or ZEROLEAKS_PROVIDER), opts.provider on resolveModel, or a requesty/ prefix on a single model id. Unknown --provider values are rejected instead of falling back. The key comes from REQUESTY_API_KEY or --requesty-api-key. OpenRouter stays the default and the OpenAI direct path is unchanged. Adds tests/provider.test.ts covering the selection rules and the CLI validation, plus README, AGENTS.md and .env.example notes. --- .env.example | 6 +++ AGENTS.md | 6 ++- README.md | 17 ++++++- src/bin/cli.ts | 35 ++++++++++++- src/provider.ts | 59 ++++++++++++++++++++++ tests/provider.test.ts | 108 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 tests/provider.test.ts 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"); + }); +});