claude-glm 1.3.3 → 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.
@@ -5,8 +5,16 @@ import type { AnthropicRequest, ProviderModel } from "./types.js";
5
5
  import { chatOpenAI } from "./providers/openai.js";
6
6
  import { chatOpenRouter } from "./providers/openrouter.js";
7
7
  import { chatGemini } from "./providers/gemini.js";
8
+ import { chatGeminiOAuth } from "./providers/gemini-oauth.js";
8
9
  import { passThrough } from "./providers/anthropic-pass.js";
9
10
  import { preprocessImages } from "./vision-preprocess.js";
11
+ import {
12
+ buildLoginUrl,
13
+ handleOAuthCallback,
14
+ getLoginStatus,
15
+ googleLogout,
16
+ loginPage,
17
+ } from "./google-auth.js";
10
18
  import { config } from "dotenv";
11
19
  import { join } from "path";
12
20
  import { homedir } from "os";
@@ -32,6 +40,76 @@ fastify.get("/_status", async () => {
32
40
  return active ?? { provider: "glm", model: "glm-5" };
33
41
  });
34
42
 
43
+ // ── Google OAuth endpoints ─────────────────────────────────────────────
44
+
45
+ // Landing page with sign-in button
46
+ fastify.get("/google/login", async (_req, reply) => {
47
+ reply.type("text/html").send(loginPage(PORT));
48
+ });
49
+
50
+ // Start OAuth flow (redirects to Google)
51
+ fastify.get("/google/login/start", async (_req, reply) => {
52
+ const authUrl = buildLoginUrl(PORT);
53
+ reply.redirect(authUrl);
54
+ });
55
+
56
+ // OAuth callback (receives auth code from Google)
57
+ fastify.get("/google/callback", async (req, reply) => {
58
+ const query = req.query as Record<string, string>;
59
+ const { code, state, error } = query;
60
+
61
+ if (error) {
62
+ return reply.type("text/html").code(400).send(
63
+ `<html><body style="font-family:system-ui;text-align:center;padding:60px;background:#0f172a;color:#e2e8f0;">
64
+ <h1 style="color:#f87171;">Login Failed</h1><p>${error}</p>
65
+ <a href="/google/login" style="color:#60a5fa;">Try again</a>
66
+ </body></html>`
67
+ );
68
+ }
69
+
70
+ if (!code || !state) {
71
+ return reply.type("text/html").code(400).send(
72
+ `<html><body style="font-family:system-ui;text-align:center;padding:60px;background:#0f172a;color:#e2e8f0;">
73
+ <h1 style="color:#f87171;">Missing Parameters</h1><p>No authorization code received.</p>
74
+ <a href="/google/login" style="color:#60a5fa;">Try again</a>
75
+ </body></html>`
76
+ );
77
+ }
78
+
79
+ try {
80
+ const tokens = await handleOAuthCallback(code, state);
81
+ reply.type("text/html").send(
82
+ `<html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0f172a;">
83
+ <div style="text-align:center;color:#e2e8f0;max-width:500px;">
84
+ <div style="font-size:48px;">&#10003;</div>
85
+ <h1 style="color:#4ade80;">Authenticated Successfully</h1>
86
+ <p>Logged in as: <strong>${tokens.email || "unknown"}</strong></p>
87
+ ${tokens.project_id ? `<p>Code Assist Project: <code style="background:#1e293b;padding:2px 8px;border-radius:4px;">${tokens.project_id}</code></p>` : `<p style="color:#94a3b8;">Using standard Generative Language API</p>`}
88
+ <p style="color:#64748b;margin-top:24px;">You can close this window.<br>Use <code style="background:#1e293b;padding:2px 8px;border-radius:4px;">go:gemini-3.1-pro-preview</code> as your model in Claude Code.</p>
89
+ </div>
90
+ </body></html>`
91
+ );
92
+ } catch (e: any) {
93
+ reply.type("text/html").code(500).send(
94
+ `<html><body style="font-family:system-ui;text-align:center;padding:60px;background:#0f172a;color:#e2e8f0;">
95
+ <h1 style="color:#f87171;">Login Failed</h1><p>${e.message}</p>
96
+ <a href="/google/login" style="color:#60a5fa;">Try again</a>
97
+ </body></html>`
98
+ );
99
+ }
100
+ });
101
+
102
+ // Login status
103
+ fastify.get("/google/status", async () => {
104
+ return getLoginStatus();
105
+ });
106
+
107
+ // Logout
108
+ fastify.post("/google/logout", async () => {
109
+ await googleLogout();
110
+ return { ok: true, message: "Logged out of Google" };
111
+ });
112
+
35
113
  // Main messages endpoint - routes by model prefix
36
114
  fastify.post("/v1/messages", async (req, res) => {
37
115
  try {
@@ -81,6 +159,15 @@ fastify.post("/v1/messages", async (req, res) => {
81
159
  return chatOpenRouter(res, body, model, key);
82
160
  }
83
161
 
162
+ if (provider === "gemini-oauth") {
163
+ res.raw.setHeader("Content-Type", "text/event-stream");
164
+ res.raw.setHeader("Cache-Control", "no-cache, no-transform");
165
+ res.raw.setHeader("Connection", "keep-alive");
166
+ // @ts-ignore
167
+ res.raw.flushHeaders?.();
168
+ return chatGeminiOAuth(res, body, model);
169
+ }
170
+
84
171
  if (provider === "gemini") {
85
172
  const key = process.env.GEMINI_API_KEY;
86
173
  if (!key) {
@@ -142,7 +229,18 @@ fastify.post("/v1/messages", async (req, res) => {
142
229
  });
143
230
  } catch (e: any) {
144
231
  const status = e?.statusCode ?? 500;
145
- return res.code(status).send({ error: e?.message || "proxy error" });
232
+ const msg = e?.message || "proxy error";
233
+ console.error(`[ccx] ERROR: ${msg}`);
234
+
235
+ // If SSE headers already sent, we can't send a JSON error - write error as SSE event
236
+ if (res.raw.headersSent) {
237
+ try {
238
+ res.raw.write(`event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: msg } })}\n\n`);
239
+ res.raw.end();
240
+ } catch { /* stream already closed */ }
241
+ return;
242
+ }
243
+ return res.code(status).send({ error: msg });
146
244
  }
147
245
  });
148
246
 
@@ -155,9 +253,17 @@ function apiError(status: number, message: string) {
155
253
 
156
254
  fastify
157
255
  .listen({ port: PORT, host: "127.0.0.1" })
158
- .then(() => {
256
+ .then(async () => {
159
257
  console.log(`[ccx] Proxy listening on http://127.0.0.1:${PORT}`);
160
258
  console.log(`[ccx] Configure API keys in: ${envPath}`);
259
+
260
+ // Show Google login status
261
+ const gStatus = await getLoginStatus();
262
+ if (gStatus.loggedIn) {
263
+ console.log(`[ccx] Google: logged in as ${gStatus.email || "unknown"} (${gStatus.mode})`);
264
+ } else {
265
+ console.log(`[ccx] Google: not logged in. Visit http://127.0.0.1:${PORT}/google/login to authenticate`);
266
+ }
161
267
  })
162
268
  .catch((err) => {
163
269
  console.error("[ccx] Failed to start proxy:", err.message);
@@ -0,0 +1,602 @@
1
+ // Google OAuth 2.0 authentication for Gemini via Code Assist API
2
+ // Uses the same client credentials as the official Gemini CLI
3
+ // (safe to include - this is an "installed application" per Google's OAuth2 docs)
4
+
5
+ import * as http from "http";
6
+ import * as crypto from "crypto";
7
+ import { readFile, writeFile, mkdir } from "fs/promises";
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+ import { execSync } from "child_process";
11
+
12
+ // ── OAuth constants (from official Gemini CLI) ─────────────────────────
13
+
14
+ const OAUTH_CLIENT_ID =
15
+ "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
16
+ const OAUTH_CLIENT_SECRET = "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";
17
+
18
+ const OAUTH_SCOPES = [
19
+ "https://www.googleapis.com/auth/cloud-platform",
20
+ "https://www.googleapis.com/auth/userinfo.email",
21
+ "https://www.googleapis.com/auth/userinfo.profile",
22
+ ];
23
+
24
+ const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
25
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
26
+ const USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo";
27
+
28
+ // Code Assist API for Pro subscribers
29
+ const CA_ENDPOINT = "https://cloudcode-pa.googleapis.com";
30
+ const CA_VERSION = "v1internal";
31
+
32
+ // ── Storage ────────────────────────────────────────────────────────────
33
+
34
+ const PROXY_DIR = join(homedir(), ".claude-proxy");
35
+ const AUTH_FILE = join(PROXY_DIR, "google-oauth.json");
36
+
37
+ export interface GoogleTokens {
38
+ access_token: string;
39
+ refresh_token: string;
40
+ expires_at: number; // timestamp in ms
41
+ email?: string;
42
+ project_id?: string;
43
+ }
44
+
45
+ export async function loadTokens(): Promise<GoogleTokens | null> {
46
+ try {
47
+ const data = await readFile(AUTH_FILE, "utf-8");
48
+ const parsed = JSON.parse(data);
49
+ if (!parsed.access_token || !parsed.refresh_token) return null;
50
+ return parsed as GoogleTokens;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ async function saveTokens(tokens: GoogleTokens): Promise<void> {
57
+ await mkdir(PROXY_DIR, { recursive: true });
58
+ await writeFile(AUTH_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
59
+ }
60
+
61
+ // ── PKCE helpers ───────────────────────────────────────────────────────
62
+
63
+ function generatePKCE() {
64
+ const verifier = crypto.randomBytes(32).toString("base64url");
65
+ const challenge = crypto
66
+ .createHash("sha256")
67
+ .update(verifier)
68
+ .digest("base64url");
69
+ return { verifier, challenge };
70
+ }
71
+
72
+ // ── Token refresh ──────────────────────────────────────────────────────
73
+
74
+ async function refreshAccessToken(
75
+ refreshToken: string
76
+ ): Promise<{ access_token: string; expires_in: number }> {
77
+ const resp = await fetch(TOKEN_ENDPOINT, {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
80
+ body: new URLSearchParams({
81
+ client_id: OAUTH_CLIENT_ID,
82
+ client_secret: OAUTH_CLIENT_SECRET,
83
+ refresh_token: refreshToken,
84
+ grant_type: "refresh_token",
85
+ }),
86
+ });
87
+
88
+ if (!resp.ok) {
89
+ const text = await resp.text();
90
+ throw new Error(`Token refresh failed (${resp.status}): ${text}`);
91
+ }
92
+
93
+ return (await resp.json()) as { access_token: string; expires_in: number };
94
+ }
95
+
96
+ /** Get a valid access token, auto-refreshing if needed */
97
+ export async function getAccessToken(): Promise<string> {
98
+ const tokens = await loadTokens();
99
+ if (!tokens) {
100
+ throw new Error(
101
+ "Not logged in to Google. Visit http://127.0.0.1:" +
102
+ (process.env.CLAUDE_PROXY_PORT || "17870") +
103
+ "/google/login to authenticate."
104
+ );
105
+ }
106
+
107
+ // Refresh if expired or within 5-minute buffer
108
+ if (Date.now() > tokens.expires_at - 5 * 60 * 1000) {
109
+ console.log("[gemini-oauth] Access token expired, refreshing...");
110
+ try {
111
+ const refreshed = await refreshAccessToken(tokens.refresh_token);
112
+ tokens.access_token = refreshed.access_token;
113
+ tokens.expires_at = Date.now() + refreshed.expires_in * 1000;
114
+ await saveTokens(tokens);
115
+ console.log("[gemini-oauth] Token refreshed successfully");
116
+ } catch (e: any) {
117
+ throw new Error(
118
+ `Token refresh failed: ${e.message}. Please re-login at /google/login`
119
+ );
120
+ }
121
+ }
122
+
123
+ return tokens.access_token;
124
+ }
125
+
126
+ // ── User info ──────────────────────────────────────────────────────────
127
+
128
+ async function fetchUserEmail(
129
+ accessToken: string
130
+ ): Promise<string | undefined> {
131
+ try {
132
+ const resp = await fetch(USERINFO_ENDPOINT, {
133
+ headers: { Authorization: `Bearer ${accessToken}` },
134
+ });
135
+ if (resp.ok) {
136
+ const data = (await resp.json()) as any;
137
+ return data.email;
138
+ }
139
+ } catch {}
140
+ return undefined;
141
+ }
142
+
143
+ // ── Code Assist project setup ──────────────────────────────────────────
144
+
145
+ async function setupCodeAssist(
146
+ accessToken: string
147
+ ): Promise<string | undefined> {
148
+ try {
149
+ console.log("[gemini-oauth] Setting up Code Assist project...");
150
+
151
+ const resp = await fetch(`${CA_ENDPOINT}/${CA_VERSION}:loadCodeAssist`, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ Authorization: `Bearer ${accessToken}`,
156
+ },
157
+ body: JSON.stringify({
158
+ metadata: {
159
+ ideType: "IDE_UNSPECIFIED",
160
+ platform: "PLATFORM_UNSPECIFIED",
161
+ pluginType: "GEMINI",
162
+ },
163
+ }),
164
+ });
165
+
166
+ if (!resp.ok) {
167
+ console.warn(
168
+ `[gemini-oauth] Code Assist setup returned ${resp.status} - will use standard API`
169
+ );
170
+ return undefined;
171
+ }
172
+
173
+ const data = (await resp.json()) as any;
174
+
175
+ // Already set up
176
+ if (data.cloudaicompanionProject) {
177
+ console.log(
178
+ `[gemini-oauth] Code Assist project: ${data.cloudaicompanionProject}`
179
+ );
180
+ if (data.currentTier) {
181
+ console.log(
182
+ `[gemini-oauth] Tier: ${data.currentTier.name || data.currentTier.id}`
183
+ );
184
+ }
185
+ return data.cloudaicompanionProject;
186
+ }
187
+
188
+ // Need to onboard
189
+ const tier =
190
+ data.paidTier ||
191
+ data.currentTier ||
192
+ data.availableTiers?.find(
193
+ (t: any) => t.id === "STANDARD" || t.id === "FREE"
194
+ ) ||
195
+ data.availableTiers?.[0];
196
+
197
+ if (!tier) {
198
+ console.warn("[gemini-oauth] No tier available for onboarding");
199
+ return undefined;
200
+ }
201
+
202
+ console.log(
203
+ `[gemini-oauth] Onboarding to tier: ${tier.name || tier.id}...`
204
+ );
205
+ const onboardResp = await fetch(
206
+ `${CA_ENDPOINT}/${CA_VERSION}:onboardUser`,
207
+ {
208
+ method: "POST",
209
+ headers: {
210
+ "Content-Type": "application/json",
211
+ Authorization: `Bearer ${accessToken}`,
212
+ },
213
+ body: JSON.stringify({
214
+ tierId: tier.id,
215
+ metadata: {
216
+ ideType: "IDE_UNSPECIFIED",
217
+ platform: "PLATFORM_UNSPECIFIED",
218
+ pluginType: "GEMINI",
219
+ },
220
+ }),
221
+ }
222
+ );
223
+
224
+ if (!onboardResp.ok) {
225
+ console.warn(
226
+ `[gemini-oauth] Onboard failed (${onboardResp.status}) - will use standard API`
227
+ );
228
+ return undefined;
229
+ }
230
+
231
+ const onboardData = (await onboardResp.json()) as any;
232
+
233
+ // Handle long-running operation
234
+ if (!onboardData.done && onboardData.name) {
235
+ console.log("[gemini-oauth] Waiting for onboarding to complete...");
236
+ const opName = onboardData.name;
237
+ let attempts = 0;
238
+ while (attempts < 12) {
239
+ await new Promise((r) => setTimeout(r, 5000));
240
+ const opResp = await fetch(
241
+ `${CA_ENDPOINT}/${CA_VERSION}/operations/${opName}`,
242
+ {
243
+ headers: { Authorization: `Bearer ${accessToken}` },
244
+ }
245
+ );
246
+ if (!opResp.ok) break;
247
+ const opData = (await opResp.json()) as any;
248
+ if (opData.done) {
249
+ const projectId = opData.response?.cloudaicompanionProject?.id;
250
+ if (projectId) {
251
+ console.log(
252
+ `[gemini-oauth] Onboarded to project: ${projectId}`
253
+ );
254
+ return projectId;
255
+ }
256
+ break;
257
+ }
258
+ attempts++;
259
+ }
260
+ }
261
+
262
+ const projectId = onboardData.response?.cloudaicompanionProject?.id;
263
+ if (projectId) {
264
+ console.log(`[gemini-oauth] Onboarded to project: ${projectId}`);
265
+ return projectId;
266
+ }
267
+
268
+ return undefined;
269
+ } catch (e: any) {
270
+ console.warn("[gemini-oauth] Code Assist setup error:", e.message);
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ // ── Browser opener ─────────────────────────────────────────────────────
276
+
277
+ function openBrowser(url: string) {
278
+ try {
279
+ if (process.platform === "darwin") {
280
+ execSync(`open "${url}"`);
281
+ } else if (process.platform === "win32") {
282
+ execSync(`start "" "${url}"`);
283
+ } else {
284
+ execSync(`xdg-open "${url}"`);
285
+ }
286
+ } catch {
287
+ // Browser open failed - URL is shown in console anyway
288
+ }
289
+ }
290
+
291
+ // ── Standalone login (browser-based OAuth from terminal) ───────────────
292
+
293
+ export async function googleLoginStandalone(): Promise<GoogleTokens> {
294
+ // Find an available port for callback
295
+ const port = await new Promise<number>((resolve, reject) => {
296
+ const srv = http.createServer();
297
+ srv.listen(0, "127.0.0.1", () => {
298
+ const addr = srv.address();
299
+ if (addr && typeof addr === "object") {
300
+ const p = addr.port;
301
+ srv.close(() => resolve(p));
302
+ } else {
303
+ reject(new Error("Could not find available port"));
304
+ }
305
+ });
306
+ });
307
+
308
+ const redirectUri = `http://127.0.0.1:${port}/oauth2callback`;
309
+ const state = crypto.randomBytes(32).toString("hex");
310
+ const { verifier, challenge } = generatePKCE();
311
+
312
+ const params = new URLSearchParams({
313
+ client_id: OAUTH_CLIENT_ID,
314
+ redirect_uri: redirectUri,
315
+ response_type: "code",
316
+ scope: OAUTH_SCOPES.join(" "),
317
+ access_type: "offline",
318
+ prompt: "consent",
319
+ state,
320
+ code_challenge_method: "S256",
321
+ code_challenge: challenge,
322
+ });
323
+
324
+ const authUrl = `${AUTH_ENDPOINT}?${params}`;
325
+
326
+ return new Promise((resolve, reject) => {
327
+ const timeout = setTimeout(() => {
328
+ server.close();
329
+ reject(new Error("Login timed out after 5 minutes"));
330
+ }, 5 * 60 * 1000);
331
+
332
+ const server = http.createServer(async (req, res) => {
333
+ if (!req.url?.startsWith("/oauth2callback")) {
334
+ res.writeHead(404);
335
+ res.end("Not found");
336
+ return;
337
+ }
338
+
339
+ try {
340
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
341
+ const error = url.searchParams.get("error");
342
+ const code = url.searchParams.get("code");
343
+ const returnedState = url.searchParams.get("state");
344
+
345
+ if (error) {
346
+ res.writeHead(400, { "Content-Type": "text/html" });
347
+ res.end(errorPage(`OAuth error: ${error}`));
348
+ clearTimeout(timeout);
349
+ server.close();
350
+ reject(new Error(`OAuth error: ${error}`));
351
+ return;
352
+ }
353
+
354
+ if (returnedState !== state) {
355
+ res.writeHead(400, { "Content-Type": "text/html" });
356
+ res.end(errorPage("State mismatch - possible CSRF attack"));
357
+ clearTimeout(timeout);
358
+ server.close();
359
+ reject(new Error("OAuth state mismatch"));
360
+ return;
361
+ }
362
+
363
+ if (!code) {
364
+ res.writeHead(400, { "Content-Type": "text/html" });
365
+ res.end(errorPage("No authorization code received"));
366
+ clearTimeout(timeout);
367
+ server.close();
368
+ reject(new Error("No authorization code"));
369
+ return;
370
+ }
371
+
372
+ // Exchange code for tokens
373
+ const tokenResp = await fetch(TOKEN_ENDPOINT, {
374
+ method: "POST",
375
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
376
+ body: new URLSearchParams({
377
+ client_id: OAUTH_CLIENT_ID,
378
+ client_secret: OAUTH_CLIENT_SECRET,
379
+ code,
380
+ code_verifier: verifier,
381
+ grant_type: "authorization_code",
382
+ redirect_uri: redirectUri,
383
+ }),
384
+ });
385
+
386
+ if (!tokenResp.ok) {
387
+ const text = await tokenResp.text();
388
+ res.writeHead(500, { "Content-Type": "text/html" });
389
+ res.end(errorPage(`Token exchange failed: ${text}`));
390
+ clearTimeout(timeout);
391
+ server.close();
392
+ reject(new Error(`Token exchange failed: ${text}`));
393
+ return;
394
+ }
395
+
396
+ const tokenData = (await tokenResp.json()) as any;
397
+
398
+ const tokens: GoogleTokens = {
399
+ access_token: tokenData.access_token,
400
+ refresh_token: tokenData.refresh_token,
401
+ expires_at: Date.now() + tokenData.expires_in * 1000,
402
+ };
403
+
404
+ // Fetch user info
405
+ tokens.email = await fetchUserEmail(tokens.access_token);
406
+
407
+ // Setup Code Assist
408
+ tokens.project_id = await setupCodeAssist(tokens.access_token);
409
+
410
+ // Save
411
+ await saveTokens(tokens);
412
+
413
+ res.writeHead(200, { "Content-Type": "text/html" });
414
+ res.end(successPage(tokens.email, tokens.project_id));
415
+
416
+ clearTimeout(timeout);
417
+ server.close();
418
+ resolve(tokens);
419
+ } catch (e: any) {
420
+ res.writeHead(500, { "Content-Type": "text/html" });
421
+ res.end(errorPage(e.message));
422
+ clearTimeout(timeout);
423
+ server.close();
424
+ reject(e);
425
+ }
426
+ });
427
+
428
+ server.listen(port, "127.0.0.1", () => {
429
+ console.log(`\n[gemini-oauth] Opening browser for Google login...`);
430
+ console.log(`[gemini-oauth] If browser doesn't open, visit:`);
431
+ console.log(` ${authUrl}\n`);
432
+ openBrowser(authUrl);
433
+ });
434
+ });
435
+ }
436
+
437
+ // ── Proxy-integrated login (via Fastify routes) ────────────────────────
438
+
439
+ // In-memory pending OAuth state for proxy-integrated login
440
+ let pendingOAuth: {
441
+ state: string;
442
+ verifier: string;
443
+ redirectUri: string;
444
+ } | null = null;
445
+
446
+ /** Build the Google OAuth authorization URL (for proxy-integrated login) */
447
+ export function buildLoginUrl(proxyPort: number): string {
448
+ const redirectUri = `http://127.0.0.1:${proxyPort}/google/callback`;
449
+ const state = crypto.randomBytes(32).toString("hex");
450
+ const { verifier, challenge } = generatePKCE();
451
+
452
+ pendingOAuth = { state, verifier, redirectUri };
453
+
454
+ const params = new URLSearchParams({
455
+ client_id: OAUTH_CLIENT_ID,
456
+ redirect_uri: redirectUri,
457
+ response_type: "code",
458
+ scope: OAUTH_SCOPES.join(" "),
459
+ access_type: "offline",
460
+ prompt: "consent",
461
+ state,
462
+ code_challenge_method: "S256",
463
+ code_challenge: challenge,
464
+ });
465
+
466
+ return `${AUTH_ENDPOINT}?${params}`;
467
+ }
468
+
469
+ /** Handle OAuth callback (for proxy-integrated login) */
470
+ export async function handleOAuthCallback(
471
+ code: string,
472
+ state: string
473
+ ): Promise<GoogleTokens> {
474
+ if (!pendingOAuth) {
475
+ throw new Error("No pending OAuth flow. Visit /google/login first.");
476
+ }
477
+
478
+ if (state !== pendingOAuth.state) {
479
+ pendingOAuth = null;
480
+ throw new Error("OAuth state mismatch - possible CSRF attack.");
481
+ }
482
+
483
+ const { verifier, redirectUri } = pendingOAuth;
484
+ pendingOAuth = null;
485
+
486
+ // Exchange code for tokens
487
+ const tokenResp = await fetch(TOKEN_ENDPOINT, {
488
+ method: "POST",
489
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
490
+ body: new URLSearchParams({
491
+ client_id: OAUTH_CLIENT_ID,
492
+ client_secret: OAUTH_CLIENT_SECRET,
493
+ code,
494
+ code_verifier: verifier,
495
+ grant_type: "authorization_code",
496
+ redirect_uri: redirectUri,
497
+ }),
498
+ });
499
+
500
+ if (!tokenResp.ok) {
501
+ const text = await tokenResp.text();
502
+ throw new Error(`Token exchange failed (${tokenResp.status}): ${text}`);
503
+ }
504
+
505
+ const tokenData = (await tokenResp.json()) as any;
506
+
507
+ const tokens: GoogleTokens = {
508
+ access_token: tokenData.access_token,
509
+ refresh_token: tokenData.refresh_token,
510
+ expires_at: Date.now() + tokenData.expires_in * 1000,
511
+ };
512
+
513
+ // Fetch user info
514
+ tokens.email = await fetchUserEmail(tokens.access_token);
515
+
516
+ // Setup Code Assist
517
+ tokens.project_id = await setupCodeAssist(tokens.access_token);
518
+
519
+ // Save
520
+ await saveTokens(tokens);
521
+
522
+ console.log(
523
+ `[gemini-oauth] Login successful! Email: ${tokens.email || "unknown"}, Project: ${tokens.project_id || "none (using standard API)"}`
524
+ );
525
+
526
+ return tokens;
527
+ }
528
+
529
+ /** Get login status */
530
+ export async function getLoginStatus(): Promise<{
531
+ loggedIn: boolean;
532
+ email?: string;
533
+ projectId?: string;
534
+ expiresAt?: number;
535
+ mode?: string;
536
+ }> {
537
+ const tokens = await loadTokens();
538
+ if (!tokens) return { loggedIn: false };
539
+ return {
540
+ loggedIn: true,
541
+ email: tokens.email,
542
+ projectId: tokens.project_id,
543
+ expiresAt: tokens.expires_at,
544
+ mode: tokens.project_id ? "code-assist" : "standard-api",
545
+ };
546
+ }
547
+
548
+ /** Logout */
549
+ export async function googleLogout(): Promise<void> {
550
+ try {
551
+ const { unlink } = await import("fs/promises");
552
+ await unlink(AUTH_FILE);
553
+ console.log("[gemini-oauth] Logged out, credentials removed.");
554
+ } catch {
555
+ console.log("[gemini-oauth] Already logged out.");
556
+ }
557
+ }
558
+
559
+ // ── HTML pages ─────────────────────────────────────────────────────────
560
+
561
+ function successPage(email?: string, projectId?: string): string {
562
+ return `<!DOCTYPE html>
563
+ <html><head><title>Login Successful</title></head>
564
+ <body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0f172a;">
565
+ <div style="text-align:center;color:#e2e8f0;max-width:500px;">
566
+ <div style="font-size:48px;margin-bottom:16px;">&#10003;</div>
567
+ <h1 style="color:#4ade80;margin:0 0 12px;">Authenticated Successfully</h1>
568
+ <p>Logged in as: <strong>${email || "unknown"}</strong></p>
569
+ ${projectId ? `<p>Code Assist Project: <code style="background:#1e293b;padding:2px 8px;border-radius:4px;">${projectId}</code></p>` : `<p style="color:#94a3b8;">No Code Assist project (using standard API)</p>`}
570
+ <p style="color:#64748b;margin-top:24px;">You can close this window and return to your terminal.<br>Use <code style="background:#1e293b;padding:2px 8px;border-radius:4px;">go:gemini-3.1-pro-preview</code> as your model.</p>
571
+ </div>
572
+ </body></html>`;
573
+ }
574
+
575
+ function errorPage(message: string): string {
576
+ return `<!DOCTYPE html>
577
+ <html><head><title>Login Failed</title></head>
578
+ <body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0f172a;">
579
+ <div style="text-align:center;color:#e2e8f0;max-width:500px;">
580
+ <div style="font-size:48px;margin-bottom:16px;">&#10007;</div>
581
+ <h1 style="color:#f87171;margin:0 0 12px;">Authentication Failed</h1>
582
+ <p>${message}</p>
583
+ <p style="color:#64748b;margin-top:24px;">Please try again at <a href="/google/login" style="color:#60a5fa;">/google/login</a></p>
584
+ </div>
585
+ </body></html>`;
586
+ }
587
+
588
+ export function loginPage(_proxyPort: number): string {
589
+ return `<!DOCTYPE html>
590
+ <html><head><title>Google Login</title></head>
591
+ <body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0f172a;">
592
+ <div style="text-align:center;color:#e2e8f0;max-width:500px;">
593
+ <h1 style="margin:0 0 12px;">Google Login for Gemini</h1>
594
+ <p style="color:#94a3b8;">Click the button below to authenticate with your Google account.</p>
595
+ <p style="color:#94a3b8;">This uses the same OAuth flow as the official Gemini CLI.</p>
596
+ <a href="/google/login/start" style="display:inline-block;margin-top:24px;padding:12px 32px;background:#4285f4;color:white;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">
597
+ Sign in with Google
598
+ </a>
599
+ <p style="color:#475569;margin-top:32px;font-size:14px;">Scopes: cloud-platform, userinfo.email, userinfo.profile</p>
600
+ </div>
601
+ </body></html>`;
602
+ }
package/adapters/map.ts CHANGED
@@ -10,6 +10,7 @@ const PROVIDER_PREFIXES: ProviderKey[] = [
10
10
  "openai",
11
11
  "openrouter",
12
12
  "gemini",
13
+ "gemini-oauth",
13
14
  "glm",
14
15
  "anthropic",
15
16
  ];
@@ -32,6 +33,15 @@ const MODEL_SHORTCUTS: Record<string, string> = {
32
33
  opus: "anthropic:claude-opus-4-5-20251101",
33
34
  sonnet: "anthropic:claude-sonnet-4-5-20250929",
34
35
  haiku: "anthropic:claude-haiku-4-5-20251001",
36
+ // Gemini OAuth shortcuts (Google account login)
37
+ go: "gemini-oauth:gemini-3-pro-preview",
38
+ gp: "gemini-oauth:gemini-3-pro-preview",
39
+ g3: "gemini-oauth:gemini-3-pro-preview",
40
+ "gemini-pro": "gemini-oauth:gemini-3-pro-preview",
41
+ gf: "gemini-oauth:gemini-3-flash-preview",
42
+ "gemini-flash": "gemini-oauth:gemini-3-flash-preview",
43
+ "g25p": "gemini-oauth:gemini-2.5-pro",
44
+ "g25f": "gemini-oauth:gemini-2.5-flash",
35
45
  // Add more shortcuts as needed
36
46
  };
37
47
 
@@ -91,8 +101,8 @@ export function warnIfTools(
91
101
  provider: ProviderKey,
92
102
  ): void {
93
103
  if (req.tools && req.tools.length > 0) {
94
- // Only GLM and Anthropic support tools natively
95
- if (provider !== "glm" && provider !== "anthropic") {
104
+ // GLM, Anthropic, and Gemini OAuth support tools natively
105
+ if (provider !== "glm" && provider !== "anthropic" && provider !== "gemini-oauth") {
96
106
  console.warn(
97
107
  `[proxy] Warning: ${provider} may not fully support Anthropic-style tools. Passing through anyway.`,
98
108
  );
@@ -0,0 +1,470 @@
1
+ // Gemini OAuth adapter - uses Google OAuth tokens with Code Assist API or standard Generative Language API
2
+ // Supports full tool/function calling and streaming
3
+
4
+ import { FastifyReply } from "fastify";
5
+ import { createParser } from "eventsource-parser";
6
+ import { sendEvent } from "../sse.js";
7
+ import { getAccessToken, loadTokens } from "../google-auth.js";
8
+ import type {
9
+ AnthropicRequest,
10
+ AnthropicMessage,
11
+ AnthropicTool,
12
+ AnthropicContentBlock,
13
+ } from "../types.js";
14
+ import * as crypto from "crypto";
15
+
16
+ // ── Endpoints ──────────────────────────────────────────────────────────
17
+
18
+ const CA_ENDPOINT = "https://cloudcode-pa.googleapis.com";
19
+ const CA_VERSION = "v1internal";
20
+ const GL_ENDPOINT =
21
+ process.env.GEMINI_BASE_URL ||
22
+ "https://generativelanguage.googleapis.com/v1beta";
23
+
24
+ // ── Format converters: Anthropic → Gemini ──────────────────────────────
25
+
26
+ /** Find tool name by tool_use_id in message history */
27
+ function findToolName(messages: AnthropicMessage[], toolUseId: string): string {
28
+ for (const m of messages) {
29
+ if (typeof m.content === "string") continue;
30
+ for (const block of m.content as AnthropicContentBlock[]) {
31
+ if (block.type === "tool_use" && block.id === toolUseId) {
32
+ return block.name;
33
+ }
34
+ }
35
+ }
36
+ return "unknown_tool";
37
+ }
38
+
39
+ /** Convert Anthropic content blocks to Gemini parts */
40
+ function toGeminiParts(
41
+ content: AnthropicMessage["content"],
42
+ allMessages: AnthropicMessage[]
43
+ ): any[] {
44
+ if (typeof content === "string") {
45
+ return content ? [{ text: content }] : [{ text: " " }];
46
+ }
47
+
48
+ const parts: any[] = [];
49
+ for (const block of content as AnthropicContentBlock[]) {
50
+ if (block.type === "text") {
51
+ if (block.text) parts.push({ text: block.text });
52
+ } else if (block.type === "image") {
53
+ parts.push({
54
+ inlineData: {
55
+ mimeType: block.source.media_type,
56
+ data: block.source.data,
57
+ },
58
+ });
59
+ } else if (block.type === "tool_use") {
60
+ parts.push({
61
+ functionCall: {
62
+ name: block.name,
63
+ args:
64
+ typeof block.input === "string"
65
+ ? JSON.parse(block.input)
66
+ : block.input,
67
+ },
68
+ thoughtSignature: "skip_thought_signature_validator",
69
+ });
70
+ } else if (block.type === "tool_result") {
71
+ const functionName = findToolName(allMessages, block.tool_use_id);
72
+ const resultContent =
73
+ typeof block.content === "string"
74
+ ? block.content
75
+ : JSON.stringify(block.content);
76
+ parts.push({
77
+ functionResponse: {
78
+ name: functionName,
79
+ response: { content: resultContent },
80
+ },
81
+ });
82
+ }
83
+ }
84
+
85
+ return parts.length > 0 ? parts : [{ text: " " }];
86
+ }
87
+
88
+ /** Convert Anthropic messages to Gemini contents array */
89
+ function toGeminiContents(messages: AnthropicMessage[]) {
90
+ // Gemini requires alternating user/model roles
91
+ // Merge consecutive same-role messages
92
+ const merged: { role: string; parts: any[] }[] = [];
93
+
94
+ for (const m of messages) {
95
+ const role = m.role === "assistant" ? "model" : "user";
96
+ const parts = toGeminiParts(m.content, messages);
97
+
98
+ if (merged.length > 0 && merged[merged.length - 1].role === role) {
99
+ // Merge into previous message of same role
100
+ merged[merged.length - 1].parts.push(...parts);
101
+ } else {
102
+ merged.push({ role, parts });
103
+ }
104
+ }
105
+
106
+ return merged;
107
+ }
108
+
109
+ /** Whitelist of fields Gemini actually accepts in function declaration schemas */
110
+ const GEMINI_ALLOWED = new Set([
111
+ "type", "properties", "required", "description",
112
+ "enum", "items", "format", "nullable", "title",
113
+ "anyOf", "$ref", "$defs", "$id", "$anchor",
114
+ "minimum", "maximum", "minItems", "maxItems",
115
+ "prefixItems", "additionalProperties", "propertyOrdering",
116
+ ]);
117
+
118
+ /** Recursively strip JSON Schema fields Gemini doesn't support.
119
+ * `isPropertyMap` = true when we're inside a "properties" object,
120
+ * where keys are user-defined property names (not schema keywords). */
121
+ function sanitizeSchema(obj: any, isPropertyMap = false): any {
122
+ if (obj === null || obj === undefined || typeof obj !== "object") return obj;
123
+ if (Array.isArray(obj)) return obj.map((v) => sanitizeSchema(v, false));
124
+
125
+ const clean: any = {};
126
+ for (const [key, val] of Object.entries(obj)) {
127
+ if (!isPropertyMap && !GEMINI_ALLOWED.has(key)) continue;
128
+ // When key is "properties", its value is a map of name→schema
129
+ clean[key] = sanitizeSchema(val, key === "properties");
130
+ }
131
+ return clean;
132
+ }
133
+
134
+ /** Convert Anthropic tools to Gemini function declarations */
135
+ function toGeminiTools(tools: AnthropicTool[]) {
136
+ return [
137
+ {
138
+ functionDeclarations: tools.map((t) => ({
139
+ name: t.name,
140
+ description: t.description || "",
141
+ parameters: sanitizeSchema(t.input_schema) || { type: "object", properties: {} },
142
+ })),
143
+ },
144
+ ];
145
+ }
146
+
147
+ // ── Main adapter ────────────────────────────────────────────────────────
148
+
149
+ export async function chatGeminiOAuth(
150
+ res: FastifyReply,
151
+ body: AnthropicRequest,
152
+ model: string
153
+ ) {
154
+ // Helper to send error as SSE (since headers are already flushed by the gateway)
155
+ function sendSSEError(msg: string) {
156
+ try {
157
+ const id = `msg_${Date.now()}`;
158
+ res.raw.write(`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } })}\n\n`);
159
+ res.raw.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`);
160
+ res.raw.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: `[Gemini OAuth Error] ${msg}` } })}\n\n`);
161
+ res.raw.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`);
162
+ res.raw.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 0 } })}\n\n`);
163
+ res.raw.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
164
+ } catch { /* stream closed */ }
165
+ try { res.raw.end(); } catch {}
166
+ }
167
+
168
+ try {
169
+ return await _chatGeminiOAuthInner(res, body, model);
170
+ } catch (e: any) {
171
+ console.error(`[gemini-oauth] ERROR: ${e.message}`);
172
+ sendSSEError(e.message);
173
+ }
174
+ }
175
+
176
+ async function _chatGeminiOAuthInner(
177
+ res: FastifyReply,
178
+ body: AnthropicRequest,
179
+ model: string
180
+ ) {
181
+ // Get access token (auto-refreshes if expired)
182
+ const accessToken = await getAccessToken();
183
+ const tokens = await loadTokens();
184
+ const projectId = tokens?.project_id;
185
+
186
+ // Build Gemini request
187
+ const contents = toGeminiContents(body.messages);
188
+
189
+ // Generation config matching Gemini CLI defaults for chat
190
+ const generationConfig: any = {
191
+ temperature: body.temperature ?? 1,
192
+ topP: 0.95,
193
+ topK: 64,
194
+ thinkingConfig: { includeThoughts: true },
195
+ };
196
+ if (body.max_tokens !== undefined) {
197
+ generationConfig.maxOutputTokens = body.max_tokens;
198
+ }
199
+
200
+ // Tools
201
+ const tools =
202
+ body.tools && body.tools.length > 0 ? toGeminiTools(body.tools) : undefined;
203
+
204
+ if (tools) {
205
+ console.log(
206
+ `[gemini-oauth] Sending ${body.tools!.length} tools as Gemini function declarations`
207
+ );
208
+ }
209
+
210
+ let url: string;
211
+ let reqBody: any;
212
+
213
+ if (projectId) {
214
+ // Code Assist API - systemInstruction has a different protobuf schema,
215
+ // so prepend system prompt to first user message instead
216
+ if (body.system) {
217
+ if (contents.length > 0 && contents[0].role === "user") {
218
+ contents[0].parts.unshift({ text: `[System Instructions]\n${body.system}\n[End System Instructions]\n\n` });
219
+ } else {
220
+ contents.unshift({
221
+ role: "user",
222
+ parts: [{ text: `[System Instructions]\n${body.system}\n[End System Instructions]` }],
223
+ });
224
+ }
225
+ }
226
+
227
+ url = `${CA_ENDPOINT}/${CA_VERSION}:streamGenerateContent?alt=sse`;
228
+ reqBody = {
229
+ model,
230
+ project: projectId,
231
+ user_prompt_id: crypto.randomUUID(),
232
+ request: {
233
+ contents,
234
+ generationConfig,
235
+ ...(tools && { tools }),
236
+ },
237
+ };
238
+ console.log(
239
+ `[gemini-oauth] Code Assist API | model="${model}" project="${projectId}"`
240
+ );
241
+ } else {
242
+ // Standard Generative Language API - systemInstruction works natively
243
+ const systemInstruction = body.system
244
+ ? { role: "user", parts: [{ text: body.system }] }
245
+ : undefined;
246
+
247
+ url = `${GL_ENDPOINT}/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
248
+ reqBody = {
249
+ contents,
250
+ ...(systemInstruction && { systemInstruction }),
251
+ generationConfig,
252
+ ...(tools && { tools }),
253
+ };
254
+ console.log(`[gemini-oauth] Standard API | model="${model}"`);
255
+ }
256
+
257
+ // Log outgoing request for debugging
258
+ const reqJson = JSON.stringify(reqBody);
259
+ console.log(`[gemini-oauth] REQUEST URL: ${url}`);
260
+ console.log(`[gemini-oauth] REQUEST BODY (${reqJson.length} bytes): ${reqJson.slice(0, 500)}...`);
261
+
262
+ const resp = await fetch(url, {
263
+ method: "POST",
264
+ headers: {
265
+ "Content-Type": "application/json",
266
+ Authorization: `Bearer ${accessToken}`,
267
+ // Required headers matching Gemini CLI client identity
268
+ "User-Agent": "google-api-nodejs-client/9.15.1",
269
+ "X-Goog-Api-Client": "gl-node/22.17.0",
270
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
271
+ },
272
+ body: reqJson,
273
+ });
274
+
275
+ if (!resp.ok || !resp.body) {
276
+ const text = await safeText(resp);
277
+ console.error(`[gemini-oauth] API error ${resp.status}: ${text}`);
278
+ throw new Error(`Gemini API returned ${resp.status}: ${text.slice(0, 300)}`);
279
+ }
280
+
281
+ // ── Stream response and convert to Anthropic SSE format ──────────────
282
+
283
+ const msgId = `msg_${Date.now()}`;
284
+ let contentIndex = 0;
285
+ let hasStartedMessage = false;
286
+ let hasStartedThinking = false;
287
+ let hasStartedContent = false;
288
+
289
+ // Accumulate function calls from streaming chunks
290
+ const pendingToolCalls: { id: string; name: string; args: any }[] = [];
291
+
292
+ function ensureMessageStarted() {
293
+ if (!hasStartedMessage) {
294
+ hasStartedMessage = true;
295
+ sendEvent(res, "message_start", {
296
+ type: "message_start",
297
+ message: {
298
+ id: msgId,
299
+ type: "message",
300
+ role: "assistant",
301
+ model,
302
+ content: [],
303
+ stop_reason: null,
304
+ stop_sequence: null,
305
+ usage: { input_tokens: 0, output_tokens: 0 },
306
+ },
307
+ });
308
+ }
309
+ }
310
+
311
+ function ensureThinkingBlockStarted() {
312
+ if (!hasStartedThinking) {
313
+ hasStartedThinking = true;
314
+ ensureMessageStarted();
315
+ sendEvent(res, "content_block_start", {
316
+ type: "content_block_start",
317
+ index: contentIndex,
318
+ content_block: { type: "thinking", thinking: "" },
319
+ });
320
+ }
321
+ }
322
+
323
+ function closeThinkingBlock() {
324
+ if (hasStartedThinking) {
325
+ sendEvent(res, "content_block_stop", {
326
+ type: "content_block_stop",
327
+ index: contentIndex,
328
+ });
329
+ contentIndex++;
330
+ hasStartedThinking = false;
331
+ }
332
+ }
333
+
334
+ function ensureContentBlockStarted() {
335
+ if (!hasStartedContent) {
336
+ closeThinkingBlock();
337
+ hasStartedContent = true;
338
+ ensureMessageStarted();
339
+ sendEvent(res, "content_block_start", {
340
+ type: "content_block_start",
341
+ index: contentIndex,
342
+ content_block: { type: "text", text: "" },
343
+ });
344
+ }
345
+ }
346
+
347
+ function closeContentBlock() {
348
+ if (hasStartedContent) {
349
+ sendEvent(res, "content_block_stop", {
350
+ type: "content_block_stop",
351
+ index: contentIndex,
352
+ });
353
+ contentIndex++;
354
+ hasStartedContent = false;
355
+ }
356
+ }
357
+
358
+ const reader = resp.body.getReader();
359
+ const decoder = new TextDecoder();
360
+ const parser = createParser({ onEvent: (event: any) => {
361
+ const data = event.data;
362
+ if (!data) return;
363
+ try {
364
+ const json = JSON.parse(data);
365
+
366
+ // Handle both Code Assist (wrapped) and standard API (unwrapped) responses
367
+ const candidateData = json.response || json;
368
+ const candidate = candidateData?.candidates?.[0];
369
+ if (!candidate?.content?.parts) return;
370
+
371
+ for (const part of candidate.content.parts) {
372
+ // Handle thinking/reasoning (Gemini 2.5+ models)
373
+ if (part.thought === true && part.text) {
374
+ ensureThinkingBlockStarted();
375
+ sendEvent(res, "content_block_delta", {
376
+ type: "content_block_delta",
377
+ index: contentIndex,
378
+ delta: { type: "thinking_delta", thinking: part.text },
379
+ });
380
+ }
381
+ // Handle regular text
382
+ else if (part.text && part.thought !== true) {
383
+ ensureContentBlockStarted();
384
+ sendEvent(res, "content_block_delta", {
385
+ type: "content_block_delta",
386
+ index: contentIndex,
387
+ delta: { type: "text_delta", text: part.text },
388
+ });
389
+ }
390
+
391
+ // Handle function calls
392
+ if (part.functionCall) {
393
+ pendingToolCalls.push({
394
+ id: `toolu_${crypto.randomBytes(12).toString("hex")}`,
395
+ name: part.functionCall.name,
396
+ args: part.functionCall.args || {},
397
+ });
398
+ }
399
+ }
400
+ } catch {
401
+ // ignore parse errors in SSE stream
402
+ }
403
+ }});
404
+
405
+ while (true) {
406
+ const { value, done } = await reader.read();
407
+ if (done) break;
408
+ parser.feed(decoder.decode(value, { stream: true }));
409
+ }
410
+
411
+ // ── Finalize: close blocks and emit tool_use if any ──────────────────
412
+
413
+ ensureMessageStarted();
414
+ closeThinkingBlock();
415
+ closeContentBlock();
416
+
417
+ // Emit tool_use content blocks
418
+ if (pendingToolCalls.length > 0) {
419
+ for (const tc of pendingToolCalls) {
420
+ sendEvent(res, "content_block_start", {
421
+ type: "content_block_start",
422
+ index: contentIndex,
423
+ content_block: {
424
+ type: "tool_use",
425
+ id: tc.id,
426
+ name: tc.name,
427
+ input: {},
428
+ },
429
+ });
430
+
431
+ sendEvent(res, "content_block_delta", {
432
+ type: "content_block_delta",
433
+ index: contentIndex,
434
+ delta: {
435
+ type: "input_json_delta",
436
+ partial_json: JSON.stringify(tc.args),
437
+ },
438
+ });
439
+
440
+ sendEvent(res, "content_block_stop", {
441
+ type: "content_block_stop",
442
+ index: contentIndex,
443
+ });
444
+
445
+ contentIndex++;
446
+ }
447
+ }
448
+
449
+ // Stop reason
450
+ const stopReason = pendingToolCalls.length > 0 ? "tool_use" : "end_turn";
451
+
452
+ sendEvent(res, "message_delta", {
453
+ type: "message_delta",
454
+ delta: { stop_reason: stopReason, stop_sequence: null },
455
+ usage: { output_tokens: 0 },
456
+ });
457
+ sendEvent(res, "message_stop", { type: "message_stop" });
458
+
459
+ res.raw.end();
460
+ }
461
+
462
+ // ── Helpers ────────────────────────────────────────────────────────────
463
+
464
+ async function safeText(resp: Response) {
465
+ try {
466
+ return await resp.text();
467
+ } catch {
468
+ return "<no-body>";
469
+ }
470
+ }
@@ -47,8 +47,7 @@ export async function chatGemini(
47
47
 
48
48
  const reader = resp.body.getReader();
49
49
  const decoder = new TextDecoder();
50
- const parser = createParser((event) => {
51
- if (event.type !== "event") return;
50
+ const parser = createParser({ onEvent: (event: any) => {
52
51
  const data = event.data;
53
52
  if (!data) return;
54
53
  try {
@@ -62,7 +61,7 @@ export async function chatGemini(
62
61
  } catch {
63
62
  // ignore parse errors
64
63
  }
65
- });
64
+ }});
66
65
 
67
66
  while (true) {
68
67
  const { value, done } = await reader.read();
@@ -52,8 +52,7 @@ export async function chatOpenAI(
52
52
 
53
53
  const reader = resp.body.getReader();
54
54
  const decoder = new TextDecoder();
55
- const parser = createParser((event) => {
56
- if (event.type !== "event") return;
55
+ const parser = createParser({ onEvent: (event: any) => {
57
56
  const data = event.data;
58
57
  if (!data || data === "[DONE]") return;
59
58
  try {
@@ -63,7 +62,7 @@ export async function chatOpenAI(
63
62
  } catch {
64
63
  // ignore parse errors on keepalives, etc.
65
64
  }
66
- });
65
+ }});
67
66
 
68
67
  while (true) {
69
68
  const { value, done } = await reader.read();
@@ -219,8 +219,7 @@ export async function chatOpenRouter(
219
219
 
220
220
  const reader = resp.body.getReader();
221
221
  const decoder = new TextDecoder();
222
- const parser = createParser((event) => {
223
- if (event.type !== "event") return;
222
+ const parser = createParser({ onEvent: (event: any) => {
224
223
  const data = event.data;
225
224
  if (!data || data === "[DONE]") return;
226
225
  try {
@@ -268,7 +267,7 @@ export async function chatOpenRouter(
268
267
  } catch {
269
268
  // ignore parse errors
270
269
  }
271
- });
270
+ }});
272
271
 
273
272
  while (true) {
274
273
  const { value, done } = await reader.read();
package/adapters/types.ts CHANGED
@@ -28,7 +28,7 @@ export type AnthropicRequest = {
28
28
  system?: string;
29
29
  };
30
30
 
31
- export type ProviderKey = "openai" | "openrouter" | "gemini" | "glm" | "anthropic";
31
+ export type ProviderKey = "openai" | "openrouter" | "gemini" | "gemini-oauth" | "glm" | "anthropic";
32
32
 
33
33
  export type ProviderModel = {
34
34
  provider: ProviderKey;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-glm",
3
- "version": "1.3.3",
4
- "description": "Cross-platform installer for Claude Code with Z.AI GLM models, multi-provider proxy, and dangerously-skip-permissions shortcuts. Run with: npx claude-glm",
3
+ "version": "1.4.0",
4
+ "description": "Cross-platform installer for Claude Code with Z.AI GLM models, multi-provider proxy, Gemini OAuth, and dangerously-skip-permissions shortcuts. Run with: npx claude-glm",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -10,6 +10,8 @@
10
10
  "openai",
11
11
  "openrouter",
12
12
  "gemini",
13
+ "gemini-oauth",
14
+ "google",
13
15
  "ai",
14
16
  "llm",
15
17
  "installer",
@@ -48,13 +50,14 @@
48
50
  "node": ">=18.0.0"
49
51
  },
50
52
  "dependencies": {
51
- "dotenv": "^16.4.5",
52
- "eventsource-parser": "^1.1.2",
53
- "fastify": "^4.28.1"
53
+ "dotenv": "^16.6.1",
54
+ "eventsource-parser": "^3.0.0",
55
+ "fastify": "^4.29.1"
54
56
  },
55
57
  "devDependencies": {
56
58
  "@types/node": "^20.14.0",
57
- "tsx": "^4.15.6",
59
+ "tsx": "^4.21.0",
58
60
  "typescript": "^5.6.3"
59
- }
61
+ },
62
+ "main": "index.js"
60
63
  }