fix(patterns): make reflections/patterns slug sub-paths configurable (#3389)

* fix(patterns): make reflections/patterns slug sub-paths configurable

gatherReflections()'s SQL WHERE clause and the pattern-page write slug
were hardcoded to wiki/personal/reflections/ and wiki/personal/patterns/
respectively. A prior fix (#2415/#2939) made the leading namespace root
configurable via dream.synthesize.output_root, but the personal/reflections
and personal/patterns sub-path segments stayed pinned literals, so brains
whose schema has no personal/ nesting (e.g. a flat meetings/ tree) could
not point the phase at their own compiled_truth source.

Adds two new config keys:
- dream.patterns.source_slug_prefix (default: <output_root>/personal/reflections)
- dream.patterns.output_slug_prefix (default: <output_root>/personal/patterns)

Both default to the exact literal the code previously hardcoded, so
existing installs see no behavior change. A custom output_slug_prefix is
also added to the subagent's put_page allow-list, since the filing-rules
JSON globs only remap the wiki/personal/patterns/* literal by output_root
and would otherwise reject writes to a differently-shaped output path.

Updated test/cycle-patterns.test.ts's scope-filter assertions to match;
added coverage for the two new config keys and the allow-list addition.

* fix(patterns): drain PGLite subagent job inline (no worker claims it)

runPhasePatterns submitted a subagent job via queue.add() and waited on
it via waitForCompletion, but on PGLite there is no separate Minions
worker process (the embedded data-dir holds an exclusive file lock;
'gbrain jobs work' refuses to start against it). synthesize.ts already
has runPgliteSubagentsInline to drive the claim -> run -> complete loop
inline for exactly this reason; patterns.ts never called it, so a real
(non-dry-run) invocation against a PGLite brain always hung until
subagentWaitTimeoutMs (default 35 min) with the job stuck in 'waiting'.

Exports runPgliteSubagentsInline from synthesize.ts (was test-only via
__testing) and calls it from patterns.ts with the same private
per-run childQueueName derivation synthesize.ts uses, so the inline
drain never claims unrelated 'default'-queue jobs a Postgres worker
owns.

Updated test/cycle-patterns-child-outcome.test.ts's #2782 regression
test: its premise (no worker running with a 1ms wait timeout, so the
job never completes and waitForCompletion genuinely times out) is
exactly the scenario this fix addresses. With the inline drain, a fake
ANTHROPIC_API_KEY test fixture now gets claimed and actually attempted,
failing fast and landing the job in 'dead' rather than staying
uncompleted until a timeout. The #2782 status-reflects-outcome contract
the test exists to pin is unchanged (any non-'complete' outcome with
zero writes still surfaces as status 'fail'); updated the expected
outcome/error code to match the outcome that now actually occurs.

* feat(think): surface usage/cost_usd in --json output

think's own cost was previously unsurfaced anywhere: not in this CLI's
own --json output, not in budget_ledger (nothing in src/core/think/*.ts
ever writes to it), and invisible to a wrapping caller's own token
accounting since the LLM call think makes is its own, separate API
call from anything the caller's session tracks.

runThink() already captured result.usage.{input_tokens,output_tokens}
from the underlying client.create() call but discarded it. Adds
usage/cost_usd to ThinkResult, populates usage on the real-LLM-call
path (undefined on the no-client/stub paths, matching how synthesisOk
already distinguishes those), and computes cost_usd in think.ts's CLI
handler via the existing canonicalLookup() pricing table (same pattern
brain-score-recommendations.ts's estimateAnthropicCost already uses).
Extracted the multiply-and-sum into a small exported computeThinkCostUsd
for direct unit testing. Also appends the cost to the human-readable
footer.

Verified live: gbrain think --json against a real anchor returned
usage:{input_tokens:3271,output_tokens:1490}, cost_usd:0.0536, matching
Opus pricing ($5/$25 per MTok) by hand calculation.
This commit is contained in:
Ingmar Krusch
2026-07-27 14:11:36 -07:00
committed by GitHub
parent f0a28eb276
commit dde1bd9353
8 changed files with 223 additions and 31 deletions
+30 -1
View File
@@ -9,6 +9,7 @@ import type { BrainEngine } from '../core/engine.ts';
import { runThink, persistSynthesis, stripGapsSection } from '../core/think/index.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
import { canonicalLookup } from '../core/model-pricing.ts';
function flagValue(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
@@ -20,6 +21,27 @@ function flagPresent(args: string[], name: string): boolean {
return args.includes(name);
}
/**
* think's own cost was previously unsurfaced anywhere: not in this CLI's own
* `--json` output, not in `budget_ledger`, and invisible to a wrapping
* caller's own token accounting (the LLM call `think` makes is its own,
* separate API call). Returns undefined when `usage` is absent (no-client/
* stub paths, or a remote-MCP call that didn't forward it) or when the
* resolved model has no entry in the canonical pricing table.
*/
export function computeThinkCostUsd(
usage: { input_tokens: number; output_tokens: number } | undefined,
modelUsed: string,
): number | undefined {
if (!usage) return undefined;
const pricing = canonicalLookup(modelUsed);
if (!pricing) return undefined;
return Number(
((usage.input_tokens / 1_000_000) * pricing.input
+ (usage.output_tokens / 1_000_000) * pricing.output).toFixed(4),
);
}
export async function runThinkCli(engine: BrainEngine, args: string[]): Promise<void> {
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
console.log(`Usage: gbrain think "<question>" [options]
@@ -146,9 +168,15 @@ prints what would have been the input (exit 0).
}
}
const costUsd = computeThinkCostUsd(
(result as { usage?: { input_tokens: number; output_tokens: number } }).usage,
result.modelUsed,
);
if (json) {
console.log(JSON.stringify({
...result,
cost_usd: costUsd ?? null,
saved_slug: savedSlug ?? null,
evidence_inserted: evidenceInserted,
}, null, 2));
@@ -165,7 +193,8 @@ prints what would have been the input (exit 0).
console.log('');
}
console.log('---');
console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}`);
const costSuffix = costUsd !== undefined ? ` | Cost: $${costUsd.toFixed(4)}` : '';
console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}${costSuffix}`);
if (savedSlug) {
console.log(`Saved: ${savedSlug} (${evidenceInserted} evidence rows)`);
}
+80 -12
View File
@@ -20,6 +20,7 @@
import { join, dirname } from 'node:path';
import { mkdirSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import type { BrainEngine } from '../engine.ts';
import type { PhaseResult, PhaseError } from '../cycle.ts';
import { MinionQueue } from '../minions/queue.ts';
@@ -29,7 +30,14 @@ import { serializeMarkdown } from '../markdown.ts';
import type { Page, PageType } from '../types.ts';
// #2415: allow-list + output-root resolution shared with the synthesize
// phase — both phases must agree on the configured namespace.
import { loadAllowedSlugPrefixes, loadOutputRoot } from './synthesize.ts';
// runPgliteSubagentsInline is shared too: PGLite has no separate Minions
// worker process (the embedded data-dir holds an exclusive file lock), so a
// job submitted via queue.add() sits in 'waiting' forever unless something
// drives the claim -> run -> complete loop inline. synthesize.ts already
// does this for its own children; patterns.ts previously submitted and
// waited without ever draining, so every real (non-dry-run) invocation on a
// PGLite brain hung until subagentWaitTimeoutMs (default 35 min).
import { loadAllowedSlugPrefixes, loadOutputRoot, runPgliteSubagentsInline } from './synthesize.ts';
import { probeChatModel } from '../ai/gateway.ts';
import { normalizeModelId } from '../model-id.ts';
@@ -115,7 +123,7 @@ export async function runPhasePatterns(
}
// Gather reflections within lookback window.
const reflections = await gatherReflections(engine, config.lookbackDays, config.outputRoot);
const reflections = await gatherReflections(engine, config.lookbackDays, config.sourceSlugPrefix);
if (reflections.length < config.minEvidence) {
return skipped(
'insufficient_evidence',
@@ -152,6 +160,16 @@ export async function runPhasePatterns(
return failed(makeError('InternalError', 'NO_ALLOWLIST',
'skills/_brain-filing-rules.json missing dream_synthesize_paths.globs'));
}
// A configured dream.patterns.output_slug_prefix diverging from the
// default `${outputRoot}/personal/patterns` composition (e.g. a flat
// schema with no personal/ nesting) is not covered by the filing-rules
// globs above, which only remap the `wiki/personal/patterns/*` literal
// by outputRoot. Add it explicitly so the subagent's put_page allow-list
// actually grants write access to wherever it's configured to write.
const outputGlob = `${config.outputSlugPrefix}/*`;
if (!allowedSlugPrefixes.includes(outputGlob)) {
allowedSlugPrefixes.push(outputGlob);
}
// #2781: budget the subagent from the REMAINING parent-job time, not
// the fixed config default. Checked after the cheap gates (disabled /
@@ -167,8 +185,15 @@ export async function runPhasePatterns(
}
const queue = new MinionQueue(engine);
// PGLite children drain inline (no separate worker can open the embedded
// data-dir), so give this job a private per-run queue: the inline drain
// must never claim unrelated 'default'-queue jobs a Postgres worker owns.
// Mirrors synthesize.ts's childQueueName derivation exactly.
const childQueueName = engine.kind === 'pglite'
? `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`
: 'default';
const data: SubagentHandlerData = {
prompt: buildPatternsPrompt(reflections, config.minEvidence, config.outputRoot),
prompt: buildPatternsPrompt(reflections, config.minEvidence, config.sourceSlugPrefix, config.outputSlugPrefix),
model: config.model,
max_turns: 30,
allowed_slug_prefixes: allowedSlugPrefixes,
@@ -176,11 +201,19 @@ export async function runPhasePatterns(
const submitOpts: Partial<MinionJobInput> = {
max_stalled: 3,
timeout_ms: budgets.timeoutMs,
queue: childQueueName,
};
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
// PGLite cannot run a separate Minions worker because the embedded DB
// holds an exclusive file lock. Drain this phase's private child queue
// inline so the parent observes the terminal state instead of polling
// waitForCompletion until subagentWaitTimeoutMs expires. No-op on
// Postgres (a real worker process claims the job there).
await runPgliteSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);
let outcome: string;
try {
const final = await waitForCompletion(queue, job.id, {
@@ -273,6 +306,21 @@ interface PatternsConfig {
model: string;
/** #2415: shared output namespace (dream.synthesize.output_root, default 'wiki'). */
outputRoot: string;
/**
* Slug prefix `gatherReflections` reads from (SQL `LIKE` scope). Defaults
* to `${outputRoot}/personal/reflections`, matching pre-existing behavior.
* Config `dream.patterns.source_slug_prefix` overrides it for brains whose
* schema has no `personal/reflections/` convention (e.g. a flat
* `meetings/` tree) so the phase can read from wherever compiled_truth
* excerpts actually live.
*/
sourceSlugPrefix: string;
/**
* Slug prefix new pattern pages are written under. Defaults to
* `${outputRoot}/personal/patterns`, matching pre-existing behavior.
* Config `dream.patterns.output_slug_prefix` overrides it.
*/
outputSlugPrefix: string;
/** #1594-family: subagent job timeout, config `dream.patterns.subagent_timeout_ms`. */
subagentTimeoutMs: number;
/** #1594-family: waitForCompletion timeout, config `dream.patterns.subagent_wait_timeout_ms`. */
@@ -289,6 +337,14 @@ async function getNumberConfig(engine: BrainEngine, key: string, fallback: numbe
return Number.isNaN(value) ? fallback : value;
}
/** Trims leading/trailing slashes from a config-supplied slug prefix; falls back to `fallback` when unset or empty after trimming. */
async function getSlugPrefixConfig(engine: BrainEngine, key: string, fallback: string): Promise<string> {
const raw = await engine.getConfig(key);
if (!raw) return fallback;
const trimmed = raw.trim().replace(/^\/+|\/+$/g, '');
return trimmed || fallback;
}
async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig> {
const enabledStr = await engine.getConfig('dream.patterns.enabled');
const enabled = enabledStr === null ? true : enabledStr === 'true';
@@ -302,12 +358,19 @@ async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig>
tier: 'reasoning',
fallback: 'sonnet',
});
const outputRoot = await loadOutputRoot(engine);
return {
enabled,
lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30,
minEvidence: minEvidenceStr ? Math.max(1, parseInt(minEvidenceStr, 10) || 3) : 3,
model,
outputRoot: await loadOutputRoot(engine),
outputRoot,
sourceSlugPrefix: await getSlugPrefixConfig(
engine, 'dream.patterns.source_slug_prefix', `${outputRoot}/personal/reflections`,
),
outputSlugPrefix: await getSlugPrefixConfig(
engine, 'dream.patterns.output_slug_prefix', `${outputRoot}/personal/patterns`,
),
subagentTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS,
),
@@ -328,11 +391,11 @@ interface ReflectionRef {
async function gatherReflections(
engine: BrainEngine,
lookbackDays: number,
outputRoot = 'wiki',
sourceSlugPrefix = 'wiki/personal/reflections',
): Promise<ReflectionRef[]> {
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString();
// #2415: reflections live under the configured output root (bound as a
// parameter; outputRoot is slug-grammar-validated by loadOutputRoot).
// Reflections live under the configured source slug prefix (bound as a
// parameter; see PatternsConfig.sourceSlugPrefix / dream.patterns.source_slug_prefix).
const rows = await engine.executeRaw<{ slug: string; title: string | null; compiled_truth: string | null }>(
`SELECT slug, title, compiled_truth
FROM pages
@@ -340,7 +403,7 @@ async function gatherReflections(
AND updated_at >= $1::timestamptz
ORDER BY updated_at DESC
LIMIT 100`,
[since, `${outputRoot}/personal/reflections/%`],
[since, `${sourceSlugPrefix}/%`],
);
return rows.map(r => ({
slug: r.slug,
@@ -351,7 +414,12 @@ async function gatherReflections(
// ── Prompt ────────────────────────────────────────────────────────────
function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number, outputRoot = 'wiki'): string {
function buildPatternsPrompt(
reflections: ReflectionRef[],
minEvidence: number,
sourceSlugPrefix = 'wiki/personal/reflections',
outputSlugPrefix = 'wiki/personal/patterns',
): string {
const today = new Date().toISOString().slice(0, 10);
const corpus = reflections
.map((r, i) => `### ${i + 1}. [[${r.slug}]] — ${r.title}\n${r.excerpt}`)
@@ -361,15 +429,15 @@ function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number,
OUTPUT POLICY
- Only name a pattern if it appears in at least ${minEvidence} DISTINCT reflections.
- Each pattern page MUST cite the reflections that constitute its evidence (use [[${outputRoot}/personal/reflections/...]] wikilinks).
- Each pattern page MUST cite the reflections that constitute its evidence (use [[${sourceSlugPrefix}/...]] wikilinks).
- Use \`search\` to check whether a similar pattern page already exists; if yes, update it (use the same slug). If no, create a new one.
- Pattern slug format: \`${outputRoot}/personal/patterns/<topic-slug>\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date).
- Pattern slug format: \`${outputSlugPrefix}/<topic-slug>\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date).
- A "pattern" is a recurring theme, anxiety, decision pattern, relationship dynamic, or self-knowledge motif. NOT a single insight. NOT a list of unrelated topics.
DO NOT WRITE
- A "patterns from today" digest (that's the dream-cycle-summaries page; not your job).
- Patterns with <${minEvidence} reflections cited.
- Anything outside ${outputRoot}/personal/patterns/.
- Anything outside ${outputSlugPrefix}/.
CONTEXT
- Today: ${today}
+1 -1
View File
@@ -276,7 +276,7 @@ const INLINE_PGLITE_LOCK_MS = 30_000;
* `yieldDuringPhase` is ticked on a 60s interval while a child runs so the
* 5-min cycle lock TTL keeps refreshing during long (up to 30-min) children.
*/
async function runPgliteSubagentsInline(
export async function runPgliteSubagentsInline(
engine: BrainEngine,
queue: MinionQueue,
queueName: string,
+14
View File
@@ -149,6 +149,17 @@ export interface ThinkResult {
takesFromVector: number;
graphHits: number;
};
/**
* Token usage from the real LLM call, when one happened. Undefined on the
* no-client/stub paths (no Anthropic key, model not usable) — same
* distinction `synthesisOk` already makes. `think`'s cost was previously
* unsurfaced anywhere: not in this CLI's own output, not in
* `budget_ledger`, and invisible to a wrapping caller's own token
* accounting (the LLM call `think` makes is its own separate API call).
*/
usage?: { input_tokens: number; output_tokens: number };
/** USD cost computed from `usage` + `canonicalLookup(modelUsed)`, when both are available. */
cost_usd?: number;
}
const DEFAULT_MAX_OUTPUT_TOKENS = 4000;
@@ -441,6 +452,7 @@ export async function runThink(
// return ANDs it with a non-empty-answer check (catches valid-but-empty JSON).
let synthesisOk = true;
let response: ThinkResponse;
let usage: { input_tokens: number; output_tokens: number } | undefined;
if (opts.stubResponse) {
response = opts.stubResponse;
} else {
@@ -504,6 +516,7 @@ export async function runThink(
system: systemPrompt,
messages: [{ role: 'user', content: userMessage }],
});
usage = { input_tokens: result.usage.input_tokens, output_tokens: result.usage.output_tokens };
const block = result.content.find(b => b.type === 'text');
const text = block && 'text' in block ? block.text : '';
const parsed = tryParseJSON(text);
@@ -554,6 +567,7 @@ export async function runThink(
takesFromVector: gather.diagnostics.takesFromVector,
graphHits: gather.diagnostics.graphHits,
},
usage,
};
}
+15 -12
View File
@@ -5,10 +5,17 @@
* zero pattern pages written (e.g. when no subagent-capable worker slot was
* free for the whole wait window) — a silent no-op for days.
*
* Drives the real phase against PGLite with the (#1594-family) configurable
* wait timeout set to 1ms and NO worker running, so the child job never
* completes: waitForCompletion throws TimeoutError → outcome 'timeout' →
* nothing written → the phase must report status 'fail', not 'ok'.
* A later fix added runPgliteSubagentsInline to this phase (patterns.ts
* previously submitted a job and waited without anything ever claiming it on
* PGLite — synthesize.ts already had this inline drain, patterns.ts didn't).
* So a fake ANTHROPIC_API_KEY here now gets claimed and actually attempted;
* the real Anthropic call fails immediately, exhausting max_attempts and
* landing the job in 'dead' (not 'timeout' — nothing ever times out, the
* failure is immediate). The #2782 status-reflects-outcome contract this
* test exists to pin is unchanged: any non-'complete' outcome with zero
* writes must still surface as status 'fail', just under the outcome that
* actually occurs now that the job is drained instead of left stuck in
* 'waiting' for the full wait window.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
@@ -57,24 +64,20 @@ async function seedReflections(): Promise<void> {
}
describe('runPhasePatterns child-outcome status (#2782)', () => {
test('child timeout with zero writes → status fail (was silent ok)', async () => {
test('child dead with zero writes → status fail (was silent ok)', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-outcome-'));
try {
await seedReflections();
// #1594-family knob: make the wait window elapse immediately. No
// minion worker runs in this test, so the child job stays queued.
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
const result = await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
runPhasePatterns(engine, { brainDir, dryRun: false }),
);
expect(result.status).toBe('fail');
expect(result.details.child_outcome).toBe('timeout');
expect(result.details.child_outcome).toBe('dead');
expect(result.details.patterns_written).toBe(0);
expect(result.error?.code).toBe('PATTERNS_CHILD_TIMEOUT');
expect(result.error?.class).toBe('Timeout');
expect(result.error?.code).toBe('PATTERNS_CHILD_DEAD');
expect(result.error?.class).toBe('InternalError');
} finally {
rmSync(brainDir, { recursive: true, force: true });
}
+29 -5
View File
@@ -74,12 +74,18 @@ describe('patterns phase wiring', () => {
});
describe('patterns scope filter', () => {
test('filters reflections by slug LIKE <output_root>/personal/reflections/%', () => {
// #2415: the namespace root is configurable (dream.synthesize.output_root,
// default 'wiki') and bound as a parameter — the scope filter itself and
// the reflections sub-path stay pinned.
test('filters reflections by slug LIKE <source_slug_prefix>/%', () => {
// #2415 made the top-level namespace root configurable
// (dream.synthesize.output_root, default 'wiki'). A later patch made the
// full `personal/reflections` sub-path configurable too
// (dream.patterns.source_slug_prefix, defaults to
// `<output_root>/personal/reflections` so existing behavior is
// unchanged) — schemas with no `personal/` nesting (e.g. a flat
// `meetings/` tree) can point the phase at their own compiled_truth
// source instead.
expect(patternsSrc).toContain('slug LIKE $2');
expect(patternsSrc).toContain('/personal/reflections/%');
expect(patternsSrc).toContain('${sourceSlugPrefix}/%');
expect(patternsSrc).toContain('dream.patterns.source_slug_prefix');
});
test('orders by updated_at DESC for recency-bias', () => {
@@ -89,4 +95,22 @@ describe('patterns scope filter', () => {
test('caps gather to 100 reflections (cost control)', () => {
expect(patternsSrc).toContain('LIMIT 100');
});
test('output slug prefix is config-driven, defaulting to <output_root>/personal/patterns', () => {
expect(patternsSrc).toContain('dream.patterns.output_slug_prefix');
expect(patternsSrc).toContain('${outputRoot}/personal/patterns');
});
test('source slug prefix defaults to <output_root>/personal/reflections', () => {
expect(patternsSrc).toContain('${outputRoot}/personal/reflections');
});
test('adds a configured output_slug_prefix to the subagent write allow-list', () => {
// A custom dream.patterns.output_slug_prefix (e.g. a flat schema with no
// personal/ nesting) is not covered by the filing-rules globs, which only
// remap the `wiki/personal/patterns/*` literal by output_root. The phase
// must add it explicitly so put_page actually grants write access there.
expect(patternsSrc).toContain('outputGlob');
expect(patternsSrc).toContain('allowedSlugPrefixes.push(outputGlob)');
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test';
import { computeThinkCostUsd } from '../src/commands/think.ts';
// think's own cost was previously unsurfaced anywhere: not in this CLI's own
// --json output, not in budget_ledger, and invisible to a wrapping caller's
// own token accounting (the LLM call think makes is its own, separate API
// call). computeThinkCostUsd is the small, pure function that turns
// {input_tokens, output_tokens} + a resolved modelUsed into a USD figure via
// the existing canonical pricing table.
describe('computeThinkCostUsd', () => {
test('computes cost from real usage against a known model', () => {
const cost = computeThinkCostUsd(
{ input_tokens: 1_000_000, output_tokens: 1_000_000 },
'anthropic:claude-sonnet-5',
);
// sonnet-5 pricing: input $3.00/MTok, output $15.00/MTok.
expect(cost).toBe(18);
});
test('undefined usage (no-client/stub path) → undefined, never a fabricated cost', () => {
expect(computeThinkCostUsd(undefined, 'anthropic:claude-sonnet-5')).toBeUndefined();
});
test('unknown model id → undefined, not zero (no pricing to compute from)', () => {
expect(computeThinkCostUsd(
{ input_tokens: 100, output_tokens: 100 },
'some-unlisted-provider:some-model',
)).toBeUndefined();
});
test('zero-token usage → zero cost, not undefined (a real call that happened to be free)', () => {
expect(computeThinkCostUsd(
{ input_tokens: 0, output_tokens: 0 },
'anthropic:claude-sonnet-5',
)).toBe(0);
});
});
+17
View File
@@ -177,6 +177,12 @@ describe('runThink (with stub client)', () => {
expect(result.gaps).toEqual(['no info on funding history']);
expect(result.takesGathered).toBeGreaterThan(0);
expect(result.warnings).not.toContain('LLM_OUTPUT_NOT_JSON');
// think's own cost was previously unsurfaced anywhere (not in this CLI's
// output, not in budget_ledger, and invisible to a wrapping caller's own
// token accounting since the LLM call is think's own, separate call).
// usage flows through from the real client.create() response so the CLI
// can compute cost_usd from it via canonicalLookup(modelUsed).
expect(result.usage).toEqual({ input_tokens: 10, output_tokens: 10 });
});
test('passes the question into page excerpt selection', async () => {
@@ -417,6 +423,17 @@ describe('runThink + persistSynthesis — #1698 never persist empty', () => {
expect(full.synthesisOk).toBe(true);
});
test('opts.stubResponse path never made a real LLM call — usage stays undefined', async () => {
// Same distinction synthesisOk already makes: opts.stubResponse bypasses
// client.create() entirely, so there is no real usage to report. cost_usd
// must not be computed (and should render as null in --json) when this
// happens, since there is nothing to compute it from.
const result = await runThink(engine, {
question: 'stub no usage', stubResponse: { answer: 'has content', citations: [], gaps: [] },
});
expect(result.usage).toBeUndefined();
});
test('pre-existing ThinkResult literal without synthesisOk still persists (back-compat)', async () => {
const legacy: any = {
question: 'legacy backcompat', answer: 'legacy body', citations: [], gaps: [],