opencode-gemini-auth-proxy 1.3.10
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/LICENSE +21 -0
- package/README.md +254 -0
- package/index.ts +14 -0
- package/package.json +23 -0
- package/src/constants.ts +39 -0
- package/src/fetch.ts +11 -0
- package/src/gemini/oauth.ts +178 -0
- package/src/plugin/auth.test.ts +58 -0
- package/src/plugin/auth.ts +46 -0
- package/src/plugin/cache.ts +65 -0
- package/src/plugin/debug.ts +258 -0
- package/src/plugin/project.test.ts +112 -0
- package/src/plugin/project.ts +552 -0
- package/src/plugin/request-helpers.test.ts +84 -0
- package/src/plugin/request-helpers.ts +439 -0
- package/src/plugin/request.test.ts +50 -0
- package/src/plugin/request.ts +483 -0
- package/src/plugin/server.ts +246 -0
- package/src/plugin/token.test.ts +74 -0
- package/src/plugin/token.ts +188 -0
- package/src/plugin/types.ts +76 -0
- package/src/plugin.ts +700 -0
- package/src/shims.d.ts +8 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { accessTokenExpired } from "./auth";
|
|
2
|
+
import type { OAuthAuthDetails } from "./types";
|
|
3
|
+
|
|
4
|
+
const authCache = new Map<string, OAuthAuthDetails>();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Produces a stable cache key from a refresh token string.
|
|
8
|
+
*/
|
|
9
|
+
function normalizeRefreshKey(refresh?: string): string | undefined {
|
|
10
|
+
const key = refresh?.trim();
|
|
11
|
+
return key ? key : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns a cached auth snapshot when available, favoring unexpired tokens.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails {
|
|
18
|
+
const key = normalizeRefreshKey(auth.refresh);
|
|
19
|
+
if (!key) {
|
|
20
|
+
return auth;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cached = authCache.get(key);
|
|
24
|
+
if (!cached) {
|
|
25
|
+
authCache.set(key, auth);
|
|
26
|
+
return auth;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!accessTokenExpired(auth)) {
|
|
30
|
+
authCache.set(key, auth);
|
|
31
|
+
return auth;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!accessTokenExpired(cached)) {
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
authCache.set(key, auth);
|
|
39
|
+
return auth;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Stores the latest auth snapshot keyed by refresh token.
|
|
44
|
+
*/
|
|
45
|
+
export function storeCachedAuth(auth: OAuthAuthDetails): void {
|
|
46
|
+
const key = normalizeRefreshKey(auth.refresh);
|
|
47
|
+
if (!key) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
authCache.set(key, auth);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clears cached auth globally or for a specific refresh token.
|
|
55
|
+
*/
|
|
56
|
+
export function clearCachedAuth(refresh?: string): void {
|
|
57
|
+
if (!refresh) {
|
|
58
|
+
authCache.clear();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const key = normalizeRefreshKey(refresh);
|
|
62
|
+
if (key) {
|
|
63
|
+
authCache.delete(key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cwd, env } from "node:process";
|
|
4
|
+
|
|
5
|
+
const DEBUG_FLAG = env.OPENCODE_GEMINI_DEBUG ?? "";
|
|
6
|
+
const MAX_BODY_PREVIEW_CHARS = 2000;
|
|
7
|
+
const debugEnabled = DEBUG_FLAG.trim() === "1";
|
|
8
|
+
const logFilePath = debugEnabled ? defaultLogFilePath() : undefined;
|
|
9
|
+
const logWriter = createLogWriter(logFilePath);
|
|
10
|
+
|
|
11
|
+
export interface GeminiDebugContext {
|
|
12
|
+
id: string;
|
|
13
|
+
streaming: boolean;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GeminiDebugRequestMeta {
|
|
18
|
+
originalUrl: string;
|
|
19
|
+
resolvedUrl: string;
|
|
20
|
+
method?: string;
|
|
21
|
+
headers?: HeadersInit;
|
|
22
|
+
body?: BodyInit | null;
|
|
23
|
+
streaming: boolean;
|
|
24
|
+
projectId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GeminiDebugResponseMeta {
|
|
28
|
+
body?: string;
|
|
29
|
+
note?: string;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
headersOverride?: HeadersInit;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let requestCounter = 0;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns true when Gemini debug logging is enabled.
|
|
38
|
+
*/
|
|
39
|
+
export function isGeminiDebugEnabled(): boolean {
|
|
40
|
+
return debugEnabled;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Writes an arbitrary debug line when debugging is enabled.
|
|
45
|
+
*/
|
|
46
|
+
export function logGeminiDebugMessage(message: string): void {
|
|
47
|
+
if (!debugEnabled) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
logDebug(`[Gemini Debug] ${message}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Produces a truncated preview of a debug body payload.
|
|
55
|
+
*/
|
|
56
|
+
export function formatDebugBodyPreview(text?: string | null): string | undefined {
|
|
57
|
+
if (!text) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return truncateForLog(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Begins a debug trace for a Gemini request, logging request metadata when debugging is enabled.
|
|
65
|
+
*/
|
|
66
|
+
export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDebugContext | null {
|
|
67
|
+
if (!debugEnabled) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const id = `GEMINI-${++requestCounter}`;
|
|
72
|
+
const method = meta.method ?? "GET";
|
|
73
|
+
logDebug(`[Gemini Debug ${id}] ${method} ${meta.resolvedUrl}`);
|
|
74
|
+
if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) {
|
|
75
|
+
logDebug(`[Gemini Debug ${id}] Original URL: ${meta.originalUrl}`);
|
|
76
|
+
}
|
|
77
|
+
if (meta.projectId) {
|
|
78
|
+
logDebug(`[Gemini Debug ${id}] Project: ${meta.projectId}`);
|
|
79
|
+
}
|
|
80
|
+
logDebug(`[Gemini Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`);
|
|
81
|
+
logDebug(`[Gemini Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`);
|
|
82
|
+
const bodyPreview = formatBodyPreview(meta.body);
|
|
83
|
+
if (bodyPreview) {
|
|
84
|
+
logDebug(`[Gemini Debug ${id}] Body Preview: ${bodyPreview}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { id, streaming: meta.streaming, startedAt: Date.now() };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Logs response details for a previously started debug trace when debugging is enabled.
|
|
92
|
+
*/
|
|
93
|
+
export function logGeminiDebugResponse(
|
|
94
|
+
context: GeminiDebugContext | null | undefined,
|
|
95
|
+
response: Response,
|
|
96
|
+
meta: GeminiDebugResponseMeta = {},
|
|
97
|
+
): void {
|
|
98
|
+
if (!debugEnabled || !context) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const durationMs = Date.now() - context.startedAt;
|
|
103
|
+
logDebug(
|
|
104
|
+
`[Gemini Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`,
|
|
105
|
+
);
|
|
106
|
+
logDebug(
|
|
107
|
+
`[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify(
|
|
108
|
+
maskHeaders(meta.headersOverride ?? response.headers),
|
|
109
|
+
)}`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const traceId = getHeaderValue(meta.headersOverride ?? response.headers, "x-cloudaicompanion-trace-id");
|
|
113
|
+
if (traceId) {
|
|
114
|
+
logDebug(`[Gemini Debug ${context.id}] Trace ID: ${traceId}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (meta.note) {
|
|
118
|
+
logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (meta.error) {
|
|
122
|
+
logDebug(`[Gemini Debug ${context.id}] Error: ${formatError(meta.error)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (meta.body) {
|
|
126
|
+
logDebug(
|
|
127
|
+
`[Gemini Debug ${context.id}] Response Body Preview: ${truncateForLog(meta.body)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Obscures sensitive headers and returns a plain object for logging.
|
|
134
|
+
*/
|
|
135
|
+
function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
|
|
136
|
+
if (!headers) {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const result: Record<string, string> = {};
|
|
141
|
+
const parsed = headers instanceof Headers ? headers : new Headers(headers);
|
|
142
|
+
parsed.forEach((value, key) => {
|
|
143
|
+
if (key.toLowerCase() === "authorization") {
|
|
144
|
+
result[key] = "[redacted]";
|
|
145
|
+
} else {
|
|
146
|
+
result[key] = value;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Reads a header value from a HeadersInit or Headers instance.
|
|
154
|
+
*/
|
|
155
|
+
function getHeaderValue(headers: HeadersInit | Headers, key: string): string | undefined {
|
|
156
|
+
const target = key.toLowerCase();
|
|
157
|
+
if (headers instanceof Headers) {
|
|
158
|
+
const value = headers.get(key);
|
|
159
|
+
return value ?? undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (Array.isArray(headers)) {
|
|
163
|
+
for (const [headerKey, headerValue] of headers) {
|
|
164
|
+
if (headerKey.toLowerCase() === target) {
|
|
165
|
+
return headerValue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const record = headers as Record<string, string | undefined>;
|
|
172
|
+
for (const [headerKey, headerValue] of Object.entries(record)) {
|
|
173
|
+
if (headerKey.toLowerCase() === target) {
|
|
174
|
+
return headerValue ?? undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Produces a short, type-aware preview of a request/response body for logs.
|
|
182
|
+
*/
|
|
183
|
+
function formatBodyPreview(body?: BodyInit | null): string | undefined {
|
|
184
|
+
if (body == null) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (typeof body === "string") {
|
|
189
|
+
return truncateForLog(body);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (body instanceof URLSearchParams) {
|
|
193
|
+
return truncateForLog(body.toString());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
197
|
+
return `[Blob size=${body.size}]`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
201
|
+
return "[FormData payload omitted]";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return `[${body.constructor?.name ?? typeof body} payload omitted]`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Truncates long strings to a fixed preview length for logging.
|
|
209
|
+
*/
|
|
210
|
+
function truncateForLog(text: string): string {
|
|
211
|
+
if (text.length <= MAX_BODY_PREVIEW_CHARS) {
|
|
212
|
+
return text;
|
|
213
|
+
}
|
|
214
|
+
return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Writes a single debug line using the configured writer.
|
|
219
|
+
*/
|
|
220
|
+
function logDebug(line: string): void {
|
|
221
|
+
logWriter(line);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Converts unknown error-like values into printable strings.
|
|
226
|
+
*/
|
|
227
|
+
function formatError(error: unknown): string {
|
|
228
|
+
if (error instanceof Error) {
|
|
229
|
+
return error.stack ?? error.message;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return JSON.stringify(error);
|
|
233
|
+
} catch {
|
|
234
|
+
return String(error);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Builds a timestamped log file path in the current working directory.
|
|
240
|
+
*/
|
|
241
|
+
function defaultLogFilePath(): string {
|
|
242
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
243
|
+
return join(cwd(), `gemini-debug-${timestamp}.log`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Creates a line writer that appends to a file when provided.
|
|
248
|
+
*/
|
|
249
|
+
function createLogWriter(filePath?: string): (line: string) => void {
|
|
250
|
+
if (!filePath) {
|
|
251
|
+
return () => {};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const stream = createWriteStream(filePath, { flags: "a" });
|
|
255
|
+
return (line: string) => {
|
|
256
|
+
stream.write(`${line}\n`);
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { resolveProjectContextFromAccessToken } from "./project";
|
|
4
|
+
import type { OAuthAuthDetails } from "./types";
|
|
5
|
+
|
|
6
|
+
const baseAuth: OAuthAuthDetails = {
|
|
7
|
+
type: "oauth",
|
|
8
|
+
refresh: "refresh-token",
|
|
9
|
+
access: "access-token",
|
|
10
|
+
expires: Date.now() + 60_000,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function toUrlString(input: RequestInfo): string {
|
|
14
|
+
if (typeof input === "string") {
|
|
15
|
+
return input;
|
|
16
|
+
}
|
|
17
|
+
if (input instanceof URL) {
|
|
18
|
+
return input.toString();
|
|
19
|
+
}
|
|
20
|
+
return (input as Request).url ?? input.toString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("resolveProjectContextFromAccessToken", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mock.restore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("stores managed project id from loadCodeAssist without onboarding", async () => {
|
|
29
|
+
const fetchMock = mock(async (input: RequestInfo) => {
|
|
30
|
+
const url = toUrlString(input);
|
|
31
|
+
if (url.includes(":loadCodeAssist")) {
|
|
32
|
+
return new Response(
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
currentTier: { id: "free-tier" },
|
|
35
|
+
cloudaicompanionProject: "projects/server-project",
|
|
36
|
+
}),
|
|
37
|
+
{ status: 200 },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
41
|
+
});
|
|
42
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
43
|
+
|
|
44
|
+
const result = await resolveProjectContextFromAccessToken(
|
|
45
|
+
baseAuth,
|
|
46
|
+
baseAuth.access ?? "",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(result.effectiveProjectId).toBe("projects/server-project");
|
|
50
|
+
expect(result.auth.refresh).toContain("projects/server-project");
|
|
51
|
+
expect(fetchMock.mock.calls.length).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("onboards free-tier users without sending a project id", async () => {
|
|
55
|
+
let onboardBody: Record<string, unknown> | undefined;
|
|
56
|
+
const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
|
|
57
|
+
const url = toUrlString(input);
|
|
58
|
+
if (url.includes(":loadCodeAssist")) {
|
|
59
|
+
return new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
allowedTiers: [{ id: "free-tier", isDefault: true }],
|
|
62
|
+
}),
|
|
63
|
+
{ status: 200 },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (url.includes(":onboardUser")) {
|
|
67
|
+
const rawBody = typeof init?.body === "string" ? init.body : "{}";
|
|
68
|
+
onboardBody = JSON.parse(rawBody) as Record<string, unknown>;
|
|
69
|
+
return new Response(
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
done: true,
|
|
72
|
+
response: { cloudaicompanionProject: { id: "managed-project" } },
|
|
73
|
+
}),
|
|
74
|
+
{ status: 200 },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
78
|
+
});
|
|
79
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
80
|
+
|
|
81
|
+
const result = await resolveProjectContextFromAccessToken(
|
|
82
|
+
baseAuth,
|
|
83
|
+
baseAuth.access ?? "",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(result.effectiveProjectId).toBe("managed-project");
|
|
87
|
+
expect(result.auth.refresh).toContain("managed-project");
|
|
88
|
+
expect(onboardBody?.cloudaicompanionProject).toBeUndefined();
|
|
89
|
+
const metadata = onboardBody?.metadata as Record<string, unknown> | undefined;
|
|
90
|
+
expect(metadata?.duetProject).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("throws when a non-free tier requires a project id", async () => {
|
|
94
|
+
const fetchMock = mock(async (input: RequestInfo) => {
|
|
95
|
+
const url = toUrlString(input);
|
|
96
|
+
if (url.includes(":loadCodeAssist")) {
|
|
97
|
+
return new Response(
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
allowedTiers: [{ id: "standard-tier", isDefault: true }],
|
|
100
|
+
}),
|
|
101
|
+
{ status: 200 },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
105
|
+
});
|
|
106
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
resolveProjectContextFromAccessToken(baseAuth, baseAuth.access ?? ""),
|
|
110
|
+
).rejects.toThrow("Google Gemini requires a Google Cloud project");
|
|
111
|
+
});
|
|
112
|
+
});
|