opencode-gemini-auth 1.4.0 → 1.4.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -34,10 +34,10 @@ describe("formatGeminiQuotaOutput", () => {
34
34
  Date.now = REAL_DATE_NOW;
35
35
  });
36
36
 
37
- it("renders sorted, model-specific usage lines", () => {
37
+ it("renders grouped progress bars, groups by version, and hides token type when all are REQUESTS", () => {
38
38
  const buckets: RetrieveUserQuotaBucket[] = [
39
39
  {
40
- modelId: "gemini-2.5-pro",
40
+ modelId: "gemini-2.5-pro_vertex",
41
41
  tokenType: "requests",
42
42
  remainingFraction: 0.5,
43
43
  remainingAmount: "100",
@@ -47,16 +47,75 @@ describe("formatGeminiQuotaOutput", () => {
47
47
  modelId: "gemini-2.5-flash",
48
48
  remainingAmount: "20",
49
49
  },
50
+ {
51
+ modelId: "gemini-2.5-pro",
52
+ tokenType: "requests",
53
+ remainingFraction: 0.7,
54
+ remainingAmount: "140",
55
+ resetTime: new Date(FIXED_NOW + 2 * 60 * 60 * 1000).toISOString(),
56
+ },
57
+ {
58
+ modelId: "gemini-3-pro-preview",
59
+ tokenType: "requests",
60
+ remainingFraction: 0.95,
61
+ },
62
+ {
63
+ modelId: "gemini-2.0-flash",
64
+ tokenType: "requests",
65
+ remainingFraction: 0.8,
66
+ },
50
67
  ];
51
68
 
52
69
  const output = formatGeminiQuotaOutput("test-project", buckets);
53
70
  expect(output).toContain("Gemini quota usage for project `test-project`");
54
- expect(output).toContain("- gemini-2.5-flash: 20 remaining");
71
+ expect(output).toContain("Variant");
72
+ expect(output).toContain("Remaining");
73
+ expect(output).toContain("Reset");
74
+ expect(output).not.toContain("Type");
75
+ expect(output).toContain("gemini-2.5-flash\n ↳ default");
76
+ expect(output).toContain("gemini-2.5-pro\n ↳ default");
77
+ expect(output).toContain(" ↳ vertex");
78
+ expect(output).toContain("Gemini 3 (1 model, 1 bucket)");
79
+ expect(output).toContain("Gemini 2.5 (2 models, 3 buckets)");
80
+ expect(output).toContain("Gemini 2.0 (1 model, 1 bucket)");
81
+ expect(output).toContain("▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░ 70.0% (140 left)");
55
82
  expect(output).toContain(
56
- "- gemini-2.5-pro (requests): 50.0% remaining (100 left), resets in 1h",
83
+ "▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░ 50.0% (100 left)",
84
+ );
85
+ expect(output.indexOf("Gemini 3 (1 model, 1 bucket)")).toBeLessThan(
86
+ output.indexOf("Gemini 2.5 (2 models, 3 buckets)"),
87
+ );
88
+ expect(output.indexOf("Gemini 2.5 (2 models, 3 buckets)")).toBeLessThan(
89
+ output.indexOf("Gemini 2.0 (1 model, 1 bucket)"),
90
+ );
91
+ expect(output).toContain("\n\nGemini 2.5 (2 models, 3 buckets)");
92
+ expect(output.indexOf("gemini-2.0-flash")).toBeGreaterThan(
93
+ output.indexOf("gemini-2.5-pro"),
57
94
  );
58
95
  expect(output.indexOf("gemini-2.5-flash")).toBeLessThan(
59
96
  output.indexOf("gemini-2.5-pro"),
60
97
  );
61
98
  });
99
+
100
+ it("shows token type column when multiple token types are present", () => {
101
+ const buckets: RetrieveUserQuotaBucket[] = [
102
+ {
103
+ modelId: "gemini-2.5-pro_vertex",
104
+ tokenType: "REQUESTS",
105
+ remainingFraction: 0.9,
106
+ },
107
+ {
108
+ modelId: "gemini-2.5-pro",
109
+ tokenType: "TOKENS",
110
+ remainingFraction: 0.8,
111
+ },
112
+ ];
113
+
114
+ const output = formatGeminiQuotaOutput("test-project", buckets);
115
+ expect(output).toContain("Type");
116
+ expect(output).toContain("Gemini 2.5 (1 model, 2 buckets)");
117
+ expect(output).toContain("REQUESTS");
118
+ expect(output).toContain("TOKENS");
119
+ expect(output).toContain(" ↳ vertex");
120
+ });
62
121
  });
@@ -82,10 +82,45 @@ export function formatGeminiQuotaOutput(
82
82
  buckets: RetrieveUserQuotaBucket[],
83
83
  ): string {
84
84
  const sortedBuckets = [...buckets].sort(compareQuotaBuckets);
85
- const lines = [`Gemini quota usage for project \`${projectId}\``, ""];
85
+ const groupedRows = groupQuotaRows(sortedBuckets);
86
+ const versionGroups = groupByVersion(groupedRows);
87
+ const variantWidth = Math.max(
88
+ "Variant".length,
89
+ ...versionGroups.flatMap((group) =>
90
+ group.models.flatMap((model) => model.rows.map((row) => row.variant.length))
91
+ ),
92
+ );
93
+ const tokenTypeValues = [...new Set(versionGroups.flatMap((group) =>
94
+ group.models.flatMap((model) => model.rows.map((row) => row.tokenType))
95
+ ))];
96
+ const showTokenType = tokenTypeValues.length > 1 || tokenTypeValues[0] !== "REQUESTS";
97
+ const lines = [
98
+ `Gemini quota usage for project \`${projectId}\``,
99
+ "",
100
+ showTokenType
101
+ ? ` ↳ ${pad("Variant", variantWidth)} Remaining Reset Type`
102
+ : ` ↳ ${pad("Variant", variantWidth)} Remaining Reset`,
103
+ ];
86
104
 
87
- for (const bucket of sortedBuckets) {
88
- lines.push(formatQuotaBucketLine(bucket));
105
+ for (let index = 0; index < versionGroups.length; index += 1) {
106
+ const versionGroup = versionGroups[index];
107
+ if (!versionGroup) {
108
+ continue;
109
+ }
110
+ if (index > 0) {
111
+ lines.push("");
112
+ }
113
+ lines.push(formatVersionGroupTitle(versionGroup));
114
+ for (const model of versionGroup.models) {
115
+ lines.push(model.baseModel);
116
+ for (const row of model.rows) {
117
+ lines.push(
118
+ showTokenType
119
+ ? ` ↳ ${pad(row.variant, variantWidth)} ${pad(row.usageRemaining, 27)} ${pad(row.resetValue, 8)} ${row.tokenType}`
120
+ : ` ↳ ${pad(row.variant, variantWidth)} ${pad(row.usageRemaining, 27)} ${row.resetValue}`,
121
+ );
122
+ }
123
+ }
89
124
  }
90
125
 
91
126
  return lines.join("\n");
@@ -110,18 +145,6 @@ function compareQuotaBuckets(
110
145
  return (left.resetTime ?? "").localeCompare(right.resetTime ?? "");
111
146
  }
112
147
 
113
- function formatQuotaBucketLine(bucket: RetrieveUserQuotaBucket): string {
114
- const modelId = bucket.modelId?.trim() || "unknown-model";
115
- const tokenType = bucket.tokenType?.trim();
116
- const usageRemaining = formatUsageRemaining(bucket);
117
- const resetLabel = formatRelativeResetTime(bucket.resetTime);
118
- const subject = tokenType ? `${modelId} (${tokenType})` : modelId;
119
-
120
- return resetLabel
121
- ? `- ${subject}: ${usageRemaining}, ${resetLabel}`
122
- : `- ${subject}: ${usageRemaining}`;
123
- }
124
-
125
148
  function formatUsageRemaining(bucket: RetrieveUserQuotaBucket): string {
126
149
  const remainingAmount = formatRemainingAmount(bucket.remainingAmount);
127
150
  const remainingFraction = bucket.remainingFraction;
@@ -129,17 +152,19 @@ function formatUsageRemaining(bucket: RetrieveUserQuotaBucket): string {
129
152
  typeof remainingFraction === "number" && Number.isFinite(remainingFraction);
130
153
 
131
154
  if (hasFraction) {
132
- const percent = Math.max(0, remainingFraction * 100).toFixed(1);
155
+ const clamped = clamp(remainingFraction, 0, 1);
156
+ const percent = (clamped * 100).toFixed(1);
157
+ const bar = buildProgressBar(clamped);
133
158
  return remainingAmount
134
- ? `${percent}% remaining (${remainingAmount} left)`
135
- : `${percent}% remaining`;
159
+ ? `${bar} ${percent}% (${remainingAmount} left)`
160
+ : `${bar} ${percent}%`;
136
161
  }
137
162
 
138
163
  if (remainingAmount) {
139
- return `${remainingAmount} remaining`;
164
+ return remainingAmount;
140
165
  }
141
166
 
142
- return "remaining unknown";
167
+ return "unknown";
143
168
  }
144
169
 
145
170
  function formatRemainingAmount(value: string | undefined): string | undefined {
@@ -180,3 +205,178 @@ export function formatRelativeResetTime(resetTime: string | undefined): string |
180
205
  }
181
206
  return `resets in ${minutes}m`;
182
207
  }
208
+
209
+ function buildProgressBar(fraction: number, width = 20): string {
210
+ const clamped = clamp(fraction, 0, 1);
211
+ const filled = clamped >= 1
212
+ ? width
213
+ : Math.max(0, Math.min(width, Math.max(clamped > 0 ? 1 : 0, Math.floor(clamped * width))));
214
+ const empty = width - filled;
215
+ return `${"▓".repeat(filled)}${"░".repeat(empty)}`;
216
+ }
217
+
218
+ function pad(value: string, width: number): string {
219
+ if (value.length >= width) {
220
+ return value;
221
+ }
222
+ return value.padEnd(width, " ");
223
+ }
224
+
225
+ function clamp(value: number, min: number, max: number): number {
226
+ if (value < min) {
227
+ return min;
228
+ }
229
+ if (value > max) {
230
+ return max;
231
+ }
232
+ return value;
233
+ }
234
+
235
+ function normalizeTokenType(bucket: RetrieveUserQuotaBucket): string {
236
+ const value = bucket.tokenType?.trim();
237
+ return value ? value.toUpperCase() : "REQUESTS";
238
+ }
239
+
240
+ interface GroupedQuotaRow {
241
+ variant: string;
242
+ usageRemaining: string;
243
+ resetValue: string;
244
+ tokenType: string;
245
+ }
246
+
247
+ interface GroupedQuotaModel {
248
+ baseModel: string;
249
+ version: string | undefined;
250
+ rows: GroupedQuotaRow[];
251
+ }
252
+
253
+ function groupQuotaRows(sortedBuckets: RetrieveUserQuotaBucket[]): GroupedQuotaModel[] {
254
+ const groups = new Map<string, GroupedQuotaModel>();
255
+
256
+ for (const bucket of sortedBuckets) {
257
+ const modelId = bucket.modelId?.trim() || "unknown-model";
258
+ const { baseModel, variant } = splitModelVariant(modelId);
259
+ const usageRemaining = formatUsageRemaining(bucket);
260
+ const resetLabel = formatRelativeResetTime(bucket.resetTime);
261
+ const resetValue = resetLabel?.replace("resets in ", "") ?? "-";
262
+ const tokenType = normalizeTokenType(bucket);
263
+
264
+ const existing = groups.get(baseModel);
265
+ if (existing) {
266
+ existing.rows.push({
267
+ variant,
268
+ usageRemaining,
269
+ resetValue,
270
+ tokenType,
271
+ });
272
+ continue;
273
+ }
274
+
275
+ groups.set(baseModel, {
276
+ baseModel,
277
+ version: extractModelVersion(baseModel),
278
+ rows: [{
279
+ variant,
280
+ usageRemaining,
281
+ resetValue,
282
+ tokenType,
283
+ }],
284
+ });
285
+ }
286
+
287
+ return [...groups.values()];
288
+ }
289
+
290
+ interface VersionQuotaGroup {
291
+ title: string;
292
+ version: string | undefined;
293
+ models: GroupedQuotaModel[];
294
+ }
295
+
296
+ function groupByVersion(models: GroupedQuotaModel[]): VersionQuotaGroup[] {
297
+ const groups = new Map<string, VersionQuotaGroup>();
298
+
299
+ for (const model of models) {
300
+ const key = model.version ?? "__unknown__";
301
+ const existing = groups.get(key);
302
+ if (existing) {
303
+ existing.models.push(model);
304
+ continue;
305
+ }
306
+
307
+ groups.set(key, {
308
+ title: model.version ? `Gemini ${model.version}` : "Other",
309
+ version: model.version,
310
+ models: [model],
311
+ });
312
+ }
313
+
314
+ const ordered = [...groups.values()].sort((left, right) =>
315
+ compareVersionDesc(left.version, right.version),
316
+ );
317
+
318
+ for (const group of ordered) {
319
+ group.models.sort((left, right) => left.baseModel.localeCompare(right.baseModel));
320
+ }
321
+
322
+ return ordered;
323
+ }
324
+
325
+ function extractModelVersion(modelId: string): string | undefined {
326
+ const match = modelId.match(/^gemini-([0-9]+(?:\.[0-9]+)*)-/i);
327
+ return match?.[1];
328
+ }
329
+
330
+ function compareVersionDesc(left: string | undefined, right: string | undefined): number {
331
+ if (!left && !right) {
332
+ return 0;
333
+ }
334
+ if (!left) {
335
+ return 1;
336
+ }
337
+ if (!right) {
338
+ return -1;
339
+ }
340
+
341
+ const leftSegments = left.split(".").map((part) => Number.parseInt(part, 10));
342
+ const rightSegments = right.split(".").map((part) => Number.parseInt(part, 10));
343
+ const max = Math.max(leftSegments.length, rightSegments.length);
344
+
345
+ for (let index = 0; index < max; index += 1) {
346
+ const l = leftSegments[index] ?? 0;
347
+ const r = rightSegments[index] ?? 0;
348
+ if (Number.isNaN(l) || Number.isNaN(r)) {
349
+ break;
350
+ }
351
+ if (l > r) {
352
+ return -1;
353
+ }
354
+ if (l < r) {
355
+ return 1;
356
+ }
357
+ }
358
+
359
+ return right.localeCompare(left);
360
+ }
361
+
362
+ function formatVersionGroupTitle(group: VersionQuotaGroup): string {
363
+ const modelCount = group.models.length;
364
+ const bucketCount = group.models.reduce((count, model) => count + model.rows.length, 0);
365
+ const modelLabel = modelCount === 1 ? "model" : "models";
366
+ const bucketLabel = bucketCount === 1 ? "bucket" : "buckets";
367
+ return `${group.title} (${modelCount} ${modelLabel}, ${bucketCount} ${bucketLabel})`;
368
+ }
369
+
370
+ function splitModelVariant(modelId: string): { baseModel: string; variant: string } {
371
+ const vertexSuffix = "_vertex";
372
+ if (modelId.endsWith(vertexSuffix)) {
373
+ return {
374
+ baseModel: modelId.slice(0, -vertexSuffix.length),
375
+ variant: "vertex",
376
+ };
377
+ }
378
+ return {
379
+ baseModel: modelId,
380
+ variant: "default",
381
+ };
382
+ }