@link-assistant/agent 0.6.2 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -855,6 +855,10 @@ const GOOGLE_OAUTH_CLIENT_ID =
855
855
  '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
856
856
  const GOOGLE_OAUTH_CLIENT_SECRET = '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';
857
857
  const GOOGLE_OAUTH_SCOPES = [
858
+ // Note: We intentionally do NOT include generative-language.* scopes here
859
+ // because they are not registered for the Gemini CLI OAuth client (see issue #93).
860
+ // Instead, we rely on the fallback mechanism to use API keys when OAuth fails
861
+ // with scope errors (see issue #100).
858
862
  'https://www.googleapis.com/auth/cloud-platform',
859
863
  'https://www.googleapis.com/auth/userinfo.email',
860
864
  'https://www.googleapis.com/auth/userinfo.profile',
@@ -1260,12 +1264,363 @@ const GooglePlugin: AuthPlugin = {
1260
1264
  }
1261
1265
  }
1262
1266
 
1267
+ /**
1268
+ * Cloud Code API Configuration
1269
+ *
1270
+ * The official Gemini CLI uses Google's Cloud Code API (cloudcode-pa.googleapis.com)
1271
+ * instead of the standard Generative Language API (generativelanguage.googleapis.com).
1272
+ *
1273
+ * The Cloud Code API:
1274
+ * 1. Accepts `cloud-platform` OAuth scope (unlike generativelanguage.googleapis.com)
1275
+ * 2. Handles subscription tier validation (FREE, STANDARD, etc.)
1276
+ * 3. Proxies requests to the Generative Language API internally
1277
+ *
1278
+ * @see https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/server.ts
1279
+ * @see https://github.com/link-assistant/agent/issues/100
1280
+ */
1281
+ const CLOUD_CODE_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
1282
+ const CLOUD_CODE_API_VERSION = 'v1internal';
1283
+
1284
+ log.debug(() => ({
1285
+ message: 'google oauth loader initialized',
1286
+ cloudCodeEndpoint: CLOUD_CODE_ENDPOINT,
1287
+ apiVersion: CLOUD_CODE_API_VERSION,
1288
+ }));
1289
+
1290
+ /**
1291
+ * Check if we have a fallback API key available.
1292
+ * This allows trying API key authentication if Cloud Code API fails.
1293
+ * See: https://github.com/link-assistant/agent/issues/100
1294
+ */
1295
+ const getFallbackApiKey = (): string | undefined => {
1296
+ // Check for API key in environment variables
1297
+ const envKey =
1298
+ process.env['GOOGLE_GENERATIVE_AI_API_KEY'] ||
1299
+ process.env['GEMINI_API_KEY'];
1300
+
1301
+ if (envKey) {
1302
+ log.debug(() => ({
1303
+ message: 'fallback api key available',
1304
+ source: process.env['GOOGLE_GENERATIVE_AI_API_KEY']
1305
+ ? 'GOOGLE_GENERATIVE_AI_API_KEY'
1306
+ : 'GEMINI_API_KEY',
1307
+ keyLength: envKey.length,
1308
+ }));
1309
+ return envKey;
1310
+ }
1311
+
1312
+ log.debug(() => ({
1313
+ message: 'no fallback api key available',
1314
+ hint: 'Set GOOGLE_GENERATIVE_AI_API_KEY or GEMINI_API_KEY for fallback',
1315
+ }));
1316
+
1317
+ // Check for API key in auth storage (async, so we need to handle this differently)
1318
+ // For now, we only support env var fallback synchronously
1319
+ return undefined;
1320
+ };
1321
+
1322
+ /**
1323
+ * Detect if an error is a scope-related authentication error.
1324
+ * This is triggered when OAuth token doesn't have the required scopes.
1325
+ */
1326
+ const isScopeError = (response: Response): boolean => {
1327
+ if (response.status !== 403) return false;
1328
+ const wwwAuth = response.headers.get('www-authenticate') || '';
1329
+ const isScope =
1330
+ wwwAuth.includes('insufficient_scope') ||
1331
+ wwwAuth.includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT');
1332
+
1333
+ if (isScope) {
1334
+ log.debug(() => ({
1335
+ message: 'detected oauth scope error',
1336
+ status: response.status,
1337
+ wwwAuthenticate: wwwAuth.substring(0, 200),
1338
+ }));
1339
+ }
1340
+
1341
+ return isScope;
1342
+ };
1343
+
1344
+ /**
1345
+ * Transform a Generative Language API URL to Cloud Code API URL
1346
+ *
1347
+ * Input: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent
1348
+ * Output: https://cloudcode-pa.googleapis.com/v1internal:generateContent
1349
+ *
1350
+ * The Cloud Code API uses a different URL structure:
1351
+ * - Model is passed in request body, not URL path
1352
+ * - Method is directly after version: /v1internal:methodName
1353
+ */
1354
+ const transformToCloudCodeUrl = (url: string): string | null => {
1355
+ try {
1356
+ const parsed = new URL(url);
1357
+ if (!parsed.hostname.includes('generativelanguage.googleapis.com')) {
1358
+ log.debug(() => ({
1359
+ message:
1360
+ 'url is not generativelanguage api, skipping cloud code transform',
1361
+ hostname: parsed.hostname,
1362
+ }));
1363
+ return null; // Not a Generative Language API URL
1364
+ }
1365
+
1366
+ // Extract the method from the path
1367
+ // Path format: /v1beta/models/gemini-2.0-flash:generateContent
1368
+ const pathMatch = parsed.pathname.match(/:(\w+)$/);
1369
+ if (!pathMatch) {
1370
+ log.debug(() => ({
1371
+ message: 'could not extract method from url path',
1372
+ pathname: parsed.pathname,
1373
+ }));
1374
+ return null; // Can't determine method
1375
+ }
1376
+
1377
+ const method = pathMatch[1];
1378
+ const cloudCodeUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}:${method}`;
1379
+
1380
+ log.debug(() => ({
1381
+ message: 'transformed url to cloud code api',
1382
+ originalUrl: url.substring(0, 100),
1383
+ method,
1384
+ cloudCodeUrl,
1385
+ }));
1386
+
1387
+ return cloudCodeUrl;
1388
+ } catch (error) {
1389
+ log.debug(() => ({
1390
+ message: 'failed to parse url for cloud code transform',
1391
+ url: url.substring(0, 100),
1392
+ error: String(error),
1393
+ }));
1394
+ return null;
1395
+ }
1396
+ };
1397
+
1398
+ /**
1399
+ * Extract model name from Generative Language API URL
1400
+ *
1401
+ * Input: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent
1402
+ * Output: gemini-2.0-flash
1403
+ */
1404
+ const extractModelFromUrl = (url: string): string | null => {
1405
+ try {
1406
+ const parsed = new URL(url);
1407
+ // Path format: /v1beta/models/gemini-2.0-flash:generateContent
1408
+ const pathMatch = parsed.pathname.match(/\/models\/([^:]+):/);
1409
+ const model = pathMatch ? pathMatch[1] : null;
1410
+
1411
+ log.debug(() => ({
1412
+ message: 'extracted model from url',
1413
+ pathname: parsed.pathname,
1414
+ model,
1415
+ }));
1416
+
1417
+ return model;
1418
+ } catch (error) {
1419
+ log.debug(() => ({
1420
+ message: 'failed to extract model from url',
1421
+ url: url.substring(0, 100),
1422
+ error: String(error),
1423
+ }));
1424
+ return null;
1425
+ }
1426
+ };
1427
+
1428
+ /**
1429
+ * Transform request body for Cloud Code API
1430
+ *
1431
+ * The Cloud Code API expects requests in this format:
1432
+ * {
1433
+ * model: "gemini-2.0-flash",
1434
+ * project: "optional-project-id",
1435
+ * user_prompt_id: "optional-prompt-id",
1436
+ * request: { contents: [...], generationConfig: {...}, ... }
1437
+ * }
1438
+ *
1439
+ * The standard AI SDK sends:
1440
+ * { contents: [...], generationConfig: {...}, ... }
1441
+ */
1442
+ const transformRequestBody = (body: string, model: string): string => {
1443
+ try {
1444
+ const parsed = JSON.parse(body);
1445
+
1446
+ // Get project ID from environment if available
1447
+ const projectId =
1448
+ process.env['GOOGLE_CLOUD_PROJECT'] ||
1449
+ process.env['GOOGLE_CLOUD_PROJECT_ID'];
1450
+
1451
+ // Wrap in Cloud Code API format
1452
+ const cloudCodeRequest = {
1453
+ model,
1454
+ ...(projectId && { project: projectId }),
1455
+ request: parsed,
1456
+ };
1457
+
1458
+ log.debug(() => ({
1459
+ message: 'transformed request body for cloud code api',
1460
+ model,
1461
+ hasProjectId: !!projectId,
1462
+ originalBodyLength: body.length,
1463
+ transformedBodyLength: JSON.stringify(cloudCodeRequest).length,
1464
+ }));
1465
+
1466
+ return JSON.stringify(cloudCodeRequest);
1467
+ } catch (error) {
1468
+ log.debug(() => ({
1469
+ message: 'failed to transform request body, using original',
1470
+ error: String(error),
1471
+ }));
1472
+ return body; // Return original if parsing fails
1473
+ }
1474
+ };
1475
+
1476
+ /**
1477
+ * Transform Cloud Code API response to standard format
1478
+ *
1479
+ * Cloud Code API returns:
1480
+ * { response: { candidates: [...], ... }, traceId: "..." }
1481
+ *
1482
+ * Standard API returns:
1483
+ * { candidates: [...], ... }
1484
+ */
1485
+ const transformResponseBody = async (
1486
+ response: Response
1487
+ ): Promise<Response> => {
1488
+ const contentType = response.headers.get('content-type');
1489
+ const isStreaming = contentType?.includes('text/event-stream');
1490
+
1491
+ log.debug(() => ({
1492
+ message: 'transforming cloud code response',
1493
+ status: response.status,
1494
+ contentType,
1495
+ isStreaming,
1496
+ }));
1497
+
1498
+ // For streaming responses, we need to transform each chunk
1499
+ if (isStreaming) {
1500
+ // The Cloud Code API returns SSE with data: { response: {...} } format
1501
+ // We need to transform each chunk to unwrap the response
1502
+ const reader = response.body?.getReader();
1503
+ if (!reader) {
1504
+ log.debug(() => ({
1505
+ message: 'no response body reader available for streaming',
1506
+ }));
1507
+ return response;
1508
+ }
1509
+
1510
+ const encoder = new TextEncoder();
1511
+ const decoder = new TextDecoder();
1512
+ let chunkCount = 0;
1513
+
1514
+ const transformedStream = new ReadableStream({
1515
+ async start(controller) {
1516
+ try {
1517
+ while (true) {
1518
+ const { done, value } = await reader.read();
1519
+ if (done) {
1520
+ log.debug(() => ({
1521
+ message: 'streaming response complete',
1522
+ totalChunks: chunkCount,
1523
+ }));
1524
+ controller.close();
1525
+ break;
1526
+ }
1527
+
1528
+ const text = decoder.decode(value, { stream: true });
1529
+ chunkCount++;
1530
+
1531
+ // Split by SSE event boundaries
1532
+ const events = text.split('\n\n');
1533
+ for (const event of events) {
1534
+ if (!event.trim()) continue;
1535
+
1536
+ // Check if this is a data line
1537
+ if (event.startsWith('data: ')) {
1538
+ try {
1539
+ const jsonStr = event.slice(6).trim();
1540
+ if (jsonStr === '[DONE]') {
1541
+ controller.enqueue(encoder.encode(event + '\n\n'));
1542
+ continue;
1543
+ }
1544
+
1545
+ const parsed = JSON.parse(jsonStr);
1546
+
1547
+ // Unwrap Cloud Code response format if present
1548
+ const unwrapped = parsed.response || parsed;
1549
+ controller.enqueue(
1550
+ encoder.encode(
1551
+ 'data: ' + JSON.stringify(unwrapped) + '\n\n'
1552
+ )
1553
+ );
1554
+ } catch {
1555
+ // If parsing fails, pass through as-is
1556
+ controller.enqueue(encoder.encode(event + '\n\n'));
1557
+ }
1558
+ } else {
1559
+ // Non-data lines (like event type), pass through
1560
+ controller.enqueue(encoder.encode(event + '\n\n'));
1561
+ }
1562
+ }
1563
+ }
1564
+ } catch (error) {
1565
+ log.debug(() => ({
1566
+ message: 'error during streaming response transformation',
1567
+ error: String(error),
1568
+ }));
1569
+ controller.error(error);
1570
+ }
1571
+ },
1572
+ });
1573
+
1574
+ return new Response(transformedStream, {
1575
+ status: response.status,
1576
+ statusText: response.statusText,
1577
+ headers: response.headers,
1578
+ });
1579
+ }
1580
+
1581
+ // For non-streaming responses, parse and unwrap
1582
+ try {
1583
+ const json = await response.json();
1584
+ const unwrapped = json.response || json;
1585
+
1586
+ log.debug(() => ({
1587
+ message: 'unwrapped non-streaming cloud code response',
1588
+ hasResponseWrapper: !!json.response,
1589
+ hasTraceId: !!json.traceId,
1590
+ }));
1591
+
1592
+ return new Response(JSON.stringify(unwrapped), {
1593
+ status: response.status,
1594
+ statusText: response.statusText,
1595
+ headers: response.headers,
1596
+ });
1597
+ } catch (error) {
1598
+ log.debug(() => ({
1599
+ message: 'failed to parse non-streaming response, returning original',
1600
+ error: String(error),
1601
+ }));
1602
+ return response;
1603
+ }
1604
+ };
1605
+
1263
1606
  return {
1264
1607
  apiKey: 'oauth-token-used-via-custom-fetch',
1265
1608
  async fetch(input: RequestInfo | URL, init?: RequestInit) {
1266
1609
  let currentAuth = await getAuth();
1267
- if (!currentAuth || currentAuth.type !== 'oauth')
1610
+ if (!currentAuth || currentAuth.type !== 'oauth') {
1611
+ log.debug(() => ({
1612
+ message: 'no google oauth credentials, using standard fetch',
1613
+ }));
1268
1614
  return fetch(input, init);
1615
+ }
1616
+
1617
+ log.debug(() => ({
1618
+ message: 'google oauth fetch initiated',
1619
+ hasAccessToken: !!currentAuth?.access,
1620
+ tokenExpiresIn: currentAuth
1621
+ ? Math.round((currentAuth.expires - Date.now()) / 1000)
1622
+ : 0,
1623
+ }));
1269
1624
 
1270
1625
  // Refresh token if expired (with 5 minute buffer)
1271
1626
  const FIVE_MIN_MS = 5 * 60 * 1000;
@@ -1273,7 +1628,12 @@ const GooglePlugin: AuthPlugin = {
1273
1628
  !currentAuth.access ||
1274
1629
  currentAuth.expires < Date.now() + FIVE_MIN_MS
1275
1630
  ) {
1276
- log.info(() => ({ message: 'refreshing google oauth token' }));
1631
+ log.info(() => ({
1632
+ message: 'refreshing google oauth token',
1633
+ reason: !currentAuth.access
1634
+ ? 'no access token'
1635
+ : 'token expiring soon',
1636
+ }));
1277
1637
  const response = await fetch(GOOGLE_TOKEN_URL, {
1278
1638
  method: 'POST',
1279
1639
  headers: {
@@ -1288,10 +1648,21 @@ const GooglePlugin: AuthPlugin = {
1288
1648
  });
1289
1649
 
1290
1650
  if (!response.ok) {
1651
+ const errorText = await response.text().catch(() => 'unknown');
1652
+ log.error(() => ({
1653
+ message: 'google oauth token refresh failed',
1654
+ status: response.status,
1655
+ error: errorText.substring(0, 200),
1656
+ }));
1291
1657
  throw new Error(`Token refresh failed: ${response.status}`);
1292
1658
  }
1293
1659
 
1294
1660
  const json = await response.json();
1661
+ log.debug(() => ({
1662
+ message: 'google oauth token refreshed successfully',
1663
+ expiresIn: json.expires_in,
1664
+ }));
1665
+
1295
1666
  await Auth.set('google', {
1296
1667
  type: 'oauth',
1297
1668
  // Google doesn't return a new refresh token on refresh
@@ -1307,18 +1678,180 @@ const GooglePlugin: AuthPlugin = {
1307
1678
  };
1308
1679
  }
1309
1680
 
1310
- // Google API uses Bearer token authentication
1681
+ // Get the original URL
1682
+ const originalUrl =
1683
+ typeof input === 'string'
1684
+ ? input
1685
+ : input instanceof URL
1686
+ ? input.toString()
1687
+ : (input as Request).url;
1688
+
1689
+ log.debug(() => ({
1690
+ message: 'processing google api request',
1691
+ originalUrl: originalUrl.substring(0, 100),
1692
+ method: init?.method || 'GET',
1693
+ }));
1694
+
1695
+ // Try to transform to Cloud Code API URL
1696
+ const cloudCodeUrl = transformToCloudCodeUrl(originalUrl);
1697
+ const model = extractModelFromUrl(originalUrl);
1698
+
1699
+ // If this is a Generative Language API request, route through Cloud Code API
1700
+ if (cloudCodeUrl && model) {
1701
+ log.info(() => ({
1702
+ message: 'routing google oauth request through cloud code api',
1703
+ originalUrl: originalUrl.substring(0, 100) + '...',
1704
+ cloudCodeUrl,
1705
+ model,
1706
+ }));
1707
+
1708
+ // Transform request body to Cloud Code format
1709
+ let body = init?.body;
1710
+ if (typeof body === 'string') {
1711
+ body = transformRequestBody(body, model);
1712
+ }
1713
+
1714
+ // Make request to Cloud Code API with Bearer token
1715
+ const headers: Record<string, string> = {
1716
+ ...(init?.headers as Record<string, string>),
1717
+ Authorization: `Bearer ${currentAuth.access}`,
1718
+ 'x-goog-api-client': 'agent/0.6.3',
1719
+ };
1720
+ // Remove any API key header if present since we're using OAuth
1721
+ delete headers['x-goog-api-key'];
1722
+
1723
+ log.debug(() => ({
1724
+ message: 'sending request to cloud code api',
1725
+ url: cloudCodeUrl,
1726
+ hasBody: !!body,
1727
+ }));
1728
+
1729
+ const cloudCodeResponse = await fetch(cloudCodeUrl, {
1730
+ ...init,
1731
+ body,
1732
+ headers,
1733
+ });
1734
+
1735
+ log.debug(() => ({
1736
+ message: 'cloud code api response received',
1737
+ status: cloudCodeResponse.status,
1738
+ statusText: cloudCodeResponse.statusText,
1739
+ contentType: cloudCodeResponse.headers.get('content-type'),
1740
+ }));
1741
+
1742
+ // Check for errors and handle fallback
1743
+ if (!cloudCodeResponse.ok) {
1744
+ // Try to get error details for logging
1745
+ const errorBody = await cloudCodeResponse
1746
+ .clone()
1747
+ .text()
1748
+ .catch(() => 'unknown');
1749
+ log.warn(() => ({
1750
+ message: 'cloud code api returned error',
1751
+ status: cloudCodeResponse.status,
1752
+ statusText: cloudCodeResponse.statusText,
1753
+ errorBody: errorBody.substring(0, 500),
1754
+ }));
1755
+
1756
+ const fallbackApiKey = getFallbackApiKey();
1757
+ if (fallbackApiKey) {
1758
+ log.warn(() => ({
1759
+ message:
1760
+ 'cloud code api error, falling back to api key with standard api',
1761
+ status: cloudCodeResponse.status,
1762
+ fallbackTarget: originalUrl.substring(0, 100),
1763
+ }));
1764
+
1765
+ // Fall back to standard API with API key
1766
+ const apiKeyHeaders: Record<string, string> = {
1767
+ ...(init?.headers as Record<string, string>),
1768
+ 'x-goog-api-key': fallbackApiKey,
1769
+ };
1770
+ delete apiKeyHeaders['Authorization'];
1771
+
1772
+ log.debug(() => ({
1773
+ message: 'sending fallback request with api key',
1774
+ url: originalUrl.substring(0, 100),
1775
+ }));
1776
+
1777
+ return fetch(originalUrl, {
1778
+ ...init,
1779
+ headers: apiKeyHeaders,
1780
+ });
1781
+ }
1782
+
1783
+ log.error(() => ({
1784
+ message: 'cloud code api error and no api key fallback available',
1785
+ status: cloudCodeResponse.status,
1786
+ hint: 'Set GOOGLE_GENERATIVE_AI_API_KEY or GEMINI_API_KEY environment variable for fallback',
1787
+ }));
1788
+
1789
+ // No fallback available, return the error response
1790
+ return cloudCodeResponse;
1791
+ }
1792
+
1793
+ log.debug(() => ({
1794
+ message: 'cloud code api request successful, transforming response',
1795
+ }));
1796
+
1797
+ // Transform response back to standard format
1798
+ return transformResponseBody(cloudCodeResponse);
1799
+ }
1800
+
1801
+ // Not a Generative Language API request, use standard OAuth flow
1802
+ log.debug(() => ({
1803
+ message:
1804
+ 'not a generative language api request, using standard oauth',
1805
+ url: originalUrl.substring(0, 100),
1806
+ }));
1807
+
1311
1808
  const headers: Record<string, string> = {
1312
1809
  ...(init?.headers as Record<string, string>),
1313
1810
  Authorization: `Bearer ${currentAuth.access}`,
1314
1811
  };
1315
- // Remove any API key header if present since we're using OAuth
1316
1812
  delete headers['x-goog-api-key'];
1317
1813
 
1318
- return fetch(input, {
1814
+ const oauthResponse = await fetch(input, {
1319
1815
  ...init,
1320
1816
  headers,
1321
1817
  });
1818
+
1819
+ log.debug(() => ({
1820
+ message: 'standard oauth response received',
1821
+ status: oauthResponse.status,
1822
+ }));
1823
+
1824
+ // Check if OAuth failed due to insufficient scopes
1825
+ if (isScopeError(oauthResponse)) {
1826
+ const fallbackApiKey = getFallbackApiKey();
1827
+ if (fallbackApiKey) {
1828
+ log.warn(() => ({
1829
+ message:
1830
+ 'oauth scope error, falling back to api key authentication',
1831
+ hint: 'This should not happen with Cloud Code API routing',
1832
+ url: originalUrl.substring(0, 100),
1833
+ }));
1834
+
1835
+ const apiKeyHeaders: Record<string, string> = {
1836
+ ...(init?.headers as Record<string, string>),
1837
+ 'x-goog-api-key': fallbackApiKey,
1838
+ };
1839
+ delete apiKeyHeaders['Authorization'];
1840
+
1841
+ return fetch(input, {
1842
+ ...init,
1843
+ headers: apiKeyHeaders,
1844
+ });
1845
+ } else {
1846
+ log.error(() => ({
1847
+ message: 'oauth scope error and no api key fallback available',
1848
+ hint: 'Set GOOGLE_GENERATIVE_AI_API_KEY or GEMINI_API_KEY environment variable',
1849
+ url: originalUrl.substring(0, 100),
1850
+ }));
1851
+ }
1852
+ }
1853
+
1854
+ return oauthResponse;
1322
1855
  },
1323
1856
  };
1324
1857
  },
@@ -0,0 +1,30 @@
1
+ export namespace FileIgnore {
2
+ export const PATTERNS = [
3
+ '.git',
4
+ 'node_modules',
5
+ '.DS_Store',
6
+ '*.log',
7
+ 'dist',
8
+ 'build',
9
+ '.next',
10
+ '.nuxt',
11
+ '.output',
12
+ '.vercel',
13
+ '.netlify',
14
+ 'coverage',
15
+ '.nyc_output',
16
+ '.cache',
17
+ '.tmp',
18
+ 'tmp',
19
+ '*.tmp',
20
+ '*.swp',
21
+ '*.swo',
22
+ '*~',
23
+ '.env',
24
+ '.env.local',
25
+ '.env.*.local',
26
+ '.vscode',
27
+ '.idea',
28
+ '*.tsbuildinfo',
29
+ ];
30
+ }
package/src/index.js CHANGED
@@ -247,7 +247,7 @@ async function readSystemMessages(argv) {
247
247
 
248
248
  async function runAgentMode(argv, request) {
249
249
  // Log version and command info in verbose mode using lazy logging
250
- Log.Default.lazy.info(() => ({
250
+ Log.Default.info(() => ({
251
251
  message: 'Agent started',
252
252
  version: pkg.version,
253
253
  command: process.argv.join(' '),
@@ -255,7 +255,7 @@ async function runAgentMode(argv, request) {
255
255
  scriptPath: import.meta.path,
256
256
  }));
257
257
  if (Flag.OPENCODE_DRY_RUN) {
258
- Log.Default.lazy.info(() => ({
258
+ Log.Default.info(() => ({
259
259
  message: 'Dry run mode enabled',
260
260
  mode: 'dry-run',
261
261
  }));
@@ -318,7 +318,7 @@ async function runAgentMode(argv, request) {
318
318
  async function runContinuousAgentMode(argv) {
319
319
  const compactJson = argv['compact-json'] === true;
320
320
  // Log version and command info in verbose mode using lazy logging
321
- Log.Default.lazy.info(() => ({
321
+ Log.Default.info(() => ({
322
322
  message: 'Agent started (continuous mode)',
323
323
  version: pkg.version,
324
324
  command: process.argv.join(' '),
@@ -326,7 +326,7 @@ async function runContinuousAgentMode(argv) {
326
326
  scriptPath: import.meta.path,
327
327
  }));
328
328
  if (Flag.OPENCODE_DRY_RUN) {
329
- Log.Default.lazy.info(() => ({
329
+ Log.Default.info(() => ({
330
330
  message: 'Dry run mode enabled',
331
331
  mode: 'dry-run',
332
332
  }));
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Google Cloud Code API Client
3
+ *
4
+ * This module provides a client for Google's Cloud Code API (cloudcode-pa.googleapis.com),
5
+ * which is used by the official Gemini CLI for OAuth-authenticated requests.
6
+ *
7
+ * The Cloud Code API:
8
+ * 1. Accepts `cloud-platform` OAuth scope (unlike generativelanguage.googleapis.com)
9
+ * 2. Handles subscription tier validation (FREE, STANDARD, etc.)
10
+ * 3. Proxies requests to the Generative Language API internally
11
+ *
12
+ * @see https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/server.ts
13
+ * @see https://github.com/link-assistant/agent/issues/100
14
+ */
15
+
16
+ import { Log } from '../util/log';
17
+ import { Auth } from '../auth';
18
+
19
+ const log = Log.create({ service: 'google-cloudcode' });
20
+
21
+ // Cloud Code API endpoints (from gemini-cli)
22
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
23
+ const CODE_ASSIST_API_VERSION = 'v1internal';
24
+
25
+ // Google OAuth endpoints
26
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
27
+ const GOOGLE_OAUTH_CLIENT_ID =
28
+ '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
29
+ const GOOGLE_OAUTH_CLIENT_SECRET = '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';
30
+
31
+ /**
32
+ * User tier from Cloud Code API
33
+ */
34
+ export enum UserTierId {
35
+ FREE = 'FREE',
36
+ STANDARD = 'STANDARD',
37
+ LEGACY = 'LEGACY',
38
+ }
39
+
40
+ /**
41
+ * Cloud Code API request format
42
+ */
43
+ interface CloudCodeRequest {
44
+ model: string;
45
+ project?: string;
46
+ user_prompt_id?: string;
47
+ request: {
48
+ contents: Array<{
49
+ role: string;
50
+ parts: Array<{ text?: string; [key: string]: unknown }>;
51
+ }>;
52
+ systemInstruction?: {
53
+ role: string;
54
+ parts: Array<{ text?: string }>;
55
+ };
56
+ tools?: unknown[];
57
+ toolConfig?: unknown;
58
+ generationConfig?: {
59
+ temperature?: number;
60
+ topP?: number;
61
+ topK?: number;
62
+ maxOutputTokens?: number;
63
+ candidateCount?: number;
64
+ stopSequences?: string[];
65
+ responseMimeType?: string;
66
+ responseSchema?: unknown;
67
+ thinkingConfig?: {
68
+ thinkingBudget?: number;
69
+ };
70
+ };
71
+ safetySettings?: unknown[];
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Cloud Code API response format
77
+ */
78
+ interface CloudCodeResponse {
79
+ response: {
80
+ candidates: Array<{
81
+ content: {
82
+ role: string;
83
+ parts: Array<{
84
+ text?: string;
85
+ thought?: boolean;
86
+ functionCall?: unknown;
87
+ functionResponse?: unknown;
88
+ }>;
89
+ };
90
+ finishReason?: string;
91
+ safetyRatings?: unknown[];
92
+ }>;
93
+ usageMetadata?: {
94
+ promptTokenCount?: number;
95
+ candidatesTokenCount?: number;
96
+ totalTokenCount?: number;
97
+ thoughtsTokenCount?: number;
98
+ cachedContentTokenCount?: number;
99
+ };
100
+ modelVersion?: string;
101
+ };
102
+ traceId?: string;
103
+ }
104
+
105
+ /**
106
+ * Load Code Assist response (for user setup)
107
+ */
108
+ interface LoadCodeAssistResponse {
109
+ cloudaicompanionProject?: string;
110
+ currentTier?: {
111
+ id: UserTierId;
112
+ name: string;
113
+ description: string;
114
+ };
115
+ allowedTiers?: Array<{
116
+ id: UserTierId;
117
+ name: string;
118
+ description: string;
119
+ isDefault?: boolean;
120
+ userDefinedCloudaicompanionProject?: boolean;
121
+ }>;
122
+ }
123
+
124
+ /**
125
+ * Onboard user response (for user setup)
126
+ */
127
+ interface OnboardUserResponse {
128
+ done: boolean;
129
+ response?: {
130
+ cloudaicompanionProject?: {
131
+ id: string;
132
+ };
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Google Cloud Code API client
138
+ *
139
+ * This client implements the same API used by the official Gemini CLI.
140
+ * It wraps OAuth authentication and provides methods for:
141
+ * - Setting up user (onboarding to FREE/STANDARD tier)
142
+ * - Generating content (streaming and non-streaming)
143
+ * - Counting tokens
144
+ */
145
+ export class CloudCodeClient {
146
+ private accessToken: string;
147
+ private refreshToken: string;
148
+ private tokenExpiry: number;
149
+ private projectId?: string;
150
+ private userTier?: UserTierId;
151
+
152
+ constructor(
153
+ private auth: {
154
+ access: string;
155
+ refresh: string;
156
+ expires: number;
157
+ },
158
+ projectId?: string
159
+ ) {
160
+ this.accessToken = auth.access;
161
+ this.refreshToken = auth.refresh;
162
+ this.tokenExpiry = auth.expires;
163
+ this.projectId = projectId;
164
+ }
165
+
166
+ /**
167
+ * Refresh the OAuth access token if expired
168
+ */
169
+ private async ensureValidToken(): Promise<void> {
170
+ const FIVE_MIN_MS = 5 * 60 * 1000;
171
+ if (this.tokenExpiry > Date.now() + FIVE_MIN_MS) {
172
+ return; // Token is still valid
173
+ }
174
+
175
+ log.info(() => ({
176
+ message: 'refreshing google oauth token for cloud code',
177
+ }));
178
+
179
+ const response = await fetch(GOOGLE_TOKEN_URL, {
180
+ method: 'POST',
181
+ headers: {
182
+ 'Content-Type': 'application/x-www-form-urlencoded',
183
+ },
184
+ body: new URLSearchParams({
185
+ client_id: GOOGLE_OAUTH_CLIENT_ID,
186
+ client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
187
+ refresh_token: this.refreshToken,
188
+ grant_type: 'refresh_token',
189
+ }),
190
+ });
191
+
192
+ if (!response.ok) {
193
+ throw new Error(`Token refresh failed: ${response.status}`);
194
+ }
195
+
196
+ const json = await response.json();
197
+ this.accessToken = json.access_token;
198
+ this.tokenExpiry = Date.now() + json.expires_in * 1000;
199
+
200
+ // Update stored auth
201
+ await Auth.set('google', {
202
+ type: 'oauth',
203
+ refresh: this.refreshToken,
204
+ access: this.accessToken,
205
+ expires: this.tokenExpiry,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Make an authenticated request to the Cloud Code API
211
+ */
212
+ private async request<T>(
213
+ method: string,
214
+ body: unknown,
215
+ options: { stream?: boolean } = {}
216
+ ): Promise<T | Response> {
217
+ await this.ensureValidToken();
218
+
219
+ const url = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`;
220
+
221
+ const response = await fetch(url, {
222
+ method: 'POST',
223
+ headers: {
224
+ 'Content-Type': 'application/json',
225
+ Authorization: `Bearer ${this.accessToken}`,
226
+ 'x-goog-api-client': 'agent/0.6.3',
227
+ },
228
+ body: JSON.stringify(body),
229
+ });
230
+
231
+ if (!response.ok) {
232
+ const errorText = await response.text();
233
+ log.error(() => ({
234
+ message: 'cloud code api error',
235
+ status: response.status,
236
+ error: errorText,
237
+ }));
238
+ throw new Error(
239
+ `Cloud Code API error: ${response.status} - ${errorText}`
240
+ );
241
+ }
242
+
243
+ if (options.stream) {
244
+ return response;
245
+ }
246
+
247
+ return response.json() as Promise<T>;
248
+ }
249
+
250
+ /**
251
+ * Load code assist to check user tier and project
252
+ */
253
+ async loadCodeAssist(): Promise<LoadCodeAssistResponse> {
254
+ const body = {
255
+ cloudaicompanionProject: this.projectId,
256
+ metadata: {
257
+ ideType: 'IDE_UNSPECIFIED',
258
+ platform: 'PLATFORM_UNSPECIFIED',
259
+ pluginType: 'GEMINI',
260
+ duetProject: this.projectId,
261
+ },
262
+ };
263
+
264
+ return this.request<LoadCodeAssistResponse>('loadCodeAssist', body);
265
+ }
266
+
267
+ /**
268
+ * Onboard user to a tier (FREE or STANDARD)
269
+ */
270
+ async onboardUser(tierId: UserTierId): Promise<OnboardUserResponse> {
271
+ const body = {
272
+ tierId,
273
+ cloudaicompanionProject:
274
+ tierId === UserTierId.FREE ? undefined : this.projectId,
275
+ metadata: {
276
+ ideType: 'IDE_UNSPECIFIED',
277
+ platform: 'PLATFORM_UNSPECIFIED',
278
+ pluginType: 'GEMINI',
279
+ ...(tierId !== UserTierId.FREE && { duetProject: this.projectId }),
280
+ },
281
+ };
282
+
283
+ return this.request<OnboardUserResponse>('onboardUser', body);
284
+ }
285
+
286
+ /**
287
+ * Setup user - check tier and onboard if necessary
288
+ */
289
+ async setupUser(): Promise<{ projectId?: string; userTier: UserTierId }> {
290
+ const loadRes = await this.loadCodeAssist();
291
+
292
+ // If user already has a tier, use it
293
+ if (loadRes.currentTier) {
294
+ this.userTier = loadRes.currentTier.id;
295
+ this.projectId = loadRes.cloudaicompanionProject || this.projectId;
296
+ return {
297
+ projectId: this.projectId,
298
+ userTier: this.userTier,
299
+ };
300
+ }
301
+
302
+ // Find the default tier to onboard to
303
+ let targetTier = UserTierId.FREE;
304
+ for (const tier of loadRes.allowedTiers || []) {
305
+ if (tier.isDefault) {
306
+ targetTier = tier.id;
307
+ break;
308
+ }
309
+ }
310
+
311
+ log.info(() => ({ message: 'onboarding user', tier: targetTier }));
312
+
313
+ // Poll onboardUser until done
314
+ let lroRes = await this.onboardUser(targetTier);
315
+ while (!lroRes.done) {
316
+ await new Promise((resolve) => setTimeout(resolve, 5000));
317
+ lroRes = await this.onboardUser(targetTier);
318
+ }
319
+
320
+ this.userTier = targetTier;
321
+ if (lroRes.response?.cloudaicompanionProject?.id) {
322
+ this.projectId = lroRes.response.cloudaicompanionProject.id;
323
+ }
324
+
325
+ return {
326
+ projectId: this.projectId,
327
+ userTier: this.userTier,
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Generate content (non-streaming)
333
+ */
334
+ async generateContent(request: CloudCodeRequest): Promise<CloudCodeResponse> {
335
+ return this.request<CloudCodeResponse>('generateContent', request);
336
+ }
337
+
338
+ /**
339
+ * Generate content (streaming)
340
+ */
341
+ async generateContentStream(request: CloudCodeRequest): Promise<Response> {
342
+ return this.request<Response>('streamGenerateContent', request, {
343
+ stream: true,
344
+ }) as Promise<Response>;
345
+ }
346
+
347
+ /**
348
+ * Get project ID (may be set during setup)
349
+ */
350
+ getProjectId(): string | undefined {
351
+ return this.projectId;
352
+ }
353
+
354
+ /**
355
+ * Get user tier
356
+ */
357
+ getUserTier(): UserTierId | undefined {
358
+ return this.userTier;
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Create a Cloud Code client from stored Google OAuth credentials
364
+ */
365
+ export async function createCloudCodeClient(): Promise<CloudCodeClient | null> {
366
+ const auth = await Auth.get('google');
367
+ if (!auth || auth.type !== 'oauth') {
368
+ return null;
369
+ }
370
+
371
+ const projectId =
372
+ process.env['GOOGLE_CLOUD_PROJECT'] ||
373
+ process.env['GOOGLE_CLOUD_PROJECT_ID'];
374
+
375
+ return new CloudCodeClient(auth, projectId);
376
+ }
377
+
378
+ /**
379
+ * Check if Cloud Code API is available (user has OAuth credentials)
380
+ */
381
+ export async function isCloudCodeAvailable(): Promise<boolean> {
382
+ const auth = await Auth.get('google');
383
+ return auth?.type === 'oauth';
384
+ }