opencode-gemini-auth 1.3.11 → 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/README.md +6 -0
- package/package.json +2 -2
- package/src/plugin/project/api.ts +1 -8
- package/src/plugin/project/types.ts +12 -0
- package/src/plugin/quota.test.ts +121 -0
- package/src/plugin/quota.ts +382 -0
- package/src/plugin/types.ts +4 -0
- package/src/plugin.ts +29 -0
package/README.md
CHANGED
|
@@ -47,6 +47,12 @@ Add the plugin to your Opencode configuration file
|
|
|
47
47
|
|
|
48
48
|
Once authenticated, Opencode will use your Google account for Gemini requests.
|
|
49
49
|
|
|
50
|
+
To check your current Gemini Code Assist quota buckets at any time, run:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
/gquota
|
|
54
|
+
```
|
|
55
|
+
|
|
50
56
|
## Configuration
|
|
51
57
|
|
|
52
58
|
### Google Cloud Project
|
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
|
+
"version": "1.4.1",
|
|
5
5
|
"author": "jenslys",
|
|
6
6
|
"repository": "https://github.com/jenslys/opencode-gemini-auth",
|
|
7
7
|
"files": [
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"update:gemini-cli": "git -C .local/gemini-cli pull --ff-only"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@opencode-ai/plugin": "^1.
|
|
17
|
+
"@opencode-ai/plugin": "^1.2.10",
|
|
18
18
|
"@types/bun": "latest"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FREE_TIER_ID,
|
|
5
5
|
type LoadCodeAssistPayload,
|
|
6
6
|
type OnboardUserPayload,
|
|
7
|
+
type RetrieveUserQuotaResponse,
|
|
7
8
|
ProjectIdRequiredError,
|
|
8
9
|
} from "./types";
|
|
9
10
|
import { buildMetadata, isVpcScError, parseJsonSafe, wait } from "./utils";
|
|
@@ -131,14 +132,6 @@ export async function onboardManagedProject(
|
|
|
131
132
|
return undefined;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
interface RetrieveUserQuotaBucket {
|
|
135
|
-
modelId?: string;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
interface RetrieveUserQuotaResponse {
|
|
139
|
-
buckets?: RetrieveUserQuotaBucket[];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
135
|
/**
|
|
143
136
|
* Retrieves Code Assist quota buckets, which include model IDs visible to the current account/project.
|
|
144
137
|
*/
|
|
@@ -43,6 +43,18 @@ export interface OnboardUserPayload {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface RetrieveUserQuotaBucket {
|
|
47
|
+
remainingAmount?: string;
|
|
48
|
+
remainingFraction?: number;
|
|
49
|
+
resetTime?: string;
|
|
50
|
+
tokenType?: string;
|
|
51
|
+
modelId?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RetrieveUserQuotaResponse {
|
|
55
|
+
buckets?: RetrieveUserQuotaBucket[];
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
/**
|
|
47
59
|
* Error raised when a required Google Cloud project is missing during Gemini onboarding.
|
|
48
60
|
*/
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { formatGeminiQuotaOutput, formatRelativeResetTime } from "./quota";
|
|
3
|
+
import type { RetrieveUserQuotaBucket } from "./project/types";
|
|
4
|
+
|
|
5
|
+
const REAL_DATE_NOW = Date.now;
|
|
6
|
+
const FIXED_NOW = Date.parse("2026-02-21T00:00:00.000Z");
|
|
7
|
+
|
|
8
|
+
describe("formatRelativeResetTime", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
Date.now = () => FIXED_NOW;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
Date.now = REAL_DATE_NOW;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("formats future reset times as relative labels", () => {
|
|
18
|
+
const reset = new Date(FIXED_NOW + 90 * 60 * 1000).toISOString();
|
|
19
|
+
expect(formatRelativeResetTime(reset)).toBe("resets in 1h 30m");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns reset pending when reset time is in the past", () => {
|
|
23
|
+
const reset = new Date(FIXED_NOW - 60 * 1000).toISOString();
|
|
24
|
+
expect(formatRelativeResetTime(reset)).toBe("reset pending");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("formatGeminiQuotaOutput", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
Date.now = () => FIXED_NOW;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
Date.now = REAL_DATE_NOW;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders grouped progress bars, groups by version, and hides token type when all are REQUESTS", () => {
|
|
38
|
+
const buckets: RetrieveUserQuotaBucket[] = [
|
|
39
|
+
{
|
|
40
|
+
modelId: "gemini-2.5-pro_vertex",
|
|
41
|
+
tokenType: "requests",
|
|
42
|
+
remainingFraction: 0.5,
|
|
43
|
+
remainingAmount: "100",
|
|
44
|
+
resetTime: new Date(FIXED_NOW + 60 * 60 * 1000).toISOString(),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
modelId: "gemini-2.5-flash",
|
|
48
|
+
remainingAmount: "20",
|
|
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
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const output = formatGeminiQuotaOutput("test-project", buckets);
|
|
70
|
+
expect(output).toContain("Gemini quota usage for project `test-project`");
|
|
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)");
|
|
82
|
+
expect(output).toContain(
|
|
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"),
|
|
94
|
+
);
|
|
95
|
+
expect(output.indexOf("gemini-2.5-flash")).toBeLessThan(
|
|
96
|
+
output.indexOf("gemini-2.5-pro"),
|
|
97
|
+
);
|
|
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
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { accessTokenExpired, isOAuthAuth } from "./auth";
|
|
3
|
+
import { resolveCachedAuth } from "./cache";
|
|
4
|
+
import { ensureProjectContext, retrieveUserQuota } from "./project";
|
|
5
|
+
import type { RetrieveUserQuotaBucket } from "./project/types";
|
|
6
|
+
import { refreshAccessToken } from "./token";
|
|
7
|
+
import type { GetAuth, PluginClient } from "./types";
|
|
8
|
+
|
|
9
|
+
export const GEMINI_QUOTA_TOOL_NAME = "gemini_quota";
|
|
10
|
+
|
|
11
|
+
interface GeminiQuotaToolDependencies {
|
|
12
|
+
client: PluginClient;
|
|
13
|
+
getAuthResolver: () => GetAuth | undefined;
|
|
14
|
+
getConfiguredProjectId: () => string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createGeminiQuotaTool({
|
|
18
|
+
client,
|
|
19
|
+
getAuthResolver,
|
|
20
|
+
getConfiguredProjectId,
|
|
21
|
+
}: GeminiQuotaToolDependencies) {
|
|
22
|
+
return tool({
|
|
23
|
+
description:
|
|
24
|
+
"Retrieve current Gemini Code Assist quota usage for the authenticated user and project.",
|
|
25
|
+
args: {},
|
|
26
|
+
async execute() {
|
|
27
|
+
const getAuth = getAuthResolver();
|
|
28
|
+
if (!getAuth) {
|
|
29
|
+
return "Gemini quota is unavailable before Google auth is initialized. Authenticate with the Google provider and retry.";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const auth = await getAuth();
|
|
33
|
+
if (!isOAuthAuth(auth)) {
|
|
34
|
+
return "Gemini quota requires OAuth with Google. Run `opencode auth login` and choose `OAuth with Google (Gemini CLI)`.";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let authRecord = resolveCachedAuth(auth);
|
|
38
|
+
if (accessTokenExpired(authRecord)) {
|
|
39
|
+
const refreshed = await refreshAccessToken(authRecord, client);
|
|
40
|
+
if (!refreshed?.access) {
|
|
41
|
+
return "Gemini quota lookup failed because the access token could not be refreshed. Re-authenticate and retry.";
|
|
42
|
+
}
|
|
43
|
+
authRecord = refreshed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!authRecord.access) {
|
|
47
|
+
return "Gemini quota lookup failed because no access token is available. Re-authenticate and retry.";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const projectContext = await ensureProjectContext(
|
|
52
|
+
authRecord,
|
|
53
|
+
client,
|
|
54
|
+
getConfiguredProjectId(),
|
|
55
|
+
);
|
|
56
|
+
if (!projectContext.effectiveProjectId) {
|
|
57
|
+
return "Gemini quota lookup failed because no Google Cloud project could be resolved.";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const quota = await retrieveUserQuota(
|
|
61
|
+
authRecord.access,
|
|
62
|
+
projectContext.effectiveProjectId,
|
|
63
|
+
);
|
|
64
|
+
if (!quota?.buckets?.length) {
|
|
65
|
+
return `No Gemini quota buckets were returned for project \`${projectContext.effectiveProjectId}\`.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return formatGeminiQuotaOutput(
|
|
69
|
+
projectContext.effectiveProjectId,
|
|
70
|
+
quota.buckets,
|
|
71
|
+
);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
74
|
+
return `Gemini quota lookup failed: ${message}`;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatGeminiQuotaOutput(
|
|
81
|
+
projectId: string,
|
|
82
|
+
buckets: RetrieveUserQuotaBucket[],
|
|
83
|
+
): string {
|
|
84
|
+
const sortedBuckets = [...buckets].sort(compareQuotaBuckets);
|
|
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
|
+
];
|
|
104
|
+
|
|
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
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function compareQuotaBuckets(
|
|
130
|
+
left: RetrieveUserQuotaBucket,
|
|
131
|
+
right: RetrieveUserQuotaBucket,
|
|
132
|
+
): number {
|
|
133
|
+
const leftModel = left.modelId ?? "";
|
|
134
|
+
const rightModel = right.modelId ?? "";
|
|
135
|
+
if (leftModel !== rightModel) {
|
|
136
|
+
return leftModel.localeCompare(rightModel);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const leftTokenType = left.tokenType ?? "";
|
|
140
|
+
const rightTokenType = right.tokenType ?? "";
|
|
141
|
+
if (leftTokenType !== rightTokenType) {
|
|
142
|
+
return leftTokenType.localeCompare(rightTokenType);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (left.resetTime ?? "").localeCompare(right.resetTime ?? "");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function formatUsageRemaining(bucket: RetrieveUserQuotaBucket): string {
|
|
149
|
+
const remainingAmount = formatRemainingAmount(bucket.remainingAmount);
|
|
150
|
+
const remainingFraction = bucket.remainingFraction;
|
|
151
|
+
const hasFraction =
|
|
152
|
+
typeof remainingFraction === "number" && Number.isFinite(remainingFraction);
|
|
153
|
+
|
|
154
|
+
if (hasFraction) {
|
|
155
|
+
const clamped = clamp(remainingFraction, 0, 1);
|
|
156
|
+
const percent = (clamped * 100).toFixed(1);
|
|
157
|
+
const bar = buildProgressBar(clamped);
|
|
158
|
+
return remainingAmount
|
|
159
|
+
? `${bar} ${percent}% (${remainingAmount} left)`
|
|
160
|
+
: `${bar} ${percent}%`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (remainingAmount) {
|
|
164
|
+
return remainingAmount;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return "unknown";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatRemainingAmount(value: string | undefined): string | undefined {
|
|
171
|
+
if (!value) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
const parsed = Number.parseInt(value, 10);
|
|
175
|
+
if (!Number.isFinite(parsed)) {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
return parsed.toLocaleString("en-US");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatRelativeResetTime(resetTime: string | undefined): string | undefined {
|
|
182
|
+
if (!resetTime) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const resetAt = new Date(resetTime).getTime();
|
|
187
|
+
if (Number.isNaN(resetAt)) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const diffMs = resetAt - Date.now();
|
|
192
|
+
if (diffMs <= 0) {
|
|
193
|
+
return "reset pending";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const totalMinutes = Math.ceil(diffMs / (1000 * 60));
|
|
197
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
198
|
+
const minutes = totalMinutes % 60;
|
|
199
|
+
|
|
200
|
+
if (hours > 0 && minutes > 0) {
|
|
201
|
+
return `resets in ${hours}h ${minutes}m`;
|
|
202
|
+
}
|
|
203
|
+
if (hours > 0) {
|
|
204
|
+
return `resets in ${hours}h`;
|
|
205
|
+
}
|
|
206
|
+
return `resets in ${minutes}m`;
|
|
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
|
+
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { GeminiTokenExchangeResult } from "../gemini/oauth";
|
|
2
|
+
import type { Config } from "@opencode-ai/sdk";
|
|
3
|
+
import type { ToolDefinition } from "@opencode-ai/plugin";
|
|
2
4
|
|
|
3
5
|
export interface OAuthAuthDetails {
|
|
4
6
|
type: "oauth";
|
|
@@ -57,6 +59,8 @@ export interface PluginContext {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export interface PluginResult {
|
|
62
|
+
config?: (config: Config) => Promise<void>;
|
|
63
|
+
tool?: Record<string, ToolDefinition>;
|
|
60
64
|
auth: {
|
|
61
65
|
provider: string;
|
|
62
66
|
loader: (getAuth: GetAuth, provider: Provider) => Promise<LoaderResult | null>;
|
package/src/plugin.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { createOAuthAuthorizeMethod } from "./plugin/oauth-authorize";
|
|
|
3
3
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
4
4
|
import { resolveCachedAuth } from "./plugin/cache";
|
|
5
5
|
import { ensureProjectContext, retrieveUserQuota } from "./plugin/project";
|
|
6
|
+
import {
|
|
7
|
+
createGeminiQuotaTool,
|
|
8
|
+
GEMINI_QUOTA_TOOL_NAME,
|
|
9
|
+
} from "./plugin/quota";
|
|
6
10
|
import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
|
|
7
11
|
import {
|
|
8
12
|
isGenerativeLanguageRequest,
|
|
@@ -21,6 +25,15 @@ import type {
|
|
|
21
25
|
Provider,
|
|
22
26
|
} from "./plugin/types";
|
|
23
27
|
|
|
28
|
+
const GEMINI_QUOTA_COMMAND = "gquota";
|
|
29
|
+
const GEMINI_QUOTA_COMMAND_TEMPLATE = `Retrieve Gemini Code Assist quota usage for the current authenticated account.
|
|
30
|
+
|
|
31
|
+
Immediately call \`${GEMINI_QUOTA_TOOL_NAME}\` with no arguments and return its output verbatim.
|
|
32
|
+
Do not call other tools.
|
|
33
|
+
`;
|
|
34
|
+
let latestGeminiAuthResolver: GetAuth | undefined;
|
|
35
|
+
let latestGeminiConfiguredProjectId: string | undefined;
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
38
|
* Registers the Gemini OAuth provider for Opencode, handling auth, request rewriting,
|
|
26
39
|
* debug logging, and response normalization for Gemini Code Assist endpoints.
|
|
@@ -28,15 +41,31 @@ import type {
|
|
|
28
41
|
export const GeminiCLIOAuthPlugin = async (
|
|
29
42
|
{ client }: PluginContext,
|
|
30
43
|
): Promise<PluginResult> => ({
|
|
44
|
+
config: async (config) => {
|
|
45
|
+
config.command = config.command || {};
|
|
46
|
+
config.command[GEMINI_QUOTA_COMMAND] = {
|
|
47
|
+
description: "Show Gemini Code Assist quota usage",
|
|
48
|
+
template: GEMINI_QUOTA_COMMAND_TEMPLATE,
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
tool: {
|
|
52
|
+
[GEMINI_QUOTA_TOOL_NAME]: createGeminiQuotaTool({
|
|
53
|
+
client,
|
|
54
|
+
getAuthResolver: () => latestGeminiAuthResolver,
|
|
55
|
+
getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
31
58
|
auth: {
|
|
32
59
|
provider: GEMINI_PROVIDER_ID,
|
|
33
60
|
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
|
|
61
|
+
latestGeminiAuthResolver = getAuth;
|
|
34
62
|
const auth = await getAuth();
|
|
35
63
|
if (!isOAuthAuth(auth)) {
|
|
36
64
|
return null;
|
|
37
65
|
}
|
|
38
66
|
|
|
39
67
|
const configuredProjectId = resolveConfiguredProjectId(provider);
|
|
68
|
+
latestGeminiConfiguredProjectId = configuredProjectId;
|
|
40
69
|
normalizeProviderModelCosts(provider);
|
|
41
70
|
|
|
42
71
|
return {
|