dsh-agy 0.2.12 → 0.3.0
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 +67 -11
- package/cordis.patch.yml +4 -2
- package/lib/{accounts-DB1tT9Gy.mjs → accounts-CkaHhb5H.mjs} +1155 -646
- package/lib/accounts-CkaHhb5H.mjs.map +1 -0
- package/lib/{catalog-bfGIyoba.mjs → catalog-BgdDVJEB.mjs} +17 -2
- package/lib/{catalog-bfGIyoba.mjs.map → catalog-BgdDVJEB.mjs.map} +1 -1
- package/lib/cli/index.d.mts +10 -1
- package/lib/cli/index.d.mts.map +1 -1
- package/lib/cli/index.mjs +21 -8
- package/lib/cli/index.mjs.map +1 -1
- package/lib/client.js +2168 -14
- package/lib/{constants-BFKYjOmh.mjs → constants-Db4tmyfb.mjs} +79 -14
- package/lib/constants-Db4tmyfb.mjs.map +1 -0
- package/lib/{exchange-dQm2mjJ5.mjs → exchange-yZPIZumk.mjs} +27 -12
- package/lib/exchange-yZPIZumk.mjs.map +1 -0
- package/lib/identity-Ci8EGc1F.mjs +96 -0
- package/lib/identity-Ci8EGc1F.mjs.map +1 -0
- package/lib/{import-D7R0GpRr.mjs → import-BaFULQnH.mjs} +11 -7
- package/lib/import-BaFULQnH.mjs.map +1 -0
- package/lib/index.mjs +2 -2
- package/lib/{models-ClGTQBLY.mjs → models-BGuwl50d.mjs} +35 -22
- package/lib/models-BGuwl50d.mjs.map +1 -0
- package/lib/plugin-common-B1MG00JT.mjs +1313 -0
- package/lib/plugin-common-B1MG00JT.mjs.map +1 -0
- package/lib/{proxy-DLvX8yxv.mjs → proxy-DfaL73mL.mjs} +52 -3
- package/lib/proxy-DfaL73mL.mjs.map +1 -0
- package/lib/quota-summary-5CypVffd.mjs +127 -0
- package/lib/quota-summary-5CypVffd.mjs.map +1 -0
- package/lib/{translate-D3GFUpw4.mjs → translate-BQu8HLvB.mjs} +25 -6
- package/lib/translate-BQu8HLvB.mjs.map +1 -0
- package/lib/web/plugin.d.mts +1 -0
- package/lib/web/plugin.d.mts.map +1 -1
- package/lib/web/plugin.mjs +651 -1701
- package/lib/web/plugin.mjs.map +1 -1
- package/package.json +16 -4
- package/lib/accounts-DB1tT9Gy.mjs.map +0 -1
- package/lib/constants-BFKYjOmh.mjs.map +0 -1
- package/lib/exchange-dQm2mjJ5.mjs.map +0 -1
- package/lib/identity-DsftHTJC.mjs +0 -35
- package/lib/identity-DsftHTJC.mjs.map +0 -1
- package/lib/import-D7R0GpRr.mjs.map +0 -1
- package/lib/models-ClGTQBLY.mjs.map +0 -1
- package/lib/plugin-common-B63uavj7.mjs +0 -254
- package/lib/plugin-common-B63uavj7.mjs.map +0 -1
- package/lib/proxy-DLvX8yxv.mjs.map +0 -1
- package/lib/translate-D3GFUpw4.mjs.map +0 -1
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
import { m as fetchAgyFirstOk, v as AgyAuthError, y as AgyPoolBlockedError } from "./constants-Db4tmyfb.mjs";
|
|
2
|
+
import { a as probeFetch, o as proxiedFetch, t as accountFetch } from "./proxy-DfaL73mL.mjs";
|
|
3
|
+
import { _ as resolveMasterKeyCodec, b as describeFetchError, d as MASTER_KEY_REF, f as createAesGcmCodec, g as resolveDshHome, h as persistMasterKey, m as loadMasterKey, p as deriveKey, r as AgySessionManager, s as resolveAntigravityVersion, t as JsonAccountStore, u as pickProbeProxyUrl, v as classifyFetchError, x as isSessionAccumulationOverflow, y as classifyHttpError } from "./accounts-CkaHhb5H.mjs";
|
|
4
|
+
import { i as generateAntigravityRequestId, n as currentSessionGeneration, r as deriveAntigravitySessionId, t as bumpSessionGeneration } from "./identity-Ci8EGc1F.mjs";
|
|
5
|
+
import { i as setThoughtSignature, r as resolveMultimodalFiles, t as toAgyRequestBody } from "./translate-BQu8HLvB.mjs";
|
|
6
|
+
import { t as parseAgySse } from "./parse-Bs4rPkHA.mjs";
|
|
7
|
+
import { i as resolveAgyModel, n as listAgyModels, t as catalogModelList } from "./models-BGuwl50d.mjs";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from "@deepseek-ai/dsh-llm";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import lockfile from "proper-lockfile";
|
|
13
|
+
//#region src/adapter/adapter.ts
|
|
14
|
+
/**
|
|
15
|
+
* AgyAdapter: the DSH seam. A thin orchestrator over the deep modules —
|
|
16
|
+
* account session resolution (shell-provided), request translation, SSE
|
|
17
|
+
* parsing, failure classification, and rotation reporting. All wire details
|
|
18
|
+
* live in translate.ts / parse.ts / models.ts.
|
|
19
|
+
*/
|
|
20
|
+
/** Collect image refs from user-message content only (spec scope: user images; tool-result nesting out of scope). */
|
|
21
|
+
function collectImageRefs(options) {
|
|
22
|
+
const refs = [];
|
|
23
|
+
for (const message of options.messages) {
|
|
24
|
+
if (message.role !== "user") continue;
|
|
25
|
+
for (const block of message.content) if (block.type === "image") refs.push(block.attachment);
|
|
26
|
+
}
|
|
27
|
+
return refs;
|
|
28
|
+
}
|
|
29
|
+
const UPSTREAM_ERROR_CODE = "UPSTREAM";
|
|
30
|
+
/** First-class DSH retryable code: the default retry policy honors SERVER (5xx), not UPSTREAM. */
|
|
31
|
+
const SERVER_ERROR_CODE = "SERVER";
|
|
32
|
+
/** Stable ledger key for an account: email when present, else the generated id. */
|
|
33
|
+
function ledgerAccountKey(session) {
|
|
34
|
+
return session.account.email ?? session.account.id;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the impersonation headers for one request.
|
|
38
|
+
*
|
|
39
|
+
* There is NO request-id header. `x-goog-request-id` used to be sent here and is
|
|
40
|
+
* present in neither official binary; the id the backend correlates on is the
|
|
41
|
+
* body's `requestId` field, which this request already carries. An invented
|
|
42
|
+
* header is a shape no official client emits, which is precisely the anomaly the
|
|
43
|
+
* impersonation set exists to avoid.
|
|
44
|
+
*
|
|
45
|
+
* `User-Agent` carries the Antigravity client string and NOTHING else. The
|
|
46
|
+
* harness's `attributionHeaders()` is deliberately not merged in: it returns a
|
|
47
|
+
* lowercase `user-agent` key, so spreading it alongside the camel-case
|
|
48
|
+
* `session.impersonation` produced TWO distinct object properties that `fetch`
|
|
49
|
+
* folded into one comma-joined value —
|
|
50
|
+
*
|
|
51
|
+
* `deepseek-harness/<v> (+url), antigravity/<v> <platform>`
|
|
52
|
+
*
|
|
53
|
+
* — a header no official client can emit, byte-identical for every dsh-agy
|
|
54
|
+
* user, and therefore a stronger fingerprint than the one it was trying to
|
|
55
|
+
* avoid. There is exactly one `User-Agent` field on the wire and the upstream
|
|
56
|
+
* requires it to be the client identity, so this request cannot carry both.
|
|
57
|
+
*
|
|
58
|
+
* This adapter performs its own dispatch (see the `fetchAgyFirstOk` call in
|
|
59
|
+
* `stream()`); nothing downstream re-adds the header.
|
|
60
|
+
*/
|
|
61
|
+
function buildRequestHeaders(session) {
|
|
62
|
+
return {
|
|
63
|
+
authorization: `Bearer ${session.auth.access}`,
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
accept: "text/event-stream",
|
|
66
|
+
"User-Agent": session.impersonation["User-Agent"],
|
|
67
|
+
"X-Goog-Api-Client": session.impersonation["X-Goog-Api-Client"]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
var AgyAdapter = class extends LlmAdapter {
|
|
71
|
+
options;
|
|
72
|
+
constructor(options) {
|
|
73
|
+
super();
|
|
74
|
+
this.options = options;
|
|
75
|
+
}
|
|
76
|
+
providerInfo(_provider) {
|
|
77
|
+
return {
|
|
78
|
+
id: "agy",
|
|
79
|
+
name: "Antigravity (agy)"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The catalog as DSH's model selector sees it: discovered models minus the
|
|
84
|
+
* user's hidden set. Filtering here is what makes "turn a model off" hide it
|
|
85
|
+
* from the picker — the selector reads this, and DSH itself is untouched.
|
|
86
|
+
*/
|
|
87
|
+
async listModels(_provider) {
|
|
88
|
+
const all = await this.listAllModels();
|
|
89
|
+
const hidden = this.options.modelVisibility?.disabledFor("agy");
|
|
90
|
+
if (hidden === void 0 || hidden.size === 0) return all;
|
|
91
|
+
return all.filter((model) => !hidden.has(model.id));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The complete catalog, ignoring the user's hidden set.
|
|
95
|
+
*
|
|
96
|
+
* The settings page lists models through this rather than `listModels`: if it
|
|
97
|
+
* read the filtered list, a hidden model would vanish from the page along
|
|
98
|
+
* with the switch that hides it, leaving no way to turn it back on without
|
|
99
|
+
* hand-editing the file.
|
|
100
|
+
*/
|
|
101
|
+
async listAllModels() {
|
|
102
|
+
try {
|
|
103
|
+
const session = await this.options.getSession();
|
|
104
|
+
const routing = { proxyUrl: session?.account.proxy };
|
|
105
|
+
return await listAgyModels(session?.auth.access, session?.account.projectId, accountFetch(routing));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof AgyPoolBlockedError || error instanceof AgyAuthError) return catalogModelList();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async resolveModel(provider, model) {
|
|
112
|
+
return resolveAgyModel(provider, model);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Pre-resolve every image attachment into base64 bytes before translation.
|
|
116
|
+
* Image input hard-fails with UNSUPPORTED_CONTENT (terminal, never retried)
|
|
117
|
+
* when the store is missing or a read fails — silently dropping images and
|
|
118
|
+
* sending text-only is the exact failure mode this path exists to prevent.
|
|
119
|
+
*/
|
|
120
|
+
async resolveRequestImages(options) {
|
|
121
|
+
const refs = collectImageRefs(options);
|
|
122
|
+
const images = /* @__PURE__ */ new Map();
|
|
123
|
+
if (refs.length === 0) return images;
|
|
124
|
+
const store = this.options.resolveAttachments?.();
|
|
125
|
+
if (!store) throw new LlmError("agy image input requires the durable attachment service (in-harness plugin only)", "UNSUPPORTED_CONTENT");
|
|
126
|
+
const settled = await Promise.allSettled(refs.map(async (ref) => {
|
|
127
|
+
const stored = await store.readImage(ref);
|
|
128
|
+
return {
|
|
129
|
+
attachmentId: ref.attachmentId,
|
|
130
|
+
image: {
|
|
131
|
+
mediaType: stored.ref.mediaType,
|
|
132
|
+
data: Buffer.from(stored.data).toString("base64")
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}));
|
|
136
|
+
const failedIndex = settled.findIndex((outcome) => outcome.status === "rejected");
|
|
137
|
+
if (failedIndex !== -1) {
|
|
138
|
+
const ref = refs[failedIndex];
|
|
139
|
+
const cause = settled[failedIndex].reason;
|
|
140
|
+
throw new LlmError(`agy image attachment "${ref.attachmentId}" could not be loaded: ${cause instanceof Error ? cause.message : String(cause)}`, "UNSUPPORTED_CONTENT", { cause: cause instanceof Error ? cause : void 0 });
|
|
141
|
+
}
|
|
142
|
+
for (const outcome of settled) if (outcome.status === "fulfilled") images.set(outcome.value.attachmentId, outcome.value.image);
|
|
143
|
+
return images;
|
|
144
|
+
}
|
|
145
|
+
async *stream(options) {
|
|
146
|
+
const holder = {};
|
|
147
|
+
try {
|
|
148
|
+
yield* this.streamInner(options, holder);
|
|
149
|
+
} finally {
|
|
150
|
+
if (holder.account !== void 0) this.options.noteRequestSettled?.(holder.account);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async *streamInner(options, holder) {
|
|
154
|
+
const images = await this.resolveRequestImages(options);
|
|
155
|
+
/**
|
|
156
|
+
* The DSH agent loop stamps `options.sessionId` on every request it builds,
|
|
157
|
+
* so it is the conversation identity — used both to scope account affinity
|
|
158
|
+
* here and to derive the upstream `sessionId` below.
|
|
159
|
+
*/
|
|
160
|
+
const conversationKey = options.sessionId === void 0 ? void 0 : String(options.sessionId);
|
|
161
|
+
let session;
|
|
162
|
+
try {
|
|
163
|
+
session = await this.options.getSession(options.model, conversationKey);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof AgyAuthError) {
|
|
166
|
+
if (error.kind === "transport") throw new LlmError(error.message, "TRANSPORT", { cause: error });
|
|
167
|
+
if (error.kind === "rate-limit") throw new LlmError(error.message, "RATE_LIMIT", { requestId: ProviderRequestId(generateAntigravityRequestId()) });
|
|
168
|
+
throw new LlmError(error.message, "INVALID_CREDENTIAL", { cause: error });
|
|
169
|
+
}
|
|
170
|
+
if (error instanceof AgyPoolBlockedError) {
|
|
171
|
+
if (error.kind === "quota-exhausted") throw new LlmError(error.message, QUOTA_EXCEEDED_CODE);
|
|
172
|
+
const delta = Math.ceil(error.blockedUntil - Date.now());
|
|
173
|
+
const providerRetryAfterMs = Number.isFinite(delta) && delta > 0 ? delta : 1;
|
|
174
|
+
throw new LlmError(error.message, "RATE_LIMIT", {
|
|
175
|
+
providerRetryAfterMs,
|
|
176
|
+
requestId: ProviderRequestId(generateAntigravityRequestId())
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
if (!session) throw new LlmError("No agy account configured — run `dsh-agy login` to authenticate.", "NO_CREDENTIAL");
|
|
182
|
+
const multimodalFiles = await resolveMultimodalFiles(options);
|
|
183
|
+
holder.account = session.account;
|
|
184
|
+
this.options.noteRequestStarted?.(session.account);
|
|
185
|
+
const conversationAccount = ledgerAccountKey(session);
|
|
186
|
+
/** Wall-clock origin for this attempt's latency figures. */
|
|
187
|
+
const startedAt = Date.now();
|
|
188
|
+
const routing = {
|
|
189
|
+
proxyUrl: session.account.proxy,
|
|
190
|
+
streaming: true
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Send one request, resending at most once when the upstream reports the
|
|
194
|
+
* per-`sessionId` accumulation wall.
|
|
195
|
+
*
|
|
196
|
+
* The upstream accumulates a conversation's input server-side per
|
|
197
|
+
* `sessionId`; once that passes 1M tokens every request reusing the id fails
|
|
198
|
+
* with a 400 until the upstream session expires. Bumping the generation
|
|
199
|
+
* names a fresh upstream session and recovers the conversation. This is not
|
|
200
|
+
* an account fault, so it does not go through `reportFailure`: the account
|
|
201
|
+
* stays healthy and only the derived id changes.
|
|
202
|
+
*/
|
|
203
|
+
const sendAttempt = async () => {
|
|
204
|
+
for (let attempt = 0;; attempt++) {
|
|
205
|
+
const generation = conversationKey !== void 0 && conversationAccount !== void 0 ? currentSessionGeneration(conversationAccount, conversationKey) : 0;
|
|
206
|
+
const requestId = generateAntigravityRequestId();
|
|
207
|
+
const body = toAgyRequestBody(options, {
|
|
208
|
+
projectId: session.account.projectId,
|
|
209
|
+
sessionId: deriveAntigravitySessionId(session.account.email, conversationKey, generation) ?? void 0,
|
|
210
|
+
requestId,
|
|
211
|
+
...this.options.thinkingBudgetFor === void 0 ? {} : { thinkingBudgetFor: this.options.thinkingBudgetFor },
|
|
212
|
+
...this.options.claudeBudgetFor === void 0 ? {} : { claudeBudgetFor: this.options.claudeBudgetFor },
|
|
213
|
+
...this.options.tieredBudgetFor === void 0 ? {} : { tieredBudgetFor: this.options.tieredBudgetFor },
|
|
214
|
+
...images.size > 0 ? { images } : {},
|
|
215
|
+
...multimodalFiles.size > 0 ? { multimodalFiles } : {}
|
|
216
|
+
});
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await fetchAgyFirstOk("/v1internal:streamGenerateContent?alt=sse", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: buildRequestHeaders(session),
|
|
222
|
+
body: JSON.stringify(body),
|
|
223
|
+
signal: options.signal
|
|
224
|
+
}, accountFetch(routing), routing);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const classified = classifyFetchError(error, { proxyUrl: session.account.proxy });
|
|
227
|
+
await this.options.reportFailure(classified.kind, session);
|
|
228
|
+
this.recordUsage(session, options.model, { ok: false }, startedAt);
|
|
229
|
+
throw new LlmError(classified.message ?? "agy fetch failed", "TRANSPORT", { cause: error });
|
|
230
|
+
}
|
|
231
|
+
if (response.ok) return { response };
|
|
232
|
+
const bodyText = await response.text().catch(() => void 0);
|
|
233
|
+
if (attempt === 0 && conversationKey !== void 0 && conversationAccount !== void 0 && isSessionAccumulationOverflow(response.status, bodyText)) {
|
|
234
|
+
bumpSessionGeneration(conversationAccount, conversationKey);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
response,
|
|
239
|
+
bodyText
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const { response, bodyText } = await sendAttempt();
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
const classified = classifyHttpError(response.status, response.headers, bodyText);
|
|
246
|
+
await this.options.reportFailure(classified.kind, session, {
|
|
247
|
+
retryAfterMs: classified.retryAfterMs,
|
|
248
|
+
status: response.status,
|
|
249
|
+
rateLimitCategory: classified.rateLimitCategory,
|
|
250
|
+
resetTime: classified.resetTime,
|
|
251
|
+
model: options.model,
|
|
252
|
+
verificationUrl: classified.verificationUrl
|
|
253
|
+
});
|
|
254
|
+
this.recordUsage(session, options.model, {
|
|
255
|
+
ok: false,
|
|
256
|
+
rateLimited: classified.kind === "rate-limit"
|
|
257
|
+
}, startedAt);
|
|
258
|
+
if (classified.kind === "rate-limit") {
|
|
259
|
+
if (classified.rateLimitCategory === "quota_exhausted") throw new LlmError(`agy daily quota exhausted (${response.status}): ${classified.message ?? ""}`, QUOTA_EXCEEDED_CODE);
|
|
260
|
+
throw new LlmError(`agy rate-limited (${response.status}): ${classified.message ?? ""}`, "RATE_LIMIT", {
|
|
261
|
+
providerRetryAfterMs: classified.retryAfterMs ?? void 0,
|
|
262
|
+
requestId: ProviderRequestId(generateAntigravityRequestId())
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (classified.kind === "verification-required") {
|
|
266
|
+
const appeal = classified.verificationUrl ? ` Verify at: ${classified.verificationUrl}` : "";
|
|
267
|
+
throw new LlmError(`agy account needs verification (${response.status}): ${classified.message ?? ""}${appeal}`, "RATE_LIMIT", { requestId: ProviderRequestId(generateAntigravityRequestId()) });
|
|
268
|
+
}
|
|
269
|
+
if (classified.kind === "auth-failure") throw new LlmError(`agy authentication failed (${response.status}) — run \`dsh-agy login\``, "INVALID_CREDENTIAL");
|
|
270
|
+
if (classified.status !== void 0 && classified.status >= 500) throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, SERVER_ERROR_CODE, {
|
|
271
|
+
providerRetryAfterMs: classified.retryAfterMs ?? void 0,
|
|
272
|
+
requestId: ProviderRequestId(generateAntigravityRequestId())
|
|
273
|
+
});
|
|
274
|
+
throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, UPSTREAM_ERROR_CODE);
|
|
275
|
+
}
|
|
276
|
+
if (!response.body) throw new LlmError("agy stream returned no body", UPSTREAM_ERROR_CODE);
|
|
277
|
+
try {
|
|
278
|
+
let usage;
|
|
279
|
+
let ttftMs;
|
|
280
|
+
for await (const chunk of parseAgySse(response.body, {
|
|
281
|
+
signal: options.signal,
|
|
282
|
+
onToolSignature: (toolCallId, signature) => {
|
|
283
|
+
setThoughtSignature(toolCallId, signature);
|
|
284
|
+
}
|
|
285
|
+
})) {
|
|
286
|
+
if (chunk.type === "usage") usage = {
|
|
287
|
+
input: chunk.usage.inputTokens,
|
|
288
|
+
output: chunk.usage.outputTokens,
|
|
289
|
+
cacheRead: chunk.usage.cacheReadTokens ?? 0,
|
|
290
|
+
cacheWrite: chunk.usage.cacheWriteTokens ?? 0
|
|
291
|
+
};
|
|
292
|
+
else if (ttftMs === void 0 && (chunk.type === "text-delta" || chunk.type === "reasoning-delta")) ttftMs = Date.now() - startedAt;
|
|
293
|
+
yield chunk;
|
|
294
|
+
}
|
|
295
|
+
await this.options.markSuccess?.(session);
|
|
296
|
+
this.recordUsage(session, options.model, {
|
|
297
|
+
ok: true,
|
|
298
|
+
usage,
|
|
299
|
+
ttftMs
|
|
300
|
+
}, startedAt);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (error instanceof DOMException && error.name === "AbortError") throw new LlmError("agy stream aborted", "ABORTED", { cause: error });
|
|
303
|
+
await this.options.reportFailure("network-error", session);
|
|
304
|
+
this.recordUsage(session, options.model, { ok: false }, startedAt);
|
|
305
|
+
throw new LlmError(error instanceof Error ? describeFetchError(error) : "agy stream parse failed", UPSTREAM_ERROR_CODE, { cause: error });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Fold one attempt into the usage ledger.
|
|
310
|
+
*
|
|
311
|
+
* Statistics are diagnostics, never load-bearing: a ledger failure must not
|
|
312
|
+
* fail a generation, so this swallows its own errors.
|
|
313
|
+
*/
|
|
314
|
+
recordUsage(session, model, result, startedAt) {
|
|
315
|
+
const record = this.options.recordUsage;
|
|
316
|
+
if (record === void 0) return;
|
|
317
|
+
try {
|
|
318
|
+
record({
|
|
319
|
+
...ledgerAccountKey(session) === void 0 ? {} : { account: ledgerAccountKey(session) },
|
|
320
|
+
...model === void 0 ? {} : { model },
|
|
321
|
+
ok: result.ok,
|
|
322
|
+
...result.rateLimited === true ? { rateLimited: true } : {},
|
|
323
|
+
...result.usage === void 0 ? {} : { usage: result.usage },
|
|
324
|
+
...result.ttftMs === void 0 ? {} : { ttftMs: result.ttftMs },
|
|
325
|
+
latencyMs: Math.max(0, Date.now() - startedAt)
|
|
326
|
+
});
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
const EMPTY_SET = /* @__PURE__ */ new Set();
|
|
331
|
+
/** Keep only well-formed `provider -> { modelId: true }` entries (tolerates hand edits). */
|
|
332
|
+
function sanitizeDisabledModels(raw) {
|
|
333
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
334
|
+
const out = {};
|
|
335
|
+
for (const [provider, value] of Object.entries(raw)) {
|
|
336
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
337
|
+
const perProvider = {};
|
|
338
|
+
for (const [modelId, flag] of Object.entries(value)) if (flag === true) perProvider[modelId] = true;
|
|
339
|
+
if (Object.keys(perProvider).length > 0) out[provider] = perProvider;
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
function parseModelVisibility(text) {
|
|
344
|
+
let raw;
|
|
345
|
+
try {
|
|
346
|
+
raw = JSON.parse(text);
|
|
347
|
+
} catch {
|
|
348
|
+
return {
|
|
349
|
+
version: 1,
|
|
350
|
+
disabled: {}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
version: 1,
|
|
355
|
+
disabled: typeof raw === "object" && raw !== null ? sanitizeDisabledModels(raw.disabled) : {}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* In-memory authoritative copy of the blacklist, backed by a JSON file.
|
|
360
|
+
*
|
|
361
|
+
* The adapter calls `disabledFor()` on every `listModels()`, so that read is a
|
|
362
|
+
* pure in-memory lookup: a toggle takes effect on the next catalog refresh
|
|
363
|
+
* without rebuilding the adapter or restarting the host. Writes go through the
|
|
364
|
+
* file immediately (toggles are rare, user-initiated).
|
|
365
|
+
*
|
|
366
|
+
* `disabledFor()` also revalidates against the file's mtime and reloads when it
|
|
367
|
+
* moved. Two instances of this class routinely coexist in ONE process — the
|
|
368
|
+
* main plugin entry and the web entry each build their own runtime — and a
|
|
369
|
+
* toggle is written by the web instance while the model selector reads the main
|
|
370
|
+
* instance. Without the mtime check the selector would keep serving its stale
|
|
371
|
+
* in-memory copy, so switching a model off appeared to do nothing until the
|
|
372
|
+
* host restarted (the `llm/adapters-updated` notification fires, the picker
|
|
373
|
+
* refreshes, and the model is still listed). The same check is what lets a CLI
|
|
374
|
+
* toggle be seen by a running server.
|
|
375
|
+
*/
|
|
376
|
+
var ModelVisibility = class ModelVisibility {
|
|
377
|
+
file;
|
|
378
|
+
disabled;
|
|
379
|
+
/** Raw text this instance last read or wrote; see `reloadIfChanged`. */
|
|
380
|
+
raw;
|
|
381
|
+
/** Reusable frozen empty set, so the common "nothing disabled" read allocates nothing. */
|
|
382
|
+
sets = /* @__PURE__ */ new Map();
|
|
383
|
+
constructor(options = {}) {
|
|
384
|
+
this.file = options.file ?? join(resolveDshHome(), "agy-models.json");
|
|
385
|
+
const initial = this.readWithRaw();
|
|
386
|
+
this.disabled = initial.disabled;
|
|
387
|
+
this.raw = initial.raw;
|
|
388
|
+
}
|
|
389
|
+
/** Path of the backing file. */
|
|
390
|
+
get path() {
|
|
391
|
+
return this.file;
|
|
392
|
+
}
|
|
393
|
+
/** The exact bytes `write()` would persist, so `raw` can track what this instance wrote. */
|
|
394
|
+
static serialize(next) {
|
|
395
|
+
return JSON.stringify({
|
|
396
|
+
version: 1,
|
|
397
|
+
disabled: next
|
|
398
|
+
}, null, 2) + "\n";
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Read the map together with the raw text it came from.
|
|
402
|
+
*
|
|
403
|
+
* The TEXT (not an mtime) is what detects another writer's change. An mtime
|
|
404
|
+
* cannot: a toggle and a re-read can land in the same filesystem timestamp
|
|
405
|
+
* tick, and Windows resolves that coarsely enough to have failed CI — two
|
|
406
|
+
* distinct writes reported an identical mtime, so the second change was never
|
|
407
|
+
* seen and a disabled model stayed selectable. Content comparison cannot miss
|
|
408
|
+
* a change, whatever the clock resolution.
|
|
409
|
+
*/
|
|
410
|
+
readWithRaw() {
|
|
411
|
+
try {
|
|
412
|
+
const raw = readFileSync(this.file, "utf8");
|
|
413
|
+
return {
|
|
414
|
+
disabled: parseModelVisibility(raw).disabled,
|
|
415
|
+
raw
|
|
416
|
+
};
|
|
417
|
+
} catch {
|
|
418
|
+
return {
|
|
419
|
+
disabled: {},
|
|
420
|
+
raw: ""
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
write(next) {
|
|
425
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
426
|
+
const tmp = `${this.file}.tmp`;
|
|
427
|
+
writeFileSync(tmp, ModelVisibility.serialize(next), { mode: 384 });
|
|
428
|
+
renameSync(tmp, this.file);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Disabled model ids for one provider. Cheap enough for every catalog refresh:
|
|
432
|
+
* one `stat` plus an in-memory map lookup, and the stat is what keeps two
|
|
433
|
+
* same-process instances (and a CLI writer) in agreement.
|
|
434
|
+
*/
|
|
435
|
+
disabledFor(provider) {
|
|
436
|
+
this.reloadIfChanged();
|
|
437
|
+
const cacheKey = provider;
|
|
438
|
+
const cached = this.sets.get(cacheKey);
|
|
439
|
+
if (cached !== void 0) return cached;
|
|
440
|
+
const perProvider = this.disabled[provider];
|
|
441
|
+
if (perProvider === void 0) return EMPTY_SET;
|
|
442
|
+
const ids = Object.keys(perProvider);
|
|
443
|
+
if (ids.length === 0) return EMPTY_SET;
|
|
444
|
+
const set = new Set(ids);
|
|
445
|
+
this.sets.set(cacheKey, set);
|
|
446
|
+
return set;
|
|
447
|
+
}
|
|
448
|
+
/** Whether one model is currently hidden. Also revalidates, for the same reason. */
|
|
449
|
+
isDisabled(provider, modelId) {
|
|
450
|
+
this.reloadIfChanged();
|
|
451
|
+
return this.disabled[provider]?.[modelId] === true;
|
|
452
|
+
}
|
|
453
|
+
/** The raw map, for the settings UI. */
|
|
454
|
+
all() {
|
|
455
|
+
const out = {};
|
|
456
|
+
for (const [provider, models] of Object.entries(this.disabled)) out[provider] = { ...models };
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Hide or show one model. Persists immediately.
|
|
461
|
+
*
|
|
462
|
+
* The edit is applied to a freshly re-read map, not to this instance's copy:
|
|
463
|
+
* with two instances in one process (main plugin + web entry) and a CLI in
|
|
464
|
+
* another, editing the stale copy would silently drop whatever the other
|
|
465
|
+
* writer had toggled since this instance last read. Toggles are rare and
|
|
466
|
+
* user-initiated, so a read-modify-write costs nothing here — unlike the
|
|
467
|
+
* ledger, this is not a hot path.
|
|
468
|
+
*
|
|
469
|
+
* Not locked: a simultaneous toggle from two processes can still lose one of
|
|
470
|
+
* the two updates. The write itself is atomic (tmp + rename), so the file is
|
|
471
|
+
* never corrupt — losing one toggle under a genuine race is an acceptable
|
|
472
|
+
* trade for not introducing a lock file into a settings toggle.
|
|
473
|
+
*/
|
|
474
|
+
setDisabled(provider, modelId, disabled) {
|
|
475
|
+
if (modelId === "") throw new Error("setDisabled: modelId must not be empty");
|
|
476
|
+
this.reload();
|
|
477
|
+
const next = {};
|
|
478
|
+
for (const [key, models] of Object.entries(this.disabled)) next[key] = { ...models };
|
|
479
|
+
const perProvider = { ...next[provider] ?? {} };
|
|
480
|
+
if (disabled) perProvider[modelId] = true;
|
|
481
|
+
else delete perProvider[modelId];
|
|
482
|
+
if (Object.keys(perProvider).length === 0) delete next[provider];
|
|
483
|
+
else next[provider] = perProvider;
|
|
484
|
+
this.write(next);
|
|
485
|
+
this.disabled = next;
|
|
486
|
+
this.raw = ModelVisibility.serialize(next);
|
|
487
|
+
this.sets.delete(provider);
|
|
488
|
+
}
|
|
489
|
+
/** Re-read the file (multi-process: another writer may have toggled a model). */
|
|
490
|
+
reload() {
|
|
491
|
+
const fresh = this.readWithRaw();
|
|
492
|
+
this.disabled = fresh.disabled;
|
|
493
|
+
this.raw = fresh.raw;
|
|
494
|
+
this.sets.clear();
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Reload when the file's contents differ from what this instance last read.
|
|
498
|
+
*
|
|
499
|
+
* Reads rather than stats: `disabledFor()` runs once per catalog refresh, not
|
|
500
|
+
* per request, and the call it feeds is a network request to the model
|
|
501
|
+
* endpoint — one small local read is not the cost that matters here, while a
|
|
502
|
+
* missed change is the bug this whole mechanism exists to prevent.
|
|
503
|
+
*/
|
|
504
|
+
reloadIfChanged() {
|
|
505
|
+
let current;
|
|
506
|
+
try {
|
|
507
|
+
current = readFileSync(this.file, "utf8");
|
|
508
|
+
} catch {
|
|
509
|
+
current = "";
|
|
510
|
+
}
|
|
511
|
+
if (current === this.raw) return;
|
|
512
|
+
this.reload();
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/thinking-types.ts
|
|
517
|
+
/**
|
|
518
|
+
* Thinking-budget vocabulary shared by the host store, the RPC wire contract,
|
|
519
|
+
* and the browser UI.
|
|
520
|
+
*
|
|
521
|
+
* Split out for the same reason `usage-types.ts` exists: these types live on
|
|
522
|
+
* their own, free of any `node:*` import, so the browser bundle can describe the
|
|
523
|
+
* settings surface without pulling in the host's storage module. Putting them in
|
|
524
|
+
* `thinking-budget.ts` instead made `rpc-contract.ts` (which the client
|
|
525
|
+
* typechecks) reach `node:fs` through the transitive import — caught by
|
|
526
|
+
* `tsconfig.client.json`, which includes the contract and excludes `src/store`.
|
|
527
|
+
*/
|
|
528
|
+
/** The three levels a tiered model exposes, in display order. */
|
|
529
|
+
const THINKING_LEVELS = [
|
|
530
|
+
"low",
|
|
531
|
+
"medium",
|
|
532
|
+
"high"
|
|
533
|
+
];
|
|
534
|
+
const THINKING_BUDGET_MAX = 65535;
|
|
535
|
+
/**
|
|
536
|
+
* The Claude family's `thinkingBudget` bounds, measured separately from Gemini's.
|
|
537
|
+
*
|
|
538
|
+
* The two families do NOT share a contract, which is why one interval cannot
|
|
539
|
+
* serve both:
|
|
540
|
+
* - Claude's floor is **1024**, not `-1`. A budget of 1 or 512 is rejected with
|
|
541
|
+
* `thinking.enabled.budget_tokens: Input should be greater than or equal to
|
|
542
|
+
* 1024`; `-1` and `0` are accepted as special values.
|
|
543
|
+
* - Claude additionally requires **`max_tokens` strictly greater than the
|
|
544
|
+
* budget**: `budget=1024, max_tokens=1024` is a 400, and a budget sent with
|
|
545
|
+
* no `maxOutputTokens` at all also fails. So the margin is at least one token.
|
|
546
|
+
*
|
|
547
|
+
* `CLAUDE_BUDGET_MAX` is therefore one below `AGY_CLAUDE_MAX_OUTPUT_TOKENS` — the
|
|
548
|
+
* largest budget that can still leave room for a strictly greater `max_tokens`.
|
|
549
|
+
*/
|
|
550
|
+
const CLAUDE_BUDGET_MIN = 1024;
|
|
551
|
+
const CLAUDE_BUDGET_MAX = 63999;
|
|
552
|
+
/** Whether `value` may be sent as a Claude `thinkingBudget`. */
|
|
553
|
+
function isValidClaudeBudget(value) {
|
|
554
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return false;
|
|
555
|
+
if (value === -1 || value === 0) return true;
|
|
556
|
+
return value >= 1024 && value <= 63999;
|
|
557
|
+
}
|
|
558
|
+
/** Whether `value` may be sent as a `thinkingBudget`. */
|
|
559
|
+
function isValidThinkingBudget(value) {
|
|
560
|
+
return typeof value === "number" && Number.isInteger(value) && value >= -1 && value <= 65535;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Keep only well-formed entries (tolerates hand edits).
|
|
564
|
+
*
|
|
565
|
+
* Rejects out-of-range and non-integer values rather than clamping them: a
|
|
566
|
+
* clamped value would silently mean something the user did not ask for, and
|
|
567
|
+
* upstream rejects it anyway, so dropping the entry falls back to the
|
|
568
|
+
* `thinkingLevel` path — which is valid.
|
|
569
|
+
*/
|
|
570
|
+
function sanitizeThinkingBudgets(raw) {
|
|
571
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
572
|
+
const out = {};
|
|
573
|
+
for (const level of THINKING_LEVELS) {
|
|
574
|
+
const value = raw[level];
|
|
575
|
+
if (isValidThinkingBudget(value)) out[level] = value;
|
|
576
|
+
}
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
function parseThinkingDocument(text) {
|
|
580
|
+
let raw;
|
|
581
|
+
try {
|
|
582
|
+
raw = JSON.parse(text);
|
|
583
|
+
} catch {
|
|
584
|
+
return {
|
|
585
|
+
version: 1,
|
|
586
|
+
budgets: {}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const record = typeof raw === "object" && raw !== null ? raw : {};
|
|
590
|
+
const budgets = sanitizeThinkingBudgets(record.budgets);
|
|
591
|
+
const claude = record.claudeBudget;
|
|
592
|
+
const tiered = record.tieredBudget;
|
|
593
|
+
return {
|
|
594
|
+
version: 1,
|
|
595
|
+
budgets,
|
|
596
|
+
...isValidClaudeBudget(claude) ? { claudeBudget: claude } : {},
|
|
597
|
+
...isValidThinkingBudget(tiered) ? { tieredBudget: tiered } : {}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* In-memory authoritative copy of the budgets, backed by a JSON file.
|
|
602
|
+
*
|
|
603
|
+
* `budgetFor` sits on the GENERATION HOT PATH (`toAgyRequestBody` calls it for
|
|
604
|
+
* every request), so the cross-instance revalidation it needs is rate-limited:
|
|
605
|
+
* see `revalidateIntervalMs`. Writes go to the file immediately, since edits are
|
|
606
|
+
* rare and user-initiated, and they bypass the limit so a concurrent edit is
|
|
607
|
+
* never overwritten.
|
|
608
|
+
*/
|
|
609
|
+
var ThinkingBudgetStore = class ThinkingBudgetStore {
|
|
610
|
+
file;
|
|
611
|
+
revalidateIntervalMs;
|
|
612
|
+
doc;
|
|
613
|
+
/** Raw text this instance last read or wrote; see `reloadNow`. */
|
|
614
|
+
raw;
|
|
615
|
+
/** When `reloadNow` last ran, for the hot-path throttle. */
|
|
616
|
+
checkedAt;
|
|
617
|
+
constructor(options = {}) {
|
|
618
|
+
this.file = options.file ?? join(resolveDshHome(), "agy-thinking.json");
|
|
619
|
+
this.revalidateIntervalMs = options.revalidateIntervalMs ?? 1e3;
|
|
620
|
+
const initial = this.readWithRaw();
|
|
621
|
+
this.doc = initial.doc;
|
|
622
|
+
this.raw = initial.raw;
|
|
623
|
+
this.checkedAt = Date.now();
|
|
624
|
+
}
|
|
625
|
+
/** Path of the backing file. */
|
|
626
|
+
get path() {
|
|
627
|
+
return this.file;
|
|
628
|
+
}
|
|
629
|
+
static serialize(doc) {
|
|
630
|
+
return JSON.stringify(doc, null, 2) + "\n";
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Read the document together with the raw text it came from.
|
|
634
|
+
*
|
|
635
|
+
* Content, not mtime: two writers can land in the same filesystem timestamp
|
|
636
|
+
* tick (Windows resolves it coarsely enough to have failed CI in this repo),
|
|
637
|
+
* and a missed change is exactly the bug this check exists to prevent.
|
|
638
|
+
*/
|
|
639
|
+
readWithRaw() {
|
|
640
|
+
try {
|
|
641
|
+
const raw = readFileSync(this.file, "utf8");
|
|
642
|
+
return {
|
|
643
|
+
doc: parseThinkingDocument(raw),
|
|
644
|
+
raw
|
|
645
|
+
};
|
|
646
|
+
} catch {
|
|
647
|
+
return {
|
|
648
|
+
doc: {
|
|
649
|
+
version: 1,
|
|
650
|
+
budgets: {}
|
|
651
|
+
},
|
|
652
|
+
raw: ""
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Re-read the file if another writer changed it.
|
|
658
|
+
*
|
|
659
|
+
* Compares CONTENT, not mtime: a toggle and a re-read can land in the same
|
|
660
|
+
* filesystem timestamp tick, and Windows resolves that coarsely enough to have
|
|
661
|
+
* failed CI in this repo (`model-visibility.ts` records the incident). Content
|
|
662
|
+
* comparison cannot miss a change, whatever the clock resolution.
|
|
663
|
+
*
|
|
664
|
+
* Unconditional — callers on the hot path use `reloadIfChanged` instead.
|
|
665
|
+
*/
|
|
666
|
+
reloadNow() {
|
|
667
|
+
let current;
|
|
668
|
+
try {
|
|
669
|
+
current = readFileSync(this.file, "utf8");
|
|
670
|
+
} catch {
|
|
671
|
+
current = "";
|
|
672
|
+
}
|
|
673
|
+
this.checkedAt = Date.now();
|
|
674
|
+
if (current === this.raw) return;
|
|
675
|
+
const fresh = this.readWithRaw();
|
|
676
|
+
this.doc = fresh.doc;
|
|
677
|
+
this.raw = fresh.raw;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Hot-path revalidation: `reloadNow`, rate-limited.
|
|
681
|
+
*
|
|
682
|
+
* Without the limit every generation pays a blocking `readFileSync` plus a
|
|
683
|
+
* UTF-8 decode — the module used to claim these reads were "in-memory", which
|
|
684
|
+
* an unconditional read made false. The file is written only by `setBudget`
|
|
685
|
+
* (a rare, user-initiated action), so the worst case here is that a settings
|
|
686
|
+
* change takes up to `revalidateIntervalMs` to reach an already-running
|
|
687
|
+
* adapter. That is the right trade for a setting; it is not right for the write
|
|
688
|
+
* path, which is why `write` calls `reloadNow` directly.
|
|
689
|
+
*/
|
|
690
|
+
reloadIfChanged() {
|
|
691
|
+
if (Date.now() - this.checkedAt < this.revalidateIntervalMs) return;
|
|
692
|
+
this.reloadNow();
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Write the whole document atomically.
|
|
696
|
+
*
|
|
697
|
+
* Always re-reads first, so a concurrent edit by the OTHER instance in this
|
|
698
|
+
* process (main plugin + web entry) is merged rather than overwritten: each
|
|
699
|
+
* caller passes a mutation, not a full replacement built from a stale copy.
|
|
700
|
+
*/
|
|
701
|
+
write(mutate) {
|
|
702
|
+
this.reloadNow();
|
|
703
|
+
const next = {
|
|
704
|
+
version: 1,
|
|
705
|
+
budgets: { ...this.doc.budgets },
|
|
706
|
+
...this.doc.claudeBudget === void 0 ? {} : { claudeBudget: this.doc.claudeBudget },
|
|
707
|
+
...this.doc.tieredBudget === void 0 ? {} : { tieredBudget: this.doc.tieredBudget }
|
|
708
|
+
};
|
|
709
|
+
mutate(next);
|
|
710
|
+
const text = ThinkingBudgetStore.serialize(next);
|
|
711
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
712
|
+
const tmp = `${this.file}.tmp`;
|
|
713
|
+
writeFileSync(tmp, text, { mode: 384 });
|
|
714
|
+
renameSync(tmp, this.file);
|
|
715
|
+
this.doc = next;
|
|
716
|
+
this.raw = text;
|
|
717
|
+
}
|
|
718
|
+
/** The configured budget for one level, or undefined when unset. */
|
|
719
|
+
budgetFor(level) {
|
|
720
|
+
this.reloadIfChanged();
|
|
721
|
+
if (level === void 0) return void 0;
|
|
722
|
+
const key = level.toLowerCase();
|
|
723
|
+
if (!THINKING_LEVELS.includes(key)) return void 0;
|
|
724
|
+
return this.doc.budgets[key];
|
|
725
|
+
}
|
|
726
|
+
/** The Claude-family budget, or undefined when unset. */
|
|
727
|
+
claudeBudget() {
|
|
728
|
+
this.reloadIfChanged();
|
|
729
|
+
return this.doc.claudeBudget;
|
|
730
|
+
}
|
|
731
|
+
/** The tiered-slot budget (the selector's Default effort), or undefined. */
|
|
732
|
+
tieredBudget() {
|
|
733
|
+
this.reloadIfChanged();
|
|
734
|
+
return this.doc.tieredBudget;
|
|
735
|
+
}
|
|
736
|
+
/** The raw map, for the settings UI. */
|
|
737
|
+
all() {
|
|
738
|
+
this.reloadIfChanged();
|
|
739
|
+
return { ...this.doc.budgets };
|
|
740
|
+
}
|
|
741
|
+
/** The whole document, for the settings UI. */
|
|
742
|
+
snapshot() {
|
|
743
|
+
this.reloadIfChanged();
|
|
744
|
+
return {
|
|
745
|
+
version: 1,
|
|
746
|
+
budgets: { ...this.doc.budgets },
|
|
747
|
+
...this.doc.claudeBudget === void 0 ? {} : { claudeBudget: this.doc.claudeBudget },
|
|
748
|
+
...this.doc.tieredBudget === void 0 ? {} : { tieredBudget: this.doc.tieredBudget }
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/** Replace one level's budget, or clear it when `value` is undefined. */
|
|
752
|
+
setBudget(level, value) {
|
|
753
|
+
const key = level.toLowerCase();
|
|
754
|
+
if (!THINKING_LEVELS.includes(key)) throw new Error(`unknown thinking level: ${level}`);
|
|
755
|
+
if (value !== void 0 && !isValidThinkingBudget(value)) throw new Error(`thinking budget must be an integer in [-1, ${THINKING_BUDGET_MAX}]`);
|
|
756
|
+
this.write((doc) => {
|
|
757
|
+
if (value === void 0) delete doc.budgets[key];
|
|
758
|
+
else doc.budgets[key] = value;
|
|
759
|
+
});
|
|
760
|
+
return this.all();
|
|
761
|
+
}
|
|
762
|
+
/** Replace the tiered-slot budget, or clear it when `value` is undefined. */
|
|
763
|
+
setTieredBudget(value) {
|
|
764
|
+
if (value !== void 0 && !isValidThinkingBudget(value)) throw new Error(`thinking budget must be an integer in [-1, ${THINKING_BUDGET_MAX}]`);
|
|
765
|
+
this.write((doc) => {
|
|
766
|
+
if (value === void 0) delete doc.tieredBudget;
|
|
767
|
+
else doc.tieredBudget = value;
|
|
768
|
+
});
|
|
769
|
+
return this.snapshot();
|
|
770
|
+
}
|
|
771
|
+
/** Replace the Claude budget, or clear it when `value` is undefined. */
|
|
772
|
+
setClaudeBudget(value) {
|
|
773
|
+
if (value !== void 0 && !isValidClaudeBudget(value)) throw new Error(`Claude thinking budget must be -1, 0, or an integer in [${CLAUDE_BUDGET_MIN}, ${CLAUDE_BUDGET_MAX}]`);
|
|
774
|
+
this.write((doc) => {
|
|
775
|
+
if (value === void 0) delete doc.claudeBudget;
|
|
776
|
+
else doc.claudeBudget = value;
|
|
777
|
+
});
|
|
778
|
+
return this.snapshot();
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/usage-types.ts
|
|
783
|
+
/** Zeroed counters, for accumulation. */
|
|
784
|
+
function zeroCounters() {
|
|
785
|
+
return {
|
|
786
|
+
input: 0,
|
|
787
|
+
output: 0,
|
|
788
|
+
cacheRead: 0,
|
|
789
|
+
cacheWrite: 0,
|
|
790
|
+
requests: 0,
|
|
791
|
+
succeeded: 0,
|
|
792
|
+
failed: 0,
|
|
793
|
+
rateLimited: 0,
|
|
794
|
+
rotations: 0,
|
|
795
|
+
latencyMs: 0,
|
|
796
|
+
latencyN: 0,
|
|
797
|
+
ttftMs: 0,
|
|
798
|
+
ttftN: 0
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function zeroAccount(now) {
|
|
802
|
+
return {
|
|
803
|
+
totals: zeroCounters(),
|
|
804
|
+
models: {},
|
|
805
|
+
sources: {
|
|
806
|
+
chat: 0,
|
|
807
|
+
cli: 0,
|
|
808
|
+
verify: 0,
|
|
809
|
+
test: 0
|
|
810
|
+
},
|
|
811
|
+
lastUsedAt: now
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
function emptyDocument(now) {
|
|
815
|
+
return {
|
|
816
|
+
version: 2,
|
|
817
|
+
since: now,
|
|
818
|
+
totals: zeroCounters(),
|
|
819
|
+
accounts: {},
|
|
820
|
+
days: {}
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
/** Local-time day key. Local (not UTC) so "today" matches the user's clock. */
|
|
824
|
+
function dayKey(time) {
|
|
825
|
+
const d = new Date(time);
|
|
826
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
827
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
828
|
+
return `${d.getFullYear()}-${month}-${day}`;
|
|
829
|
+
}
|
|
830
|
+
/** Add `add` into `into`, in place. */
|
|
831
|
+
function addInto(into, add) {
|
|
832
|
+
into.input += add.input;
|
|
833
|
+
into.output += add.output;
|
|
834
|
+
into.cacheRead += add.cacheRead;
|
|
835
|
+
into.cacheWrite += add.cacheWrite;
|
|
836
|
+
into.requests += add.requests;
|
|
837
|
+
into.succeeded += add.succeeded;
|
|
838
|
+
into.failed += add.failed;
|
|
839
|
+
into.rateLimited += add.rateLimited;
|
|
840
|
+
into.rotations += add.rotations;
|
|
841
|
+
into.latencyMs += add.latencyMs;
|
|
842
|
+
into.latencyN += add.latencyN;
|
|
843
|
+
into.ttftMs += add.ttftMs;
|
|
844
|
+
into.ttftN += add.ttftN;
|
|
845
|
+
}
|
|
846
|
+
/** Coerce arbitrary parsed JSON into a valid counter block (tolerates hand edits). */
|
|
847
|
+
function sanitizeCounters(raw) {
|
|
848
|
+
const out = zeroCounters();
|
|
849
|
+
if (typeof raw !== "object" || raw === null) return out;
|
|
850
|
+
for (const key of Object.keys(out)) {
|
|
851
|
+
const value = raw[key];
|
|
852
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) out[key] = value;
|
|
853
|
+
}
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
function sanitizeAccount(raw, now) {
|
|
857
|
+
const out = zeroAccount(now);
|
|
858
|
+
if (typeof raw !== "object" || raw === null) return out;
|
|
859
|
+
const record = raw;
|
|
860
|
+
out.totals = sanitizeCounters(record.totals);
|
|
861
|
+
if (typeof record.lastUsedAt === "number" && Number.isFinite(record.lastUsedAt)) out.lastUsedAt = record.lastUsedAt;
|
|
862
|
+
if (typeof record.models === "object" && record.models !== null) for (const [id, value] of Object.entries(record.models)) out.models[id] = sanitizeCounters(value);
|
|
863
|
+
if (typeof record.sources === "object" && record.sources !== null) for (const key of [
|
|
864
|
+
"chat",
|
|
865
|
+
"cli",
|
|
866
|
+
"verify",
|
|
867
|
+
"test"
|
|
868
|
+
]) {
|
|
869
|
+
const value = record.sources[key];
|
|
870
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) out.sources[key] = value;
|
|
871
|
+
}
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
/** Parse a persisted document defensively; a corrupt file degrades to empty. */
|
|
875
|
+
function parseStatsDocument(text, now) {
|
|
876
|
+
let raw;
|
|
877
|
+
try {
|
|
878
|
+
raw = JSON.parse(text);
|
|
879
|
+
} catch {
|
|
880
|
+
return emptyDocument(now);
|
|
881
|
+
}
|
|
882
|
+
if (typeof raw !== "object" || raw === null) return emptyDocument(now);
|
|
883
|
+
const record = raw;
|
|
884
|
+
const doc = emptyDocument(now);
|
|
885
|
+
if (typeof record.since === "number" && Number.isFinite(record.since)) doc.since = record.since;
|
|
886
|
+
doc.totals = sanitizeCounters(record.totals);
|
|
887
|
+
if (typeof record.accounts === "object" && record.accounts !== null) for (const [key, value] of Object.entries(record.accounts)) doc.accounts[key] = sanitizeAccount(value, now);
|
|
888
|
+
if (typeof record.days === "object" && record.days !== null) {
|
|
889
|
+
for (const [key, value] of Object.entries(record.days)) if (/^\d{4}-\d{2}-\d{2}$/.test(key)) doc.days[key] = sanitizeDayBucket(value);
|
|
890
|
+
}
|
|
891
|
+
return doc;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Coerce one day's stored value into a `DayBucket`.
|
|
895
|
+
*
|
|
896
|
+
* Accepts the version-1 shape too: that stored a bare counter block, so a
|
|
897
|
+
* document written before the upgrade keeps its day counts as `totals` with no
|
|
898
|
+
* breakdown. Losing the breakdown for those days is the correct trade — the
|
|
899
|
+
* alternative (dropping the days) would erase real history, and recomputing a
|
|
900
|
+
* dimension split that was never recorded is impossible. The all-time
|
|
901
|
+
* `accounts`/`models` maps are unaffected, so only range-scoped breakdown tables
|
|
902
|
+
* are thinner for pre-upgrade days.
|
|
903
|
+
*/
|
|
904
|
+
function sanitizeDayBucket(raw) {
|
|
905
|
+
const out = {
|
|
906
|
+
totals: zeroCounters(),
|
|
907
|
+
models: {},
|
|
908
|
+
accounts: {}
|
|
909
|
+
};
|
|
910
|
+
if (typeof raw !== "object" || raw === null) return out;
|
|
911
|
+
const record = raw;
|
|
912
|
+
if (typeof record.totals === "object" && record.totals !== null) {
|
|
913
|
+
out.totals = sanitizeCounters(record.totals);
|
|
914
|
+
if (typeof record.models === "object" && record.models !== null) for (const [id, value] of Object.entries(record.models)) out.models[id] = sanitizeCounters(value);
|
|
915
|
+
if (typeof record.accounts === "object" && record.accounts !== null) for (const [key, value] of Object.entries(record.accounts)) out.accounts[key] = sanitizeCounters(value);
|
|
916
|
+
return out;
|
|
917
|
+
}
|
|
918
|
+
out.totals = sanitizeCounters(raw);
|
|
919
|
+
return out;
|
|
920
|
+
}
|
|
921
|
+
/** Drop day buckets older than the window; their counts already live in `totals`. */
|
|
922
|
+
function pruneDays(doc, now) {
|
|
923
|
+
const cutoff = dayKey(now - 2592e6);
|
|
924
|
+
for (const key of Object.keys(doc.days)) if (key < cutoff) delete doc.days[key];
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Fold the day buckets for a window into the three views the Usage tab renders.
|
|
928
|
+
*
|
|
929
|
+
* The breakdown tables read THIS rather than the all-time `accounts`/`models`
|
|
930
|
+
* maps, so one range selection governs the whole page. Rows are sorted by
|
|
931
|
+
* request count descending, matching the all-time view; the caller re-sorts if
|
|
932
|
+
* it wants another order (the UI shows them as-is).
|
|
933
|
+
*
|
|
934
|
+
* @param doc - the ledger snapshot.
|
|
935
|
+
* @param days - window length in days, at least 1.
|
|
936
|
+
* @param now - current time (Unix ms).
|
|
937
|
+
* @returns totals and their per-model / per-account partitions.
|
|
938
|
+
*/
|
|
939
|
+
function foldWindowBreakdown(doc, days, now) {
|
|
940
|
+
const cutoff = dayKey(now - (Math.max(1, Math.floor(days)) - 1) * 864e5);
|
|
941
|
+
const totals = zeroCounters();
|
|
942
|
+
const models = /* @__PURE__ */ new Map();
|
|
943
|
+
const accounts = /* @__PURE__ */ new Map();
|
|
944
|
+
for (const [key, bucket] of Object.entries(doc.days)) {
|
|
945
|
+
if (key < cutoff) continue;
|
|
946
|
+
addInto(totals, bucket.totals);
|
|
947
|
+
for (const [model, counters] of Object.entries(bucket.models)) {
|
|
948
|
+
const current = models.get(model);
|
|
949
|
+
if (current === void 0) models.set(model, { ...counters });
|
|
950
|
+
else addInto(current, counters);
|
|
951
|
+
}
|
|
952
|
+
for (const [account, counters] of Object.entries(bucket.accounts)) {
|
|
953
|
+
const current = accounts.get(account);
|
|
954
|
+
if (current === void 0) accounts.set(account, { ...counters });
|
|
955
|
+
else addInto(current, counters);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const byRequests = (a, b) => b.counters.requests - a.counters.requests;
|
|
959
|
+
return {
|
|
960
|
+
totals,
|
|
961
|
+
models: [...models.entries()].map(([model, counters]) => ({
|
|
962
|
+
model,
|
|
963
|
+
counters
|
|
964
|
+
})).sort(byRequests),
|
|
965
|
+
accounts: [...accounts.entries()].map(([account, counters]) => ({
|
|
966
|
+
account,
|
|
967
|
+
counters
|
|
968
|
+
})).sort(byRequests)
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
/** Merge one record into a document, in place. Shared by the live store and tests. */
|
|
972
|
+
function applyRecord(doc, record, now) {
|
|
973
|
+
const delta = zeroCounters();
|
|
974
|
+
if (record.poolEvent !== true) {
|
|
975
|
+
delta.requests = 1;
|
|
976
|
+
if (record.ok) delta.succeeded = 1;
|
|
977
|
+
else delta.failed = 1;
|
|
978
|
+
if (record.rateLimited === true) delta.rateLimited = 1;
|
|
979
|
+
}
|
|
980
|
+
if (record.rotated === true) delta.rotations = 1;
|
|
981
|
+
if (record.usage !== void 0) {
|
|
982
|
+
delta.input = record.usage.input;
|
|
983
|
+
delta.output = record.usage.output;
|
|
984
|
+
delta.cacheRead = record.usage.cacheRead;
|
|
985
|
+
delta.cacheWrite = record.usage.cacheWrite;
|
|
986
|
+
}
|
|
987
|
+
if (typeof record.latencyMs === "number" && Number.isFinite(record.latencyMs) && record.latencyMs >= 0) {
|
|
988
|
+
delta.latencyMs = record.latencyMs;
|
|
989
|
+
delta.latencyN = 1;
|
|
990
|
+
}
|
|
991
|
+
if (typeof record.ttftMs === "number" && Number.isFinite(record.ttftMs) && record.ttftMs >= 0) {
|
|
992
|
+
delta.ttftMs = record.ttftMs;
|
|
993
|
+
delta.ttftN = 1;
|
|
994
|
+
}
|
|
995
|
+
addInto(doc.totals, delta);
|
|
996
|
+
if (record.account !== void 0 && record.account !== "") {
|
|
997
|
+
const account = doc.accounts[record.account] ?? zeroAccount(now);
|
|
998
|
+
addInto(account.totals, delta);
|
|
999
|
+
if (record.poolEvent !== true) account.sources[record.source] += 1;
|
|
1000
|
+
account.lastUsedAt = now;
|
|
1001
|
+
if (record.model !== void 0 && record.model !== "") {
|
|
1002
|
+
const model = account.models[record.model] ?? zeroCounters();
|
|
1003
|
+
addInto(model, delta);
|
|
1004
|
+
account.models[record.model] = model;
|
|
1005
|
+
}
|
|
1006
|
+
doc.accounts[record.account] = account;
|
|
1007
|
+
}
|
|
1008
|
+
const key = dayKey(now);
|
|
1009
|
+
const bucket = doc.days[key] ?? {
|
|
1010
|
+
totals: zeroCounters(),
|
|
1011
|
+
models: {},
|
|
1012
|
+
accounts: {}
|
|
1013
|
+
};
|
|
1014
|
+
addInto(bucket.totals, delta);
|
|
1015
|
+
if (record.model !== void 0 && record.model !== "") {
|
|
1016
|
+
const model = bucket.models[record.model] ?? zeroCounters();
|
|
1017
|
+
addInto(model, delta);
|
|
1018
|
+
bucket.models[record.model] = model;
|
|
1019
|
+
}
|
|
1020
|
+
if (record.account !== void 0 && record.account !== "") {
|
|
1021
|
+
const account = bucket.accounts[record.account] ?? zeroCounters();
|
|
1022
|
+
addInto(account, delta);
|
|
1023
|
+
bucket.accounts[record.account] = account;
|
|
1024
|
+
}
|
|
1025
|
+
doc.days[key] = bucket;
|
|
1026
|
+
pruneDays(doc, now);
|
|
1027
|
+
}
|
|
1028
|
+
const properStatsLock = { withLock(file, fn) {
|
|
1029
|
+
const release = lockfile.lockSync(file, {
|
|
1030
|
+
stale: 8e3,
|
|
1031
|
+
update: 4e3,
|
|
1032
|
+
realpath: false
|
|
1033
|
+
});
|
|
1034
|
+
try {
|
|
1035
|
+
return fn();
|
|
1036
|
+
} finally {
|
|
1037
|
+
release();
|
|
1038
|
+
}
|
|
1039
|
+
} };
|
|
1040
|
+
/**
|
|
1041
|
+
* Accumulating usage ledger. `record()` is the hot path and performs no I/O:
|
|
1042
|
+
* it folds the record into an in-memory delta. `flush()` merges that delta into
|
|
1043
|
+
* the persisted document under the file lock.
|
|
1044
|
+
*/
|
|
1045
|
+
var UsageStats = class {
|
|
1046
|
+
file;
|
|
1047
|
+
lock;
|
|
1048
|
+
now;
|
|
1049
|
+
flushEvery;
|
|
1050
|
+
flushIntervalMs;
|
|
1051
|
+
maxPending;
|
|
1052
|
+
onFlushError;
|
|
1053
|
+
/** This process's un-flushed records. */
|
|
1054
|
+
pending = [];
|
|
1055
|
+
timer;
|
|
1056
|
+
lastFlushError;
|
|
1057
|
+
/**
|
|
1058
|
+
* Whether the current run of consecutive failures has already been reported.
|
|
1059
|
+
* Count-based flushing is also suspended while set: see `record()`.
|
|
1060
|
+
*/
|
|
1061
|
+
flushFailedSince;
|
|
1062
|
+
constructor(options = {}) {
|
|
1063
|
+
this.file = options.file ?? join(resolveDshHome(), "agy-stats.json");
|
|
1064
|
+
this.lock = options.lock ?? properStatsLock;
|
|
1065
|
+
this.now = options.now ?? (() => Date.now());
|
|
1066
|
+
this.flushEvery = options.flushEvery ?? 50;
|
|
1067
|
+
this.flushIntervalMs = options.flushIntervalMs ?? 5e3;
|
|
1068
|
+
this.maxPending = options.maxPending ?? 500;
|
|
1069
|
+
this.onFlushError = options.onFlushError;
|
|
1070
|
+
}
|
|
1071
|
+
/** Path of the backing file. */
|
|
1072
|
+
get path() {
|
|
1073
|
+
return this.file;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Pre-create the ledger so `proper-lockfile` can lock it. The lock throws
|
|
1077
|
+
* ENOENT on a nonexistent target, so the empty document must exist (0600,
|
|
1078
|
+
* tmp+rename) before the first flush.
|
|
1079
|
+
*/
|
|
1080
|
+
ensureFile(now) {
|
|
1081
|
+
if (existsSync(this.file)) return;
|
|
1082
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
1083
|
+
const tmp = `${this.file}.tmp-init`;
|
|
1084
|
+
writeFileSync(tmp, JSON.stringify(emptyDocument(now), null, 2) + "\n", { mode: 384 });
|
|
1085
|
+
renameSync(tmp, this.file);
|
|
1086
|
+
}
|
|
1087
|
+
readFile() {
|
|
1088
|
+
const now = this.now();
|
|
1089
|
+
try {
|
|
1090
|
+
return parseStatsDocument(readFileSync(this.file, "utf8"), now);
|
|
1091
|
+
} catch {
|
|
1092
|
+
return emptyDocument(now);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/** Atomic replace (tmp + rename), owner-only, so a reader never sees a partial file. */
|
|
1096
|
+
writeFile(doc) {
|
|
1097
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
1098
|
+
const tmp = `${this.file}.tmp`;
|
|
1099
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2) + "\n", { mode: 384 });
|
|
1100
|
+
renameSync(tmp, this.file);
|
|
1101
|
+
}
|
|
1102
|
+
/** Record one request. Hot path: no I/O. */
|
|
1103
|
+
record(record) {
|
|
1104
|
+
this.pending.push(record);
|
|
1105
|
+
if (this.maxPending > 0 && this.pending.length > this.maxPending) this.pending.splice(0, this.pending.length - this.maxPending);
|
|
1106
|
+
if (this.flushFailedSince === void 0 && this.flushEvery > 0 && this.pending.length >= this.flushEvery) {
|
|
1107
|
+
this.flush();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
this.armTimer();
|
|
1111
|
+
}
|
|
1112
|
+
armTimer() {
|
|
1113
|
+
if (this.timer !== void 0 || this.flushIntervalMs <= 0) return;
|
|
1114
|
+
this.timer = setTimeout(() => {
|
|
1115
|
+
this.timer = void 0;
|
|
1116
|
+
this.flush();
|
|
1117
|
+
}, this.flushIntervalMs);
|
|
1118
|
+
this.timer.unref?.();
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Merge pending records into the persisted document.
|
|
1122
|
+
*
|
|
1123
|
+
* Under the lock, the fresh on-disk state is re-read and this process's
|
|
1124
|
+
* records are replayed onto it. That is what keeps concurrently-running
|
|
1125
|
+
* writers (Desktop, a web-profile server, a CLI invocation) from clobbering
|
|
1126
|
+
* each other: a plain overwrite of the in-memory document would drop every
|
|
1127
|
+
* count another process added since this one loaded the file.
|
|
1128
|
+
*/
|
|
1129
|
+
flush() {
|
|
1130
|
+
if (this.timer !== void 0) {
|
|
1131
|
+
clearTimeout(this.timer);
|
|
1132
|
+
this.timer = void 0;
|
|
1133
|
+
}
|
|
1134
|
+
const batch = this.pending;
|
|
1135
|
+
if (batch.length === 0) return;
|
|
1136
|
+
this.pending = [];
|
|
1137
|
+
const now = this.now();
|
|
1138
|
+
try {
|
|
1139
|
+
this.ensureFile(now);
|
|
1140
|
+
this.lock.withLock(this.file, () => {
|
|
1141
|
+
const doc = this.readFile();
|
|
1142
|
+
for (const record of batch) applyRecord(doc, record, now);
|
|
1143
|
+
this.writeFile(doc);
|
|
1144
|
+
});
|
|
1145
|
+
this.lastFlushError = void 0;
|
|
1146
|
+
this.flushFailedSince = void 0;
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
this.pending = [...batch, ...this.pending];
|
|
1149
|
+
if (this.maxPending > 0 && this.pending.length > this.maxPending) this.pending.splice(0, this.pending.length - this.maxPending);
|
|
1150
|
+
this.lastFlushError = error;
|
|
1151
|
+
if (this.flushFailedSince === void 0) {
|
|
1152
|
+
this.flushFailedSince = now;
|
|
1153
|
+
try {
|
|
1154
|
+
this.onFlushError?.(error);
|
|
1155
|
+
} catch {}
|
|
1156
|
+
}
|
|
1157
|
+
this.armTimer();
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
/** Flush if anything is pending; safe to call on turn end and process exit. */
|
|
1161
|
+
flushSync() {
|
|
1162
|
+
this.flush();
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* A snapshot for reading (the Usage tab).
|
|
1166
|
+
*
|
|
1167
|
+
* Reads the file rather than an in-memory copy: the writer is not always this
|
|
1168
|
+
* process. A CLI `login`/`verify` runs in its own process, and the browser may
|
|
1169
|
+
* be served by a different plugin instance than the one that generated, so an
|
|
1170
|
+
* in-memory cache would show a stale or empty ledger. This process's own
|
|
1171
|
+
* un-flushed records are layered on top so the UI never lags behind its own
|
|
1172
|
+
* activity.
|
|
1173
|
+
*
|
|
1174
|
+
* Called once per UI open/refresh, never on the generation hot path, so the
|
|
1175
|
+
* read is cheap enough.
|
|
1176
|
+
*/
|
|
1177
|
+
snapshot() {
|
|
1178
|
+
const now = this.now();
|
|
1179
|
+
const doc = this.readFile();
|
|
1180
|
+
if (this.pending.length > 0) for (const record of this.pending) applyRecord(doc, record, now);
|
|
1181
|
+
return doc;
|
|
1182
|
+
}
|
|
1183
|
+
/** The most recent flush failure, if any (diagnostics). */
|
|
1184
|
+
get flushError() {
|
|
1185
|
+
return this.lastFlushError;
|
|
1186
|
+
}
|
|
1187
|
+
/** Number of records awaiting flush. */
|
|
1188
|
+
get pendingCount() {
|
|
1189
|
+
return this.pending.length;
|
|
1190
|
+
}
|
|
1191
|
+
/** Whether the ledger is currently failing to persist (a run of failures). */
|
|
1192
|
+
get flushFailing() {
|
|
1193
|
+
return this.flushFailedSince !== void 0;
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/plugin-common.ts
|
|
1198
|
+
/**
|
|
1199
|
+
* Shared runtime construction for the in-harness plugin entries: master-key
|
|
1200
|
+
* codec resolution (credentials seam first, credentials document fallback),
|
|
1201
|
+
* account store, session manager, and adapter. Used by the main plugin
|
|
1202
|
+
* (adapter registration) and the web plugin (route registration) so both
|
|
1203
|
+
* entries operate on the same store.
|
|
1204
|
+
*/
|
|
1205
|
+
function codecFrom(masterKey) {
|
|
1206
|
+
return createAesGcmCodec(deriveKey(masterKey));
|
|
1207
|
+
}
|
|
1208
|
+
/** Resolve or create the master key, preferring the credentials seam when available. */
|
|
1209
|
+
async function resolveCodec(ctx) {
|
|
1210
|
+
const dshHome = resolveDshHome();
|
|
1211
|
+
const credentials = ctx.get("credentials");
|
|
1212
|
+
if (credentials) {
|
|
1213
|
+
const resolved = await credentials.resolve(MASTER_KEY_REF);
|
|
1214
|
+
if (resolved) return {
|
|
1215
|
+
codec: codecFrom(resolved.value),
|
|
1216
|
+
created: false
|
|
1217
|
+
};
|
|
1218
|
+
const fileKey = loadMasterKey(dshHome);
|
|
1219
|
+
if (fileKey) return {
|
|
1220
|
+
codec: codecFrom(fileKey),
|
|
1221
|
+
created: false
|
|
1222
|
+
};
|
|
1223
|
+
const fresh = randomBytes(32).toString("hex");
|
|
1224
|
+
try {
|
|
1225
|
+
await credentials.set(MASTER_KEY_REF, fresh);
|
|
1226
|
+
return {
|
|
1227
|
+
codec: codecFrom(fresh),
|
|
1228
|
+
created: true
|
|
1229
|
+
};
|
|
1230
|
+
} catch {
|
|
1231
|
+
persistMasterKey(dshHome, fresh);
|
|
1232
|
+
return {
|
|
1233
|
+
codec: codecFrom(fresh),
|
|
1234
|
+
created: true
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return resolveMasterKeyCodec(dshHome);
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Fire-and-forget version warm-up so fingerprint generation inside the
|
|
1242
|
+
* rate-limit path never waits on a cold feed.
|
|
1243
|
+
*
|
|
1244
|
+
* Routed through `pickProbeProxyUrl`: the release feeds are not account-scoped,
|
|
1245
|
+
* but the host IP is exactly what a per-account-proxy user asked to hide, and a
|
|
1246
|
+
* bare `proxiedFetch` sends this boot-time request directly. Never throws — an
|
|
1247
|
+
* unreadable store or a dead feed must not be the reason plugin boot fails.
|
|
1248
|
+
*/
|
|
1249
|
+
async function warmVersionCache(store) {
|
|
1250
|
+
let fetchImpl = proxiedFetch;
|
|
1251
|
+
try {
|
|
1252
|
+
const storage = await store.load();
|
|
1253
|
+
const proxyUrl = pickProbeProxyUrl(storage.accounts, storage.activeIndex);
|
|
1254
|
+
fetchImpl = probeFetch(proxyUrl);
|
|
1255
|
+
} catch {}
|
|
1256
|
+
await resolveAntigravityVersion(fetchImpl).catch(() => {});
|
|
1257
|
+
}
|
|
1258
|
+
/** Build the store, session manager, and adapter for one plugin entry. */
|
|
1259
|
+
async function createAgyRuntime(ctx) {
|
|
1260
|
+
const { codec } = await resolveCodec(ctx);
|
|
1261
|
+
const dshHome = resolveDshHome();
|
|
1262
|
+
const store = new JsonAccountStore({
|
|
1263
|
+
file: `${dshHome}/agy-accounts.json`,
|
|
1264
|
+
codec
|
|
1265
|
+
});
|
|
1266
|
+
warmVersionCache(store);
|
|
1267
|
+
const stats = new UsageStats({ onFlushError: (error) => {
|
|
1268
|
+
ctx.logger.warn(`[dsh-agy] usage ledger could not be written (${stats.path}): ${error instanceof Error ? error.message : String(error)} — counts are buffered and will retry`);
|
|
1269
|
+
} });
|
|
1270
|
+
const modelVisibility = new ModelVisibility();
|
|
1271
|
+
const thinkingBudget = new ThinkingBudgetStore();
|
|
1272
|
+
const sessions = new AgySessionManager({
|
|
1273
|
+
store,
|
|
1274
|
+
recordUsage: (record) => {
|
|
1275
|
+
stats.record(record);
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
const healthIntervalMs = Number(process.env.DSH_AGY_HEALTH_INTERVAL_MS ?? 0);
|
|
1279
|
+
if (Number.isFinite(healthIntervalMs) && healthIntervalMs > 0) sessions.startHealthProbe(healthIntervalMs);
|
|
1280
|
+
const adapter = new AgyAdapter({
|
|
1281
|
+
getSession: (model, conversationKey) => sessions.getSession(model, void 0, conversationKey),
|
|
1282
|
+
reportFailure: (kind, session, info) => sessions.reportFailure(kind, session, info),
|
|
1283
|
+
markSuccess: (session) => sessions.markSuccess(session),
|
|
1284
|
+
resolveAttachments: () => ctx.get("attachments"),
|
|
1285
|
+
noteRequestStarted: (account) => sessions.noteRequestStarted(account),
|
|
1286
|
+
noteRequestSettled: (account) => sessions.noteRequestSettled(account),
|
|
1287
|
+
modelVisibility,
|
|
1288
|
+
thinkingBudgetFor: (level) => thinkingBudget.budgetFor(level),
|
|
1289
|
+
claudeBudgetFor: () => thinkingBudget.claudeBudget(),
|
|
1290
|
+
tieredBudgetFor: () => thinkingBudget.tieredBudget(),
|
|
1291
|
+
recordUsage: (record) => {
|
|
1292
|
+
stats.record({
|
|
1293
|
+
...record,
|
|
1294
|
+
source: "chat"
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
process.once("exit", () => {
|
|
1299
|
+
stats.flushSync();
|
|
1300
|
+
});
|
|
1301
|
+
return {
|
|
1302
|
+
store,
|
|
1303
|
+
sessions,
|
|
1304
|
+
adapter,
|
|
1305
|
+
stats,
|
|
1306
|
+
modelVisibility,
|
|
1307
|
+
thinkingBudget
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
//#endregion
|
|
1311
|
+
export { THINKING_BUDGET_MAX as a, CLAUDE_BUDGET_MIN as i, foldWindowBreakdown as n, CLAUDE_BUDGET_MAX as r, createAgyRuntime as t };
|
|
1312
|
+
|
|
1313
|
+
//# sourceMappingURL=plugin-common-B1MG00JT.mjs.map
|