Skip to content

FEATURE-REQUEST: What about Key rotation? #15

Description

@beta0x01

I asked Deekwiki to give me full Guidance to Add AI Provider API Keys Rotation, and here is their respond.


complete, final guide — every file, every line, from zero to end.

Step 1 — Create src/cache/CacheService.ts (new file)

Create this file from scratch:

// src/cache/CacheService.ts

class CacheService {
private readonly store = new Map<string, unknown>();

get<T>(key: string): T | undefined {
return this.store.get(key) as T | undefined;
}

set<T>(key: string, value: T): void {
this.store.set(key, value);
}
}

/** Process-lifetime singleton. Resets on restart. */
export const cache = new CacheService();


Step 2 — Create src/llm/keyRotator.ts (new file)

Create this file from scratch:

// src/llm/keyRotator.ts

import { cache } from '../cache/CacheService.js';

export interface ApiKeyEntry {
id: string; // UUID
key: string; // the actual API key string
label?: string; // human-readable name (optional)
isEnabled: boolean;
}

/**

  • Advances the round-robin cursor and returns the next enabled key.
  • The cursor is stored in the in-memory cache, namespaced by providerId.
  • If the previously cached key ID no longer exists in the enabled list
  • (deleted or disabled between calls), findIndex returns -1 and
  • (-1 + 1) % length = 0, so rotation restarts from index 0 automatically.
    */
    export function nextApiKey(providerId: string, entries: ApiKeyEntry[]): string {
    const enabled = entries.filter((e) => e.isEnabled);
    if (enabled.length === 0) {
    throw new Error(No enabled API keys configured for provider: ${providerId});
    }
    const cacheKey = apikey:rotation:${providerId};
    const lastUsedId = cache.get<string>(cacheKey);
    let nextIndex = 0;
    if (lastUsedId !== undefined) {
    const currentIndex = enabled.findIndex((e) => e.id === lastUsedId);
    nextIndex = (currentIndex + 1) % enabled.length;
    }
    const chosen = enabled[nextIndex]!;
    cache.set(cacheKey, chosen.id);
    return chosen.key;
    }

/** Returns true if at least one key is enabled. Used for startup validation

  • without advancing the rotation cursor. */
    export function hasEnabledKey(entries: ApiKeyEntry[]): boolean {
    return entries.some((e) => e.isEnabled);
    }

Step 3 — Modify src/config/config.ts

Two additions. The rest of the file is untouched.

Addition 1: Insert after line 46 (after export type PluginConfig = z.infer<typeof PluginConfig>;):

const ApiKeyEntry = z.object({
  id: z.string().uuid(),
  key: z.string().min(1),
  label: z.string().optional(),
  isEnabled: z.boolean().default(true),
});
export type ApiKeyEntry = z.infer<typeof ApiKeyEntry>;

Addition 2: Inside ConfigSchema, insert after line 60 (after api_key: z.string().default(''),):

  api_keys: z.array(ApiKeyEntry).default([]),

The relevant section of ConfigSchema will look like this after the change:

const ConfigSchema = z.object({
  backend: Backend.default(''),
  model: z.string().default(''),
  base_url: z.string().default(''),
  api_key: z.string().default(''),
  api_keys: z.array(ApiKeyEntry).default([]),   // ← new line
  skills_dirs: z.array(z.string()).default([]),
  // ... rest unchanged

Step 4 — Modify src/llm/openai.ts

Five targeted changes. Everything else in the file is untouched.

Change 1 — Replace line 98 (field declaration):

// Before:
  readonly apiKey: string;

// After:
private readonly resolveKey: () => string;
private cachedKey: string = '';

Change 2 — Replace line 107 (constructor parameter):

// Before:
    apiKey: string,

// After:
apiKey: string | (() => string),

Change 3 — Replace line 114 (constructor assignment):

// Before:
    this.apiKey = apiKey;

// After:
this.resolveKey = typeof apiKey === 'string' ? () => apiKey : apiKey;

Change 4 — Replace lines 137–138 inside ping():

// Before:
    if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;

// After:
// Reuse the last key resolved for a real request; don't advance the cursor.
const key = this.cachedKey || this.resolveKey();
if (key) headers.Authorization = Bearer ${key};

Change 5 — Replace lines 329–330 inside headers():

// Before:
    if (this.apiKey) h.Authorization = `Bearer ${this.apiKey}`;

// After:
this.cachedKey = this.resolveKey(); // advance cursor, cache for ping reuse
if (this.cachedKey) h.Authorization = Bearer ${this.cachedKey};

After all changes, the class header and the two affected methods look like:

export class OpenAIClient implements Client, StreamingClient, Pinger {
  readonly baseURL: string;
  private readonly resolveKey: () => string;
  private cachedKey: string = '';
  readonly modelID: string;
  readonly label: string;
  private readonly extraHeaders: Record<string, string>;
  private readonly temperature?: number;
  private readonly maxTokens?: number;

constructor(
baseURL: string,
apiKey: string | (() => string),
model: string,
label = 'openai-compat',
extraHeaders: Record<string, string> = {},
genOpts: { temperature?: number; maxTokens?: number } = {},
) {
this.baseURL = baseURL;
this.resolveKey = typeof apiKey === 'string' ? () => apiKey : apiKey;
this.modelID = model;
// ... rest unchanged
}

async ping(signal?: AbortSignal): Promise<void> {
const headers: Record<string, string> = {};
const key = this.cachedKey || this.resolveKey();
if (key) headers.Authorization = Bearer ${key};
const resp = await fetch(${this.baseURL}/models, { method: 'GET', headers, signal });
if (resp.status >= 500) {
throw new Error(${this.label} status ${resp.status});
}
}

// ... chat(), chatOnce(), chatStream(), openStream() all unchanged ...

private headers(): Record<string, string> {
const h: Record<string, string> = { ...this.extraHeaders, 'Content-Type': 'application/json' };
this.cachedKey = this.resolveKey();
if (this.cachedKey) h.Authorization = Bearer ${this.cachedKey};
return h;
}

Note: lmStudio() passes '' — this becomes () => '', and the if (key) guard handles it correctly with no behavior change.


Step 5 — Modify src/llm/gemini.ts

Four targeted changes. Everything else in the file is untouched.

Change 1 — Replace lines 38–39 (field declarations):

// Before:
  readonly apiKey: string;
  readonly modelID: string;

// After:
private readonly resolveKey: () => string;
private cachedKey: string = '';
readonly modelID: string;

Change 2 — Replace line 41 (constructor signature):

// Before:
  constructor(baseURL: string, apiKey: string, model: string) {

// After:
constructor(baseURL: string, apiKey: string | (() => string), model: string) {

Change 3 — Replace line 43 (constructor assignment):

// Before:
    this.apiKey = apiKey;

// After:
this.resolveKey = typeof apiKey === 'string' ? () => apiKey : apiKey;

Change 4 — Replace ping() and the fetch line inside chat():

  async ping(signal?: AbortSignal): Promise<void> {
    // Reuse the last key resolved for a real request; don't advance the cursor.
    const key = this.cachedKey || this.resolveKey();
    const resp = await fetch(`${this.baseURL}/models?key=${encodeURIComponent(key)}`, {
      method: 'GET',
      signal,
    });
    if (resp.status >= 500) {
      throw new Error(`gemini status ${resp.status}`);
    }
  }

async chat(req: ChatRequest, signal?: AbortSignal): Promise<ChatResponse> {
const body = encodeRequest(req);
const combinedSignal = withTimeout(signal, CHAT_TIMEOUT_MS);
this.cachedKey = this.resolveKey(); // advance cursor, cache for ping reuse
let resp: Response;
try {
resp = await fetch(
${this.baseURL}/${withModelsPrefix(req.model || this.modelID)}:generateContent?key=${encodeURIComponent(this.cachedKey)},
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: combinedSignal,
},
);
// ... rest of chat() unchanged


Step 6 — Replace src/llm/factory.ts entirely

// src/llm/factory.ts
// Build the right Client from the parsed Config.

import type { Config } from '../config/config.js';
import type { Client } from './client.js';
import { GeminiClient } from './gemini.js';
import { hasEnabledKey, nextApiKey } from './keyRotator.js';
import { OllamaClient } from './ollama.js';
import { OpenAIClient } from './openai.js';
import {
DEEPSEEK_DEFAULT_BASE_URL,
DEEPSEEK_DEFAULT_MODEL,
GEMINI_DEFAULT_BASE_URL,
GEMINI_DEFAULT_MODEL,
GROQ_DEFAULT_BASE_URL,
GROQ_DEFAULT_MODEL,
KIMI_DEFAULT_BASE_URL,
KIMI_DEFAULT_MAX_TOKENS,
KIMI_DEFAULT_MODEL,
OPENROUTER_DEFAULT_BASE_URL,
OPENROUTER_DEFAULT_MODEL,
} from './providers.js';

/**

  • Creates a per-request key resolver for the given backend.
  • If api_keys has enabled entries, each call to the resolver advances the
  • round-robin cursor (stored in the in-memory CacheService).
  • Falls back to the static api_key string for single-key setups.
    */
    function createKeyResolver(cfg: Config, backend: string): () => string {
    if (cfg.api_keys.length > 0 && hasEnabledKey(cfg.api_keys)) {
    return () => nextApiKey(backend, cfg.api_keys);
    }
    return () => cfg.api_key;
    }

/**

  • Returns true if the config has at least one usable key.
  • Used for startup validation without advancing the rotation cursor.
    */
    function hasKey(cfg: Config): boolean {
    if (cfg.api_keys.length > 0) return hasEnabledKey(cfg.api_keys);
    return Boolean(cfg.api_key);
    }

export function newFromConfig(cfg: Config): Client {
// Generation knobs shared by the OpenAI-compatible backends. temperature is
// sent only to models that accept it (see OpenAIClient.encodeRequest).
const gen = { temperature: cfg.temperature, maxTokens: cfg.max_tokens };
switch (cfg.backend) {
case 'ollama':
case '':
return new OllamaClient(cfg.base_url, cfg.model);
case 'lmstudio':
return OpenAIClient.lmStudio(cfg.base_url, cfg.model);
case 'openai-compat':
if (!cfg.base_url) {
throw new Error('openai-compat backend requires base_url');
}
return new OpenAIClient(
cfg.base_url,
createKeyResolver(cfg, 'openai-compat'),
cfg.model,
'openai-compat',
{},
gen,
);
case 'kimi':
if (!hasKey(cfg)) {
throw new Error('kimi backend requires api_key or MOONSHOT_API_KEY');
}
return new OpenAIClient(
cfg.base_url || KIMI_DEFAULT_BASE_URL,
createKeyResolver(cfg, 'kimi'),
cfg.model || KIMI_DEFAULT_MODEL,
'kimi',
{},
// Kimi can't be tuned down via temperature (locked to 1), so default a
// response cap to keep it from narrating unbounded; user override wins.
{ temperature: cfg.temperature, maxTokens: cfg.max_tokens ?? KIMI_DEFAULT_MAX_TOKENS },
);
case 'groq':
if (!hasKey(cfg)) {
throw new Error('groq backend requires api_key or GROQ_API_KEY');
}
return new OpenAIClient(
cfg.base_url || GROQ_DEFAULT_BASE_URL,
createKeyResolver(cfg, 'groq'),
cfg.model || GROQ_DEFAULT_MODEL,
'groq',
{},
gen,
);
case 'openrouter':
if (!hasKey(cfg)) {
throw new Error('openrouter backend requires api_key or OPENROUTER_API_KEY');
}
return new OpenAIClient(
cfg.base_url || OPENROUTER_DEFAULT_BASE_URL,
createKeyResolver(cfg, 'openrouter'),
cfg.model || OPENROUTER_DEFAULT_MODEL,
'openrouter',
{
'HTTP-Referer': 'https://github.com/pentesterflow/agent',
'X-OpenRouter-Title': 'PentesterFlow',
},
gen,
);
case 'deepseek':
if (!hasKey(cfg)) {
throw new Error('deepseek backend requires api_key or DEEPSEEK_API_KEY');
}
return new OpenAIClient(
cfg.base_url || DEEPSEEK_DEFAULT_BASE_URL,
createKeyResolver(cfg, 'deepseek'),
cfg.model || DEEPSEEK_DEFAULT_MODEL,
'deepseek',
{},
gen,
);
case 'gemini':
if (!hasKey(cfg)) {
throw new Error('gemini backend requires api_key or GEMINI_API_KEY');
}
return new GeminiClient(
cfg.base_url || GEMINI_DEFAULT_BASE_URL,
createKeyResolver(cfg, 'gemini'),
cfg.model || GEMINI_DEFAULT_MODEL,
);
default: {
const _exhaustive: never = cfg.backend;
throw new Error(unknown backend: ${String(_exhaustive)});
}
}
}


Step 7 — Modify src/cli/index.ts

Four targeted changes. Everything else in the file is untouched.

Change 1 — Replace line 245 (--api-key flag):

// Before:
  if (flags.apiKey) cfg.api_key = flags.apiKey;

// After:
if (flags.apiKey) {
cfg.api_key = flags.apiKey;
cfg.api_keys = []; // single-key CLI override disables rotation
}

Change 2 — Replace lines 247–261 (env var fallbacks). Add && !cfg.api_keys.some((e) => e.isEnabled) to each guard:

// Before:
  if (cfg.backend === 'kimi' && !cfg.api_key) {
    cfg.api_key = process.env.MOONSHOT_API_KEY || process.env.KIMI_API_KEY || '';
  }
  if (cfg.backend === 'groq' && !cfg.api_key) {
    cfg.api_key = process.env.GROQ_API_KEY || '';
  }
  if (cfg.backend === 'openrouter' && !cfg.api_key) {
    cfg.api_key = process.env.OPENROUTER_API_KEY || '';
  }
  if (cfg.backend === 'deepseek' && !cfg.api_key) {
    cfg.api_key = process.env.DEEPSEEK_API_KEY || '';
  }
  if (cfg.backend === 'gemini' && !cfg.api_key) {
    cfg.api_key = process.env.GEMINI_API_KEY || '';
  }

// After:
if (cfg.backend === 'kimi' && !cfg.api_key && !cfg.api_keys.some((e) => e.isEnabled)) {
cfg.api_key = process.env.MOONSHOT_API_KEY || process.env.KIMI_API_KEY || '';
}
if (cfg.backend === 'groq' && !cfg.api_key && !cfg.api_keys.some((e) => e.isEnabled)) {
cfg.api_key = process.env.GROQ_API_KEY || '';
}
if (cfg.backend === 'openrouter' && !cfg.api_key && !cfg.api_keys.some((e) => e.isEnabled)) {
cfg.api_key = process.env.OPENROUTER_API_KEY || '';
}
if (cfg.backend === 'deepseek' && !cfg.api_key && !cfg.api_keys.some((e) => e.isEnabled)) {
cfg.api_key = process.env.DEEPSEEK_API_KEY || '';
}
if (cfg.backend === 'gemini' && !cfg.api_key && !cfg.api_keys.some((e) => e.isEnabled)) {
cfg.api_key = process.env.GEMINI_API_KEY || '';
}

Change 3 — Replace lines 695–700 (readConfig):

// Before:
        readConfig: () => ({
          backend: cfg.backend,
          baseURL: cfg.base_url,
          apiKey: cfg.api_key,
          model: cfg.model,
        }),

// After:
readConfig: () => ({
backend: cfg.backend,
baseURL: cfg.base_url,
// For the UI model picker, supply the first enabled key from the
// rotation pool (or fall back to the single api_key). listModels()
// only needs one valid key — it doesn't participate in rotation.
apiKey: cfg.api_keys.find((e) => e.isEnabled)?.key ?? cfg.api_key,
model: cfg.model,
}),

Change 4 — Replace line 709 inside applyProvider:

// Before:
          if (change.apiKey !== undefined) cfg.api_key = change.apiKey;

// After:
if (change.apiKey !== undefined) {
cfg.api_key = change.apiKey;
// Only wipe the rotation pool if the incoming key is not already
// in it (e.g. user just confirmed the same backend without changing
// the key — readConfig returns the first enabled key, which IS in
// the pool, so we must not clear it).
const alreadyInPool = cfg.api_keys.some(
(e) => e.key === change.apiKey && e.isEnabled,
);
if (!alreadyInPool) cfg.api_keys = [];
}


How to configure ~/.pentesterflow/config.json

After applying all changes, add api_keys to your config. Leave api_key as "" when using the rotation pool:

{
  "backend": "groq",
  "model": "llama-3.3-70b-versatile",
  "api_key": "",
  "api_keys": [
    { "id": "11111111-1111-1111-1111-111111111111", "key": "gsk_AAA...", "label": "key-1", "isEnabled": true },
    { "id": "22222222-2222-2222-2222-222222222222", "key": "gsk_BBB...", "label": "key-2", "isEnabled": true },
    { "id": "33333333-3333-3333-3333-333333333333", "key": "gsk_CCC...", "label": "key-3", "isEnabled": false }
  ]
}

Request 1 → gsk_AAA..., Request 2 → gsk_BBB..., Request 3 → gsk_AAA... (key-3 is skipped, wraps around). Rotation resets on process restart.


Summary of all files

File Action
src/cache/CacheService.ts Create — in-memory Map singleton
src/llm/keyRotator.ts Create — round-robin logic + hasEnabledKey
src/config/config.ts Modify — add ApiKeyEntry schema + api_keys field
src/llm/openai.ts Modify — resolveKey + cachedKey; fix ping() and headers()
src/llm/gemini.ts Modify — same pattern; fix ping() and chat()
src/llm/factory.ts Replace — createKeyResolver + hasKey; no more bare cfg.api_key
src/cli/index.ts Modify — 4 spots: --api-key flag, env vars, readConfig, applyProvider

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions