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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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:
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ zeroleaks techniques
| `--max-probes <n>` | Cap injection probes (0 = all, default 20; severity-ordered) |
| `--no-multi-turn` | Skip multi-turn grooming probes |
| `--injection-model <model>` | Model for the compliance judge (defaults to the evaluator model) |
| `--provider <provider>` | Router for non-OpenAI models: `openrouter` (default) or `requesty` |
| `--requesty-api-key <key>` | Requesty key (or `REQUESTY_API_KEY`); used with `--provider requesty` or `requesty/*` ids |
| `-o, --output <file>` | 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 |
Expand Down Expand Up @@ -248,15 +250,28 @@ 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 |
|----------|-------------|
| `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

Expand Down
35 changes: 33 additions & 2 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +70,14 @@ program
"--openai-api-key <key>",
"OpenAI API key (or set OPENAI_API_KEY); openai/* and gpt-* models route to the OpenAI API",
)
.option(
"--requesty-api-key <key>",
"Requesty API key (or set REQUESTY_API_KEY); used with --provider requesty or requesty/* model ids",
)
.option(
"--provider <provider>",
"Router for models not sent to the OpenAI API: openrouter (default) or requesty",
)
.option(
"--attacker-model <model>",
`Model for the attacker agent (default: ${DEFAULT_MODELS.attacker})`,
Expand Down Expand Up @@ -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"
Expand Down
59 changes: 59 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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.
*
Expand All @@ -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,
Expand All @@ -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,
});
Expand Down
108 changes: 108 additions & 0 deletions tests/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};

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<string, string | undefined> = { ...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");
});
});