opencode-gemini-auth 1.3.11 → 1.4.0
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 +62 -0
- package/src/plugin/quota.ts +182 -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.0",
|
|
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,62 @@
|
|
|
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 sorted, model-specific usage lines", () => {
|
|
38
|
+
const buckets: RetrieveUserQuotaBucket[] = [
|
|
39
|
+
{
|
|
40
|
+
modelId: "gemini-2.5-pro",
|
|
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
|
+
|
|
52
|
+
const output = formatGeminiQuotaOutput("test-project", buckets);
|
|
53
|
+
expect(output).toContain("Gemini quota usage for project `test-project`");
|
|
54
|
+
expect(output).toContain("- gemini-2.5-flash: 20 remaining");
|
|
55
|
+
expect(output).toContain(
|
|
56
|
+
"- gemini-2.5-pro (requests): 50.0% remaining (100 left), resets in 1h",
|
|
57
|
+
);
|
|
58
|
+
expect(output.indexOf("gemini-2.5-flash")).toBeLessThan(
|
|
59
|
+
output.indexOf("gemini-2.5-pro"),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
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 lines = [`Gemini quota usage for project \`${projectId}\``, ""];
|
|
86
|
+
|
|
87
|
+
for (const bucket of sortedBuckets) {
|
|
88
|
+
lines.push(formatQuotaBucketLine(bucket));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function compareQuotaBuckets(
|
|
95
|
+
left: RetrieveUserQuotaBucket,
|
|
96
|
+
right: RetrieveUserQuotaBucket,
|
|
97
|
+
): number {
|
|
98
|
+
const leftModel = left.modelId ?? "";
|
|
99
|
+
const rightModel = right.modelId ?? "";
|
|
100
|
+
if (leftModel !== rightModel) {
|
|
101
|
+
return leftModel.localeCompare(rightModel);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const leftTokenType = left.tokenType ?? "";
|
|
105
|
+
const rightTokenType = right.tokenType ?? "";
|
|
106
|
+
if (leftTokenType !== rightTokenType) {
|
|
107
|
+
return leftTokenType.localeCompare(rightTokenType);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (left.resetTime ?? "").localeCompare(right.resetTime ?? "");
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
+
function formatUsageRemaining(bucket: RetrieveUserQuotaBucket): string {
|
|
126
|
+
const remainingAmount = formatRemainingAmount(bucket.remainingAmount);
|
|
127
|
+
const remainingFraction = bucket.remainingFraction;
|
|
128
|
+
const hasFraction =
|
|
129
|
+
typeof remainingFraction === "number" && Number.isFinite(remainingFraction);
|
|
130
|
+
|
|
131
|
+
if (hasFraction) {
|
|
132
|
+
const percent = Math.max(0, remainingFraction * 100).toFixed(1);
|
|
133
|
+
return remainingAmount
|
|
134
|
+
? `${percent}% remaining (${remainingAmount} left)`
|
|
135
|
+
: `${percent}% remaining`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (remainingAmount) {
|
|
139
|
+
return `${remainingAmount} remaining`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return "remaining unknown";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatRemainingAmount(value: string | undefined): string | undefined {
|
|
146
|
+
if (!value) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const parsed = Number.parseInt(value, 10);
|
|
150
|
+
if (!Number.isFinite(parsed)) {
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
return parsed.toLocaleString("en-US");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatRelativeResetTime(resetTime: string | undefined): string | undefined {
|
|
157
|
+
if (!resetTime) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resetAt = new Date(resetTime).getTime();
|
|
162
|
+
if (Number.isNaN(resetAt)) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const diffMs = resetAt - Date.now();
|
|
167
|
+
if (diffMs <= 0) {
|
|
168
|
+
return "reset pending";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const totalMinutes = Math.ceil(diffMs / (1000 * 60));
|
|
172
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
173
|
+
const minutes = totalMinutes % 60;
|
|
174
|
+
|
|
175
|
+
if (hours > 0 && minutes > 0) {
|
|
176
|
+
return `resets in ${hours}h ${minutes}m`;
|
|
177
|
+
}
|
|
178
|
+
if (hours > 0) {
|
|
179
|
+
return `resets in ${hours}h`;
|
|
180
|
+
}
|
|
181
|
+
return `resets in ${minutes}m`;
|
|
182
|
+
}
|
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 {
|