@lucid-agents/pi-xgate 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -10,10 +10,10 @@ reasoning signatures survive the full agent loop.
10
10
 
11
11
  The release package is `@lucid-agents/pi-xgate`. It includes the client dependency
12
12
  and the reviewed model-capability snapshot; the internal router contracts package
13
- is not required. With Node 24.19+ (below 25) and Pi 1.1.0:
13
+ is not required. With Node 24.19 or later and Pi 1.1.0:
14
14
 
15
15
  ```sh
16
- pi install npm:@lucid-agents/pi-xgate@0.1.6
16
+ pi install npm:@lucid-agents/pi-xgate@0.1.8
17
17
  ```
18
18
 
19
19
  For repository development before a release is available:
@@ -56,8 +56,21 @@ Both files must be regular, nonsymlink files with no group/other permissions.
56
56
  Select a model through `/model`. Only the broker policy's models with compatible
57
57
  live qualifications are offered. OpenAI requires the new
58
58
  `openai-anonymous-tools-v2` router profile; an older server will expose no OpenAI
59
- coding models. Anthropic requires `anthropic-anonymous-usage-v3` and uses only
60
- `anthropic_no_cache` native Messages scopes.
59
+ coding models. Anthropic requires `anthropic-anonymous-usage-v3` native Messages
60
+ scopes qualified as `anthropic_no_cache`, `anthropic_ephemeral_5m` or
61
+ `anthropic_ephemeral_1h`.
62
+
63
+ Anthropic caches a prompt only at explicit `cache_control` breakpoints. On a
64
+ cache-qualified scope the provider marks, like Pi's direct Anthropic provider, the
65
+ last tool, the system prompt and the newest user turn (three of Anthropic's four
66
+ breakpoints), so each call in a conversation can read the prefix the previous call
67
+ wrote. Breakpoints are five-minute `ephemeral` writes; Pi's `cacheRetention: "long"`
68
+ selects one-hour writes only on an `anthropic_ephemeral_1h` scope, and
69
+ `cacheRetention: "none"` sends none. An `anthropic_no_cache` scope gets no
70
+ breakpoints, and cache usage it reports is rejected as an invalid result. The
71
+ breakpoints carry no session ID or other key: the provider cache is keyed by the
72
+ content prefix alone. Cache warming stays off, so no unpaid or extra paid call
73
+ is made to keep a cache alive.
61
74
  An expired/revoked catalog scope cannot authorize payment just because Pi remembers
62
75
  an earlier model list: the provider refreshes before each invocation and the wallet
63
76
  client validates the actual quote again.
package/dist/codec.d.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import type { AssistantMessage, Message, Model, SimpleStreamOptions, Tool, Usage } from "@earendil-works/pi-ai";
2
2
  import { type Route } from "@lucid-agents/xgate-client/types";
3
+ /** The widest Anthropic prompt-cache write lifetime a scope's qualification permits. */
4
+ export type CacheTtl = "none" | "5m" | "1h";
3
5
  export type Entry = {
4
6
  model: Model<any>;
5
7
  catalog: any;
6
8
  route: Route;
7
9
  efforts: string[];
10
+ /** Anthropic only: requests may carry `cache_control` breakpoints up to this lifetime. */
11
+ cache: CacheTtl;
12
+ /** Anthropic only: the per-million-token price of a one-hour cache write. */
13
+ cacheWrite1h: number;
8
14
  };
9
15
  export declare function catalogEntries(rows: any[]): Entry[];
10
16
  /**
package/dist/codec.js CHANGED
@@ -2,6 +2,16 @@ import * as pi from "@earendil-works/pi-ai";
2
2
  import { XgateError } from "@lucid-agents/xgate-client/types";
3
3
  import capabilities from "./generated/openai-model-capabilities.json" with { type: "json" };
4
4
  import { createHash } from "node:crypto";
5
+ /**
6
+ * The router quotes the class a request's `cache_control` markers select, and serves it only
7
+ * when the scope's qualification covers that class. A wider qualification covers narrower
8
+ * requests, so a cache-qualified scope still serves a request without markers.
9
+ */
10
+ const ANTHROPIC_CACHE = {
11
+ anthropic_no_cache: "none",
12
+ anthropic_ephemeral_5m: "5m",
13
+ anthropic_ephemeral_1h: "1h",
14
+ };
5
15
  const need = (x, code) => {
6
16
  if (!x)
7
17
  throw new XgateError(code);
@@ -44,7 +54,7 @@ export function catalogEntries(rows) {
44
54
  if (!q ||
45
55
  row.privacy?.revision !== "anonymous-volatile-v1" ||
46
56
  row.privacy.result_replay_available !== false ||
47
- (anthropic && q.cache_mode !== "anthropic_no_cache"))
57
+ (anthropic && !Object.hasOwn(ANTHROPIC_CACHE, q.cache_mode)))
48
58
  continue;
49
59
  if (q.valid_until !== undefined &&
50
60
  Number(q.valid_until) * 1000 <= Date.now())
@@ -84,8 +94,13 @@ export function catalogEntries(rows) {
84
94
  ["minimal", "low", "medium", "high"].includes(level));
85
95
  thinkingLevelMap[level] = allowed ? level : null;
86
96
  }
97
+ const write5m = p.rates.input_cache_write_5m ?? p.rates.input_cache_write;
87
98
  entries.push({
88
99
  catalog: row,
100
+ cache: anthropic ? ANTHROPIC_CACHE[q.cache_mode] : "none",
101
+ cacheWrite1h: p.rates.input_cache_write_1h
102
+ ? rate(p.rates.input_cache_write_1h)
103
+ : 0,
89
104
  route: anthropic
90
105
  ? "/providers/anthropic/v1/messages"
91
106
  : "/providers/openai/v1/responses",
@@ -107,9 +122,7 @@ export function catalogEntries(rows) {
107
122
  input: rate(p.rates.input_uncached),
108
123
  output: rate(p.rates.output),
109
124
  cacheRead: rate(p.rates.input_cache_read),
110
- cacheWrite: p.rates.input_cache_write
111
- ? rate(p.rates.input_cache_write)
112
- : 0,
125
+ cacheWrite: write5m ? rate(write5m) : 0,
113
126
  },
114
127
  inputLimits: { maxRequestBytes: 8 * 1024 * 1024 },
115
128
  },
@@ -301,23 +314,38 @@ export function encode(entry, context, options = {}) {
301
314
  need(history.length > 0 && history.length <= 256, "history_limit");
302
315
  let request;
303
316
  if (anthropic) {
304
- const cap = entry.catalog.execution_profile.capabilities;
317
+ const cap = entry.catalog.execution_profile.capabilities, cache = cacheControl(entry.cache, options.cacheRetention);
305
318
  request = {
306
319
  model: entry.model.id,
307
320
  messages: history,
308
321
  max_tokens: max,
309
322
  stream: false,
310
- ...(system ? { system } : {}),
323
+ ...(system
324
+ ? {
325
+ system: cache
326
+ ? [{ type: "text", text: system, cache_control: cache }]
327
+ : system,
328
+ }
329
+ : {}),
311
330
  ...(tools.length
312
331
  ? {
313
- tools: tools.map((t) => ({
332
+ tools: tools.map((t, i) => ({
314
333
  name: t.name,
315
334
  description: t.description,
316
335
  input_schema: t.parameters,
336
+ ...(cache && i === tools.length - 1
337
+ ? { cache_control: cache }
338
+ : {}),
317
339
  })),
318
340
  }
319
341
  : {}),
320
342
  };
343
+ // Anthropic caches nothing without a breakpoint. Like Pi's direct Anthropic provider, mark
344
+ // the tools, the system prompt and the newest turn: the next request in the conversation
345
+ // reads the whole previous prefix. A final user turn ends in text or a tool result.
346
+ const last = history.at(-1);
347
+ if (cache && last.role === "user")
348
+ last.content.at(-1).cache_control = cache;
321
349
  if (effort)
322
350
  request.output_config = { effort };
323
351
  if (cap.thinking.adaptive)
@@ -357,6 +385,13 @@ export function encode(entry, context, options = {}) {
357
385
  need(Buffer.byteLength(JSON.stringify(request)) <= 8 * 1024 * 1024, "request_bound");
358
386
  return request;
359
387
  }
388
+ function cacheControl(qualified, retention) {
389
+ if (qualified === "none" || retention === "none")
390
+ return undefined;
391
+ return retention === "long" && qualified === "1h"
392
+ ? { type: "ephemeral", ttl: "1h" }
393
+ : { type: "ephemeral" };
394
+ }
360
395
  function tokens(value) {
361
396
  need(Number.isSafeInteger(value) && value >= 0, "invalid_usage");
362
397
  return value;
@@ -483,14 +518,22 @@ export function decode(entry, body) {
483
518
  ? (u.cache_read_input_tokens ?? 0)
484
519
  : u.input_tokens_details?.cached_tokens), write = tokens(anthropic
485
520
  ? (u.cache_creation_input_tokens ?? 0)
486
- : (u.input_tokens_details?.cache_write_tokens ?? 0)), totalInput = tokens(u.input_tokens), output = tokens(u.output_tokens), input = anthropic ? totalInput : totalInput - read - write;
487
- need(input >= 0 && (!anthropic || (read === 0 && write === 0)), "cache_usage_mismatch");
521
+ : (u.input_tokens_details?.cache_write_tokens ?? 0)), totalInput = tokens(u.input_tokens), output = tokens(u.output_tokens), input = anthropic ? totalInput : totalInput - read - write,
522
+ // Anthropic splits writes by lifetime; an unsplit write is the default five minutes.
523
+ write1h = anthropic && object(u.cache_creation)
524
+ ? tokens(u.cache_creation.ephemeral_1h_input_tokens ?? 0)
525
+ : 0;
526
+ need(input >= 0 &&
527
+ write1h <= write &&
528
+ (!anthropic || entry.cache !== "none" || (read === 0 && write === 0)) &&
529
+ (!anthropic || entry.cache === "1h" || write1h === 0), "cache_usage_mismatch");
488
530
  const rates = entry.model.cost;
489
531
  const cost = {
490
532
  input: (input * rates.input) / 1e6,
491
533
  output: (output * rates.output) / 1e6,
492
534
  cacheRead: (read * rates.cacheRead) / 1e6,
493
- cacheWrite: (write * rates.cacheWrite) / 1e6,
535
+ cacheWrite: ((write - write1h) * rates.cacheWrite + write1h * entry.cacheWrite1h) /
536
+ 1e6,
494
537
  total: 0,
495
538
  };
496
539
  cost.total = cost.input + cost.output + cost.cacheRead + cost.cacheWrite;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucid-agents/pi-xgate",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -32,14 +32,14 @@
32
32
  "LICENSE"
33
33
  ],
34
34
  "dependencies": {
35
- "@lucid-agents/xgate-client": "0.1.6"
35
+ "@lucid-agents/xgate-client": "0.1.8"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-ai": "0.85.1 || 1.1.0",
39
39
  "@earendil-works/pi-coding-agent": "0.85.1 || 1.1.0"
40
40
  },
41
41
  "engines": {
42
- "node": ">=24.19 <25"
42
+ "node": ">=24.19"
43
43
  },
44
44
  "description": "Wallet-paid OpenAI and Anthropic inference through xgate for the Pi coding agent",
45
45
  "repository": {
@@ -56,10 +56,10 @@
56
56
  "registry": "https://registry.npmjs.org"
57
57
  },
58
58
  "license": "MIT",
59
- "gitHead": "f26a9cdf35db07e8aa6b6ad7c6408884aa283ebc",
59
+ "gitHead": "d410140f8e57fd3fc4309a1c54772b8a9f99ccd9",
60
60
  "xgateRelease": {
61
- "version": "v0.1.6",
62
- "sourceSha": "f26a9cdf35db07e8aa6b6ad7c6408884aa283ebc",
63
- "candidateId": "rc-f26a9cdf35db-38096296216-1"
61
+ "version": "v0.1.8",
62
+ "sourceSha": "d410140f8e57fd3fc4309a1c54772b8a9f99ccd9",
63
+ "candidateId": "rc-d410140f8e57-42908627975-1"
64
64
  }
65
65
  }
package/src/codec.ts CHANGED
@@ -13,11 +13,27 @@ import type {
13
13
  import { XgateError, type Route } from "@lucid-agents/xgate-client/types";
14
14
  import capabilities from "./generated/openai-model-capabilities.json" with { type: "json" };
15
15
  import { createHash } from "node:crypto";
16
+ /** The widest Anthropic prompt-cache write lifetime a scope's qualification permits. */
17
+ export type CacheTtl = "none" | "5m" | "1h";
16
18
  export type Entry = {
17
19
  model: Model<any>;
18
20
  catalog: any;
19
21
  route: Route;
20
22
  efforts: string[];
23
+ /** Anthropic only: requests may carry `cache_control` breakpoints up to this lifetime. */
24
+ cache: CacheTtl;
25
+ /** Anthropic only: the per-million-token price of a one-hour cache write. */
26
+ cacheWrite1h: number;
27
+ };
28
+ /**
29
+ * The router quotes the class a request's `cache_control` markers select, and serves it only
30
+ * when the scope's qualification covers that class. A wider qualification covers narrower
31
+ * requests, so a cache-qualified scope still serves a request without markers.
32
+ */
33
+ const ANTHROPIC_CACHE: Record<string, CacheTtl> = {
34
+ anthropic_no_cache: "none",
35
+ anthropic_ephemeral_5m: "5m",
36
+ anthropic_ephemeral_1h: "1h",
21
37
  };
22
38
  const need = (x: unknown, code: string): void => {
23
39
  if (!x) throw new XgateError(code);
@@ -70,7 +86,7 @@ export function catalogEntries(rows: any[]): Entry[] {
70
86
  !q ||
71
87
  row.privacy?.revision !== "anonymous-volatile-v1" ||
72
88
  row.privacy.result_replay_available !== false ||
73
- (anthropic && q.cache_mode !== "anthropic_no_cache")
89
+ (anthropic && !Object.hasOwn(ANTHROPIC_CACHE, q.cache_mode))
74
90
  )
75
91
  continue;
76
92
  if (
@@ -123,8 +139,13 @@ export function catalogEntries(rows: any[]): Entry[] {
123
139
  ["minimal", "low", "medium", "high"].includes(level));
124
140
  thinkingLevelMap[level] = allowed ? level : null;
125
141
  }
142
+ const write5m = p.rates.input_cache_write_5m ?? p.rates.input_cache_write;
126
143
  entries.push({
127
144
  catalog: row,
145
+ cache: anthropic ? ANTHROPIC_CACHE[q.cache_mode]! : "none",
146
+ cacheWrite1h: p.rates.input_cache_write_1h
147
+ ? rate(p.rates.input_cache_write_1h)
148
+ : 0,
128
149
  route: anthropic
129
150
  ? "/providers/anthropic/v1/messages"
130
151
  : "/providers/openai/v1/responses",
@@ -146,9 +167,7 @@ export function catalogEntries(rows: any[]): Entry[] {
146
167
  input: rate(p.rates.input_uncached),
147
168
  output: rate(p.rates.output),
148
169
  cacheRead: rate(p.rates.input_cache_read),
149
- cacheWrite: p.rates.input_cache_write
150
- ? rate(p.rates.input_cache_write)
151
- : 0,
170
+ cacheWrite: write5m ? rate(write5m) : 0,
152
171
  },
153
172
  inputLimits: { maxRequestBytes: 8 * 1024 * 1024 },
154
173
  },
@@ -397,23 +416,39 @@ export function encode(
397
416
  need(history.length > 0 && history.length <= 256, "history_limit");
398
417
  let request: any;
399
418
  if (anthropic) {
400
- const cap = entry.catalog.execution_profile.capabilities;
419
+ const cap = entry.catalog.execution_profile.capabilities,
420
+ cache = cacheControl(entry.cache, options.cacheRetention);
401
421
  request = {
402
422
  model: entry.model.id,
403
423
  messages: history,
404
424
  max_tokens: max,
405
425
  stream: false,
406
- ...(system ? { system } : {}),
426
+ ...(system
427
+ ? {
428
+ system: cache
429
+ ? [{ type: "text", text: system, cache_control: cache }]
430
+ : system,
431
+ }
432
+ : {}),
407
433
  ...(tools.length
408
434
  ? {
409
- tools: tools.map((t) => ({
435
+ tools: tools.map((t, i) => ({
410
436
  name: t.name,
411
437
  description: t.description,
412
438
  input_schema: t.parameters,
439
+ ...(cache && i === tools.length - 1
440
+ ? { cache_control: cache }
441
+ : {}),
413
442
  })),
414
443
  }
415
444
  : {}),
416
445
  };
446
+ // Anthropic caches nothing without a breakpoint. Like Pi's direct Anthropic provider, mark
447
+ // the tools, the system prompt and the newest turn: the next request in the conversation
448
+ // reads the whole previous prefix. A final user turn ends in text or a tool result.
449
+ const last = history.at(-1);
450
+ if (cache && last.role === "user")
451
+ last.content.at(-1).cache_control = cache;
417
452
  if (effort) request.output_config = { effort };
418
453
  if (cap.thinking.adaptive) request.thinking = { type: "adaptive" };
419
454
  else if (requested && cap.thinking.enabled) {
@@ -456,6 +491,15 @@ export function encode(
456
491
  );
457
492
  return request;
458
493
  }
494
+ function cacheControl(
495
+ qualified: CacheTtl,
496
+ retention: SimpleStreamOptions["cacheRetention"],
497
+ ): { type: "ephemeral"; ttl?: "1h" } | undefined {
498
+ if (qualified === "none" || retention === "none") return undefined;
499
+ return retention === "long" && qualified === "1h"
500
+ ? { type: "ephemeral", ttl: "1h" }
501
+ : { type: "ephemeral" };
502
+ }
459
503
  function tokens(value: any): number {
460
504
  need(Number.isSafeInteger(value) && value >= 0, "invalid_usage");
461
505
  return value;
@@ -628,9 +672,17 @@ export function decode(
628
672
  ),
629
673
  totalInput = tokens(u.input_tokens),
630
674
  output = tokens(u.output_tokens),
631
- input = anthropic ? totalInput : totalInput - read - write;
675
+ input = anthropic ? totalInput : totalInput - read - write,
676
+ // Anthropic splits writes by lifetime; an unsplit write is the default five minutes.
677
+ write1h =
678
+ anthropic && object(u.cache_creation)
679
+ ? tokens(u.cache_creation.ephemeral_1h_input_tokens ?? 0)
680
+ : 0;
632
681
  need(
633
- input >= 0 && (!anthropic || (read === 0 && write === 0)),
682
+ input >= 0 &&
683
+ write1h <= write &&
684
+ (!anthropic || entry.cache !== "none" || (read === 0 && write === 0)) &&
685
+ (!anthropic || entry.cache === "1h" || write1h === 0),
634
686
  "cache_usage_mismatch",
635
687
  );
636
688
  const rates = entry.model.cost;
@@ -638,7 +690,9 @@ export function decode(
638
690
  input: (input * rates.input) / 1e6,
639
691
  output: (output * rates.output) / 1e6,
640
692
  cacheRead: (read * rates.cacheRead) / 1e6,
641
- cacheWrite: (write * rates.cacheWrite) / 1e6,
693
+ cacheWrite:
694
+ ((write - write1h) * rates.cacheWrite + write1h * entry.cacheWrite1h) /
695
+ 1e6,
642
696
  total: 0,
643
697
  };
644
698
  cost.total = cost.input + cost.output + cost.cacheRead + cost.cacheWrite;