castle-web-cli 0.4.185 → 0.4.186
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 +31 -9
- package/dist/metering.d.ts +10 -3
- package/dist/metering.js +54 -5
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -31,7 +31,7 @@ import { applyPlanOps, buildRouterPromptParts, buildTaskPrompt, parsePlanOps, pl
|
|
|
31
31
|
import { readCastleJson } from './castleJson.js';
|
|
32
32
|
import { checkOpenrouterKey, checkOpenrouterModel, primeOpenrouterCatalog, } from './openrouter-catalog.js';
|
|
33
33
|
import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
|
|
34
|
-
import { castleCreditsExhausted, createRefreshQueue, fetchAiCredits, fetchBudget, freeRolesFor, freeTierForRoles, meteringHeaders, modelIsFree, newAgentSessionId, spendableMicros, withCustomHeaders, } from './metering.js';
|
|
34
|
+
import { castleCreditsExhausted, createBudgetRefreshRetry, createRefreshQueue, fetchAiCredits, fetchBudget, freeRolesFor, freeTierForRoles, meteringHeaders, modelIsFree, newAgentSessionId, spendableMicros, withCustomHeaders, } from './metering.js';
|
|
35
35
|
import { anthropicKeyHelperCommand, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from './byo-auth.js';
|
|
36
36
|
import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from './byo-accounts.js';
|
|
37
37
|
import { cancelLogin, logout, startLogin, submitLoginCode } from './byo-login.js';
|
|
@@ -1537,17 +1537,20 @@ function createUsageFeed(opts) {
|
|
|
1537
1537
|
let latest = null;
|
|
1538
1538
|
let credits = null;
|
|
1539
1539
|
let budgetPolls = 0;
|
|
1540
|
-
|
|
1540
|
+
let stopped = false;
|
|
1541
|
+
async function readUsage(refreshCredits = false, allowFollowUp = true) {
|
|
1541
1542
|
const settings = opts.settings();
|
|
1542
1543
|
if (!anyRoleIsCastlePaid(settings)) {
|
|
1543
1544
|
credits = null;
|
|
1544
|
-
return null;
|
|
1545
|
+
return { usage: null, refreshAfterMs: null, allowFollowUp };
|
|
1545
1546
|
}
|
|
1546
1547
|
// Budget is the authoritative spendable amount and its read can force a
|
|
1547
1548
|
// stale proxy gate to sync after a purchase. Read credits second so the
|
|
1548
1549
|
// descriptive fields (plan, rate, reload state) describe that same or a
|
|
1549
1550
|
// newer account snapshot instead of racing ahead of the gate refresh.
|
|
1550
|
-
|
|
1551
|
+
// Only a full credit-state refresh asks the host to sync its held budget. Cheap polls and
|
|
1552
|
+
// spend preflight stay local to the proxy, so control-plane health cannot delay a run.
|
|
1553
|
+
const budget = await fetchBudget(refreshCredits);
|
|
1551
1554
|
if (refreshCredits) {
|
|
1552
1555
|
const nextCredits = await fetchAiCredits();
|
|
1553
1556
|
// Once the richer field has loaded, a transient GraphQL failure should
|
|
@@ -1555,7 +1558,11 @@ function createUsageFeed(opts) {
|
|
|
1555
1558
|
if (nextCredits)
|
|
1556
1559
|
credits = nextCredits;
|
|
1557
1560
|
}
|
|
1558
|
-
return
|
|
1561
|
+
return {
|
|
1562
|
+
usage: usageFrame(budget, credits, settings),
|
|
1563
|
+
refreshAfterMs: budget?.refreshAfterMs ?? null,
|
|
1564
|
+
allowFollowUp,
|
|
1565
|
+
};
|
|
1559
1566
|
}
|
|
1560
1567
|
function publishUsage(next) {
|
|
1561
1568
|
if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
|
|
@@ -1563,11 +1570,24 @@ function createUsageFeed(opts) {
|
|
|
1563
1570
|
latest = next;
|
|
1564
1571
|
opts.broadcast({ type: 'usage', usage: next });
|
|
1565
1572
|
}
|
|
1573
|
+
function publishRead(read) {
|
|
1574
|
+
if (stopped)
|
|
1575
|
+
return;
|
|
1576
|
+
publishUsage(read.usage);
|
|
1577
|
+
if (!read.allowFollowUp || read.refreshAfterMs === null)
|
|
1578
|
+
return;
|
|
1579
|
+
budgetRetry.schedule(read.refreshAfterMs);
|
|
1580
|
+
}
|
|
1566
1581
|
// Focus, host relay, popover-open and run-finished can all arrive together.
|
|
1567
|
-
const refreshQueue = createRefreshQueue({ read: readUsage, publish:
|
|
1568
|
-
const
|
|
1569
|
-
|
|
1570
|
-
|
|
1582
|
+
const refreshQueue = createRefreshQueue({ read: readUsage, publish: publishRead });
|
|
1583
|
+
const budgetRetry = createBudgetRefreshRetry(() => requestRefresh(true, false, false));
|
|
1584
|
+
function requestRefresh(withCredits, allowFollowUp = withCredits, supersedeRetry = allowFollowUp) {
|
|
1585
|
+
if (stopped)
|
|
1586
|
+
return;
|
|
1587
|
+
if (supersedeRetry)
|
|
1588
|
+
budgetRetry.cancel();
|
|
1589
|
+
void refreshQueue.request(withCredits, allowFollowUp);
|
|
1590
|
+
}
|
|
1571
1591
|
const refresh = () => requestRefresh(true);
|
|
1572
1592
|
const stopRunWatch = onAgentRunFinished(refresh);
|
|
1573
1593
|
const timer = setInterval(() => {
|
|
@@ -1582,8 +1602,10 @@ function createUsageFeed(opts) {
|
|
|
1582
1602
|
latest: () => latest,
|
|
1583
1603
|
refresh,
|
|
1584
1604
|
stop: () => {
|
|
1605
|
+
stopped = true;
|
|
1585
1606
|
stopRunWatch();
|
|
1586
1607
|
clearInterval(timer);
|
|
1608
|
+
budgetRetry.dispose();
|
|
1587
1609
|
},
|
|
1588
1610
|
};
|
|
1589
1611
|
}
|
package/dist/metering.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface CastleBudget {
|
|
|
20
20
|
limitMicros: number | null;
|
|
21
21
|
resetAtMs: number;
|
|
22
22
|
blocked: boolean;
|
|
23
|
+
refreshAfterMs?: number;
|
|
23
24
|
blockedModelPrefixes: string[];
|
|
24
25
|
freeModelPrefixes: string[];
|
|
25
26
|
}
|
|
@@ -84,10 +85,16 @@ export declare function freeRolesFor(spends: readonly RoleSpend[], prefixes: rea
|
|
|
84
85
|
* calls: a cheap poll cannot downgrade a pending user-requested full refresh.
|
|
85
86
|
*/
|
|
86
87
|
export declare function createRefreshQueue<T>(opts: {
|
|
87
|
-
read: (withFullState: boolean) => Promise<T>;
|
|
88
|
+
read: (withFullState: boolean, allowFollowUp: boolean) => Promise<T>;
|
|
88
89
|
publish: (value: T) => void;
|
|
89
90
|
}): {
|
|
90
|
-
request: (withFullState: boolean) => Promise<void>;
|
|
91
|
+
request: (withFullState: boolean, allowFollowUp?: boolean) => Promise<void>;
|
|
92
|
+
};
|
|
93
|
+
export declare function budgetRefreshDelay(value: unknown): number | null;
|
|
94
|
+
export declare function createBudgetRefreshRetry(run: () => void): {
|
|
95
|
+
schedule: (afterMs: unknown) => void;
|
|
96
|
+
cancel: () => void;
|
|
97
|
+
dispose: () => void;
|
|
91
98
|
};
|
|
92
99
|
export declare function castleCreditsExhausted(budget: CastleBudget, credits: AiCredits | null): boolean;
|
|
93
100
|
/** Rich credit state, or null when ghost/proxy cannot provide the new field. */
|
|
@@ -102,7 +109,7 @@ export declare function fetchAiCredits(): Promise<AiCredits | null>;
|
|
|
102
109
|
* sentence instead of a spawned agent failing against a 403, and so the editor
|
|
103
110
|
* can show the bar. Neither is worth failing a run over.
|
|
104
111
|
*/
|
|
105
|
-
export declare function fetchBudget(): Promise<CastleBudget | null>;
|
|
112
|
+
export declare function fetchBudget(forceRefresh?: boolean): Promise<CastleBudget | null>;
|
|
106
113
|
export declare function meteringHeaders(opts: {
|
|
107
114
|
deckDir: string;
|
|
108
115
|
sessionId: string;
|
package/dist/metering.js
CHANGED
|
@@ -63,6 +63,7 @@ const CASTLE_BUDGET_PATH = "/castle/budget";
|
|
|
63
63
|
const CASTLE_API_PATH = "/castle/api/graphql";
|
|
64
64
|
// Read before a run starts, so it must not be able to hold one up for long.
|
|
65
65
|
const BUDGET_FETCH_TIMEOUT_MS = 5_000;
|
|
66
|
+
const MAX_BUDGET_REFRESH_AFTER_MS = 5_000;
|
|
66
67
|
export function spendableMicros(budget) {
|
|
67
68
|
if (budget.limitMicros === null)
|
|
68
69
|
return null;
|
|
@@ -130,23 +131,34 @@ export function freeRolesFor(spends, prefixes) {
|
|
|
130
131
|
export function createRefreshQueue(opts) {
|
|
131
132
|
let queued = false;
|
|
132
133
|
let queuedFullState = false;
|
|
134
|
+
let queuedFollowUp = false;
|
|
133
135
|
let active = null;
|
|
134
|
-
const request = (withFullState) => {
|
|
136
|
+
const request = (withFullState, allowFollowUp = true) => {
|
|
135
137
|
queued = true;
|
|
136
138
|
queuedFullState ||= withFullState;
|
|
139
|
+
queuedFollowUp ||= allowFollowUp;
|
|
137
140
|
if (active)
|
|
138
141
|
return active;
|
|
139
142
|
active = (async () => {
|
|
140
143
|
while (queued) {
|
|
141
144
|
const fullState = queuedFullState;
|
|
145
|
+
const followUp = queuedFollowUp;
|
|
142
146
|
queued = false;
|
|
143
147
|
queuedFullState = false;
|
|
144
|
-
|
|
148
|
+
queuedFollowUp = false;
|
|
149
|
+
const value = await opts.read(fullState, followUp);
|
|
145
150
|
// If another trigger arrived while this read was in flight, its result
|
|
146
151
|
// is already the one the caller wants. Skip the older intermediate
|
|
147
152
|
// frame instead of flashing it before the trailing refresh completes.
|
|
148
|
-
|
|
153
|
+
// Preserve its stronger intent, though: a cheap poll queued behind a
|
|
154
|
+
// full read must not swallow that read's retry hint.
|
|
155
|
+
if (queued) {
|
|
156
|
+
queuedFullState ||= fullState;
|
|
157
|
+
queuedFollowUp ||= followUp;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
149
160
|
opts.publish(value);
|
|
161
|
+
}
|
|
150
162
|
}
|
|
151
163
|
})().finally(() => {
|
|
152
164
|
active = null;
|
|
@@ -155,6 +167,40 @@ export function createRefreshQueue(opts) {
|
|
|
155
167
|
};
|
|
156
168
|
return { request };
|
|
157
169
|
}
|
|
170
|
+
export function budgetRefreshDelay(value) {
|
|
171
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
172
|
+
return null;
|
|
173
|
+
return Math.min(MAX_BUDGET_REFRESH_AFTER_MS, Math.ceil(value));
|
|
174
|
+
}
|
|
175
|
+
export function createBudgetRefreshRetry(run) {
|
|
176
|
+
let timer = null;
|
|
177
|
+
let disposed = false;
|
|
178
|
+
const cancel = () => {
|
|
179
|
+
if (timer)
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
timer = null;
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
schedule: (afterMs) => {
|
|
185
|
+
if (disposed)
|
|
186
|
+
return;
|
|
187
|
+
const delay = budgetRefreshDelay(afterMs);
|
|
188
|
+
cancel();
|
|
189
|
+
if (delay === null)
|
|
190
|
+
return;
|
|
191
|
+
timer = setTimeout(() => {
|
|
192
|
+
timer = null;
|
|
193
|
+
run();
|
|
194
|
+
}, delay + 50);
|
|
195
|
+
timer.unref?.();
|
|
196
|
+
},
|
|
197
|
+
cancel,
|
|
198
|
+
dispose: () => {
|
|
199
|
+
disposed = true;
|
|
200
|
+
cancel();
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
158
204
|
const EXHAUSTED_BALANCE_CREDITS = 50;
|
|
159
205
|
export function castleCreditsExhausted(budget, credits) {
|
|
160
206
|
if (!budget.blocked || credits?.plan !== "credits")
|
|
@@ -246,13 +292,14 @@ export async function fetchAiCredits() {
|
|
|
246
292
|
* sentence instead of a spawned agent failing against a 403, and so the editor
|
|
247
293
|
* can show the bar. Neither is worth failing a run over.
|
|
248
294
|
*/
|
|
249
|
-
export async function fetchBudget() {
|
|
295
|
+
export async function fetchBudget(forceRefresh = false) {
|
|
250
296
|
const base = process.env.CASTLE_LLM_PROXY_URL;
|
|
251
297
|
const token = process.env.CASTLE_LLM_PROXY_TOKEN;
|
|
252
298
|
if (!base || !token)
|
|
253
299
|
return null;
|
|
254
300
|
try {
|
|
255
|
-
const
|
|
301
|
+
const suffix = forceRefresh ? '?refresh=1' : '';
|
|
302
|
+
const resp = await fetch(`${base}${CASTLE_BUDGET_PATH}${suffix}`, {
|
|
256
303
|
headers: { authorization: `Bearer ${token}` },
|
|
257
304
|
signal: AbortSignal.timeout(BUDGET_FETCH_TIMEOUT_MS),
|
|
258
305
|
});
|
|
@@ -262,11 +309,13 @@ export async function fetchBudget() {
|
|
|
262
309
|
if (typeof body?.usedMicros !== "number" || typeof body.blocked !== "boolean") {
|
|
263
310
|
return null;
|
|
264
311
|
}
|
|
312
|
+
const refreshAfterMs = budgetRefreshDelay(body.refreshAfterMs);
|
|
265
313
|
return {
|
|
266
314
|
usedMicros: body.usedMicros,
|
|
267
315
|
limitMicros: typeof body.limitMicros === "number" ? body.limitMicros : null,
|
|
268
316
|
resetAtMs: typeof body.resetAtMs === "number" ? body.resetAtMs : 0,
|
|
269
317
|
blocked: body.blocked,
|
|
318
|
+
...(refreshAfterMs !== null ? { refreshAfterMs } : {}),
|
|
270
319
|
blockedModelPrefixes: Array.isArray(body.blockedModelPrefixes)
|
|
271
320
|
? body.blockedModelPrefixes.filter((p) => typeof p === "string")
|
|
272
321
|
: [],
|