Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
# keys never leave the machine. Leave everything commented to get the
# default behaviour (claude-cli backend, Max subscription billing).

# -----------------------------------------------------------------------------
# Optional outbound proxy
# -----------------------------------------------------------------------------
# On macOS, npm run daily/dry-run automatically imports the active HTTP(S)
# proxy from System Settings. On other platforms, or to override it explicitly:
# HTTPS_PROXY=http://127.0.0.1:7890
# HTTP_PROXY=http://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1

# -----------------------------------------------------------------------------
# LLM backend selector (default: claude-cli)
# -----------------------------------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions lib/ai/backends/claude-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateClaudeCliAvailable } from "./claude-cli";

test("validateClaudeCliAvailable accepts an executable CLI", () => {
const previous = process.env.CLAUDE_CLI_PATH;
process.env.CLAUDE_CLI_PATH = process.execPath;
try {
assert.doesNotThrow(() => validateClaudeCliAvailable());
} finally {
if (previous === undefined) delete process.env.CLAUDE_CLI_PATH;
else process.env.CLAUDE_CLI_PATH = previous;
}
});

test("validateClaudeCliAvailable fails fast with configuration guidance", () => {
const previous = process.env.CLAUDE_CLI_PATH;
process.env.CLAUDE_CLI_PATH = "/definitely/missing/claude";
try {
assert.throws(
() => validateClaudeCliAvailable(),
/LLM_BACKEND=claude-cli.*\.env\.local.*LLM_BACKEND=deepseek/,
);
} finally {
if (previous === undefined) delete process.env.CLAUDE_CLI_PATH;
else process.env.CLAUDE_CLI_PATH = previous;
}
});
23 changes: 21 additions & 2 deletions lib/ai/backends/claude-cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import path from "node:path";
import { classifyError, logLlmCall } from "../log";
import type { LlmRunOptions, LlmRunResult } from "../llm";
Expand All @@ -13,6 +13,22 @@ function resolveCliPath(): string {
return "claude";
}

export function validateClaudeCliAvailable(): void {
const cli = resolveCliPath();
const probe = spawnSync(cli, ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
stdio: "ignore",
});
if (probe.error || probe.status !== 0) {
throw new Error(
"LLM_BACKEND=claude-cli but the 'claude' CLI is unavailable. " +
"Install/login to Claude Code, or create .env.local with an API backend " +
"such as LLM_BACKEND=deepseek and its matching API key.",
);
}
}

/**
* Invoke the local `claude` CLI in print mode against the Max subscription.
* Writes the user prompt over stdin to bypass shell argument length limits.
Expand All @@ -38,7 +54,10 @@ export function runClaudeCli({

return new Promise((resolve, reject) => {
const child = spawn(cli, args, {
shell: true,
// Unix can execute the binary directly, preserving every prompt
// argument literally. Windows npm installs a .cmd shim, which still
// requires the command shell.
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
});

Expand Down
93 changes: 93 additions & 0 deletions lib/ai/backends/openai-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { LlmIncompleteResponseError } from "../errors";
import { PRESETS, runOpenAICompat } from "./openai-compat";

test("DeepSeek requests JSON in non-thinking mode and rejects truncation", async () => {
const requestBodies: Array<Record<string, unknown>> = [];
let requestCount = 0;
const server = http.createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
requestBodies.push(JSON.parse(body) as Record<string, unknown>);
requestCount += 1;
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
id: `test-${requestCount}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "deepseek-v4-flash",
choices: [
{
index: 0,
message: {
role: "assistant",
content:
requestCount === 1 ? '{"ok":true}' : '{"ok":',
},
finish_reason: requestCount === 1 ? "stop" : "length",
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 10,
total_tokens: 20,
},
}),
);
});
});

await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address && typeof address !== "string");

const previousApiKey = process.env.DEEPSEEK_API_KEY;
const previousCwd = process.cwd();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "daily-brief-test-"));
process.env.DEEPSEEK_API_KEY = "test-key-openai-compat";
process.chdir(tempDir);

const cfg = {
...PRESETS.deepseek,
defaultBaseUrl: `http://127.0.0.1:${address.port}/v1`,
};
const options = {
systemPrompt: "Return JSON.",
userPrompt: "Return an object.",
};

try {
const result = await runOpenAICompat(options, cfg);
assert.equal(result.text, '{"ok":true}');

await assert.rejects(
runOpenAICompat(options, cfg),
LlmIncompleteResponseError,
);

assert.equal(requestBodies.length, 2);
for (const body of requestBodies) {
assert.deepEqual(body.response_format, { type: "json_object" });
assert.deepEqual(body.thinking, { type: "disabled" });
assert.equal(body.max_tokens, 8192);
}
} finally {
process.chdir(previousCwd);
if (previousApiKey === undefined) delete process.env.DEEPSEEK_API_KEY;
else process.env.DEEPSEEK_API_KEY = previousApiKey;
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
52 changes: 48 additions & 4 deletions lib/ai/backends/openai-compat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import OpenAI from "openai";
import { LlmIncompleteResponseError } from "../errors";
import { classifyError, logLlmCall } from "../log";
import type { LlmRunOptions, LlmRunResult } from "../llm";

Expand Down Expand Up @@ -83,6 +84,18 @@ export async function runOpenAICompat(
const timeoutMs = opts.timeoutMs ?? 180_000;

try {
// DeepSeek V4 defaults to thinking mode. Digest/enrichment requests are
// structured extraction tasks, so thinking spends output budget without
// improving the JSON. DeepSeek's native JSON mode also prevents the
// malformed/truncated objects that previously escaped this backend as a
// successful response and later crashed JSON.parse in pipeline.ts.
const structuredOutputOptions =
cfg.backend === "deepseek"
? {
response_format: { type: "json_object" as const },
thinking: { type: "disabled" as const },
}
: {};
const resp = await client.chat.completions.create(
{
model,
Expand All @@ -97,14 +110,42 @@ export async function runOpenAICompat(
// entries parseable. 8192 covers all observed daily batches with
// generous headroom. Match the explicit value Anthropic SDK uses.
max_tokens: 8192,
// Don't force JSON mode — not all OpenAI-compat providers support
// response_format=json_object, and our prompts + jsonrepair already
// handle the slop.
// JSON/thinking controls are enabled only for the DeepSeek preset;
// generic OpenAI-compatible providers keep their existing behavior.
...structuredOutputOptions,
},
{ timeout: timeoutMs },
);
const text = (resp.choices[0]?.message?.content ?? "").trim();
const choice = resp.choices[0];
const text = (choice?.message?.content ?? "").trim();
const durationMs = Date.now() - started;
const finishReason = choice?.finish_reason ?? null;
const incompleteReason = !choice
? "response contained no choices"
: !text
? "response content was empty"
: finishReason && finishReason !== "stop"
? `finish_reason=${finishReason}`
: null;

if (incompleteReason) {
const error = new LlmIncompleteResponseError(
`${cfg.backend} returned an incomplete response: ${incompleteReason}`,
);
logLlmCall({
ts: new Date(started).toISOString(),
backend: cfg.backend,
model,
durationMs,
success: false,
inputChars,
outputChars: text.length,
errorCategory: "other",
errorSnippet: error.message,
});
throw error;
}

logLlmCall({
ts: new Date(started).toISOString(),
backend: cfg.backend,
Expand All @@ -118,6 +159,9 @@ export async function runOpenAICompat(
});
return { text, durationMs };
} catch (err) {
// Incomplete responses were already logged above with their real partial
// output length. Avoid writing a second, misleading zero-length record.
if (err instanceof LlmIncompleteResponseError) throw err;
const durationMs = Date.now() - started;
const msg = err instanceof Error ? err.message : String(err);
logLlmCall({
Expand Down
11 changes: 11 additions & 0 deletions lib/ai/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* The provider returned a response, but it cannot be consumed as a complete
* model output (for example, finish_reason=length or an empty content field).
* Callers may retry or use a local fallback without hiding API/auth failures.
*/
export class LlmIncompleteResponseError extends Error {
constructor(message: string) {
super(message);
this.name = "LlmIncompleteResponseError";
}
}
11 changes: 9 additions & 2 deletions lib/ai/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
* See .env.example for the full list.
*/

import { CLAUDE_MODEL, runClaudeCli } from "./backends/claude-cli";
import {
CLAUDE_MODEL,
runClaudeCli,
validateClaudeCliAvailable,
} from "./backends/claude-cli";
import {
PRESETS as ANTHROPIC_PRESETS,
anthropicCompatModel,
Expand Down Expand Up @@ -116,7 +120,10 @@ export async function runLlm(opts: LlmRunOptions): Promise<LlmRunResult> {
*/
export function validateBackendCredentials(): void {
const backend = getBackend();
if (backend === "claude-cli") return;
if (backend === "claude-cli") {
validateClaudeCliAvailable();
return;
}

const required: Record<Exclude<LlmBackendId, "claude-cli">, string> = {
anthropic: "ANTHROPIC_API_KEY",
Expand Down
70 changes: 70 additions & 0 deletions lib/ai/pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildFallbackDailyReport,
generateDailyReport,
type ArticleInput,
} from "./pipeline";

function article(
category: ArticleInput["category"],
index: number,
summary?: string,
): ArticleInput {
return {
sourceId: `${category}-source-${index % 2}`,
source: `Source ${index % 2}`,
title: `${category} title ${index}`,
url: `https://example.com/${category}/${index}`,
excerpt: `${category} excerpt ${index}`,
summary,
category,
publishedAt: new Date(Date.now() - index * 1_000),
};
}

test("buildFallbackDailyReport preserves enriched summaries and category caps", () => {
const articles = [
...Array.from({ length: 7 }, (_, i) =>
article("tech", i, i === 0 ? "enriched tech summary" : undefined),
),
...Array.from({ length: 6 }, (_, i) => article("finance", i)),
...Array.from({ length: 4 }, (_, i) => article("politics", i)),
];

const report = buildFallbackDailyReport(articles);

assert.equal(report.tech_briefs.length, 5);
assert.equal(report.finance_briefs.length, 5);
assert.equal(report.politics_briefs.length, 3);
assert.equal(report.tech_briefs[0]?.summary, "enriched tech summary");
assert.equal(report.finance_briefs[0]?.summary, "finance excerpt 0");
});

test("generateDailyReport falls back after two truncated JSON responses", async () => {
const articles = [article("tech", 0, "summary")];
let attempts = 0;

const { report } = await generateDailyReport(articles, async () => {
attempts += 1;
throw new SyntaxError("Unexpected end of JSON input");
});

assert.equal(attempts, 2);
assert.equal(report.tech_briefs.length, 1);
assert.equal(report.tech_briefs[0]?.summary, "summary");
});

test("generateDailyReport does not hide provider/auth failures", async () => {
const articles = [article("tech", 0)];
let attempts = 0;

await assert.rejects(
generateDailyReport(articles, async () => {
attempts += 1;
throw new Error("401 invalid API key");
}),
/401 invalid API key/,
);
assert.equal(attempts, 2);
});
Loading