opencode-gemini-auth 1.4.7 → 1.4.8

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.7",
4
+ "version": "1.4.8",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -12,7 +12,7 @@ import type { OAuthAuthDetails } from "./types";
12
12
  * Builds the OAuth authorize callback used by plugin auth methods.
13
13
  */
14
14
  export function createOAuthAuthorizeMethod(options?: {
15
- getConfiguredProjectId?: () => string | undefined;
15
+ getConfiguredProjectId?: () => Promise<string | undefined> | string | undefined;
16
16
  }): () => Promise<{
17
17
  url: string;
18
18
  instructions: string;
@@ -28,7 +28,7 @@ export function createOAuthAuthorizeMethod(options?: {
28
28
  }
29
29
 
30
30
  const configuredProjectId = resolveConfiguredProjectId({
31
- configProjectId: options?.getConfiguredProjectId?.(),
31
+ configProjectId: await options?.getConfiguredProjectId?.(),
32
32
  });
33
33
 
34
34
  try {
@@ -97,19 +97,38 @@ export function createOAuthAuthorizeMethod(options?: {
97
97
  method: "auto",
98
98
  callback: async (): Promise<GeminiTokenExchangeResult> => {
99
99
  try {
100
- const callbackUrl = await listener.waitForCallback();
101
- const code = callbackUrl.searchParams.get("code");
102
- const state = callbackUrl.searchParams.get("state");
103
-
104
- if (!code || !state) {
105
- return { type: "failed", error: "Missing code or state in callback URL" };
106
- }
107
- if (state !== authorization.state) {
108
- return { type: "failed", error: "State mismatch in callback URL (possible CSRF attempt)" };
100
+ while (true) {
101
+ const callbackUrl = await listener.waitForCallback();
102
+ const callbackError = callbackUrl.searchParams.get("error");
103
+ const callbackErrorDescription = callbackUrl.searchParams.get("error_description");
104
+ if (callbackError) {
105
+ return {
106
+ type: "failed",
107
+ error: callbackErrorDescription || callbackError,
108
+ };
109
+ }
110
+
111
+ const code = callbackUrl.searchParams.get("code");
112
+ const state = callbackUrl.searchParams.get("state");
113
+ if (!code || !state) {
114
+ continue;
115
+ }
116
+ if (state !== authorization.state) {
117
+ if (isGeminiDebugEnabled()) {
118
+ logGeminiDebugMessage("Ignoring OAuth callback with mismatched state");
119
+ }
120
+ continue;
121
+ }
122
+
123
+ const exchangeResult = await exchangeGeminiWithVerifier(code, authorization.verifier);
124
+ if (shouldIgnoreMalformedAuthCode(exchangeResult)) {
125
+ if (isGeminiDebugEnabled()) {
126
+ logGeminiDebugMessage("Ignoring malformed OAuth callback code and waiting for the next redirect");
127
+ }
128
+ continue;
129
+ }
130
+ return await maybeHydrateProjectId(exchangeResult);
109
131
  }
110
- return await maybeHydrateProjectId(
111
- await exchangeGeminiWithVerifier(code, authorization.verifier),
112
- );
113
132
  } catch (error) {
114
133
  return {
115
134
  type: "failed",
@@ -196,3 +215,11 @@ function openBrowserUrl(url: string): void {
196
215
  child.unref?.();
197
216
  } catch {}
198
217
  }
218
+
219
+ function shouldIgnoreMalformedAuthCode(result: GeminiTokenExchangeResult): boolean {
220
+ if (result.type !== "failed") {
221
+ return false;
222
+ }
223
+
224
+ return /invalid_grant/i.test(result.error) && /malformed auth code/i.test(result.error);
225
+ }
@@ -3,7 +3,13 @@ import { formatRefreshParts, parseRefreshParts } from "../auth";
3
3
  import type { OAuthAuthDetails, PluginClient, ProjectContextResult } from "../types";
4
4
  import { loadManagedProject, onboardManagedProject } from "./api";
5
5
  import { FREE_TIER_ID, LEGACY_TIER_ID, ProjectIdRequiredError } from "./types";
6
- import { buildIneligibleTierMessage, getCacheKey, normalizeProjectId, pickOnboardTier } from "./utils";
6
+ import {
7
+ buildIneligibleTierMessage,
8
+ getCacheKey,
9
+ normalizeProjectId,
10
+ pickOnboardTier,
11
+ throwIfValidationRequired,
12
+ } from "./utils";
7
13
 
8
14
  const projectContextResultCache = new Map<string, ProjectContextResult>();
9
15
  const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
@@ -44,9 +50,10 @@ export async function resolveProjectContextFromAccessToken(
44
50
  persistAuth?: (auth: OAuthAuthDetails) => Promise<void>,
45
51
  ): Promise<ProjectContextResult> {
46
52
  const parts = parseRefreshParts(auth.refresh);
47
- const projectId = configuredProjectId?.trim() || parts.projectId;
53
+ const configuredProject = configuredProjectId?.trim();
54
+ const projectId = configuredProject || parts.projectId;
48
55
 
49
- if (projectId || parts.managedProjectId) {
56
+ if (!configuredProject && (projectId || parts.managedProjectId)) {
50
57
  return {
51
58
  auth,
52
59
  effectiveProjectId: projectId || parts.managedProjectId || "",
@@ -68,6 +75,10 @@ export async function resolveProjectContextFromAccessToken(
68
75
  }
69
76
 
70
77
  const currentTierId = loadPayload.currentTier?.id;
78
+ if (!currentTierId) {
79
+ throwIfValidationRequired(loadPayload.ineligibleTiers);
80
+ }
81
+
71
82
  if (currentTierId) {
72
83
  if (projectId) {
73
84
  return { auth, effectiveProjectId: projectId };
@@ -20,7 +20,10 @@ export interface CloudAiCompanionProject {
20
20
  }
21
21
 
22
22
  export interface GeminiIneligibleTier {
23
+ reasonCode?: string;
23
24
  reasonMessage?: string;
25
+ validationUrl?: string;
26
+ validationLearnMoreUrl?: string;
24
27
  }
25
28
 
26
29
  export interface LoadCodeAssistPayload {
@@ -65,3 +68,27 @@ export class ProjectIdRequiredError extends Error {
65
68
  );
66
69
  }
67
70
  }
71
+
72
+ export class AccountValidationRequiredError extends Error {
73
+ validationUrl?: string;
74
+ validationLearnMoreUrl?: string;
75
+
76
+ constructor(
77
+ message: string,
78
+ validationUrl?: string,
79
+ validationLearnMoreUrl?: string,
80
+ ) {
81
+ const parts = [message.trim()];
82
+ if (validationUrl) {
83
+ parts.push(`Complete validation: ${validationUrl}`);
84
+ }
85
+ if (validationLearnMoreUrl) {
86
+ parts.push(`Learn more: ${validationLearnMoreUrl}`);
87
+ }
88
+
89
+ super(parts.join("\n"));
90
+ this.name = "AccountValidationRequiredError";
91
+ this.validationUrl = validationUrl;
92
+ this.validationLearnMoreUrl = validationLearnMoreUrl;
93
+ }
94
+ }
@@ -1,5 +1,6 @@
1
1
  import type { OAuthAuthDetails } from "../types";
2
2
  import {
3
+ AccountValidationRequiredError,
3
4
  CODE_ASSIST_METADATA,
4
5
  LEGACY_TIER_ID,
5
6
  type CloudAiCompanionProject,
@@ -68,6 +69,26 @@ export function buildIneligibleTierMessage(tiers?: GeminiIneligibleTier[]): stri
68
69
  return reasons.length > 0 ? reasons.join(", ") : undefined;
69
70
  }
70
71
 
72
+ export function throwIfValidationRequired(tiers?: GeminiIneligibleTier[]): void {
73
+ if (!tiers || tiers.length === 0) {
74
+ return;
75
+ }
76
+
77
+ const validationTier = tiers.find((tier) => {
78
+ const reasonCode = tier?.reasonCode?.trim().toUpperCase();
79
+ return reasonCode === "VALIDATION_REQUIRED" && !!tier.validationUrl?.trim();
80
+ });
81
+ if (!validationTier) {
82
+ return;
83
+ }
84
+
85
+ throw new AccountValidationRequiredError(
86
+ validationTier.reasonMessage?.trim() || "Verify your account to continue.",
87
+ validationTier.validationUrl?.trim(),
88
+ validationTier.validationLearnMoreUrl?.trim(),
89
+ );
90
+ }
91
+
71
92
  /**
72
93
  * Detects VPC-SC errors from Cloud Code responses.
73
94
  */
@@ -111,6 +111,78 @@ describe("resolveProjectContextFromAccessToken", () => {
111
111
  ).rejects.toThrow("Google Gemini requires a Google Cloud project");
112
112
  });
113
113
 
114
+ it("continues with an allowed paid tier even when free tier is ineligible", async () => {
115
+ let onboardBody: Record<string, unknown> | undefined;
116
+ const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
117
+ const url = toUrlString(input);
118
+ if (url.includes(":loadCodeAssist")) {
119
+ return new Response(
120
+ JSON.stringify({
121
+ allowedTiers: [{ id: "standard-tier", isDefault: true }],
122
+ ineligibleTiers: [
123
+ {
124
+ reasonCode: "INELIGIBLE_ACCOUNT",
125
+ reasonMessage: "Not eligible for free tier",
126
+ tierId: "free-tier",
127
+ },
128
+ ],
129
+ }),
130
+ { status: 200 },
131
+ );
132
+ }
133
+ if (url.includes(":onboardUser")) {
134
+ const rawBody = typeof init?.body === "string" ? init.body : "{}";
135
+ onboardBody = JSON.parse(rawBody) as Record<string, unknown>;
136
+ return new Response(
137
+ JSON.stringify({
138
+ done: true,
139
+ response: { cloudaicompanionProject: { id: "configured-project" } },
140
+ }),
141
+ { status: 200 },
142
+ );
143
+ }
144
+ throw new Error(`Unexpected fetch to ${url}`);
145
+ });
146
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
147
+
148
+ const result = await resolveProjectContextFromAccessToken(
149
+ baseAuth,
150
+ baseAuth.access ?? "",
151
+ "configured-project",
152
+ );
153
+
154
+ expect(result.effectiveProjectId).toBe("configured-project");
155
+ expect(onboardBody?.cloudaicompanionProject).toBe("configured-project");
156
+ });
157
+
158
+ it("throws validation-required errors before tier onboarding even when allowed tiers exist", async () => {
159
+ const fetchMock = mock(async (input: RequestInfo) => {
160
+ const url = toUrlString(input);
161
+ if (url.includes(":loadCodeAssist")) {
162
+ return new Response(
163
+ JSON.stringify({
164
+ allowedTiers: [{ id: "standard-tier", isDefault: true }],
165
+ ineligibleTiers: [
166
+ {
167
+ reasonCode: "VALIDATION_REQUIRED",
168
+ reasonMessage: "Verify your account to continue.",
169
+ validationUrl: "https://example.com/verify",
170
+ validationLearnMoreUrl: "https://example.com/help",
171
+ },
172
+ ],
173
+ }),
174
+ { status: 200 },
175
+ );
176
+ }
177
+ throw new Error(`Unexpected fetch to ${url}`);
178
+ });
179
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
180
+
181
+ await expect(
182
+ resolveProjectContextFromAccessToken(baseAuth, baseAuth.access ?? "", "configured-project"),
183
+ ).rejects.toThrow("Complete validation: https://example.com/verify");
184
+ });
185
+
114
186
  it("prefers a configured project id over a persisted managed project id", async () => {
115
187
  const authWithManagedProject: OAuthAuthDetails = {
116
188
  ...baseAuth,
@@ -120,8 +192,20 @@ describe("resolveProjectContextFromAccessToken", () => {
120
192
  }),
121
193
  };
122
194
 
123
- const fetchMock = mock(async () => {
124
- throw new Error("should not fetch project context when a configured project id exists");
195
+ let loadBody: Record<string, unknown> | undefined;
196
+ const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
197
+ const url = toUrlString(input);
198
+ if (url.includes(":loadCodeAssist")) {
199
+ const rawBody = typeof init?.body === "string" ? init.body : "{}";
200
+ loadBody = JSON.parse(rawBody) as Record<string, unknown>;
201
+ return new Response(
202
+ JSON.stringify({
203
+ currentTier: { id: "standard-tier" },
204
+ }),
205
+ { status: 200 },
206
+ );
207
+ }
208
+ throw new Error(`Unexpected fetch to ${url}`);
125
209
  });
126
210
  (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
127
211
 
@@ -132,6 +216,6 @@ describe("resolveProjectContextFromAccessToken", () => {
132
216
  );
133
217
 
134
218
  expect(result.effectiveProjectId).toBe("configured-project");
135
- expect(fetchMock.mock.calls.length).toBe(0);
219
+ expect(loadBody?.cloudaicompanionProject).toBe("configured-project");
136
220
  });
137
221
  });
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { Config } from "@opencode-ai/sdk";
3
3
 
4
- import { resolveConfiguredProjectId, resolveConfiguredProjectIdFromConfig } from "./provider";
5
- import type { Provider } from "./types";
4
+ import {
5
+ resolveConfiguredProjectId,
6
+ resolveConfiguredProjectIdFromClient,
7
+ resolveConfiguredProjectIdFromConfig,
8
+ } from "./provider";
9
+ import type { PluginClient, Provider } from "./types";
6
10
 
7
11
  describe("resolveConfiguredProjectId", () => {
8
12
  it("reads project id from provider options", () => {
@@ -56,4 +60,24 @@ describe("resolveConfiguredProjectId", () => {
56
60
  }),
57
61
  ).toBe("opencode-project");
58
62
  });
63
+
64
+ it("reads the current project id from the Opencode config API when available", async () => {
65
+ const client = {
66
+ config: {
67
+ get: async () => ({
68
+ data: {
69
+ provider: {
70
+ google: {
71
+ options: {
72
+ projectId: "live-config-project",
73
+ },
74
+ },
75
+ },
76
+ } satisfies Config,
77
+ }),
78
+ },
79
+ } satisfies PluginClient;
80
+
81
+ await expect(resolveConfiguredProjectIdFromClient(client)).resolves.toBe("live-config-project");
82
+ });
59
83
  });
@@ -1,7 +1,7 @@
1
1
  import type { Config } from "@opencode-ai/sdk";
2
2
 
3
3
  import { GEMINI_PROVIDER_ID } from "../constants";
4
- import type { Provider } from "./types";
4
+ import type { PluginClient, Provider } from "./types";
5
5
 
6
6
  interface ResolveConfiguredProjectIdInput {
7
7
  provider?: Provider | null;
@@ -45,6 +45,21 @@ export function resolveConfiguredProjectIdFromConfig(
45
45
  return normalizeProjectId(providerConfig?.options?.projectId);
46
46
  }
47
47
 
48
+ export async function resolveConfiguredProjectIdFromClient(
49
+ client: PluginClient | null | undefined,
50
+ ): Promise<string | undefined> {
51
+ if (!client?.config?.get) {
52
+ return undefined;
53
+ }
54
+
55
+ try {
56
+ const result = await client.config.get();
57
+ return resolveConfiguredProjectIdFromConfig(result?.data);
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
48
63
  function normalizeProjectId(value: unknown): string | undefined {
49
64
  if (typeof value !== "string") {
50
65
  return undefined;
@@ -37,23 +37,12 @@ export async function startOAuthListener(
37
37
  : 80;
38
38
  const origin = `${redirectUri.protocol}//${redirectUri.host}`;
39
39
 
40
- let settled = false;
41
- let resolveCallback: (url: URL) => void;
42
- let rejectCallback: (error: Error) => void;
43
- const callbackPromise = new Promise<URL>((resolve, reject) => {
44
- resolveCallback = (url: URL) => {
45
- if (settled) return;
46
- settled = true;
47
- if (timeoutHandle) clearTimeout(timeoutHandle);
48
- resolve(url);
49
- };
50
- rejectCallback = (error: Error) => {
51
- if (settled) return;
52
- settled = true;
53
- if (timeoutHandle) clearTimeout(timeoutHandle);
54
- reject(error);
55
- };
56
- });
40
+ const callbackQueue: URL[] = [];
41
+ const callbackWaiters: Array<{
42
+ resolve: (url: URL) => void;
43
+ reject: (error: Error) => void;
44
+ }> = [];
45
+ let terminalError: Error | undefined;
57
46
 
58
47
  const successResponse = `<!DOCTYPE html>
59
48
  <html lang="en">
@@ -182,8 +171,27 @@ const successResponse = `<!DOCTYPE html>
182
171
  </body>
183
172
  </html>`;
184
173
 
174
+ const deliverCallback = (url: URL) => {
175
+ const waiter = callbackWaiters.shift();
176
+ if (waiter) {
177
+ waiter.resolve(url);
178
+ return;
179
+ }
180
+ callbackQueue.push(url);
181
+ };
182
+
183
+ const failPendingWaiters = (error: Error) => {
184
+ if (terminalError) {
185
+ return;
186
+ }
187
+ terminalError = error;
188
+ while (callbackWaiters.length > 0) {
189
+ callbackWaiters.shift()?.reject(error);
190
+ }
191
+ };
192
+
185
193
  const timeoutHandle = setTimeout(() => {
186
- rejectCallback(new Error("Timed out waiting for OAuth callback"));
194
+ failPendingWaiters(new Error("Timed out waiting for OAuth callback"));
187
195
  }, timeoutMs);
188
196
  timeoutHandle.unref?.();
189
197
 
@@ -201,14 +209,18 @@ const successResponse = `<!DOCTYPE html>
201
209
  return;
202
210
  }
203
211
 
212
+ const hasCode = !!url.searchParams.get("code");
213
+ const hasState = !!url.searchParams.get("state");
214
+ const hasError = !!url.searchParams.get("error");
215
+ if (!hasError && (!hasCode || !hasState)) {
216
+ response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
217
+ response.end("Ignoring incomplete OAuth callback. Return to the Google sign-in flow.");
218
+ return;
219
+ }
220
+
204
221
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
205
222
  response.end(successResponse);
206
-
207
- resolveCallback(url);
208
-
209
- setImmediate(() => {
210
- server.close();
211
- });
223
+ deliverCallback(url);
212
224
  });
213
225
 
214
226
  await new Promise<void>((resolve, reject) => {
@@ -224,11 +236,21 @@ const successResponse = `<!DOCTYPE html>
224
236
  });
225
237
 
226
238
  server.on("error", (error) => {
227
- rejectCallback(error instanceof Error ? error : new Error(String(error)));
239
+ failPendingWaiters(error instanceof Error ? error : new Error(String(error)));
228
240
  });
229
241
 
230
242
  return {
231
- waitForCallback: () => callbackPromise,
243
+ waitForCallback: async () => {
244
+ if (callbackQueue.length > 0) {
245
+ return callbackQueue.shift() as URL;
246
+ }
247
+ if (terminalError) {
248
+ throw terminalError;
249
+ }
250
+ return await new Promise<URL>((resolve, reject) => {
251
+ callbackWaiters.push({ resolve, reject });
252
+ });
253
+ },
232
254
  close: () =>
233
255
  new Promise<void>((resolve, reject) => {
234
256
  server.close((error) => {
@@ -236,9 +258,10 @@ const successResponse = `<!DOCTYPE html>
236
258
  reject(error);
237
259
  return;
238
260
  }
239
- if (!settled) {
240
- rejectCallback(new Error("OAuth listener closed before callback"));
261
+ if (timeoutHandle) {
262
+ clearTimeout(timeoutHandle);
241
263
  }
264
+ failPendingWaiters(new Error("OAuth listener closed before callback"));
242
265
  resolve();
243
266
  });
244
267
  }),
@@ -52,6 +52,11 @@ export interface PluginClient {
52
52
  auth: {
53
53
  set(input: { path: { id: string }; body: OAuthAuthDetails }): Promise<void>;
54
54
  };
55
+ config?: {
56
+ get(options?: unknown): Promise<{
57
+ data?: Config;
58
+ } | undefined>;
59
+ };
55
60
  tui?: {
56
61
  showToast(input: {
57
62
  body: {
package/src/plugin.ts CHANGED
@@ -9,7 +9,11 @@ import {
9
9
  } from "./plugin/quota";
10
10
  import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
11
11
  import { maybeShowGeminiCapacityToast, maybeShowGeminiTestToast } from "./plugin/notify";
12
- import { resolveConfiguredProjectId, resolveConfiguredProjectIdFromConfig } from "./plugin/provider";
12
+ import {
13
+ resolveConfiguredProjectId,
14
+ resolveConfiguredProjectIdFromClient,
15
+ resolveConfiguredProjectIdFromConfig,
16
+ } from "./plugin/provider";
13
17
  import {
14
18
  isGenerativeLanguageRequest,
15
19
  prepareGeminiRequest,
@@ -43,127 +47,137 @@ let latestGeminiConfiguredProjectId: string | undefined;
43
47
  */
44
48
  export const GeminiCLIOAuthPlugin = async (
45
49
  { client }: PluginContext,
46
- ): Promise<PluginResult> => ({
47
- config: async (config) => {
48
- latestGeminiConfiguredProjectId = resolveConfiguredProjectIdFromConfig(config);
49
- config.command = config.command || {};
50
- config.command[GEMINI_QUOTA_COMMAND] = {
51
- description: "Show Gemini Code Assist quota usage",
52
- template: GEMINI_QUOTA_COMMAND_TEMPLATE,
53
- };
54
- },
55
- tool: {
56
- [GEMINI_QUOTA_TOOL_NAME]: createGeminiQuotaTool({
57
- client,
58
- getAuthResolver: () => latestGeminiAuthResolver,
59
- getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
60
- }),
61
- },
62
- auth: {
63
- provider: GEMINI_PROVIDER_ID,
64
- loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
65
- latestGeminiAuthResolver = getAuth;
66
- const auth = await getAuth();
67
- if (!isOAuthAuth(auth)) {
68
- return null;
69
- }
50
+ ): Promise<PluginResult> => {
51
+ const resolveLatestConfiguredProjectId = async (provider?: Provider): Promise<string | undefined> => {
52
+ const configProjectId =
53
+ (await resolveConfiguredProjectIdFromClient(client)) ?? latestGeminiConfiguredProjectId;
54
+ const resolvedProjectId = resolveConfiguredProjectId({
55
+ provider,
56
+ configProjectId,
57
+ });
58
+ latestGeminiConfiguredProjectId = resolvedProjectId;
59
+ return resolvedProjectId;
60
+ };
70
61
 
71
- const configuredProjectId = resolveConfiguredProjectId({
72
- provider,
73
- configProjectId: latestGeminiConfiguredProjectId,
74
- });
75
- latestGeminiConfiguredProjectId = configuredProjectId;
76
- normalizeProviderModelCosts(provider);
77
- const thinkingConfigDefaults = resolveThinkingConfigDefaults(provider);
62
+ return {
63
+ config: async (config) => {
64
+ latestGeminiConfiguredProjectId = resolveConfiguredProjectIdFromConfig(config);
65
+ config.command = config.command || {};
66
+ config.command[GEMINI_QUOTA_COMMAND] = {
67
+ description: "Show Gemini Code Assist quota usage",
68
+ template: GEMINI_QUOTA_COMMAND_TEMPLATE,
69
+ };
70
+ },
71
+ tool: {
72
+ [GEMINI_QUOTA_TOOL_NAME]: createGeminiQuotaTool({
73
+ client,
74
+ getAuthResolver: () => latestGeminiAuthResolver,
75
+ getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
76
+ }),
77
+ },
78
+ auth: {
79
+ provider: GEMINI_PROVIDER_ID,
80
+ loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
81
+ latestGeminiAuthResolver = getAuth;
82
+ const auth = await getAuth();
83
+ if (!isOAuthAuth(auth)) {
84
+ return null;
85
+ }
78
86
 
79
- return {
80
- apiKey: "",
81
- async fetch(input, init) {
82
- if (!isGenerativeLanguageRequest(input)) {
83
- return fetch(input, init);
84
- }
87
+ await resolveLatestConfiguredProjectId(provider);
88
+ normalizeProviderModelCosts(provider);
89
+ const thinkingConfigDefaults = resolveThinkingConfigDefaults(provider);
85
90
 
86
- const latestAuth = await getAuth();
87
- if (!isOAuthAuth(latestAuth)) {
88
- return fetch(input, init);
89
- }
91
+ return {
92
+ apiKey: "",
93
+ async fetch(input, init) {
94
+ if (!isGenerativeLanguageRequest(input)) {
95
+ return fetch(input, init);
96
+ }
90
97
 
91
- let authRecord = resolveCachedAuth(latestAuth);
92
- if (accessTokenExpired(authRecord)) {
93
- const refreshed = await refreshAccessToken(authRecord, client);
94
- if (!refreshed) {
98
+ const latestAuth = await getAuth();
99
+ if (!isOAuthAuth(latestAuth)) {
95
100
  return fetch(input, init);
96
101
  }
97
- authRecord = refreshed;
98
- }
99
102
 
100
- if (!authRecord.access) {
101
- return fetch(input, init);
102
- }
103
+ let authRecord = resolveCachedAuth(latestAuth);
104
+ if (accessTokenExpired(authRecord)) {
105
+ const refreshed = await refreshAccessToken(authRecord, client);
106
+ if (!refreshed) {
107
+ return fetch(input, init);
108
+ }
109
+ authRecord = refreshed;
110
+ }
111
+
112
+ if (!authRecord.access) {
113
+ return fetch(input, init);
114
+ }
103
115
 
104
- const projectContext = await ensureProjectContextOrThrow(
105
- authRecord,
106
- client,
107
- configuredProjectId,
108
- );
109
- await maybeShowGeminiTestToast(client, projectContext.effectiveProjectId);
110
- await maybeLogAvailableQuotaModels(
111
- authRecord.access,
112
- projectContext.effectiveProjectId,
113
- );
114
- const transformed = prepareGeminiRequest(
115
- input,
116
- init,
117
- authRecord.access,
118
- projectContext.effectiveProjectId,
119
- thinkingConfigDefaults,
120
- );
121
- const debugContext = startGeminiDebugRequest({
122
- originalUrl: toUrlString(input),
123
- resolvedUrl: toUrlString(transformed.request),
124
- method: transformed.init.method,
125
- headers: transformed.init.headers,
126
- body: transformed.init.body,
127
- streaming: transformed.streaming,
128
- projectId: projectContext.effectiveProjectId,
129
- });
116
+ const configuredProjectId = await resolveLatestConfiguredProjectId(provider);
117
+ const projectContext = await ensureProjectContextOrThrow(
118
+ authRecord,
119
+ client,
120
+ configuredProjectId,
121
+ );
122
+ await maybeShowGeminiTestToast(client, projectContext.effectiveProjectId);
123
+ await maybeLogAvailableQuotaModels(
124
+ authRecord.access,
125
+ projectContext.effectiveProjectId,
126
+ );
127
+ const transformed = prepareGeminiRequest(
128
+ input,
129
+ init,
130
+ authRecord.access,
131
+ projectContext.effectiveProjectId,
132
+ thinkingConfigDefaults,
133
+ );
134
+ const debugContext = startGeminiDebugRequest({
135
+ originalUrl: toUrlString(input),
136
+ resolvedUrl: toUrlString(transformed.request),
137
+ method: transformed.init.method,
138
+ headers: transformed.init.headers,
139
+ body: transformed.init.body,
140
+ streaming: transformed.streaming,
141
+ projectId: projectContext.effectiveProjectId,
142
+ });
130
143
 
131
- /**
132
- * Retry transport/429 failures while preserving the requested model.
133
- * We intentionally do not auto-downgrade model tiers to avoid misleading users.
134
- */
135
- const response = await fetchWithRetry(transformed.request, transformed.init);
136
- await maybeShowGeminiCapacityToast(
137
- client,
138
- response,
139
- projectContext.effectiveProjectId,
140
- transformed.requestedModel,
141
- );
142
- return transformGeminiResponse(
143
- response,
144
- transformed.streaming,
145
- debugContext,
146
- transformed.requestedModel,
147
- );
144
+ /**
145
+ * Retry transport/429 failures while preserving the requested model.
146
+ * We intentionally do not auto-downgrade model tiers to avoid misleading users.
147
+ */
148
+ const response = await fetchWithRetry(transformed.request, transformed.init);
149
+ await maybeShowGeminiCapacityToast(
150
+ client,
151
+ response,
152
+ projectContext.effectiveProjectId,
153
+ transformed.requestedModel,
154
+ );
155
+ return transformGeminiResponse(
156
+ response,
157
+ transformed.streaming,
158
+ debugContext,
159
+ transformed.requestedModel,
160
+ );
161
+ },
162
+ };
163
+ },
164
+ methods: [
165
+ {
166
+ label: "OAuth with Google (Gemini CLI)",
167
+ type: "oauth",
168
+ authorize: createOAuthAuthorizeMethod({
169
+ getConfiguredProjectId: () => resolveLatestConfiguredProjectId(),
170
+ }),
148
171
  },
149
- };
172
+ {
173
+ provider: GEMINI_PROVIDER_ID,
174
+ label: "Manually enter API Key",
175
+ type: "api",
176
+ },
177
+ ],
150
178
  },
151
- methods: [
152
- {
153
- label: "OAuth with Google (Gemini CLI)",
154
- type: "oauth",
155
- authorize: createOAuthAuthorizeMethod({
156
- getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
157
- }),
158
- },
159
- {
160
- provider: GEMINI_PROVIDER_ID,
161
- label: "Manually enter API Key",
162
- type: "api",
163
- },
164
- ],
165
- },
166
- });
179
+ };
180
+ };
167
181
 
168
182
  export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
169
183
  const loggedQuotaModelsByProject = new Set<string>();