@cortexkit/opencode-openai-auth 1.0.5 → 1.2.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/dist/bin.js +9 -8
- package/dist/{pool-lifecycle-C_6YRmsh.d.ts → codex-request-CjXETpTA.d.ts} +190 -27
- package/dist/index-03em8hv1.js +69 -0
- package/dist/index-6n4pnfkg.js +2 -0
- package/dist/{index-pg305s90.js → index-bc89akak.js} +1 -1
- package/dist/{index-y4gxyxqd.js → index-ehjj89rx.js} +2 -2
- package/dist/index-f21addzm.js +90 -0
- package/dist/{index-fsky1rsh.js → index-ffg0sgg5.js} +2 -2
- package/dist/{index-nx2qh047.js → index-ftkhhr6d.js} +1 -1
- package/dist/{index-826s1yxa.js → index-htvcmtcx.js} +2 -2
- package/dist/{index-fsxbncpw.js → index-qp5hdpw8.js} +1 -1
- package/dist/index-qxbchh2d.js +8 -0
- package/dist/{index-116q849r.js → index-r2s51ac1.js} +2 -2
- package/dist/index-rwbrhkse.js +65 -0
- package/dist/{index-66asrje2.js → index-xwvze96g.js} +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.js +1 -1
- package/dist/{protocol-DctqemRI.d.ts → model-CQcw10oW.d.ts} +67 -25
- package/dist/{port-file-ZPlxXB4G.d.ts → port-file-BDJnzgHH.d.ts} +1 -1
- package/dist/protocol-C-kagE6O.d.ts +29 -0
- package/dist/rpc/port-file.d.ts +1 -1
- package/dist/rpc/port-file.js +1 -1
- package/dist/rpc/protocol.d.ts +1 -1
- package/dist/rpc/protocol.js +1 -1
- package/dist/rpc/rpc-client.d.ts +3 -3
- package/dist/rpc/rpc-client.js +1 -1
- package/dist/rpc/rpc-dir.js +1 -1
- package/dist/{sidebar-state-z_M7Trnd.d.ts → sidebar-state-BfQ4mpZO.d.ts} +109 -10
- package/dist/sidebar-state.d.ts +2 -2
- package/dist/sidebar-state.js +1 -1
- package/dist/token-util-cf0nyh7n.js +1 -0
- package/dist/{token-bzhkgc29.js → token-z6qq7a1d.js} +1 -1
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +20 -2
- package/dist/v2/server.d.ts +180 -1
- package/dist/v2/server.js +3 -3
- package/package.json +3 -3
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +148 -26
- package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +371 -0
- package/src/tui-compiled/raw/shared/134b008900698fe0-backoff.js +92 -0
- package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +430 -0
- package/src/tui-compiled/raw/shared/2577ec69f9973ae9-quota-normalize.js +154 -0
- package/src/tui-compiled/raw/shared/2dce6e9613993329-quota-manager.js +473 -0
- package/src/tui-compiled/raw/shared/30f5bc480f31f51c-roster.js +566 -0
- package/src/tui-compiled/raw/shared/43fc9e6b994519c7-refresh-lock.js +0 -1
- package/src/tui-compiled/raw/shared/4593a4d71edf69fd-tombstone.js +25 -0
- package/src/tui-compiled/raw/shared/4d05c2d7a531aece-oauth.js +500 -0
- package/src/tui-compiled/raw/shared/6371b24a0573d79e-any-signal.js +42 -0
- package/src/tui-compiled/raw/shared/7c140680fc81be92-interlock.js +29 -0
- package/src/tui-compiled/raw/shared/85512cc5263ba259-provider.js +87 -0
- package/src/tui-compiled/raw/shared/abd062d633e29fd2-pool-routing.js +192 -0
- package/src/tui-compiled/raw/shared/b9588ad4725c1d10-enrollment.js +806 -0
- package/src/tui-compiled/raw/shared/bfbdd0f9aae56b47-pool-quota.js +85 -0
- package/src/tui-compiled/raw/shared/c852a15cfe489d12-pool.js +0 -1
- package/src/tui-compiled/raw/shared/ca0fccaba1d87af4-errors.js +8 -0
- package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +152 -0
- package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +1 -1
- package/src/tui-compiled/raw/shared/d9d2892a4c371301-host-slot.js +72 -0
- package/src/tui-compiled/raw/shared/dbc61cd405941828-accounts.js +1470 -0
- package/src/tui-compiled/raw/shared/e8cd4929507508d8-refresh-file-lock.js +26 -12
- package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +24 -0
- package/src/tui-compiled/raw/sidebar-state.js +103 -14
- package/src/tui-compiled/raw/tui/command-dialogs.tsx +3 -34
- package/src/tui-compiled/raw/tui/section-text.js +21 -0
- package/src/tui-compiled/raw/tui/v2-dialog.js +137 -0
- package/src/tui-compiled/raw/tui/v2.tsx +133 -0
- package/src/tui-compiled/raw/tui.tsx +228 -70
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +148 -26
- package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +371 -0
- package/src/tui-compiled/runtime/shared/134b008900698fe0-backoff.js +92 -0
- package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +430 -0
- package/src/tui-compiled/runtime/shared/2577ec69f9973ae9-quota-normalize.js +154 -0
- package/src/tui-compiled/runtime/shared/2dce6e9613993329-quota-manager.js +473 -0
- package/src/tui-compiled/runtime/shared/30f5bc480f31f51c-roster.js +566 -0
- package/src/tui-compiled/runtime/shared/43fc9e6b994519c7-refresh-lock.js +0 -1
- package/src/tui-compiled/runtime/shared/4593a4d71edf69fd-tombstone.js +25 -0
- package/src/tui-compiled/runtime/shared/4d05c2d7a531aece-oauth.js +500 -0
- package/src/tui-compiled/runtime/shared/6371b24a0573d79e-any-signal.js +42 -0
- package/src/tui-compiled/runtime/shared/7c140680fc81be92-interlock.js +29 -0
- package/src/tui-compiled/runtime/shared/85512cc5263ba259-provider.js +87 -0
- package/src/tui-compiled/runtime/shared/abd062d633e29fd2-pool-routing.js +192 -0
- package/src/tui-compiled/runtime/shared/b9588ad4725c1d10-enrollment.js +806 -0
- package/src/tui-compiled/runtime/shared/bfbdd0f9aae56b47-pool-quota.js +85 -0
- package/src/tui-compiled/runtime/shared/c852a15cfe489d12-pool.js +0 -1
- package/src/tui-compiled/runtime/shared/ca0fccaba1d87af4-errors.js +8 -0
- package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +152 -0
- package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +1 -1
- package/src/tui-compiled/runtime/shared/d9d2892a4c371301-host-slot.js +72 -0
- package/src/tui-compiled/runtime/shared/dbc61cd405941828-accounts.js +1470 -0
- package/src/tui-compiled/runtime/shared/e8cd4929507508d8-refresh-file-lock.js +26 -12
- package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +24 -0
- package/src/tui-compiled/runtime/sidebar-state.js +103 -14
- package/src/tui-compiled/runtime/tui/command-dialogs.js +3 -15
- package/src/tui-compiled/runtime/tui/section-text.js +21 -0
- package/src/tui-compiled/runtime/tui/v2-dialog.js +137 -0
- package/src/tui-compiled/runtime/tui/v2.js +95 -0
- package/src/tui-compiled/runtime/tui.js +351 -222
- package/dist/index-4fnghj9e.js +0 -65
- package/dist/index-a69gr76b.js +0 -2
- package/dist/index-h263aftt.js +0 -157
- package/dist/index-ssj7m2rd.js +0 -8
- package/dist/index-ydz3hbhc.js +0 -2
- package/dist/map-m6f5a_IA.d.ts +0 -56
- package/dist/token-util-4a2xxnpg.js +0 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 86400000;
|
|
3
|
+
const MIN_REFRESH_RETRY_DELAY_MS = 300000, MAX_REFRESH_RETRY_DELAY_MS = 3600000, MIN_QUOTA_RETRY_DELAY_MS = 60000, MAX_QUOTA_RETRY_DELAY_MS = 900000, NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 300000;
|
|
4
|
+
function formatErrorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
export function isTransientRefreshError(error) {
|
|
8
|
+
const status = error?.status;
|
|
9
|
+
if (typeof status === "number")
|
|
10
|
+
return status === 429 || status >= 500;
|
|
11
|
+
if (!(error instanceof Error))
|
|
12
|
+
return !1;
|
|
13
|
+
return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch failed") || "code" in error && (error.code === "ECONNRESET" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT" || error.code === "UND_ERR_CONNECT_TIMEOUT");
|
|
14
|
+
}
|
|
15
|
+
export function isTransientQuotaError(error) {
|
|
16
|
+
const message = formatErrorMessage(error), status = error?.status;
|
|
17
|
+
if (typeof status === "number")
|
|
18
|
+
return status === 429 || status >= 500;
|
|
19
|
+
if (!(error instanceof Error))
|
|
20
|
+
return !1;
|
|
21
|
+
const code = error.code;
|
|
22
|
+
return error.name === "AbortError" || error.name === "TimeoutError" || message.includes("fetch failed") || code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT";
|
|
23
|
+
}
|
|
24
|
+
export function hashRefreshToken(refreshToken) {
|
|
25
|
+
return createHash("sha256").update(refreshToken).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
export function buildRefreshOperationError(input) {
|
|
28
|
+
const tokenHash = hashRefreshToken(input.refreshToken), retryCount = (input.previous?.tokenHash === tokenHash ? input.previous.retryCount ?? 0 : 0) + 1;
|
|
29
|
+
let delay;
|
|
30
|
+
const retryAfter = input.error?.retryAfter;
|
|
31
|
+
if (typeof retryAfter === "number" && retryAfter > 0)
|
|
32
|
+
delay = retryAfter * 1000;
|
|
33
|
+
else if (isTransientRefreshError(input.error))
|
|
34
|
+
delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
35
|
+
else
|
|
36
|
+
delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
|
|
37
|
+
return {
|
|
38
|
+
message: formatErrorMessage(input.error),
|
|
39
|
+
checkedAt: input.now,
|
|
40
|
+
nextRetryAt: input.now + delay,
|
|
41
|
+
retryCount,
|
|
42
|
+
tokenHash
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function refreshBackoffActive(error, refreshToken, now) {
|
|
46
|
+
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
47
|
+
return !1;
|
|
48
|
+
if (!refreshToken)
|
|
49
|
+
return !0;
|
|
50
|
+
return error.tokenHash === hashRefreshToken(refreshToken);
|
|
51
|
+
}
|
|
52
|
+
export function formatRefreshBackoffMessage(error, now) {
|
|
53
|
+
return `Codex OAuth refresh is backed off for ${Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000))}s after: ${error.message}`;
|
|
54
|
+
}
|
|
55
|
+
export function buildQuotaOperationError(input) {
|
|
56
|
+
const retryCount = (input.previous?.retryCount ?? 0) + 1;
|
|
57
|
+
let delay;
|
|
58
|
+
const retryAfter = input.error?.retryAfter;
|
|
59
|
+
if (typeof retryAfter === "number" && retryAfter > 0)
|
|
60
|
+
delay = retryAfter * 1000;
|
|
61
|
+
else if (isTransientQuotaError(input.error))
|
|
62
|
+
delay = Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
63
|
+
else
|
|
64
|
+
delay = NON_TRANSIENT_QUOTA_RETRY_DELAY_MS;
|
|
65
|
+
return {
|
|
66
|
+
message: formatErrorMessage(input.error),
|
|
67
|
+
checkedAt: input.now,
|
|
68
|
+
nextRetryAt: input.now + delay,
|
|
69
|
+
retryCount
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function quotaBackoffActive(error, now) {
|
|
73
|
+
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
74
|
+
return !1;
|
|
75
|
+
return !0;
|
|
76
|
+
}
|
|
77
|
+
export function formatQuotaBackoffMessage(error, now) {
|
|
78
|
+
return `Quota API backed off for ${Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000))}s after: ${error.message}`;
|
|
79
|
+
}
|
|
80
|
+
export function parseRetryAfter(value) {
|
|
81
|
+
if (!value)
|
|
82
|
+
return;
|
|
83
|
+
const seconds = Number(value);
|
|
84
|
+
if (Number.isFinite(seconds) && seconds > 0)
|
|
85
|
+
return Math.ceil(seconds);
|
|
86
|
+
const date = Date.parse(value);
|
|
87
|
+
if (Number.isFinite(date)) {
|
|
88
|
+
const delta = Math.ceil((date - Date.now()) / 1000);
|
|
89
|
+
return delta > 0 ? delta : void 0;
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { createLogger } from "./cc9e0702a752cea7-index.js";
|
|
2
|
+
import { combineAbortSignals } from "./6371b24a0573d79e-any-signal.js";
|
|
3
|
+
import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from "./0f77ebc3c914c9c6-custody.js";
|
|
4
|
+
import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
|
|
5
|
+
import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
|
|
6
|
+
import { acquireInsulaQuotaSource } from "./d0f0b6be200311af-insula.js";
|
|
7
|
+
import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
|
|
8
|
+
/**
|
|
9
|
+
* One plugin host's view of its vault accounts. It turns vault credentials
|
|
10
|
+
* into rows for `/routing`, authorizes each physical send with the vault, and
|
|
11
|
+
* reports a 401 against the exact record version that send used. Metadata
|
|
12
|
+
* (the connection and discovery) is shared and coalesced; credential reads
|
|
13
|
+
* never are. It never enrolls: a missing token is an error here, and setup
|
|
14
|
+
* is where enrollment happens.
|
|
15
|
+
*/
|
|
16
|
+
export class ClaustrumConsumer {
|
|
17
|
+
#options;
|
|
18
|
+
#shutdown = new AbortController();
|
|
19
|
+
#logger;
|
|
20
|
+
#custody;
|
|
21
|
+
#connecting;
|
|
22
|
+
#refreshing;
|
|
23
|
+
#roster;
|
|
24
|
+
/**
|
|
25
|
+
* The grants named by this instance's latest discovery, with the view that
|
|
26
|
+
* discovery committed. Memory only: the roster file never carries it.
|
|
27
|
+
*/
|
|
28
|
+
#grantReach;
|
|
29
|
+
#timer;
|
|
30
|
+
#started = false;
|
|
31
|
+
#insula;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.#options = options;
|
|
34
|
+
this.#logger = options.logger ?? createLogger('claustrum');
|
|
35
|
+
}
|
|
36
|
+
#assertOpen(signal) {
|
|
37
|
+
this.#shutdown.signal.throwIfAborted();
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
}
|
|
40
|
+
async #active() {
|
|
41
|
+
return (await this.#options.isCustodyActive?.()) ?? true;
|
|
42
|
+
}
|
|
43
|
+
#wait(pending, signal) {
|
|
44
|
+
const combined = combineAbortSignals([
|
|
45
|
+
this.#shutdown.signal,
|
|
46
|
+
...(signal ? [signal] : []),
|
|
47
|
+
]);
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
combined.signal.removeEventListener('abort', abort);
|
|
51
|
+
combined.dispose();
|
|
52
|
+
};
|
|
53
|
+
const abort = () => {
|
|
54
|
+
cleanup();
|
|
55
|
+
reject(combined.signal.reason);
|
|
56
|
+
};
|
|
57
|
+
combined.signal.addEventListener('abort', abort, { once: true });
|
|
58
|
+
// Observe the operation even after cancellation: shared metadata work may
|
|
59
|
+
// finish for other callers, and a late connection must still be closed.
|
|
60
|
+
pending.then((value) => {
|
|
61
|
+
cleanup();
|
|
62
|
+
if (combined.signal.aborted)
|
|
63
|
+
reject(combined.signal.reason);
|
|
64
|
+
else
|
|
65
|
+
resolve(value);
|
|
66
|
+
}, (error) => {
|
|
67
|
+
cleanup();
|
|
68
|
+
reject(error);
|
|
69
|
+
});
|
|
70
|
+
if (combined.signal.aborted)
|
|
71
|
+
abort();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async #getCustody() {
|
|
75
|
+
this.#assertOpen();
|
|
76
|
+
if (this.#custody)
|
|
77
|
+
return this.#custody;
|
|
78
|
+
if (!this.#connecting) {
|
|
79
|
+
this.#connecting = this.#options
|
|
80
|
+
.connect()
|
|
81
|
+
.then((client) => {
|
|
82
|
+
if (this.#shutdown.signal.aborted) {
|
|
83
|
+
client.close();
|
|
84
|
+
this.#assertOpen();
|
|
85
|
+
}
|
|
86
|
+
this.#custody = new ClaustrumScopedCustody({
|
|
87
|
+
client,
|
|
88
|
+
family: this.#options.family,
|
|
89
|
+
tokenPath: this.#options.tokenPath,
|
|
90
|
+
parseIdentity: this.#options.parseIdentity,
|
|
91
|
+
requireAssertion: this.#options.requireAssertion,
|
|
92
|
+
now: this.#options.now,
|
|
93
|
+
logger: this.#logger,
|
|
94
|
+
});
|
|
95
|
+
if (this.#options.insula && client.insula) {
|
|
96
|
+
this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
|
|
97
|
+
}
|
|
98
|
+
return this.#custody;
|
|
99
|
+
})
|
|
100
|
+
.finally(() => {
|
|
101
|
+
this.#connecting = undefined;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return this.#wait(this.#connecting);
|
|
105
|
+
}
|
|
106
|
+
/** The last committed roster this instance saw, without any I/O. */
|
|
107
|
+
snapshot() {
|
|
108
|
+
return this.#roster;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* What this consumer is granted, from its latest discovery, without any I/O.
|
|
112
|
+
* It explains an empty roster: `grants === 0` means no grant at all, while
|
|
113
|
+
* `grants > 0` means a grant that reaches no credential (`tuples` names its
|
|
114
|
+
* selector). Undefined means unknown, never zero: the vault or client is too
|
|
115
|
+
* old to send it, nothing was discovered yet, or the roster now in memory is
|
|
116
|
+
* a different view than the one this instance discovered (a peer process
|
|
117
|
+
* held the discovery lease and its newer roster was served instead).
|
|
118
|
+
*/
|
|
119
|
+
grantReach() {
|
|
120
|
+
const latest = this.#grantReach;
|
|
121
|
+
return latest && this.#roster?.view === latest.view
|
|
122
|
+
? latest.reach
|
|
123
|
+
: undefined;
|
|
124
|
+
}
|
|
125
|
+
/** Rows for `/routing`, from the last committed roster. */
|
|
126
|
+
routingRows() {
|
|
127
|
+
return vaultRoutingRows(this.#roster);
|
|
128
|
+
}
|
|
129
|
+
refresh() {
|
|
130
|
+
this.#assertOpen();
|
|
131
|
+
if (this.#refreshing)
|
|
132
|
+
return this.#refreshing;
|
|
133
|
+
this.#refreshing = (async () => {
|
|
134
|
+
if (!(await this.#active())) {
|
|
135
|
+
this.#roster = undefined;
|
|
136
|
+
this.#grantReach = undefined;
|
|
137
|
+
this.#custody?.close();
|
|
138
|
+
this.#custody = undefined;
|
|
139
|
+
this.#insula?.release();
|
|
140
|
+
this.#insula = undefined;
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const custody = await this.#getCustody();
|
|
144
|
+
// Capture the inventory so its grant fields can be kept beside the roster;
|
|
145
|
+
// the roster file itself never stores them.
|
|
146
|
+
let discovered;
|
|
147
|
+
const roster = await refreshVaultRoster({
|
|
148
|
+
path: this.#options.rosterPath,
|
|
149
|
+
custody: {
|
|
150
|
+
discover: async (signal) => {
|
|
151
|
+
discovered = await custody.discover(signal);
|
|
152
|
+
return discovered;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
isActive: () => this.#active(),
|
|
156
|
+
signal: this.#shutdown.signal,
|
|
157
|
+
projection: () => ({
|
|
158
|
+
reservedRouteIds: new Set(this.#options.reservedRouteIds?.() ?? []),
|
|
159
|
+
routePrefix: this.#options.routePrefix,
|
|
160
|
+
mapAccount: this.#options.mapAccount,
|
|
161
|
+
now: (this.#options.now ?? Date.now)(),
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
this.#assertOpen();
|
|
165
|
+
const changed = roster?.view !== this.#roster?.view;
|
|
166
|
+
this.#roster = roster;
|
|
167
|
+
// Each discovery replaces the previous value, including with unknown. A
|
|
168
|
+
// refresh that served a peer's roster discovered nothing and leaves it;
|
|
169
|
+
// grantReach() hides it once the roster's view no longer matches.
|
|
170
|
+
if (discovered && roster?.view !== undefined)
|
|
171
|
+
this.#grantReach = discovered.grantReach
|
|
172
|
+
? { view: roster.view, reach: discovered.grantReach }
|
|
173
|
+
: undefined;
|
|
174
|
+
// Notify on view changes only: subscribers rewrite shared state on each
|
|
175
|
+
// notification, and the poll runs every few seconds.
|
|
176
|
+
if (roster && changed)
|
|
177
|
+
this.#options.onRoster?.(roster);
|
|
178
|
+
return roster;
|
|
179
|
+
})().finally(() => {
|
|
180
|
+
this.#refreshing = undefined;
|
|
181
|
+
});
|
|
182
|
+
return this.#refreshing;
|
|
183
|
+
}
|
|
184
|
+
start() {
|
|
185
|
+
if (this.#started)
|
|
186
|
+
return;
|
|
187
|
+
this.#assertOpen();
|
|
188
|
+
this.#started = true;
|
|
189
|
+
const tick = async () => {
|
|
190
|
+
try {
|
|
191
|
+
await this.refresh();
|
|
192
|
+
await this.refreshInsulaQuota();
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (!this.#shutdown.signal.aborted)
|
|
196
|
+
this.#options.onError?.(error);
|
|
197
|
+
}
|
|
198
|
+
if (this.#shutdown.signal.aborted)
|
|
199
|
+
return;
|
|
200
|
+
const delay = this.#options.pollIntervalMs ?? 5_000;
|
|
201
|
+
if (delay <= 0)
|
|
202
|
+
return;
|
|
203
|
+
this.#timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
|
|
204
|
+
this.#timer = undefined;
|
|
205
|
+
void tick();
|
|
206
|
+
}, delay);
|
|
207
|
+
this.#timer.unref?.();
|
|
208
|
+
};
|
|
209
|
+
void tick();
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Fetch on a background tick, at most once per minute across projects with
|
|
213
|
+
* the same daemon and enrollment token path. Admission must use the cache
|
|
214
|
+
* instead, so an optional quota daemon never delays a provider request.
|
|
215
|
+
*/
|
|
216
|
+
async refreshInsulaQuota() {
|
|
217
|
+
if (!this.#options.insula ||
|
|
218
|
+
this.#shutdown.signal.aborted ||
|
|
219
|
+
!(await this.#active()))
|
|
220
|
+
return;
|
|
221
|
+
try {
|
|
222
|
+
await readClaustrumEnrollmentToken(this.#options.tokenPath);
|
|
223
|
+
await this.#getCustody();
|
|
224
|
+
await this.#insula?.source.refresh((this.#options.now ?? Date.now)());
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// An optional source must never prevent the plugin's own quota poll.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Cache-only, for unknown-quota admission with a served receipt. Undefined
|
|
232
|
+
* means poll the provider normally. Only get_scoped's asserted account_id
|
|
233
|
+
* joins insula; parsed identities and display metadata cannot substitute.
|
|
234
|
+
* Background polling should use cachedInsulaQuotaForRoute instead.
|
|
235
|
+
*/
|
|
236
|
+
async cachedInsulaQuota(receipt) {
|
|
237
|
+
if (!this.#options.insula ||
|
|
238
|
+
this.#shutdown.signal.aborted ||
|
|
239
|
+
!(await this.#active()))
|
|
240
|
+
return undefined;
|
|
241
|
+
try {
|
|
242
|
+
await readClaustrumEnrollmentToken(this.#options.tokenPath);
|
|
243
|
+
return this.#normalizeInsulaQuota(receipt.assertedAccountIdentity);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Background cache lookup without fetching a credential. Re-read the roster
|
|
251
|
+
* so a peer's account replacement invalidates the old route immediately.
|
|
252
|
+
* A route without a known provider account cannot join an insula entry.
|
|
253
|
+
*/
|
|
254
|
+
async cachedInsulaQuotaForRoute(routeId) {
|
|
255
|
+
if (!this.#options.insula ||
|
|
256
|
+
this.#shutdown.signal.aborted ||
|
|
257
|
+
!(await this.#active()))
|
|
258
|
+
return undefined;
|
|
259
|
+
try {
|
|
260
|
+
await readClaustrumEnrollmentToken(this.#options.tokenPath);
|
|
261
|
+
const roster = await readVaultRoster(this.#options.rosterPath);
|
|
262
|
+
const row = roster?.rows.find((entry) => entry.routeId === routeId);
|
|
263
|
+
return this.#normalizeInsulaQuota(row?.accountIdentity);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
#normalizeInsulaQuota(accountIdentity) {
|
|
270
|
+
if (!this.#options.insula || !accountIdentity)
|
|
271
|
+
return undefined;
|
|
272
|
+
const current = this.#insula?.source.current(this.#options.insula.provider, accountIdentity, (this.#options.now ?? Date.now)());
|
|
273
|
+
return current
|
|
274
|
+
? this.#options.insula.normalize(current.entry, current.context)
|
|
275
|
+
: undefined;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Authorize one physical send on a vault route. The roster file is re-read
|
|
279
|
+
* first, so a decline committed by another process applies before the next
|
|
280
|
+
* poll, and a route whose credential or account changed is refused.
|
|
281
|
+
*/
|
|
282
|
+
async authorize(routeId, signal) {
|
|
283
|
+
this.#assertOpen(signal);
|
|
284
|
+
const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
|
|
285
|
+
if (!roster || !(await this.#active()))
|
|
286
|
+
throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
287
|
+
const current = await readVaultRoster(this.#options.rosterPath);
|
|
288
|
+
this.#assertOpen(signal);
|
|
289
|
+
const observed = roster.rows.find((row) => row.routeId === routeId);
|
|
290
|
+
const configured = current?.rows.find((row) => row.routeId === routeId);
|
|
291
|
+
if (configured && !configured.enabled)
|
|
292
|
+
throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
|
|
293
|
+
if (!observed ||
|
|
294
|
+
!configured ||
|
|
295
|
+
configured.state !== 'active' ||
|
|
296
|
+
configured.credentialId !== observed.credentialId ||
|
|
297
|
+
configured.accountIdentity !== observed.accountIdentity)
|
|
298
|
+
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
|
|
299
|
+
const custody = await this.#wait(this.#getCustody(), signal);
|
|
300
|
+
const combined = combineAbortSignals([
|
|
301
|
+
this.#shutdown.signal,
|
|
302
|
+
...(signal ? [signal] : []),
|
|
303
|
+
]);
|
|
304
|
+
try {
|
|
305
|
+
return await custody.authorize({
|
|
306
|
+
credentialId: configured.credentialId,
|
|
307
|
+
credentialType: configured.credentialType,
|
|
308
|
+
...(configured.accountIdentity !== undefined && {
|
|
309
|
+
accountIdentity: configured.accountIdentity,
|
|
310
|
+
}),
|
|
311
|
+
}, combined.signal);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
combined.dispose();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async reportFailure(attempt, status, source) {
|
|
318
|
+
this.#assertOpen();
|
|
319
|
+
if (!this.#custody)
|
|
320
|
+
throw new ClaustrumConsumerError('no-receipt', 'Claustrum dispatch receipt has no active owner');
|
|
321
|
+
await this.#custody.reportFailure(attempt, status, source);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Send on a vault route. `dispatch` builds and sends the request with the
|
|
325
|
+
* receipt's token and may be called twice, so it must be able to rebuild
|
|
326
|
+
* its body. A 401 is retried once, and only when the vault now serves a new
|
|
327
|
+
* record version of the same credential and account; the final 401 is
|
|
328
|
+
* reported against the version that send actually used. Each call to
|
|
329
|
+
* `dispatch` gets its own receipt.
|
|
330
|
+
*/
|
|
331
|
+
async send(routeId, dispatch, options) {
|
|
332
|
+
const signal = options.signal;
|
|
333
|
+
let served = await this.authorize(routeId, signal);
|
|
334
|
+
let response = await dispatch(served, signal);
|
|
335
|
+
if (response.status === 401 && !signal?.aborted) {
|
|
336
|
+
let current;
|
|
337
|
+
try {
|
|
338
|
+
current = await this.authorize(routeId, signal);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// Re-authorization failed: keep this 401 and report it against the
|
|
342
|
+
// receipt (served record version) this request was sent with.
|
|
343
|
+
}
|
|
344
|
+
if (decideScopedRetryAfter401(options.site, served, current, this.#logger)) {
|
|
345
|
+
await response.body?.cancel().catch(() => { });
|
|
346
|
+
served = current;
|
|
347
|
+
response = await dispatch(current, signal);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (response.status === 401) {
|
|
351
|
+
await this.reportFailure(served, 401, options.reporterSource ?? 'direct').catch((error) => this.#options.onError?.(error));
|
|
352
|
+
// After the report the vault may mark the account as needing a new
|
|
353
|
+
// login; refresh now so routing drops it without waiting for the next poll. Deferred so a consumer closed in
|
|
354
|
+
// the meantime reports to onError instead of throwing from this send.
|
|
355
|
+
Promise.resolve()
|
|
356
|
+
.then(() => this.refresh())
|
|
357
|
+
.catch((error) => {
|
|
358
|
+
if (!this.#shutdown.signal.aborted)
|
|
359
|
+
this.#options.onError?.(error);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return response;
|
|
363
|
+
}
|
|
364
|
+
/** Decline a vault account: it stays listed and never routes until accepted. */
|
|
365
|
+
async decline(routeId) {
|
|
366
|
+
this.#assertOpen();
|
|
367
|
+
await declineVaultRoute(this.#options.rosterPath, routeId);
|
|
368
|
+
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
369
|
+
}
|
|
370
|
+
async accept(routeId) {
|
|
371
|
+
this.#assertOpen();
|
|
372
|
+
await acceptVaultRoute(this.#options.rosterPath, routeId);
|
|
373
|
+
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Store a quota or profile observation for a vault route. The receipt the
|
|
377
|
+
* reading was taken with is required: the observation lands only while the
|
|
378
|
+
* route still holds the credential and account that receipt was served for
|
|
379
|
+
* (see `recordVaultQuota`), so a reading for a replaced account is dropped.
|
|
380
|
+
*/
|
|
381
|
+
async recordQuota(routeId, observation, attempt) {
|
|
382
|
+
this.#assertOpen();
|
|
383
|
+
const kept = await recordVaultQuota(this.#options.rosterPath, {
|
|
384
|
+
routeId,
|
|
385
|
+
observation,
|
|
386
|
+
credentialId: attempt.credentialId,
|
|
387
|
+
accountIdentitySource: attempt.accountIdentitySource,
|
|
388
|
+
...(attempt.accountIdentity !== undefined && {
|
|
389
|
+
accountIdentity: attempt.accountIdentity,
|
|
390
|
+
}),
|
|
391
|
+
...(attempt.expectedAccountIdentity !== undefined && {
|
|
392
|
+
expectedAccountIdentity: attempt.expectedAccountIdentity,
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
if (kept)
|
|
396
|
+
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
397
|
+
return kept;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Store a background observation that belongs to an account rather than to
|
|
401
|
+
* a served credential (an insula reading from `cachedInsulaQuotaForRoute`).
|
|
402
|
+
* It lands only while the route is still bound to that known account, so no
|
|
403
|
+
* credential fetch is needed and a replaced account's reading is dropped.
|
|
404
|
+
* The in-memory roster is reloaded from the file, without a vault call.
|
|
405
|
+
*/
|
|
406
|
+
async recordAccountQuota(routeId, accountIdentity, observation) {
|
|
407
|
+
this.#assertOpen();
|
|
408
|
+
const kept = await recordVaultAccountQuota(this.#options.rosterPath, {
|
|
409
|
+
routeId,
|
|
410
|
+
accountIdentity,
|
|
411
|
+
observation,
|
|
412
|
+
});
|
|
413
|
+
if (kept)
|
|
414
|
+
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
415
|
+
return kept;
|
|
416
|
+
}
|
|
417
|
+
close() {
|
|
418
|
+
if (this.#shutdown.signal.aborted)
|
|
419
|
+
return;
|
|
420
|
+
this.#shutdown.abort(new ClaustrumConsumerError('closed', 'Claustrum consumer is closed'));
|
|
421
|
+
if (this.#timer)
|
|
422
|
+
(this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
|
|
423
|
+
this.#timer = undefined;
|
|
424
|
+
this.#custody?.close();
|
|
425
|
+
this.#insula?.release();
|
|
426
|
+
this.#insula = undefined;
|
|
427
|
+
this.#roster = undefined;
|
|
428
|
+
this.#grantReach = undefined;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export function toResetIso(raw) {
|
|
2
|
+
if (raw == null || raw === "")
|
|
3
|
+
return;
|
|
4
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
5
|
+
if (Number.isFinite(n)) {
|
|
6
|
+
const ms = n < 100000000000 ? n * 1000 : n, d = new Date(ms);
|
|
7
|
+
return Number.isNaN(d.getTime()) ? void 0 : d.toISOString();
|
|
8
|
+
}
|
|
9
|
+
const d = new Date(raw);
|
|
10
|
+
return Number.isNaN(d.getTime()) ? void 0 : d.toISOString();
|
|
11
|
+
}
|
|
12
|
+
function positiveFinite(value) {
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
15
|
+
}
|
|
16
|
+
function nonNegativeFinite(value) {
|
|
17
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
18
|
+
}
|
|
19
|
+
function windowFromHeader(h, prefix) {
|
|
20
|
+
const used = h.get(prefix);
|
|
21
|
+
if (used === null || used.trim() === "")
|
|
22
|
+
return;
|
|
23
|
+
const usedPercent = Number(used);
|
|
24
|
+
if (!Number.isFinite(usedPercent) || usedPercent < 0 || usedPercent > 100)
|
|
25
|
+
return;
|
|
26
|
+
const base = prefix.slice(0, -13), windowMinutes = positiveFinite(h.get(`${base}-window-minutes`)), resetsAt = toResetIso(h.get(`${base}-reset-at`) ?? void 0);
|
|
27
|
+
if (usedPercent === 0 && h.get(`${base}-window-minutes`) === "0" && !resetsAt)
|
|
28
|
+
return;
|
|
29
|
+
return {
|
|
30
|
+
usedPercent,
|
|
31
|
+
remainingPercent: 100 - usedPercent,
|
|
32
|
+
resetsAt,
|
|
33
|
+
checkedAt: Date.now(),
|
|
34
|
+
...windowMinutes !== void 0 ? { windowMinutes } : {}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const QUOTA_USED_PERCENT_HEADERS = [
|
|
38
|
+
"x-codex-primary-used-percent",
|
|
39
|
+
"x-codex-secondary-used-percent"
|
|
40
|
+
];
|
|
41
|
+
function usedPercentHeaderState(h, name) {
|
|
42
|
+
const raw = h.get(name);
|
|
43
|
+
if (raw === null)
|
|
44
|
+
return "absent";
|
|
45
|
+
if (raw.trim() === "")
|
|
46
|
+
return "invalid";
|
|
47
|
+
const value = Number(raw);
|
|
48
|
+
return Number.isFinite(value) && value >= 0 && value <= 100 ? "valid" : "invalid";
|
|
49
|
+
}
|
|
50
|
+
export function normalizeQuotaHeaders(h) {
|
|
51
|
+
if (QUOTA_USED_PERCENT_HEADERS.some((name) => usedPercentHeaderState(h, name) === "invalid"))
|
|
52
|
+
return {};
|
|
53
|
+
const snapshot = {}, primary = windowFromHeader(h, "x-codex-primary-used-percent");
|
|
54
|
+
if (primary)
|
|
55
|
+
snapshot.primary = primary;
|
|
56
|
+
const secondary = windowFromHeader(h, "x-codex-secondary-used-percent");
|
|
57
|
+
if (secondary)
|
|
58
|
+
snapshot.secondary = secondary;
|
|
59
|
+
return snapshot;
|
|
60
|
+
}
|
|
61
|
+
export function isCompleteQuotaHeaderFrame(h) {
|
|
62
|
+
const states = QUOTA_USED_PERCENT_HEADERS.map((name) => usedPercentHeaderState(h, name));
|
|
63
|
+
return states.includes("valid") && !states.includes("invalid");
|
|
64
|
+
}
|
|
65
|
+
function windowFromWs(w) {
|
|
66
|
+
if (!w)
|
|
67
|
+
return;
|
|
68
|
+
if (!Number.isFinite(w.used_percent) || w.used_percent < 0 || w.used_percent > 100)
|
|
69
|
+
return;
|
|
70
|
+
const windowMinutes = positiveFinite(w.window_minutes), resetsAt = toResetIso(w.reset_at);
|
|
71
|
+
if (w.used_percent === 0 && windowMinutes === void 0 && !resetsAt)
|
|
72
|
+
return;
|
|
73
|
+
return {
|
|
74
|
+
usedPercent: w.used_percent,
|
|
75
|
+
remainingPercent: 100 - w.used_percent,
|
|
76
|
+
resetsAt,
|
|
77
|
+
checkedAt: Date.now(),
|
|
78
|
+
...windowMinutes !== void 0 ? { windowMinutes } : {}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function normalizeWsFrame(event) {
|
|
82
|
+
const snapshot = {}, primary = windowFromWs(event.rate_limits?.primary);
|
|
83
|
+
if (primary)
|
|
84
|
+
snapshot.primary = primary;
|
|
85
|
+
const secondary = windowFromWs(event.rate_limits?.secondary);
|
|
86
|
+
if (secondary)
|
|
87
|
+
snapshot.secondary = secondary;
|
|
88
|
+
return snapshot;
|
|
89
|
+
}
|
|
90
|
+
function nonNegativeNumberish(value) {
|
|
91
|
+
if (value == null || value === "")
|
|
92
|
+
return;
|
|
93
|
+
const parsed = Number(value);
|
|
94
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
95
|
+
}
|
|
96
|
+
function windowFromWham(w) {
|
|
97
|
+
if (!w)
|
|
98
|
+
return;
|
|
99
|
+
if (!Number.isFinite(w.used_percent) || w.used_percent < 0 || w.used_percent > 100)
|
|
100
|
+
return;
|
|
101
|
+
const windowSeconds = positiveFinite(w.limit_window_seconds), resetsAt = toResetIso(w.reset_at);
|
|
102
|
+
if (w.used_percent === 0 && windowSeconds === void 0 && !resetsAt)
|
|
103
|
+
return;
|
|
104
|
+
return {
|
|
105
|
+
usedPercent: w.used_percent,
|
|
106
|
+
remainingPercent: 100 - w.used_percent,
|
|
107
|
+
resetsAt,
|
|
108
|
+
checkedAt: Date.now(),
|
|
109
|
+
...windowSeconds !== void 0 ? { windowMinutes: windowSeconds / 60 } : {}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function normalizeWham(json) {
|
|
113
|
+
const snapshot = {}, primary = windowFromWham(json.rate_limit?.primary_window);
|
|
114
|
+
if (primary)
|
|
115
|
+
snapshot.primary = primary;
|
|
116
|
+
const secondary = windowFromWham(json.rate_limit?.secondary_window);
|
|
117
|
+
if (secondary)
|
|
118
|
+
snapshot.secondary = secondary;
|
|
119
|
+
const resetCreditsAvailable = nonNegativeFinite(json.rate_limit_reset_credits?.available_count);
|
|
120
|
+
if (resetCreditsAvailable !== void 0)
|
|
121
|
+
snapshot.resetCreditsAvailable = resetCreditsAvailable;
|
|
122
|
+
const resetCreditsApplicable = nonNegativeFinite(json.rate_limit_reset_credits?.applicable_available_count);
|
|
123
|
+
if (resetCreditsApplicable !== void 0)
|
|
124
|
+
snapshot.resetCreditsApplicable = resetCreditsApplicable;
|
|
125
|
+
const individualLimit = json.spend_control?.individual_limit;
|
|
126
|
+
if (json.spend_control === null || individualLimit === null)
|
|
127
|
+
snapshot.spendControlCleared = !0;
|
|
128
|
+
if (individualLimit && typeof json.spend_control?.reached === "boolean") {
|
|
129
|
+
const limit = nonNegativeNumberish(individualLimit.limit), used = nonNegativeNumberish(individualLimit.used), remaining = nonNegativeNumberish(individualLimit.remaining), usedPercent = nonNegativeNumberish(individualLimit.used_percent), remainingPercent = nonNegativeNumberish(individualLimit.remaining_percent);
|
|
130
|
+
if (limit !== void 0 && used !== void 0 && remaining !== void 0 && usedPercent !== void 0 && remainingPercent !== void 0)
|
|
131
|
+
snapshot.spendControl = {
|
|
132
|
+
limit,
|
|
133
|
+
used,
|
|
134
|
+
remaining,
|
|
135
|
+
usedPercent,
|
|
136
|
+
remainingPercent,
|
|
137
|
+
resetsAt: toResetIso(individualLimit.reset_at),
|
|
138
|
+
unit: individualLimit.unit,
|
|
139
|
+
source: individualLimit.source,
|
|
140
|
+
reached: json.spend_control.reached
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const credits = json.credits;
|
|
144
|
+
if (credits && typeof credits.has_credits === "boolean" && typeof credits.unlimited === "boolean" && typeof credits.overage_limit_reached === "boolean") {
|
|
145
|
+
const balance = nonNegativeNumberish(credits.balance);
|
|
146
|
+
snapshot.credits = {
|
|
147
|
+
hasCredits: credits.has_credits,
|
|
148
|
+
unlimited: credits.unlimited,
|
|
149
|
+
overageLimitReached: credits.overage_limit_reached,
|
|
150
|
+
...balance !== void 0 ? { balance } : {}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return snapshot;
|
|
154
|
+
}
|