Skip to content
Merged
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
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ OPENROUTER_API_KEY=your_openrouter_api_key_here
# are sent to the OpenAI API directly instead of through OpenRouter.
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
# Optional: an OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, Azure, a
# gateway...). Same as --base-url. With no OpenRouter key, every model goes
# here; with one, only OpenAI-style and `openai/`-prefixed ids do. Local
# servers don't need OPENAI_API_KEY.
# OPENAI_BASE_URL=http://localhost:11434/v1
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ bun test
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)
- `OPENAI_BASE_URL` - optional; an OpenAI-compatible endpoint (same as `--base-url`)

At least one key is required.
At least one key is required, or a base URL for a keyless local server.

## Project Architecture

Expand All @@ -64,7 +64,7 @@ src/

### Providers (`src/provider.ts`)

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.
Agents never construct a provider directly — they call `resolveModel(modelId, { openrouterApiKey })`. Without `OPENAI_BASE_URL`, 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. With `OPENAI_BASE_URL`, OpenAI-style ids always go to that endpoint, and so does every other id when there's no OpenRouter key. A leading `openai/` is stripped before the request. To add another provider, extend `resolveModel` — don't reintroduce `createOpenRouter` in the agents.

### Agent System (`src/agents/`)

Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ zeroleaks techniques
| `--no-multi-turn` | Skip multi-turn grooming probes |
| `-d, --duration <ms>` | Time budget; 0 = no limit, otherwise more than 30000 (the last 30 s is kept for wrap-up) |
| `--injection-model <model>` | Model for the compliance judge (defaults to the evaluator model) |
| `--base-url <url>` | Send models to an OpenAI-compatible endpoint (see [Providers](#providers)) |
| `-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 @@ -267,23 +268,43 @@ interface ScanResult {

By default every model runs through [OpenRouter](https://openrouter.ai), so any OpenRouter slug works (`anthropic/...`, `x-ai/...`, `openai/...`, etc.).

If `OPENAI_API_KEY` is set, OpenAI-style ids such as `openai/gpt-5`, `gpt-5`, and `o3-mini` go straight to the OpenAI API instead. To use an OpenAI-compatible endpoint (Azure, a gateway, a local server), set `OPENAI_BASE_URL`. One scan can mix providers, for example an OpenAI target with an OpenRouter attacker:
If `OPENAI_API_KEY` is set, OpenAI-style ids such as `openai/gpt-5`, `gpt-5`, and `o3-mini` go straight to the OpenAI API instead. One scan can mix providers, for example an OpenAI target with an OpenRouter attacker:

```bash
zeroleaks scan -f ./prompt.txt \
--target-model gpt-5 --openai-api-key sk-... \
--attacker-model "anthropic/claude-opus-4.8"
```

### OpenAI-compatible endpoints

Any server that speaks the OpenAI chat completions API works: Ollama, vLLM, LM Studio, llama.cpp, LiteLLM, Azure, Together, Groq, and so on. Pass its URL with `--base-url` (or set `OPENAI_BASE_URL`). With no OpenRouter key, every model goes to that endpoint, whatever its id. Local servers don't need a key.

```bash
zeroleaks scan -f ./prompt.txt --base-url http://localhost:11434/v1 \
--attacker-model llama3.1:70b --target-model llama3.1:8b \
--evaluator-model llama3.1:70b
```

For a hosted endpoint, add its key with `--openai-api-key`.

If you also have an OpenRouter key, only OpenAI-style ids go to the endpoint and the rest go to OpenRouter. Prefix a model with `openai/` to send it to the endpoint anyway. The prefix is stripped, so `openai/llama3.1:8b` reaches the server as `llama3.1:8b`:

```bash
zeroleaks scan -f ./prompt.txt --base-url http://localhost:11434/v1 \
--target-model openai/llama3.1:8b \
--attacker-model "anthropic/claude-opus-4.8"
```

## 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) |
| `OPENAI_BASE_URL` | Optional. An OpenAI-compatible endpoint, same as `--base-url` |

At least one key is required. Get an OpenRouter key at [openrouter.ai](https://openrouter.ai).
Set at least one key, or a base URL for a local server. Get an OpenRouter key at [openrouter.ai](https://openrouter.ai).

## Research references

Expand Down
22 changes: 20 additions & 2 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ function fail(message: string): never {
process.exit(EXIT.noVerdict);
}

function isHttpUrl(value: string): boolean {
try {
return ["http:", "https:"].includes(new URL(value).protocol);
} catch {
return false;
}
}

function readPromptFile(path: string): string {
try {
return readFileSync(path, "utf-8");
Expand Down Expand Up @@ -173,6 +181,10 @@ program
"--openai-api-key <key>",
"OpenAI API key (or set OPENAI_API_KEY); openai/* and gpt-* models route to the OpenAI API",
)
.option(
"--base-url <url>",
"OpenAI-compatible endpoint (or set OPENAI_BASE_URL), e.g. http://localhost:11434/v1",
)
.option(
"--attacker-model <model>",
`Model for the attacker agent (default: ${DEFAULT_MODELS.attacker})`,
Expand Down Expand Up @@ -228,13 +240,18 @@ program

const apiKey = options.apiKey || process.env.OPENROUTER_API_KEY;
const openaiApiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
if (!apiKey && !openaiApiKey) {
const baseUrl = options.baseUrl || process.env.OPENAI_BASE_URL;
if (baseUrl && !isHttpUrl(baseUrl)) {
fail(`invalid --base-url "${baseUrl}"; expected an http(s) URL`);
}
if (!apiKey && !openaiApiKey && !baseUrl) {
fail(
"no API key. Set OPENROUTER_API_KEY (--api-key) and/or OPENAI_API_KEY (--openai-api-key).",
"no API key. Set OPENROUTER_API_KEY (--api-key), OPENAI_API_KEY (--openai-api-key), or an OpenAI-compatible endpoint (--base-url).",
);
}
if (apiKey) process.env.OPENROUTER_API_KEY = apiKey;
if (openaiApiKey) process.env.OPENAI_API_KEY = openaiApiKey;
if (baseUrl) process.env.OPENAI_BASE_URL = baseUrl;

const mode = (options.mode || "dual") as
| "extraction"
Expand Down Expand Up @@ -271,6 +288,7 @@ program
`${c.gray("Attacker")} ${options.attackerModel || DEFAULT_MODELS.attacker}`,
`${c.gray("Target")} ${options.targetModel || DEFAULT_MODELS.target}`,
`${c.gray("Evaluator")} ${options.evaluatorModel || DEFAULT_MODELS.evaluator}`,
...(baseUrl ? [`${c.gray("Endpoint")} ${baseUrl}`] : []),
...(mode !== "extraction"
? [
`${c.gray("Probes")} ${maxProbes === 0 ? "all" : maxProbes}${
Expand Down
36 changes: 25 additions & 11 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { LanguageModelV1 } from "ai";
export interface ProviderKeys {
openrouterApiKey?: string;
openaiApiKey?: string;
/** An OpenAI-compatible endpoint; defaults to `OPENAI_BASE_URL`. */
openaiBaseUrl?: string;
}

/**
Expand All @@ -23,28 +25,40 @@ export function isOpenAiModel(model: string): boolean {
/**
* Resolve a model id to an AI SDK model.
*
* An OpenAI-style id routes to the OpenAI API when an OpenAI key is available
* (via `opts.openaiApiKey` or `OPENAI_API_KEY`); everything else — and OpenAI
* 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).
* With `OPENAI_BASE_URL` set, the model goes to that OpenAI-compatible
* endpoint (OpenAI, Azure, a gateway, Ollama, vLLM, LM Studio...) when either:
* - the id is OpenAI-style (`openai/*`, `gpt-*`, `o1/o3/o4-*`), or
* - there is no OpenRouter key, so the endpoint is the only provider.
* A leading `openai/` is stripped, so `openai/llama3.1:8b` sends `llama3.1:8b`
* to the endpoint even when OpenRouter serves the other roles. Local servers
* don't need a key.
*
* Without a base URL, OpenAI-style ids go to the OpenAI API when an OpenAI key
* is available, and everything else goes through OpenRouter.
*/
export function resolveModel(
model: string,
opts: ProviderKeys = {},
): LanguageModelV1 {
const openaiApiKey = opts.openaiApiKey ?? process.env.OPENAI_API_KEY;
const openrouterApiKey =
opts.openrouterApiKey ?? process.env.OPENROUTER_API_KEY;
const baseURL =
opts.openaiBaseUrl ?? (process.env.OPENAI_BASE_URL || undefined);

const useOpenAi = baseURL
? isOpenAiModel(model) || !openrouterApiKey
: Boolean(openaiApiKey) && isOpenAiModel(model);

if (openaiApiKey && isOpenAiModel(model)) {
if (useOpenAi) {
const openai = createOpenAI({
apiKey: openaiApiKey,
baseURL: process.env.OPENAI_BASE_URL || undefined,
// The SDK throws on a missing key; keyless local servers ignore it.
apiKey: openaiApiKey ?? "",
baseURL,
});
return openai(model.replace(/^openai\//, ""));
}

const openrouter = createOpenRouter({
apiKey: opts.openrouterApiKey ?? process.env.OPENROUTER_API_KEY,
});
const openrouter = createOpenRouter({ apiKey: openrouterApiKey });
return openrouter(model) as LanguageModelV1;
}
30 changes: 24 additions & 6 deletions test/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ interface RunOptions {
runtime?: "bun" | "node";
/** A script to run with Bun in place of the CLI, such as LIBRARY_SCAN. */
script?: string;
/**
* How the child finds the mock: "env" sets OPENAI_API_KEY and
* OPENAI_BASE_URL, "flag" passes only `--base-url`, "none" leaves it to
* the caller's args.
*/
endpoint?: "env" | "flag" | "none";
/** Extra environment variables for the child. */
env?: Record<string, string>;
}

const runs: CliRun[] = [];
Expand Down Expand Up @@ -91,15 +99,24 @@ export async function runCli(
name: string,
scenario: Scenario,
args: string[],
{ runtime = "bun", script = CLI_PATH }: RunOptions = {},
{
runtime = "bun",
script = CLI_PATH,
endpoint = "env",
env,
}: RunOptions = {},
): Promise<CliRun> {
const runDir = join(ARTIFACTS_DIR, slug(name));
mkdirSync(runDir, { recursive: true });
const reportPath = join(runDir, "result.json");

const mock = startMockLlm(scenario);
const setsOutput = args.includes("-o") || args.includes("--output");
const cliArgs =
args[0] === "scan" && !setsOutput ? [...args, "-o", reportPath] : args;
const cliArgs = [
...args,
...(args[0] === "scan" && !setsOutput ? ["-o", reportPath] : []),
...(endpoint === "flag" ? ["--base-url", mock.url] : []),
];
const command =
runtime === "node"
? [
Expand All @@ -111,16 +128,17 @@ export async function runCli(
]
: [process.execPath, "--no-env-file", script, ...cliArgs];

const mock = startMockLlm(scenario);
try {
const child = Bun.spawn(command, {
cwd: runDir,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NO_COLOR: "1",
OPENAI_API_KEY: "sk-mock",
OPENAI_BASE_URL: mock.url,
...(endpoint === "env"
? { OPENAI_API_KEY: "sk-mock", OPENAI_BASE_URL: mock.url }
: {}),
...env,
},
stdout: "pipe",
stderr: "pipe",
Expand Down
13 changes: 12 additions & 1 deletion test/e2e/mock-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export const MOCK_MODELS = {
judge: "gpt-mock-judge",
} as const;

/** Ids that don't look like OpenAI's, as a local server would serve. */
export const LOCAL_MOCK_MODELS = {
attacker: "llama-mock-attacker",
target: "llama-mock-target",
evaluator: "llama-mock-evaluator",
judge: "llama-mock-judge",
} as const;

type MockRole = keyof typeof MOCK_MODELS;

type TargetBehavior = "refuse" | "comply" | "leak" | "error";
Expand Down Expand Up @@ -117,7 +125,10 @@ export function startMockLlm(scenario: Scenario): MockLlm {
}

function roleFor(model: string): MockRole | "unknown" {
const entry = Object.entries(MOCK_MODELS).find(([, id]) => id === model);
const entry = [
...Object.entries(MOCK_MODELS),
...Object.entries(LOCAL_MOCK_MODELS),
].find(([, id]) => id === model);
return entry ? (entry[0] as MockRole) : "unknown";
}

Expand Down
77 changes: 73 additions & 4 deletions test/e2e/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
SYSTEM_PROMPT,
writeSummary,
} from "./harness";
import type { GraderBehavior } from "./mock-llm";
import { type GraderBehavior, LOCAL_MOCK_MODELS } from "./mock-llm";

setDefaultTimeout(30_000);
beforeAll(resetArtifacts);
Expand Down Expand Up @@ -528,9 +528,9 @@ describe("library callbacks", () => {

describe("models", () => {
test("the models named on screen are the models tested", async () => {
// No model flags: every role falls back to its default. Those defaults
// route to OpenRouter, which fails locally without a key, so the scan
// makes no network calls and ends inconclusive.
// No model flags: every role falls back to its default. With no
// OpenRouter key the defaults go to the mock endpoint, which doesn't
// serve them, so the scan ends inconclusive.
const run = await runCli("default models", { target: "refuse" }, [
...SCAN,
"--mode",
Expand All @@ -547,6 +547,75 @@ describe("models", () => {
target: shown("Target"),
evaluator: shown("Evaluator"),
});
const requested = new Set(run.requests.map((r) => r.model));
expect(requested.size).toBeGreaterThan(0);
for (const model of requested) {
expect([shown("Target"), shown("Evaluator")]).toContain(model);
}
expect(run.exitCode).toBe(EXIT.inconclusive);
});
});

const LOCAL_MODEL_FLAGS = (prefix = "") => [
"--attacker-model",
`${prefix}${LOCAL_MOCK_MODELS.attacker}`,
"--target-model",
`${prefix}${LOCAL_MOCK_MODELS.target}`,
"--evaluator-model",
`${prefix}${LOCAL_MOCK_MODELS.evaluator}`,
"--injection-model",
`${prefix}${LOCAL_MOCK_MODELS.judge}`,
];
const LOCAL_INJECTION = [
...SCAN,
"--mode",
"injection",
"--no-multi-turn",
"--max-probes",
"4",
];

describe("OpenAI-compatible endpoint", () => {
test("--base-url alone sends every model to the endpoint, with no key", async () => {
const run = await runCli(
"base-url flag, no keys",
{ target: "refuse", judge: JUDGE_REFUSED },
[...LOCAL_INJECTION, ...LOCAL_MODEL_FLAGS()],
{ endpoint: "flag" },
);

expect(run.stdout).toMatch(/Endpoint\s+http:\/\/127\.0\.0\.1/);
expect(run.requests.length).toBeGreaterThan(0);
expect(run.requests.every((r) => !r.failed)).toBe(true);
expect(run.result?.coverage.injection?.completed).toBe(4);
expect(run.exitCode).toBe(EXIT.secure);
});

test("with an OpenRouter key, openai/ ids still reach the endpoint", async () => {
const run = await runCli(
"base-url flag, openai/ prefix",
{ target: "refuse", judge: JUDGE_REFUSED },
[...LOCAL_INJECTION, ...LOCAL_MODEL_FLAGS("openai/")],
{ endpoint: "flag", env: { OPENROUTER_API_KEY: "sk-or-unused" } },
);

// The prefix is stripped before the request goes out.
expect(run.requests.length).toBeGreaterThan(0);
for (const request of run.requests) {
expect(Object.values<string>(LOCAL_MOCK_MODELS)).toContain(request.model);
}
expect(run.exitCode).toBe(EXIT.secure);
});

test("a --base-url that isn't an http(s) URL is rejected before scanning", async () => {
const run = await runCli(
"invalid base-url",
{ target: "refuse" },
[...LOCAL_INJECTION, "--base-url", "localhost:11434"],
{ endpoint: "none" },
);

expect(run.stderr).toContain("invalid --base-url");
expect(run.requests).toHaveLength(0);
expect(run.exitCode).toBe(EXIT.inconclusive);
});
Expand Down
Loading