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
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ See [Adding an evaluator](#adding-an-evaluator) for the full guide.
- [PR naming](#pr-naming)
- [**Adding an evaluator**](#adding-an-evaluator) ← most common contribution
- [Adding a suite](#adding-a-suite)
- [Adding an LLM provider](#adding-an-llm-provider)
- [Adding a telemetry provider](#adding-a-telemetry-provider)
- [Adding a test agent](#adding-a-test-agent)
- [Submitting findings](#submitting-findings)
Expand Down Expand Up @@ -373,6 +374,34 @@ Opfor can fetch recorded traces from an observability platform and give them to

---

## Adding an LLM provider

All provider metadata (default model, API key env var, capabilities, model builder) lives in one object: `providerRegistryData` in `core/src/providers/factory.ts`. CLI, MCP, and the browser extension all derive from it directly.

### Checklist

1. **Add an entry** to `providerRegistryData`:

```typescript
"my-provider": {
displayName: "My Provider",
defaultModel: "my-provider-default-model",
envVar: "MY_PROVIDER_API_KEY",
capabilities: { supportsJsonMode: true, requiresBaseURL: false },
build: ({ apiKey, model }): LanguageModel => createMyProvider({ apiKey })(model),
},
```

Set `requiresBaseURL: true` if the provider needs a user-supplied endpoint (e.g. Azure), and add `baseUrlPromptMessage` if the generic prompt copy isn't clear enough.

2. **Add the key to the `PROVIDERS` named-constant** in the same file (`{ MY_PROVIDER: "my-provider" }`). This `{ NAME: value }` object stays hand-written on purpose: it gives call sites literal-typed named access (`PROVIDERS.MY_PROVIDER`) and compile-time key checking that a derived `Object.fromEntries` view would lose. It's the one registry mirror not auto-derived — skip it and the `PROVIDER_CHOICES` test fails, and the extension popup dropdown / MCP tool-description list silently omit your provider.
3. **Add the env var row** to `AGENTS.md` and `.env.example`.
4. **Add the SDK dependency** to `core/package.json` if the provider needs a new `@ai-sdk/*` package.
5. **Extend `core/tests/providerFactory.test.ts`**'s `EXPECTED_PROVIDER_TAG` map with the new provider's SDK `.provider` tag (run the test once to see what the AI SDK reports).
6. **Add the provider to `runners/sdk/src/types.ts`**'s `ProviderName` union. The SDK can't import core's types directly (core isn't published to npm), so this one hand-copied union is the exception — everything else derives from the registry automatically.

---

## Adding a test agent

Test agents live in `tests/e2e/agents/` and give developers a real target to run Opfor against locally. They are never published to npm.
Expand Down
13 changes: 11 additions & 2 deletions core/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ export { setEnvProvider, getEnv } from "./lib/env.js";
export { randomUUID, randomTraceHex } from "./lib/random.js";
export { newOtelTraceId } from "./lib/tracePropagation.js";

export { createModel, PROVIDER_ENV_VARS, PROVIDER_DEFAULTS } from "./providers/factory.js";
export type { LlmConfig } from "./config/types.js";
export {
createModel,
PROVIDERS,
PROVIDER_ENV_VARS,
PROVIDER_DEFAULTS,
PROVIDER_CAPABILITIES,
PROVIDER_DISPLAY_NAMES,
PROVIDER_BASE_URL_PROMPTS,
PROVIDER_CHOICES,
} from "./providers/factory.js";
export type { LlmConfig, ProviderName } from "./config/types.js";

export { getAdapter } from "./telemetry/adapter.js";
24 changes: 3 additions & 21 deletions core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,27 +271,9 @@ export interface TelemetryConfig {
propagation?: TelemetryPropagationConfig;
}

export const PROVIDERS = {
OPENAI: "openai",
ANTHROPIC: "anthropic",
GROQ: "groq",
GOOGLE: "google",
DEEPSEEK: "deepseek",
AZURE: "azure",
OPENAI_COMPATIBLE: "openai-compatible",
} as const;

export type ProviderName = (typeof PROVIDERS)[keyof typeof PROVIDERS];

export const PROVIDER_CHOICES: { name: string; value: ProviderName }[] = [
{ name: "OpenAI", value: PROVIDERS.OPENAI },
{ name: "Anthropic (Claude)", value: PROVIDERS.ANTHROPIC },
{ name: "Google (Gemini)", value: PROVIDERS.GOOGLE },
{ name: "Groq", value: PROVIDERS.GROQ },
{ name: "DeepSeek", value: PROVIDERS.DEEPSEEK },
{ name: "Azure OpenAI", value: PROVIDERS.AZURE },
{ name: "Custom (OpenAI-compatible)", value: PROVIDERS.OPENAI_COMPATIBLE },
];
// Canonical definitions live in providers/factory.ts; re-exported so existing importers keep working.
export { PROVIDERS, PROVIDER_CHOICES, type ProviderName } from "../providers/factory.js";
import type { ProviderName } from "../providers/factory.js";

/** Partial LLM config used in setup/config file inputs — all fields optional before resolution. */
export interface LlmConfigInput {
Expand Down
3 changes: 2 additions & 1 deletion core/src/lib/generateJsonObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface JsonLlmMessage {
export async function generateJsonObject(
model: LanguageModel,
messages: JsonLlmMessage[],
options?: { abortSignal?: AbortSignal }
options?: { abortSignal?: AbortSignal; temperature?: number }
): Promise<Record<string, unknown>> {
const systemMsg = messages.find((m) => m.role === "system")?.content;
const conversationMessages = messages
Expand All @@ -27,6 +27,7 @@ export async function generateJsonObject(
output: "no-schema",
...(systemMsg ? { system: systemMsg } : {}),
messages: conversationMessages,
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}),
});

Expand Down
81 changes: 61 additions & 20 deletions core/src/providers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createDeepSeek } from "@ai-sdk/deepseek";
import { createAzure } from "@ai-sdk/azure";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { PROVIDERS, type LlmConfig, type ProviderName } from "../config/types.js";
import type { LlmConfig } from "../config/types.js";
import { getEnv } from "../lib/env.js";

export interface ProviderCapabilities {
Expand All @@ -32,9 +32,13 @@ export interface ProviderBuildContext {
* editing a switch plus three parallel lookup tables (the old "5 files" problem).
*/
export interface ProviderAdapter {
/** Human-readable label for provider-picker UIs (CLI wizard, extension popup). */
displayName: string;
defaultModel: string;
envVar: string;
capabilities: ProviderCapabilities;
/** Prompt copy for `capabilities.requiresBaseURL`; falls back to a generic message when omitted. */
baseUrlPromptMessage?: string;
/**
* Custom message thrown by createModel when `capabilities.requiresBaseURL` is
* set but no baseURL is supplied. Defaults to a generic message when omitted.
Expand All @@ -43,70 +47,96 @@ export interface ProviderAdapter {
build(ctx: ProviderBuildContext): LanguageModel;
}

/** Table-driven provider dispatch — the single source of truth for all providers. */
export const providerRegistry: Record<ProviderName, ProviderAdapter> = {
[PROVIDERS.OPENAI]: {
// Unexported so `ProviderName` below can infer the literal key union; `providerRegistry`
// re-declares it with an explicit `Record<ProviderName, ProviderAdapter>` annotation,
// which avoids a non-portable nested `@ai-sdk/provider` type in the declaration emit.
const providerRegistryData = {
openai: {
displayName: "OpenAI",
defaultModel: "gpt-4o-mini",
envVar: "OPENAI_API_KEY",
capabilities: { supportsJsonMode: true, requiresBaseURL: false },
build: ({ apiKey, model }) => createOpenAI({ apiKey })(model),
build: ({ apiKey, model }): LanguageModel => createOpenAI({ apiKey })(model),
},
[PROVIDERS.ANTHROPIC]: {
anthropic: {
displayName: "Anthropic (Claude)",
defaultModel: "claude-3-5-haiku-20241022",
envVar: "ANTHROPIC_API_KEY",
capabilities: { supportsJsonMode: false, requiresBaseURL: false },
build: ({ apiKey, model }) => createAnthropic({ apiKey })(model),
build: ({ apiKey, model }): LanguageModel => createAnthropic({ apiKey })(model),
},
[PROVIDERS.GROQ]: {
groq: {
displayName: "Groq",
defaultModel: "llama-3.3-70b-versatile",
envVar: "GROQ_API_KEY",
capabilities: { supportsJsonMode: true, requiresBaseURL: false },
build: ({ apiKey, model }) =>
build: ({ apiKey, model }): LanguageModel =>
createOpenAICompatible({
name: "groq",
apiKey,
baseURL: "https://api.groq.com/openai/v1",
}).chatModel(model),
},
[PROVIDERS.GOOGLE]: {
google: {
displayName: "Google (Gemini)",
defaultModel: "gemini-2.0-flash",
envVar: "GOOGLE_GENERATIVE_AI_API_KEY",
capabilities: { supportsJsonMode: false, requiresBaseURL: false },
build: ({ apiKey, model }) => createGoogleGenerativeAI({ apiKey })(model),
build: ({ apiKey, model }): LanguageModel => createGoogleGenerativeAI({ apiKey })(model),
},
[PROVIDERS.DEEPSEEK]: {
deepseek: {
displayName: "DeepSeek",
defaultModel: "deepseek-chat",
envVar: "DEEPSEEK_API_KEY",
capabilities: { supportsJsonMode: true, requiresBaseURL: false },
build: ({ apiKey, model }) => createDeepSeek({ apiKey })(model),
build: ({ apiKey, model }): LanguageModel => createDeepSeek({ apiKey })(model),
},
[PROVIDERS.AZURE]: {
azure: {
displayName: "Azure OpenAI",
defaultModel: "gpt-4o-mini",
envVar: "AZURE_OPENAI_API_KEY",
capabilities: { supportsJsonMode: true, requiresBaseURL: true },
baseUrlError: `baseURL is required for provider '${PROVIDERS.AZURE}' (Azure resource endpoint, e.g. https://<resource>.openai.azure.com)`,
baseUrlPromptMessage: "Azure resource endpoint (e.g. https://my-resource.openai.azure.com)",
baseUrlError: `baseURL is required for provider 'azure' (Azure resource endpoint, e.g. https://<resource>.openai.azure.com)`,
// Add the /openai path when the endpoint has none; leave proxy/custom paths as-is.
build: ({ apiKey, model, baseURL }) => {
build: ({ apiKey, model, baseURL }): LanguageModel => {
const base = baseURL!.replace(/\/+$/, "");
let resolved: string;
try {
resolved = new URL(base).pathname === "/" ? `${base}/openai` : base;
} catch {
throw new Error(
`baseURL is not a valid URL for provider '${PROVIDERS.AZURE}' (Azure resource endpoint, e.g. https://<resource>.openai.azure.com)`
`baseURL is not a valid URL for provider 'azure' (Azure resource endpoint, e.g. https://<resource>.openai.azure.com)`
);
}
return createAzure({ apiKey, baseURL: resolved })(model);
},
},
[PROVIDERS.OPENAI_COMPATIBLE]: {
"openai-compatible": {
displayName: "Custom (OpenAI-compatible)",
defaultModel: "",
envVar: "OPFOR_API_KEY",
capabilities: { supportsJsonMode: true, requiresBaseURL: true },
build: ({ apiKey, model, baseURL }) =>
build: ({ apiKey, model, baseURL }): LanguageModel =>
createOpenAICompatible({ name: "custom", apiKey, baseURL: baseURL! }).chatModel(model),
},
};
} satisfies Record<string, ProviderAdapter>;

/** Canonical provider key union, derived from the registry — no hand-maintained copy elsewhere. */
export type ProviderName = keyof typeof providerRegistryData;

export const providerRegistry: Record<ProviderName, ProviderAdapter> = providerRegistryData;

/** `{ OPENAI: "openai", ... }`-style constant for call sites that prefer named access. */
export const PROVIDERS = {
OPENAI: "openai",
ANTHROPIC: "anthropic",
GROQ: "groq",
GOOGLE: "google",
DEEPSEEK: "deepseek",
AZURE: "azure",
OPENAI_COMPATIBLE: "openai-compatible",
} as const satisfies Record<string, ProviderName>;

// Freeze the capability objects: the derived PROVIDER_CAPABILITIES aliases them,
// so this keeps a stray `PROVIDER_CAPABILITIES.x.requiresBaseURL = …` from leaking
Expand All @@ -131,6 +161,17 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string> = projectRegistry((
export const PROVIDER_CAPABILITIES: Record<ProviderName, ProviderCapabilities> = projectRegistry(
(a) => a.capabilities
);
export const PROVIDER_DISPLAY_NAMES: Record<ProviderName, string> = projectRegistry(
(a) => a.displayName
);
export const PROVIDER_BASE_URL_PROMPTS: Record<ProviderName, string | undefined> = projectRegistry(
(a) => a.baseUrlPromptMessage
);

/** `{ name, value }[]` shape consumed directly by the CLI wizard's `select()` prompt. */
export const PROVIDER_CHOICES: { name: string; value: ProviderName }[] = (
Object.keys(providerRegistry) as ProviderName[]
).map((value) => ({ name: PROVIDER_DISPLAY_NAMES[value], value }));

/** Returns an error message string if the config is invalid, or null if valid. */
export function validateLlmConfig(llm: LlmConfig): string | null {
Expand Down
30 changes: 30 additions & 0 deletions core/tests/providerFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const {
PROVIDER_DEFAULTS,
PROVIDER_ENV_VARS,
PROVIDER_CAPABILITIES,
PROVIDER_DISPLAY_NAMES,
PROVIDER_BASE_URL_PROMPTS,
PROVIDER_CHOICES,
} = await import("../src/providers/factory.js");
const { PROVIDERS } = await import("../src/config/types.js");

Expand Down Expand Up @@ -86,6 +89,33 @@ test("the three lookup tables keep their exact values", () => {
});
});

test("every provider has a display name; only baseURL-requiring providers may have a prompt message", () => {
for (const provider of Object.values(PROVIDERS)) {
assert.ok(
PROVIDER_DISPLAY_NAMES[provider] && PROVIDER_DISPLAY_NAMES[provider].length > 0,
`${provider} is missing a displayName`
);
if (PROVIDER_BASE_URL_PROMPTS[provider] !== undefined) {
assert.ok(
PROVIDER_CAPABILITIES[provider].requiresBaseURL,
`${provider} has a baseUrlPromptMessage but doesn't require a baseURL`
);
}
}
assert.ok(PROVIDER_CAPABILITIES.azure.requiresBaseURL && PROVIDER_BASE_URL_PROMPTS.azure);
});

test("PROVIDER_CHOICES lists every provider once, matching PROVIDER_DISPLAY_NAMES", () => {
assert.strictEqual(PROVIDER_CHOICES.length, Object.values(PROVIDERS).length);
assert.deepStrictEqual(
new Set(PROVIDER_CHOICES.map((c) => c.value)),
new Set(Object.values(PROVIDERS))
);
for (const choice of PROVIDER_CHOICES) {
assert.strictEqual(choice.name, PROVIDER_DISPLAY_NAMES[choice.value]);
}
});

test("createModel throws when apiKeyEnv is missing", () => {
assert.throws(
() => createModel({ provider: PROVIDERS.OPENAI, model: "m" }),
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Runs the full red team evaluation from a config file produced by `opfor_setup`.
| Groq | `groq` | `GROQ_API_KEY` |
| Google (Gemini) | `google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` |
| Azure OpenAI | `azure` | `AZURE_API_KEY` |
| Azure OpenAI | `azure` | `AZURE_OPENAI_API_KEY` |
| OpenAI-compatible | `openai-compatible` | (set via `api_key_env` + `base_url`) |

---
Expand Down
4 changes: 2 additions & 2 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ attackerModel: { provider: "openai", model: "gpt-4o", apiKeyEnv: "OPENAI_API_KEY
attackerModel: { provider: "anthropic", model: "claude-sonnet-4", apiKeyEnv: "ANTHROPIC_API_KEY" }

// Google
attackerModel: { provider: "google", model: "gemini-2.0-flash", apiKeyEnv: "GOOGLE_API_KEY" }
attackerModel: { provider: "google", model: "gemini-2.0-flash", apiKeyEnv: "GOOGLE_GENERATIVE_AI_API_KEY" }

// Groq
attackerModel: { provider: "groq", model: "llama-3.3-70b", apiKeyEnv: "GROQ_API_KEY" }
Expand All @@ -320,7 +320,7 @@ attackerModel: { provider: "deepseek", model: "deepseek-chat", apiKeyEnv: "DEEPS
attackerModel: {
provider: "azure",
model: "gpt-4o",
apiKeyEnv: "AZURE_API_KEY",
apiKeyEnv: "AZURE_OPENAI_API_KEY",
baseUrl: "https://my-resource.openai.azure.com"
}

Expand Down
10 changes: 4 additions & 6 deletions runners/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { input, select, checkbox, confirm } from "@inquirer/prompts";
import { log } from "@keyvaluesystems/agent-opfor-core/lib/logger.js";
import { loadSkillCatalog } from "@keyvaluesystems/agent-opfor-core/config/loadSkillCatalog.js";
import {
PROVIDERS,
PROVIDER_CHOICES,
type LlmConfig,
type ProviderName,
Expand All @@ -16,6 +15,8 @@ import {
import {
PROVIDER_DEFAULTS,
PROVIDER_ENV_VARS,
PROVIDER_CAPABILITIES,
PROVIDER_BASE_URL_PROMPTS,
} from "@keyvaluesystems/agent-opfor-core/providers/factory.js";
import type {
RunConfig,
Expand Down Expand Up @@ -226,11 +227,8 @@ async function collectLlmConfig(label: string): Promise<LlmConfig> {
});

let baseURL: string | undefined;
if (provider === PROVIDERS.AZURE || provider === PROVIDERS.OPENAI_COMPATIBLE) {
const message =
provider === PROVIDERS.AZURE
? "Azure resource endpoint (e.g. https://my-resource.openai.azure.com)"
: "Base URL (required for this provider)";
if (PROVIDER_CAPABILITIES[provider].requiresBaseURL) {
const message = PROVIDER_BASE_URL_PROMPTS[provider] ?? "Base URL (required for this provider)";
baseURL = await input({
message,
validate: (v) => (v.trim() ? true : "A value is required"),
Expand Down
13 changes: 2 additions & 11 deletions runners/extension/config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
import { PROVIDERS } from "./providers.js";

/** Providers whose endpoint is hardcoded — no baseUrl required from the user. */
const PROVIDERS_WITH_FIXED_URL = new Set([
PROVIDERS.OPENAI,
PROVIDERS.ANTHROPIC,
PROVIDERS.GROQ,
PROVIDERS.GOOGLE,
PROVIDERS.DEEPSEEK,
]);
import { PROVIDERS, PROVIDER_CAPABILITIES } from "./dist/core.bundle.js";

/**
* Load per-task LLM configs from Options storage.
Expand Down Expand Up @@ -38,7 +29,7 @@ export async function getLlmProfile(kind) {

export function assertLlmCfg(cfg, { kind }) {
if (!cfg?.enabled) throw new Error(`${kind} LLM is disabled (enable it in extension Options).`);
if (!PROVIDERS_WITH_FIXED_URL.has(cfg.provider) && !cfg.baseUrl) {
if (PROVIDER_CAPABILITIES[cfg.provider]?.requiresBaseURL && !cfg.baseUrl) {
throw new Error(`${kind} LLM missing baseUrl in Options.`);
}
if (!cfg.model) throw new Error(`${kind} LLM missing model in Options.`);
Expand Down
Loading
Loading