@desplega.ai/agent-swarm 1.56.5 → 1.56.6
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/openapi.json +232 -1
- package/package.json +1 -1
- package/src/be/db.ts +154 -0
- package/src/be/migrations/028_api_key_tracking.sql +38 -0
- package/src/commands/runner.ts +124 -7
- package/src/http/api-keys.ts +168 -0
- package/src/http/index.ts +2 -0
- package/src/tests/api-key-tracking.test.ts +197 -0
- package/src/tests/error-tracker.test.ts +59 -0
- package/src/utils/credentials.test.ts +10 -10
- package/src/utils/credentials.ts +113 -14
- package/src/utils/error-tracker.ts +52 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.56.
|
|
5
|
+
"version": "1.56.5",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -1415,6 +1415,237 @@
|
|
|
1415
1415
|
}
|
|
1416
1416
|
}
|
|
1417
1417
|
},
|
|
1418
|
+
"/api/keys/report-usage": {
|
|
1419
|
+
"post": {
|
|
1420
|
+
"summary": "Record which API key was used for a task",
|
|
1421
|
+
"tags": [
|
|
1422
|
+
"API Keys"
|
|
1423
|
+
],
|
|
1424
|
+
"security": [
|
|
1425
|
+
{
|
|
1426
|
+
"bearerAuth": []
|
|
1427
|
+
}
|
|
1428
|
+
],
|
|
1429
|
+
"requestBody": {
|
|
1430
|
+
"content": {
|
|
1431
|
+
"application/json": {
|
|
1432
|
+
"schema": {
|
|
1433
|
+
"type": "object",
|
|
1434
|
+
"properties": {
|
|
1435
|
+
"keyType": {
|
|
1436
|
+
"type": "string"
|
|
1437
|
+
},
|
|
1438
|
+
"keySuffix": {
|
|
1439
|
+
"type": "string",
|
|
1440
|
+
"minLength": 1,
|
|
1441
|
+
"maxLength": 10
|
|
1442
|
+
},
|
|
1443
|
+
"keyIndex": {
|
|
1444
|
+
"type": "integer",
|
|
1445
|
+
"minimum": 0
|
|
1446
|
+
},
|
|
1447
|
+
"taskId": {
|
|
1448
|
+
"type": "string",
|
|
1449
|
+
"format": "uuid"
|
|
1450
|
+
},
|
|
1451
|
+
"scope": {
|
|
1452
|
+
"type": "string"
|
|
1453
|
+
},
|
|
1454
|
+
"scopeId": {
|
|
1455
|
+
"type": "string"
|
|
1456
|
+
}
|
|
1457
|
+
},
|
|
1458
|
+
"required": [
|
|
1459
|
+
"keyType",
|
|
1460
|
+
"keySuffix",
|
|
1461
|
+
"keyIndex"
|
|
1462
|
+
]
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
},
|
|
1467
|
+
"responses": {
|
|
1468
|
+
"200": {
|
|
1469
|
+
"description": "Usage recorded"
|
|
1470
|
+
},
|
|
1471
|
+
"400": {
|
|
1472
|
+
"description": "Validation error"
|
|
1473
|
+
},
|
|
1474
|
+
"401": {
|
|
1475
|
+
"description": "Unauthorized"
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
},
|
|
1480
|
+
"/api/keys/report-rate-limit": {
|
|
1481
|
+
"post": {
|
|
1482
|
+
"summary": "Mark an API key as rate-limited",
|
|
1483
|
+
"tags": [
|
|
1484
|
+
"API Keys"
|
|
1485
|
+
],
|
|
1486
|
+
"security": [
|
|
1487
|
+
{
|
|
1488
|
+
"bearerAuth": []
|
|
1489
|
+
}
|
|
1490
|
+
],
|
|
1491
|
+
"requestBody": {
|
|
1492
|
+
"content": {
|
|
1493
|
+
"application/json": {
|
|
1494
|
+
"schema": {
|
|
1495
|
+
"type": "object",
|
|
1496
|
+
"properties": {
|
|
1497
|
+
"keyType": {
|
|
1498
|
+
"type": "string"
|
|
1499
|
+
},
|
|
1500
|
+
"keySuffix": {
|
|
1501
|
+
"type": "string",
|
|
1502
|
+
"minLength": 1,
|
|
1503
|
+
"maxLength": 10
|
|
1504
|
+
},
|
|
1505
|
+
"keyIndex": {
|
|
1506
|
+
"type": "integer",
|
|
1507
|
+
"minimum": 0
|
|
1508
|
+
},
|
|
1509
|
+
"rateLimitedUntil": {
|
|
1510
|
+
"type": "string",
|
|
1511
|
+
"format": "date-time"
|
|
1512
|
+
},
|
|
1513
|
+
"scope": {
|
|
1514
|
+
"type": "string"
|
|
1515
|
+
},
|
|
1516
|
+
"scopeId": {
|
|
1517
|
+
"type": "string"
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1520
|
+
"required": [
|
|
1521
|
+
"keyType",
|
|
1522
|
+
"keySuffix",
|
|
1523
|
+
"keyIndex",
|
|
1524
|
+
"rateLimitedUntil"
|
|
1525
|
+
]
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
},
|
|
1530
|
+
"responses": {
|
|
1531
|
+
"200": {
|
|
1532
|
+
"description": "Key marked as rate-limited"
|
|
1533
|
+
},
|
|
1534
|
+
"400": {
|
|
1535
|
+
"description": "Validation error"
|
|
1536
|
+
},
|
|
1537
|
+
"401": {
|
|
1538
|
+
"description": "Unauthorized"
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
},
|
|
1543
|
+
"/api/keys/available": {
|
|
1544
|
+
"get": {
|
|
1545
|
+
"summary": "Get available (non-rate-limited) key indices for a credential type",
|
|
1546
|
+
"tags": [
|
|
1547
|
+
"API Keys"
|
|
1548
|
+
],
|
|
1549
|
+
"security": [
|
|
1550
|
+
{
|
|
1551
|
+
"bearerAuth": []
|
|
1552
|
+
}
|
|
1553
|
+
],
|
|
1554
|
+
"parameters": [
|
|
1555
|
+
{
|
|
1556
|
+
"schema": {
|
|
1557
|
+
"type": "string"
|
|
1558
|
+
},
|
|
1559
|
+
"required": true,
|
|
1560
|
+
"name": "keyType",
|
|
1561
|
+
"in": "query"
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
"schema": {
|
|
1565
|
+
"type": "integer",
|
|
1566
|
+
"minimum": 1
|
|
1567
|
+
},
|
|
1568
|
+
"required": true,
|
|
1569
|
+
"name": "totalKeys",
|
|
1570
|
+
"in": "query"
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
"schema": {
|
|
1574
|
+
"type": "string"
|
|
1575
|
+
},
|
|
1576
|
+
"required": false,
|
|
1577
|
+
"name": "scope",
|
|
1578
|
+
"in": "query"
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
"schema": {
|
|
1582
|
+
"type": "string"
|
|
1583
|
+
},
|
|
1584
|
+
"required": false,
|
|
1585
|
+
"name": "scopeId",
|
|
1586
|
+
"in": "query"
|
|
1587
|
+
}
|
|
1588
|
+
],
|
|
1589
|
+
"responses": {
|
|
1590
|
+
"200": {
|
|
1591
|
+
"description": "List of available key indices"
|
|
1592
|
+
},
|
|
1593
|
+
"400": {
|
|
1594
|
+
"description": "Validation error"
|
|
1595
|
+
},
|
|
1596
|
+
"401": {
|
|
1597
|
+
"description": "Unauthorized"
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
},
|
|
1602
|
+
"/api/keys/status": {
|
|
1603
|
+
"get": {
|
|
1604
|
+
"summary": "Get all API key status records",
|
|
1605
|
+
"tags": [
|
|
1606
|
+
"API Keys"
|
|
1607
|
+
],
|
|
1608
|
+
"security": [
|
|
1609
|
+
{
|
|
1610
|
+
"bearerAuth": []
|
|
1611
|
+
}
|
|
1612
|
+
],
|
|
1613
|
+
"parameters": [
|
|
1614
|
+
{
|
|
1615
|
+
"schema": {
|
|
1616
|
+
"type": "string"
|
|
1617
|
+
},
|
|
1618
|
+
"required": false,
|
|
1619
|
+
"name": "keyType",
|
|
1620
|
+
"in": "query"
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
"schema": {
|
|
1624
|
+
"type": "string"
|
|
1625
|
+
},
|
|
1626
|
+
"required": false,
|
|
1627
|
+
"name": "scope",
|
|
1628
|
+
"in": "query"
|
|
1629
|
+
},
|
|
1630
|
+
{
|
|
1631
|
+
"schema": {
|
|
1632
|
+
"type": "string"
|
|
1633
|
+
},
|
|
1634
|
+
"required": false,
|
|
1635
|
+
"name": "scopeId",
|
|
1636
|
+
"in": "query"
|
|
1637
|
+
}
|
|
1638
|
+
],
|
|
1639
|
+
"responses": {
|
|
1640
|
+
"200": {
|
|
1641
|
+
"description": "List of key status records"
|
|
1642
|
+
},
|
|
1643
|
+
"401": {
|
|
1644
|
+
"description": "Unauthorized"
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1418
1649
|
"/api/events": {
|
|
1419
1650
|
"post": {
|
|
1420
1651
|
"summary": "Store a single event",
|
package/package.json
CHANGED
package/src/be/db.ts
CHANGED
|
@@ -7487,3 +7487,157 @@ export function getContextSummaryByTaskId(taskId: string): ContextSummary {
|
|
|
7487
7487
|
snapshotCount: countRow?.cnt ?? 0,
|
|
7488
7488
|
};
|
|
7489
7489
|
}
|
|
7490
|
+
|
|
7491
|
+
// ─── API Key Pool Tracking ───────────────────────────────────────────────────
|
|
7492
|
+
|
|
7493
|
+
export interface ApiKeyStatus {
|
|
7494
|
+
id: string;
|
|
7495
|
+
keyType: string;
|
|
7496
|
+
keySuffix: string;
|
|
7497
|
+
keyIndex: number;
|
|
7498
|
+
scope: string;
|
|
7499
|
+
scopeId: string | null;
|
|
7500
|
+
status: string;
|
|
7501
|
+
rateLimitedUntil: string | null;
|
|
7502
|
+
lastUsedAt: string | null;
|
|
7503
|
+
lastRateLimitAt: string | null;
|
|
7504
|
+
totalUsageCount: number;
|
|
7505
|
+
rateLimitCount: number;
|
|
7506
|
+
createdAt: string;
|
|
7507
|
+
updatedAt: string;
|
|
7508
|
+
}
|
|
7509
|
+
|
|
7510
|
+
/**
|
|
7511
|
+
* Get available (non-rate-limited) key indices for a credential type.
|
|
7512
|
+
* Automatically clears expired rate limits before returning.
|
|
7513
|
+
*/
|
|
7514
|
+
export function getAvailableKeyIndices(
|
|
7515
|
+
keyType: string,
|
|
7516
|
+
totalKeys: number,
|
|
7517
|
+
scope = "global",
|
|
7518
|
+
scopeId: string | null = null,
|
|
7519
|
+
): number[] {
|
|
7520
|
+
const now = new Date().toISOString();
|
|
7521
|
+
const db = getDb();
|
|
7522
|
+
const effectiveScopeId = scopeId ?? "";
|
|
7523
|
+
|
|
7524
|
+
// Auto-clear expired rate limits
|
|
7525
|
+
db.prepare(
|
|
7526
|
+
`UPDATE api_key_status
|
|
7527
|
+
SET status = 'available', rateLimitedUntil = NULL, updatedAt = ?
|
|
7528
|
+
WHERE keyType = ? AND scope = ? AND scopeId = ?
|
|
7529
|
+
AND status = 'rate_limited' AND rateLimitedUntil IS NOT NULL AND rateLimitedUntil <= ?`,
|
|
7530
|
+
).run(now, keyType, scope, effectiveScopeId, now);
|
|
7531
|
+
|
|
7532
|
+
// Get currently rate-limited key indices
|
|
7533
|
+
const rateLimited = db
|
|
7534
|
+
.prepare<{ keyIndex: number }, [string, string, string]>(
|
|
7535
|
+
`SELECT keyIndex FROM api_key_status
|
|
7536
|
+
WHERE keyType = ? AND scope = ? AND scopeId = ?
|
|
7537
|
+
AND status = 'rate_limited'`,
|
|
7538
|
+
)
|
|
7539
|
+
.all(keyType, scope, effectiveScopeId);
|
|
7540
|
+
|
|
7541
|
+
const blockedIndices = new Set(rateLimited.map((r) => r.keyIndex));
|
|
7542
|
+
const available: number[] = [];
|
|
7543
|
+
for (let i = 0; i < totalKeys; i++) {
|
|
7544
|
+
if (!blockedIndices.has(i)) available.push(i);
|
|
7545
|
+
}
|
|
7546
|
+
return available;
|
|
7547
|
+
}
|
|
7548
|
+
|
|
7549
|
+
/**
|
|
7550
|
+
* Record that a key was used for a task (upsert key status + update task).
|
|
7551
|
+
*/
|
|
7552
|
+
export function recordKeyUsage(
|
|
7553
|
+
keyType: string,
|
|
7554
|
+
keySuffix: string,
|
|
7555
|
+
keyIndex: number,
|
|
7556
|
+
taskId: string | null,
|
|
7557
|
+
scope = "global",
|
|
7558
|
+
scopeId: string | null = null,
|
|
7559
|
+
): void {
|
|
7560
|
+
const now = new Date().toISOString();
|
|
7561
|
+
const db = getDb();
|
|
7562
|
+
const effectiveScopeId = scopeId ?? "";
|
|
7563
|
+
|
|
7564
|
+
// Upsert key status record
|
|
7565
|
+
db.prepare(
|
|
7566
|
+
`INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, lastUsedAt, totalUsageCount, updatedAt)
|
|
7567
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
|
7568
|
+
ON CONFLICT(keyType, keySuffix, scope, scopeId)
|
|
7569
|
+
DO UPDATE SET
|
|
7570
|
+
lastUsedAt = excluded.lastUsedAt,
|
|
7571
|
+
totalUsageCount = totalUsageCount + 1,
|
|
7572
|
+
keyIndex = excluded.keyIndex,
|
|
7573
|
+
updatedAt = excluded.updatedAt`,
|
|
7574
|
+
).run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, now, now);
|
|
7575
|
+
|
|
7576
|
+
// Record which key was used on the task
|
|
7577
|
+
if (taskId) {
|
|
7578
|
+
db.prepare("UPDATE agent_tasks SET credentialKeySuffix = ? WHERE id = ?").run(
|
|
7579
|
+
keySuffix,
|
|
7580
|
+
taskId,
|
|
7581
|
+
);
|
|
7582
|
+
}
|
|
7583
|
+
}
|
|
7584
|
+
|
|
7585
|
+
/**
|
|
7586
|
+
* Mark a key as rate-limited with a retry-after timestamp.
|
|
7587
|
+
*/
|
|
7588
|
+
export function markKeyRateLimited(
|
|
7589
|
+
keyType: string,
|
|
7590
|
+
keySuffix: string,
|
|
7591
|
+
keyIndex: number,
|
|
7592
|
+
rateLimitedUntil: string,
|
|
7593
|
+
scope = "global",
|
|
7594
|
+
scopeId: string | null = null,
|
|
7595
|
+
): void {
|
|
7596
|
+
const now = new Date().toISOString();
|
|
7597
|
+
const effectiveScopeId = scopeId ?? "";
|
|
7598
|
+
getDb()
|
|
7599
|
+
.prepare(
|
|
7600
|
+
`INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, status, rateLimitedUntil, lastRateLimitAt, rateLimitCount, updatedAt)
|
|
7601
|
+
VALUES (?, ?, ?, ?, ?, 'rate_limited', ?, ?, 1, ?)
|
|
7602
|
+
ON CONFLICT(keyType, keySuffix, scope, scopeId)
|
|
7603
|
+
DO UPDATE SET
|
|
7604
|
+
status = 'rate_limited',
|
|
7605
|
+
rateLimitedUntil = excluded.rateLimitedUntil,
|
|
7606
|
+
lastRateLimitAt = excluded.lastRateLimitAt,
|
|
7607
|
+
rateLimitCount = rateLimitCount + 1,
|
|
7608
|
+
keyIndex = excluded.keyIndex,
|
|
7609
|
+
updatedAt = excluded.updatedAt`,
|
|
7610
|
+
)
|
|
7611
|
+
.run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, rateLimitedUntil, now, now);
|
|
7612
|
+
}
|
|
7613
|
+
|
|
7614
|
+
/**
|
|
7615
|
+
* Get all key status records for a credential type.
|
|
7616
|
+
*/
|
|
7617
|
+
export function getKeyStatuses(
|
|
7618
|
+
keyType?: string,
|
|
7619
|
+
scope?: string,
|
|
7620
|
+
scopeId?: string | null,
|
|
7621
|
+
): ApiKeyStatus[] {
|
|
7622
|
+
const db = getDb();
|
|
7623
|
+
const conditions: string[] = [];
|
|
7624
|
+
const params: string[] = [];
|
|
7625
|
+
|
|
7626
|
+
if (keyType) {
|
|
7627
|
+
conditions.push("keyType = ?");
|
|
7628
|
+
params.push(keyType);
|
|
7629
|
+
}
|
|
7630
|
+
if (scope) {
|
|
7631
|
+
conditions.push("scope = ?");
|
|
7632
|
+
params.push(scope);
|
|
7633
|
+
if (scopeId !== undefined) {
|
|
7634
|
+
conditions.push("scopeId = ?");
|
|
7635
|
+
params.push(scopeId ?? "");
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7638
|
+
|
|
7639
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7640
|
+
return db
|
|
7641
|
+
.prepare<ApiKeyStatus, string[]>(`SELECT * FROM api_key_status ${where} ORDER BY keyIndex`)
|
|
7642
|
+
.all(...params);
|
|
7643
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Track API key pool status for rate limit awareness and automatic rotation
|
|
2
|
+
CREATE TABLE IF NOT EXISTS api_key_status (
|
|
3
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
4
|
+
-- Which credential type: 'CLAUDE_CODE_OAUTH_TOKEN' or 'ANTHROPIC_API_KEY'
|
|
5
|
+
keyType TEXT NOT NULL,
|
|
6
|
+
-- Last 5 characters of the key (for identification without storing secrets)
|
|
7
|
+
keySuffix TEXT NOT NULL,
|
|
8
|
+
-- Position in the comma-separated credential pool (0-based)
|
|
9
|
+
keyIndex INTEGER NOT NULL,
|
|
10
|
+
-- Scope mirrors swarm_config (global, agent, repo)
|
|
11
|
+
scope TEXT NOT NULL DEFAULT 'global',
|
|
12
|
+
scopeId TEXT NOT NULL DEFAULT '',
|
|
13
|
+
-- Current status
|
|
14
|
+
status TEXT NOT NULL DEFAULT 'available' CHECK(status IN ('available', 'rate_limited')),
|
|
15
|
+
-- When the rate limit expires (ISO timestamp from retry-after)
|
|
16
|
+
rateLimitedUntil TEXT,
|
|
17
|
+
-- Tracking timestamps
|
|
18
|
+
lastUsedAt TEXT,
|
|
19
|
+
lastRateLimitAt TEXT,
|
|
20
|
+
-- Counters
|
|
21
|
+
totalUsageCount INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
rateLimitCount INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
-- Metadata
|
|
24
|
+
createdAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
25
|
+
updatedAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
26
|
+
-- Unique constraint: one entry per key type + suffix + scope.
|
|
27
|
+
-- Known limitation: two different keys sharing the same last 5 chars will collide,
|
|
28
|
+
-- causing the ON CONFLICT upsert to overwrite keyIndex. In practice this is rare
|
|
29
|
+
-- (1 in ~60M for random alphanumeric suffixes) and the impact is minor (wrong index
|
|
30
|
+
-- logged, but rate-limit tracking still works since suffix is the real identifier).
|
|
31
|
+
UNIQUE(keyType, keySuffix, scope, scopeId)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_api_key_status_lookup
|
|
35
|
+
ON api_key_status(keyType, scope, scopeId, status);
|
|
36
|
+
|
|
37
|
+
-- Track which key was used per task (last 5 chars only)
|
|
38
|
+
ALTER TABLE agent_tasks ADD COLUMN credentialKeySuffix TEXT;
|
package/src/commands/runner.ts
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
type ProviderSessionConfig,
|
|
19
19
|
} from "../providers/index.ts";
|
|
20
20
|
import { getContextWindowSize } from "../utils/context-window.ts";
|
|
21
|
-
import { resolveCredentialPools } from "../utils/credentials.ts";
|
|
21
|
+
import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
|
|
22
|
+
import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
|
|
22
23
|
import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
|
|
23
24
|
import { detectVcsProvider } from "../vcs/index.ts";
|
|
24
25
|
import { interpolate } from "../workflows/template.ts";
|
|
@@ -178,12 +179,17 @@ async function closeAgent(config: ApiConfig, role: string): Promise<void> {
|
|
|
178
179
|
* Falls back to baseEnv on any error (network, parse, etc).
|
|
179
180
|
* Credential env vars with comma-separated values get one randomly selected.
|
|
180
181
|
*/
|
|
182
|
+
interface ResolvedEnvResult {
|
|
183
|
+
env: Record<string, string | undefined>;
|
|
184
|
+
credentialSelections: CredentialSelection[];
|
|
185
|
+
}
|
|
186
|
+
|
|
181
187
|
async function fetchResolvedEnv(
|
|
182
188
|
apiUrl: string,
|
|
183
189
|
apiKey: string,
|
|
184
190
|
agentId: string,
|
|
185
191
|
baseEnv: Record<string, string | undefined> = process.env,
|
|
186
|
-
): Promise<
|
|
192
|
+
): Promise<ResolvedEnvResult> {
|
|
187
193
|
const env: Record<string, string | undefined> = { ...baseEnv };
|
|
188
194
|
|
|
189
195
|
if (apiUrl && agentId) {
|
|
@@ -213,8 +219,9 @@ async function fetchResolvedEnv(
|
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
221
|
|
|
216
|
-
resolveCredentialPools(env);
|
|
217
|
-
|
|
222
|
+
const credentialSelections = await resolveCredentialPools(env, { apiUrl, apiKey });
|
|
223
|
+
|
|
224
|
+
return { env, credentialSelections };
|
|
218
225
|
}
|
|
219
226
|
|
|
220
227
|
/** Tools that produce noise — skip auto-progress for these */
|
|
@@ -545,6 +552,64 @@ async function ensureTaskFinished(
|
|
|
545
552
|
}
|
|
546
553
|
}
|
|
547
554
|
|
|
555
|
+
/** Report key usage to the API (fire-and-forget) */
|
|
556
|
+
async function reportKeyUsage(
|
|
557
|
+
apiUrl: string,
|
|
558
|
+
apiKey: string,
|
|
559
|
+
keyType: string,
|
|
560
|
+
selection: CredentialSelection,
|
|
561
|
+
taskId?: string,
|
|
562
|
+
): Promise<void> {
|
|
563
|
+
try {
|
|
564
|
+
await fetch(`${apiUrl}/api/keys/report-usage`, {
|
|
565
|
+
method: "POST",
|
|
566
|
+
headers: {
|
|
567
|
+
"Content-Type": "application/json",
|
|
568
|
+
Authorization: `Bearer ${apiKey}`,
|
|
569
|
+
},
|
|
570
|
+
body: JSON.stringify({
|
|
571
|
+
keyType,
|
|
572
|
+
keySuffix: selection.keySuffix,
|
|
573
|
+
keyIndex: selection.index,
|
|
574
|
+
taskId,
|
|
575
|
+
}),
|
|
576
|
+
});
|
|
577
|
+
} catch {
|
|
578
|
+
// Non-blocking
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Report a rate-limited key to the API (fire-and-forget) */
|
|
583
|
+
async function reportKeyRateLimit(
|
|
584
|
+
apiUrl: string,
|
|
585
|
+
apiKey: string,
|
|
586
|
+
keyType: string,
|
|
587
|
+
keySuffix: string,
|
|
588
|
+
keyIndex: number,
|
|
589
|
+
rateLimitedUntil: string,
|
|
590
|
+
): Promise<void> {
|
|
591
|
+
try {
|
|
592
|
+
await fetch(`${apiUrl}/api/keys/report-rate-limit`, {
|
|
593
|
+
method: "POST",
|
|
594
|
+
headers: {
|
|
595
|
+
"Content-Type": "application/json",
|
|
596
|
+
Authorization: `Bearer ${apiKey}`,
|
|
597
|
+
},
|
|
598
|
+
body: JSON.stringify({
|
|
599
|
+
keyType,
|
|
600
|
+
keySuffix,
|
|
601
|
+
keyIndex,
|
|
602
|
+
rateLimitedUntil,
|
|
603
|
+
}),
|
|
604
|
+
});
|
|
605
|
+
console.log(
|
|
606
|
+
`[credentials] Reported key ...${keySuffix} as rate-limited until ${rateLimitedUntil}`,
|
|
607
|
+
);
|
|
608
|
+
} catch {
|
|
609
|
+
// Non-blocking
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
548
613
|
/**
|
|
549
614
|
* Pause a task via the API (for graceful shutdown).
|
|
550
615
|
* Unlike marking as failed, paused tasks can be resumed after container restart.
|
|
@@ -775,6 +840,12 @@ interface RunningTask {
|
|
|
775
840
|
cursorUpdates?: Array<{ channelId: string; ts: string }>;
|
|
776
841
|
/** Resolved working directory for VCS detection */
|
|
777
842
|
workingDir?: string;
|
|
843
|
+
/** Credential tracking: which key was used for this task */
|
|
844
|
+
credentialInfo?: {
|
|
845
|
+
keyType: string;
|
|
846
|
+
keySuffix: string;
|
|
847
|
+
keyIndex: number;
|
|
848
|
+
};
|
|
778
849
|
}
|
|
779
850
|
|
|
780
851
|
/** Runner state for tracking concurrent tasks */
|
|
@@ -1395,7 +1466,18 @@ async function spawnProviderProcess(
|
|
|
1395
1466
|
const effectiveTaskId = realTaskId || crypto.randomUUID();
|
|
1396
1467
|
|
|
1397
1468
|
// Resolve env first so we can use MODEL_OVERRIDE from config
|
|
1398
|
-
const freshEnv = await fetchResolvedEnv(
|
|
1469
|
+
const { env: freshEnv, credentialSelections } = await fetchResolvedEnv(
|
|
1470
|
+
opts.apiUrl,
|
|
1471
|
+
opts.apiKey,
|
|
1472
|
+
opts.agentId,
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
// Report which key was selected for this task (fire-and-forget)
|
|
1476
|
+
if (credentialSelections.length > 0 && realTaskId) {
|
|
1477
|
+
for (const sel of credentialSelections) {
|
|
1478
|
+
reportKeyUsage(opts.apiUrl, opts.apiKey, sel.keyType, sel, realTaskId).catch(() => {});
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1399
1481
|
|
|
1400
1482
|
// Propagate agent-fs config to process.env so getBasePrompt() can read them
|
|
1401
1483
|
// (fetchResolvedEnv returns a new object, doesn't update process.env)
|
|
@@ -1729,6 +1811,16 @@ async function spawnProviderProcess(
|
|
|
1729
1811
|
return result;
|
|
1730
1812
|
});
|
|
1731
1813
|
|
|
1814
|
+
// Build credential info for rate limit tracking
|
|
1815
|
+
const primarySelection = credentialSelections[0];
|
|
1816
|
+
const credentialInfo = primarySelection
|
|
1817
|
+
? {
|
|
1818
|
+
keyType: primarySelection.keyType,
|
|
1819
|
+
keySuffix: primarySelection.keySuffix,
|
|
1820
|
+
keyIndex: primarySelection.index,
|
|
1821
|
+
}
|
|
1822
|
+
: undefined;
|
|
1823
|
+
|
|
1732
1824
|
const runningTask: RunningTask = {
|
|
1733
1825
|
taskId: effectiveTaskId,
|
|
1734
1826
|
session,
|
|
@@ -1736,6 +1828,7 @@ async function spawnProviderProcess(
|
|
|
1736
1828
|
startTime: new Date(),
|
|
1737
1829
|
promise,
|
|
1738
1830
|
result: null,
|
|
1831
|
+
credentialInfo,
|
|
1739
1832
|
};
|
|
1740
1833
|
|
|
1741
1834
|
// Non-blocking completion tracking
|
|
@@ -1766,7 +1859,7 @@ async function runProviderIteration(
|
|
|
1766
1859
|
cwd?: string;
|
|
1767
1860
|
},
|
|
1768
1861
|
): Promise<ProviderResult> {
|
|
1769
|
-
const freshEnv = await fetchResolvedEnv(opts.apiUrl, opts.apiKey, opts.agentId);
|
|
1862
|
+
const { env: freshEnv } = await fetchResolvedEnv(opts.apiUrl, opts.apiKey, opts.agentId);
|
|
1770
1863
|
const model = (freshEnv.MODEL_OVERRIDE as string) || "";
|
|
1771
1864
|
|
|
1772
1865
|
const config: ProviderSessionConfig = {
|
|
@@ -1811,6 +1904,7 @@ async function checkCompletedProcesses(
|
|
|
1811
1904
|
triggerType?: string;
|
|
1812
1905
|
cursorUpdates?: Array<{ channelId: string; ts: string }>;
|
|
1813
1906
|
workingDir?: string;
|
|
1907
|
+
credentialInfo?: RunningTask["credentialInfo"];
|
|
1814
1908
|
}> = [];
|
|
1815
1909
|
|
|
1816
1910
|
for (const [taskId, task] of state.activeTasks) {
|
|
@@ -1825,12 +1919,13 @@ async function checkCompletedProcesses(
|
|
|
1825
1919
|
triggerType: task.triggerType,
|
|
1826
1920
|
cursorUpdates: task.cursorUpdates,
|
|
1827
1921
|
workingDir: task.workingDir,
|
|
1922
|
+
credentialInfo: task.credentialInfo,
|
|
1828
1923
|
});
|
|
1829
1924
|
}
|
|
1830
1925
|
}
|
|
1831
1926
|
|
|
1832
1927
|
// Remove completed tasks from the map and ensure they're marked as finished
|
|
1833
|
-
for (const { taskId, result, cursorUpdates, workingDir } of completedTasks) {
|
|
1928
|
+
for (const { taskId, result, cursorUpdates, workingDir, credentialInfo } of completedTasks) {
|
|
1834
1929
|
state.activeTasks.delete(taskId);
|
|
1835
1930
|
|
|
1836
1931
|
if (apiConfig) {
|
|
@@ -1849,6 +1944,28 @@ async function checkCompletedProcesses(
|
|
|
1849
1944
|
if (result.exitCode !== 0 && result.failureReason) {
|
|
1850
1945
|
failureReason = result.failureReason;
|
|
1851
1946
|
console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
|
|
1947
|
+
|
|
1948
|
+
// If rate-limited and we know which key was used, report it
|
|
1949
|
+
if (credentialInfo && /rate.?limit/i.test(failureReason)) {
|
|
1950
|
+
// Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
|
|
1951
|
+
const parsedResetTime = parseRateLimitResetTime(failureReason);
|
|
1952
|
+
const defaultCooldownMs = 5 * 60 * 1000;
|
|
1953
|
+
const rateLimitedUntil =
|
|
1954
|
+
parsedResetTime ?? new Date(Date.now() + defaultCooldownMs).toISOString();
|
|
1955
|
+
if (parsedResetTime) {
|
|
1956
|
+
console.log(
|
|
1957
|
+
`[credentials] Parsed rate limit reset time from error: ${parsedResetTime}`,
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
reportKeyRateLimit(
|
|
1961
|
+
apiConfig.apiUrl,
|
|
1962
|
+
apiConfig.apiKey,
|
|
1963
|
+
credentialInfo.keyType,
|
|
1964
|
+
credentialInfo.keySuffix,
|
|
1965
|
+
credentialInfo.keyIndex,
|
|
1966
|
+
rateLimitedUntil,
|
|
1967
|
+
).catch(() => {});
|
|
1968
|
+
}
|
|
1852
1969
|
}
|
|
1853
1970
|
await ensureTaskFinished(apiConfig, role, taskId, result.exitCode, failureReason);
|
|
1854
1971
|
|