Skip to content

Commit 041d7b9

Browse files
hamr0claude
andauthored
feat(clipipe): structured-output parsing + provider costUsd (A1) — v0.26.0 (#11)
* feat(clipipe): opt-in structured-output parsing + Loop honors provider costUsd (A1) CLIPipeProvider gains an opt-in `parse` option ('claude-json' preset or a (stdout)=>Partial<GenerateResult> fn) that maps structured CLI output onto the normalized GenerateResult/Usage shape; default (unset) stays raw-text/zero-usage (byte-identical). GenerateResult gains optional `costUsd`, and the Loop prefers a finite result.costUsd over estimateCost at both cost sites (resolveRoundCost) so a CLI's own price feeds bareguard's USD axis without a local rate table. A provider-supplied 0 is a valid priced value, distinct from null/unpriced. Requested by adaptlearn (F2/A1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jdj7UfmMkPsvqqDN7ChhK * release: v0.26.0 — CLIPipe structured output + provider costUsd (A1) Version bump (package.json, package-lock ×2, context.md header), CHANGELOG cut [Unreleased] -> [0.26.0], and bareagent.context.md provider-section update (corrects the now-false 'CLIPipe always returns zero usage' claim + adds the parse:'claude-json' surface and costUsd field). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jdj7UfmMkPsvqqDN7ChhK --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3b67334 commit 041d7b9

9 files changed

Lines changed: 283 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to bare-agent are documented here. Format: [Keep a Changelog
44

55
## [Unreleased]
66

7+
## [0.26.0] — 2026-07-08
8+
9+
### Added
10+
11+
- **`CLIPipeProvider` opt-in structured-output parsing — surfaces real usage + cost (adaptlearn F2/A1).** `generate()` returned stdout verbatim as `text` and hard-coded `usage: { inputTokens: 0, outputTokens: 0 }`, so a bareguard `Gate` with a token or USD cap saw **zero usage** from CLI-piped runs — the budget axis was blind (and, under an active USD cap, fails closed on unpriced cost). But `claude -p --output-format json` emits a single JSON envelope carrying everything a provider result needs. New opt-in `new CLIPipeProvider({ parse })`: `'claude-json'` is a shipped preset that `JSON.parse`s stdout and maps the envelope onto the normalized `GenerateResult`/`Usage` shape — `text ← result` (the assistant text, not the raw JSON), `usage.inputTokens ← usage.input_tokens`, `outputTokens ← usage.output_tokens`, `cacheReadTokens`/`cacheCreationTokens ← usage.cache_{read,creation}_input_tokens` (absent ⇒ omitted, per the `Usage` contract — never a synthetic 0), `model ← ` the first `modelUsage` key, and `costUsd ← total_cost_usd`. Malformed JSON, a non-object envelope, `is_error: true`, or a non-`success` subtype throw a **loud `ProviderError`** — never a silent fall-back to raw text (the caller explicitly asked for structured output). A `parse: (stdout) => Partial<GenerateResult>` **function** is the CLI-agnostic escape hatch (merged over defaults); `'claude-json'` is a preset over it. To make the CLI's own price actually enforce a budget **without a local rate table**, `GenerateResult` gains an optional `costUsd?: number` and the **Loop now prefers a finite `result.costUsd` over `estimateCost`** (both the main and summarize cost paths) and forwards it to `onLlmResult` as `pricing: 'priced'` — a provider-supplied `0` is a valid priced value (a subscription/marginal-$0 run), distinct from omitted/null which still falls back to the rate table. Out of scope for A1 (unchanged): tool calls (`toolCalls` stays `[]`), streaming, and any claude-specific default args. **POC-first** — the real `claude -p "say OK" --output-format json` envelope was captured live (2026-07-08) before building, and the shipped provider was driven end-to-end against the real CLI (`text:"OK"`, `inputTokens` > 0, authoritative `costUsd` surfaced). Default (no `parse`) is **byte-identical to before** (raw stdout as text, zero usage — a regression guard test asserts a raw JSON envelope stays unparsed). `src/provider-clipipe.js`, `src/loop.js`, `types/index.d.ts`, `test/provider-clipipe.test.js` (+11), `test/loop.test.js` (+3, provider-cost preference incl. the `0`-is-priced and non-finite-falls-back cases).
12+
713
## [0.25.0] — 2026-07-03
814

915
### Added

bareagent.context.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# bareagent — Integration Guide
22

33
> For AI assistants and developers wiring bareagent into a project.
4-
> v0.25.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
4+
> v0.26.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
55
>
66
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
77
@@ -59,6 +59,7 @@ Eight entry points:
5959
| Catch typed errors programmatically | ProviderError, ToolError, TimeoutError, CircuitOpenError |
6060
| Cache identical planner calls | Planner({ cacheTTL: 60000 }) |
6161
| Stream CLIPipe output in real-time | CLIPipeProvider({ onChunk: fn }) |
62+
| Get real usage + cost from a CLI provider | CLIPipeProvider({ parse: 'claude-json' }) |
6263
| Browse the web (inline snapshots) | createBrowsingTools + Loop |
6364
| Browse the web (token-efficient, disk-based) | `barebrowse` CLI session — snapshots to `.barebrowse/*.yml` |
6465
| Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
@@ -739,9 +740,11 @@ new Ollama({ model: 'llama3.2', url: 'http://localhost:11434' })
739740
// CLIPipe — pipe prompts to any CLI tool via stdin/stdout
740741
new CLIPipe({ command: 'claude', args: ['--print'], systemPromptFlag: '--system-prompt', timeout: 30000 })
741742
new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
743+
// CLIPipe structured output (v0.26.0+) — map a CLI's JSON envelope to real usage + cost
744+
new CLIPipe({ command: 'claude', args: ['-p', '--output-format', 'json'], parse: 'claude-json' })
742745
```
743746
744-
All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. CLIPipe always returns `toolCalls: []` and zero usage (CLI tools don't report tokens), and omits `model`.
747+
All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
745748
746749
**Temperature graceful degradation (BA-10).** Newer models reject ANY non-default `temperature` with a `400` (`claude-sonnet-5`: `` `temperature` is deprecated for this model. ``; OpenAI o1/gpt-5-class: `Unsupported value: 'temperature' … Only the default (1) …`). All four providers detect that specific 400 (message names `temperature` as unsupported/deprecated AND a temperature was sent), **drop the param, warn once per instance, and retry once** — so a call that would otherwise throw succeeds at the model's default temperature. Keyed off the API error text, not a model list. A genuine out-of-range 400 is NOT degraded (it re-throws — dropping it would mask a caller bug). When a drop happens the result carries `temperatureDropped: true` (an optional `GenerateResult`/`Loop.run` field) so a caller can report the effective temperature — `recurse`'s `refineLeaf` uses it for an honest receipt. Dormant on models that accept temperature (byte-identical to before).
747750

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bare-agent",
3-
"version": "0.25.0",
3+
"version": "0.26.0",
44
"files": [
55
"index.js",
66
"index.d.ts",

src/loop.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,23 @@ function estimateCost(model, usage) {
153153
return Number.isFinite(cost) ? cost : null;
154154
}
155155

156+
/**
157+
* Resolve the priced USD for a round. A provider MAY report its own authoritative `costUsd` on the
158+
* GenerateResult (e.g. CLIPipeProvider `parse:'claude-json'` surfacing the claude CLI's own
159+
* `total_cost_usd` — a real price with NO local rate table). When present as a FINITE number it wins
160+
* over the rate-table estimate — including `0`, a valid priced value (a subscription/marginal-$0 run),
161+
* which stays 'priced', never demoted to the null/unpriced sentinel. A non-finite provider cost
162+
* (±Inf/NaN) is NOT a price → fall through to estimateCost (same couldn't-price guard as above).
163+
* @param {any} result - the GenerateResult from provider.generate()
164+
* @param {string|null} model
165+
* @param {Usage|null} usage
166+
* @returns {number|null}
167+
*/
168+
function resolveRoundCost(result, model, usage) {
169+
if (result && Number.isFinite(result.costUsd)) return result.costUsd;
170+
return estimateCost(model, usage);
171+
}
172+
156173
// R-C6: default instruction for the provider-bound `ctx.summarize` lent to the assemble seam.
157174
const DEFAULT_SUMMARY_INSTRUCTION =
158175
'You are a precise conversation summarizer. Produce a concise, factual summary of the following ' +
@@ -472,7 +489,7 @@ class Loop {
472489
const result = await loop.provider.generate(prompt, [], { temperature: 0, ...genOpts });
473490
const usage = (result && result.usage) || null;
474491
const model = (result && result.model) || loop.provider.model || null;
475-
const cost = estimateCost(model, usage);
492+
const cost = resolveRoundCost(result, model, usage);
476493
if (cost !== null) { totalCost += cost; pricedAny = true; }
477494
addUsage(usage); // summarize tokens are real spend → count them in the cumulative meter
478495
metrics.context.summaries++; // §3.6 CE-activity rollup
@@ -616,7 +633,7 @@ class Loop {
616633
// Prefer the model the response reports (robust when provider.model is absent or varies per
617634
// response — e.g. FallbackProvider, or a CircuitBreaker-wrapped provider that drops .model).
618635
const model = result.model || this.provider.model || null;
619-
const roundCost = estimateCost(model, lastUsage);
636+
const roundCost = resolveRoundCost(result, model, lastUsage);
620637
if (roundCost !== null) totalCost += roundCost;
621638

622639
// Meter this round: count the turn, accumulate the four token tiers, and classify pricing —

src/provider-clipipe.js

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const { ProviderError } = require('./errors');
1616
* @property {number} [timeout=30000] - Timeout in milliseconds.
1717
* @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
1818
* @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
19+
* @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
1920
*/
2021

2122
class CLIPipeProvider {
@@ -33,6 +34,10 @@ class CLIPipeProvider {
3334
this.timeout = options.timeout ?? 30000;
3435
this.systemPromptFlag = options.systemPromptFlag || null;
3536
this.onChunk = options.onChunk || null;
37+
if (options.parse != null && options.parse !== 'claude-json' && typeof options.parse !== 'function') {
38+
throw new Error("[CLIPipeProvider] options.parse must be 'claude-json' or a function");
39+
}
40+
this.parse = options.parse || null;
3641
}
3742

3843
/**
@@ -61,14 +66,77 @@ class CLIPipeProvider {
6166
}
6267

6368
const prompt = this._formatPrompt(promptMessages);
64-
const text = await this._spawn(prompt, extraArgs);
69+
const stdout = await this._spawn(prompt, extraArgs);
70+
71+
if (this.parse === 'claude-json') return this._parseClaudeJson(stdout);
72+
if (typeof this.parse === 'function') {
73+
const partial = this.parse(stdout) || {};
74+
return {
75+
text: '',
76+
toolCalls: [],
77+
...partial,
78+
usage: { inputTokens: 0, outputTokens: 0, ...(partial.usage || {}) },
79+
};
80+
}
6581
return {
66-
text,
82+
text: stdout,
6783
toolCalls: [],
6884
usage: { inputTokens: 0, outputTokens: 0 },
6985
};
7086
}
7187

88+
/**
89+
* Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
90+
* The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
91+
* ProviderError — never a silent fall-back to raw text.
92+
* @param {string} stdout - Trimmed stdout from the CLI.
93+
* @returns {GenerateResult}
94+
* @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype).
95+
*/
96+
_parseClaudeJson(stdout) {
97+
let obj;
98+
try {
99+
obj = JSON.parse(stdout);
100+
} catch (_) {
101+
const preview = stdout.length > 200 ? `${stdout.slice(0, 200)}…` : stdout;
102+
throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected JSON on stdout, got: ${preview}`, /** @type {any} */ ({ status: 0 }));
103+
}
104+
if (!obj || typeof obj !== 'object') {
105+
throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected a JSON object, got ${obj === null ? 'null' : typeof obj}`, /** @type {any} */ ({ status: 0 }));
106+
}
107+
if (obj.is_error === true || obj.subtype !== 'success') {
108+
const detail = typeof obj.result === 'string' ? obj.result : JSON.stringify(obj.result ?? null);
109+
throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${obj.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
110+
}
111+
112+
const u = (obj.usage && typeof obj.usage === 'object') ? obj.usage : {};
113+
/** @type {import('../types').Usage} */
114+
const usage = {
115+
inputTokens: Number(u.input_tokens) || 0,
116+
outputTokens: Number(u.output_tokens) || 0,
117+
};
118+
// Absent cache tiers mean the model didn't cache — omit rather than emit a synthetic 0 (per Usage docs).
119+
if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
120+
if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
121+
122+
// `modelUsage` is an object keyed by model id (e.g. {"claude-opus-4-8[1m]": {...}}) — take the first key.
123+
const model = (obj.modelUsage && typeof obj.modelUsage === 'object')
124+
? (Object.keys(obj.modelUsage)[0] ?? null)
125+
: null;
126+
127+
/** @type {GenerateResult} */
128+
const result = {
129+
text: typeof obj.result === 'string' ? obj.result : '',
130+
toolCalls: [],
131+
usage,
132+
model,
133+
};
134+
// The CLI's own price is authoritative (subscription runs report an equivalent cost even at $0
135+
// marginal) — feeds bareguard's USD axis with no local rate table. Only a finite number counts.
136+
if (Number.isFinite(obj.total_cost_usd)) result.costUsd = obj.total_cost_usd;
137+
return result;
138+
}
139+
72140
/**
73141
* Convert OpenAI-format messages to a plain text prompt.
74142
* @param {Message[]} messages

test/loop.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,3 +1076,49 @@ describe('Loop — BA-11 deny-spin guard', () => {
10761076
for (const id of toolCallIds) assert.ok(toolResultIds.has(id), `tool_call ${id} has a paired result`);
10771077
});
10781078
});
1079+
1080+
describe('A1 — provider-supplied costUsd (CLI authoritative price)', () => {
1081+
it('prefers result.costUsd over estimateCost and forwards it as priced', async () => {
1082+
const events = [];
1083+
// No model → estimateCost would return null (unpriced). The provider's own costUsd must win.
1084+
const provider = {
1085+
model: null,
1086+
async generate() {
1087+
return { text: 'done', toolCalls: [], usage: { inputTokens: 100, outputTokens: 5 }, costUsd: 0.0495 };
1088+
},
1089+
};
1090+
const loop = new Loop({ provider, onLlmResult: (e) => { events.push(e); } });
1091+
const result = await loop.run([{ role: 'user', content: 'hi' }]);
1092+
assert.equal(result.cost, 0.0495, 'authoritative CLI cost accumulates into totalCost');
1093+
assert.equal(result.metrics.costUsd, 0.0495);
1094+
assert.equal(result.metrics.unpricedRounds, 0, 'a provider-priced round is NOT unpriced');
1095+
assert.equal(events.length, 1);
1096+
assert.equal(events[0].costUsd, 0.0495);
1097+
assert.equal(events[0].pricing, 'priced');
1098+
});
1099+
1100+
it('treats a provider costUsd of 0 as priced (not the null/unpriced sentinel)', async () => {
1101+
const provider = {
1102+
model: null,
1103+
async generate() {
1104+
return { text: 'ok', toolCalls: [], usage: { inputTokens: 1, outputTokens: 1 }, costUsd: 0 };
1105+
},
1106+
};
1107+
const result = await new Loop({ provider }).run([{ role: 'user', content: 'hi' }]);
1108+
assert.equal(result.metrics.costUsd, 0, 'a real $0 round is priced 0, never null');
1109+
assert.equal(result.metrics.unpricedRounds, 0);
1110+
});
1111+
1112+
it('falls back to estimateCost when costUsd is absent or non-finite', async () => {
1113+
// Non-finite provider cost is NOT a price → estimateCost path → null (no model) → unpriced.
1114+
const provider = {
1115+
model: null,
1116+
async generate() {
1117+
return { text: 'ok', toolCalls: [], usage: { inputTokens: 1, outputTokens: 1 }, costUsd: NaN };
1118+
},
1119+
};
1120+
const result = await new Loop({ provider }).run([{ role: 'user', content: 'hi' }]);
1121+
assert.equal(result.metrics.costUsd, null, 'NaN cost falls through, not treated as priced');
1122+
assert.equal(result.metrics.unpricedRounds, 1);
1123+
});
1124+
});

0 commit comments

Comments
 (0)