sidekick-shared 0.21.0 → 0.21.1
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 +2 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +23 -6
- package/dist/multiProviderQuotaService.d.ts +10 -0
- package/dist/multiProviderQuotaService.js +34 -0
- package/dist/providerQuota.d.ts +2 -1
- package/dist/providers/detect.js +3 -13
- package/dist/providers/openCode.d.ts +31 -0
- package/dist/providers/openCode.js +108 -2
- package/dist/providers/openCodeDatabase.d.ts +19 -0
- package/dist/providers/openCodeDatabase.js +28 -0
- package/dist/quota.d.ts +1 -1
- package/dist/quotaHistory.d.ts +1 -1
- package/dist/quotaHistory.js +14 -2
- package/dist/quotaSnapshots.d.ts +8 -2
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +2 -1
- package/dist/schemas/quota.d.ts +165 -0
- package/dist/schemas/quota.js +8 -1
- package/dist/schemas/quotaHistory.d.ts +2 -0
- package/dist/schemas/quotaHistory.js +1 -0
- package/dist/zaiQuota.d.ts +209 -0
- package/dist/zaiQuota.js +290 -0
- package/dist/zaiQuotaWatcher.d.ts +97 -0
- package/dist/zaiQuotaWatcher.js +259 -0
- package/package.json +1 -1
package/dist/zaiQuota.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ZAI_PROVIDER_IDS = exports.ZAI_PROMPT_INVOCATIONS = exports.ZAI_TIER_BUDGETS = void 0;
|
|
4
|
+
exports.isZaiProviderId = isZaiProviderId;
|
|
5
|
+
exports.turnTokenWeight = turnTokenWeight;
|
|
6
|
+
exports.accumulateZaiUsage = accumulateZaiUsage;
|
|
7
|
+
exports.resolveZaiTier = resolveZaiTier;
|
|
8
|
+
exports.inferZaiQuotaState = inferZaiQuotaState;
|
|
9
|
+
exports.parseZaiQuotaError = parseZaiQuotaError;
|
|
10
|
+
exports.extractNextFlushTime = extractNextFlushTime;
|
|
11
|
+
exports.makeUnavailableZaiQuotaState = makeUnavailableZaiQuotaState;
|
|
12
|
+
exports.rowsToZaiTurnsAndErrors = rowsToZaiTurnsAndErrors;
|
|
13
|
+
// ── Tier budgets ──
|
|
14
|
+
/**
|
|
15
|
+
* Published z.ai coding-plan prompt budgets.
|
|
16
|
+
*
|
|
17
|
+
* Source: https://docs.z.ai/devpack/overview & /devpack/faq.
|
|
18
|
+
* "One prompt ≈ one query; each prompt is estimated to invoke the model
|
|
19
|
+
* 15–20 times." We use the midpoint (17.5) when converting observed
|
|
20
|
+
* assistant turns into prompt equivalents.
|
|
21
|
+
*/
|
|
22
|
+
exports.ZAI_TIER_BUDGETS = {
|
|
23
|
+
lite: { fiveHour: 80, weekly: 400 },
|
|
24
|
+
pro: { fiveHour: 400, weekly: 2000 },
|
|
25
|
+
max: { fiveHour: 1600, weekly: 8000 },
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Midpoint of z.ai's documented "15–20 model invocations per prompt" range.
|
|
29
|
+
* Used to convert accumulated assistant turns into prompt equivalents.
|
|
30
|
+
* Calibration note: this constant may need per-tier adjustment after
|
|
31
|
+
* real-world validation against the z.ai web UI.
|
|
32
|
+
*/
|
|
33
|
+
exports.ZAI_PROMPT_INVOCATIONS = 17.5;
|
|
34
|
+
const FIVE_HOUR_MS = 5 * 3_600_000;
|
|
35
|
+
const SEVEN_DAY_MS = 7 * 86_400_000;
|
|
36
|
+
/**
|
|
37
|
+
* Provider IDs that OpenCode tags on z.ai-routed messages.
|
|
38
|
+
* `zai-coding-plan` is the Coding Plan key; `zai` is the bare API key.
|
|
39
|
+
*/
|
|
40
|
+
exports.ZAI_PROVIDER_IDS = ['zai', 'zai-coding-plan'];
|
|
41
|
+
function isZaiProviderId(value) {
|
|
42
|
+
return value === 'zai' || value === 'zai-coding-plan';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Sums the token fields of a single turn into a scalar.
|
|
46
|
+
* Cache reads are weighted at 0.1× (Anthropic convention) to avoid
|
|
47
|
+
* over-counting repeated context. Output and reasoning tokens are weighted 1×.
|
|
48
|
+
*/
|
|
49
|
+
function turnTokenWeight(turn) {
|
|
50
|
+
return (turn.inputTokens +
|
|
51
|
+
turn.outputTokens +
|
|
52
|
+
Math.round(turn.cacheReadTokens * 0.1) +
|
|
53
|
+
turn.cacheWriteTokens +
|
|
54
|
+
(turn.reasoningTokens || 0));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reduces a stream of z.ai-routed assistant turns into rolling-window usage.
|
|
58
|
+
*
|
|
59
|
+
* Pure & stateless: the caller is responsible for passing the relevant turns.
|
|
60
|
+
* Turns outside the 7-day window are ignored; turns inside the 7-day window
|
|
61
|
+
* but outside the 5-hour window still count toward weekly usage.
|
|
62
|
+
*/
|
|
63
|
+
function accumulateZaiUsage(turns, nowMs = Date.now()) {
|
|
64
|
+
const fiveHourCutoff = nowMs - FIVE_HOUR_MS;
|
|
65
|
+
const weeklyCutoff = nowMs - SEVEN_DAY_MS;
|
|
66
|
+
let fiveHourTurns = 0;
|
|
67
|
+
let weeklyTurns = 0;
|
|
68
|
+
let fiveHourTokens = 0;
|
|
69
|
+
let weeklyTokens = 0;
|
|
70
|
+
let fiveHourStartedAtMs = null;
|
|
71
|
+
let weeklyStartedAtMs = null;
|
|
72
|
+
for (const turn of turns) {
|
|
73
|
+
if (turn.timestampMs < weeklyCutoff)
|
|
74
|
+
continue;
|
|
75
|
+
weeklyTurns += 1;
|
|
76
|
+
weeklyTokens += turnTokenWeight(turn);
|
|
77
|
+
if (weeklyStartedAtMs === null || turn.timestampMs < weeklyStartedAtMs) {
|
|
78
|
+
weeklyStartedAtMs = turn.timestampMs;
|
|
79
|
+
}
|
|
80
|
+
if (turn.timestampMs >= fiveHourCutoff) {
|
|
81
|
+
fiveHourTurns += 1;
|
|
82
|
+
fiveHourTokens += turnTokenWeight(turn);
|
|
83
|
+
if (fiveHourStartedAtMs === null || turn.timestampMs < fiveHourStartedAtMs) {
|
|
84
|
+
fiveHourStartedAtMs = turn.timestampMs;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
fiveHourTurns,
|
|
90
|
+
weeklyTurns,
|
|
91
|
+
fiveHourTokens,
|
|
92
|
+
weeklyTokens,
|
|
93
|
+
fiveHourPrompts: fiveHourTurns / exports.ZAI_PROMPT_INVOCATIONS,
|
|
94
|
+
weeklyPrompts: weeklyTurns / exports.ZAI_PROMPT_INVOCATIONS,
|
|
95
|
+
fiveHourStartedAtMs,
|
|
96
|
+
weeklyStartedAtMs,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// ── Tier resolution ──
|
|
100
|
+
/**
|
|
101
|
+
* Resolves the effective z.ai tier when the user has selected `'auto'`.
|
|
102
|
+
*
|
|
103
|
+
* Heuristic: if accumulated weekly prompts already exceed a tier's budget,
|
|
104
|
+
* the user must be on a higher tier. This under-detects at the start of a
|
|
105
|
+
* weekly cycle (a Max user looks identical to a Lite user until they cross
|
|
106
|
+
* 400 prompts/week), so callers should still expose an explicit override.
|
|
107
|
+
*/
|
|
108
|
+
function resolveZaiTier(configured, accumulated) {
|
|
109
|
+
if (configured !== 'auto')
|
|
110
|
+
return configured;
|
|
111
|
+
if (accumulated.weeklyPrompts > exports.ZAI_TIER_BUDGETS.pro.weekly)
|
|
112
|
+
return 'max';
|
|
113
|
+
if (accumulated.weeklyPrompts > exports.ZAI_TIER_BUDGETS.lite.weekly)
|
|
114
|
+
return 'pro';
|
|
115
|
+
return 'lite';
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Converts accumulated usage into a shared `QuotaState`.
|
|
119
|
+
*
|
|
120
|
+
* The two windows reuse the `fiveHour` / `sevenDay` field names for cross-
|
|
121
|
+
* provider compatibility, but are labeled "5-Hour" and "Weekly" via
|
|
122
|
+
* `fiveHourLabel` / `sevenDayLabel` — mirroring how Codex relabels its
|
|
123
|
+
* generic primary/secondary windows.
|
|
124
|
+
*
|
|
125
|
+
* Reset timestamps are best-effort because z.ai does not expose them:
|
|
126
|
+
* - `fiveHour.resetsAt` ← first turn in window + 5h, unless an error
|
|
127
|
+
* event supplied an authoritative reset.
|
|
128
|
+
* - `sevenDay.resetsAt` ← first turn in window + 7d (documented as
|
|
129
|
+
* "7 days from order time" — order time is unknown, so this is a
|
|
130
|
+
* lower bound at best).
|
|
131
|
+
*/
|
|
132
|
+
function inferZaiQuotaState(accumulated, tier, options = {}) {
|
|
133
|
+
const budget = exports.ZAI_TIER_BUDGETS[tier];
|
|
134
|
+
const capturedAt = options.capturedAt ?? new Date().toISOString();
|
|
135
|
+
const fiveHourUtil = budget.fiveHour > 0
|
|
136
|
+
? Math.min((accumulated.fiveHourPrompts / budget.fiveHour) * 100, 200)
|
|
137
|
+
: 0;
|
|
138
|
+
const weeklyUtil = budget.weekly > 0
|
|
139
|
+
? Math.min((accumulated.weeklyPrompts / budget.weekly) * 100, 200)
|
|
140
|
+
: 0;
|
|
141
|
+
const fiveHourResetsAt = options.authoritativeFiveHourResetAt
|
|
142
|
+
?? (accumulated.fiveHourStartedAtMs !== null
|
|
143
|
+
? new Date(accumulated.fiveHourStartedAtMs + FIVE_HOUR_MS).toISOString()
|
|
144
|
+
: '');
|
|
145
|
+
const sevenDayResetsAt = options.authoritativeWeeklyResetAt
|
|
146
|
+
?? (accumulated.weeklyStartedAtMs !== null
|
|
147
|
+
? new Date(accumulated.weeklyStartedAtMs + SEVEN_DAY_MS).toISOString()
|
|
148
|
+
: '');
|
|
149
|
+
const fiveHour = {
|
|
150
|
+
utilization: Math.round(fiveHourUtil * 10) / 10,
|
|
151
|
+
resetsAt: fiveHourResetsAt,
|
|
152
|
+
};
|
|
153
|
+
const sevenDay = {
|
|
154
|
+
utilization: Math.round(weeklyUtil * 10) / 10,
|
|
155
|
+
resetsAt: sevenDayResetsAt,
|
|
156
|
+
};
|
|
157
|
+
const hasAnyData = accumulated.fiveHourTurns > 0 || accumulated.weeklyTurns > 0;
|
|
158
|
+
return {
|
|
159
|
+
fiveHour,
|
|
160
|
+
sevenDay,
|
|
161
|
+
available: hasAnyData,
|
|
162
|
+
providerId: 'zai',
|
|
163
|
+
source: 'session',
|
|
164
|
+
capturedAt,
|
|
165
|
+
stale: options.stale ?? !hasAnyData,
|
|
166
|
+
fiveHourLabel: '5-Hour',
|
|
167
|
+
sevenDayLabel: 'Weekly',
|
|
168
|
+
planType: tier,
|
|
169
|
+
limitId: `zai-${tier}`,
|
|
170
|
+
limitName: `z.ai ${tier.charAt(0).toUpperCase() + tier.slice(1)}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* z.ai business error codes that signal quota/plan state.
|
|
175
|
+
* Source: https://docs.z.ai/api-reference/api-code
|
|
176
|
+
*/
|
|
177
|
+
const ZAI_ERROR_CODES = {
|
|
178
|
+
'1308': 'exhausted', // Usage limit reached for `{number} {unit}` (includes next_flush_time)
|
|
179
|
+
'1310': 'exhausted', // Weekly/Monthly limit exhausted (resets at next_flush_time)
|
|
180
|
+
'1313': 'fup', // Fair-Use-Policy violation — request rate restricted
|
|
181
|
+
'1309': 'expired', // GLM Coding Plan package has expired
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Extracts a z.ai quota error from a message-error payload, if present.
|
|
185
|
+
*
|
|
186
|
+
* The reset timestamp is embedded inside the human-readable `message` text
|
|
187
|
+
* as `${next_flush_time}`. z.ai's docs interpolate the value into the
|
|
188
|
+
* message string; we parse ISO datetimes, epoch seconds, and the literal
|
|
189
|
+
* `YYYY-MM-DD HH:mm:ss` form defensively.
|
|
190
|
+
*
|
|
191
|
+
* Returns `null` when the code is not a quota-related business error.
|
|
192
|
+
*/
|
|
193
|
+
function parseZaiQuotaError(error) {
|
|
194
|
+
if (!error)
|
|
195
|
+
return null;
|
|
196
|
+
const code = String(error.code ?? '');
|
|
197
|
+
const kind = ZAI_ERROR_CODES[code];
|
|
198
|
+
if (!kind)
|
|
199
|
+
return null;
|
|
200
|
+
const message = error.message ?? '';
|
|
201
|
+
const resetsAt = extractNextFlushTime(message);
|
|
202
|
+
return { kind, resetsAt, code, message };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Parses `${next_flush_time}` out of a z.ai error message.
|
|
206
|
+
*
|
|
207
|
+
* Handles ISO 8601 (`2025-01-31T14:00:00Z`), space-separated
|
|
208
|
+
* (`2025-01-31 14:00:00`), and epoch-seconds (1234567890) forms.
|
|
209
|
+
* Returns undefined when no parseable timestamp is found.
|
|
210
|
+
*/
|
|
211
|
+
function extractNextFlushTime(message) {
|
|
212
|
+
if (!message)
|
|
213
|
+
return undefined;
|
|
214
|
+
// ISO 8601 with optional timezone.
|
|
215
|
+
const isoMatch = message.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/);
|
|
216
|
+
if (isoMatch) {
|
|
217
|
+
const ms = Date.parse(isoMatch[1]);
|
|
218
|
+
if (Number.isFinite(ms))
|
|
219
|
+
return new Date(ms).toISOString();
|
|
220
|
+
}
|
|
221
|
+
// `YYYY-MM-DD HH:mm:ss` (no T separator) — z.ai's likely interpolated form.
|
|
222
|
+
const spaceMatch = message.match(/(\d{4}-\d{2}-\d{2}[ ]\d{2}:\d{2}:\d{2})/);
|
|
223
|
+
if (spaceMatch) {
|
|
224
|
+
const normalised = spaceMatch[1].replace(' ', 'T');
|
|
225
|
+
const ms = Date.parse(`${normalised}Z`);
|
|
226
|
+
if (Number.isFinite(ms))
|
|
227
|
+
return new Date(ms).toISOString();
|
|
228
|
+
}
|
|
229
|
+
// Epoch seconds — 10-digit integer in a plausible range.
|
|
230
|
+
const epochMatch = message.match(/(?<!\d)(\d{10})(?!\d)/);
|
|
231
|
+
if (epochMatch) {
|
|
232
|
+
const seconds = Number(epochMatch[1]);
|
|
233
|
+
if (seconds > 1_700_000_000 && seconds < 2_000_000_000) {
|
|
234
|
+
return new Date(seconds * 1000).toISOString();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
// ── Unavailable state helper ──
|
|
240
|
+
/**
|
|
241
|
+
* Builds an unavailable z.ai `QuotaState` for the cold-start case where
|
|
242
|
+
* no z.ai traffic has been observed yet.
|
|
243
|
+
*/
|
|
244
|
+
function makeUnavailableZaiQuotaState(error = 'No z.ai usage observed yet', tier = 'lite', capturedAt = new Date().toISOString()) {
|
|
245
|
+
return {
|
|
246
|
+
fiveHour: { utilization: 0, resetsAt: '' },
|
|
247
|
+
sevenDay: { utilization: 0, resetsAt: '' },
|
|
248
|
+
available: false,
|
|
249
|
+
error,
|
|
250
|
+
providerId: 'zai',
|
|
251
|
+
source: 'session',
|
|
252
|
+
capturedAt,
|
|
253
|
+
stale: true,
|
|
254
|
+
fiveHourLabel: '5-Hour',
|
|
255
|
+
sevenDayLabel: 'Weekly',
|
|
256
|
+
planType: tier,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Converts raw OpenCode DB rows (filtered to z.ai providerIDs by the caller)
|
|
261
|
+
* into the watcher's `ZaiAssistantTurn` shape, and surfaces any trapped
|
|
262
|
+
* business errors.
|
|
263
|
+
*
|
|
264
|
+
* Returns `{ turns, errors }` so callers can replay them through
|
|
265
|
+
* `ZaiQuotaWatcher.ingestAssistantTurns` and `ingestError`.
|
|
266
|
+
*/
|
|
267
|
+
function rowsToZaiTurnsAndErrors(rows) {
|
|
268
|
+
const turns = [];
|
|
269
|
+
const errors = [];
|
|
270
|
+
for (const row of rows) {
|
|
271
|
+
turns.push({
|
|
272
|
+
timestampMs: row.timeCreated,
|
|
273
|
+
model: row.modelId,
|
|
274
|
+
inputTokens: row.inputTokens || 0,
|
|
275
|
+
outputTokens: row.outputTokens || 0,
|
|
276
|
+
cacheReadTokens: row.cacheReadTokens || 0,
|
|
277
|
+
cacheWriteTokens: row.cacheWriteTokens || 0,
|
|
278
|
+
reasoningTokens: row.reasoningTokens || 0,
|
|
279
|
+
});
|
|
280
|
+
if (row.errorCode != null || row.errorMessage) {
|
|
281
|
+
const parsed = parseZaiQuotaError({
|
|
282
|
+
code: row.errorCode ?? undefined,
|
|
283
|
+
message: row.errorMessage ?? undefined,
|
|
284
|
+
});
|
|
285
|
+
if (parsed)
|
|
286
|
+
errors.push(parsed);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { turns, errors };
|
|
290
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { QuotaHistorySample } from './quotaHistory';
|
|
2
|
+
import { type ZaiAccumulatedUsage, type ZaiAssistantTurn, type ZaiTier } from './zaiQuota';
|
|
3
|
+
import type { QuotaState } from './quota';
|
|
4
|
+
import type { ProviderQuotaState } from './providerQuota';
|
|
5
|
+
import type { Disposable } from './quotaPoller';
|
|
6
|
+
/** Storage key wider than `AccountProviderId` — z.ai has no full account
|
|
7
|
+
* management in v1, but we still need a stable key for snapshot/history. */
|
|
8
|
+
type ZaiSnapshotProvider = 'zai';
|
|
9
|
+
type TierResolver = (configured: ZaiTier | 'auto', accumulated: ZaiAccumulatedUsage) => ZaiTier;
|
|
10
|
+
type SnapshotReader = (providerId: ZaiSnapshotProvider, accountId: string) => QuotaState | null;
|
|
11
|
+
type SnapshotWriter = (providerId: ZaiSnapshotProvider, accountId: string, quota: QuotaState) => void;
|
|
12
|
+
type HistoryAppender = (sample: QuotaHistorySample) => void | Promise<void>;
|
|
13
|
+
export interface ZaiQuotaWatcherOptions {
|
|
14
|
+
/** Configured tier. Default: `'auto'` (heuristic from observed usage). */
|
|
15
|
+
tier?: ZaiTier | 'auto';
|
|
16
|
+
/** Override tier resolution (used by tests). Default: `resolveZaiTier`. */
|
|
17
|
+
resolveTier?: TierResolver;
|
|
18
|
+
/** Override snapshot read (used by tests). */
|
|
19
|
+
readSnapshot?: SnapshotReader;
|
|
20
|
+
/** Override snapshot write (used by tests). */
|
|
21
|
+
writeSnapshot?: SnapshotWriter;
|
|
22
|
+
/** Override history append (used by tests). */
|
|
23
|
+
appendHistorySample?: HistoryAppender;
|
|
24
|
+
/** Stable workspace identifier — when provided, samples are appended to per-workspace history. */
|
|
25
|
+
workspaceId?: string;
|
|
26
|
+
/** Account identifier for snapshot persistence. Default: `'default'`. */
|
|
27
|
+
accountId?: string;
|
|
28
|
+
/** Minimum time between recomputes (ms). Default: 1000. */
|
|
29
|
+
recomputeDebounceMs?: number;
|
|
30
|
+
/** Time between prunes of in-memory turn buffer (ms). Default: 60000. */
|
|
31
|
+
pruneIntervalMs?: number;
|
|
32
|
+
/** Internal clock for tests. */
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Watches z.ai-routed OpenCode activity and emits derived `QuotaState`.
|
|
37
|
+
*
|
|
38
|
+
* The watcher is event-driven (not a poller): call `ingestAssistantTurn`
|
|
39
|
+
* for every observed z.ai assistant turn, and `ingestError` whenever a
|
|
40
|
+
* z.ai business error is observed. The watcher debounces recompute and
|
|
41
|
+
* emits via `onUpdate` / `onQuotaUpdate`.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ZaiQuotaWatcher implements Disposable {
|
|
44
|
+
private readonly tier;
|
|
45
|
+
private readonly resolveTier;
|
|
46
|
+
private readonly readSnapshot;
|
|
47
|
+
private readonly writeSnapshot;
|
|
48
|
+
private readonly appendHistorySample;
|
|
49
|
+
private readonly workspaceId;
|
|
50
|
+
private readonly accountId;
|
|
51
|
+
private readonly recomputeDebounceMs;
|
|
52
|
+
private readonly pruneIntervalMs;
|
|
53
|
+
private readonly now;
|
|
54
|
+
private readonly listeners;
|
|
55
|
+
/** In-memory rolling 7-day buffer of observed turns. */
|
|
56
|
+
private turns;
|
|
57
|
+
/** Most recent authoritative 5h reset (parsed from a z.ai error event). */
|
|
58
|
+
private authoritativeFiveHourResetAt;
|
|
59
|
+
/** Most recent authoritative weekly reset (parsed from a z.ai error event). */
|
|
60
|
+
private authoritativeWeeklyResetAt;
|
|
61
|
+
/** Most recent trapped error (so consumers can show "rate-limited until X"). */
|
|
62
|
+
private lastError;
|
|
63
|
+
private lastEmissionKey;
|
|
64
|
+
private pendingRecomputeTimer;
|
|
65
|
+
private pruneTimer;
|
|
66
|
+
private running;
|
|
67
|
+
constructor(options?: ZaiQuotaWatcherOptions);
|
|
68
|
+
start(): void;
|
|
69
|
+
stop(): void;
|
|
70
|
+
dispose(): void;
|
|
71
|
+
onUpdate(cb: (state: ProviderQuotaState<'zai'>) => void): Disposable;
|
|
72
|
+
onQuotaUpdate(cb: (state: ProviderQuotaState<'zai'>) => void): Disposable;
|
|
73
|
+
/** Feed an observed z.ai assistant turn. Schedules a debounced recompute. */
|
|
74
|
+
ingestAssistantTurn(turn: ZaiAssistantTurn): void;
|
|
75
|
+
/**
|
|
76
|
+
* Feed multiple turns at once (used for cold-start seeding from the DB).
|
|
77
|
+
* Schedules a single debounced recompute.
|
|
78
|
+
*/
|
|
79
|
+
ingestAssistantTurns(turns: readonly ZaiAssistantTurn[]): void;
|
|
80
|
+
/** Feed a z.ai error event. Triggers an immediate recompute + emit. */
|
|
81
|
+
ingestError(error: {
|
|
82
|
+
code?: string | number;
|
|
83
|
+
message?: string;
|
|
84
|
+
type?: string;
|
|
85
|
+
}): boolean;
|
|
86
|
+
/** Force a recompute+emit now (skipping the debounce). Used by tests. */
|
|
87
|
+
refresh(): void;
|
|
88
|
+
/** Current in-memory turn count (testing/diagnostics). */
|
|
89
|
+
get bufferedTurnCount(): number;
|
|
90
|
+
private scheduleRecompute;
|
|
91
|
+
private recomputeAndEmit;
|
|
92
|
+
private maybeAppendHistory;
|
|
93
|
+
private emitCachedOrUnavailable;
|
|
94
|
+
private emitState;
|
|
95
|
+
private pruneOldTurns;
|
|
96
|
+
}
|
|
97
|
+
export {};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ZaiQuotaWatcher = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Event-driven watcher that derives a z.ai coding-plan `QuotaState` from
|
|
6
|
+
* observed OpenCode assistant turns.
|
|
7
|
+
*
|
|
8
|
+
* z.ai does not expose a quota API, so this watcher maintains rolling
|
|
9
|
+
* 5-hour / 7-day windows in memory and recomputes an estimated `QuotaState`
|
|
10
|
+
* whenever a new turn arrives. The consumer is responsible for feeding it
|
|
11
|
+
* z.ai-routed assistant turns (and any trapped error events) — typically
|
|
12
|
+
* by subscribing to `SessionMonitor.onSessionEvent` and filtering on
|
|
13
|
+
* `providerID ∈ {zai, zai-coding-plan}`.
|
|
14
|
+
*
|
|
15
|
+
* Optionally writes snapshots + history samples for cross-session cache
|
|
16
|
+
* and trend visualisation, mirroring `CodexQuotaWatcher`.
|
|
17
|
+
*/
|
|
18
|
+
const quotaHistory_1 = require("./quotaHistory");
|
|
19
|
+
const quotaSnapshots_1 = require("./quotaSnapshots");
|
|
20
|
+
const zaiQuota_1 = require("./zaiQuota");
|
|
21
|
+
const DEFAULT_ACCOUNT_ID = 'default';
|
|
22
|
+
const DEFAULT_RECOMPUTE_DEBOUNCE_MS = 1_000;
|
|
23
|
+
const DEFAULT_PRUNE_INTERVAL_MS = 60_000;
|
|
24
|
+
const SEVEN_DAY_MS = 7 * 86_400_000;
|
|
25
|
+
function enrichZaiState(state) {
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
runtimeProvider: 'zai',
|
|
29
|
+
providerId: 'zai',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Watches z.ai-routed OpenCode activity and emits derived `QuotaState`.
|
|
34
|
+
*
|
|
35
|
+
* The watcher is event-driven (not a poller): call `ingestAssistantTurn`
|
|
36
|
+
* for every observed z.ai assistant turn, and `ingestError` whenever a
|
|
37
|
+
* z.ai business error is observed. The watcher debounces recompute and
|
|
38
|
+
* emits via `onUpdate` / `onQuotaUpdate`.
|
|
39
|
+
*/
|
|
40
|
+
class ZaiQuotaWatcher {
|
|
41
|
+
tier;
|
|
42
|
+
resolveTier;
|
|
43
|
+
readSnapshot;
|
|
44
|
+
writeSnapshot;
|
|
45
|
+
appendHistorySample;
|
|
46
|
+
workspaceId;
|
|
47
|
+
accountId;
|
|
48
|
+
recomputeDebounceMs;
|
|
49
|
+
pruneIntervalMs;
|
|
50
|
+
now;
|
|
51
|
+
listeners = [];
|
|
52
|
+
/** In-memory rolling 7-day buffer of observed turns. */
|
|
53
|
+
turns = [];
|
|
54
|
+
/** Most recent authoritative 5h reset (parsed from a z.ai error event). */
|
|
55
|
+
authoritativeFiveHourResetAt;
|
|
56
|
+
/** Most recent authoritative weekly reset (parsed from a z.ai error event). */
|
|
57
|
+
authoritativeWeeklyResetAt;
|
|
58
|
+
/** Most recent trapped error (so consumers can show "rate-limited until X"). */
|
|
59
|
+
lastError = null;
|
|
60
|
+
lastEmissionKey = null;
|
|
61
|
+
pendingRecomputeTimer;
|
|
62
|
+
pruneTimer;
|
|
63
|
+
running = false;
|
|
64
|
+
constructor(options = {}) {
|
|
65
|
+
this.tier = options.tier ?? 'auto';
|
|
66
|
+
this.resolveTier = options.resolveTier ?? zaiQuota_1.resolveZaiTier;
|
|
67
|
+
this.readSnapshot = options.readSnapshot ?? quotaSnapshots_1.readQuotaSnapshot;
|
|
68
|
+
this.writeSnapshot = options.writeSnapshot ?? quotaSnapshots_1.writeQuotaSnapshot;
|
|
69
|
+
this.appendHistorySample = options.appendHistorySample ?? quotaHistory_1.appendQuotaHistorySample;
|
|
70
|
+
this.workspaceId = options.workspaceId;
|
|
71
|
+
this.accountId = options.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
72
|
+
this.recomputeDebounceMs = options.recomputeDebounceMs ?? DEFAULT_RECOMPUTE_DEBOUNCE_MS;
|
|
73
|
+
this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
|
|
74
|
+
this.now = options.now ?? Date.now;
|
|
75
|
+
}
|
|
76
|
+
start() {
|
|
77
|
+
if (this.running)
|
|
78
|
+
return;
|
|
79
|
+
this.running = true;
|
|
80
|
+
// Emit cached state immediately so consumers don't show "unavailable"
|
|
81
|
+
// during the gap before the first z.ai turn arrives.
|
|
82
|
+
this.emitCachedOrUnavailable();
|
|
83
|
+
this.pruneTimer = setInterval(() => this.pruneOldTurns(), this.pruneIntervalMs);
|
|
84
|
+
}
|
|
85
|
+
stop() {
|
|
86
|
+
if (!this.running)
|
|
87
|
+
return;
|
|
88
|
+
this.running = false;
|
|
89
|
+
if (this.pendingRecomputeTimer) {
|
|
90
|
+
clearTimeout(this.pendingRecomputeTimer);
|
|
91
|
+
this.pendingRecomputeTimer = undefined;
|
|
92
|
+
}
|
|
93
|
+
if (this.pruneTimer) {
|
|
94
|
+
clearInterval(this.pruneTimer);
|
|
95
|
+
this.pruneTimer = undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
dispose() {
|
|
99
|
+
this.stop();
|
|
100
|
+
this.listeners.splice(0, this.listeners.length);
|
|
101
|
+
}
|
|
102
|
+
onUpdate(cb) {
|
|
103
|
+
this.listeners.push(cb);
|
|
104
|
+
return {
|
|
105
|
+
dispose: () => {
|
|
106
|
+
const index = this.listeners.indexOf(cb);
|
|
107
|
+
if (index >= 0)
|
|
108
|
+
this.listeners.splice(index, 1);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
onQuotaUpdate(cb) {
|
|
113
|
+
return this.onUpdate(cb);
|
|
114
|
+
}
|
|
115
|
+
/** Feed an observed z.ai assistant turn. Schedules a debounced recompute. */
|
|
116
|
+
ingestAssistantTurn(turn) {
|
|
117
|
+
this.turns.push(turn);
|
|
118
|
+
this.scheduleRecompute();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Feed multiple turns at once (used for cold-start seeding from the DB).
|
|
122
|
+
* Schedules a single debounced recompute.
|
|
123
|
+
*/
|
|
124
|
+
ingestAssistantTurns(turns) {
|
|
125
|
+
for (const turn of turns)
|
|
126
|
+
this.turns.push(turn);
|
|
127
|
+
this.scheduleRecompute();
|
|
128
|
+
}
|
|
129
|
+
/** Feed a z.ai error event. Triggers an immediate recompute + emit. */
|
|
130
|
+
ingestError(error) {
|
|
131
|
+
const parsed = (0, zaiQuota_1.parseZaiQuotaError)(error);
|
|
132
|
+
if (!parsed)
|
|
133
|
+
return false;
|
|
134
|
+
this.lastError = parsed;
|
|
135
|
+
if (parsed.resetsAt) {
|
|
136
|
+
// The first observed error after the user crosses a threshold tells
|
|
137
|
+
// us the authoritative window boundary. Apply to whichever window
|
|
138
|
+
// the kind maps to.
|
|
139
|
+
if (parsed.kind === 'exhausted') {
|
|
140
|
+
this.authoritativeFiveHourResetAt = parsed.resetsAt;
|
|
141
|
+
// 1310 is "weekly/monthly exhausted" — also pin the weekly window.
|
|
142
|
+
if (String(parsed.code) === '1310') {
|
|
143
|
+
this.authoritativeWeeklyResetAt = parsed.resetsAt;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this.scheduleRecompute();
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
/** Force a recompute+emit now (skipping the debounce). Used by tests. */
|
|
151
|
+
refresh() {
|
|
152
|
+
this.recomputeAndEmit();
|
|
153
|
+
}
|
|
154
|
+
/** Current in-memory turn count (testing/diagnostics). */
|
|
155
|
+
get bufferedTurnCount() {
|
|
156
|
+
return this.turns.length;
|
|
157
|
+
}
|
|
158
|
+
// ── internals ──
|
|
159
|
+
scheduleRecompute() {
|
|
160
|
+
if (!this.running)
|
|
161
|
+
return;
|
|
162
|
+
if (this.pendingRecomputeTimer)
|
|
163
|
+
clearTimeout(this.pendingRecomputeTimer);
|
|
164
|
+
this.pendingRecomputeTimer = setTimeout(() => {
|
|
165
|
+
this.pendingRecomputeTimer = undefined;
|
|
166
|
+
this.recomputeAndEmit();
|
|
167
|
+
}, this.recomputeDebounceMs);
|
|
168
|
+
}
|
|
169
|
+
recomputeAndEmit() {
|
|
170
|
+
const nowMs = this.now();
|
|
171
|
+
const accumulated = (0, zaiQuota_1.accumulateZaiUsage)(this.turns, nowMs);
|
|
172
|
+
const tier = this.resolveTier(this.tier, accumulated);
|
|
173
|
+
const state = accumulated.fiveHourTurns > 0 || accumulated.weeklyTurns > 0
|
|
174
|
+
? (0, zaiQuota_1.inferZaiQuotaState)(accumulated, tier, {
|
|
175
|
+
capturedAt: new Date(nowMs).toISOString(),
|
|
176
|
+
stale: false,
|
|
177
|
+
authoritativeFiveHourResetAt: this.authoritativeFiveHourResetAt,
|
|
178
|
+
authoritativeWeeklyResetAt: this.authoritativeWeeklyResetAt,
|
|
179
|
+
})
|
|
180
|
+
: (0, zaiQuota_1.makeUnavailableZaiQuotaState)(undefined, tier, new Date(nowMs).toISOString());
|
|
181
|
+
// If we recently saw an error, override availability so the UI shows it.
|
|
182
|
+
if (this.lastError) {
|
|
183
|
+
state.error = this.lastError.message;
|
|
184
|
+
state.failureKind = this.lastError.kind === 'expired' ? 'auth' : 'rate_limit';
|
|
185
|
+
state.rateLimitReachedType = this.lastError.kind;
|
|
186
|
+
}
|
|
187
|
+
this.writeSnapshot('zai', this.accountId, state);
|
|
188
|
+
this.maybeAppendHistory(state);
|
|
189
|
+
this.emitState(enrichZaiState(state));
|
|
190
|
+
}
|
|
191
|
+
maybeAppendHistory(state) {
|
|
192
|
+
if (!this.workspaceId)
|
|
193
|
+
return;
|
|
194
|
+
const sample = {
|
|
195
|
+
timestamp: state.capturedAt ?? new Date(this.now()).toISOString(),
|
|
196
|
+
runtimeProvider: 'zai',
|
|
197
|
+
providerId: this.accountId,
|
|
198
|
+
workspaceId: this.workspaceId,
|
|
199
|
+
fiveHour: { utilization: state.fiveHour.utilization, resetsAt: state.fiveHour.resetsAt },
|
|
200
|
+
sevenDay: { utilization: state.sevenDay.utilization, resetsAt: state.sevenDay.resetsAt },
|
|
201
|
+
available: state.available,
|
|
202
|
+
error: state.error,
|
|
203
|
+
source: 'session',
|
|
204
|
+
stale: state.stale,
|
|
205
|
+
};
|
|
206
|
+
try {
|
|
207
|
+
const result = this.appendHistorySample(sample);
|
|
208
|
+
if (result && typeof result.catch === 'function') {
|
|
209
|
+
result.catch(() => {
|
|
210
|
+
// History append must never break the live emission path.
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Synchronous errors swallowed for the same reason.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
emitCachedOrUnavailable() {
|
|
219
|
+
const cached = this.readSnapshot('zai', this.accountId);
|
|
220
|
+
if (cached) {
|
|
221
|
+
this.emitState(enrichZaiState({
|
|
222
|
+
...cached,
|
|
223
|
+
providerId: 'zai',
|
|
224
|
+
source: 'cache',
|
|
225
|
+
stale: true,
|
|
226
|
+
fiveHourLabel: cached.fiveHourLabel ?? '5-Hour',
|
|
227
|
+
sevenDayLabel: cached.sevenDayLabel ?? 'Weekly',
|
|
228
|
+
}));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Initial state with no observations yet.
|
|
232
|
+
const tier = this.tier === 'auto' ? 'lite' : this.tier;
|
|
233
|
+
this.emitState(enrichZaiState((0, zaiQuota_1.makeUnavailableZaiQuotaState)(undefined, tier)));
|
|
234
|
+
}
|
|
235
|
+
emitState(state) {
|
|
236
|
+
const nextKey = JSON.stringify(state);
|
|
237
|
+
if (this.lastEmissionKey === nextKey)
|
|
238
|
+
return;
|
|
239
|
+
this.lastEmissionKey = nextKey;
|
|
240
|
+
for (const listener of this.listeners) {
|
|
241
|
+
try {
|
|
242
|
+
listener(state);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Listener failures must not stop the watcher.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
pruneOldTurns() {
|
|
250
|
+
const cutoff = this.now() - SEVEN_DAY_MS;
|
|
251
|
+
if (this.turns.length === 0)
|
|
252
|
+
return;
|
|
253
|
+
const next = this.turns.filter(turn => turn.timestampMs >= cutoff);
|
|
254
|
+
if (next.length !== this.turns.length) {
|
|
255
|
+
this.turns = next;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
exports.ZaiQuotaWatcher = ZaiQuotaWatcher;
|
package/package.json
CHANGED