opencode-gemini-auth 1.3.10 → 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.
@@ -0,0 +1,187 @@
1
+ import { GEMINI_PROVIDER_ID } from "../../constants";
2
+ import { formatRefreshParts, parseRefreshParts } from "../auth";
3
+ import type { OAuthAuthDetails, PluginClient, ProjectContextResult } from "../types";
4
+ import { loadManagedProject, onboardManagedProject } from "./api";
5
+ import { FREE_TIER_ID, LEGACY_TIER_ID, ProjectIdRequiredError } from "./types";
6
+ import { buildIneligibleTierMessage, getCacheKey, normalizeProjectId, pickOnboardTier } from "./utils";
7
+
8
+ const projectContextResultCache = new Map<string, ProjectContextResult>();
9
+ const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
10
+
11
+ /**
12
+ * Clears cached project context results and pending promises.
13
+ */
14
+ export function invalidateProjectContextCache(refresh?: string): void {
15
+ if (!refresh) {
16
+ projectContextPendingCache.clear();
17
+ projectContextResultCache.clear();
18
+ return;
19
+ }
20
+
21
+ projectContextPendingCache.delete(refresh);
22
+ projectContextResultCache.delete(refresh);
23
+
24
+ const prefix = `${refresh}|cfg:`;
25
+ for (const key of projectContextPendingCache.keys()) {
26
+ if (key.startsWith(prefix)) {
27
+ projectContextPendingCache.delete(key);
28
+ }
29
+ }
30
+ for (const key of projectContextResultCache.keys()) {
31
+ if (key.startsWith(prefix)) {
32
+ projectContextResultCache.delete(key);
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Resolves a project context for an access token, optionally persisting updated auth.
39
+ */
40
+ export async function resolveProjectContextFromAccessToken(
41
+ auth: OAuthAuthDetails,
42
+ accessToken: string,
43
+ configuredProjectId?: string,
44
+ persistAuth?: (auth: OAuthAuthDetails) => Promise<void>,
45
+ ): Promise<ProjectContextResult> {
46
+ const parts = parseRefreshParts(auth.refresh);
47
+ const projectId = configuredProjectId?.trim() || parts.projectId;
48
+
49
+ if (projectId || parts.managedProjectId) {
50
+ return {
51
+ auth,
52
+ effectiveProjectId: projectId || parts.managedProjectId || "",
53
+ };
54
+ }
55
+
56
+ const loadPayload = await loadManagedProject(accessToken, projectId);
57
+ if (!loadPayload) {
58
+ throw new ProjectIdRequiredError();
59
+ }
60
+
61
+ const managedProjectId = normalizeProjectId(loadPayload.cloudaicompanionProject);
62
+ if (managedProjectId) {
63
+ const updatedAuth = withProjectAuth(auth, parts.refreshToken, projectId, managedProjectId);
64
+ if (persistAuth) {
65
+ await persistAuth(updatedAuth);
66
+ }
67
+ return { auth: updatedAuth, effectiveProjectId: managedProjectId };
68
+ }
69
+
70
+ const currentTierId = loadPayload.currentTier?.id;
71
+ if (currentTierId) {
72
+ if (projectId) {
73
+ return { auth, effectiveProjectId: projectId };
74
+ }
75
+ const ineligibleMessage = buildIneligibleTierMessage(loadPayload.ineligibleTiers);
76
+ if (ineligibleMessage) {
77
+ throw new Error(ineligibleMessage);
78
+ }
79
+ throw new ProjectIdRequiredError();
80
+ }
81
+
82
+ const tier = pickOnboardTier(loadPayload.allowedTiers);
83
+ const tierId = tier.id ?? LEGACY_TIER_ID;
84
+ if (tierId !== FREE_TIER_ID && !projectId) {
85
+ throw new ProjectIdRequiredError();
86
+ }
87
+
88
+ const onboardedProjectId = await onboardManagedProject(accessToken, tierId, projectId);
89
+ if (onboardedProjectId) {
90
+ const updatedAuth = withProjectAuth(auth, parts.refreshToken, projectId, onboardedProjectId);
91
+ if (persistAuth) {
92
+ await persistAuth(updatedAuth);
93
+ }
94
+ return { auth: updatedAuth, effectiveProjectId: onboardedProjectId };
95
+ }
96
+
97
+ if (projectId) {
98
+ return { auth, effectiveProjectId: projectId };
99
+ }
100
+ throw new ProjectIdRequiredError();
101
+ }
102
+
103
+ /**
104
+ * Resolves an effective project ID for the current auth state, caching results per refresh token.
105
+ */
106
+ export async function ensureProjectContext(
107
+ auth: OAuthAuthDetails,
108
+ client: PluginClient,
109
+ configuredProjectId?: string,
110
+ ): Promise<ProjectContextResult> {
111
+ const accessToken = auth.access;
112
+ if (!accessToken) {
113
+ return { auth, effectiveProjectId: "" };
114
+ }
115
+
116
+ const cacheKey = buildProjectCacheKey(auth, configuredProjectId);
117
+ if (cacheKey) {
118
+ const cached = projectContextResultCache.get(cacheKey);
119
+ if (cached) {
120
+ return cached;
121
+ }
122
+ const pending = projectContextPendingCache.get(cacheKey);
123
+ if (pending) {
124
+ return pending;
125
+ }
126
+ }
127
+
128
+ const resolveContext = async (): Promise<ProjectContextResult> =>
129
+ resolveProjectContextFromAccessToken(
130
+ auth,
131
+ accessToken,
132
+ configuredProjectId,
133
+ async (updatedAuth) => {
134
+ await client.auth.set({
135
+ path: { id: GEMINI_PROVIDER_ID },
136
+ body: updatedAuth,
137
+ });
138
+ },
139
+ );
140
+
141
+ if (!cacheKey) {
142
+ return resolveContext();
143
+ }
144
+
145
+ const promise = resolveContext()
146
+ .then((result) => {
147
+ const nextKey = getCacheKey(result.auth) ?? cacheKey;
148
+ projectContextPendingCache.delete(cacheKey);
149
+ projectContextResultCache.set(nextKey, result);
150
+ if (nextKey !== cacheKey) {
151
+ projectContextResultCache.delete(cacheKey);
152
+ }
153
+ return result;
154
+ })
155
+ .catch((error) => {
156
+ projectContextPendingCache.delete(cacheKey);
157
+ throw error;
158
+ });
159
+
160
+ projectContextPendingCache.set(cacheKey, promise);
161
+ return promise;
162
+ }
163
+
164
+ function withProjectAuth(
165
+ auth: OAuthAuthDetails,
166
+ refreshToken: string,
167
+ projectId: string | undefined,
168
+ managedProjectId: string,
169
+ ): OAuthAuthDetails {
170
+ return {
171
+ ...auth,
172
+ refresh: formatRefreshParts({
173
+ refreshToken,
174
+ projectId,
175
+ managedProjectId,
176
+ }),
177
+ };
178
+ }
179
+
180
+ function buildProjectCacheKey(auth: OAuthAuthDetails, configuredProjectId?: string): string | undefined {
181
+ const base = getCacheKey(auth);
182
+ if (!base) {
183
+ return undefined;
184
+ }
185
+ const project = configuredProjectId?.trim() ?? "";
186
+ return project ? `${base}|cfg:${project}` : base;
187
+ }
@@ -0,0 +1,6 @@
1
+ export { loadManagedProject, onboardManagedProject, retrieveUserQuota } from "./api";
2
+ export {
3
+ ensureProjectContext,
4
+ invalidateProjectContextCache,
5
+ resolveProjectContextFromAccessToken,
6
+ } from "./context";
@@ -0,0 +1,67 @@
1
+ export const FREE_TIER_ID = "free-tier";
2
+ export const LEGACY_TIER_ID = "legacy-tier";
3
+
4
+ export const CODE_ASSIST_METADATA = {
5
+ ideType: "IDE_UNSPECIFIED",
6
+ platform: "PLATFORM_UNSPECIFIED",
7
+ pluginType: "GEMINI",
8
+ } as const;
9
+
10
+ export interface GeminiUserTier {
11
+ id?: string;
12
+ isDefault?: boolean;
13
+ userDefinedCloudaicompanionProject?: boolean;
14
+ name?: string;
15
+ description?: string;
16
+ }
17
+
18
+ export interface CloudAiCompanionProject {
19
+ id?: string;
20
+ }
21
+
22
+ export interface GeminiIneligibleTier {
23
+ reasonMessage?: string;
24
+ }
25
+
26
+ export interface LoadCodeAssistPayload {
27
+ cloudaicompanionProject?: string | CloudAiCompanionProject;
28
+ currentTier?: {
29
+ id?: string;
30
+ name?: string;
31
+ };
32
+ allowedTiers?: GeminiUserTier[];
33
+ ineligibleTiers?: GeminiIneligibleTier[];
34
+ }
35
+
36
+ export interface OnboardUserPayload {
37
+ name?: string;
38
+ done?: boolean;
39
+ response?: {
40
+ cloudaicompanionProject?: {
41
+ id?: string;
42
+ };
43
+ };
44
+ }
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
+
58
+ /**
59
+ * Error raised when a required Google Cloud project is missing during Gemini onboarding.
60
+ */
61
+ export class ProjectIdRequiredError extends Error {
62
+ constructor() {
63
+ super(
64
+ "Google Gemini requires a Google Cloud project. Enable the Gemini for Google Cloud API on a project you control, then set `provider.google.options.projectId` in your Opencode config (or set OPENCODE_GEMINI_PROJECT_ID / GOOGLE_CLOUD_PROJECT).",
65
+ );
66
+ }
67
+ }
@@ -0,0 +1,120 @@
1
+ import type { OAuthAuthDetails } from "../types";
2
+ import {
3
+ CODE_ASSIST_METADATA,
4
+ LEGACY_TIER_ID,
5
+ type CloudAiCompanionProject,
6
+ type GeminiIneligibleTier,
7
+ type GeminiUserTier,
8
+ } from "./types";
9
+
10
+ /**
11
+ * Builds metadata headers required by the Code Assist API.
12
+ */
13
+ export function buildMetadata(projectId?: string, includeDuetProject = true): Record<string, string> {
14
+ const metadata: Record<string, string> = {
15
+ ideType: CODE_ASSIST_METADATA.ideType,
16
+ platform: CODE_ASSIST_METADATA.platform,
17
+ pluginType: CODE_ASSIST_METADATA.pluginType,
18
+ };
19
+ if (projectId && includeDuetProject) {
20
+ metadata.duetProject = projectId;
21
+ }
22
+ return metadata;
23
+ }
24
+
25
+ /**
26
+ * Normalizes project identifiers from API payloads or config.
27
+ */
28
+ export function normalizeProjectId(value?: string | CloudAiCompanionProject): string | undefined {
29
+ if (!value) {
30
+ return undefined;
31
+ }
32
+ if (typeof value === "string") {
33
+ const trimmed = value.trim();
34
+ return trimmed ? trimmed : undefined;
35
+ }
36
+ if (typeof value === "object" && typeof value.id === "string") {
37
+ const trimmed = value.id.trim();
38
+ return trimmed ? trimmed : undefined;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ /**
44
+ * Selects the default tier ID from the allowed tiers list.
45
+ */
46
+ export function pickOnboardTier(allowedTiers?: GeminiUserTier[]): GeminiUserTier {
47
+ if (allowedTiers && allowedTiers.length > 0) {
48
+ for (const tier of allowedTiers) {
49
+ if (tier?.isDefault) {
50
+ return tier;
51
+ }
52
+ }
53
+ return allowedTiers[0] ?? { id: LEGACY_TIER_ID, userDefinedCloudaicompanionProject: true };
54
+ }
55
+ return { id: LEGACY_TIER_ID, userDefinedCloudaicompanionProject: true };
56
+ }
57
+
58
+ /**
59
+ * Builds a concise error message from ineligible tier payloads.
60
+ */
61
+ export function buildIneligibleTierMessage(tiers?: GeminiIneligibleTier[]): string | undefined {
62
+ if (!tiers || tiers.length === 0) {
63
+ return undefined;
64
+ }
65
+ const reasons = tiers
66
+ .map((tier) => tier?.reasonMessage?.trim())
67
+ .filter((message): message is string => !!message);
68
+ return reasons.length > 0 ? reasons.join(", ") : undefined;
69
+ }
70
+
71
+ /**
72
+ * Detects VPC-SC errors from Cloud Code responses.
73
+ */
74
+ export function isVpcScError(payload: unknown): boolean {
75
+ if (!payload || typeof payload !== "object") {
76
+ return false;
77
+ }
78
+ const error = (payload as { error?: unknown }).error;
79
+ if (!error || typeof error !== "object") {
80
+ return false;
81
+ }
82
+ const details = (error as { details?: unknown }).details;
83
+ if (!Array.isArray(details)) {
84
+ return false;
85
+ }
86
+ return details.some((detail) => {
87
+ if (!detail || typeof detail !== "object") {
88
+ return false;
89
+ }
90
+ return (detail as { reason?: unknown }).reason === "SECURITY_POLICY_VIOLATED";
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Safely parses JSON, returning null on failure.
96
+ */
97
+ export function parseJsonSafe(text: string): unknown {
98
+ try {
99
+ return JSON.parse(text);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Promise-based delay utility.
107
+ */
108
+ export function wait(ms: number): Promise<void> {
109
+ return new Promise((resolve) => {
110
+ setTimeout(resolve, ms);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Generates a cache key for project context based on refresh token.
116
+ */
117
+ export function getCacheKey(auth: OAuthAuthDetails): string | undefined {
118
+ const refresh = auth.refresh?.trim();
119
+ return refresh ? refresh : undefined;
120
+ }
@@ -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
+ }