@link-assistant/agent 0.6.3 → 0.8.1
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 +9 -9
- package/src/auth/plugins.ts +538 -5
- package/src/cli/continuous-mode.js +188 -18
- package/src/cli/event-handler.js +99 -0
- package/src/config/config.ts +51 -0
- package/src/index.js +82 -157
- package/src/mcp/index.ts +125 -0
- package/src/provider/google-cloudcode.ts +384 -0
- package/src/session/message-v2.ts +30 -1
- package/src/session/processor.ts +21 -2
- package/src/session/prompt.ts +23 -1
- package/src/session/retry.ts +18 -0
- package/EXAMPLES.md +0 -462
- package/LICENSE +0 -24
- package/MODELS.md +0 -143
- package/README.md +0 -616
- package/TOOLS.md +0 -154
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"lint:fix": "eslint . --fix",
|
|
15
15
|
"format": "prettier --write .",
|
|
16
16
|
"format:check": "prettier --check .",
|
|
17
|
-
"check:file-size": "node scripts/check-file-size.mjs",
|
|
17
|
+
"check:file-size": "node ../scripts/check-file-size.mjs",
|
|
18
18
|
"check": "npm run lint && npm run format:check && npm run check:file-size",
|
|
19
|
-
"prepare": "husky || true",
|
|
19
|
+
"prepare": "cd .. && husky || true",
|
|
20
20
|
"changeset": "changeset",
|
|
21
|
-
"changeset:version": "node scripts/changeset-version.mjs",
|
|
21
|
+
"changeset:version": "node ../scripts/changeset-version.mjs",
|
|
22
22
|
"changeset:publish": "changeset publish",
|
|
23
23
|
"changeset:status": "changeset status --since=origin/main"
|
|
24
24
|
},
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"files": [
|
|
45
45
|
"src/",
|
|
46
46
|
"package.json",
|
|
47
|
-
"README.md",
|
|
48
|
-
"MODELS.md",
|
|
49
|
-
"TOOLS.md",
|
|
50
|
-
"EXAMPLES.md",
|
|
51
|
-
"LICENSE"
|
|
47
|
+
"../README.md",
|
|
48
|
+
"../MODELS.md",
|
|
49
|
+
"../TOOLS.md",
|
|
50
|
+
"../EXAMPLES.md",
|
|
51
|
+
"../LICENSE"
|
|
52
52
|
],
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@actions/core": "^1.11.1",
|
package/src/auth/plugins.ts
CHANGED
|
@@ -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(() => ({
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
},
|