@animalabs/connectome-host 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/recipe.ts CHANGED
@@ -75,6 +75,10 @@ export interface RecipeStrategy {
75
75
  export interface RecipeAgent {
76
76
  name?: string;
77
77
  model?: string;
78
+ /** IANA zone used when rendering wall-clock times to the agent. */
79
+ timezone?: string;
80
+ /** Provider transport. Omitted preserves the historical Anthropic default. */
81
+ provider?: 'anthropic' | 'openai-responses';
78
82
  systemPrompt: string;
79
83
  maxTokens?: number;
80
84
  /**
@@ -86,9 +90,8 @@ export interface RecipeAgent {
86
90
  /** Per-agent context compile budget (input tokens). When unset, the
87
91
  * ContextManager default (100k) applies. Raise for large-context models. */
88
92
  contextBudgetTokens?: number;
89
- /** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Set '1h' for
90
- * agents whose reply cadence is slower than 5 minutes — avoids re-writing
91
- * the full context to cache after every gap. Unset: provider default (5m). */
93
+ /** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
94
+ * '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads. */
92
95
  cacheTtl?: '5m' | '1h';
93
96
  strategy?: RecipeStrategy;
94
97
  /**
@@ -100,6 +103,13 @@ export interface RecipeAgent {
100
103
  enabled: boolean;
101
104
  budgetTokens?: number;
102
105
  };
106
+ /** Stateless OpenAI Responses settings. Only used with
107
+ * `provider: "openai-responses"`. */
108
+ responses?: {
109
+ reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
110
+ reasoningContext?: 'current_turn' | 'all_turns';
111
+ compactThreshold?: number;
112
+ };
103
113
  /**
104
114
  * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
105
115
  * turn triggers an automatic rewind of the triggering turn + retry (keeping
@@ -500,6 +510,7 @@ export const DEFAULT_RECIPE: Recipe = {
500
510
  description: 'General-purpose assistant with tool access',
501
511
  agent: {
502
512
  name: 'agent',
513
+ cacheTtl: '1h',
503
514
  systemPrompt: [
504
515
  'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
505
516
  'Use them to help the user with their tasks.',
@@ -678,6 +689,39 @@ export function validateRecipe(raw: unknown): Recipe {
678
689
  throw new Error('Recipe agent must have a "systemPrompt" string');
679
690
  }
680
691
 
692
+ if (agent.provider !== undefined && agent.provider !== 'anthropic' && agent.provider !== 'openai-responses') {
693
+ throw new Error(`Recipe agent.provider must be 'anthropic' or 'openai-responses', got ${JSON.stringify(agent.provider)}.`);
694
+ }
695
+
696
+ if (agent.timezone !== undefined) {
697
+ if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
698
+ throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
699
+ }
700
+ try {
701
+ new Intl.DateTimeFormat('en-US', { timeZone: agent.timezone }).format(new Date(0));
702
+ } catch {
703
+ throw new Error(`Recipe agent.timezone is not a valid IANA time zone: ${JSON.stringify(agent.timezone)}.`);
704
+ }
705
+ }
706
+
707
+ if (agent.responses !== undefined) {
708
+ if (!agent.responses || typeof agent.responses !== 'object' || Array.isArray(agent.responses)) {
709
+ throw new Error('Recipe agent.responses must be an object.');
710
+ }
711
+ const responses = agent.responses as Record<string, unknown>;
712
+ const efforts = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
713
+ if (responses.reasoningEffort !== undefined && !efforts.includes(String(responses.reasoningEffort))) {
714
+ throw new Error(`Invalid agent.responses.reasoningEffort ${JSON.stringify(responses.reasoningEffort)}.`);
715
+ }
716
+ if (responses.reasoningContext !== undefined && responses.reasoningContext !== 'current_turn' && responses.reasoningContext !== 'all_turns') {
717
+ throw new Error(`Invalid agent.responses.reasoningContext ${JSON.stringify(responses.reasoningContext)}.`);
718
+ }
719
+ if (responses.compactThreshold !== undefined &&
720
+ (typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
721
+ throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
722
+ }
723
+ }
724
+
681
725
  if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
682
726
  throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
683
727
  }
@@ -687,6 +731,7 @@ export function validateRecipe(raw: unknown): Recipe {
687
731
  if (agent.cacheTtl !== undefined && agent.cacheTtl !== '5m' && agent.cacheTtl !== '1h') {
688
732
  throw new Error(`Recipe agent.cacheTtl must be '5m' or '1h', got ${JSON.stringify(agent.cacheTtl)}.`);
689
733
  }
734
+ agent.cacheTtl ??= '1h';
690
735
 
691
736
  // Validate agent.thinking if present. Catches typos and constraint
692
737
  // violations (notably max_tokens > budget_tokens) at recipe-load time
@@ -9,6 +9,7 @@ import type {
9
9
  SummaryEntry,
10
10
  } from '@animalabs/context-manager';
11
11
  import type { ContentBlock } from '@animalabs/membrane';
12
+ import { formatZonedTime, resolveTimeZone } from '@animalabs/agent-framework';
12
13
 
13
14
  // Structural mirror of AutobiographicalStrategy's internal Chunk.
14
15
  // Kept inline because @animalabs/context-manager does not currently export it.
@@ -24,7 +25,7 @@ interface Chunk {
24
25
  phaseType?: string;
25
26
  }
26
27
 
27
- export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig>;
28
+ export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig> & { timeZone?: string };
28
29
 
29
30
  /**
30
31
  * Chatbot-flavoured context strategy for agents that receive messages via
@@ -43,6 +44,13 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
43
44
  override readonly name: string = 'frontdesk';
44
45
 
45
46
  private salientSourceIds: Set<string> = new Set();
47
+ private readonly timeZone: string;
48
+
49
+ constructor(options: FrontdeskStrategyOptions = {}) {
50
+ const { timeZone, ...strategyOptions } = options;
51
+ super(strategyOptions);
52
+ this.timeZone = resolveTimeZone(timeZone);
53
+ }
46
54
 
47
55
  override select(
48
56
  store: MessageStoreView,
@@ -146,9 +154,7 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
146
154
  }
147
155
  if (!d && msgTs instanceof Date && !isNaN(msgTs.getTime())) d = msgTs;
148
156
  if (!d) return null;
149
- const hh = String(d.getHours()).padStart(2, '0');
150
- const mm = String(d.getMinutes()).padStart(2, '0');
151
- return `${hh}:${mm}`;
157
+ return formatZonedTime(d, this.timeZone);
152
158
  }
153
159
 
154
160
  // ==========================================================================
@@ -93,6 +93,9 @@ export interface WelcomeMessage {
93
93
  /** Per-agent cost breakdown for the parent process, present when the
94
94
  * framework's usage tracker has data. Empty during cold start. */
95
95
  perAgentCost?: PerAgentCost[];
96
+ /** Recent provider calls with cache verdicts. Present when the host's
97
+ * provider adapter exposes the call ledger. */
98
+ callLedger?: CallLedgerSnapshot;
96
99
  }
97
100
 
98
101
  /**
@@ -164,6 +167,91 @@ export interface UsageMessage {
164
167
  perAgentCost?: PerAgentCost[];
165
168
  }
166
169
 
170
+ /** Live replacement snapshot emitted after each provider call. */
171
+ export interface CallLedgerMessage {
172
+ type: 'call-ledger';
173
+ ledger: CallLedgerSnapshot;
174
+ }
175
+
176
+ export type CallLedgerVerdict =
177
+ | 'HIT'
178
+ | 'hit+extend'
179
+ | 'uncached'
180
+ | 'rewrite:expired'
181
+ | 'rewrite:prefix-mutated'
182
+ | 'rewrite:prefix-truncated'
183
+ | 'rewrite:unexplained'
184
+ | 'first-write'
185
+ | 'ERROR'
186
+ | 'empty'
187
+ | 'unknown';
188
+
189
+ export interface CallLedgerRow {
190
+ id: string;
191
+ timestamp: string;
192
+ kind: 'complete' | 'stream';
193
+ /** Honest call-class estimate; `~` means the trigger plumbing did not
194
+ * provide a definitive origin. */
195
+ originEstimate: 'turn~' | 'aux~';
196
+ model: string;
197
+ messages: number;
198
+ durationMs: number;
199
+ tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
200
+ cost?: CallCostBreakdown;
201
+ cache: {
202
+ /** Undefined for older compact logs that predate marker summaries. */
203
+ breakpoints?: number;
204
+ ttls: string[];
205
+ effectiveTtl: '5m' | '1h';
206
+ };
207
+ verdict: CallLedgerVerdict;
208
+ cause: string;
209
+ stopReason?: string;
210
+ error?: string;
211
+ }
212
+
213
+ export interface CallCostBreakdown {
214
+ input: number;
215
+ cacheWrite5m: number;
216
+ cacheWrite1h: number;
217
+ cacheRead: number;
218
+ output: number;
219
+ total: number;
220
+ currency: 'USD';
221
+ /** `billing` means every charged usage bucket and pricing modifier was
222
+ * known. Unknown/mixed buckets are left unpriced rather than estimated. */
223
+ grade: 'billing';
224
+ pricingVersion: string;
225
+ rates: {
226
+ inputPerMillion: number;
227
+ outputPerMillion: number;
228
+ cacheWrite5mPerMillion: number;
229
+ cacheWrite1hPerMillion: number;
230
+ cacheReadPerMillion: number;
231
+ };
232
+ }
233
+
234
+ export interface CallLedgerSnapshot {
235
+ /** Oldest to newest; clients may reverse for display. */
236
+ rows: CallLedgerRow[];
237
+ summary: {
238
+ calls: number;
239
+ input: number;
240
+ output: number;
241
+ cacheRead: number;
242
+ cacheWrite: number;
243
+ cacheHitRatio: number;
244
+ cost?: {
245
+ total: number;
246
+ currency: 'USD';
247
+ pricedCalls: number;
248
+ unpricedCalls: number;
249
+ pricingVersion: string;
250
+ };
251
+ byVerdict: Partial<Record<CallLedgerVerdict, number>>;
252
+ };
253
+ }
254
+
167
255
  export interface TokenUsage {
168
256
  input: number;
169
257
  output: number;
@@ -352,6 +440,7 @@ export type WebUiServerMessage =
352
440
  | ChildEventMessage
353
441
  | CommandResultMessage
354
442
  | UsageMessage
443
+ | CallLedgerMessage
355
444
  | BranchChangedMessage
356
445
  | SessionChangedMessage
357
446
  | PeekMessage
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { CallLedger, summarizeCacheControls, type ProviderCallRecord } from '../src/call-ledger.js';
3
+
4
+ const at = (seconds: number): string => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString();
5
+
6
+ function call(overrides: Partial<ProviderCallRecord> = {}): ProviderCallRecord {
7
+ return {
8
+ timestamp: at(0),
9
+ kind: 'stream',
10
+ durationMs: 1000,
11
+ model: 'claude-fable-5',
12
+ messages: 10,
13
+ inputTokens: 2,
14
+ outputTokens: 100,
15
+ cacheReadTokens: 0,
16
+ cacheWriteTokens: 0,
17
+ cacheBreakpoints: 4,
18
+ cacheTtls: ['1h', '1h', '1h', '1h'],
19
+ stopReason: 'end_turn',
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ describe('CallLedger cache verdicts', () => {
25
+ test('distinguishes first write, hit, and expired rewrite using the effective TTL', () => {
26
+ const ledger = new CallLedger({ hydrate: false, defaultTtl: '1h' });
27
+ ledger.record(call({ timestamp: at(0), cacheWriteTokens: 100_000 }));
28
+ ledger.record(call({ timestamp: at(1800), cacheReadTokens: 100_000 }));
29
+ ledger.record(call({ timestamp: at(5500), cacheWriteTokens: 101_000 }));
30
+
31
+ const rows = ledger.snapshot().rows;
32
+ expect(rows.map((r) => r.verdict)).toEqual(['first-write', 'HIT', 'rewrite:expired']);
33
+ expect(rows[2]!.cause).toContain('gap 3700s > ttl 3600s');
34
+ });
35
+
36
+ test('calls without cache flags are visibly uncached', () => {
37
+ const ledger = new CallLedger({ hydrate: false });
38
+ ledger.record(call({ kind: 'complete', cacheBreakpoints: 0, cacheTtls: [], inputTokens: 48_000 }));
39
+ expect(ledger.snapshot().rows[0]).toMatchObject({
40
+ verdict: 'uncached',
41
+ cause: 'no cache_control flags in request',
42
+ });
43
+ });
44
+
45
+ test('reports hit+extend and an aggregate cache ratio', () => {
46
+ const ledger = new CallLedger({ hydrate: false });
47
+ ledger.record(call({ inputTokens: 100, cacheReadTokens: 900, cacheWriteTokens: 100 }));
48
+ const snap = ledger.snapshot();
49
+ expect(snap.rows[0]!.verdict).toBe('hit+extend');
50
+ expect(snap.summary.cacheHitRatio).toBeCloseTo(900 / 1100);
51
+ });
52
+
53
+ test('allocates an auditable per-call cost and reconciles the retained total', () => {
54
+ const ledger = new CallLedger({ hydrate: false });
55
+ ledger.record(call({
56
+ cacheWriteTokens: 336_010,
57
+ cacheWrite5mTokens: 336_010,
58
+ cacheWrite1hTokens: 0,
59
+ cacheWriteBucketsAuthoritative: true,
60
+ cacheTtls: ['5m'],
61
+ outputTokens: 639,
62
+ serviceTier: 'standard',
63
+ inferenceGeo: 'global',
64
+ }));
65
+ ledger.record(call({ model: 'unknown-model' }));
66
+
67
+ const snap = ledger.snapshot();
68
+ expect(snap.rows[0]!.cost?.total).toBeCloseTo(4.232095, 9);
69
+ expect(snap.rows[1]!.cost).toBeUndefined();
70
+ expect(snap.summary.cost).toMatchObject({
71
+ total: 4.232095,
72
+ pricedCalls: 1,
73
+ unpricedCalls: 1,
74
+ currency: 'USD',
75
+ });
76
+ });
77
+
78
+ test('does not guess a cache-write TTL bucket from request flags', () => {
79
+ const ledger = new CallLedger({ hydrate: false });
80
+ ledger.record(call({ cacheWriteTokens: 100_000, cacheTtls: ['1h'] }));
81
+ expect(ledger.snapshot().rows[0]!.cost).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ test('summarizeCacheControls inventories markers without retaining content', () => {
86
+ const raw = {
87
+ system: [{ type: 'text', text: 'secret', cache_control: { type: 'ephemeral', ttl: '1h' } }],
88
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'private', cache_control: { type: 'ephemeral' } }] }],
89
+ };
90
+ expect(summarizeCacheControls(raw)).toEqual({ count: 2, ttls: ['1h', 'default'] });
91
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { ANTHROPIC_PRICING_VERSION, priceAnthropicCall } from '../src/call-pricing.js';
3
+
4
+ const timestamp = '2026-07-13T12:00:00.000Z';
5
+
6
+ function usage(overrides: Partial<Parameters<typeof priceAnthropicCall>[2]> = {}) {
7
+ return {
8
+ inputTokens: 0,
9
+ outputTokens: 0,
10
+ cacheReadTokens: 0,
11
+ cacheWrite5mTokens: 0,
12
+ cacheWrite1hTokens: 0,
13
+ unclassifiedCacheWriteTokens: 0,
14
+ serviceTier: 'standard',
15
+ inferenceGeo: 'global',
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('Anthropic per-call pricing', () => {
21
+ test('reproduces the ledger-dashboard Fable/Mythos 5m sample exactly', () => {
22
+ const cost = priceAnthropicCall('claude-fable-5', timestamp, usage({
23
+ inputTokens: 2,
24
+ cacheWrite5mTokens: 336_010,
25
+ outputTokens: 639,
26
+ }));
27
+
28
+ expect(cost?.total).toBeCloseTo(4.232095, 9);
29
+ expect(cost).toMatchObject({
30
+ cacheWrite5m: 4.200125,
31
+ cacheWrite1h: 0,
32
+ currency: 'USD',
33
+ grade: 'billing',
34
+ pricingVersion: ANTHROPIC_PRICING_VERSION,
35
+ });
36
+ });
37
+
38
+ test('prices 1h writes at 2x base input and cache reads at 0.1x', () => {
39
+ const write = priceAnthropicCall('claude-mythos-5', timestamp, usage({ cacheWrite1hTokens: 336_010 }));
40
+ const read = priceAnthropicCall('claude-mythos-5', timestamp, usage({ cacheReadTokens: 336_010 }));
41
+ expect(write?.cacheWrite1h).toBeCloseTo(6.7202, 9);
42
+ expect(read?.cacheRead).toBeCloseTo(0.33601, 9);
43
+ });
44
+
45
+ test('applies the US-only inference multiplier to every token category', () => {
46
+ const global = priceAnthropicCall('claude-fable-5', timestamp, usage({ inputTokens: 1_000_000 }));
47
+ const us = priceAnthropicCall('claude-fable-5', timestamp, usage({
48
+ inputTokens: 1_000_000,
49
+ inferenceGeo: 'us',
50
+ }));
51
+ expect(global?.total).toBe(10);
52
+ expect(us?.total).toBeCloseTo(11, 9);
53
+ });
54
+
55
+ test('leaves unknown rates, custom tiers, and unclassified writes unpriced', () => {
56
+ expect(priceAnthropicCall('unknown-model', timestamp, usage())).toBeUndefined();
57
+ expect(priceAnthropicCall('claude-fable-5', timestamp, usage({ serviceTier: 'priority' }))).toBeUndefined();
58
+ expect(priceAnthropicCall('claude-fable-5', timestamp, usage({
59
+ unclassifiedCacheWriteTokens: 1,
60
+ }))).toBeUndefined();
61
+ });
62
+
63
+ test('honors the published Sonnet 5 promotional cutoff', () => {
64
+ const promo = priceAnthropicCall('claude-sonnet-5', '2026-08-31T23:59:59Z', usage({ inputTokens: 1_000_000 }));
65
+ const standard = priceAnthropicCall('claude-sonnet-5', '2026-09-01T00:00:00Z', usage({ inputTokens: 1_000_000 }));
66
+ expect(promo?.total).toBe(2);
67
+ expect(standard?.total).toBe(3);
68
+ });
69
+ });
@@ -81,6 +81,7 @@ function makeStrategy(): TestFrontdesk {
81
81
  targetChunkTokens: 50,
82
82
  autoTickOnNewMessage: false,
83
83
  maxMessageTokens: 0,
84
+ timeZone: 'UTC',
84
85
  });
85
86
  }
86
87
 
@@ -110,6 +111,15 @@ describe('provenance wrapping', () => {
110
111
  expect(header!.endsWith('\n')).toBe(true);
111
112
  });
112
113
 
114
+ test('renders provenance time in the configured zone', () => {
115
+ const s = new TestFrontdesk({ timeZone: 'America/Los_Angeles' });
116
+ const m = msg('User', 'hello', {
117
+ serverId: 'zulip',
118
+ timestamp: '2026-07-17T14:32:00Z',
119
+ });
120
+ expect(s.pub_buildHeader(m)).toContain('07:32');
121
+ });
122
+
113
123
  test('wrapProvenance prepends header into the first text block', () => {
114
124
  const s = makeStrategy();
115
125
  const m = msg('User', 'hello', {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ projectItem,
4
+ reconstructEffectiveHistory,
5
+ } from '../scripts/import-codex-rollout.js';
6
+
7
+ describe('Codex rollout import', () => {
8
+ test('uses replacement_history as the authoritative compaction boundary', () => {
9
+ const history = reconstructEffectiveHistory([
10
+ { type: 'response_item', payload: { type: 'message', id: 'discarded' } },
11
+ {
12
+ type: 'compacted', timestamp: '2026-07-13T00:00:00Z',
13
+ payload: { replacement_history: [
14
+ { type: 'message', role: 'user', content: 'kept' },
15
+ { type: 'compaction', id: 'cmp_1', encrypted_content: 'opaque' },
16
+ ] },
17
+ },
18
+ { type: 'response_item', payload: { type: 'reasoning', id: 'rs_2', encrypted_content: 'tail' } },
19
+ ]);
20
+
21
+ expect(history.map(entry => entry.item.id ?? entry.item.type)).toEqual([
22
+ 'message', 'cmp_1', 'rs_2',
23
+ ]);
24
+ expect(history[0]!.restoredByCompaction).toBe(true);
25
+ expect(history[2]!.restoredByCompaction).toBe(false);
26
+ });
27
+
28
+ test('keeps the exact native item on the Chronicle projection block', () => {
29
+ const item = {
30
+ type: 'message', id: 'msg_1', role: 'assistant', phase: 'commentary',
31
+ content: [{ type: 'output_text', text: 'hello' }],
32
+ };
33
+ const [block] = projectItem(item);
34
+ expect(block).toMatchObject({ type: 'text', text: 'hello', rawItem: item });
35
+ });
36
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import { LoggingAnthropicAdapter } from '../src/logging-adapter.js';
3
+ import type { ProviderCallRecord } from '../src/call-ledger.js';
3
4
  import type { ProviderRequest, ProviderResponse } from '@animalabs/membrane';
4
5
 
5
6
  // Regression guard for the reasoning passthrough. `withReasoning` injects
@@ -57,7 +58,7 @@ describe('LoggingAnthropicAdapter.withReasoning', () => {
57
58
  describe('LoggingAnthropicAdapter request logging', () => {
58
59
  const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null');
59
60
  const internals = adapter as unknown as {
60
- requestSummary(r: ProviderRequest): Record<string, unknown>;
61
+ requestSummary(r: ProviderRequest, raw?: unknown): Record<string, unknown>;
61
62
  refusalRawRequest(r: ProviderResponse, raw: unknown): unknown;
62
63
  };
63
64
 
@@ -76,6 +77,20 @@ describe('LoggingAnthropicAdapter request logging', () => {
76
77
  });
77
78
  });
78
79
 
80
+ test('summarizes provider cache markers without retaining prompt content', () => {
81
+ const raw = {
82
+ messages: [{
83
+ role: 'user',
84
+ content: [{ type: 'text', text: 'do not log me', cache_control: { type: 'ephemeral', ttl: '1h' } }],
85
+ }],
86
+ };
87
+ expect(internals.requestSummary(baseRequest, raw)).toMatchObject({
88
+ cacheBreakpoints: 1,
89
+ cacheTtls: ['1h'],
90
+ });
91
+ expect(JSON.stringify(internals.requestSummary(baseRequest, raw))).not.toContain('do not log me');
92
+ });
93
+
79
94
  test('retains the raw request only for refusals', () => {
80
95
  const rawRequest = { messages: ['forensic context'] };
81
96
  const success = { raw: { stop_reason: 'end_turn' } } as unknown as ProviderResponse;
@@ -84,4 +99,46 @@ describe('LoggingAnthropicAdapter request logging', () => {
84
99
  expect(internals.refusalRawRequest(success, rawRequest)).toBeUndefined();
85
100
  expect(internals.refusalRawRequest(refusal, rawRequest)).toBe(rawRequest);
86
101
  });
102
+
103
+ test('forwards authoritative billing buckets from the provider response', () => {
104
+ const calls: ProviderCallRecord[] = [];
105
+ const observed = new LoggingAnthropicAdapter(
106
+ { apiKey: 'test' },
107
+ '/dev/null',
108
+ undefined,
109
+ (call) => calls.push(call),
110
+ ) as unknown as {
111
+ observeCall(
112
+ kind: 'complete' | 'stream',
113
+ timestamp: string,
114
+ durationMs: number,
115
+ request: ProviderRequest,
116
+ rawRequest: unknown,
117
+ response: ProviderResponse,
118
+ ): void;
119
+ };
120
+ const response = {
121
+ usage: { inputTokens: 2, outputTokens: 10, cacheCreationTokens: 100, cacheReadTokens: 50 },
122
+ raw: {
123
+ usage: {
124
+ cache_creation: {
125
+ ephemeral_5m_input_tokens: 25,
126
+ ephemeral_1h_input_tokens: 75,
127
+ },
128
+ service_tier: 'standard',
129
+ inference_geo: 'global',
130
+ },
131
+ },
132
+ } as unknown as ProviderResponse;
133
+
134
+ observed.observeCall('stream', '2026-07-13T00:00:00Z', 10, baseRequest, {}, response);
135
+ expect(calls[0]).toMatchObject({
136
+ cacheWriteTokens: 100,
137
+ cacheWrite5mTokens: 25,
138
+ cacheWrite1hTokens: 75,
139
+ cacheWriteBucketsAuthoritative: true,
140
+ serviceTier: 'standard',
141
+ inferenceGeo: 'global',
142
+ });
143
+ });
87
144
  });
@@ -4,7 +4,7 @@
4
4
  * inference).
5
5
  */
6
6
  import { describe, test, expect } from 'bun:test';
7
- import { validateRecipe } from '../src/recipe.js';
7
+ import { DEFAULT_RECIPE, validateRecipe } from '../src/recipe.js';
8
8
 
9
9
  function recipeWithCacheTtl(cacheTtl?: unknown) {
10
10
  return {
@@ -17,6 +17,10 @@ function recipeWithCacheTtl(cacheTtl?: unknown) {
17
17
  }
18
18
 
19
19
  describe('recipe agent.cacheTtl validation', () => {
20
+ test('the shipped default recipe explicitly uses "1h"', () => {
21
+ expect(DEFAULT_RECIPE.agent.cacheTtl).toBe('1h');
22
+ });
23
+
20
24
  test('accepts "5m"', () => {
21
25
  expect(validateRecipe(recipeWithCacheTtl('5m')).agent.cacheTtl).toBe('5m');
22
26
  });
@@ -25,8 +29,8 @@ describe('recipe agent.cacheTtl validation', () => {
25
29
  expect(validateRecipe(recipeWithCacheTtl('1h')).agent.cacheTtl).toBe('1h');
26
30
  });
27
31
 
28
- test('accepts unset (provider default applies)', () => {
29
- expect(validateRecipe(recipeWithCacheTtl()).agent.cacheTtl).toBeUndefined();
32
+ test('defaults unset cacheTtl to "1h"', () => {
33
+ expect(validateRecipe(recipeWithCacheTtl()).agent.cacheTtl).toBe('1h');
30
34
  });
31
35
 
32
36
  test('rejects a typo\'d TTL at load time', () => {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+
4
+ function recipe(agent: Record<string, unknown> = {}) {
5
+ return { name: 'provider-test', agent: { systemPrompt: 'sys', ...agent } };
6
+ }
7
+
8
+ describe('recipe provider validation', () => {
9
+ test('preserves Anthropic as the omitted provider and accepts Responses', () => {
10
+ expect(validateRecipe(recipe()).agent.provider).toBeUndefined();
11
+ expect(validateRecipe(recipe({ provider: 'openai-responses' })).agent.provider)
12
+ .toBe('openai-responses');
13
+ });
14
+
15
+ test('accepts Responses reasoning and compaction settings', () => {
16
+ expect(validateRecipe(recipe({
17
+ provider: 'openai-responses',
18
+ responses: {
19
+ reasoningEffort: 'xhigh',
20
+ reasoningContext: 'all_turns',
21
+ compactThreshold: 100_000,
22
+ },
23
+ })).agent.responses).toEqual({
24
+ reasoningEffort: 'xhigh',
25
+ reasoningContext: 'all_turns',
26
+ compactThreshold: 100_000,
27
+ });
28
+ });
29
+
30
+ test('rejects unknown providers and malformed Responses settings', () => {
31
+ expect(() => validateRecipe(recipe({ provider: 'openai-chat' }))).toThrow(/agent.provider/);
32
+ expect(() => validateRecipe(recipe({ responses: { reasoningEffort: 'ultra' } }))).toThrow(/reasoningEffort/);
33
+ expect(() => validateRecipe(recipe({ responses: { reasoningContext: 'previous_turn' } }))).toThrow(/reasoningContext/);
34
+ expect(() => validateRecipe(recipe({ responses: { compactThreshold: 0 } }))).toThrow(/compactThreshold/);
35
+ });
36
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+
4
+ function recipe(timezone?: unknown) {
5
+ return { name: 'tz', agent: { systemPrompt: 'test', ...(timezone === undefined ? {} : { timezone }) } };
6
+ }
7
+
8
+ describe('recipe agent.timezone validation', () => {
9
+ test('accepts an IANA timezone', () => {
10
+ expect(validateRecipe(recipe('America/Los_Angeles')).agent.timezone).toBe('America/Los_Angeles');
11
+ });
12
+
13
+ test('allows the setting to be omitted', () => {
14
+ expect(validateRecipe(recipe()).agent.timezone).toBeUndefined();
15
+ });
16
+
17
+ test('rejects invalid timezone names', () => {
18
+ expect(() => validateRecipe(recipe('Pacific/Definitely_Not'))).toThrow(/valid IANA time zone/);
19
+ });
20
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { formatNow } from '../src/modules/time-module.js';
3
+
4
+ describe('TimeModule presentation timezone', () => {
5
+ test('formats current time independently of the host timezone', () => {
6
+ expect(formatNow(new Date('2026-07-15T12:34:56.789Z'), 'America/Los_Angeles')).toEqual({
7
+ iso: '2026-07-15T05:34:56.789-07:00',
8
+ local: '2026-07-15T05:34:56.789-07:00 [America/Los_Angeles]',
9
+ timezone: 'America/Los_Angeles',
10
+ unixMs: 1_784_118_896_789,
11
+ });
12
+ });
13
+ });