diff --git a/README.md b/README.md
index 6b111ad..6256eb0 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,211 @@ The design follows the inference setting discussed in:
Operational premise: low-entropy disclosures that appear non-identifying in
isolation may become identifying under cross-post and cross-platform fusion.
+## Quick Start
+
+Get up and running in three steps:
+
+### 1. Clone and install
+
+```bash
+git clone https://github.com/ni5arga/deanonymizer.git
+cd deanonymizer
+npm install
+```
+
+### 2. Set up an LLM provider
+
+You need access to at least one LLM. Pick any option below — the tool works
+with 9 different providers out of the box. The fastest free option is
+**OpenRouter** with Google Gemini:
+
+```bash
+# Grab a free API key from https://openrouter.ai/keys
+export OPENAI_API_KEY="your-openrouter-key"
+```
+
+Or if you already have an Anthropic or OpenAI key, just export it and go:
+
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+# or
+export OPENAI_API_KEY=sk-...
+```
+
+### 3. Run it
+
+```bash
+# Audit your Reddit account
+npm run audit -- my_reddit_handle
+
+# Audit your Hacker News account
+npm run audit -- --hn my_hn_handle
+
+# Both at once
+npm run audit -- my_reddit_handle --hn my_hn_handle
+```
+
+The tool will fetch your public posts, send them to the LLM for analysis, and
+print a color-coded exposure report showing what an attacker could infer about
+you — along with exact links to the leaking posts so you can delete them.
+
+## Supported Providers
+
+deanonymizer works with any OpenAI-compatible Chat Completions endpoint. Use
+`--provider` to select one, or let the tool auto-detect from your environment.
+
+| Provider | `--provider` | Env vars needed | Default model | Free tier? |
+|----------|-------------|-----------------|---------------|------------|
+| **Anthropic** | `anthropic` | `ANTHROPIC_API_KEY` | `claude-haiku-4-5` | No |
+| **OpenAI** | `openai` | `OPENAI_API_KEY` | `gpt-4o-mini` | No |
+| **OpenRouter** | `openrouter` | `OPENAI_API_KEY` | `google/gemini-2.0-flash-exp:free` | ✅ Yes |
+| **Google Gemini** | `gemini` | `OPENAI_API_KEY` (Gemini key) | `gemini-2.0-flash` | ✅ Yes (free tier) |
+| **Ollama** | `ollama` | None (local) | `llama3` | ✅ Local |
+| **Groq** | `groq` | `OPENAI_API_KEY` (Groq key) | `llama-3.3-70b-versatile` | ✅ Yes |
+| **Together** | `together` | `OPENAI_API_KEY` (Together key) | `meta-llama/Llama-3.3-70B-Instruct-Turbo` | Limited |
+| **NVIDIA NIM** | `nvidia` | `OPENAI_API_KEY` (NVIDIA key) | `meta/llama-3.3-70b-instruct` | Limited |
+| **Mistral** | `mistral` | `OPENAI_API_KEY` (Mistral key) | `mistral-small-latest` | Limited |
+
+### Provider setup examples
+
+
+Anthropic (Claude)
+
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+npm run audit -- my_reddit_handle
+
+# Use a higher-quality model
+npm run audit -- my_reddit_handle --model claude-sonnet-4-6
+```
+
+
+
+OpenAI
+
+```bash
+export OPENAI_API_KEY=sk-...
+npm run audit -- my_reddit_handle
+
+# Override model
+export OPENAI_MODEL=gpt-4o
+npm run audit -- my_reddit_handle
+```
+
+
+
+OpenRouter (recommended free option)
+
+OpenRouter aggregates dozens of models behind a single API. Sign up at
+[openrouter.ai](https://openrouter.ai) and grab a free key.
+
+```bash
+export OPENAI_API_KEY="your-openrouter-key"
+
+# Option A: Use the provider preset (auto-configures base URL + model)
+npm run audit -- my_reddit_handle --provider openrouter
+
+# Option B: Manual setup
+export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
+export OPENAI_MODEL="google/gemini-2.0-flash-exp:free"
+npm run audit -- my_reddit_handle
+```
+
+> **Tip:** Avoid `openrouter/free` as the model name — it auto-routes through a
+> congested queue and frequently times out. Instead, pick a specific free model
+> like `google/gemini-2.0-flash-exp:free` or
+> `meta-llama/llama-3.3-70b-instruct:free`.
+
+
+
+Google Gemini (direct)
+
+```bash
+export OPENAI_API_KEY="your-gemini-api-key"
+npm run audit -- my_reddit_handle --provider gemini
+
+# Or manually:
+export OPENAI_BASE_URL="https://generativelanguage.googleapis.com/v1beta/openai/"
+export OPENAI_MODEL="gemini-2.0-flash"
+npm run audit -- my_reddit_handle
+```
+
+
+
+Ollama (local, fully offline)
+
+Install [Ollama](https://ollama.ai), pull a model, then run:
+
+```bash
+ollama pull llama3
+npm run audit -- my_reddit_handle --provider ollama
+
+# Or manually:
+npm run audit -- my_reddit_handle --base-url http://localhost:11434/v1 --model llama3
+```
+
+No API key needed — everything runs on your machine.
+
+
+
+Groq
+
+```bash
+export OPENAI_API_KEY="your-groq-key"
+npm run audit -- my_reddit_handle --provider groq
+```
+
+
+
+Together AI
+
+```bash
+export OPENAI_API_KEY="your-together-key"
+npm run audit -- my_reddit_handle --provider together
+```
+
+
+
+NVIDIA NIM
+
+```bash
+export OPENAI_API_KEY="nvapi-..."
+npm run audit -- my_reddit_handle --provider nvidia
+```
+
+
+
+Mistral
+
+```bash
+export OPENAI_API_KEY="your-mistral-key"
+npm run audit -- my_reddit_handle --provider mistral
+```
+
+
+
+Any other OpenAI-compatible endpoint
+
+Point `--base-url` at any Chat Completions surface:
+
+```bash
+export OPENAI_API_KEY="your-key"
+npm run audit -- my_reddit_handle --base-url https://your-api.example.com/v1 --model your-model
+```
+
+
+### Model fallback
+
+When the primary model fails with a retryable error (429 rate limit, 504
+gateway timeout, empty response), deanonymizer automatically retries with a
+known-good fallback model for that provider. You'll see a warning like:
+
+```
+⚠ Model "openrouter/free" failed, falling back to "google/gemini-2.0-flash-exp:free"
+```
+
+This happens transparently — no configuration needed.
+
## Formal objective
Given a subject handle set H and public artifact set D, produce a risk report R
@@ -140,10 +345,16 @@ npm run audit -- my_reddit_handle --require-external-proof
npm run audit -- my_reddit_handle --concurrency 3
# Run against a local Ollama model
-npm run audit -- my_reddit_handle --base-url http://localhost:11434/v1 --model llama3
+npm run audit -- my_reddit_handle --provider ollama
+
+# Use OpenRouter with a specific free model
+npm run audit -- my_reddit_handle --provider openrouter --model meta-llama/llama-3.3-70b-instruct:free
# Force a specific provider/model for one run
npm run audit -- my_reddit_handle --provider openai --model gpt-4o-mini
+
+# Increase timeout for slow models (default: 70000ms)
+npm run audit -- my_reddit_handle --timeout 120000
```
## CLI options
@@ -151,6 +362,14 @@ npm run audit -- my_reddit_handle --provider openai --model gpt-4o-mini
| Flag | Default | Description |
|------|---------|-------------|
| [reddit-username] / --reddit | none | Reddit user to audit (accepts u/name) |
+| --hn \ | none | Hacker News user to audit |
+| -n, --max \ | 300 | Maximum items fetched per platform |
+| --max-chars \ | 120000 | Maximum analysis transcript budget |
+| --concurrency \ | all (≤8) | Number of chunk workers processed in parallel |
+| --provider \ | auto-detect | LLM provider (see Supported Providers above) |
+| --base-url \ | none | OpenAI-compatible base URL (overrides provider preset) |
+| --model \ | provider default | Override the model name |
+| --timeout \ | 70000 | LLM request timeout in milliseconds |
| --hn | none | Hacker News user to audit |
| --github | none | GitHub user to audit (uses public REST API; set `GITHUB_TOKEN` to raise rate limit) |
| --so | none | Stack Overflow user to audit (numeric user_id or profile URL) |
@@ -162,9 +381,53 @@ npm run audit -- my_reddit_handle --provider openai --model gpt-4o-mini
| --model | provider default | Override the model name |
| --json | false | Emit JSON instead of text report |
| --require-external-proof | false | Fail if no proof URL exists beyond audited profile pages |
-| -o, --out | stdout | Write output to file |
+| -o, --out \ | stdout | Write output to file |
| --i-am-authorized | false | Skip interactive authorization prompt for scripted runs |
+## Troubleshooting
+
+### "timed out after 45000ms" / "timed out after 70000ms"
+
+The LLM is taking too long to respond. Common causes:
+- **Free-tier congestion** (especially OpenRouter's `openrouter/free` auto-router)
+- **Too many concurrent chunks** overwhelming rate limits
+
+Fixes:
+```bash
+# Increase the timeout
+npm run audit -- my_handle --timeout 120000
+
+# Reduce concurrency (process one chunk at a time)
+npm run audit -- my_handle --concurrency 1
+
+# Reduce the number of posts to analyze
+npm run audit -- my_handle -n 100
+
+# Switch to a faster model
+npm run audit -- my_handle --provider openrouter --model google/gemini-2.0-flash-exp:free
+```
+
+### "returned no choices or an error payload: {\"error\":{...}}"
+
+The LLM provider returned an error instead of a valid response. The error JSON
+is included in the message for diagnosis. Common causes:
+- **504 Gateway Timeout**: Provider is overloaded. Try a different model.
+- **429 Rate Limit**: Too many requests. Lower `--concurrency` or wait.
+- **401 Unauthorized**: Check your API key.
+
+### "Cannot read properties of undefined (reading '0')"
+
+This was a bug in older versions where error payloads from providers like
+OpenRouter would crash the tool. Update to the latest version — this is now
+handled gracefully with a descriptive error message and automatic fallback.
+
+### Model keeps failing on OpenRouter
+
+Avoid `openrouter/free` — use an explicit free model:
+```bash
+npm run audit -- my_handle --provider openrouter --model google/gemini-2.0-flash-exp:free
+```
+
## Reproducibility and calibration
- Increase -n to expand retrieval depth
diff --git a/src/analyze.ts b/src/analyze.ts
index 7c60e6d..9a24420 100644
--- a/src/analyze.ts
+++ b/src/analyze.ts
@@ -269,10 +269,14 @@ export async function analyze(
maxChars: number;
chunkChars?: number;
chunkConcurrency?: number;
+ /** Primary chunk timeout in ms (default 70 000). */
+ timeoutMs?: number;
onProgress?: (p: AnalyzeProgress) => void;
},
): Promise {
const llm = opts.llm;
+ const primaryTimeout = opts.timeoutMs ?? 70000;
+ const compressedTimeout = Math.max(Math.round(primaryTimeout * 0.65), 30000);
const allItems = profiles.flatMap((p) => p.items);
const username = profiles[0]?.username ?? "(unknown)";
@@ -373,6 +377,7 @@ ${SCHEMA_HINT}`;
maxTokens: 2200,
json: true,
}),
+ primaryTimeout,
llm.requestTimeoutMs ?? 70000,
`chunk ${currentChunk}/${totalChunks}`,
);
@@ -409,6 +414,7 @@ ${SCHEMA_HINT}`;
maxTokens: 1100,
json: true,
}),
+ compressedTimeout,
llm.requestTimeoutMs ? Math.round(llm.requestTimeoutMs * 0.6) : 45000,
`compressed chunk ${currentChunk}/${totalChunks}`,
);
diff --git a/src/index.ts b/src/index.ts
index 042826d..60d840a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -50,6 +50,7 @@ program
)
.option(
"--provider ",
+ "LLM provider: anthropic, openai, openrouter, gemini, ollama, groq, together, nvidia, mistral (default: auto-detect)",
"LLM provider: 'anthropic', 'openai', or 'claude-code' (default: auto-detect from env)",
)
.option(
@@ -66,6 +67,10 @@ program
"Fail if no public proof URL exists beyond the audited platform profile pages",
)
.option("-o, --out ", "Write the report to a file")
+ .option(
+ "--timeout ",
+ "LLM request timeout in milliseconds (default: 70000)",
+ )
.option(
"--i-am-authorized",
"Skip the interactive consent prompt (asserts you own/are authorized for the target)",
@@ -110,6 +115,9 @@ program
const concurrency = opts.concurrency
? Math.max(1, Number.parseInt(opts.concurrency, 10) || 1)
: undefined;
+ const timeoutMs = opts.timeout
+ ? Math.max(10000, Number.parseInt(opts.timeout, 10) || 70000)
+ : undefined;
// Resolve the LLM backend up front so a misconfigured provider fails before
// we spend time fetching public history.
@@ -168,6 +176,7 @@ program
llm,
maxChars,
chunkConcurrency: concurrency,
+ timeoutMs,
onProgress: (p) => {
const chunkLabel =
p.currentChunk && p.totalChunks
diff --git a/src/llm/config.test.ts b/src/llm/config.test.ts
index 3c2c2bf..92fd702 100644
--- a/src/llm/config.test.ts
+++ b/src/llm/config.test.ts
@@ -32,8 +32,23 @@ describe("resolveProvider", () => {
);
});
+ it("accepts all supported provider names", () => {
+ const providers = [
+ "openrouter",
+ "gemini",
+ "ollama",
+ "groq",
+ "together",
+ "nvidia",
+ "mistral",
+ ];
+ for (const p of providers) {
+ assert.equal(resolveProvider({ provider: p }, {}), p);
+ }
+ });
+
it("throws on an unknown provider name", () => {
- assert.throws(() => resolveProvider({ provider: "gemini" }, {}), /Unknown/);
+ assert.throws(() => resolveProvider({ provider: "foobar" }, {}), /Unknown/);
});
it("throws when nothing is configured", () => {
@@ -65,6 +80,32 @@ describe("createLLMClient", () => {
assert.match(client.label, /openai \(base: http:\/\/localhost:11434\/v1\)/);
});
+ it("uses provider preset base URL for openrouter", () => {
+ const client = createLLMClient(
+ { provider: "openrouter" },
+ { OPENAI_API_KEY: "sk-or" },
+ );
+ assert.match(client.label, /openrouter\.ai/);
+ assert.equal(client.model, "google/gemini-2.0-flash-exp:free");
+ });
+
+ it("uses provider preset base URL for nvidia", () => {
+ const client = createLLMClient(
+ { provider: "nvidia" },
+ { OPENAI_API_KEY: "nvapi-..." },
+ );
+ assert.match(client.label, /nvidia/);
+ assert.equal(client.model, "meta/llama-3.3-70b-instruct");
+ });
+
+ it("allows model override even with a provider preset", () => {
+ const client = createLLMClient(
+ { provider: "groq", model: "custom-model" },
+ { OPENAI_API_KEY: "gsk-..." },
+ );
+ assert.equal(client.model, "custom-model");
+ });
+
it("throws when openai is selected without key or base url", () => {
assert.throws(
() => createLLMClient({ provider: "openai" }, {}),
diff --git a/src/llm/index.ts b/src/llm/index.ts
index 9614636..0ffe49e 100644
--- a/src/llm/index.ts
+++ b/src/llm/index.ts
@@ -6,7 +6,59 @@ import type { LLMClient, Provider } from "./types.js";
export type { LLMClient, Provider } from "./types.js";
const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5";
-const OPENAI_DEFAULT_MODEL = "gpt-4o-mini";
+
+/**
+ * Known provider presets. Each maps a friendly --provider name to the
+ * base URL, default model, and fallback models for the OpenAI-compatible
+ * client. Anthropic uses its native SDK and is handled separately.
+ */
+interface ProviderPreset {
+ baseUrl?: string;
+ defaultModel: string;
+ fallbackModels: string[];
+}
+
+const PROVIDER_PRESETS: Record = {
+ openai: {
+ defaultModel: "gpt-4o-mini",
+ fallbackModels: ["gpt-4o"],
+ },
+ openrouter: {
+ baseUrl: "https://openrouter.ai/api/v1",
+ defaultModel: "google/gemini-2.0-flash-exp:free",
+ fallbackModels: ["meta-llama/llama-3.3-70b-instruct:free"],
+ },
+ gemini: {
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
+ defaultModel: "gemini-2.0-flash",
+ fallbackModels: ["gemini-1.5-flash"],
+ },
+ ollama: {
+ baseUrl: "http://localhost:11434/v1",
+ defaultModel: "llama3",
+ fallbackModels: ["mistral"],
+ },
+ groq: {
+ baseUrl: "https://api.groq.com/openai/v1",
+ defaultModel: "llama-3.3-70b-versatile",
+ fallbackModels: ["gemma2-9b-it"],
+ },
+ together: {
+ baseUrl: "https://api.together.xyz/v1",
+ defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
+ fallbackModels: [],
+ },
+ nvidia: {
+ baseUrl: "https://integrate.api.nvidia.com/v1",
+ defaultModel: "meta/llama-3.3-70b-instruct",
+ fallbackModels: ["nvidia/llama-3.1-nemotron-70b-instruct"],
+ },
+ mistral: {
+ baseUrl: "https://api.mistral.ai/v1",
+ defaultModel: "mistral-small-latest",
+ fallbackModels: ["open-mistral-nemo"],
+ },
+};
/** Per-run overrides supplied via CLI flags; each falls back to env. */
export interface LLMOverrides {
@@ -17,8 +69,23 @@ export interface LLMOverrides {
type Env = Record;
+const KNOWN_PROVIDERS = new Set([
+ "anthropic",
+ "openai",
+ "openrouter",
+ "gemini",
+ "ollama",
+ "groq",
+ "together",
+ "nvidia",
+ "mistral",
+]);
+
function normalizeProvider(value: string): Provider {
const p = value.trim().toLowerCase();
+ if (KNOWN_PROVIDERS.has(p)) return p as Provider;
+ throw new Error(
+ `Unknown LLM provider "${value}". Supported: ${[...KNOWN_PROVIDERS].join(", ")}.`,
if (p === "anthropic" || p === "openai" || p === "claude-code") return p;
throw new Error(
`Unknown LLM provider "${value}". Use "anthropic", "openai", or "claude-code".`,
@@ -46,6 +113,9 @@ export function resolveProvider(
throw new Error(
"No LLM provider configured. Set OPENAI_API_KEY (optionally with " +
"OPENAI_BASE_URL for Gemini/Ollama/etc.) or ANTHROPIC_API_KEY, or pass " +
+ "--provider/--base-url.\n\n" +
+ "Supported providers: " +
+ [...KNOWN_PROVIDERS].join(", "),
"--provider/--base-url. Pass --provider claude-code to route through the " +
"Claude Code CLI without an API key.",
);
@@ -77,7 +147,12 @@ export function createLLMClient(
return new AnthropicClient({ apiKey, model });
}
- const baseUrl = overrides.baseUrl ?? env.OPENAI_BASE_URL;
+ // All non-anthropic providers go through the OpenAI-compatible client.
+ const preset = PROVIDER_PRESETS[provider];
+
+ // Base URL priority: CLI flag → env → provider preset → undefined (plain OpenAI)
+ const baseUrl = overrides.baseUrl ?? env.OPENAI_BASE_URL ?? preset?.baseUrl;
+
// Local servers (e.g. Ollama) accept any non-empty key; only require a real
// key when talking to a hosted endpoint without an explicit base URL.
const apiKey = env.OPENAI_API_KEY ?? "";
@@ -87,6 +162,18 @@ export function createLLMClient(
"OPENAI_BASE_URL/--base-url is set.",
);
}
- const model = overrides.model ?? env.OPENAI_MODEL ?? OPENAI_DEFAULT_MODEL;
- return new OpenAIClient({ apiKey: apiKey || "not-needed", baseUrl, model });
+
+ const model =
+ overrides.model ??
+ env.OPENAI_MODEL ??
+ preset?.defaultModel ??
+ "gpt-4o-mini";
+ const fallbackModels = preset?.fallbackModels ?? [];
+
+ return new OpenAIClient({
+ apiKey: apiKey || "not-needed",
+ baseUrl,
+ model,
+ fallbackModels,
+ });
}
diff --git a/src/llm/openai.test.ts b/src/llm/openai.test.ts
index 9b29864..a697211 100644
--- a/src/llm/openai.test.ts
+++ b/src/llm/openai.test.ts
@@ -22,11 +22,12 @@ function stub(
return { lastRequest: () => lastRequest };
}
-function makeClient(): OpenAIClient {
+function makeClient(fallbackModels?: string[]): OpenAIClient {
return new OpenAIClient({
apiKey: "test-key",
baseUrl: "http://localhost:11434/v1",
model: "llama3",
+ fallbackModels,
});
}
@@ -97,7 +98,41 @@ describe("OpenAIClient.complete", () => {
const client = makeClient();
stub(client, async () => ({ choices: [] }));
await assert.rejects(client.complete({ user: "go", maxTokens: 5 }), {
- message: /returned no content/,
+ message: /returned no choices/,
});
});
+
+ it("throws when choices is undefined (e.g. OpenRouter error payload)", async () => {
+ const client = makeClient();
+ stub(client, async () => ({
+ error: { message: "The operation was aborted", code: 504 },
+ }));
+ await assert.rejects(client.complete({ user: "go", maxTokens: 5 }), {
+ message: /returned no choices or an error payload/,
+ });
+ });
+
+ it("falls back to next model on retryable error", async () => {
+ const client = makeClient(["fallback-model"]);
+ let callCount = 0;
+ stub(client, async (req) => {
+ callCount++;
+ const r = req as { model: string };
+ if (r.model === "llama3") {
+ const err = new Error("rate limit exceeded") as Error & {
+ status: number;
+ };
+ err.status = 429;
+ throw err;
+ }
+ return {
+ choices: [
+ { message: { content: "from-fallback" }, finish_reason: "stop" },
+ ],
+ };
+ });
+ const result = await client.complete({ user: "go", maxTokens: 5 });
+ assert.equal(result, "from-fallback");
+ assert.equal(callCount, 2);
+ });
});
diff --git a/src/llm/openai.ts b/src/llm/openai.ts
index 5d73855..6eeaf19 100644
--- a/src/llm/openai.ts
+++ b/src/llm/openai.ts
@@ -4,36 +4,106 @@ import type {
ChatCompletionMessageParam,
} from "openai/resources/chat/completions";
import type { LLMClient, LLMCompleteParams } from "./types.js";
+import pc from "picocolors";
+
+/** HTTP status codes worth retrying with a different model. */
+const RETRYABLE_CODES = new Set([429, 500, 502, 503, 504]);
+
+function isRetryableError(error: unknown): boolean {
+ if (error instanceof Error) {
+ const msg = error.message.toLowerCase();
+ if (
+ msg.includes("rate limit") ||
+ msg.includes("timeout") ||
+ msg.includes("econnrefused") ||
+ msg.includes("econnreset") ||
+ msg.includes("aborted")
+ )
+ return true;
+ }
+ // OpenAI SDK attaches `status` on API errors.
+ const status = (error as { status?: number })?.status;
+ if (status && RETRYABLE_CODES.has(status)) return true;
+ return false;
+}
/**
* OpenAI-compatible backend. By configuring `baseURL` this also drives Gemini
* (generativelanguage…/openai/), Ollama (localhost:11434/v1), Groq, Together,
- * and any other Chat Completions-compatible endpoint. No prompt caching —
- * `response_format: json_object` is requested when supported, and the caller's
- * JSON-repair path still runs as a safety net for endpoints that ignore it.
+ * NVIDIA NIM, OpenRouter, Mistral, and any other Chat Completions-compatible
+ * endpoint.
+ *
+ * When `fallbackModels` are configured, a retryable failure (504, 429, empty
+ * choices, etc.) on the primary model triggers an automatic retry with the
+ * next fallback before the error propagates.
*/
export class OpenAIClient implements LLMClient {
readonly label: string;
readonly model: string;
+ private readonly fallbackModels: string[];
+ private activeModel: string;
private readonly client: OpenAI;
- constructor(config: { apiKey: string; baseUrl?: string; model: string }) {
+ constructor(config: {
+ apiKey: string;
+ baseUrl?: string;
+ model: string;
+ fallbackModels?: string[];
+ }) {
this.client = new OpenAI({
apiKey: config.apiKey,
...(config.baseUrl ? { baseURL: config.baseUrl } : {}),
});
this.model = config.model;
+ this.activeModel = config.model;
+ this.fallbackModels = config.fallbackModels ?? [];
this.label = config.baseUrl ? `openai (base: ${config.baseUrl})` : "openai";
}
async complete(params: LLMCompleteParams): Promise {
+ const modelsToTry = [
+ this.activeModel,
+ ...this.fallbackModels.filter((m) => m !== this.activeModel),
+ ];
+
+ let lastError: unknown;
+ for (const model of modelsToTry) {
+ try {
+ return await this.tryComplete(params, model);
+ } catch (error) {
+ lastError = error;
+ if (
+ isRetryableError(error) &&
+ model !== modelsToTry[modelsToTry.length - 1]
+ ) {
+ const nextModel = modelsToTry[modelsToTry.indexOf(model) + 1];
+ if (nextModel) {
+ process.stderr.write(
+ pc.yellow(
+ ` ⚠ Model "${model}" failed, falling back to "${nextModel}"\n`,
+ ),
+ );
+ this.activeModel = nextModel;
+ continue;
+ }
+ }
+ throw error;
+ }
+ }
+ throw lastError;
+ }
+
+ private async tryComplete(
+ params: LLMCompleteParams,
+ model: string,
+ ): Promise {
const messages: ChatCompletionMessageParam[] = [];
if (params.system)
messages.push({ role: "system", content: params.system });
messages.push({ role: "user", content: params.user });
const request: ChatCompletionCreateParamsNonStreaming = {
- model: this.model,
+ model,
max_tokens: params.maxTokens,
messages,
...(params.json ? { response_format: { type: "json_object" } } : {}),
@@ -44,7 +114,20 @@ export class OpenAIClient implements LLMClient {
resp = await this.client.chat.completions.create(request);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
- throw new Error(`${this.label} request failed: ${message}`);
+ throw Object.assign(
+ new Error(`${this.label} request failed: ${message}`),
+ { status: (error as { status?: number })?.status },
+ );
+ }
+
+ if (!resp.choices || resp.choices.length === 0) {
+ const respStr = JSON.stringify(resp);
+ throw Object.assign(
+ new Error(
+ `${this.label} returned no choices or an error payload: ${respStr}`,
+ ),
+ { status: 502 },
+ );
}
const choice = resp.choices[0];
diff --git a/src/llm/types.ts b/src/llm/types.ts
index f383dcf..317b9a9 100644
--- a/src/llm/types.ts
+++ b/src/llm/types.ts
@@ -1,5 +1,15 @@
/** Provider-agnostic LLM abstraction used by the analysis stage. */
+export type Provider =
+ | "anthropic"
+ | "openai"
+ | "openrouter"
+ | "gemini"
+ | "ollama"
+ | "groq"
+ | "together"
+ | "nvidia"
+ | "mistral";
export type Provider = "anthropic" | "openai" | "claude-code";
/** A single completion request, normalized across providers. */
diff --git a/src/report.ts b/src/report.ts
index 550fc31..9437c9e 100644
--- a/src/report.ts
+++ b/src/report.ts
@@ -7,6 +7,10 @@ const RISK_COLOR = {
high: pc.red,
} as const;
+const CONF_BADGE = {
+ high: pc.bgRed(pc.white(pc.bold(" HIGH "))),
+ medium: pc.bgYellow(pc.black(pc.bold(" MED "))),
+ low: pc.bgBlack(pc.dim(" LOW ")),
const CONF_TAG = {
high: pc.red(pc.bold("HIGH")),
medium: pc.yellow(pc.bold("MED")),
@@ -15,6 +19,8 @@ const CONF_TAG = {
const ORDER = { high: 0, medium: 1, low: 2 } as const;
+const BOX_WIDTH = 66;
+
function date(utc: number): string {
return new Date(utc * 1000).toISOString().slice(0, 10);
}
@@ -74,6 +80,34 @@ function findingBlock(f: Finding, n: number): string[] {
return out;
}
+/** Wrap content lines in a colored box with the severity badge in the top border. */
+function findingBox(
+ f: Finding,
+ index: number,
+ contentLines: string[],
+): string[] {
+ const color = RISK_COLOR[f.confidence];
+ const badge = CONF_BADGE[f.confidence];
+ const w = BOX_WIDTH;
+
+ // Top border with badge embedded
+ const badgePlain = { high: " HIGH ", medium: " MED ", low: " LOW " }[f.confidence];
+ const topAfterBadge = w - 5 - badgePlain.length;
+ const top =
+ color("┌─ ") +
+ badge +
+ " " +
+ color("─".repeat(Math.max(topAfterBadge, 0)) + "┐");
+
+ const bottom = color("└" + "─".repeat(w - 2) + "┘");
+
+ const boxed = contentLines.map((line) => {
+ return color("│") + " " + line;
+ });
+
+ return [top, ...boxed, bottom];
+}
+
export function renderText(r: AuditResult): string {
const out: string[] = [];
const counts = { high: 0, medium: 0, low: 0 };
@@ -169,6 +203,21 @@ export function renderText(r: AuditResult): string {
(a, b) => ORDER[a.confidence] - ORDER[b.confidence],
);
+ sorted.forEach((f: Finding, i) => {
+ const lines: string[] = [];
+
+ lines.push(
+ `${pc.dim(`#${i + 1}`)} ${pc.cyan(f.category)} — ${pc.bold(f.claim)}`,
+ );
+ lines.push(` ${pc.dim("why")} ${f.rationale}`);
+ for (const e of f.evidence ?? []) {
+ lines.push(` ${pc.dim("┊")} "${e.quote}"`);
+ lines.push(` ${pc.blue(pc.underline(e.permalink))}`);
+ }
+ lines.push(` ${pc.green("fix")} ${f.remediation}`);
+
+ out.push(...findingBox(f, i, lines));
+ out.push("");
let currentGroup: Finding["confidence"] | null = null;
sorted.forEach((f, i) => {
if (f.confidence !== currentGroup) {