opencode-gemini-auth 1.3.9 → 1.3.11

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,175 @@
1
+ import { parseRetryDelayFromBody } from "./quota";
2
+
3
+ export const DEFAULT_MAX_ATTEMPTS = 3;
4
+ const DEFAULT_INITIAL_DELAY_MS = 5000;
5
+ const DEFAULT_MAX_DELAY_MS = 30000;
6
+
7
+ const RETRYABLE_NETWORK_CODES = new Set([
8
+ "ECONNRESET",
9
+ "ETIMEDOUT",
10
+ "EPIPE",
11
+ "ENOTFOUND",
12
+ "EAI_AGAIN",
13
+ "ECONNREFUSED",
14
+ "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC",
15
+ "ERR_SSL_WRONG_VERSION_NUMBER",
16
+ "ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC",
17
+ "ERR_SSL_BAD_RECORD_MAC",
18
+ "EPROTO",
19
+ ]);
20
+
21
+ /**
22
+ * Ensures request bodies are replayable before we attempt retries.
23
+ */
24
+ export function canRetryRequest(init: RequestInit | undefined): boolean {
25
+ if (!init?.body) {
26
+ return true;
27
+ }
28
+
29
+ const body = init.body;
30
+ if (typeof body === "string") {
31
+ return true;
32
+ }
33
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
34
+ return true;
35
+ }
36
+ if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
37
+ return true;
38
+ }
39
+ if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
40
+ return true;
41
+ }
42
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
43
+ return true;
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Status-based retry policy aligned to Gemini CLI.
51
+ */
52
+ export function isRetryableStatus(status: number): boolean {
53
+ return status === 429 || (status >= 500 && status < 600);
54
+ }
55
+
56
+ /**
57
+ * Handles transient network failures (including nested `cause.code` errors).
58
+ */
59
+ export function isRetryableNetworkError(error: unknown): boolean {
60
+ const code = getNetworkErrorCode(error);
61
+ if (code && RETRYABLE_NETWORK_CODES.has(code)) {
62
+ return true;
63
+ }
64
+
65
+ return error instanceof Error && error.message.toLowerCase().includes("fetch failed");
66
+ }
67
+
68
+ /**
69
+ * Resolves retry delay using header hints, parsed payload retry info, and backoff fallback.
70
+ */
71
+ export async function resolveRetryDelayMs(
72
+ response: Response,
73
+ attempt: number,
74
+ quotaDelayMs?: number,
75
+ ): Promise<number> {
76
+ const retryAfterMsHeader = parseRetryAfterMs(response.headers.get("retry-after-ms"));
77
+ if (retryAfterMsHeader !== null) {
78
+ return clampDelay(retryAfterMsHeader);
79
+ }
80
+
81
+ const retryAfterHeader = parseRetryAfter(response.headers.get("retry-after"));
82
+ if (retryAfterHeader !== null) {
83
+ return clampDelay(retryAfterHeader);
84
+ }
85
+
86
+ if (quotaDelayMs !== undefined) {
87
+ return clampDelay(quotaDelayMs);
88
+ }
89
+
90
+ const bodyDelay = await parseRetryDelayFromBody(response);
91
+ if (bodyDelay !== null) {
92
+ return clampDelay(bodyDelay);
93
+ }
94
+
95
+ return getExponentialDelayWithJitter(attempt);
96
+ }
97
+
98
+ export function getExponentialDelayWithJitter(attempt: number): number {
99
+ const base = Math.min(DEFAULT_MAX_DELAY_MS, DEFAULT_INITIAL_DELAY_MS * Math.pow(2, attempt - 1));
100
+ const jitter = base * 0.3 * (Math.random() * 2 - 1);
101
+ return clampDelay(base + jitter);
102
+ }
103
+
104
+ export function wait(ms: number): Promise<void> {
105
+ return new Promise((resolve) => {
106
+ setTimeout(resolve, ms);
107
+ });
108
+ }
109
+
110
+ function getNetworkErrorCode(error: unknown): string | undefined {
111
+ const readCode = (value: unknown): string | undefined => {
112
+ if (!value || typeof value !== "object") {
113
+ return undefined;
114
+ }
115
+ if ("code" in value && typeof (value as { code?: unknown }).code === "string") {
116
+ return (value as { code: string }).code;
117
+ }
118
+ return undefined;
119
+ };
120
+
121
+ const direct = readCode(error);
122
+ if (direct) {
123
+ return direct;
124
+ }
125
+
126
+ let cursor: unknown = error;
127
+ for (let depth = 0; depth < 5; depth += 1) {
128
+ if (!cursor || typeof cursor !== "object" || !("cause" in cursor)) {
129
+ break;
130
+ }
131
+ cursor = (cursor as { cause?: unknown }).cause;
132
+ const code = readCode(cursor);
133
+ if (code) {
134
+ return code;
135
+ }
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ function parseRetryAfterMs(value: string | null): number | null {
141
+ if (!value) {
142
+ return null;
143
+ }
144
+ const parsed = Number(value.trim());
145
+ if (!Number.isFinite(parsed) || parsed <= 0) {
146
+ return null;
147
+ }
148
+ return Math.round(parsed);
149
+ }
150
+
151
+ function parseRetryAfter(value: string | null): number | null {
152
+ if (!value) {
153
+ return null;
154
+ }
155
+ const trimmed = value.trim();
156
+ if (!trimmed) {
157
+ return null;
158
+ }
159
+ const seconds = Number(trimmed);
160
+ if (Number.isFinite(seconds)) {
161
+ return Math.max(0, Math.round(seconds * 1000));
162
+ }
163
+ const parsedDate = Date.parse(trimmed);
164
+ if (!Number.isNaN(parsedDate)) {
165
+ return Math.max(0, parsedDate - Date.now());
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function clampDelay(delayMs: number): number {
171
+ if (!Number.isFinite(delayMs)) {
172
+ return DEFAULT_MAX_DELAY_MS;
173
+ }
174
+ return Math.min(Math.max(0, Math.round(delayMs)), DEFAULT_MAX_DELAY_MS);
175
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ canRetryRequest,
3
+ DEFAULT_MAX_ATTEMPTS,
4
+ getExponentialDelayWithJitter,
5
+ isRetryableNetworkError,
6
+ isRetryableStatus,
7
+ resolveRetryDelayMs,
8
+ wait,
9
+ } from "./helpers";
10
+ import { classifyQuotaResponse, retryInternals } from "./quota";
11
+
12
+ /**
13
+ * Sends requests with retry/backoff semantics aligned to Gemini CLI:
14
+ * - Retries on 429/5xx and transient network failures
15
+ * - Honors Retry-After and google.rpc.RetryInfo
16
+ * - Never rewrites requested model
17
+ */
18
+ export async function fetchWithRetry(
19
+ input: RequestInfo,
20
+ init: RequestInit | undefined,
21
+ ): Promise<Response> {
22
+ if (!canRetryRequest(init)) {
23
+ return fetch(input, init);
24
+ }
25
+
26
+ const retryInit = cloneRetryableInit(init);
27
+ let attempt = 1;
28
+
29
+ while (attempt <= DEFAULT_MAX_ATTEMPTS) {
30
+ let response: Response;
31
+ try {
32
+ response = await fetch(input, retryInit);
33
+ } catch (error) {
34
+ if (attempt >= DEFAULT_MAX_ATTEMPTS || !isRetryableNetworkError(error)) {
35
+ throw error;
36
+ }
37
+ if (retryInit.signal?.aborted) {
38
+ throw error;
39
+ }
40
+
41
+ await wait(getExponentialDelayWithJitter(attempt));
42
+ attempt += 1;
43
+ continue;
44
+ }
45
+
46
+ if (!isRetryableStatus(response.status)) {
47
+ return response;
48
+ }
49
+
50
+ const quotaContext = response.status === 429 ? await classifyQuotaResponse(response) : null;
51
+ if (response.status === 429 && quotaContext?.terminal) {
52
+ return response;
53
+ }
54
+
55
+ if (attempt >= DEFAULT_MAX_ATTEMPTS || retryInit.signal?.aborted) {
56
+ return response;
57
+ }
58
+
59
+ const delayMs = await resolveRetryDelayMs(response, attempt, quotaContext?.retryDelayMs);
60
+ if (delayMs <= 0) {
61
+ return response;
62
+ }
63
+
64
+ await wait(delayMs);
65
+ attempt += 1;
66
+ }
67
+
68
+ return fetch(input, retryInit);
69
+ }
70
+
71
+ function cloneRetryableInit(init: RequestInit | undefined): RequestInit {
72
+ if (!init) {
73
+ return {};
74
+ }
75
+ return {
76
+ ...init,
77
+ headers: new Headers(init.headers ?? {}),
78
+ };
79
+ }
80
+
81
+ export { retryInternals };
@@ -0,0 +1,210 @@
1
+ interface GoogleRpcErrorInfo {
2
+ "@type"?: string;
3
+ reason?: string;
4
+ domain?: string;
5
+ metadata?: Record<string, string>;
6
+ }
7
+
8
+ interface GoogleRpcQuotaViolation {
9
+ quotaId?: string;
10
+ description?: string;
11
+ }
12
+
13
+ interface GoogleRpcQuotaFailure {
14
+ "@type"?: string;
15
+ violations?: GoogleRpcQuotaViolation[];
16
+ }
17
+
18
+ interface GoogleRpcRetryInfo {
19
+ "@type"?: string;
20
+ retryDelay?: string | { seconds?: number; nanos?: number };
21
+ }
22
+
23
+ export interface QuotaContext {
24
+ terminal: boolean;
25
+ retryDelayMs?: number;
26
+ }
27
+
28
+ const CLOUDCODE_DOMAINS = new Set([
29
+ "cloudcode-pa.googleapis.com",
30
+ "staging-cloudcode-pa.googleapis.com",
31
+ "autopush-cloudcode-pa.googleapis.com",
32
+ ]);
33
+
34
+ /**
35
+ * Parses Code Assist 429 payload details to determine terminal vs retryable quota states.
36
+ *
37
+ * This mirrors Gemini CLI behavior so we:
38
+ * - fail fast on hard quota exhaustion (`QUOTA_EXHAUSTED`, daily limits)
39
+ * - keep retrying on short-window limits (`RATE_LIMIT_EXCEEDED`, per-minute limits)
40
+ */
41
+ export async function classifyQuotaResponse(response: Response): Promise<QuotaContext | null> {
42
+ const payload = await parseErrorBody(response);
43
+ if (!payload) {
44
+ return null;
45
+ }
46
+
47
+ const details = Array.isArray(payload.details) ? payload.details : [];
48
+ const retryInfo = details.find(
49
+ (detail): detail is GoogleRpcRetryInfo =>
50
+ isObject(detail) &&
51
+ detail["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
52
+ );
53
+ const retryDelayMs =
54
+ (retryInfo?.retryDelay ? parseRetryDelayValue(retryInfo.retryDelay) : null) ??
55
+ parseRetryDelayFromMessage(payload.message ?? "") ??
56
+ undefined;
57
+
58
+ const errorInfo = details.find(
59
+ (detail): detail is GoogleRpcErrorInfo =>
60
+ isObject(detail) &&
61
+ detail["@type"] === "type.googleapis.com/google.rpc.ErrorInfo",
62
+ );
63
+
64
+ if (errorInfo?.domain && !CLOUDCODE_DOMAINS.has(errorInfo.domain)) {
65
+ return null;
66
+ }
67
+ if (errorInfo?.reason === "QUOTA_EXHAUSTED") {
68
+ return { terminal: true, retryDelayMs };
69
+ }
70
+ if (errorInfo?.reason === "RATE_LIMIT_EXCEEDED") {
71
+ return { terminal: false, retryDelayMs: retryDelayMs ?? 10_000 };
72
+ }
73
+
74
+ const quotaFailure = details.find(
75
+ (detail): detail is GoogleRpcQuotaFailure =>
76
+ isObject(detail) &&
77
+ detail["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
78
+ );
79
+ if (quotaFailure?.violations?.length) {
80
+ const allTexts = quotaFailure.violations
81
+ .flatMap((violation) => [violation.quotaId ?? "", violation.description ?? ""])
82
+ .join(" ")
83
+ .toLowerCase();
84
+
85
+ if (allTexts.includes("perday") || allTexts.includes("daily") || allTexts.includes("per day")) {
86
+ return { terminal: true, retryDelayMs };
87
+ }
88
+ if (allTexts.includes("perminute") || allTexts.includes("per minute")) {
89
+ return { terminal: false, retryDelayMs: retryDelayMs ?? 60_000 };
90
+ }
91
+ return { terminal: false, retryDelayMs };
92
+ }
93
+
94
+ const quotaLimit = errorInfo?.metadata?.quota_limit?.toLowerCase() ?? "";
95
+ if (quotaLimit.includes("perminute") || quotaLimit.includes("per minute")) {
96
+ return { terminal: false, retryDelayMs: retryDelayMs ?? 60_000 };
97
+ }
98
+
99
+ return { terminal: false, retryDelayMs };
100
+ }
101
+
102
+ /**
103
+ * Extracts RetryInfo delay hints directly from error payloads.
104
+ *
105
+ * We keep this as a shared utility for retry scheduling so request-level behavior
106
+ * and quota-classified behavior derive from the same delay parser.
107
+ */
108
+ export async function parseRetryDelayFromBody(response: Response): Promise<number | null> {
109
+ const payload = await parseErrorBody(response);
110
+ if (!payload) {
111
+ return null;
112
+ }
113
+
114
+ const details = Array.isArray(payload.details) ? payload.details : [];
115
+ const retryInfo = details.find(
116
+ (detail): detail is GoogleRpcRetryInfo =>
117
+ isObject(detail) &&
118
+ detail["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
119
+ );
120
+ if (retryInfo?.retryDelay) {
121
+ const delayMs = parseRetryDelayValue(retryInfo.retryDelay);
122
+ if (delayMs !== null) {
123
+ return delayMs;
124
+ }
125
+ }
126
+
127
+ if (typeof payload.message === "string") {
128
+ return parseRetryDelayFromMessage(payload.message);
129
+ }
130
+ return null;
131
+ }
132
+
133
+ function parseRetryDelayValue(value: string | { seconds?: number; nanos?: number }): number | null {
134
+ if (typeof value === "string") {
135
+ const trimmed = value.trim();
136
+ if (!trimmed) {
137
+ return null;
138
+ }
139
+ if (trimmed.endsWith("ms")) {
140
+ const milliseconds = Number(trimmed.slice(0, -2));
141
+ return Number.isFinite(milliseconds) && milliseconds > 0 ? Math.round(milliseconds) : null;
142
+ }
143
+ const match = trimmed.match(/^([\d.]+)s$/);
144
+ if (!match?.[1]) {
145
+ return null;
146
+ }
147
+ const seconds = Number(match[1]);
148
+ return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : null;
149
+ }
150
+
151
+ const seconds = typeof value.seconds === "number" ? value.seconds : 0;
152
+ const nanos = typeof value.nanos === "number" ? value.nanos : 0;
153
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
154
+ return null;
155
+ }
156
+ const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
157
+ return totalMs > 0 ? totalMs : null;
158
+ }
159
+
160
+ function parseRetryDelayFromMessage(message: string): number | null {
161
+ const retryMatch = message.match(/Please retry in ([0-9.]+(?:ms|s))/i);
162
+ if (retryMatch?.[1]) {
163
+ return parseRetryDelayValue(retryMatch[1]);
164
+ }
165
+
166
+ const afterMatch = message.match(/after\s+([0-9.]+(?:ms|s))/i);
167
+ if (afterMatch?.[1]) {
168
+ return parseRetryDelayValue(afterMatch[1]);
169
+ }
170
+
171
+ return null;
172
+ }
173
+
174
+ async function parseErrorBody(
175
+ response: Response,
176
+ ): Promise<{ message?: string; details?: unknown[] } | null> {
177
+ let text = "";
178
+ try {
179
+ text = await response.clone().text();
180
+ } catch {
181
+ return null;
182
+ }
183
+ if (!text) {
184
+ return null;
185
+ }
186
+
187
+ let parsed: unknown;
188
+ try {
189
+ parsed = JSON.parse(text);
190
+ } catch {
191
+ return null;
192
+ }
193
+
194
+ if (!isObject(parsed) || !isObject(parsed.error)) {
195
+ return null;
196
+ }
197
+ return {
198
+ message: typeof parsed.error.message === "string" ? parsed.error.message : undefined,
199
+ details: Array.isArray(parsed.error.details) ? parsed.error.details : undefined,
200
+ };
201
+ }
202
+
203
+ function isObject(value: unknown): value is Record<string, any> {
204
+ return !!value && typeof value === "object";
205
+ }
206
+
207
+ export const retryInternals = {
208
+ parseRetryDelayValue,
209
+ parseRetryDelayFromMessage,
210
+ };
@@ -0,0 +1,106 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ import { fetchWithRetry, retryInternals } from "./retry";
4
+
5
+ const originalSetTimeout = globalThis.setTimeout;
6
+
7
+ function makeQuota429(reason: "RATE_LIMIT_EXCEEDED" | "QUOTA_EXHAUSTED", retryDelay?: string): Response {
8
+ const details: Record<string, unknown>[] = [
9
+ {
10
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
11
+ reason,
12
+ domain: "cloudcode-pa.googleapis.com",
13
+ },
14
+ ];
15
+ if (retryDelay) {
16
+ details.push({
17
+ "@type": "type.googleapis.com/google.rpc.RetryInfo",
18
+ retryDelay,
19
+ });
20
+ }
21
+ return new Response(
22
+ JSON.stringify({
23
+ error: {
24
+ message: "rate limited",
25
+ details,
26
+ },
27
+ }),
28
+ {
29
+ status: 429,
30
+ headers: { "content-type": "application/json" },
31
+ },
32
+ );
33
+ }
34
+
35
+ describe("fetchWithRetry", () => {
36
+ beforeEach(() => {
37
+ mock.restore();
38
+ (globalThis as { setTimeout: typeof setTimeout }).setTimeout = ((fn: (...args: any[]) => void) => {
39
+ fn();
40
+ return 0 as unknown as ReturnType<typeof setTimeout>;
41
+ }) as typeof setTimeout;
42
+ });
43
+
44
+ afterEach(() => {
45
+ (globalThis as { setTimeout: typeof setTimeout }).setTimeout = originalSetTimeout;
46
+ });
47
+
48
+ it("retries transient network errors", async () => {
49
+ const fetchMock = mock(async () => {
50
+ if (fetchMock.mock.calls.length === 1) {
51
+ const err = new Error("socket reset") as Error & { code?: string };
52
+ err.code = "ECONNRESET";
53
+ throw err;
54
+ }
55
+ return new Response("ok", { status: 200 });
56
+ });
57
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
58
+
59
+ const response = await fetchWithRetry("https://example.com", {
60
+ method: "POST",
61
+ body: JSON.stringify({ hello: "world" }),
62
+ });
63
+
64
+ expect(response.status).toBe(200);
65
+ expect(fetchMock.mock.calls.length).toBe(2);
66
+ });
67
+
68
+ it("retries rate-limit responses with retry hints", async () => {
69
+ const fetchMock = mock(async () => {
70
+ if (fetchMock.mock.calls.length === 1) {
71
+ return makeQuota429("RATE_LIMIT_EXCEEDED", "1500ms");
72
+ }
73
+ return new Response("ok", { status: 200 });
74
+ });
75
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
76
+
77
+ const response = await fetchWithRetry("https://example.com", {
78
+ method: "POST",
79
+ body: JSON.stringify({ hello: "world" }),
80
+ });
81
+
82
+ expect(response.status).toBe(200);
83
+ expect(fetchMock.mock.calls.length).toBe(2);
84
+ });
85
+
86
+ it("does not retry terminal quota exhaustion", async () => {
87
+ const fetchMock = mock(async () => makeQuota429("QUOTA_EXHAUSTED"));
88
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
89
+
90
+ const response = await fetchWithRetry("https://example.com", {
91
+ method: "POST",
92
+ body: JSON.stringify({ hello: "world" }),
93
+ });
94
+
95
+ expect(response.status).toBe(429);
96
+ expect(fetchMock.mock.calls.length).toBe(1);
97
+ });
98
+ });
99
+
100
+ describe("retryInternals", () => {
101
+ it("parses retry delays from both ms and s notation", () => {
102
+ expect(retryInternals.parseRetryDelayValue("1200ms")).toBe(1200);
103
+ expect(retryInternals.parseRetryDelayValue("1.5s")).toBe(1500);
104
+ expect(retryInternals.parseRetryDelayFromMessage("Please retry in 2s")).toBe(2000);
105
+ });
106
+ });
@@ -71,4 +71,35 @@ describe("refreshAccessToken", () => {
71
71
  }),
72
72
  });
73
73
  });
74
+
75
+ it("deduplicates concurrent refresh calls for the same refresh token", async () => {
76
+ const client = createClient();
77
+ let releaseFetch!: () => void;
78
+ const gate = new Promise<void>((resolve) => {
79
+ releaseFetch = resolve;
80
+ });
81
+
82
+ const fetchMock = mock(async () => {
83
+ await gate;
84
+ return new Response(
85
+ JSON.stringify({
86
+ access_token: "deduped-access",
87
+ expires_in: 3600,
88
+ }),
89
+ { status: 200 },
90
+ );
91
+ });
92
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
93
+
94
+ const first = refreshAccessToken(baseAuth, client);
95
+ const second = refreshAccessToken(baseAuth, client);
96
+ await Promise.resolve();
97
+
98
+ expect(fetchMock.mock.calls.length).toBe(1);
99
+ releaseFetch();
100
+
101
+ const [firstResult, secondResult] = await Promise.all([first, second]);
102
+ expect(firstResult?.access).toBe("deduped-access");
103
+ expect(secondResult?.access).toBe("deduped-access");
104
+ });
74
105
  });
@@ -24,6 +24,8 @@ interface OAuthErrorPayload {
24
24
  error_description?: string;
25
25
  }
26
26
 
27
+ const refreshInFlight = new Map<string, Promise<OAuthAuthDetails | undefined>>();
28
+
27
29
  /**
28
30
  * Parses OAuth error payloads returned by Google token endpoints, tolerating varied shapes.
29
31
  */
@@ -75,6 +77,26 @@ export async function refreshAccessToken(
75
77
  return undefined;
76
78
  }
77
79
 
80
+ const pending = refreshInFlight.get(parts.refreshToken);
81
+ if (pending) {
82
+ return pending;
83
+ }
84
+
85
+ const refreshPromise = refreshAccessTokenInternal(auth, client, parts);
86
+ refreshInFlight.set(parts.refreshToken, refreshPromise);
87
+
88
+ try {
89
+ return await refreshPromise;
90
+ } finally {
91
+ refreshInFlight.delete(parts.refreshToken);
92
+ }
93
+ }
94
+
95
+ async function refreshAccessTokenInternal(
96
+ auth: OAuthAuthDetails,
97
+ client: PluginClient,
98
+ parts: RefreshParts,
99
+ ): Promise<OAuthAuthDetails | undefined> {
78
100
  try {
79
101
  if (isGeminiDebugEnabled()) {
80
102
  logGeminiDebugMessage("OAuth refresh: POST https://oauth2.googleapis.com/token");
@@ -118,6 +140,7 @@ export async function refreshAccessToken(
118
140
  console.warn(
119
141
  "[Gemini OAuth] Google revoked the stored refresh token. Run `opencode auth login` and reauthenticate the Google provider.",
120
142
  );
143
+ clearCachedAuth(auth.refresh);
121
144
  invalidateProjectContextCache(auth.refresh);
122
145
  try {
123
146
  const clearedAuth: OAuthAuthDetails = {
@@ -165,6 +188,7 @@ export async function refreshAccessToken(
165
188
  refresh: formatRefreshParts(refreshedParts),
166
189
  };
167
190
 
191
+ clearCachedAuth(auth.refresh);
168
192
  storeCachedAuth(updatedAuth);
169
193
  invalidateProjectContextCache(auth.refresh);
170
194