@link-assistant/agent 0.8.22 → 0.10.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.8.22",
3
+ "version": "0.10.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",
@@ -1277,9 +1277,40 @@ const GooglePlugin: AuthPlugin = {
1277
1277
  *
1278
1278
  * @see https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/server.ts
1279
1279
  * @see https://github.com/link-assistant/agent/issues/100
1280
+ * @see https://github.com/link-assistant/agent/issues/102
1280
1281
  */
1281
- const CLOUD_CODE_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
1282
- const CLOUD_CODE_API_VERSION = 'v1internal';
1282
+ const CLOUD_CODE_ENDPOINT =
1283
+ process.env['CODE_ASSIST_ENDPOINT'] ||
1284
+ 'https://cloudcode-pa.googleapis.com';
1285
+ const CLOUD_CODE_API_VERSION =
1286
+ process.env['CODE_ASSIST_API_VERSION'] || 'v1internal';
1287
+
1288
+ /**
1289
+ * Synthetic thought signature used for Gemini 3+ function calls.
1290
+ * The Cloud Code API requires function call parts in the active loop
1291
+ * to have a thoughtSignature. This bypass value is accepted by the API.
1292
+ *
1293
+ * @see https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/geminiChat.ts
1294
+ */
1295
+ const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
1296
+
1297
+ /**
1298
+ * Retry configuration for transient Cloud Code API errors.
1299
+ * Matches patterns from Gemini CLI and opencode-gemini-auth plugin.
1300
+ */
1301
+ const MAX_RETRIES = 2;
1302
+ const RETRY_BASE_DELAY_MS = 800;
1303
+ const RETRY_MAX_DELAY_MS = 8000;
1304
+
1305
+ /**
1306
+ * Cached project context from Cloud Code API onboarding.
1307
+ * Persists across requests to avoid repeated loadCodeAssist calls.
1308
+ * Keyed by refresh token to invalidate when auth changes.
1309
+ */
1310
+ let cachedProjectContext: {
1311
+ projectId?: string;
1312
+ refreshToken: string;
1313
+ } | null = null;
1283
1314
 
1284
1315
  log.debug(() => ({
1285
1316
  message: 'google oauth loader initialized',
@@ -1287,13 +1318,187 @@ const GooglePlugin: AuthPlugin = {
1287
1318
  apiVersion: CLOUD_CODE_API_VERSION,
1288
1319
  }));
1289
1320
 
1321
+ /**
1322
+ * Ensure project context is available for Cloud Code API requests.
1323
+ * Calls loadCodeAssist to check user tier and onboards if necessary.
1324
+ * Results are cached to avoid repeated API calls.
1325
+ *
1326
+ * @see https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/setup.ts
1327
+ */
1328
+ const ensureProjectContext = async (
1329
+ accessToken: string,
1330
+ refreshToken: string
1331
+ ): Promise<string | undefined> => {
1332
+ // Check for explicit project ID from environment
1333
+ const envProjectId =
1334
+ process.env['GOOGLE_CLOUD_PROJECT'] ||
1335
+ process.env['GOOGLE_CLOUD_PROJECT_ID'];
1336
+ if (envProjectId) {
1337
+ return envProjectId;
1338
+ }
1339
+
1340
+ // Return cached context if still valid (same refresh token)
1341
+ if (
1342
+ cachedProjectContext &&
1343
+ cachedProjectContext.refreshToken === refreshToken
1344
+ ) {
1345
+ return cachedProjectContext.projectId;
1346
+ }
1347
+
1348
+ // Call loadCodeAssist to discover project and tier
1349
+ try {
1350
+ const loadUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}:loadCodeAssist`;
1351
+ const loadRes = await fetch(loadUrl, {
1352
+ method: 'POST',
1353
+ headers: {
1354
+ 'Content-Type': 'application/json',
1355
+ Authorization: `Bearer ${accessToken}`,
1356
+ },
1357
+ body: JSON.stringify({
1358
+ metadata: {
1359
+ ideType: 'IDE_UNSPECIFIED',
1360
+ platform: 'PLATFORM_UNSPECIFIED',
1361
+ pluginType: 'GEMINI',
1362
+ },
1363
+ }),
1364
+ });
1365
+
1366
+ if (!loadRes.ok) {
1367
+ const errorText = await loadRes.text().catch(() => 'unknown');
1368
+ log.warn(() => ({
1369
+ message: 'loadCodeAssist failed, proceeding without project',
1370
+ status: loadRes.status,
1371
+ error: errorText.substring(0, 200),
1372
+ }));
1373
+ // Cache empty result to avoid retrying on every request
1374
+ cachedProjectContext = { refreshToken };
1375
+ return undefined;
1376
+ }
1377
+
1378
+ const loadData = await loadRes.json();
1379
+ log.debug(() => ({
1380
+ message: 'loadCodeAssist response',
1381
+ hasCurrentTier: !!loadData.currentTier,
1382
+ tierId: loadData.currentTier?.id,
1383
+ hasProject: !!loadData.cloudaicompanionProject,
1384
+ }));
1385
+
1386
+ // If user already has a tier and project, use it
1387
+ if (loadData.currentTier && loadData.cloudaicompanionProject) {
1388
+ cachedProjectContext = {
1389
+ projectId: loadData.cloudaicompanionProject,
1390
+ refreshToken,
1391
+ };
1392
+ log.info(() => ({
1393
+ message: 'user already onboarded',
1394
+ tier: loadData.currentTier.id,
1395
+ projectId: loadData.cloudaicompanionProject,
1396
+ }));
1397
+ return loadData.cloudaicompanionProject;
1398
+ }
1399
+
1400
+ // If user has a tier but no project, and paidTier exists
1401
+ if (loadData.currentTier) {
1402
+ cachedProjectContext = { refreshToken };
1403
+ return undefined;
1404
+ }
1405
+
1406
+ // User needs onboarding - find default tier
1407
+ let targetTierId = 'free-tier';
1408
+ for (const tier of loadData.allowedTiers || []) {
1409
+ if (tier.isDefault) {
1410
+ targetTierId = tier.id;
1411
+ break;
1412
+ }
1413
+ }
1414
+
1415
+ log.info(() => ({
1416
+ message: 'onboarding user to tier',
1417
+ tier: targetTierId,
1418
+ }));
1419
+
1420
+ // Call onboardUser
1421
+ const onboardUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}:onboardUser`;
1422
+ const onboardReq =
1423
+ targetTierId === 'free-tier'
1424
+ ? {
1425
+ tierId: targetTierId,
1426
+ metadata: {
1427
+ ideType: 'IDE_UNSPECIFIED',
1428
+ platform: 'PLATFORM_UNSPECIFIED',
1429
+ pluginType: 'GEMINI',
1430
+ },
1431
+ }
1432
+ : {
1433
+ tierId: targetTierId,
1434
+ cloudaicompanionProject: envProjectId,
1435
+ metadata: {
1436
+ ideType: 'IDE_UNSPECIFIED',
1437
+ platform: 'PLATFORM_UNSPECIFIED',
1438
+ pluginType: 'GEMINI',
1439
+ duetProject: envProjectId,
1440
+ },
1441
+ };
1442
+
1443
+ let lroRes = await fetch(onboardUrl, {
1444
+ method: 'POST',
1445
+ headers: {
1446
+ 'Content-Type': 'application/json',
1447
+ Authorization: `Bearer ${accessToken}`,
1448
+ },
1449
+ body: JSON.stringify(onboardReq),
1450
+ }).then((r) => r.json());
1451
+
1452
+ // Poll until onboarding completes (max 10 attempts)
1453
+ let attempts = 0;
1454
+ while (!lroRes.done && attempts < 10) {
1455
+ await new Promise((resolve) => setTimeout(resolve, 5000));
1456
+ if (lroRes.name) {
1457
+ // Poll operation status
1458
+ const opUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}/${lroRes.name}`;
1459
+ lroRes = await fetch(opUrl, {
1460
+ headers: { Authorization: `Bearer ${accessToken}` },
1461
+ }).then((r) => r.json());
1462
+ } else {
1463
+ lroRes = await fetch(onboardUrl, {
1464
+ method: 'POST',
1465
+ headers: {
1466
+ 'Content-Type': 'application/json',
1467
+ Authorization: `Bearer ${accessToken}`,
1468
+ },
1469
+ body: JSON.stringify(onboardReq),
1470
+ }).then((r) => r.json());
1471
+ }
1472
+ attempts++;
1473
+ }
1474
+
1475
+ const projectId =
1476
+ lroRes.response?.cloudaicompanionProject?.id || undefined;
1477
+ cachedProjectContext = { projectId, refreshToken };
1478
+
1479
+ log.info(() => ({
1480
+ message: 'user onboarding complete',
1481
+ projectId,
1482
+ attempts,
1483
+ }));
1484
+
1485
+ return projectId;
1486
+ } catch (error) {
1487
+ log.warn(() => ({
1488
+ message: 'project context setup failed, proceeding without project',
1489
+ error: String(error),
1490
+ }));
1491
+ cachedProjectContext = { refreshToken };
1492
+ return undefined;
1493
+ }
1494
+ };
1495
+
1290
1496
  /**
1291
1497
  * Check if we have a fallback API key available.
1292
1498
  * This allows trying API key authentication if Cloud Code API fails.
1293
1499
  * See: https://github.com/link-assistant/agent/issues/100
1294
1500
  */
1295
1501
  const getFallbackApiKey = (): string | undefined => {
1296
- // Check for API key in environment variables
1297
1502
  const envKey =
1298
1503
  process.env['GOOGLE_GENERATIVE_AI_API_KEY'] ||
1299
1504
  process.env['GEMINI_API_KEY'];
@@ -1309,19 +1514,11 @@ const GooglePlugin: AuthPlugin = {
1309
1514
  return envKey;
1310
1515
  }
1311
1516
 
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
1517
  return undefined;
1320
1518
  };
1321
1519
 
1322
1520
  /**
1323
1521
  * Detect if an error is a scope-related authentication error.
1324
- * This is triggered when OAuth token doesn't have the required scopes.
1325
1522
  */
1326
1523
  const isScopeError = (response: Response): boolean => {
1327
1524
  if (response.status !== 403) return false;
@@ -1341,15 +1538,44 @@ const GooglePlugin: AuthPlugin = {
1341
1538
  return isScope;
1342
1539
  };
1343
1540
 
1541
+ /**
1542
+ * Check if a response status is retryable (transient error).
1543
+ */
1544
+ const isRetryableStatus = (status: number): boolean => {
1545
+ return status === 429 || status === 503;
1546
+ };
1547
+
1548
+ /**
1549
+ * Extract retry delay from response headers or error body.
1550
+ */
1551
+ const getRetryDelay = (response: Response, attempt: number): number => {
1552
+ // Check Retry-After header
1553
+ const retryAfter = response.headers.get('retry-after');
1554
+ if (retryAfter) {
1555
+ const seconds = parseInt(retryAfter, 10);
1556
+ if (!isNaN(seconds)) return seconds * 1000;
1557
+ }
1558
+
1559
+ // Check retry-after-ms header
1560
+ const retryAfterMs = response.headers.get('retry-after-ms');
1561
+ if (retryAfterMs) {
1562
+ const ms = parseInt(retryAfterMs, 10);
1563
+ if (!isNaN(ms)) return ms;
1564
+ }
1565
+
1566
+ // Exponential backoff
1567
+ const delay = Math.min(
1568
+ RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
1569
+ RETRY_MAX_DELAY_MS
1570
+ );
1571
+ return delay;
1572
+ };
1573
+
1344
1574
  /**
1345
1575
  * Transform a Generative Language API URL to Cloud Code API URL
1346
1576
  *
1347
1577
  * Input: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent
1348
1578
  * 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
1579
  */
1354
1580
  const transformToCloudCodeUrl = (url: string): string | null => {
1355
1581
  try {
@@ -1360,18 +1586,16 @@ const GooglePlugin: AuthPlugin = {
1360
1586
  'url is not generativelanguage api, skipping cloud code transform',
1361
1587
  hostname: parsed.hostname,
1362
1588
  }));
1363
- return null; // Not a Generative Language API URL
1589
+ return null;
1364
1590
  }
1365
1591
 
1366
- // Extract the method from the path
1367
- // Path format: /v1beta/models/gemini-2.0-flash:generateContent
1368
1592
  const pathMatch = parsed.pathname.match(/:(\w+)$/);
1369
1593
  if (!pathMatch) {
1370
1594
  log.debug(() => ({
1371
1595
  message: 'could not extract method from url path',
1372
1596
  pathname: parsed.pathname,
1373
1597
  }));
1374
- return null; // Can't determine method
1598
+ return null;
1375
1599
  }
1376
1600
 
1377
1601
  const method = pathMatch[1];
@@ -1395,6 +1619,14 @@ const GooglePlugin: AuthPlugin = {
1395
1619
  }
1396
1620
  };
1397
1621
 
1622
+ /**
1623
+ * Check if a URL is for a streaming request.
1624
+ * The AI SDK appends alt=sse for streaming or uses streamGenerateContent method.
1625
+ */
1626
+ const isStreamingRequest = (url: string): boolean => {
1627
+ return url.includes('streamGenerateContent') || url.includes('alt=sse');
1628
+ };
1629
+
1398
1630
  /**
1399
1631
  * Extract model name from Generative Language API URL
1400
1632
  *
@@ -1404,7 +1636,6 @@ const GooglePlugin: AuthPlugin = {
1404
1636
  const extractModelFromUrl = (url: string): string | null => {
1405
1637
  try {
1406
1638
  const parsed = new URL(url);
1407
- // Path format: /v1beta/models/gemini-2.0-flash:generateContent
1408
1639
  const pathMatch = parsed.pathname.match(/\/models\/([^:]+):/);
1409
1640
  const model = pathMatch ? pathMatch[1] : null;
1410
1641
 
@@ -1425,6 +1656,69 @@ const GooglePlugin: AuthPlugin = {
1425
1656
  }
1426
1657
  };
1427
1658
 
1659
+ /**
1660
+ * Inject thoughtSignature into function call parts for Gemini 3+ models.
1661
+ *
1662
+ * The Cloud Code API requires function call parts in model turns within
1663
+ * the active loop to have a `thoughtSignature` property. Without this,
1664
+ * requests with function calls will fail with 400 errors.
1665
+ *
1666
+ * @see https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/geminiChat.ts
1667
+ * @see https://github.com/sst/opencode/issues/4832
1668
+ */
1669
+ const injectThoughtSignatures = (request: any): any => {
1670
+ if (!request?.contents || !Array.isArray(request.contents)) {
1671
+ return request;
1672
+ }
1673
+
1674
+ // Find the start of the active loop (last user turn with text)
1675
+ let activeLoopStartIndex = -1;
1676
+ for (let i = request.contents.length - 1; i >= 0; i--) {
1677
+ const content = request.contents[i];
1678
+ if (
1679
+ content.role === 'user' &&
1680
+ content.parts?.some((p: any) => p.text)
1681
+ ) {
1682
+ activeLoopStartIndex = i;
1683
+ break;
1684
+ }
1685
+ }
1686
+
1687
+ if (activeLoopStartIndex === -1) {
1688
+ return request;
1689
+ }
1690
+
1691
+ // Inject thoughtSignature into the first functionCall in each model turn
1692
+ const newContents = [...request.contents];
1693
+ let modified = false;
1694
+ for (let i = activeLoopStartIndex; i < newContents.length; i++) {
1695
+ const content = newContents[i];
1696
+ if (content.role === 'model' && content.parts) {
1697
+ const newParts = [...content.parts];
1698
+ for (let j = 0; j < newParts.length; j++) {
1699
+ const part = newParts[j];
1700
+ if (part.functionCall && !part.thoughtSignature) {
1701
+ newParts[j] = {
1702
+ ...part,
1703
+ thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
1704
+ };
1705
+ newContents[i] = { ...content, parts: newParts };
1706
+ modified = true;
1707
+ break; // Only the first functionCall in each turn
1708
+ }
1709
+ }
1710
+ }
1711
+ }
1712
+
1713
+ if (modified) {
1714
+ log.debug(() => ({
1715
+ message: 'injected thoughtSignature into function call parts',
1716
+ }));
1717
+ }
1718
+
1719
+ return { ...request, contents: newContents };
1720
+ };
1721
+
1428
1722
  /**
1429
1723
  * Transform request body for Cloud Code API
1430
1724
  *
@@ -1432,29 +1726,30 @@ const GooglePlugin: AuthPlugin = {
1432
1726
  * {
1433
1727
  * model: "gemini-2.0-flash",
1434
1728
  * project: "optional-project-id",
1435
- * user_prompt_id: "optional-prompt-id",
1436
1729
  * request: { contents: [...], generationConfig: {...}, ... }
1437
1730
  * }
1438
- *
1439
- * The standard AI SDK sends:
1440
- * { contents: [...], generationConfig: {...}, ... }
1441
1731
  */
1442
- const transformRequestBody = (body: string, model: string): string => {
1732
+ const transformRequestBody = (
1733
+ body: string,
1734
+ model: string,
1735
+ projectId?: string
1736
+ ): string => {
1443
1737
  try {
1444
- const parsed = JSON.parse(body);
1738
+ let parsed = JSON.parse(body);
1445
1739
 
1446
- // Get project ID from environment if available
1447
- const projectId =
1448
- process.env['GOOGLE_CLOUD_PROJECT'] ||
1449
- process.env['GOOGLE_CLOUD_PROJECT_ID'];
1740
+ // Inject thoughtSignature for function calls
1741
+ parsed = injectThoughtSignatures(parsed);
1450
1742
 
1451
1743
  // Wrap in Cloud Code API format
1452
- const cloudCodeRequest = {
1744
+ const cloudCodeRequest: Record<string, unknown> = {
1453
1745
  model,
1454
- ...(projectId && { project: projectId }),
1455
1746
  request: parsed,
1456
1747
  };
1457
1748
 
1749
+ if (projectId) {
1750
+ cloudCodeRequest.project = projectId;
1751
+ }
1752
+
1458
1753
  log.debug(() => ({
1459
1754
  message: 'transformed request body for cloud code api',
1460
1755
  model,
@@ -1469,7 +1764,7 @@ const GooglePlugin: AuthPlugin = {
1469
1764
  message: 'failed to transform request body, using original',
1470
1765
  error: String(error),
1471
1766
  }));
1472
- return body; // Return original if parsing fails
1767
+ return body;
1473
1768
  }
1474
1769
  };
1475
1770
 
@@ -1495,10 +1790,7 @@ const GooglePlugin: AuthPlugin = {
1495
1790
  isStreaming,
1496
1791
  }));
1497
1792
 
1498
- // For streaming responses, we need to transform each chunk
1499
1793
  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
1794
  const reader = response.body?.getReader();
1503
1795
  if (!reader) {
1504
1796
  log.debug(() => ({
@@ -1528,12 +1820,10 @@ const GooglePlugin: AuthPlugin = {
1528
1820
  const text = decoder.decode(value, { stream: true });
1529
1821
  chunkCount++;
1530
1822
 
1531
- // Split by SSE event boundaries
1532
1823
  const events = text.split('\n\n');
1533
1824
  for (const event of events) {
1534
1825
  if (!event.trim()) continue;
1535
1826
 
1536
- // Check if this is a data line
1537
1827
  if (event.startsWith('data: ')) {
1538
1828
  try {
1539
1829
  const jsonStr = event.slice(6).trim();
@@ -1543,8 +1833,6 @@ const GooglePlugin: AuthPlugin = {
1543
1833
  }
1544
1834
 
1545
1835
  const parsed = JSON.parse(jsonStr);
1546
-
1547
- // Unwrap Cloud Code response format if present
1548
1836
  const unwrapped = parsed.response || parsed;
1549
1837
  controller.enqueue(
1550
1838
  encoder.encode(
@@ -1552,11 +1840,9 @@ const GooglePlugin: AuthPlugin = {
1552
1840
  )
1553
1841
  );
1554
1842
  } catch {
1555
- // If parsing fails, pass through as-is
1556
1843
  controller.enqueue(encoder.encode(event + '\n\n'));
1557
1844
  }
1558
1845
  } else {
1559
- // Non-data lines (like event type), pass through
1560
1846
  controller.enqueue(encoder.encode(event + '\n\n'));
1561
1847
  }
1562
1848
  }
@@ -1634,6 +1920,10 @@ const GooglePlugin: AuthPlugin = {
1634
1920
  ? 'no access token'
1635
1921
  : 'token expiring soon',
1636
1922
  }));
1923
+
1924
+ // Invalidate project cache when token changes
1925
+ cachedProjectContext = null;
1926
+
1637
1927
  const response = await fetch(GOOGLE_TOKEN_URL, {
1638
1928
  method: 'POST',
1639
1929
  headers: {
@@ -1698,50 +1988,94 @@ const GooglePlugin: AuthPlugin = {
1698
1988
 
1699
1989
  // If this is a Generative Language API request, route through Cloud Code API
1700
1990
  if (cloudCodeUrl && model) {
1991
+ // Ensure project context is available (onboard if needed)
1992
+ const projectId = await ensureProjectContext(
1993
+ currentAuth.access,
1994
+ currentAuth.refresh
1995
+ );
1996
+
1701
1997
  log.info(() => ({
1702
1998
  message: 'routing google oauth request through cloud code api',
1703
1999
  originalUrl: originalUrl.substring(0, 100) + '...',
1704
2000
  cloudCodeUrl,
1705
2001
  model,
2002
+ projectId: projectId || '(none)',
1706
2003
  }));
1707
2004
 
1708
2005
  // Transform request body to Cloud Code format
1709
2006
  let body = init?.body;
1710
2007
  if (typeof body === 'string') {
1711
- body = transformRequestBody(body, model);
2008
+ body = transformRequestBody(body, model, projectId);
1712
2009
  }
1713
2010
 
1714
- // Make request to Cloud Code API with Bearer token
2011
+ // Build Cloud Code API URL with alt=sse for streaming
2012
+ let finalCloudCodeUrl = cloudCodeUrl;
2013
+ if (isStreamingRequest(originalUrl)) {
2014
+ const separator = finalCloudCodeUrl.includes('?') ? '&' : '?';
2015
+ finalCloudCodeUrl = `${finalCloudCodeUrl}${separator}alt=sse`;
2016
+ }
2017
+
2018
+ // Make request to Cloud Code API with Bearer token and retry logic
1715
2019
  const headers: Record<string, string> = {
1716
2020
  ...(init?.headers as Record<string, string>),
1717
2021
  Authorization: `Bearer ${currentAuth.access}`,
1718
- 'x-goog-api-client': 'agent/0.6.3',
2022
+ 'x-goog-api-client': `agent/${process.env['npm_package_version'] || '0.7.0'}`,
1719
2023
  };
1720
- // Remove any API key header if present since we're using OAuth
1721
2024
  delete headers['x-goog-api-key'];
1722
2025
 
1723
2026
  log.debug(() => ({
1724
2027
  message: 'sending request to cloud code api',
1725
- url: cloudCodeUrl,
2028
+ url: finalCloudCodeUrl,
1726
2029
  hasBody: !!body,
1727
2030
  }));
1728
2031
 
1729
- const cloudCodeResponse = await fetch(cloudCodeUrl, {
1730
- ...init,
1731
- body,
1732
- headers,
1733
- });
2032
+ // Retry loop for transient errors
2033
+ let lastResponse: Response | null = null;
2034
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
2035
+ if (attempt > 0) {
2036
+ const delay = getRetryDelay(lastResponse!, attempt - 1);
2037
+ log.info(() => ({
2038
+ message: 'retrying cloud code api request',
2039
+ attempt,
2040
+ delayMs: delay,
2041
+ previousStatus: lastResponse?.status,
2042
+ }));
2043
+ await new Promise((resolve) => setTimeout(resolve, delay));
2044
+ }
1734
2045
 
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
- }));
2046
+ const cloudCodeResponse = await fetch(finalCloudCodeUrl, {
2047
+ ...init,
2048
+ body,
2049
+ headers,
2050
+ });
1741
2051
 
1742
- // Check for errors and handle fallback
1743
- if (!cloudCodeResponse.ok) {
1744
- // Try to get error details for logging
2052
+ log.debug(() => ({
2053
+ message: 'cloud code api response received',
2054
+ status: cloudCodeResponse.status,
2055
+ statusText: cloudCodeResponse.statusText,
2056
+ contentType: cloudCodeResponse.headers.get('content-type'),
2057
+ attempt,
2058
+ }));
2059
+
2060
+ // Success - transform and return
2061
+ if (cloudCodeResponse.ok) {
2062
+ log.debug(() => ({
2063
+ message:
2064
+ 'cloud code api request successful, transforming response',
2065
+ }));
2066
+ return transformResponseBody(cloudCodeResponse);
2067
+ }
2068
+
2069
+ // Retryable error
2070
+ if (
2071
+ isRetryableStatus(cloudCodeResponse.status) &&
2072
+ attempt < MAX_RETRIES
2073
+ ) {
2074
+ lastResponse = cloudCodeResponse;
2075
+ continue;
2076
+ }
2077
+
2078
+ // Non-retryable error or max retries reached
1745
2079
  const errorBody = await cloudCodeResponse
1746
2080
  .clone()
1747
2081
  .text()
@@ -1751,6 +2085,7 @@ const GooglePlugin: AuthPlugin = {
1751
2085
  status: cloudCodeResponse.status,
1752
2086
  statusText: cloudCodeResponse.statusText,
1753
2087
  errorBody: errorBody.substring(0, 500),
2088
+ attempt,
1754
2089
  }));
1755
2090
 
1756
2091
  const fallbackApiKey = getFallbackApiKey();
@@ -1762,18 +2097,12 @@ const GooglePlugin: AuthPlugin = {
1762
2097
  fallbackTarget: originalUrl.substring(0, 100),
1763
2098
  }));
1764
2099
 
1765
- // Fall back to standard API with API key
1766
2100
  const apiKeyHeaders: Record<string, string> = {
1767
2101
  ...(init?.headers as Record<string, string>),
1768
2102
  'x-goog-api-key': fallbackApiKey,
1769
2103
  };
1770
2104
  delete apiKeyHeaders['Authorization'];
1771
2105
 
1772
- log.debug(() => ({
1773
- message: 'sending fallback request with api key',
1774
- url: originalUrl.substring(0, 100),
1775
- }));
1776
-
1777
2106
  return fetch(originalUrl, {
1778
2107
  ...init,
1779
2108
  headers: apiKeyHeaders,
@@ -1786,16 +2115,11 @@ const GooglePlugin: AuthPlugin = {
1786
2115
  hint: 'Set GOOGLE_GENERATIVE_AI_API_KEY or GEMINI_API_KEY environment variable for fallback',
1787
2116
  }));
1788
2117
 
1789
- // No fallback available, return the error response
1790
2118
  return cloudCodeResponse;
1791
2119
  }
1792
2120
 
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);
2121
+ // Should not reach here, but return last response as safety net
2122
+ return lastResponse!;
1799
2123
  }
1800
2124
 
1801
2125
  // Not a Generative Language API request, use standard OAuth flow
@@ -1857,6 +2181,455 @@ const GooglePlugin: AuthPlugin = {
1857
2181
  },
1858
2182
  };
1859
2183
 
2184
+ /**
2185
+ * Qwen OAuth Configuration
2186
+ * Used for Qwen Coder subscription authentication via chat.qwen.ai
2187
+ *
2188
+ * Based on the official Qwen Code CLI (QwenLM/qwen-code)
2189
+ * and qwen-auth-opencode reference implementation:
2190
+ * https://github.com/QwenLM/Qwen3-Coder
2191
+ * https://github.com/lion-lef/qwen-auth-opencode
2192
+ */
2193
+ const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
2194
+ const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
2195
+ const QWEN_OAUTH_DEVICE_CODE_ENDPOINT =
2196
+ 'https://chat.qwen.ai/api/v1/oauth2/device/code';
2197
+ const QWEN_OAUTH_TOKEN_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/token';
2198
+ const QWEN_OAUTH_DEFAULT_API_URL = 'https://portal.qwen.ai/v1';
2199
+
2200
+ /**
2201
+ * Detect if running in a headless environment (no GUI)
2202
+ */
2203
+ function isHeadlessEnvironment(): boolean {
2204
+ // Check common headless indicators
2205
+ if (!process.stdout.isTTY) return true;
2206
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
2207
+ if (process.env.CI) return true;
2208
+ if (!process.env.DISPLAY && process.platform === 'linux') return true;
2209
+ return false;
2210
+ }
2211
+
2212
+ /**
2213
+ * Open URL in the default browser
2214
+ */
2215
+ function openBrowser(url: string): void {
2216
+ const platform = process.platform;
2217
+ let command: string;
2218
+
2219
+ if (platform === 'darwin') {
2220
+ command = 'open';
2221
+ } else if (platform === 'win32') {
2222
+ command = 'start';
2223
+ } else {
2224
+ command = 'xdg-open';
2225
+ }
2226
+
2227
+ Bun.spawn([command, url], { stdout: 'ignore', stderr: 'ignore' });
2228
+ }
2229
+
2230
+ /**
2231
+ * Qwen OAuth Plugin
2232
+ * Supports Qwen Coder subscription via OAuth device flow.
2233
+ *
2234
+ * Uses OAuth 2.0 Device Authorization Grant (RFC 8628) with PKCE (RFC 7636),
2235
+ * matching the official Qwen Code CLI implementation.
2236
+ *
2237
+ * @see https://github.com/QwenLM/Qwen3-Coder
2238
+ */
2239
+ const QwenPlugin: AuthPlugin = {
2240
+ provider: 'qwen-coder',
2241
+ methods: [
2242
+ {
2243
+ label: 'Qwen Coder Subscription (OAuth)',
2244
+ type: 'oauth',
2245
+ async authorize() {
2246
+ // Generate PKCE pair
2247
+ const codeVerifier = generateRandomString(32);
2248
+ const codeChallenge = generateCodeChallenge(codeVerifier);
2249
+
2250
+ // Request device code
2251
+ const deviceResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
2252
+ method: 'POST',
2253
+ headers: {
2254
+ 'Content-Type': 'application/x-www-form-urlencoded',
2255
+ Accept: 'application/json',
2256
+ },
2257
+ body: new URLSearchParams({
2258
+ client_id: QWEN_OAUTH_CLIENT_ID,
2259
+ scope: QWEN_OAUTH_SCOPE,
2260
+ code_challenge: codeChallenge,
2261
+ code_challenge_method: 'S256',
2262
+ }).toString(),
2263
+ });
2264
+
2265
+ if (!deviceResponse.ok) {
2266
+ const errorText = await deviceResponse.text();
2267
+ log.error(() => ({
2268
+ message: 'qwen oauth device code request failed',
2269
+ status: deviceResponse.status,
2270
+ error: errorText,
2271
+ }));
2272
+ throw new Error(
2273
+ `Device authorization failed: ${deviceResponse.status}`
2274
+ );
2275
+ }
2276
+
2277
+ const deviceData = (await deviceResponse.json()) as {
2278
+ device_code: string;
2279
+ user_code: string;
2280
+ verification_uri: string;
2281
+ verification_uri_complete: string;
2282
+ expires_in: number;
2283
+ interval?: number;
2284
+ };
2285
+
2286
+ const pollInterval = (deviceData.interval || 2) * 1000;
2287
+ const maxPollAttempts = Math.ceil(
2288
+ deviceData.expires_in / (pollInterval / 1000)
2289
+ );
2290
+
2291
+ // Try to open browser in non-headless environments
2292
+ if (!isHeadlessEnvironment()) {
2293
+ try {
2294
+ openBrowser(deviceData.verification_uri_complete);
2295
+ } catch {
2296
+ // Ignore browser open errors
2297
+ }
2298
+ }
2299
+
2300
+ const instructions = isHeadlessEnvironment()
2301
+ ? `Visit: ${deviceData.verification_uri}\nEnter code: ${deviceData.user_code}`
2302
+ : `Opening browser for authentication...\nIf browser doesn't open, visit: ${deviceData.verification_uri}\nEnter code: ${deviceData.user_code}`;
2303
+
2304
+ return {
2305
+ url: deviceData.verification_uri_complete,
2306
+ instructions,
2307
+ method: 'auto' as const,
2308
+ async callback(): Promise<AuthResult> {
2309
+ // Poll for authorization completion
2310
+ for (let attempt = 0; attempt < maxPollAttempts; attempt++) {
2311
+ const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2312
+ method: 'POST',
2313
+ headers: {
2314
+ 'Content-Type': 'application/x-www-form-urlencoded',
2315
+ Accept: 'application/json',
2316
+ },
2317
+ body: new URLSearchParams({
2318
+ client_id: QWEN_OAUTH_CLIENT_ID,
2319
+ device_code: deviceData.device_code,
2320
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
2321
+ code_verifier: codeVerifier,
2322
+ }).toString(),
2323
+ });
2324
+
2325
+ if (!tokenResponse.ok) {
2326
+ const errorText = await tokenResponse.text();
2327
+ try {
2328
+ const errorJson = JSON.parse(errorText);
2329
+ if (
2330
+ errorJson.error === 'authorization_pending' ||
2331
+ errorJson.error === 'slow_down'
2332
+ ) {
2333
+ await new Promise((resolve) =>
2334
+ setTimeout(
2335
+ resolve,
2336
+ errorJson.error === 'slow_down'
2337
+ ? pollInterval * 1.5
2338
+ : pollInterval
2339
+ )
2340
+ );
2341
+ continue;
2342
+ }
2343
+ } catch {
2344
+ // JSON parse failed, treat as regular error
2345
+ }
2346
+
2347
+ log.error(() => ({
2348
+ message: 'qwen oauth token poll failed',
2349
+ status: tokenResponse.status,
2350
+ error: errorText,
2351
+ }));
2352
+ return { type: 'failed' };
2353
+ }
2354
+
2355
+ const tokenData = (await tokenResponse.json()) as {
2356
+ access_token: string;
2357
+ refresh_token?: string;
2358
+ token_type: string;
2359
+ expires_in: number;
2360
+ resource_url?: string;
2361
+ };
2362
+
2363
+ return {
2364
+ type: 'success',
2365
+ refresh: tokenData.refresh_token || '',
2366
+ access: tokenData.access_token,
2367
+ expires: Date.now() + tokenData.expires_in * 1000,
2368
+ };
2369
+ }
2370
+
2371
+ log.error(() => ({
2372
+ message: 'qwen oauth authorization timeout',
2373
+ }));
2374
+ return { type: 'failed' };
2375
+ },
2376
+ };
2377
+ },
2378
+ },
2379
+ ],
2380
+ async loader(getAuth, provider) {
2381
+ const auth = await getAuth();
2382
+ if (!auth || auth.type !== 'oauth') return {};
2383
+
2384
+ // Zero out cost for subscription users (free tier)
2385
+ if (provider?.models) {
2386
+ for (const model of Object.values(provider.models)) {
2387
+ (model as any).cost = {
2388
+ input: 0,
2389
+ output: 0,
2390
+ cache: {
2391
+ read: 0,
2392
+ write: 0,
2393
+ },
2394
+ };
2395
+ }
2396
+ }
2397
+
2398
+ return {
2399
+ apiKey: 'oauth-token-used-via-custom-fetch',
2400
+ baseURL: QWEN_OAUTH_DEFAULT_API_URL,
2401
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
2402
+ let currentAuth = await getAuth();
2403
+ if (!currentAuth || currentAuth.type !== 'oauth')
2404
+ return fetch(input, init);
2405
+
2406
+ // Refresh token if expired (with 5 minute buffer)
2407
+ const FIVE_MIN_MS = 5 * 60 * 1000;
2408
+ if (
2409
+ !currentAuth.access ||
2410
+ currentAuth.expires < Date.now() + FIVE_MIN_MS
2411
+ ) {
2412
+ if (!currentAuth.refresh) {
2413
+ log.error(() => ({
2414
+ message:
2415
+ 'qwen oauth token expired and no refresh token available',
2416
+ }));
2417
+ throw new Error(
2418
+ 'Qwen OAuth token expired. Please re-authenticate with: agent auth login'
2419
+ );
2420
+ }
2421
+
2422
+ log.info(() => ({
2423
+ message: 'refreshing qwen oauth token',
2424
+ reason: !currentAuth.access
2425
+ ? 'no access token'
2426
+ : 'token expiring soon',
2427
+ }));
2428
+
2429
+ const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2430
+ method: 'POST',
2431
+ headers: {
2432
+ 'Content-Type': 'application/x-www-form-urlencoded',
2433
+ Accept: 'application/json',
2434
+ },
2435
+ body: new URLSearchParams({
2436
+ grant_type: 'refresh_token',
2437
+ refresh_token: currentAuth.refresh,
2438
+ client_id: QWEN_OAUTH_CLIENT_ID,
2439
+ }),
2440
+ });
2441
+
2442
+ if (!response.ok) {
2443
+ const errorText = await response.text().catch(() => 'unknown');
2444
+ log.error(() => ({
2445
+ message: 'qwen oauth token refresh failed',
2446
+ status: response.status,
2447
+ error: errorText.substring(0, 200),
2448
+ }));
2449
+ throw new Error(
2450
+ `Qwen token refresh failed: ${response.status}. Please re-authenticate with: agent auth login`
2451
+ );
2452
+ }
2453
+
2454
+ const json = await response.json();
2455
+ log.info(() => ({
2456
+ message: 'qwen oauth token refreshed successfully',
2457
+ expiresIn: json.expires_in,
2458
+ }));
2459
+
2460
+ await Auth.set('qwen-coder', {
2461
+ type: 'oauth',
2462
+ refresh: json.refresh_token || currentAuth.refresh,
2463
+ access: json.access_token,
2464
+ expires: Date.now() + json.expires_in * 1000,
2465
+ });
2466
+ currentAuth = {
2467
+ type: 'oauth',
2468
+ refresh: json.refresh_token || currentAuth.refresh,
2469
+ access: json.access_token,
2470
+ expires: Date.now() + json.expires_in * 1000,
2471
+ };
2472
+ }
2473
+
2474
+ const headers: Record<string, string> = {
2475
+ ...(init?.headers as Record<string, string>),
2476
+ Authorization: `Bearer ${currentAuth.access}`,
2477
+ };
2478
+ delete headers['x-api-key'];
2479
+
2480
+ return fetch(input, {
2481
+ ...init,
2482
+ headers,
2483
+ });
2484
+ },
2485
+ };
2486
+ },
2487
+ };
2488
+
2489
+ /**
2490
+ * Alibaba Plugin (alias for Qwen Coder)
2491
+ * This provides a separate menu entry for Alibaba
2492
+ * with the same Qwen Coder subscription authentication.
2493
+ */
2494
+ const AlibabaPlugin: AuthPlugin = {
2495
+ provider: 'alibaba',
2496
+ methods: [
2497
+ {
2498
+ label: 'Qwen Coder Subscription (OAuth)',
2499
+ type: 'oauth',
2500
+ async authorize() {
2501
+ // Delegate to QwenPlugin's OAuth method
2502
+ const qwenMethod = QwenPlugin.methods[0];
2503
+ if (qwenMethod?.authorize) {
2504
+ const result = await qwenMethod.authorize({});
2505
+ // Override the callback to save as alibaba provider
2506
+ if ('callback' in result) {
2507
+ const originalCallback = result.callback;
2508
+ return {
2509
+ ...result,
2510
+ async callback(code?: string): Promise<AuthResult> {
2511
+ const authResult = await originalCallback(code);
2512
+ if (authResult.type === 'success' && 'refresh' in authResult) {
2513
+ return {
2514
+ ...authResult,
2515
+ provider: 'alibaba',
2516
+ };
2517
+ }
2518
+ return authResult;
2519
+ },
2520
+ };
2521
+ }
2522
+ }
2523
+ return {
2524
+ method: 'auto' as const,
2525
+ async callback(): Promise<AuthResult> {
2526
+ return { type: 'failed' };
2527
+ },
2528
+ };
2529
+ },
2530
+ },
2531
+ ],
2532
+ async loader(getAuth, provider) {
2533
+ const auth = await getAuth();
2534
+ if (!auth || auth.type !== 'oauth') return {};
2535
+
2536
+ // Zero out cost for subscription users (free tier)
2537
+ if (provider?.models) {
2538
+ for (const model of Object.values(provider.models)) {
2539
+ (model as any).cost = {
2540
+ input: 0,
2541
+ output: 0,
2542
+ cache: {
2543
+ read: 0,
2544
+ write: 0,
2545
+ },
2546
+ };
2547
+ }
2548
+ }
2549
+
2550
+ return {
2551
+ apiKey: 'oauth-token-used-via-custom-fetch',
2552
+ baseURL: QWEN_OAUTH_DEFAULT_API_URL,
2553
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
2554
+ let currentAuth = await getAuth();
2555
+ if (!currentAuth || currentAuth.type !== 'oauth')
2556
+ return fetch(input, init);
2557
+
2558
+ // Refresh token if expired (with 5 minute buffer)
2559
+ const FIVE_MIN_MS = 5 * 60 * 1000;
2560
+ if (
2561
+ !currentAuth.access ||
2562
+ currentAuth.expires < Date.now() + FIVE_MIN_MS
2563
+ ) {
2564
+ if (!currentAuth.refresh) {
2565
+ log.error(() => ({
2566
+ message:
2567
+ 'qwen oauth token expired and no refresh token available (alibaba)',
2568
+ }));
2569
+ throw new Error(
2570
+ 'Qwen OAuth token expired. Please re-authenticate with: agent auth login'
2571
+ );
2572
+ }
2573
+
2574
+ log.info(() => ({
2575
+ message: 'refreshing qwen oauth token (alibaba provider)',
2576
+ }));
2577
+
2578
+ const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2579
+ method: 'POST',
2580
+ headers: {
2581
+ 'Content-Type': 'application/x-www-form-urlencoded',
2582
+ Accept: 'application/json',
2583
+ },
2584
+ body: new URLSearchParams({
2585
+ grant_type: 'refresh_token',
2586
+ refresh_token: currentAuth.refresh,
2587
+ client_id: QWEN_OAUTH_CLIENT_ID,
2588
+ }),
2589
+ });
2590
+
2591
+ if (!response.ok) {
2592
+ const errorText = await response.text().catch(() => 'unknown');
2593
+ log.error(() => ({
2594
+ message: 'qwen oauth token refresh failed (alibaba)',
2595
+ status: response.status,
2596
+ error: errorText.substring(0, 200),
2597
+ }));
2598
+ throw new Error(
2599
+ `Qwen token refresh failed: ${response.status}. Please re-authenticate with: agent auth login`
2600
+ );
2601
+ }
2602
+
2603
+ const json = await response.json();
2604
+ await Auth.set('alibaba', {
2605
+ type: 'oauth',
2606
+ refresh: json.refresh_token || currentAuth.refresh,
2607
+ access: json.access_token,
2608
+ expires: Date.now() + json.expires_in * 1000,
2609
+ });
2610
+ currentAuth = {
2611
+ type: 'oauth',
2612
+ refresh: json.refresh_token || currentAuth.refresh,
2613
+ access: json.access_token,
2614
+ expires: Date.now() + json.expires_in * 1000,
2615
+ };
2616
+ }
2617
+
2618
+ const headers: Record<string, string> = {
2619
+ ...(init?.headers as Record<string, string>),
2620
+ Authorization: `Bearer ${currentAuth.access}`,
2621
+ };
2622
+ delete headers['x-api-key'];
2623
+
2624
+ return fetch(input, {
2625
+ ...init,
2626
+ headers,
2627
+ });
2628
+ },
2629
+ };
2630
+ },
2631
+ };
2632
+
1860
2633
  /**
1861
2634
  * Registry of all auth plugins
1862
2635
  */
@@ -1865,6 +2638,8 @@ const plugins: Record<string, AuthPlugin> = {
1865
2638
  'github-copilot': GitHubCopilotPlugin,
1866
2639
  openai: OpenAIPlugin,
1867
2640
  google: GooglePlugin,
2641
+ 'qwen-coder': QwenPlugin,
2642
+ alibaba: AlibabaPlugin,
1868
2643
  };
1869
2644
 
1870
2645
  /**
package/src/cli/output.ts CHANGED
@@ -199,5 +199,24 @@ export function outputInput(
199
199
  writeStdout(message, compact);
200
200
  }
201
201
 
202
+ /**
203
+ * Output a help/informational message to stdout
204
+ * Used for CLI help text display (not an error condition)
205
+ */
206
+ export function outputHelp(
207
+ help: {
208
+ message: string;
209
+ hint?: string;
210
+ [key: string]: unknown;
211
+ },
212
+ compact?: boolean
213
+ ): void {
214
+ const message: OutputMessage = {
215
+ type: 'status',
216
+ ...help,
217
+ };
218
+ writeStdout(message, compact);
219
+ }
220
+
202
221
  // Re-export for backward compatibility
203
222
  export { output as write };
package/src/index.js CHANGED
@@ -31,9 +31,11 @@ import { createBusEventSubscription } from './cli/event-handler.js';
31
31
  import {
32
32
  outputStatus,
33
33
  outputError,
34
+ outputHelp,
34
35
  setCompactJson,
35
36
  outputInput,
36
37
  } from './cli/output.ts';
38
+ import stripAnsi from 'strip-ansi';
37
39
  import { createRequire } from 'module';
38
40
  import { readFileSync } from 'fs';
39
41
  import { dirname, join } from 'path';
@@ -899,14 +901,14 @@ async function main() {
899
901
  process.exit(1);
900
902
  }
901
903
 
902
- // Handle validation errors (msg without err)
904
+ // Handle validation messages (msg without err) - informational, not an error
905
+ // Display help text on stdout (industry standard: git, gh, npm all use stdout for help)
903
906
  if (msg) {
904
- outputError({
905
- errorType: 'ValidationError',
907
+ outputHelp({
906
908
  message: msg,
907
- hint: yargs.help(),
909
+ hint: stripAnsi(yargs.help()),
908
910
  });
909
- process.exit(1);
911
+ process.exit(0);
910
912
  }
911
913
  })
912
914
  .help();
@@ -19,8 +19,11 @@ import { Auth } from '../auth';
19
19
  const log = Log.create({ service: 'google-cloudcode' });
20
20
 
21
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';
22
+ // Configurable via environment variables for testing or alternative endpoints
23
+ const CODE_ASSIST_ENDPOINT =
24
+ process.env['CODE_ASSIST_ENDPOINT'] || 'https://cloudcode-pa.googleapis.com';
25
+ const CODE_ASSIST_API_VERSION =
26
+ process.env['CODE_ASSIST_API_VERSION'] || 'v1internal';
24
27
 
25
28
  // Google OAuth endpoints
26
29
  const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
@@ -216,14 +219,16 @@ export class CloudCodeClient {
216
219
  ): Promise<T | Response> {
217
220
  await this.ensureValidToken();
218
221
 
219
- const url = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`;
222
+ // Add alt=sse query param for streaming requests (matches Gemini CLI behavior)
223
+ const baseUrl = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`;
224
+ const url = options.stream ? `${baseUrl}?alt=sse` : baseUrl;
220
225
 
221
226
  const response = await fetch(url, {
222
227
  method: 'POST',
223
228
  headers: {
224
229
  'Content-Type': 'application/json',
225
230
  Authorization: `Bearer ${this.accessToken}`,
226
- 'x-goog-api-client': 'agent/0.6.3',
231
+ 'x-goog-api-client': `agent/${process.env['npm_package_version'] || '0.7.0'}`,
227
232
  },
228
233
  body: JSON.stringify(body),
229
234
  });
@@ -321,6 +321,66 @@ export namespace Provider {
321
321
  options: {},
322
322
  };
323
323
  },
324
+ /**
325
+ * Qwen Coder OAuth provider for Qwen subscription users
326
+ * Uses OAuth credentials from agent auth login (Qwen Coder Subscription)
327
+ *
328
+ * To authenticate, run: agent auth login (select Qwen Coder)
329
+ */
330
+ 'qwen-coder': async (input) => {
331
+ const auth = await Auth.get('qwen-coder');
332
+ if (auth?.type === 'oauth') {
333
+ log.info(() => ({
334
+ message: 'using qwen-coder oauth credentials',
335
+ }));
336
+ const loaderFn = await AuthPlugins.getLoader('qwen-coder');
337
+ if (loaderFn) {
338
+ const result = await loaderFn(() => Auth.get('qwen-coder'), input);
339
+ if (result.fetch) {
340
+ return {
341
+ autoload: true,
342
+ options: {
343
+ apiKey: result.apiKey || '',
344
+ baseURL: result.baseURL,
345
+ fetch: result.fetch,
346
+ },
347
+ };
348
+ }
349
+ }
350
+ }
351
+ // Default: not auto-loaded without OAuth
352
+ return { autoload: false };
353
+ },
354
+ /**
355
+ * Alibaba OAuth provider (alias for Qwen Coder)
356
+ * Uses OAuth credentials from agent auth login (Alibaba / Qwen Coder Subscription)
357
+ *
358
+ * To authenticate, run: agent auth login (select Alibaba)
359
+ */
360
+ alibaba: async (input) => {
361
+ const auth = await Auth.get('alibaba');
362
+ if (auth?.type === 'oauth') {
363
+ log.info(() => ({
364
+ message: 'using alibaba oauth credentials',
365
+ }));
366
+ const loaderFn = await AuthPlugins.getLoader('alibaba');
367
+ if (loaderFn) {
368
+ const result = await loaderFn(() => Auth.get('alibaba'), input);
369
+ if (result.fetch) {
370
+ return {
371
+ autoload: true,
372
+ options: {
373
+ apiKey: result.apiKey || '',
374
+ baseURL: result.baseURL,
375
+ fetch: result.fetch,
376
+ },
377
+ };
378
+ }
379
+ }
380
+ }
381
+ // Default: not auto-loaded without OAuth
382
+ return { autoload: false };
383
+ },
324
384
  /**
325
385
  * Google OAuth provider for Gemini subscription users
326
386
  * Uses OAuth credentials from agent auth login (Google AI Pro/Ultra)