castle-web-cli 0.4.102 → 0.4.103

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.
@@ -30,6 +30,24 @@ export declare function reportCursorRun(opts: {
30
30
  durationMs: number;
31
31
  ok: boolean;
32
32
  }): void;
33
+ /** Mirrors `CastleBudgetResponse` in castle-sandboxes/shared. */
34
+ export interface CastleBudget {
35
+ usedMicros: number;
36
+ limitMicros: number | null;
37
+ resetAtMs: number;
38
+ blocked: boolean;
39
+ }
40
+ /**
41
+ * The daily Castle-paid AI budget for the user this sandbox belongs to, or null
42
+ * when there is nothing to say: outside a sandbox, on a host whose proxy
43
+ * predates this route, or on any failure at all.
44
+ *
45
+ * Null means "don't gate, don't display" in every caller. The proxy is the
46
+ * actual enforcement point -- this read exists so a blocked user gets a
47
+ * sentence instead of a spawned agent failing against a 403, and so the editor
48
+ * can show the bar. Neither is worth failing a run over.
49
+ */
50
+ export declare function fetchBudget(): Promise<CastleBudget | null>;
33
51
  export declare function meteringHeaders(opts: {
34
52
  deckDir: string;
35
53
  sessionId: string;
package/dist/metering.js CHANGED
@@ -100,6 +100,48 @@ export function reportCursorRun(opts) {
100
100
  /* best-effort: metering must never surface in a finished run */
101
101
  });
102
102
  }
103
+ // Path on the proxy that reports this sandbox's daily budget. Must match
104
+ // CASTLE_BUDGET_PATH in castle-sandboxes/shared/src/index.ts.
105
+ const CASTLE_BUDGET_PATH = "/castle/budget";
106
+ // Read before a run starts, so it must not be able to hold one up for long.
107
+ const BUDGET_FETCH_TIMEOUT_MS = 5_000;
108
+ /**
109
+ * The daily Castle-paid AI budget for the user this sandbox belongs to, or null
110
+ * when there is nothing to say: outside a sandbox, on a host whose proxy
111
+ * predates this route, or on any failure at all.
112
+ *
113
+ * Null means "don't gate, don't display" in every caller. The proxy is the
114
+ * actual enforcement point -- this read exists so a blocked user gets a
115
+ * sentence instead of a spawned agent failing against a 403, and so the editor
116
+ * can show the bar. Neither is worth failing a run over.
117
+ */
118
+ export async function fetchBudget() {
119
+ const base = process.env.CASTLE_LLM_PROXY_URL;
120
+ const token = process.env.CASTLE_LLM_PROXY_TOKEN;
121
+ if (!base || !token)
122
+ return null;
123
+ try {
124
+ const resp = await fetch(`${base}${CASTLE_BUDGET_PATH}`, {
125
+ headers: { authorization: `Bearer ${token}` },
126
+ signal: AbortSignal.timeout(BUDGET_FETCH_TIMEOUT_MS),
127
+ });
128
+ if (!resp.ok)
129
+ return null;
130
+ const body = (await resp.json());
131
+ if (typeof body?.usedMicros !== "number" || typeof body.blocked !== "boolean") {
132
+ return null;
133
+ }
134
+ return {
135
+ usedMicros: body.usedMicros,
136
+ limitMicros: typeof body.limitMicros === "number" ? body.limitMicros : null,
137
+ resetAtMs: typeof body.resetAtMs === "number" ? body.resetAtMs : 0,
138
+ blocked: body.blocked,
139
+ };
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
103
145
  export function meteringHeaders(opts) {
104
146
  if (opts.direct || !proxyInjected(opts.route))
105
147
  return {};
@@ -11,6 +11,9 @@ export interface ORToolCall {
11
11
  export type ORContentPart = {
12
12
  type: "text";
13
13
  text: string;
14
+ cache_control?: {
15
+ type: "ephemeral";
16
+ };
14
17
  } | {
15
18
  type: "image_url";
16
19
  image_url: {
@@ -222,11 +222,65 @@ function finalizeToolCalls(pending) {
222
222
  };
223
223
  });
224
224
  }
225
+ // Anthropic prompt caching is EXPLICIT: without `cache_control` breakpoints
226
+ // nothing is cached, and a tool loop re-pays for its whole transcript every
227
+ // call. (Google caches implicitly, which is why gemini showed a ~66% hit rate
228
+ // here while sonnet showed 0%.) Measured: sonnet through this loop cost $8.10
229
+ // uncached versus $2.27 through the claude CLI, which sets breakpoints itself.
230
+ //
231
+ // Two breakpoints, the most a growing transcript needs: the system message,
232
+ // which never changes, and the tail of what we are about to send, so the NEXT
233
+ // call finds this whole turn already cached. Anthropic matches the longest
234
+ // cached prefix ending at a breakpoint.
235
+ //
236
+ // Only for providers that need the marker. Slugs that don't support it would
237
+ // carry an uninterpreted field at best, and OpenRouter's docs scope
238
+ // cache_control to Anthropic-family models.
239
+ function needsExplicitCacheControl(model) {
240
+ return model.startsWith("anthropic/");
241
+ }
242
+ function markCached(message) {
243
+ const { content } = message;
244
+ if (typeof content === "string") {
245
+ if (!content)
246
+ return message;
247
+ return { ...message, content: [{ type: "text", text: content, cache_control: { type: "ephemeral" } }] };
248
+ }
249
+ if (!Array.isArray(content) || content.length === 0)
250
+ return message;
251
+ const parts = content.slice();
252
+ const last = parts[parts.length - 1];
253
+ if (last.type !== "text")
254
+ return message;
255
+ parts[parts.length - 1] = { ...last, cache_control: { type: "ephemeral" } };
256
+ return { ...message, content: parts };
257
+ }
258
+ function withCacheBreakpoints(messages, model) {
259
+ if (!needsExplicitCacheControl(model) || messages.length === 0)
260
+ return messages;
261
+ const out = messages.slice();
262
+ const systemIndex = out.findIndex((m) => m.role === "system");
263
+ if (systemIndex !== -1)
264
+ out[systemIndex] = markCached(out[systemIndex]);
265
+ const lastIndex = out.length - 1;
266
+ if (lastIndex !== systemIndex)
267
+ out[lastIndex] = markCached(out[lastIndex]);
268
+ return out;
269
+ }
270
+ // A hard thinking budget, when one is asked for, else the effort word. Read
271
+ // from the environment rather than settings: this exists to A/B the two forms
272
+ // against each other, and a run wants it applied to every call it makes.
273
+ function reasoningField(effort) {
274
+ const cap = Number(process.env.CASTLE_REASONING_MAX_TOKENS);
275
+ if (Number.isFinite(cap) && cap > 0)
276
+ return { reasoning: { max_tokens: Math.floor(cap) } };
277
+ return effort ? { reasoning: { effort } } : {};
278
+ }
225
279
  export async function streamChatCompletion(opts) {
226
280
  const body = {
227
281
  // Routing mode rides the slug as a suffix (see applyRoutingMode).
228
282
  model: applyRoutingMode(opts.model, opts.routing),
229
- messages: opts.messages,
283
+ messages: withCacheBreakpoints(opts.messages, opts.model),
230
284
  stream: true,
231
285
  // Deprecated on OpenRouter's side (usage is always included now) but
232
286
  // harmless to send -- keeps this client correct against providers that
@@ -235,7 +289,14 @@ export async function streamChatCompletion(opts) {
235
289
  ...(opts.tools && opts.tools.length > 0 ? { tools: opts.tools } : {}),
236
290
  // See ORReasoningEffort / StreamChatOpts.reasoningEffort above for the
237
291
  // doc reference and the graceful-degradation guarantee this relies on.
238
- ...(opts.reasoningEffort ? { reasoning: { effort: opts.reasoningEffort } } : {}),
292
+ //
293
+ // `effort` means different things per provider: Anthropic derives a thinking
294
+ // budget as a fraction of max_tokens, so it caps; Gemini 3 maps it straight
295
+ // to `thinkingLevel`, so it FLOORS. A hard `reasoning.max_tokens` is the one
296
+ // form that means the same thing everywhere, so it is available as an
297
+ // override for measuring that difference. OpenRouter treats the two as
298
+ // mutually exclusive -- send one or the other, never both.
299
+ ...reasoningField(opts.reasoningEffort),
239
300
  // Pin a provider tier when requested (see StreamChatOpts.providerTier).
240
301
  ...(opts.providerTier
241
302
  ? { provider: { order: [opts.providerTier], allow_fallbacks: true } }