- Paste an article URL. Get a forensic breakdown — every claim tagged,
- its sources named, its biases surfaced, and a first-principles critique
- you can argue with.
+ Paste an article URL. Get a forensic breakdown — every claim tagged, its sources named,
+ its biases surfaced, and a first-principles critique you can argue with.
@@ -116,7 +115,9 @@ function ClaimLegend() {
-
+
{c.label}
@@ -136,9 +137,9 @@ function EmptyState() {
How it reads an article
- truthseeker doesn't tell you whether an article is true. It exposes
- its structure so you can judge it
- yourself. Every load-bearing claim is sorted into one of three kinds:
+ truthseeker doesn't tell you whether an article is true. It exposes its{" "}
+ structure so you can judge it yourself. Every
+ load-bearing claim is sorted into one of three kinds:
@@ -166,9 +167,7 @@ function LoadingState() {
-
- Examining
-
+
Examining
→ fetching and cleaning article body
@@ -262,9 +261,7 @@ function Result({ result }: { result: AnalysisResult }) {
>
{c.claim}
-
- {style.label}
-
+ {style.label}{c.evidence_quality}
{c.supported_in_article ? "· supported in article" : "· unsupported"}
diff --git a/src/lib/analysis.ts b/src/lib/analysis.ts
index d9634ec..7b791f0 100644
--- a/src/lib/analysis.ts
+++ b/src/lib/analysis.ts
@@ -47,10 +47,14 @@ export async function analyzeUrl(url: string): Promise {
const fetched = await fetchArticle(url);
if (fetched.status >= 400) {
- throw new Error(`Could not fetch article (HTTP ${fetched.status}). Some sites block bots — try downloading the article and pasting its text instead.`);
+ throw new Error(
+ `Could not fetch article (HTTP ${fetched.status}). Some sites block bots — try downloading the article and pasting its text instead.`,
+ );
}
if (fetched.textLength < 200) {
- throw new Error(`Article body was too short (${fetched.textLength} chars after cleaning). The page may require JavaScript or paywall login.`);
+ throw new Error(
+ `Article body was too short (${fetched.textLength} chars after cleaning). The page may require JavaScript or paywall login.`,
+ );
}
return analyzeText({
@@ -76,7 +80,9 @@ export async function analyzeText(opts: {
}): Promise {
const start = Date.now();
if (!opts.text || opts.text.trim().length < 200) {
- throw new Error(`Article text was too short (${opts.text?.length ?? 0} chars). Need at least 200 chars of body to analyze meaningfully.`);
+ throw new Error(
+ `Article text was too short (${opts.text?.length ?? 0} chars). Need at least 200 chars of body to analyze meaningfully.`,
+ );
}
// Cap the article text we send. The model's context window is large, but
@@ -87,9 +93,7 @@ export async function analyzeText(opts: {
// for article text.
const MAX_CHARS = 20_000;
const truncated = opts.text.length > MAX_CHARS;
- const articleText = truncated
- ? opts.text.slice(0, MAX_CHARS) + "\n[...truncated]"
- : opts.text;
+ const articleText = truncated ? opts.text.slice(0, MAX_CHARS) + "\n[...truncated]" : opts.text;
const completion = await callLLM(
buildUserPrompt({
@@ -169,7 +173,8 @@ function validateAnalysisShape(v: unknown): Analysis {
if (typeof c !== "object" || c === null) return fail("a core_claims entry is not an object");
const claim = c as Record;
if (typeof claim.claim !== "string") return fail("a core_claims entry is missing 'claim'");
- if (!CLAIM_TYPES.has(claim.type as string)) return fail(`a core_claims entry has invalid type '${claim.type}'`);
+ if (!CLAIM_TYPES.has(claim.type as string))
+ return fail(`a core_claims entry has invalid type '${claim.type}'`);
if (typeof claim.supported_in_article !== "boolean")
return fail("a core_claims entry's supported_in_article is not a boolean");
if (!EVIDENCE_QUALITIES.has(claim.evidence_quality as string))
diff --git a/src/lib/article-fetch.test.ts b/src/lib/article-fetch.test.ts
index 6e8240a..ad9f647 100644
--- a/src/lib/article-fetch.test.ts
+++ b/src/lib/article-fetch.test.ts
@@ -10,19 +10,19 @@
* all. The fix is to stop hand-copying the WHATWG table into app code; these
* tests are the gate that keeps it gone.
*/
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { fetchArticle } from './article-fetch'
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchArticle } from "./article-fetch";
/** Serve one canned HTML response to the next fetchArticle call. */
-function stubPage(html: string, contentType: string | null = 'text/html; charset=utf-8') {
+function stubPage(html: string, contentType: string | null = "text/html; charset=utf-8") {
vi.stubGlobal(
- 'fetch',
+ "fetch",
vi.fn(async () => ({
status: 200,
headers: { get: () => contentType },
text: async () => html,
})),
- )
+ );
}
/**
@@ -32,109 +32,116 @@ function stubPage(html: string, contentType: string | null = 'text/html; charset
* each test about one thing.
*/
const page = (head: string, body: string) =>
- `${head}${body}`
-const bodyOnly = (body: string) => page('', body)
+ `${head}${body}`;
+const bodyOnly = (body: string) => page("", body);
afterEach(() => {
- vi.unstubAllGlobals()
-})
-
-describe('entity decoding', () => {
- it('decodes German named entities — the publications this tool targets are German-language', async () => {
- stubPage(page('Der Wähler', '
"));
+ const { text } = await fetchArticle("https://example.com/a");
+ expect(text).toBe("write < for a less-than sign");
+ });
+
+ it("leaves a bare ampersand alone", async () => {
+ stubPage(bodyOnly("
AT&T and R&D spending
"));
+ const { text } = await fetchArticle("https://example.com/a");
+ expect(text).toBe("AT&T and R&D spending");
+ });
+
+ it("applies identical decoding to title and body", async () => {
+ const entity = "über & 'so'";
+ stubPage(page(`${entity}`, ""));
+ const article = await fetchArticle("https://example.com/a");
+ expect(article.title).toBe("über & 'so'");
+ expect(article.text).toBe(article.title);
+ });
+});
+
+describe("text extraction", () => {
+ it("strips scripts, styles and chrome out of the body", async () => {
stubPage(
page(
- '',
- '
The real body.
',
+ "",
+ "
The real body.
",
),
- )
- const { text } = await fetchArticle('https://example.com/a')
- expect(text).toBe('The real body.')
- })
-
- it('falls back to the first h1 when there is no title tag', async () => {
- stubPage(page('', '
Headline & subhead
Body text here.
'))
- const { title } = await fetchArticle('https://example.com/a')
- expect(title).toBe('Headline & subhead')
- })
-
- it('collapses whitespace in a multi-line title', async () => {
- stubPage(page('\n A long\n headline\n', '
Body.
'))
- const { title } = await fetchArticle('https://example.com/a')
- expect(title).toBe('A long headline')
- })
-
- it('returns a null title rather than an empty string when there is none', async () => {
- stubPage(page('', '
Body only.
'))
- const { title } = await fetchArticle('https://example.com/a')
- expect(title).toBeNull()
- })
-
- it('reports textLength consistent with the cleaned text', async () => {
- stubPage(bodyOnly('
Straße
'))
- const { text, textLength } = await fetchArticle('https://example.com/a')
- expect(textLength).toBe(text.length)
- expect(textLength).toBe('Straße'.length)
- })
-})
-
-describe('content-type guard', () => {
- it('rejects a PDF before an LLM call is spent on its binary bytes', async () => {
- stubPage('%PDF-1.4 binary junk', 'application/pdf')
- await expect(fetchArticle('https://example.com/a.pdf')).rejects.toThrow(/non-HTML content/)
- })
-
- it('accepts a missing content-type rather than guessing', async () => {
- stubPage(bodyOnly('
Body text.
'), null)
- const { text } = await fetchArticle('https://example.com/a')
- expect(text).toBe('Body text.')
- })
-})
+ );
+ const { text } = await fetchArticle("https://example.com/a");
+ expect(text).toBe("The real body.");
+ });
+
+ it("falls back to the first h1 when there is no title tag", async () => {
+ stubPage(page("", "
Headline & subhead
Body text here.
"));
+ const { title } = await fetchArticle("https://example.com/a");
+ expect(title).toBe("Headline & subhead");
+ });
+
+ it("collapses whitespace in a multi-line title", async () => {
+ stubPage(page("\n A long\n headline\n", "
Body.
"));
+ const { title } = await fetchArticle("https://example.com/a");
+ expect(title).toBe("A long headline");
+ });
+
+ it("returns a null title rather than an empty string when there is none", async () => {
+ stubPage(page("", "
Body only.
"));
+ const { title } = await fetchArticle("https://example.com/a");
+ expect(title).toBeNull();
+ });
+
+ it("reports textLength consistent with the cleaned text", async () => {
+ stubPage(bodyOnly("
Straße
"));
+ const { text, textLength } = await fetchArticle("https://example.com/a");
+ expect(textLength).toBe(text.length);
+ expect(textLength).toBe("Straße".length);
+ });
+});
+
+describe("content-type guard", () => {
+ it("rejects a PDF before an LLM call is spent on its binary bytes", async () => {
+ stubPage("%PDF-1.4 binary junk", "application/pdf");
+ await expect(fetchArticle("https://example.com/a.pdf")).rejects.toThrow(/non-HTML content/);
+ });
+
+ it("accepts a missing content-type rather than guessing", async () => {
+ stubPage(bodyOnly("
Body text.
"), null);
+ const { text } = await fetchArticle("https://example.com/a");
+ expect(text).toBe("Body text.");
+ });
+});
diff --git a/src/lib/article-fetch.ts b/src/lib/article-fetch.ts
index a49ec87..38e1cd0 100644
--- a/src/lib/article-fetch.ts
+++ b/src/lib/article-fetch.ts
@@ -31,7 +31,8 @@ export async function fetchArticle(url: string): Promise {
redirect: "follow",
headers: {
"User-Agent": UA,
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ Accept:
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,de;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Cache-Control": "no-cache",
diff --git a/src/lib/claim-types.test.ts b/src/lib/claim-types.test.ts
index 330da56..6ba7f85 100644
--- a/src/lib/claim-types.test.ts
+++ b/src/lib/claim-types.test.ts
@@ -13,88 +13,85 @@
* Nothing executed this repo's source before: `verify` was lint + typecheck and
* there was no test script at all.
*/
-import { describe, expect, it } from 'vitest'
-import fs from 'node:fs'
-import path from 'node:path'
-import { CLAIM_TYPES, CLAIM_TYPE_ORDER } from './claim-types'
+import { describe, expect, it } from "vitest";
+import fs from "node:fs";
+import path from "node:path";
+import { CLAIM_TYPES, CLAIM_TYPE_ORDER } from "./claim-types";
-const globalsCss = fs.readFileSync(
- path.join(__dirname, '..', 'app', 'globals.css'),
- 'utf8',
-)
+const globalsCss = fs.readFileSync(path.join(__dirname, "..", "app", "globals.css"), "utf8");
/** Custom properties defined anywhere in globals.css. */
const definedVars = new Set(
[...globalsCss.matchAll(/(^|[;{\s])(--[a-z0-9-]+)\s*:/gi)].map((m) => m[2]),
-)
+);
/**
* Colour-bearing utility prefixes used by this module. The token name is
* whatever follows, and must exist as `--color-`.
*/
-const COLOUR_PREFIXES = ['border-l-', 'bg-', 'text-']
+const COLOUR_PREFIXES = ["border-l-", "bg-", "text-"];
/** Every colour token referenced by a style object's class strings. */
function tokensIn(classNames: string[]): string[] {
- const tokens: string[] = []
+ const tokens: string[] = [];
for (const cls of classNames.flatMap((c) => c.split(/\s+/)).filter(Boolean)) {
- const prefix = COLOUR_PREFIXES.find((p) => cls.startsWith(p))
- if (!prefix) continue
- const token = cls.slice(prefix.length)
+ const prefix = COLOUR_PREFIXES.find((p) => cls.startsWith(p));
+ if (!prefix) continue;
+ const token = cls.slice(prefix.length);
// `text-white` and friends are Tailwind built-ins, not project tokens.
- if (['white', 'black', 'transparent', 'current', 'inherit'].includes(token)) continue
- tokens.push(token)
+ if (["white", "black", "transparent", "current", "inherit"].includes(token)) continue;
+ tokens.push(token);
}
- return tokens
+ return tokens;
}
-describe('claim-type colour tokens', () => {
- it('references at least one token per claim type (parser sanity)', () => {
+describe("claim-type colour tokens", () => {
+ it("references at least one token per claim type (parser sanity)", () => {
for (const [type, style] of Object.entries(CLAIM_TYPES)) {
- const tokens = tokensIn([style.border, style.text, style.chip, style.dot])
- expect(tokens.length, `no colour tokens parsed for "${type}"`).toBeGreaterThan(0)
+ const tokens = tokensIn([style.border, style.text, style.chip, style.dot]);
+ expect(tokens.length, `no colour tokens parsed for "${type}"`).toBeGreaterThan(0);
}
- })
+ });
- it('every class it names resolves to a --color-* defined in globals.css', () => {
- const missing: string[] = []
+ it("every class it names resolves to a --color-* defined in globals.css", () => {
+ const missing: string[] = [];
for (const [type, style] of Object.entries(CLAIM_TYPES)) {
for (const token of tokensIn([style.border, style.text, style.chip, style.dot])) {
- if (!definedVars.has(`--color-${token}`)) missing.push(`${type}: --color-${token}`)
+ if (!definedVars.has(`--color-${token}`)) missing.push(`${type}: --color-${token}`);
}
}
expect(
missing,
- `claim-types.ts names utilities whose colour token globals.css does not define:\n ${missing.join('\n ')}`,
- ).toEqual([])
- })
-})
+ `claim-types.ts names utilities whose colour token globals.css does not define:\n ${missing.join("\n ")}`,
+ ).toEqual([]);
+ });
+});
-describe('CLAIM_TYPE_ORDER', () => {
- it('covers exactly the keys of CLAIM_TYPES', () => {
+describe("CLAIM_TYPE_ORDER", () => {
+ it("covers exactly the keys of CLAIM_TYPES", () => {
// Drift here is silent in the UI: a type missing from the order simply
// never renders in the legend, and an extra one renders undefined styling.
- expect([...CLAIM_TYPE_ORDER].sort()).toEqual(Object.keys(CLAIM_TYPES).sort())
- })
+ expect([...CLAIM_TYPE_ORDER].sort()).toEqual(Object.keys(CLAIM_TYPES).sort());
+ });
- it('has no duplicates', () => {
- expect(new Set(CLAIM_TYPE_ORDER).size).toBe(CLAIM_TYPE_ORDER.length)
- })
-})
+ it("has no duplicates", () => {
+ expect(new Set(CLAIM_TYPE_ORDER).size).toBe(CLAIM_TYPE_ORDER.length);
+ });
+});
-describe('CLAIM_TYPES entries', () => {
- it('every type has a non-empty label and gloss', () => {
+describe("CLAIM_TYPES entries", () => {
+ it("every type has a non-empty label and gloss", () => {
// The gloss is what teaches the taxonomy on the empty state — an empty one
// ships a legend that explains nothing.
for (const [type, style] of Object.entries(CLAIM_TYPES)) {
- expect(style.label.trim(), `${type} label`).not.toBe('')
- expect(style.gloss.trim(), `${type} gloss`).not.toBe('')
+ expect(style.label.trim(), `${type} label`).not.toBe("");
+ expect(style.gloss.trim(), `${type} gloss`).not.toBe("");
}
- })
+ });
- it('labels match their key, so the UI never disagrees with the data', () => {
+ it("labels match their key, so the UI never disagrees with the data", () => {
for (const [type, style] of Object.entries(CLAIM_TYPES)) {
- expect(style.label).toBe(type)
+ expect(style.label).toBe(type);
}
- })
-})
+ });
+});
diff --git a/src/lib/llm.test.ts b/src/lib/llm.test.ts
index 34f38db..17c7a32 100644
--- a/src/lib/llm.test.ts
+++ b/src/lib/llm.test.ts
@@ -18,11 +18,11 @@
* So each test below drives a failure mode through a fake fetch and asserts
* that the SECOND link is reached.
*/
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import fs from 'node:fs'
-import path from 'node:path'
-import { freeChain } from 'ai-kit'
-import { callLLM, configuredLinks, noProviderMessage } from './llm'
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import fs from "node:fs";
+import path from "node:path";
+import { freeChain } from "ai-kit";
+import { callLLM, configuredLinks, noProviderMessage } from "./llm";
/** A chat-completions response carrying `content`. */
function ok(content: string) {
@@ -30,8 +30,8 @@ function ok(content: string) {
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content } }] }),
- text: async () => '',
- } as unknown as Response
+ text: async () => "",
+ } as unknown as Response;
}
/** A vendor refusal — the shape a retired model id actually returns. */
@@ -41,179 +41,179 @@ function fail(status: number, body = '{"error":{"code":"model_not_found"}}') {
status,
json: async () => ({}),
text: async () => body,
- } as unknown as Response
+ } as unknown as Response;
}
/** Every request the fake fetch received, in order. */
-type Sent = { url: string; model: string; auth: string }
+type Sent = { url: string; model: string; auth: string };
function spyFetch(responses: Array) {
- const sent: Sent[] = []
- let i = 0
+ const sent: Sent[] = [];
+ let i = 0;
const impl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
- const body = JSON.parse(String(init?.body ?? '{}')) as { model?: string }
- const headers = (init?.headers ?? {}) as Record
+ const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
+ const headers = (init?.headers ?? {}) as Record;
sent.push({
url: String(url),
- model: body.model ?? '',
- auth: headers.Authorization ?? '',
- })
- const next = responses[i++]
- if (next instanceof Error) throw next
- if (!next) throw new Error('fake fetch ran out of responses')
- return next
- })
- vi.stubGlobal('fetch', impl)
- return sent
+ model: body.model ?? "",
+ auth: headers.Authorization ?? "",
+ });
+ const next = responses[i++];
+ if (next instanceof Error) throw next;
+ if (!next) throw new Error("fake fetch ran out of responses");
+ return next;
+ });
+ vi.stubGlobal("fetch", impl);
+ return sent;
}
-const ORIGINAL_ENV = { ...process.env }
+const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
// Start from a known-empty provider configuration so a key leaking in from
// the developer's real environment cannot change what these tests mean.
- delete process.env.GROQ_API_KEY
- delete process.env.OPENROUTER_API_KEY
- delete process.env.TRUTHSEEKER_GROQ_MODELS
- delete process.env.TRUTHSEEKER_OPENROUTER_MODELS
-})
+ delete process.env.GROQ_API_KEY;
+ delete process.env.OPENROUTER_API_KEY;
+ delete process.env.TRUTHSEEKER_GROQ_MODELS;
+ delete process.env.TRUTHSEEKER_OPENROUTER_MODELS;
+});
afterEach(() => {
- vi.unstubAllGlobals()
- vi.restoreAllMocks()
- process.env = { ...ORIGINAL_ENV }
-})
-
-describe('callLLM', () => {
- it('refuses clearly when no vendor key is configured', async () => {
- spyFetch([])
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ process.env = { ...ORIGINAL_ENV };
+});
+
+describe("callLLM", () => {
+ it("refuses clearly when no vendor key is configured", async () => {
+ spyFetch([]);
// The old message named GROQ_API_KEY alone, which quietly became wrong the
// moment a second vendor could serve this app.
- await expect(callLLM('hi')).rejects.toThrow(/GROQ_API_KEY or OPENROUTER_API_KEY/)
- })
+ await expect(callLLM("hi")).rejects.toThrow(/GROQ_API_KEY or OPENROUTER_API_KEY/);
+ });
- it('asks a vendor whose key is absent nothing at all', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- const sent = spyFetch([ok('answer')])
+ it("asks a vendor whose key is absent nothing at all", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ const sent = spyFetch([ok("answer")]);
- await callLLM('hi')
+ await callLLM("hi");
// Not merely "it succeeded": with only a Groq key set, an OpenRouter link
// must not be attempted, because it would 401 on every single request.
- expect(sent).toHaveLength(1)
- expect(sent[0].url).toContain('api.groq.com')
- })
+ expect(sent).toHaveLength(1);
+ expect(sent[0].url).toContain("api.groq.com");
+ });
- it('steps past a retired model id to the next link', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- const sent = spyFetch([fail(404), ok('answer')])
+ it("steps past a retired model id to the next link", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ const sent = spyFetch([fail(404), ok("answer")]);
// This is the exact outage: link one answers 404 model_not_found.
- await expect(callLLM('hi')).resolves.toBe('answer')
+ await expect(callLLM("hi")).resolves.toBe("answer");
- expect(sent).toHaveLength(2)
+ expect(sent).toHaveLength(2);
// A fallback that retries the SAME id is not a fallback.
- expect(sent[0].model).not.toBe(sent[1].model)
- })
+ expect(sent[0].model).not.toBe(sent[1].model);
+ });
- it('steps past a rate limit rather than surfacing it', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- const sent = spyFetch([fail(429, 'rate limit exceeded'), ok('answer')])
+ it("steps past a rate limit rather than surfacing it", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ const sent = spyFetch([fail(429, "rate limit exceeded"), ok("answer")]);
- await expect(callLLM('hi')).resolves.toBe('answer')
- expect(sent).toHaveLength(2)
- })
+ await expect(callLLM("hi")).resolves.toBe("answer");
+ expect(sent).toHaveLength(2);
+ });
- it('steps past a timeout, because a hung vendor must not spend the chain', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- const sent = spyFetch([new Error('The operation was aborted due to timeout'), ok('answer')])
+ it("steps past a timeout, because a hung vendor must not spend the chain", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ const sent = spyFetch([new Error("The operation was aborted due to timeout"), ok("answer")]);
- await expect(callLLM('hi')).resolves.toBe('answer')
- expect(sent).toHaveLength(2)
- })
+ await expect(callLLM("hi")).resolves.toBe("answer");
+ expect(sent).toHaveLength(2);
+ });
- it('treats HTTP 200 with empty content as a miss, not as an answer', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- const sent = spyFetch([ok(' '), ok('answer')])
+ it("treats HTTP 200 with empty content as a miss, not as an answer", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ const sent = spyFetch([ok(" "), ok("answer")]);
// A free model was observed returning 200 with no content. A naive client
// reads that as a successful empty analysis and shows the user nothing,
// which is worse than an error because it looks deliberate.
- await expect(callLLM('hi')).resolves.toBe('answer')
- expect(sent).toHaveLength(2)
- })
+ await expect(callLLM("hi")).resolves.toBe("answer");
+ expect(sent).toHaveLength(2);
+ });
- it('crosses vendors, which is the half that survives a spent daily budget', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- process.env.OPENROUTER_API_KEY = 'sk-or-test'
+ it("crosses vendors, which is the half that survives a spent daily budget", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ process.env.OPENROUTER_API_KEY = "sk-or-test";
// Refuse every Groq link so the walk has to leave the vendor. Groq's daily
// budget is org-wide, so a smaller model at the same vendor is already dead
// on the day it runs out — only a different vendor has a different meter.
- const sent = spyFetch([fail(429), fail(429), fail(429), ok('answer')])
+ const sent = spyFetch([fail(429), fail(429), fail(429), ok("answer")]);
- await expect(callLLM('hi')).resolves.toBe('answer')
+ await expect(callLLM("hi")).resolves.toBe("answer");
- const vendors = new Set(sent.map((s) => new URL(s.url).host))
- expect(vendors.size).toBeGreaterThan(1)
- expect(sent.at(-1)!.url).toContain('openrouter.ai')
+ const vendors = new Set(sent.map((s) => new URL(s.url).host));
+ expect(vendors.size).toBeGreaterThan(1);
+ expect(sent.at(-1)!.url).toContain("openrouter.ai");
// Each vendor must be asked with its OWN key.
- expect(sent.at(-1)!.auth).toContain('sk-or-test')
- })
+ expect(sent.at(-1)!.auth).toContain("sk-or-test");
+ });
- it('names the whole chain when every link fails', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- spyFetch([fail(500), fail(500), fail(500), fail(500)])
+ it("names the whole chain when every link fails", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ spyFetch([fail(500), fail(500), fail(500), fail(500)]);
// "gpt-oss-120b failed" sends the reader after one model. "all N links
// failed" says the shape of the problem is the key, the network or the
// budget — a different investigation entirely.
- await expect(callLLM('hi')).rejects.toThrow(/chain exhausted/i)
- })
+ await expect(callLLM("hi")).rejects.toThrow(/chain exhausted/i);
+ });
- it('never sends a model id this repo hardcoded', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- const sent = spyFetch([ok('answer')])
+ it("never sends a model id this repo hardcoded", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ const sent = spyFetch([ok("answer")]);
- await callLLM('hi')
+ await callLLM("hi");
// The ids now come from ai-kit and are re-probed there. If one is ever
// pasted back into this repo, the fleet's daily audit stops covering it.
- expect(sent[0].model).not.toMatch(/^llama-3/)
- expect(sent[0].model.length).toBeGreaterThan(0)
- })
+ expect(sent[0].model).not.toMatch(/^llama-3/);
+ expect(sent[0].model.length).toBeGreaterThan(0);
+ });
- it('still passes the caller options through to the vendor', async () => {
- process.env.GROQ_API_KEY = 'gsk_test'
- const sent: Sent[] = []
+ it("still passes the caller options through to the vendor", async () => {
+ process.env.GROQ_API_KEY = "gsk_test";
+ const sent: Sent[] = [];
vi.stubGlobal(
- 'fetch',
+ "fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
- const body = JSON.parse(String(init?.body ?? '{}'))
- sent.push(body)
- return ok('answer')
+ const body = JSON.parse(String(init?.body ?? "{}"));
+ sent.push(body);
+ return ok("answer");
}),
- )
+ );
- await callLLM('the prompt', {
- systemPrompt: 'be terse',
+ await callLLM("the prompt", {
+ systemPrompt: "be terse",
maxTokens: 123,
temperature: 0.9,
jsonMode: true,
- })
+ });
const body = sent[0] as unknown as {
- max_tokens: number
- temperature: number
- response_format?: { type: string }
- messages: Array<{ role: string; content: string }>
- }
- expect(body.max_tokens).toBe(123)
- expect(body.temperature).toBe(0.9)
- expect(body.response_format).toEqual({ type: 'json_object' })
- expect(body.messages[0]).toEqual({ role: 'system', content: 'be terse' })
- expect(body.messages[1]).toEqual({ role: 'user', content: 'the prompt' })
- })
-})
+ max_tokens: number;
+ temperature: number;
+ response_format?: { type: string };
+ messages: Array<{ role: string; content: string }>;
+ };
+ expect(body.max_tokens).toBe(123);
+ expect(body.temperature).toBe(0.9);
+ expect(body.response_format).toEqual({ type: "json_object" });
+ expect(body.messages[0]).toEqual({ role: "system", content: "be terse" });
+ expect(body.messages[1]).toEqual({ role: "user", content: "the prompt" });
+ });
+});
/**
* A rejected key is the one failure that is NOT about the model.
@@ -228,66 +228,66 @@ describe('callLLM', () => {
* turn the chain back into the pin it replaced, so only 401/403 — "not you" —
* short-circuits, and only for the vendor that said it.
*/
-describe('callLLM when a key is rejected', () => {
- it('does not ask a second model at a vendor that refused the key', async () => {
- process.env.GROQ_API_KEY = 'gsk_revoked'
- const sent = spyFetch([fail(401, '{"error":{"code":"invalid_api_key"}}')])
+describe("callLLM when a key is rejected", () => {
+ it("does not ask a second model at a vendor that refused the key", async () => {
+ process.env.GROQ_API_KEY = "gsk_revoked";
+ const sent = spyFetch([fail(401, '{"error":{"code":"invalid_api_key"}}')]);
- await expect(callLLM('hi')).rejects.toThrow(/GROQ_API_KEY/)
+ await expect(callLLM("hi")).rejects.toThrow(/GROQ_API_KEY/);
// Groq carries more than one model in the chain; all of them share this key.
- expect(sent).toHaveLength(1)
- })
+ expect(sent).toHaveLength(1);
+ });
- it('still crosses to the next VENDOR, whose key is a different key', async () => {
- process.env.GROQ_API_KEY = 'gsk_revoked'
- process.env.OPENROUTER_API_KEY = 'sk-or-live'
- const sent = spyFetch([fail(401), ok('answer')])
+ it("still crosses to the next VENDOR, whose key is a different key", async () => {
+ process.env.GROQ_API_KEY = "gsk_revoked";
+ process.env.OPENROUTER_API_KEY = "sk-or-live";
+ const sent = spyFetch([fail(401), ok("answer")]);
- await expect(callLLM('hi')).resolves.toBe('answer')
+ await expect(callLLM("hi")).resolves.toBe("answer");
- expect(sent).toHaveLength(2)
- expect(sent[0].auth).toBe('Bearer gsk_revoked')
- expect(sent[1].auth).toBe('Bearer sk-or-live')
- })
+ expect(sent).toHaveLength(2);
+ expect(sent[0].auth).toBe("Bearer gsk_revoked");
+ expect(sent[1].auth).toBe("Bearer sk-or-live");
+ });
- it('names the rejected key and the remedy, not the chain length', async () => {
- process.env.GROQ_API_KEY = 'gsk_revoked'
- spyFetch([fail(403)])
+ it("names the rejected key and the remedy, not the chain length", async () => {
+ process.env.GROQ_API_KEY = "gsk_revoked";
+ spyFetch([fail(403)]);
- const message = await callLLM('hi').then(
- () => 'resolved, which it must not',
+ const message = await callLLM("hi").then(
+ () => "resolved, which it must not",
(e: Error) => e.message,
- )
+ );
// The unhelpful version of this sentence — "all N link(s) failed" — sends
// the reader after a vendor outage that is not happening.
- expect(message).toMatch(/rejected by the vendor/)
- expect(message).toContain('GROQ_API_KEY (groq, HTTP 403)')
- expect(message).not.toMatch(/chain exhausted/)
+ expect(message).toMatch(/rejected by the vendor/);
+ expect(message).toContain("GROQ_API_KEY (groq, HTTP 403)");
+ expect(message).not.toMatch(/chain exhausted/);
// …and points at the second meter, which is the actual fix for one dead key.
- expect(message).toContain('OPENROUTER_API_KEY')
- })
+ expect(message).toContain("OPENROUTER_API_KEY");
+ });
- it('keeps walking a vendor whose model merely 404s — the retirement case', async () => {
- process.env.GROQ_API_KEY = 'gsk_live'
- const sent = spyFetch([fail(404, '{"error":{"code":"model_not_found"}}'), ok('answer')])
+ it("keeps walking a vendor whose model merely 404s — the retirement case", async () => {
+ process.env.GROQ_API_KEY = "gsk_live";
+ const sent = spyFetch([fail(404, '{"error":{"code":"model_not_found"}}'), ok("answer")]);
- await expect(callLLM('hi')).resolves.toBe('answer')
+ await expect(callLLM("hi")).resolves.toBe("answer");
// Same vendor, second model. Over-correcting the 401 skip to cover every
// 4xx would have stopped at the first link and undone #16 entirely.
- expect(sent).toHaveLength(2)
- expect(sent[0].url).toBe(sent[1].url)
- })
+ expect(sent).toHaveLength(2);
+ expect(sent[0].url).toBe(sent[1].url);
+ });
- it('reports the ordinary exhaustion message when nothing was an auth failure', async () => {
- process.env.GROQ_API_KEY = 'gsk_live'
- spyFetch([fail(500), fail(500)])
+ it("reports the ordinary exhaustion message when nothing was an auth failure", async () => {
+ process.env.GROQ_API_KEY = "gsk_live";
+ spyFetch([fail(500), fail(500)]);
- await expect(callLLM('hi')).rejects.toThrow(/chain exhausted/)
- })
-})
+ await expect(callLLM("hi")).rejects.toThrow(/chain exhausted/);
+ });
+});
/**
* The preflight the CLI runs before spending ~20s fetching an article.
@@ -299,34 +299,34 @@ describe('callLLM when a key is rejected', () => {
* turned away at the CLI door — with an error naming a key it did not need.
* The CLI is how this project is used today, there being no deployment yet.
*/
-describe('configuredLinks', () => {
- it('finds nothing to walk when no vendor key is set', () => {
- expect(configuredLinks({})).toEqual([])
- })
-
- it('is satisfied by ANY single vendor, not one privileged one', () => {
- for (const provider of freeChain('TRUTHSEEKER')) {
- const links = configuredLinks({ [provider.keyEnv]: 'test-key' })
- expect(links.length).toBeGreaterThan(0)
+describe("configuredLinks", () => {
+ it("finds nothing to walk when no vendor key is set", () => {
+ expect(configuredLinks({})).toEqual([]);
+ });
+
+ it("is satisfied by ANY single vendor, not one privileged one", () => {
+ for (const provider of freeChain("TRUTHSEEKER")) {
+ const links = configuredLinks({ [provider.keyEnv]: "test-key" });
+ expect(links.length).toBeGreaterThan(0);
// …and only that vendor's links, never one that would 401 on every call.
- expect(links.every((l) => l.provider.keyEnv === provider.keyEnv)).toBe(true)
+ expect(links.every((l) => l.provider.keyEnv === provider.keyEnv)).toBe(true);
}
- })
-
- it('reads the environment it is handed, not the ambient one', () => {
- process.env.GROQ_API_KEY = 'gsk_ambient'
- expect(configuredLinks({})).toEqual([])
- })
-})
-
-describe('noProviderMessage', () => {
- it('names every key that would work, straight from the chain', () => {
- const message = noProviderMessage()
- for (const provider of freeChain('TRUTHSEEKER')) {
- expect(message).toContain(provider.keyEnv)
+ });
+
+ it("reads the environment it is handed, not the ambient one", () => {
+ process.env.GROQ_API_KEY = "gsk_ambient";
+ expect(configuredLinks({})).toEqual([]);
+ });
+});
+
+describe("noProviderMessage", () => {
+ it("names every key that would work, straight from the chain", () => {
+ const message = noProviderMessage();
+ for (const provider of freeChain("TRUTHSEEKER")) {
+ expect(message).toContain(provider.keyEnv);
}
- })
-})
+ });
+});
/**
* The seam this whole exercise is about: a caller re-deriving which env vars
@@ -335,18 +335,18 @@ describe('noProviderMessage', () => {
* gates its own SSOT, so the next vendor the fleet chain gains cannot leave the
* CLI behind again.
*/
-describe('the CLI preflight', () => {
+describe("the CLI preflight", () => {
const cli = fs.readFileSync(
- path.join(__dirname, '..', '..', 'scripts', 'analyze-cli.ts'),
- 'utf8',
- )
+ path.join(__dirname, "..", "..", "scripts", "analyze-cli.ts"),
+ "utf8",
+ );
- it('asks lib/llm which vendors count instead of testing a key by name', () => {
+ it("asks lib/llm which vendors count instead of testing a key by name", () => {
const code = cli
- .split('\n')
- .filter((line) => !line.trim().startsWith('//'))
- .join('\n')
- expect(code).not.toMatch(/process\.env\.[A-Z0-9_]*API_KEY/)
- expect(code).toContain('configuredLinks(')
- })
-})
+ .split("\n")
+ .filter((line) => !line.trim().startsWith("//"))
+ .join("\n");
+ expect(code).not.toMatch(/process\.env\.[A-Z0-9_]*API_KEY/);
+ expect(code).toContain("configuredLinks(");
+ });
+});
diff --git a/src/lib/llm.ts b/src/lib/llm.ts
index 5772df5..d88a381 100644
--- a/src/lib/llm.ts
+++ b/src/lib/llm.ts
@@ -79,13 +79,7 @@ export async function callLLM(prompt: string, opts: LLMOptions = {}): Promise = [];
if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
@@ -171,7 +165,9 @@ export async function callLLM(prompt: string, opts: LLMOptions = {}): Promise l.provider.keyEnv));
if (rejected.size > 0 && rejected.size === configuredVendors.size) {
- throw new Error(`${rejectedKeyMessage(rejected)} ${secondVendorHint(configuredVendors)}`.trim());
+ throw new Error(
+ `${rejectedKeyMessage(rejected)} ${secondVendorHint(configuredVendors)}`.trim(),
+ );
}
// Otherwise: name the whole chain, not just the last link. "gpt-oss-120b
diff --git a/vitest.config.ts b/vitest.config.ts
index 241071d..4e80af7 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,12 +1,12 @@
-import { defineConfig } from 'vitest/config'
-import path from 'node:path'
+import { defineConfig } from "vitest/config";
+import path from "node:path";
export default defineConfig({
test: {
- environment: 'node',
- include: ['src/**/*.test.ts'],
+ environment: "node",
+ include: ["src/**/*.test.ts"],
},
resolve: {
- alias: { '@': path.resolve(__dirname, 'src') },
+ alias: { "@": path.resolve(__dirname, "src") },
},
-})
+});