@link-assistant/agent 0.8.22 → 0.9.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 +1 -1
- package/src/auth/plugins.ts +398 -74
- package/src/provider/google-cloudcode.ts +9 -4
package/package.json
CHANGED
package/src/auth/plugins.ts
CHANGED
|
@@ -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 =
|
|
1282
|
-
|
|
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;
|
|
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;
|
|
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 = (
|
|
1732
|
+
const transformRequestBody = (
|
|
1733
|
+
body: string,
|
|
1734
|
+
model: string,
|
|
1735
|
+
projectId?: string
|
|
1736
|
+
): string => {
|
|
1443
1737
|
try {
|
|
1444
|
-
|
|
1738
|
+
let parsed = JSON.parse(body);
|
|
1445
1739
|
|
|
1446
|
-
//
|
|
1447
|
-
|
|
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;
|
|
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
|
-
//
|
|
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': '
|
|
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:
|
|
2028
|
+
url: finalCloudCodeUrl,
|
|
1726
2029
|
hasBody: !!body,
|
|
1727
2030
|
}));
|
|
1728
2031
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
}));
|
|
2046
|
+
const cloudCodeResponse = await fetch(finalCloudCodeUrl, {
|
|
2047
|
+
...init,
|
|
2048
|
+
body,
|
|
2049
|
+
headers,
|
|
2050
|
+
});
|
|
1741
2051
|
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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
|
-
|
|
1794
|
-
|
|
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
|
|
@@ -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
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
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': '
|
|
231
|
+
'x-goog-api-client': `agent/${process.env['npm_package_version'] || '0.7.0'}`,
|
|
227
232
|
},
|
|
228
233
|
body: JSON.stringify(body),
|
|
229
234
|
});
|