@agentxv2/gateway 0.1.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/.env.example +53 -0
- package/db/migrations/001_init.sql +117 -0
- package/db/migrations/002_agents.sql +20 -0
- package/db/migrations/003_a2a_results.sql +23 -0
- package/deploy/.env.deploy.example +23 -0
- package/deploy/add_swap_build.py +45 -0
- package/deploy/build_and_deploy.py +110 -0
- package/deploy/build_deploy_a2a.py +190 -0
- package/deploy/build_no_lint.py +52 -0
- package/deploy/build_on_fe.py +86 -0
- package/deploy/build_on_gw.py +98 -0
- package/deploy/build_prod.py +54 -0
- package/deploy/check_bal.py +31 -0
- package/deploy/check_code.py +47 -0
- package/deploy/check_deploy.sh +20 -0
- package/deploy/check_docs.py +16 -0
- package/deploy/check_frontend.py +42 -0
- package/deploy/check_frontend2.py +35 -0
- package/deploy/check_pks.py +34 -0
- package/deploy/check_status.py +32 -0
- package/deploy/check_v2.py +8 -0
- package/deploy/clean_install.py +28 -0
- package/deploy/deploy_a2a.py +58 -0
- package/deploy/deploy_a2a.sh +33 -0
- package/deploy/deploy_a2a_bg.sh +40 -0
- package/deploy/deploy_a2a_v2.py +151 -0
- package/deploy/deploy_a2a_v2.sh +36 -0
- package/deploy/deploy_bin.py +64 -0
- package/deploy/deploy_config.py +34 -0
- package/deploy/deploy_final.py +42 -0
- package/deploy/deploy_frontend.py +108 -0
- package/deploy/deploy_mcp.py +108 -0
- package/deploy/deploy_now.py +49 -0
- package/deploy/deploy_push_all.py +128 -0
- package/deploy/deploy_script.py +64 -0
- package/deploy/deploy_sep_a2a.sh +18 -0
- package/deploy/dev_mode.py +51 -0
- package/deploy/diag.py +12 -0
- package/deploy/diff_test.py +22 -0
- package/deploy/final_check.py +33 -0
- package/deploy/final_deploy.py +50 -0
- package/deploy/final_fix.py +54 -0
- package/deploy/final_push.py +114 -0
- package/deploy/find_frontend.py +38 -0
- package/deploy/fix_a2a.py +43 -0
- package/deploy/fix_and_build.py +77 -0
- package/deploy/fix_and_verify.py +56 -0
- package/deploy/fix_build.py +51 -0
- package/deploy/forge_deploy.py +67 -0
- package/deploy/full_push.py +101 -0
- package/deploy/full_push2.py +106 -0
- package/deploy/go_prod.py +59 -0
- package/deploy/gw_deploy.py +133 -0
- package/deploy/gw_final.py +206 -0
- package/deploy/gw_restart.py +28 -0
- package/deploy/gw_status.py +18 -0
- package/deploy/inline_build.py +99 -0
- package/deploy/one_shot.py +84 -0
- package/deploy/ox_deploy.py +32 -0
- package/deploy/pg_setup.py +36 -0
- package/deploy/prod_deploy.py +126 -0
- package/deploy/prod_final.py +140 -0
- package/deploy/prod_go.py +61 -0
- package/deploy/prod_one_shot.py +139 -0
- package/deploy/prod_setup.sh +62 -0
- package/deploy/prod_v2.py +91 -0
- package/deploy/prod_v3.py +103 -0
- package/deploy/push_docs.py +93 -0
- package/deploy/push_mcp.py +66 -0
- package/deploy/push_mcp_doc.py +28 -0
- package/deploy/push_v063.py +80 -0
- package/deploy/push_v2.py +71 -0
- package/deploy/qcheck.py +13 -0
- package/deploy/qcheck2.py +18 -0
- package/deploy/quick_check.py +8 -0
- package/deploy/rebuild.py +47 -0
- package/deploy/rebuild_frontend.py +44 -0
- package/deploy/restart.py +28 -0
- package/deploy/restore_frontend.py +42 -0
- package/deploy/restore_frontend2.py +41 -0
- package/deploy/restore_gateway.py +34 -0
- package/deploy/restore_quick.py +33 -0
- package/deploy/run_bash.py +19 -0
- package/deploy/run_sep.py +20 -0
- package/deploy/sdk_upgrade.py +50 -0
- package/deploy/server_prepare.py +41 -0
- package/deploy/server_setup.py +41 -0
- package/deploy/ssh_check.py +14 -0
- package/deploy/ssh_push.py +24 -0
- package/deploy/start_deploy.py +35 -0
- package/deploy/start_frontend.py +12 -0
- package/deploy/start_v2.py +29 -0
- package/deploy/stat.py +18 -0
- package/deploy/tail_log.py +11 -0
- package/deploy/update_env.py +32 -0
- package/deploy/upload_and_start.py +107 -0
- package/deploy/verify_a2a.py +13 -0
- package/deploy/verify_mcp.py +37 -0
- package/deploy/verify_sdk.py +21 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.js +41 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/crypto.d.ts +2 -0
- package/dist/lib/crypto.js +43 -0
- package/dist/lib/crypto.js.map +1 -0
- package/dist/lib/db.d.ts +3 -0
- package/dist/lib/db.js +26 -0
- package/dist/lib/db.js.map +1 -0
- package/dist/lib/ethers-utils.d.ts +60 -0
- package/dist/lib/ethers-utils.js +186 -0
- package/dist/lib/ethers-utils.js.map +1 -0
- package/dist/middleware/adminAuth.d.ts +2 -0
- package/dist/middleware/adminAuth.js +31 -0
- package/dist/middleware/adminAuth.js.map +1 -0
- package/dist/middleware/auth.d.ts +22 -0
- package/dist/middleware/auth.js +192 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +36 -0
- package/dist/middleware/error-handler.js +100 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/rate-limiter.d.ts +5 -0
- package/dist/middleware/rate-limiter.js +107 -0
- package/dist/middleware/rate-limiter.js.map +1 -0
- package/dist/routes/a2a.d.ts +2 -0
- package/dist/routes/a2a.js +84 -0
- package/dist/routes/a2a.js.map +1 -0
- package/dist/routes/admin.d.ts +2 -0
- package/dist/routes/admin.js +180 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/agents.d.ts +2 -0
- package/dist/routes/agents.js +43 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/chat.d.ts +2 -0
- package/dist/routes/chat.js +183 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/history.d.ts +2 -0
- package/dist/routes/history.js +92 -0
- package/dist/routes/history.js.map +1 -0
- package/dist/routes/mcp.d.ts +13 -0
- package/dist/routes/mcp.js +513 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/tenant.d.ts +2 -0
- package/dist/routes/tenant.js +154 -0
- package/dist/routes/tenant.js.map +1 -0
- package/dist/services/a2a-worker.d.ts +7 -0
- package/dist/services/a2a-worker.js +406 -0
- package/dist/services/a2a-worker.js.map +1 -0
- package/dist/services/agent-indexer.d.ts +4 -0
- package/dist/services/agent-indexer.js +124 -0
- package/dist/services/agent-indexer.js.map +1 -0
- package/e2e_wallet.js +175 -0
- package/ecosystem.config.js +17 -0
- package/package.json +33 -0
- package/src/config.ts +47 -0
- package/src/index.ts +143 -0
- package/src/lib/crypto.ts +44 -0
- package/src/lib/db.ts +25 -0
- package/src/lib/ethers-utils.ts +229 -0
- package/src/middleware/adminAuth.ts +34 -0
- package/src/middleware/auth.ts +244 -0
- package/src/middleware/error-handler.ts +109 -0
- package/src/middleware/rate-limiter.ts +115 -0
- package/src/routes/a2a.ts +97 -0
- package/src/routes/admin.ts +219 -0
- package/src/routes/agents.ts +52 -0
- package/src/routes/chat.ts +219 -0
- package/src/routes/history.ts +114 -0
- package/src/routes/mcp.ts +567 -0
- package/src/routes/tenant.ts +196 -0
- package/src/services/a2a-worker.ts +486 -0
- package/src/services/agent-indexer.ts +127 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// AgentX Gateway — Rate Limiter Middleware
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Three-layer rate limiting:
|
|
5
|
+
// 1. Global IP-level (express-rate-limit)
|
|
6
|
+
// 2. Per-tenant RPM (Redis sliding window)
|
|
7
|
+
// 3. Per-tenant daily quota (Redis counter + DB fallback)
|
|
8
|
+
// 4. Per-tenant concurrency control (Redis)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import { Request, Response, NextFunction } from 'express'
|
|
12
|
+
import Redis from 'ioredis'
|
|
13
|
+
import { config } from '../config'
|
|
14
|
+
import { getPool } from '../lib/db'
|
|
15
|
+
|
|
16
|
+
let redis: Redis | null = null
|
|
17
|
+
|
|
18
|
+
export function getRedis(): Redis | null {
|
|
19
|
+
if (!redis) {
|
|
20
|
+
redis = new Redis(config.redisUrl, { lazyConnect: true })
|
|
21
|
+
redis.connect().catch(() => {
|
|
22
|
+
redis = null
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
return redis
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function tenantRateLimiter(
|
|
29
|
+
req: Request,
|
|
30
|
+
res: Response,
|
|
31
|
+
next: NextFunction
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const tenant = req.tenant
|
|
34
|
+
if (!tenant) {
|
|
35
|
+
next()
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const r = getRedis()
|
|
40
|
+
if (!r) {
|
|
41
|
+
next()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// 1. RPM check
|
|
47
|
+
const rpmKey = `rpm:${tenant.id}`
|
|
48
|
+
const rpmCurrent = await r.incr(rpmKey)
|
|
49
|
+
if (rpmCurrent === 1) {
|
|
50
|
+
await r.expire(rpmKey, 60)
|
|
51
|
+
}
|
|
52
|
+
if (rpmCurrent > tenant.rateLimitRpm) {
|
|
53
|
+
res.status(429).json({
|
|
54
|
+
error: 'Rate limit exceeded',
|
|
55
|
+
retry_after: 60,
|
|
56
|
+
limit: tenant.rateLimitRpm,
|
|
57
|
+
limit_type: 'rpm',
|
|
58
|
+
})
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. Daily quota check (only for platform mode)
|
|
63
|
+
if (tenant.quotaDaily > 0) {
|
|
64
|
+
const quotaKey = `quota:${tenant.id}`
|
|
65
|
+
const used = await r.get(quotaKey)
|
|
66
|
+
const current = used ? parseInt(used, 10) : tenant.quotaUsed
|
|
67
|
+
if (current >= tenant.quotaDaily) {
|
|
68
|
+
res.status(429).json({
|
|
69
|
+
error: 'Daily quota exceeded. Upgrade your plan or switch to BYOK mode.',
|
|
70
|
+
limit: tenant.quotaDaily,
|
|
71
|
+
used: current,
|
|
72
|
+
limit_type: 'daily_quota',
|
|
73
|
+
})
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. Concurrency check
|
|
79
|
+
const concurrencyKey = `concurrent:${tenant.id}`
|
|
80
|
+
const currentConcurrent = await r.incr(concurrencyKey)
|
|
81
|
+
await r.expire(concurrencyKey, 300)
|
|
82
|
+
if (currentConcurrent > tenant.maxConcurrent) {
|
|
83
|
+
await r.decr(concurrencyKey)
|
|
84
|
+
res.status(429).json({
|
|
85
|
+
error: 'Too many concurrent requests',
|
|
86
|
+
limit: tenant.maxConcurrent,
|
|
87
|
+
limit_type: 'concurrency',
|
|
88
|
+
})
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
res.on('finish', () => {
|
|
93
|
+
r.decr(concurrencyKey).catch(() => {})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
next()
|
|
97
|
+
} catch {
|
|
98
|
+
next()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function updateQuota(tenantId: string, tokens: number): Promise<void> {
|
|
103
|
+
const r = getRedis()
|
|
104
|
+
if (r) {
|
|
105
|
+
const quotaKey = `quota:${tenantId}`
|
|
106
|
+
await r.incrby(quotaKey, tokens)
|
|
107
|
+
await r.expire(quotaKey, 86400)
|
|
108
|
+
} else {
|
|
109
|
+
const pool = getPool()
|
|
110
|
+
await pool.query(
|
|
111
|
+
`UPDATE tenants SET quota_used = quota_used + $2 WHERE id = $1`,
|
|
112
|
+
[tenantId, tokens]
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// AgentX Gateway — A2A Task Results API
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// GET /api/v1/a2a/pending-tasks?agentId=X → tasks ready for agent to complete
|
|
5
|
+
// GET /api/v1/a2a/task-result/:taskId → single task LLM result
|
|
6
|
+
// GET /api/v1/a2a/worker-status → A2A worker health
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import { Router, Request, Response } from 'express'
|
|
10
|
+
import { getPool } from '../lib/db'
|
|
11
|
+
import { getWorkerStatus } from '../services/a2a-worker'
|
|
12
|
+
|
|
13
|
+
const router = Router()
|
|
14
|
+
|
|
15
|
+
// ── List pending task results for an agent ─────────────────────────────────
|
|
16
|
+
|
|
17
|
+
router.get('/pending-tasks', async (req: Request, res: Response) => {
|
|
18
|
+
try {
|
|
19
|
+
const agentId = parseInt(req.query.agentId as string)
|
|
20
|
+
if (!agentId || isNaN(agentId)) {
|
|
21
|
+
res.status(400).json({ error: 'agentId query parameter required' })
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pool = getPool()
|
|
26
|
+
const { rows } = await pool.query(
|
|
27
|
+
`SELECT task_id, agent_id, task_type, input_data, output_data, status,
|
|
28
|
+
llm_model, tokens_used, processed_at, created_at
|
|
29
|
+
FROM a2a_task_results
|
|
30
|
+
WHERE agent_id = $1 AND status = 2
|
|
31
|
+
ORDER BY created_at DESC
|
|
32
|
+
LIMIT 50`,
|
|
33
|
+
[agentId]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
res.json({ tasks: rows, total: rows.length, agentId })
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
console.error('[a2a] pending-tasks error:', err.message)
|
|
39
|
+
res.status(500).json({ error: 'Failed to fetch pending tasks' })
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ── Single task result ─────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
router.get('/task-result/:taskId', async (req: Request, res: Response) => {
|
|
46
|
+
try {
|
|
47
|
+
const taskId = parseInt(req.params.taskId)
|
|
48
|
+
if (!taskId || isNaN(taskId)) {
|
|
49
|
+
res.status(400).json({ error: 'Invalid taskId' })
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pool = getPool()
|
|
54
|
+
const { rows } = await pool.query(
|
|
55
|
+
`SELECT * FROM a2a_task_results WHERE task_id = $1`,
|
|
56
|
+
[taskId]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if (rows.length === 0) {
|
|
60
|
+
res.status(404).json({ error: 'Task result not found', taskId })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
res.json(rows[0])
|
|
65
|
+
} catch (err: any) {
|
|
66
|
+
console.error('[a2a] task-result error:', err.message)
|
|
67
|
+
res.status(500).json({ error: 'Failed to fetch task result' })
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ── Worker status ──────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
router.get('/worker-status', async (_req: Request, res: Response) => {
|
|
74
|
+
try {
|
|
75
|
+
const status = getWorkerStatus()
|
|
76
|
+
const pool = getPool()
|
|
77
|
+
const { rows: stats } = await pool.query(
|
|
78
|
+
`SELECT status, COUNT(*) as count
|
|
79
|
+
FROM a2a_task_results
|
|
80
|
+
GROUP BY status`
|
|
81
|
+
)
|
|
82
|
+
const counts: Record<string, number> = { pending: 0, processing: 0, completed: 0, failed: 0 }
|
|
83
|
+
for (const r of stats) {
|
|
84
|
+
if (r.status === 0) counts.pending = Number(r.count)
|
|
85
|
+
else if (r.status === 1) counts.processing = Number(r.count)
|
|
86
|
+
else if (r.status === 2) counts.completed = Number(r.count)
|
|
87
|
+
else if (r.status === 3) counts.failed = Number(r.count)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
res.json({ ...status, taskCounts: counts })
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
console.error('[a2a] worker-status error:', err.message)
|
|
93
|
+
res.status(500).json({ error: 'Failed to fetch worker status' })
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
export default router
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// AgentX Gateway — Admin Routes
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// POST /api/v1/admin/platform-keys — Add platform API key
|
|
5
|
+
// GET /api/v1/admin/platform-keys — List platform API keys
|
|
6
|
+
// DELETE /api/v1/admin/platform-keys/:id — Delete platform API key
|
|
7
|
+
// GET /api/v1/admin/plans — List plans
|
|
8
|
+
// GET /api/v1/admin/tenants — List tenants (paginated)
|
|
9
|
+
// PATCH /api/v1/admin/tenants/:id — Update tenant plan/status
|
|
10
|
+
// GET /api/v1/admin/usage — Usage stats
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import { Router, Request, Response } from 'express'
|
|
14
|
+
import { getPool } from '../lib/db'
|
|
15
|
+
import { adminAuth } from '../middleware/adminAuth'
|
|
16
|
+
import { encryptApiKey } from '../lib/crypto'
|
|
17
|
+
import { config } from '../config'
|
|
18
|
+
|
|
19
|
+
const router = Router()
|
|
20
|
+
|
|
21
|
+
// All admin routes require admin auth
|
|
22
|
+
router.use(adminAuth)
|
|
23
|
+
|
|
24
|
+
// ── Platform API Keys ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// List all platform keys (masked)
|
|
27
|
+
router.get('/platform-keys', async (_req: Request, res: Response) => {
|
|
28
|
+
try {
|
|
29
|
+
const pool = getPool()
|
|
30
|
+
const result = await pool.query(
|
|
31
|
+
`SELECT pk.id, pk.provider, pk.endpoint, pk.models, pk.plan_ids, pk.weight, pk.is_active,
|
|
32
|
+
pk.created_at,
|
|
33
|
+
array_agg(p.slug) as plan_slugs
|
|
34
|
+
FROM platform_api_keys pk
|
|
35
|
+
LEFT JOIN plans p ON p.id = ANY(pk.plan_ids)
|
|
36
|
+
GROUP BY pk.id
|
|
37
|
+
ORDER BY pk.created_at DESC`
|
|
38
|
+
)
|
|
39
|
+
res.json({ keys: result.rows })
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
res.status(500).json({ error: err.message })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Add platform API key
|
|
46
|
+
router.post('/platform-keys', async (req: Request, res: Response) => {
|
|
47
|
+
try {
|
|
48
|
+
const { provider, endpoint, api_key, models, plan_slugs } = req.body
|
|
49
|
+
if (!provider || !endpoint || !api_key) {
|
|
50
|
+
res.status(400).json({ error: 'provider, endpoint, and api_key are required' })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const pool = getPool()
|
|
55
|
+
|
|
56
|
+
// Resolve plan slugs to IDs
|
|
57
|
+
const slugs: string[] = plan_slugs || ['pro', 'enterprise']
|
|
58
|
+
const planResult = await pool.query(
|
|
59
|
+
`SELECT id FROM plans WHERE slug = ANY($1)`, [slugs]
|
|
60
|
+
)
|
|
61
|
+
const planIds = planResult.rows.map((r: any) => r.id)
|
|
62
|
+
|
|
63
|
+
const encrypted = encryptApiKey(api_key, config.masterEncryptionKey)
|
|
64
|
+
const modelList: string[] = models || [provider === 'deepseek' ? 'deepseek-chat' : 'gpt-4o']
|
|
65
|
+
|
|
66
|
+
await pool.query(
|
|
67
|
+
`INSERT INTO platform_api_keys (provider, endpoint, api_key, plan_ids, models, weight, is_active)
|
|
68
|
+
VALUES ($1, $2, $3, $4, $5, 1, true)`,
|
|
69
|
+
[provider, endpoint, encrypted, planIds, modelList]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
res.status(201).json({ success: true, provider, endpoint, models: modelList })
|
|
73
|
+
} catch (err: any) {
|
|
74
|
+
res.status(500).json({ error: err.message })
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Delete platform API key
|
|
79
|
+
router.delete('/platform-keys/:id', async (req: Request, res: Response) => {
|
|
80
|
+
try {
|
|
81
|
+
const pool = getPool()
|
|
82
|
+
const result = await pool.query(
|
|
83
|
+
`DELETE FROM platform_api_keys WHERE id = $1 RETURNING id, provider`,
|
|
84
|
+
[req.params.id]
|
|
85
|
+
)
|
|
86
|
+
if (result.rows.length === 0) {
|
|
87
|
+
res.status(404).json({ error: 'Key not found' })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
res.json({ success: true, deleted: result.rows[0] })
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
res.status(500).json({ error: err.message })
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ── Plans ──────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
router.get('/plans', async (_req: Request, res: Response) => {
|
|
99
|
+
try {
|
|
100
|
+
const pool = getPool()
|
|
101
|
+
const result = await pool.query(
|
|
102
|
+
`SELECT id, name, slug, price_monthly, quota_daily, quota_monthly,
|
|
103
|
+
byok_enabled, rate_limit_rpm, max_concurrent, platform_models, is_active
|
|
104
|
+
FROM plans ORDER BY price_monthly ASC`
|
|
105
|
+
)
|
|
106
|
+
res.json({ plans: result.rows })
|
|
107
|
+
} catch (err: any) {
|
|
108
|
+
res.status(500).json({ error: err.message })
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ── Tenants ────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
router.get('/tenants', async (req: Request, res: Response) => {
|
|
115
|
+
try {
|
|
116
|
+
const page = parseInt(req.query.page as string) || 1
|
|
117
|
+
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100)
|
|
118
|
+
const offset = (page - 1) * limit
|
|
119
|
+
|
|
120
|
+
const pool = getPool()
|
|
121
|
+
const [countResult, tenantResult] = await Promise.all([
|
|
122
|
+
pool.query(`SELECT COUNT(*) FROM tenants`),
|
|
123
|
+
pool.query(
|
|
124
|
+
`SELECT t.id, t.wallet_address, t.status,
|
|
125
|
+
t.quota_daily, t.quota_used, t.rate_limit_rpm, t.max_concurrent,
|
|
126
|
+
t.created_at, t.updated_at,
|
|
127
|
+
p.slug as plan_slug, p.name as plan_name
|
|
128
|
+
FROM tenants t
|
|
129
|
+
LEFT JOIN plans p ON t.plan_id = p.id
|
|
130
|
+
ORDER BY t.created_at DESC
|
|
131
|
+
LIMIT $1 OFFSET $2`,
|
|
132
|
+
[limit, offset]
|
|
133
|
+
),
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
res.json({
|
|
137
|
+
tenants: tenantResult.rows,
|
|
138
|
+
total: parseInt(countResult.rows[0].count),
|
|
139
|
+
page,
|
|
140
|
+
limit,
|
|
141
|
+
})
|
|
142
|
+
} catch (err: any) {
|
|
143
|
+
res.status(500).json({ error: err.message })
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
router.patch('/tenants/:id', async (req: Request, res: Response) => {
|
|
148
|
+
try {
|
|
149
|
+
const { plan_slug, status } = req.body
|
|
150
|
+
const pool = getPool()
|
|
151
|
+
|
|
152
|
+
if (plan_slug) {
|
|
153
|
+
const planResult = await pool.query(`SELECT id FROM plans WHERE slug = $1`, [plan_slug])
|
|
154
|
+
if (planResult.rows.length === 0) {
|
|
155
|
+
res.status(400).json({ error: 'Invalid plan slug' })
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const plan = planResult.rows[0]
|
|
159
|
+
await pool.query(`UPDATE tenants SET plan_id = $1, updated_at = NOW() WHERE id = $2`,
|
|
160
|
+
[plan.id, req.params.id])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (status) {
|
|
164
|
+
await pool.query(`UPDATE tenants SET status = $1, updated_at = NOW() WHERE id = $2`,
|
|
165
|
+
[status, req.params.id])
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
res.json({ success: true })
|
|
169
|
+
} catch (err: any) {
|
|
170
|
+
res.status(500).json({ error: err.message })
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ── Usage Stats ────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
router.get('/usage', async (_req: Request, res: Response) => {
|
|
177
|
+
try {
|
|
178
|
+
const pool = getPool()
|
|
179
|
+
const [total, recent, topTenants] = await Promise.all([
|
|
180
|
+
pool.query(
|
|
181
|
+
`SELECT COUNT(*) as total_requests,
|
|
182
|
+
COALESCE(SUM(tokens_total), 0) as total_tokens
|
|
183
|
+
FROM usage_logs`
|
|
184
|
+
),
|
|
185
|
+
pool.query(
|
|
186
|
+
`SELECT DATE(created_at) as date,
|
|
187
|
+
COUNT(*) as requests,
|
|
188
|
+
SUM(tokens_total) as tokens
|
|
189
|
+
FROM usage_logs
|
|
190
|
+
WHERE created_at > NOW() - INTERVAL '30 days'
|
|
191
|
+
GROUP BY DATE(created_at)
|
|
192
|
+
ORDER BY date DESC
|
|
193
|
+
LIMIT 30`
|
|
194
|
+
),
|
|
195
|
+
pool.query(
|
|
196
|
+
`SELECT t.wallet_address, p.slug as plan,
|
|
197
|
+
COUNT(ul.*) as requests,
|
|
198
|
+
COALESCE(SUM(ul.tokens_total), 0) as tokens
|
|
199
|
+
FROM tenants t
|
|
200
|
+
LEFT JOIN usage_logs ul ON ul.tenant_id = t.id
|
|
201
|
+
LEFT JOIN plans p ON t.plan_id = p.id
|
|
202
|
+
WHERE ul.created_at > NOW() - INTERVAL '30 days' OR ul.id IS NULL
|
|
203
|
+
GROUP BY t.id, t.wallet_address, p.slug
|
|
204
|
+
ORDER BY tokens DESC
|
|
205
|
+
LIMIT 20`
|
|
206
|
+
),
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
res.json({
|
|
210
|
+
summary: total.rows[0],
|
|
211
|
+
daily: recent.rows,
|
|
212
|
+
topTenants: topTenants.rows,
|
|
213
|
+
})
|
|
214
|
+
} catch (err: any) {
|
|
215
|
+
res.status(500).json({ error: err.message })
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
export default router
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ── Agents API (public, read-only)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// GET /api/v1/agents → list all agents
|
|
5
|
+
// GET /api/v1/agents/:id → single agent detail
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { Router, Request, Response } from 'express'
|
|
9
|
+
import { getPool } from '../lib/db'
|
|
10
|
+
|
|
11
|
+
const router = Router()
|
|
12
|
+
|
|
13
|
+
// ── List all agents ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
router.get('/', async (_req: Request, res: Response) => {
|
|
16
|
+
try {
|
|
17
|
+
const pool = getPool()
|
|
18
|
+
const { rows } = await pool.query(
|
|
19
|
+
`SELECT id, owner, name, description, tags, capabilities, synced_at, created_at
|
|
20
|
+
FROM agents
|
|
21
|
+
ORDER BY id DESC`
|
|
22
|
+
)
|
|
23
|
+
res.json({ agents: rows, total: rows.length })
|
|
24
|
+
} catch (err: any) {
|
|
25
|
+
console.error('[agents] list error:', err.message)
|
|
26
|
+
res.status(500).json({ error: 'Failed to fetch agents' })
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// ── Single agent detail ─────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
router.get('/:id', async (req: Request, res: Response) => {
|
|
33
|
+
try {
|
|
34
|
+
const pool = getPool()
|
|
35
|
+
const { rows } = await pool.query(
|
|
36
|
+
`SELECT id, owner, name, description, tags, capabilities, token_uri, metadata_json, synced_at, created_at, updated_at
|
|
37
|
+
FROM agents WHERE id = $1`,
|
|
38
|
+
[parseInt(req.params.id)]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if (rows.length === 0) {
|
|
42
|
+
return res.status(404).json({ error: 'Agent not found' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
res.json(rows[0])
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
console.error('[agents] detail error:', err.message)
|
|
48
|
+
res.status(500).json({ error: 'Failed to fetch agent' })
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export default router
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// AgentX Gateway — Chat Completions Proxy
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// POST /api/v1/chat/completions
|
|
5
|
+
// Resolves tenant API key, proxies to LLM, streams back, records usage.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import { Router, Request, Response } from 'express'
|
|
9
|
+
import { getPool } from '../lib/db'
|
|
10
|
+
import { decryptApiKey } from '../lib/crypto'
|
|
11
|
+
import { config } from '../config'
|
|
12
|
+
import { updateQuota } from '../middleware/rate-limiter'
|
|
13
|
+
|
|
14
|
+
const router = Router()
|
|
15
|
+
|
|
16
|
+
router.post('/chat/completions', async (req: Request, res: Response) => {
|
|
17
|
+
const tenant = req.tenant
|
|
18
|
+
if (!tenant) {
|
|
19
|
+
res.status(401).json({ error: 'Unauthorized' })
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
model,
|
|
25
|
+
messages,
|
|
26
|
+
tools,
|
|
27
|
+
stream,
|
|
28
|
+
key_source,
|
|
29
|
+
tenant_key_id,
|
|
30
|
+
temperature,
|
|
31
|
+
max_tokens,
|
|
32
|
+
} = req.body
|
|
33
|
+
|
|
34
|
+
let apiKey: string
|
|
35
|
+
let endpoint: string
|
|
36
|
+
let keySource: string
|
|
37
|
+
let platformKeyId: string | null = null
|
|
38
|
+
let tenantKeyId: string | null = null
|
|
39
|
+
let resolvedModel = model || 'gpt-4o'
|
|
40
|
+
|
|
41
|
+
const pool = getPool()
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (key_source === 'tenant_owned' && tenant_key_id) {
|
|
45
|
+
// ── BYOK Mode ──
|
|
46
|
+
const keyRow = await pool.query(
|
|
47
|
+
`SELECT * FROM tenant_api_keys WHERE id = $1 AND tenant_id = $2 AND is_active = true`,
|
|
48
|
+
[tenant_key_id, tenant.id]
|
|
49
|
+
)
|
|
50
|
+
if (keyRow.rows.length === 0) {
|
|
51
|
+
res.status(400).json({ error: 'Tenant API key not found or inactive' })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const tk = keyRow.rows[0]
|
|
55
|
+
apiKey = decryptApiKey(tk.api_key, config.masterEncryptionKey)
|
|
56
|
+
endpoint = tk.endpoint
|
|
57
|
+
resolvedModel = model || tk.model
|
|
58
|
+
keySource = 'tenant_owned'
|
|
59
|
+
tenantKeyId = tk.id
|
|
60
|
+
} else if (tenant.quotaDaily > 0) {
|
|
61
|
+
// ── Platform Mode ──
|
|
62
|
+
if (tenant.quotaUsed >= tenant.quotaDaily) {
|
|
63
|
+
res.status(429).json({
|
|
64
|
+
error: 'Platform quota exhausted',
|
|
65
|
+
hint: 'Switch to BYOK mode or upgrade your plan',
|
|
66
|
+
})
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const planRow = await pool.query(
|
|
71
|
+
`SELECT platform_models FROM plans WHERE id = $1`,
|
|
72
|
+
[tenant.planId]
|
|
73
|
+
)
|
|
74
|
+
const planModels = planRow.rows[0]?.platform_models || []
|
|
75
|
+
|
|
76
|
+
let matchedModel: { provider: string; model: string } | null = null
|
|
77
|
+
if (resolvedModel) {
|
|
78
|
+
matchedModel = planModels.find(
|
|
79
|
+
(m: { model: string }) => m.model === resolvedModel
|
|
80
|
+
) || planModels[0]
|
|
81
|
+
} else {
|
|
82
|
+
matchedModel = planModels[0]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!matchedModel) {
|
|
86
|
+
res.status(400).json({
|
|
87
|
+
error: 'No platform models available on current plan',
|
|
88
|
+
available_models: planModels.map((m: { model: string }) => m.model),
|
|
89
|
+
})
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const keyRow = await pool.query(
|
|
94
|
+
`SELECT * FROM platform_api_keys
|
|
95
|
+
WHERE provider = $1 AND $2 = ANY(plan_ids) AND is_active = true
|
|
96
|
+
ORDER BY random() LIMIT 1`,
|
|
97
|
+
[matchedModel.provider, tenant.planId]
|
|
98
|
+
)
|
|
99
|
+
if (keyRow.rows.length === 0) {
|
|
100
|
+
res.status(500).json({ error: 'No platform API key available' })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pk = keyRow.rows[0]
|
|
105
|
+
apiKey = decryptApiKey(pk.api_key, config.masterEncryptionKey)
|
|
106
|
+
endpoint = pk.endpoint
|
|
107
|
+
resolvedModel = matchedModel.model
|
|
108
|
+
keySource = 'platform'
|
|
109
|
+
platformKeyId = pk.id
|
|
110
|
+
} else {
|
|
111
|
+
res.status(400).json({
|
|
112
|
+
error: 'No LLM access configured',
|
|
113
|
+
hint: 'Add a BYOK API key in tenant settings or upgrade to a paid plan',
|
|
114
|
+
})
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Proxy to LLM ──
|
|
119
|
+
const llmBody: Record<string, unknown> = {
|
|
120
|
+
model: resolvedModel,
|
|
121
|
+
messages,
|
|
122
|
+
stream: true,
|
|
123
|
+
}
|
|
124
|
+
if (tools) llmBody.tools = tools
|
|
125
|
+
if (temperature !== undefined) llmBody.temperature = temperature
|
|
126
|
+
if (max_tokens) llmBody.max_tokens = max_tokens
|
|
127
|
+
|
|
128
|
+
const llmResponse = await fetch(`${endpoint}/chat/completions`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify(llmBody),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (!llmResponse.ok) {
|
|
138
|
+
const errText = await llmResponse.text()
|
|
139
|
+
res.status(llmResponse.status).json({ error: `LLM error: ${errText}` })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── SSE Stream Pipe ──
|
|
144
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
145
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
146
|
+
res.setHeader('Connection', 'keep-alive')
|
|
147
|
+
|
|
148
|
+
const reader = llmResponse.body?.getReader()
|
|
149
|
+
if (!reader) {
|
|
150
|
+
res.status(500).json({ error: 'No response from LLM' })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const decoder = new TextDecoder()
|
|
155
|
+
let buffer = ''
|
|
156
|
+
let totalPrompt = 0
|
|
157
|
+
let totalCompletion = 0
|
|
158
|
+
let totalTokens = 0
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
while (true) {
|
|
162
|
+
const { done, value } = await reader.read()
|
|
163
|
+
if (done) break
|
|
164
|
+
|
|
165
|
+
buffer += decoder.decode(value, { stream: true })
|
|
166
|
+
const lines = buffer.split('\n')
|
|
167
|
+
buffer = lines.pop() ?? ''
|
|
168
|
+
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
res.write(line + '\n')
|
|
171
|
+
|
|
172
|
+
if (line.startsWith('data: ') && !line.includes('[DONE]')) {
|
|
173
|
+
try {
|
|
174
|
+
const data = JSON.parse(line.slice(6))
|
|
175
|
+
if (data.usage) {
|
|
176
|
+
totalPrompt = data.usage.prompt_tokens || 0
|
|
177
|
+
totalCompletion = data.usage.completion_tokens || 0
|
|
178
|
+
totalTokens = data.usage.total_tokens || 0
|
|
179
|
+
}
|
|
180
|
+
} catch { /* skip parse errors */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (buffer) {
|
|
186
|
+
res.write(buffer + '\n')
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
reader.releaseLock()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await pool.query(
|
|
194
|
+
`INSERT INTO usage_logs (tenant_id, key_source, platform_key_id, tenant_key_id, provider, model, tokens_prompt, tokens_completion, tokens_total, tool_calls, agent_id)
|
|
195
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
196
|
+
[
|
|
197
|
+
tenant.id, keySource, platformKeyId, tenantKeyId,
|
|
198
|
+
keySource === 'platform' ? 'openai' : 'unknown',
|
|
199
|
+
resolvedModel,
|
|
200
|
+
totalPrompt, totalCompletion, totalTokens,
|
|
201
|
+
Array.isArray(tools) ? tools.length : 0,
|
|
202
|
+
null,
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
} catch { /* usage logging is non-critical */ }
|
|
206
|
+
|
|
207
|
+
if (keySource === 'platform' && totalTokens > 0) {
|
|
208
|
+
updateQuota(tenant.id, totalTokens).catch(() => {})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
res.end()
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (!res.headersSent) {
|
|
214
|
+
res.status(500).json({ error: 'Internal proxy error' })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
export default router
|