@narumitw/pi-usage 0.60.6 → 0.60.8

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/src/core.ts CHANGED
@@ -2,246 +2,244 @@ import { createHmac } from "node:crypto";
2
2
  import type { UsageReport } from "./types.js";
3
3
 
4
4
  export class UsageCache {
5
- private readonly entries = new Map<string, { createdAt: number; report: UsageReport }>();
6
- private readonly ttlMs: number;
7
- private readonly maxEntries: number;
8
-
9
- constructor(ttlMs: number, maxEntries = 32) {
10
- if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error("Cache TTL must be positive.");
11
- if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
12
- throw new Error("Cache entry limit must be a positive integer.");
13
- }
14
- this.ttlMs = ttlMs;
15
- this.maxEntries = maxEntries;
16
- }
17
-
18
- get size(): number {
19
- return this.entries.size;
20
- }
21
-
22
- get(providerId: string, fingerprint: string, now = Date.now()): UsageReport | undefined {
23
- this.sweepExpired(now);
24
- return this.entries.get(cacheKey(providerId, fingerprint))?.report;
25
- }
26
-
27
- set(providerId: string, fingerprint: string, report: UsageReport, now = Date.now()): void {
28
- this.sweepExpired(now);
29
- const key = cacheKey(providerId, fingerprint);
30
- this.entries.delete(key);
31
- while (this.entries.size >= this.maxEntries) {
32
- const oldest = this.entries.keys().next().value;
33
- if (oldest === undefined) break;
34
- this.entries.delete(oldest);
35
- }
36
- this.entries.set(key, { createdAt: now, report });
37
- }
38
-
39
- delete(providerId: string, fingerprint: string): void {
40
- this.entries.delete(cacheKey(providerId, fingerprint));
41
- }
42
-
43
- clearProvider(providerId: string): void {
44
- for (const key of this.entries.keys()) {
45
- if (key.startsWith(`${providerId}:`)) this.entries.delete(key);
46
- }
47
- }
48
-
49
- clear(): void {
50
- this.entries.clear();
51
- }
52
-
53
- private sweepExpired(now: number): void {
54
- for (const [key, entry] of this.entries) {
55
- if (now - entry.createdAt >= this.ttlMs) this.entries.delete(key);
56
- }
57
- }
5
+ private readonly entries = new Map<string, { createdAt: number; report: UsageReport }>();
6
+ private readonly ttlMs: number;
7
+ private readonly maxEntries: number;
8
+
9
+ constructor(ttlMs: number, maxEntries = 32) {
10
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error("Cache TTL must be positive.");
11
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
12
+ throw new Error("Cache entry limit must be a positive integer.");
13
+ }
14
+ this.ttlMs = ttlMs;
15
+ this.maxEntries = maxEntries;
16
+ }
17
+
18
+ get size(): number {
19
+ return this.entries.size;
20
+ }
21
+
22
+ get(providerId: string, fingerprint: string, now = Date.now()): UsageReport | undefined {
23
+ this.sweepExpired(now);
24
+ return this.entries.get(cacheKey(providerId, fingerprint))?.report;
25
+ }
26
+
27
+ set(providerId: string, fingerprint: string, report: UsageReport, now = Date.now()): void {
28
+ this.sweepExpired(now);
29
+ const key = cacheKey(providerId, fingerprint);
30
+ this.entries.delete(key);
31
+ while (this.entries.size >= this.maxEntries) {
32
+ const oldest = this.entries.keys().next().value;
33
+ if (oldest === undefined) break;
34
+ this.entries.delete(oldest);
35
+ }
36
+ this.entries.set(key, { createdAt: now, report });
37
+ }
38
+
39
+ delete(providerId: string, fingerprint: string): void {
40
+ this.entries.delete(cacheKey(providerId, fingerprint));
41
+ }
42
+
43
+ clearProvider(providerId: string): void {
44
+ for (const key of this.entries.keys()) {
45
+ if (key.startsWith(`${providerId}:`)) this.entries.delete(key);
46
+ }
47
+ }
48
+
49
+ clear(): void {
50
+ this.entries.clear();
51
+ }
52
+
53
+ private sweepExpired(now: number): void {
54
+ for (const [key, entry] of this.entries) {
55
+ if (now - entry.createdAt >= this.ttlMs) this.entries.delete(key);
56
+ }
57
+ }
58
58
  }
59
59
 
60
60
  export function fingerprintResolvedAuth(
61
- auth: {
62
- apiKey?: string;
63
- headers?: Record<string, string | null>;
64
- baseUrl?: string;
65
- env?: Record<string, string>;
66
- source?: string;
67
- providerAuth?: {
68
- apiKey?: string;
69
- headers?: Record<string, string | null>;
70
- baseUrl?: string;
71
- };
72
- },
73
- salt: Uint8Array,
61
+ auth: {
62
+ apiKey?: string;
63
+ headers?: Record<string, string | null>;
64
+ baseUrl?: string;
65
+ env?: Record<string, string>;
66
+ source?: string;
67
+ providerAuth?: {
68
+ apiKey?: string;
69
+ headers?: Record<string, string | null>;
70
+ baseUrl?: string;
71
+ };
72
+ },
73
+ salt: Uint8Array,
74
74
  ): string {
75
- const headers = Object.entries(auth.headers ?? {})
76
- .map(([name, value]) => [name.toLowerCase(), value] as const)
77
- .sort(([left], [right]) => left.localeCompare(right));
78
- const env = Object.entries(auth.env ?? {}).sort(([left], [right]) => left.localeCompare(right));
79
- const providerHeaders = Object.entries(auth.providerAuth?.headers ?? {})
80
- .map(([name, value]) => [name.toLowerCase(), value] as const)
81
- .sort(([left], [right]) => left.localeCompare(right));
82
- const canonical = JSON.stringify({
83
- apiKey: auth.apiKey ?? "",
84
- headers,
85
- baseUrl: auth.baseUrl ?? "",
86
- env,
87
- source: auth.source ?? "",
88
- providerAuth: {
89
- apiKey: auth.providerAuth?.apiKey ?? "",
90
- headers: providerHeaders,
91
- baseUrl: auth.providerAuth?.baseUrl ?? "",
92
- },
93
- });
94
- return createHmac("sha256", salt).update(canonical).digest("hex");
75
+ const headers = Object.entries(auth.headers ?? {})
76
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
77
+ .sort(([left], [right]) => left.localeCompare(right));
78
+ const env = Object.entries(auth.env ?? {}).sort(([left], [right]) => left.localeCompare(right));
79
+ const providerHeaders = Object.entries(auth.providerAuth?.headers ?? {})
80
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
81
+ .sort(([left], [right]) => left.localeCompare(right));
82
+ const canonical = JSON.stringify({
83
+ apiKey: auth.apiKey ?? "",
84
+ headers,
85
+ baseUrl: auth.baseUrl ?? "",
86
+ env,
87
+ source: auth.source ?? "",
88
+ providerAuth: {
89
+ apiKey: auth.providerAuth?.apiKey ?? "",
90
+ headers: providerHeaders,
91
+ baseUrl: auth.providerAuth?.baseUrl ?? "",
92
+ },
93
+ });
94
+ return createHmac("sha256", salt).update(canonical).digest("hex");
95
95
  }
96
96
 
97
97
  export async function runWithConcurrency<T, R>(
98
- items: readonly T[],
99
- limit: number,
100
- worker: (item: T, index: number, signal: AbortSignal) => Promise<R>,
101
- signal: AbortSignal,
98
+ items: readonly T[],
99
+ limit: number,
100
+ worker: (item: T, index: number, signal: AbortSignal) => Promise<R>,
101
+ signal: AbortSignal,
102
102
  ): Promise<PromiseSettledResult<R>[]> {
103
- if (signal.aborted) throw abortError();
104
- if (!Number.isSafeInteger(limit) || limit < 1)
105
- throw new Error("Concurrency limit must be positive.");
106
-
107
- const results = new Array<PromiseSettledResult<R>>(items.length);
108
- let nextIndex = 0;
109
- const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
110
- while (nextIndex < items.length) {
111
- if (signal.aborted) throw abortError();
112
- const index = nextIndex;
113
- nextIndex += 1;
114
- try {
115
- results[index] = {
116
- status: "fulfilled",
117
- value: await worker(items[index] as T, index, signal),
118
- };
119
- } catch (reason) {
120
- results[index] = { status: "rejected", reason };
121
- }
122
- }
123
- });
124
-
125
- await Promise.all(runners);
126
- if (signal.aborted) throw abortError();
127
- return results;
103
+ if (signal.aborted) throw abortError();
104
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("Concurrency limit must be positive.");
105
+
106
+ const results = new Array<PromiseSettledResult<R>>(items.length);
107
+ let nextIndex = 0;
108
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
109
+ while (nextIndex < items.length) {
110
+ if (signal.aborted) throw abortError();
111
+ const index = nextIndex;
112
+ nextIndex += 1;
113
+ try {
114
+ results[index] = {
115
+ status: "fulfilled",
116
+ value: await worker(items[index] as T, index, signal),
117
+ };
118
+ } catch (reason) {
119
+ results[index] = { status: "rejected", reason };
120
+ }
121
+ }
122
+ });
123
+
124
+ await Promise.all(runners);
125
+ if (signal.aborted) throw abortError();
126
+ return results;
128
127
  }
129
128
 
130
129
  export async function awaitWithDeadline<T>(
131
- operation: Promise<T>,
132
- signal: AbortSignal,
133
- timeoutMs: number,
134
- description: string,
130
+ operation: Promise<T>,
131
+ signal: AbortSignal,
132
+ timeoutMs: number,
133
+ description: string,
135
134
  ): Promise<T> {
136
- if (signal.aborted) throw abortError();
137
- const controller = new AbortController();
138
- const abortFromCaller = () => controller.abort();
139
- signal.addEventListener("abort", abortFromCaller, { once: true });
140
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
141
- try {
142
- return await Promise.race([
143
- operation,
144
- new Promise<never>((_resolve, reject) => {
145
- controller.signal.addEventListener(
146
- "abort",
147
- () => {
148
- reject(
149
- signal.aborted
150
- ? abortError()
151
- : Object.assign(
152
- new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s ${description}.`),
153
- { name: "TimeoutError" },
154
- ),
155
- );
156
- },
157
- { once: true },
158
- );
159
- }),
160
- ]);
161
- } finally {
162
- clearTimeout(timeout);
163
- signal.removeEventListener("abort", abortFromCaller);
164
- }
135
+ if (signal.aborted) throw abortError();
136
+ const controller = new AbortController();
137
+ const abortFromCaller = () => controller.abort();
138
+ signal.addEventListener("abort", abortFromCaller, { once: true });
139
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
140
+ try {
141
+ return await Promise.race([
142
+ operation,
143
+ new Promise<never>((_resolve, reject) => {
144
+ controller.signal.addEventListener(
145
+ "abort",
146
+ () => {
147
+ reject(
148
+ signal.aborted
149
+ ? abortError()
150
+ : Object.assign(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s ${description}.`), {
151
+ name: "TimeoutError",
152
+ }),
153
+ );
154
+ },
155
+ { once: true },
156
+ );
157
+ }),
158
+ ]);
159
+ } finally {
160
+ clearTimeout(timeout);
161
+ signal.removeEventListener("abort", abortFromCaller);
162
+ }
165
163
  }
166
164
 
167
165
  export function sanitizeDisplayText(value: string, maxChars = 160): string {
168
- let result = "";
169
- for (let index = 0; index < value.length; ) {
170
- const codePoint = value.codePointAt(index) ?? 0;
171
- const character = String.fromCodePoint(codePoint);
172
- if (codePoint === 0x1b || codePoint === 0x9b || codePoint === 0x9d) {
173
- index = skipTerminalEscape(value, index, codePoint);
174
- continue;
175
- }
176
- if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
177
- if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d) result += " ";
178
- index += character.length;
179
- continue;
180
- }
181
- result += character;
182
- index += character.length;
183
- }
184
- return truncate(result.replace(/\s+/gu, " ").trim(), maxChars);
166
+ let result = "";
167
+ for (let index = 0; index < value.length; ) {
168
+ const codePoint = value.codePointAt(index) ?? 0;
169
+ const character = String.fromCodePoint(codePoint);
170
+ if (codePoint === 0x1b || codePoint === 0x9b || codePoint === 0x9d) {
171
+ index = skipTerminalEscape(value, index, codePoint);
172
+ continue;
173
+ }
174
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
175
+ if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d) result += " ";
176
+ index += character.length;
177
+ continue;
178
+ }
179
+ result += character;
180
+ index += character.length;
181
+ }
182
+ return truncate(result.replace(/\s+/gu, " ").trim(), maxChars);
185
183
  }
186
184
 
187
185
  export function redactUsageError(value: string, secrets: readonly string[] = []): string {
188
- let redacted = value;
189
- for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length)) {
190
- redacted = redacted.replace(new RegExp(escapeRegExp(secret), "g"), "<redacted>");
191
- }
192
- redacted = redacted
193
- .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
194
- .replace(/"(?:access_token|refresh_token|api_key)"\s*:\s*"[^"]+"/gi, (match) => {
195
- const separator = match.indexOf(":");
196
- return `${match.slice(0, separator + 1)}"<redacted>"`;
197
- });
198
- return sanitizeDisplayText(redacted, 600);
186
+ let redacted = value;
187
+ for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length)) {
188
+ redacted = redacted.replace(new RegExp(escapeRegExp(secret), "g"), "<redacted>");
189
+ }
190
+ redacted = redacted
191
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
192
+ .replace(/"(?:access_token|refresh_token|api_key)"\s*:\s*"[^"]+"/gi, (match) => {
193
+ const separator = match.indexOf(":");
194
+ return `${match.slice(0, separator + 1)}"<redacted>"`;
195
+ });
196
+ return sanitizeDisplayText(redacted, 600);
199
197
  }
200
198
 
201
199
  export function errorMessage(error: unknown): string {
202
- return sanitizeDisplayText(error instanceof Error ? error.message : String(error), 600);
200
+ return sanitizeDisplayText(error instanceof Error ? error.message : String(error), 600);
203
201
  }
204
202
 
205
203
  export function abortError(): Error {
206
- return Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
204
+ return Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
207
205
  }
208
206
 
209
207
  function skipTerminalEscape(value: string, start: number, codePoint: number): number {
210
- let index = start + 1;
211
- const next = value.charCodeAt(index);
212
- const isOsc = codePoint === 0x9d || (codePoint === 0x1b && next === 0x5d);
213
- if (isOsc) {
214
- if (codePoint === 0x1b) index += 1;
215
- while (index < value.length) {
216
- const current = value.charCodeAt(index);
217
- if (current === 0x07) return index + 1;
218
- if (current === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
219
- index += 1;
220
- }
221
- return index;
222
- }
223
- const isCsi = codePoint === 0x9b || (codePoint === 0x1b && next === 0x5b);
224
- if (isCsi) {
225
- if (codePoint === 0x1b) index += 1;
226
- while (index < value.length) {
227
- const current = value.charCodeAt(index);
228
- index += 1;
229
- if (current >= 0x40 && current <= 0x7e) break;
230
- }
231
- return index;
232
- }
233
- return Math.min(value.length, start + (codePoint === 0x1b ? 2 : 1));
208
+ let index = start + 1;
209
+ const next = value.charCodeAt(index);
210
+ const isOsc = codePoint === 0x9d || (codePoint === 0x1b && next === 0x5d);
211
+ if (isOsc) {
212
+ if (codePoint === 0x1b) index += 1;
213
+ while (index < value.length) {
214
+ const current = value.charCodeAt(index);
215
+ if (current === 0x07) return index + 1;
216
+ if (current === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
217
+ index += 1;
218
+ }
219
+ return index;
220
+ }
221
+ const isCsi = codePoint === 0x9b || (codePoint === 0x1b && next === 0x5b);
222
+ if (isCsi) {
223
+ if (codePoint === 0x1b) index += 1;
224
+ while (index < value.length) {
225
+ const current = value.charCodeAt(index);
226
+ index += 1;
227
+ if (current >= 0x40 && current <= 0x7e) break;
228
+ }
229
+ return index;
230
+ }
231
+ return Math.min(value.length, start + (codePoint === 0x1b ? 2 : 1));
234
232
  }
235
233
 
236
234
  function cacheKey(providerId: string, fingerprint: string): string {
237
- return `${providerId}:${fingerprint}`;
235
+ return `${providerId}:${fingerprint}`;
238
236
  }
239
237
 
240
238
  function escapeRegExp(value: string): string {
241
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
242
240
  }
243
241
 
244
242
  function truncate(value: string, maxChars: number): string {
245
- if (value.length <= maxChars) return value;
246
- return `${value.slice(0, maxChars - 1)}…`;
243
+ if (value.length <= maxChars) return value;
244
+ return `${value.slice(0, maxChars - 1)}…`;
247
245
  }