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) {