opencode-quotas 0.0.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.
Files changed (136) hide show
  1. package/README.md +344 -0
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +3 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/cli.d.ts +3 -0
  7. package/dist/src/cli.d.ts.map +1 -0
  8. package/dist/src/cli.js +42 -0
  9. package/dist/src/cli.js.map +1 -0
  10. package/dist/src/constants.d.ts +9 -0
  11. package/dist/src/constants.d.ts.map +1 -0
  12. package/dist/src/constants.js +15 -0
  13. package/dist/src/constants.js.map +1 -0
  14. package/dist/src/defaults.d.ts +3 -0
  15. package/dist/src/defaults.d.ts.map +1 -0
  16. package/dist/src/defaults.js +52 -0
  17. package/dist/src/defaults.js.map +1 -0
  18. package/dist/src/index.d.ts +6 -0
  19. package/dist/src/index.d.ts.map +1 -0
  20. package/dist/src/index.js +265 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/interfaces.d.ts +197 -0
  23. package/dist/src/interfaces.d.ts.map +1 -0
  24. package/dist/src/interfaces.js +2 -0
  25. package/dist/src/interfaces.js.map +1 -0
  26. package/dist/src/logger.d.ts +14 -0
  27. package/dist/src/logger.d.ts.map +1 -0
  28. package/dist/src/logger.js +44 -0
  29. package/dist/src/logger.js.map +1 -0
  30. package/dist/src/plugin-state.d.ts +20 -0
  31. package/dist/src/plugin-state.d.ts.map +1 -0
  32. package/dist/src/plugin-state.js +53 -0
  33. package/dist/src/plugin-state.js.map +1 -0
  34. package/dist/src/providers/antigravity/auth.d.ts +12 -0
  35. package/dist/src/providers/antigravity/auth.d.ts.map +1 -0
  36. package/dist/src/providers/antigravity/auth.js +109 -0
  37. package/dist/src/providers/antigravity/auth.js.map +1 -0
  38. package/dist/src/providers/antigravity/index.d.ts +2 -0
  39. package/dist/src/providers/antigravity/index.d.ts.map +1 -0
  40. package/dist/src/providers/antigravity/index.js +2 -0
  41. package/dist/src/providers/antigravity/index.js.map +1 -0
  42. package/dist/src/providers/antigravity/provider.d.ts +34 -0
  43. package/dist/src/providers/antigravity/provider.d.ts.map +1 -0
  44. package/dist/src/providers/antigravity/provider.js +216 -0
  45. package/dist/src/providers/antigravity/provider.js.map +1 -0
  46. package/dist/src/providers/codex.d.ts +4 -0
  47. package/dist/src/providers/codex.d.ts.map +1 -0
  48. package/dist/src/providers/codex.js +242 -0
  49. package/dist/src/providers/codex.js.map +1 -0
  50. package/dist/src/providers/github.d.ts +4 -0
  51. package/dist/src/providers/github.d.ts.map +1 -0
  52. package/dist/src/providers/github.js +139 -0
  53. package/dist/src/providers/github.js.map +1 -0
  54. package/dist/src/quota-cache.d.ts +26 -0
  55. package/dist/src/quota-cache.d.ts.map +1 -0
  56. package/dist/src/quota-cache.js +107 -0
  57. package/dist/src/quota-cache.js.map +1 -0
  58. package/dist/src/registry.d.ts +3 -0
  59. package/dist/src/registry.d.ts.map +1 -0
  60. package/dist/src/registry.js +23 -0
  61. package/dist/src/registry.js.map +1 -0
  62. package/dist/src/services/aggregation-service.d.ts +34 -0
  63. package/dist/src/services/aggregation-service.d.ts.map +1 -0
  64. package/dist/src/services/aggregation-service.js +89 -0
  65. package/dist/src/services/aggregation-service.js.map +1 -0
  66. package/dist/src/services/config-loader.d.ts +25 -0
  67. package/dist/src/services/config-loader.d.ts.map +1 -0
  68. package/dist/src/services/config-loader.js +105 -0
  69. package/dist/src/services/config-loader.js.map +1 -0
  70. package/dist/src/services/history-service.d.ts +15 -0
  71. package/dist/src/services/history-service.d.ts.map +1 -0
  72. package/dist/src/services/history-service.js +99 -0
  73. package/dist/src/services/history-service.js.map +1 -0
  74. package/dist/src/services/prediction-engine.d.ts +47 -0
  75. package/dist/src/services/prediction-engine.d.ts.map +1 -0
  76. package/dist/src/services/prediction-engine.js +94 -0
  77. package/dist/src/services/prediction-engine.js.map +1 -0
  78. package/dist/src/services/quota-service.d.ts +41 -0
  79. package/dist/src/services/quota-service.d.ts.map +1 -0
  80. package/dist/src/services/quota-service.js +257 -0
  81. package/dist/src/services/quota-service.js.map +1 -0
  82. package/dist/src/tools/quotas.d.ts +11 -0
  83. package/dist/src/tools/quotas.d.ts.map +1 -0
  84. package/dist/src/tools/quotas.js +62 -0
  85. package/dist/src/tools/quotas.js.map +1 -0
  86. package/dist/src/ui/progress-bar.d.ts +20 -0
  87. package/dist/src/ui/progress-bar.d.ts.map +1 -0
  88. package/dist/src/ui/progress-bar.js +150 -0
  89. package/dist/src/ui/progress-bar.js.map +1 -0
  90. package/dist/src/ui/quota-table.d.ts +15 -0
  91. package/dist/src/ui/quota-table.d.ts.map +1 -0
  92. package/dist/src/ui/quota-table.js +136 -0
  93. package/dist/src/ui/quota-table.js.map +1 -0
  94. package/dist/src/utils/debug.d.ts +1 -0
  95. package/dist/src/utils/debug.d.ts.map +1 -0
  96. package/dist/src/utils/debug.js +3 -0
  97. package/dist/src/utils/debug.js.map +1 -0
  98. package/dist/src/utils/paths.d.ts +7 -0
  99. package/dist/src/utils/paths.d.ts.map +1 -0
  100. package/dist/src/utils/paths.js +37 -0
  101. package/dist/src/utils/paths.js.map +1 -0
  102. package/dist/src/utils/time.d.ts +3 -0
  103. package/dist/src/utils/time.d.ts.map +1 -0
  104. package/dist/src/utils/time.js +38 -0
  105. package/dist/src/utils/time.js.map +1 -0
  106. package/dist/src/utils/validation.d.ts +6 -0
  107. package/dist/src/utils/validation.d.ts.map +1 -0
  108. package/dist/src/utils/validation.js +66 -0
  109. package/dist/src/utils/validation.js.map +1 -0
  110. package/package.json +42 -0
  111. package/src/cli.ts +53 -0
  112. package/src/constants.ts +17 -0
  113. package/src/defaults.ts +53 -0
  114. package/src/index.ts +338 -0
  115. package/src/interfaces.ts +258 -0
  116. package/src/logger.ts +55 -0
  117. package/src/plugin-state.ts +65 -0
  118. package/src/providers/antigravity/auth.ts +163 -0
  119. package/src/providers/antigravity/index.ts +1 -0
  120. package/src/providers/antigravity/provider.ts +337 -0
  121. package/src/providers/codex.ts +327 -0
  122. package/src/providers/github.ts +161 -0
  123. package/src/quota-cache.ts +157 -0
  124. package/src/registry.ts +30 -0
  125. package/src/services/aggregation-service.ts +116 -0
  126. package/src/services/config-loader.ts +124 -0
  127. package/src/services/history-service.ts +116 -0
  128. package/src/services/prediction-engine.ts +133 -0
  129. package/src/services/quota-service.ts +343 -0
  130. package/src/tools/quotas.ts +78 -0
  131. package/src/ui/progress-bar.ts +204 -0
  132. package/src/ui/quota-table.ts +173 -0
  133. package/src/utils/debug.ts +1 -0
  134. package/src/utils/paths.ts +41 -0
  135. package/src/utils/time.ts +40 -0
  136. package/src/utils/validation.ts +63 -0
@@ -0,0 +1,161 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { AUTH_FILE, getConfigDirectory } from "../utils/paths";
3
+ import { join } from "node:path";
4
+ import { type IQuotaProvider, type QuotaData } from "../interfaces";
5
+
6
+ const AUTH_PATH_LOCAL = AUTH_FILE();
7
+ const AUTH_PATH_CONFIG = join(getConfigDirectory(), "auth.json");
8
+
9
+ interface AuthInfo {
10
+ type: string;
11
+ access: string;
12
+ refresh?: string;
13
+ expires?: number;
14
+ }
15
+
16
+ type AuthFile = Record<string, AuthInfo>;
17
+
18
+ async function readAuthFile(): Promise<AuthFile | null> {
19
+ for (const path of [AUTH_PATH_LOCAL, AUTH_PATH_CONFIG]) {
20
+ try {
21
+ const raw = await readFile(path, "utf8");
22
+ return JSON.parse(raw) as AuthFile;
23
+ } catch {
24
+ continue;
25
+ }
26
+ }
27
+
28
+ return null;
29
+ }
30
+
31
+ function parseTokenSku(token: string): string | null {
32
+ const parts = token.split(";");
33
+ for (const part of parts) {
34
+ if (part.startsWith("sku=")) {
35
+ return part.split("=")[1];
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function getNextMonthStart(): Date {
42
+ const now = new Date();
43
+ const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0));
44
+ return nextMonth;
45
+ }
46
+
47
+ function formatTimeUntil(target: Date): string {
48
+ const diff = target.getTime() - Date.now();
49
+ if (diff <= 0) return "soon";
50
+
51
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
52
+ const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
53
+
54
+ if (days > 0) return `${days}d ${hours}h`;
55
+ return `${hours}h`;
56
+ }
57
+
58
+ export function parseGithubUsage(data: unknown, sku: string | null, apiWarning?: string | null): QuotaData[] {
59
+ // free_engaged_oss_quota is actually a "pro" equivalent for OSS maintainers
60
+ const isFreeLimited = sku?.includes("free") && !sku?.includes("oss");
61
+
62
+ const now = new Date();
63
+ const resetTime = getNextMonthStart();
64
+ const resetStr = `resets in ${formatTimeUntil(resetTime)}`;
65
+
66
+ let usedSuggestions = 0;
67
+ let limit = isFreeLimited ? 2000 : null;
68
+ let unit = "suggestions";
69
+
70
+ if (Array.isArray(data) && data.length > 0) {
71
+ const currentMonthUsage = data.filter((day: unknown) => {
72
+ if (!day || typeof day !== "object") return false;
73
+ const dayRecord = day as Record<string, unknown>;
74
+ const dayValue = dayRecord["day"];
75
+ if (typeof dayValue !== "string") return false;
76
+
77
+ const dayDate = new Date(dayValue);
78
+ return dayDate.getUTCMonth() === now.getUTCMonth() && dayDate.getUTCFullYear() === now.getUTCFullYear();
79
+ });
80
+
81
+ usedSuggestions = currentMonthUsage.reduce((acc: number, day: unknown) => {
82
+ if (!day || typeof day !== "object") return acc;
83
+ const dayRecord = day as Record<string, unknown>;
84
+ const suggestions = typeof dayRecord["total_suggestions_count"] === "number" ? dayRecord["total_suggestions_count"] : 0;
85
+ const chat = typeof dayRecord["total_chat_count"] === "number" ? dayRecord["total_chat_count"] : 0;
86
+ return acc + suggestions + chat;
87
+ }, 0);
88
+ }
89
+
90
+ const infoParts: string[] = [];
91
+ if (isFreeLimited) infoParts.push("Free Plan");
92
+ else if (sku) infoParts.push("Pro Plan");
93
+
94
+ if (apiWarning) {
95
+ infoParts.push("Service Currently Unavailable (API Deprecated)");
96
+ }
97
+
98
+ return [
99
+ {
100
+ id: "github-copilot",
101
+ providerName: "GitHub Copilot",
102
+ used: usedSuggestions,
103
+ limit: limit,
104
+ unit: unit,
105
+ reset: resetStr,
106
+ window: "Monthly",
107
+ info: infoParts.join(" | "),
108
+ }
109
+ ];
110
+ }
111
+
112
+ export function createGithubProvider(): IQuotaProvider {
113
+ return {
114
+ id: "github-copilot",
115
+ async fetchQuota(): Promise<QuotaData[]> {
116
+ const auth = await readAuthFile();
117
+ if (!auth) {
118
+ throw new Error("Opencode auth.json not found");
119
+ }
120
+
121
+ const info = auth["github-copilot"] || auth["github"];
122
+ if (!info || !info.access) {
123
+ throw new Error("GitHub Copilot credentials missing");
124
+ }
125
+
126
+ const sku = parseTokenSku(info.access);
127
+
128
+ // Note: GitHub does not currently support a user-level Copilot usage
129
+ // endpoint for individual accounts. The legacy/beta endpoints were
130
+ // deprecated (and may return 404). We still attempt the call, but we
131
+ // surface failures as provider metadata so users can diagnose.
132
+ let data: unknown = null;
133
+ let apiWarning: string | null = null;
134
+
135
+ try {
136
+ const response = await fetch("https://api.github.com/user/copilot/usage", {
137
+ headers: {
138
+ Authorization: `Bearer ${info.access}`,
139
+ "X-GitHub-Api-Version": "2022-11-28",
140
+ Accept: "application/vnd.github+json",
141
+ },
142
+ });
143
+
144
+ if (!response.ok) {
145
+ if (response.status === 404) {
146
+ apiWarning = "404";
147
+ } else {
148
+ const body = await response.text();
149
+ apiWarning = `${response.status}: ${body.slice(0, 50)}`;
150
+ }
151
+ } else {
152
+ data = (await response.json()) as unknown;
153
+ }
154
+ } catch (error) {
155
+ apiWarning = "Request Failed";
156
+ }
157
+
158
+ return parseGithubUsage(data, sku, apiWarning);
159
+ },
160
+ };
161
+ }
@@ -0,0 +1,157 @@
1
+ import { type IQuotaProvider, type QuotaData, type IHistoryService } from "./interfaces";
2
+ import { validateQuotaData } from "./utils/validation";
3
+
4
+ import { logger } from "./logger";
5
+
6
+ type CachedQuotas = {
7
+ data: QuotaData[];
8
+ fetchedAt: Date | null;
9
+ lastError: unknown;
10
+ };
11
+
12
+ type QuotaCacheOptions = {
13
+ refreshIntervalMs: number;
14
+ historyService?: IHistoryService;
15
+ debug?: boolean;
16
+ };
17
+
18
+ const DEFAULT_OPTIONS: QuotaCacheOptions = {
19
+ refreshIntervalMs: 60_000,
20
+ };
21
+
22
+ export class QuotaCache {
23
+ private readonly providers: IQuotaProvider[];
24
+ private readonly options: QuotaCacheOptions;
25
+ private state: CachedQuotas;
26
+ private timer: ReturnType<typeof setInterval> | null;
27
+ private inFlight: Promise<void> | null;
28
+
29
+ public constructor(providers: IQuotaProvider[], options?: Partial<QuotaCacheOptions>) {
30
+ this.providers = providers;
31
+ this.options = { ...DEFAULT_OPTIONS, ...(options ?? {}) };
32
+ this.state = { data: [], fetchedAt: null, lastError: null };
33
+ this.timer = null;
34
+ this.inFlight = null;
35
+ }
36
+
37
+ public start(): void {
38
+ if (this.timer) return;
39
+
40
+ // Kick off an initial refresh without blocking startup.
41
+ void this.refresh();
42
+
43
+ this.timer = setInterval(() => {
44
+ void this.refresh();
45
+ }, this.options.refreshIntervalMs);
46
+
47
+ // Avoid keeping the process alive just for quota polling.
48
+ this.timer.unref?.();
49
+ }
50
+
51
+ public stop(): void {
52
+ if (this.timer) {
53
+ clearInterval(this.timer);
54
+ this.timer = null;
55
+ }
56
+ }
57
+
58
+ public getSnapshot(): CachedQuotas {
59
+ return this.state;
60
+ }
61
+
62
+ public async refresh(): Promise<void> {
63
+ logger.debug(
64
+ "cache:refresh_start",
65
+ {
66
+ providerCount: this.providers.length,
67
+ refreshIntervalMs: this.options.refreshIntervalMs,
68
+ inFlight: !!this.inFlight,
69
+ },
70
+ );
71
+ if (this.inFlight) {
72
+ logger.debug("cache:refresh_coalesced", { inFlight: true });
73
+ return this.inFlight;
74
+ }
75
+
76
+ const refreshPromise = this.doRefresh();
77
+ this.inFlight = refreshPromise;
78
+
79
+ return refreshPromise;
80
+ }
81
+
82
+
83
+ private async doRefresh(): Promise<void> {
84
+ try {
85
+ const results = await Promise.all(
86
+ this.providers.map(async (p: IQuotaProvider) => {
87
+ const startedAt = Date.now();
88
+ try {
89
+ logger.debug(
90
+ "cache:provider_fetch_start",
91
+ { id: p.id },
92
+ );
93
+ const result = await p.fetchQuota();
94
+ logger.debug(
95
+ "cache:provider_fetch_ok",
96
+ {
97
+ id: p.id,
98
+ count: result.length,
99
+ durationMs: Date.now() - startedAt,
100
+ },
101
+ );
102
+ return result;
103
+ } catch (e) {
104
+ logger.error(
105
+ "cache:provider_fetch_error",
106
+ {
107
+ id: p.id,
108
+ durationMs: Date.now() - startedAt,
109
+ error: e,
110
+ },
111
+ );
112
+ return [];
113
+ }
114
+ }),
115
+ );
116
+
117
+ // Validate and normalize provider responses before storing
118
+ const flattened = results.flat();
119
+ const validatedData = flattened
120
+ .map(d => validateQuotaData(d))
121
+ .filter((v): v is QuotaData => v !== null);
122
+
123
+ this.state = {
124
+ data: validatedData,
125
+ fetchedAt: new Date(),
126
+ lastError: null,
127
+ };
128
+
129
+ logger.debug(
130
+ "cache:refresh_ok",
131
+ {
132
+ totalCount: this.state.data.length,
133
+ fetchedAt: this.state.fetchedAt?.toISOString(),
134
+ },
135
+ );
136
+
137
+ if (this.options.historyService) {
138
+ void this.options.historyService.append(this.state.data);
139
+ }
140
+ } catch (e) {
141
+ this.state = {
142
+ ...this.state,
143
+ lastError: e,
144
+ };
145
+ logger.error(
146
+ "cache:refresh_error",
147
+ { error: e },
148
+ );
149
+ } finally {
150
+ logger.debug(
151
+ "cache:refresh_end",
152
+ { inFlightCleared: true },
153
+ );
154
+ this.inFlight = null;
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,30 @@
1
+ import { type IQuotaRegistry, type IQuotaProvider } from "./interfaces";
2
+
3
+ const REGISTRY_KEY = "__OPENCODE_QUOTA_REGISTRY__";
4
+
5
+ type RegistryGlobal = {
6
+ [REGISTRY_KEY]?: IQuotaRegistry;
7
+ };
8
+
9
+ function createRegistry(): IQuotaRegistry {
10
+ const providers: IQuotaProvider[] = [];
11
+ return {
12
+ register(provider: IQuotaProvider) {
13
+ if (providers.some((p) => p.id === provider.id)) {
14
+ return;
15
+ }
16
+ providers.push(provider);
17
+ },
18
+ getAll() {
19
+ return [...providers];
20
+ },
21
+ };
22
+ }
23
+
24
+ export function getQuotaRegistry(): IQuotaRegistry {
25
+ const globalRef = globalThis as RegistryGlobal;
26
+ if (!globalRef[REGISTRY_KEY]) {
27
+ globalRef[REGISTRY_KEY] = createRegistry();
28
+ }
29
+ return globalRef[REGISTRY_KEY] as IQuotaRegistry;
30
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ type QuotaData,
3
+ type IPredictionEngine,
4
+ type IAggregationService
5
+ } from "../interfaces";
6
+ import { formatDurationMs } from "../utils/time";
7
+
8
+ /**
9
+ * Service for aggregating multiple quota sources into a single representative quota.
10
+ *
11
+ * Supports multiple aggregation strategies:
12
+ * - most_critical: Selects the quota with shortest predicted time-to-limit
13
+ * - max: Selects the quota with highest usage ratio
14
+ * - min: Selects the quota with lowest usage ratio
15
+ * - mean: Creates a synthetic quota with average usage ratio
16
+ * - median: Creates a synthetic quota with median usage ratio
17
+ */
18
+ export class AggregationService implements IAggregationService {
19
+ private readonly predictionEngine: IPredictionEngine;
20
+
21
+ constructor(predictionEngine: IPredictionEngine) {
22
+ this.predictionEngine = predictionEngine;
23
+ }
24
+
25
+ /**
26
+ * Aggregates quotas using the most critical (shortest time-to-limit) strategy.
27
+ * Falls back to max usage ratio if no predictions are available.
28
+ */
29
+ aggregateMostCritical(
30
+ quotas: QuotaData[],
31
+ windowMinutes: number = 60,
32
+ shortWindowMinutes?: number
33
+ ): QuotaData | null {
34
+ if (quotas.length === 0) return null;
35
+
36
+ let minTime = Infinity;
37
+ let representative: QuotaData | null = null;
38
+
39
+ for (const q of quotas) {
40
+ const time = this.predictionEngine.predictTimeToLimit(
41
+ q.id,
42
+ windowMinutes,
43
+ shortWindowMinutes
44
+ );
45
+ if (time < minTime) {
46
+ minTime = time;
47
+ representative = q;
48
+ }
49
+ }
50
+
51
+ // Fallback to max usage if no prediction is possible
52
+ if (!representative) {
53
+ return this.aggregateMax(quotas);
54
+ }
55
+
56
+ if (minTime !== Infinity) {
57
+ return {
58
+ ...representative,
59
+ predictedReset: `in ${formatDurationMs(minTime)} (predicted)`
60
+ };
61
+ }
62
+ return representative;
63
+ }
64
+
65
+ /**
66
+ * Aggregates quotas by selecting the one with highest usage ratio.
67
+ */
68
+ aggregateMax(quotas: QuotaData[]): QuotaData {
69
+ return quotas.reduce((a, b) => {
70
+ const aRatio = a.limit ? a.used / a.limit : 0;
71
+ const bRatio = b.limit ? b.used / b.limit : 0;
72
+ return aRatio > bRatio ? a : b;
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Aggregates quotas by selecting the one with lowest usage ratio.
78
+ */
79
+ aggregateMin(quotas: QuotaData[]): QuotaData {
80
+ return quotas.reduce((a, b) => {
81
+ const aRatio = a.limit ? a.used / a.limit : 0;
82
+ const bRatio = b.limit ? b.used / b.limit : 0;
83
+ return aRatio < bRatio ? a : b;
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Aggregates quotas by averaging their usage ratios.
89
+ * Creates a synthetic quota with percentage-based representation.
90
+ */
91
+ aggregateAverage(
92
+ quotas: QuotaData[],
93
+ name: string,
94
+ id: string,
95
+ strategy: "mean" | "median"
96
+ ): QuotaData {
97
+ const ratios = quotas.map(q => q.limit ? q.used / q.limit : 0);
98
+ let avgRatio = 0;
99
+
100
+ if (strategy === "mean") {
101
+ avgRatio = ratios.reduce((a, b) => a + b, 0) / ratios.length;
102
+ } else {
103
+ ratios.sort((a, b) => a - b);
104
+ avgRatio = ratios[Math.floor(ratios.length / 2)];
105
+ }
106
+
107
+ return {
108
+ id: id,
109
+ providerName: name,
110
+ used: Math.round(avgRatio * 100),
111
+ limit: 100,
112
+ unit: "%",
113
+ info: "Aggregated"
114
+ };
115
+ }
116
+ }
@@ -0,0 +1,124 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { DEFAULT_CONFIG } from "../defaults";
4
+ import { type QuotaConfig } from "../interfaces";
5
+ import { logger } from "../logger";
6
+
7
+ import { validatePollingInterval } from "../utils/validation";
8
+
9
+ /**
10
+ * Configuration loading and merging service.
11
+ * Handles reading config from disk and merging with defaults.
12
+ */
13
+ export class ConfigLoader {
14
+ /**
15
+ * Creates a new configuration by merging defaults with initial config.
16
+ */
17
+ static createConfig(initialConfig?: Partial<QuotaConfig>): QuotaConfig {
18
+ const config: QuotaConfig = { ...DEFAULT_CONFIG, ...initialConfig };
19
+
20
+ // Deep clone specific nested objects to avoid mutation of the constant
21
+ if (DEFAULT_CONFIG.progressBar) {
22
+ config.progressBar = { ...DEFAULT_CONFIG.progressBar, ...initialConfig?.progressBar };
23
+ }
24
+ if (DEFAULT_CONFIG.aggregatedGroups) {
25
+ config.aggregatedGroups = [
26
+ ...DEFAULT_CONFIG.aggregatedGroups,
27
+ ...(initialConfig?.aggregatedGroups || [])
28
+ ];
29
+ }
30
+
31
+ return config;
32
+ }
33
+
34
+ /**
35
+ * Loads and merges user configuration from disk into the provided config.
36
+ * Returns the updated config.
37
+ */
38
+ static async loadFromDisk(
39
+ directory: string,
40
+ config: QuotaConfig
41
+ ): Promise<QuotaConfig> {
42
+ const result = { ...config };
43
+
44
+ try {
45
+ const envConfigPath = process.env.OPENCODE_QUOTAS_CONFIG_PATH;
46
+ const configPath = envConfigPath || join(directory, ".opencode", "quotas.json");
47
+ const rawConfig = await readFile(configPath, "utf-8");
48
+ const userConfig = JSON.parse(rawConfig);
49
+
50
+ ConfigLoader.mergeUserConfig(result, userConfig);
51
+
52
+ logger.debug(
53
+ "init:config_loaded",
54
+ { configPath, debug: result.debug },
55
+ );
56
+
57
+ } catch (e) {
58
+ // Ignore missing config or parse errors
59
+ logger.error(
60
+ "init:config_load_failed",
61
+ { error: e },
62
+ );
63
+ }
64
+
65
+ // Validate and normalize config values
66
+ await ConfigLoader.validateConfig(result);
67
+
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Merges user configuration into the target config.
73
+ */
74
+ private static mergeUserConfig(target: QuotaConfig, userConfig: Partial<QuotaConfig>): void {
75
+ if (userConfig.debug !== undefined) {
76
+ target.debug = userConfig.debug;
77
+ logger.setDebug(!!target.debug);
78
+ }
79
+ if (userConfig.footer !== undefined) {
80
+ target.footer = userConfig.footer;
81
+ }
82
+ if (userConfig.progressBar) {
83
+ target.progressBar = { ...target.progressBar, ...userConfig.progressBar };
84
+ }
85
+ if (userConfig.table) {
86
+ target.table = { ...target.table, ...userConfig.table };
87
+ }
88
+ if (userConfig.disabled) {
89
+ target.disabled = userConfig.disabled;
90
+ }
91
+ if (userConfig.filterByCurrentModel !== undefined) {
92
+ target.filterByCurrentModel = userConfig.filterByCurrentModel;
93
+ }
94
+ if (userConfig.aggregatedGroups) {
95
+ target.aggregatedGroups = userConfig.aggregatedGroups;
96
+ }
97
+ if (userConfig.historyMaxAgeHours !== undefined) {
98
+ target.historyMaxAgeHours = userConfig.historyMaxAgeHours;
99
+ }
100
+ if (userConfig.predictionShortWindowMinutes !== undefined) {
101
+ target.predictionShortWindowMinutes = userConfig.predictionShortWindowMinutes;
102
+ }
103
+ if (userConfig.pollingInterval !== undefined) {
104
+ target.pollingInterval = userConfig.pollingInterval;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validates and normalizes configuration values.
110
+ */
111
+ private static async validateConfig(config: QuotaConfig): Promise<void> {
112
+ // Handle pollingInterval from user config
113
+ const validated = validatePollingInterval(config.pollingInterval as unknown);
114
+ if (validated === null) {
115
+ console.warn('[QuotaService] pollingInterval is invalid, using default');
116
+ config.pollingInterval = DEFAULT_CONFIG.pollingInterval;
117
+ } else if (validated < 10_000) {
118
+ console.warn('[QuotaService] pollingInterval below 10s is not recommended');
119
+ config.pollingInterval = Math.max(validated, 1_000);
120
+ } else {
121
+ config.pollingInterval = validated;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,116 @@
1
+ import { writeFile, readFile } from "node:fs/promises";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { HISTORY_FILE } from "../utils/paths";
5
+ import { type IHistoryService, type HistoryPoint, type QuotaData } from "../interfaces";
6
+ import { logger } from "../logger";
7
+
8
+ export class HistoryService implements IHistoryService {
9
+ private historyPath: string;
10
+ private data: Record<string, HistoryPoint[]> = {};
11
+ private maxWindowMs: number = 24 * 60 * 60 * 1000; // Keep 24 hours of history
12
+ private saveTimeout: ReturnType<typeof setTimeout> | null = null;
13
+
14
+ constructor(customPath?: string) {
15
+ this.historyPath = customPath || HISTORY_FILE();
16
+ }
17
+
18
+ async init(): Promise<void> {
19
+ try {
20
+ const dir = join(this.historyPath, "..");
21
+ if (!existsSync(dir)) {
22
+ mkdirSync(dir, { recursive: true });
23
+ }
24
+
25
+ if (existsSync(this.historyPath)) {
26
+ const raw = await readFile(this.historyPath, "utf-8");
27
+ this.data = JSON.parse(raw);
28
+ }
29
+ } catch (e) {
30
+ logger.error("history-service:init_failed", { path: this.historyPath, error: e });
31
+ this.data = {};
32
+ }
33
+ }
34
+
35
+ async append(snapshot: QuotaData[]): Promise<void> {
36
+ const timestamp = Date.now();
37
+ let changed = false;
38
+
39
+ for (const quota of snapshot) {
40
+ if (!this.data[quota.id]) {
41
+ this.data[quota.id] = [];
42
+ }
43
+
44
+ this.data[quota.id].push({
45
+ timestamp,
46
+ used: quota.used,
47
+ limit: quota.limit
48
+ });
49
+
50
+ // Prune old data for this quota
51
+ const cutoff = timestamp - this.maxWindowMs;
52
+ const originalLength = this.data[quota.id].length;
53
+ this.data[quota.id] = this.data[quota.id].filter(p => p.timestamp >= cutoff);
54
+
55
+ if (this.data[quota.id].length !== originalLength || originalLength > 0) {
56
+ changed = true;
57
+ }
58
+ }
59
+
60
+ if (changed || snapshot.length > 0) {
61
+ this.save();
62
+ }
63
+ }
64
+
65
+ getHistory(quotaId: string, windowMs: number): HistoryPoint[] {
66
+ const now = Date.now();
67
+ const cutoff = now - windowMs;
68
+ const history = this.data[quotaId] || [];
69
+ return history.filter(p => p.timestamp >= cutoff);
70
+ }
71
+
72
+ setMaxAge(hours: number): void {
73
+ this.maxWindowMs = hours * 60 * 60 * 1000;
74
+ }
75
+
76
+ async pruneAll(): Promise<void> {
77
+ const now = Date.now();
78
+ const cutoff = now - this.maxWindowMs;
79
+ let changed = false;
80
+
81
+ for (const id in this.data) {
82
+ const originalLen = this.data[id].length;
83
+ this.data[id] = this.data[id].filter(p => p.timestamp >= cutoff);
84
+
85
+ if (this.data[id].length !== originalLen) {
86
+ changed = true;
87
+ }
88
+
89
+ // Remove key if empty to free memory
90
+ if (this.data[id].length === 0) {
91
+ delete this.data[id];
92
+ changed = true;
93
+ }
94
+ }
95
+
96
+ if (changed) {
97
+ this.save();
98
+ }
99
+ }
100
+
101
+ private save(): void {
102
+ if (this.saveTimeout) {
103
+ clearTimeout(this.saveTimeout);
104
+ }
105
+
106
+ this.saveTimeout = setTimeout(async () => {
107
+ try {
108
+ await writeFile(this.historyPath, JSON.stringify(this.data, null, 2), "utf-8");
109
+ logger.debug("history-service:save_success", { path: this.historyPath });
110
+ } catch (e) {
111
+ logger.error("history-service:save_failed", { path: this.historyPath, error: e });
112
+ }
113
+ this.saveTimeout = null;
114
+ }, 5000);
115
+ }
116
+ }