castle-web-cli 0.4.90 → 0.4.91

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/dist/agent.js CHANGED
@@ -24,6 +24,7 @@ import { rawDataToString } from "./ide.js";
24
24
  import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from "./agent-prompts.js";
25
25
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
26
26
  import { classifyProviderError, failureCopy, } from "./agent-failures.js";
27
+ import { meteringHeaders, newAgentSessionId, withCustomHeaders, } from "./metering.js";
27
28
  import { runAgentNative } from "./native/loop.js";
28
29
  import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
29
30
  import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
@@ -343,7 +344,11 @@ const OPENROUTER_ALLOWED_TOOLS = "--allowedTools=Edit,Write,NotebookEdit,Bash";
343
344
  function buildAgentInvocation(backend, role, prompt, claudeModel,
344
345
  // Already resolved for this role by the caller (router turns pass
345
346
  // settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
346
- openrouterModel) {
347
+ openrouterModel,
348
+ // This run's ledger identity, and the deck dir its castle.json is read from.
349
+ // Only the claude branch can carry it: cursor-agent runs on its own key and
350
+ // never traverses the llm-proxy.
351
+ metering) {
347
352
  if (backend === "claude") {
348
353
  const viaOpenrouter = claudeModel === "openrouter";
349
354
  return {
@@ -379,9 +384,13 @@ openrouterModel) {
379
384
  : []),
380
385
  prompt,
381
386
  ],
382
- env: viaOpenrouter
387
+ env: withCustomHeaders(viaOpenrouter
383
388
  ? envForOpenrouterSpawn(openrouterApiKey())
384
- : envForAgentSpawn(backend),
389
+ : envForAgentSpawn(backend), meteringHeaders({
390
+ deckDir: metering.deckDir,
391
+ sessionId: metering.sessionId,
392
+ route: viaOpenrouter ? "openrouter" : "anthropic",
393
+ })),
385
394
  };
386
395
  }
387
396
  return {
@@ -1456,6 +1465,7 @@ async function runAgentSmith(opts) {
1456
1465
  const result = await runAgentNative({
1457
1466
  cwd: opts.cwd,
1458
1467
  role: opts.role,
1468
+ extraHeaders: opts.extraHeaders,
1459
1469
  model: opts.model,
1460
1470
  apiKey: openrouterApiKey(),
1461
1471
  reasoningEffort: opts.openrouterTuning?.reasoningEffort,
@@ -1574,10 +1584,19 @@ async function runAgentTurn(opts) {
1574
1584
  crashed: false,
1575
1585
  };
1576
1586
  }
1587
+ // One id per call: a router turn, or a single task attempt (the retry loop
1588
+ // in runTaskAgentIn calls this once per attempt, and each attempt is its own
1589
+ // conversation -- nothing is resumed).
1590
+ const sessionId = newAgentSessionId(opts.role);
1577
1591
  if (opts.backend === "smith") {
1578
1592
  return runAgentSmith({
1579
1593
  cwd: opts.cwd,
1580
1594
  role: opts.role,
1595
+ extraHeaders: meteringHeaders({
1596
+ deckDir: opts.cwd,
1597
+ sessionId,
1598
+ route: "openrouter",
1599
+ }),
1581
1600
  model: opts.openrouterModel,
1582
1601
  prompt: opts.prompt,
1583
1602
  // Mirrors claude's --append-system-prompt for tasks (the native loop
@@ -1596,7 +1615,7 @@ async function runAgentTurn(opts) {
1596
1615
  onSpawn: opts.onSpawn,
1597
1616
  });
1598
1617
  }
1599
- const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel);
1618
+ const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd });
1600
1619
  return runAgentCli({
1601
1620
  cwd: opts.cwd,
1602
1621
  command: invocation.command,
@@ -0,0 +1,21 @@
1
+ export type MeteringRoute = "anthropic" | "openrouter";
2
+ /**
3
+ * A fresh id for one agent run -- one router turn (one user message) or one
4
+ * task attempt. Every provider call in that run's agentic loop carries it, so
5
+ * the ledger can price a single turn even though concurrent tasks interleave
6
+ * with chat in wall-clock time. The role prefix keeps "chat spend vs.
7
+ * background-task spend" a prefix match with no extra column.
8
+ */
9
+ export declare function newAgentSessionId(role: "router" | "task"): string;
10
+ export declare function meteringHeaders(opts: {
11
+ deckDir: string;
12
+ sessionId: string;
13
+ route: MeteringRoute;
14
+ }): Record<string, string>;
15
+ /**
16
+ * Merge metering headers into a claude spawn env. ANTHROPIC_CUSTOM_HEADERS is
17
+ * the claude CLI's newline-separated `Name: Value` list, forwarded on every
18
+ * request it makes to ANTHROPIC_BASE_URL. An inherited value is kept, first,
19
+ * rather than clobbered.
20
+ */
21
+ export declare function withCustomHeaders(env: NodeJS.ProcessEnv, headers: Record<string, string>): NodeJS.ProcessEnv;
@@ -0,0 +1,75 @@
1
+ // Per-request metering identity for agent runs inside a Castle sandbox.
2
+ //
3
+ // The per-host llm-proxy (castle-sandboxes/llm-proxy) terminates every
4
+ // provider call a sandbox makes, records a usage row, and strips these two
5
+ // headers before forwarding upstream -- so they never reach Anthropic or
6
+ // OpenRouter. Names must match LLM_SESSION_HEADER / LLM_DECK_HEADER in
7
+ // castle-sandboxes/shared/src/index.ts.
8
+ //
9
+ // The proxy already knows the user (its token is minted per sandbox) and the
10
+ // sandbox. These headers add the two dimensions only this process knows: which
11
+ // deck the work belongs to, and which agent run issued the call.
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { randomBytes } from "crypto";
15
+ const SESSION_HEADER = "x-castle-agent-session-id";
16
+ const DECK_HEADER = "x-castle-deck-id";
17
+ /**
18
+ * A fresh id for one agent run -- one router turn (one user message) or one
19
+ * task attempt. Every provider call in that run's agentic loop carries it, so
20
+ * the ledger can price a single turn even though concurrent tasks interleave
21
+ * with chat in wall-clock time. The role prefix keeps "chat spend vs.
22
+ * background-task spend" a prefix match with no extra column.
23
+ */
24
+ export function newAgentSessionId(role) {
25
+ return `${role === "router" ? "r" : "t"}-${randomBytes(12).toString("hex")}`;
26
+ }
27
+ // Outside a sandbox nothing injects a base URL, and the headers would be sent
28
+ // to the provider itself -- handing a deck id to a third party to no purpose.
29
+ function proxyInjected(route) {
30
+ return Boolean(route === "anthropic"
31
+ ? process.env.ANTHROPIC_BASE_URL
32
+ : process.env.OPENROUTER_BASE_URL);
33
+ }
34
+ // castle.json is written by the first save (see save-deck.ts), so an unsaved
35
+ // deck has no Castle id and its spend stays unattributed rather than being
36
+ // labelled with the sandbox-local directory name, which never becomes the
37
+ // deck id. Re-read per run: a deck saved mid-session starts attributing on its
38
+ // next turn.
39
+ function deckIdFor(deckDir) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(path.join(deckDir, "castle.json"), "utf8"));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (typeof parsed.deckId !== "string" || !parsed.deckId)
48
+ return null;
49
+ return parsed.deckId.replace(/[\r\n]/g, "") || null;
50
+ }
51
+ export function meteringHeaders(opts) {
52
+ if (!proxyInjected(opts.route))
53
+ return {};
54
+ const deckId = deckIdFor(opts.deckDir);
55
+ return {
56
+ [SESSION_HEADER]: opts.sessionId,
57
+ ...(deckId ? { [DECK_HEADER]: deckId } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * Merge metering headers into a claude spawn env. ANTHROPIC_CUSTOM_HEADERS is
62
+ * the claude CLI's newline-separated `Name: Value` list, forwarded on every
63
+ * request it makes to ANTHROPIC_BASE_URL. An inherited value is kept, first,
64
+ * rather than clobbered.
65
+ */
66
+ export function withCustomHeaders(env, headers) {
67
+ const lines = Object.entries(headers).map(([name, value]) => `${name}: ${value}`);
68
+ if (lines.length === 0)
69
+ return env;
70
+ const inherited = env.ANTHROPIC_CUSTOM_HEADERS;
71
+ return {
72
+ ...env,
73
+ ANTHROPIC_CUSTOM_HEADERS: [...(inherited ? [inherited] : []), ...lines].join("\n"),
74
+ };
75
+ }
@@ -607,6 +607,7 @@ async function runLoop(opts, toolSchemas, log) {
607
607
  reasoningEffort: opts.reasoningEffort ?? REASONING_EFFORT[opts.role],
608
608
  routing: opts.routing,
609
609
  providerTier: opts.providerTier,
610
+ extraHeaders: opts.extraHeaders,
610
611
  maxTokens: MAX_COMPLETION_TOKENS,
611
612
  signal: controller.signal,
612
613
  onDelta: opts.onDelta,
@@ -39,6 +39,7 @@ export interface StreamChatOpts {
39
39
  reasoningEffort?: ORReasoningEffort;
40
40
  routing?: ORRoutingMode;
41
41
  providerTier?: string;
42
+ extraHeaders?: Record<string, string>;
42
43
  maxTokens?: number;
43
44
  signal?: AbortSignal;
44
45
  onDelta?: (delta: string) => void;
@@ -247,6 +247,7 @@ export async function streamChatCompletion(opts) {
247
247
  res = await connectWithRetry({
248
248
  method: "POST",
249
249
  headers: {
250
+ ...opts.extraHeaders,
250
251
  Authorization: `Bearer ${opts.apiKey}`,
251
252
  "Content-Type": "application/json",
252
253
  },
@@ -28,6 +28,7 @@ export interface NativeRunOpts {
28
28
  restart?: () => void;
29
29
  logPath?: string;
30
30
  timeoutMs: number;
31
+ extraHeaders?: Record<string, string>;
31
32
  signal?: AbortSignal;
32
33
  onDelta?: (delta: string) => void;
33
34
  onActivity?: (activity: string | null) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.90",
3
+ "version": "0.4.91",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"