@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.
Files changed (175) hide show
  1. package/.env.example +53 -0
  2. package/db/migrations/001_init.sql +117 -0
  3. package/db/migrations/002_agents.sql +20 -0
  4. package/db/migrations/003_a2a_results.sql +23 -0
  5. package/deploy/.env.deploy.example +23 -0
  6. package/deploy/add_swap_build.py +45 -0
  7. package/deploy/build_and_deploy.py +110 -0
  8. package/deploy/build_deploy_a2a.py +190 -0
  9. package/deploy/build_no_lint.py +52 -0
  10. package/deploy/build_on_fe.py +86 -0
  11. package/deploy/build_on_gw.py +98 -0
  12. package/deploy/build_prod.py +54 -0
  13. package/deploy/check_bal.py +31 -0
  14. package/deploy/check_code.py +47 -0
  15. package/deploy/check_deploy.sh +20 -0
  16. package/deploy/check_docs.py +16 -0
  17. package/deploy/check_frontend.py +42 -0
  18. package/deploy/check_frontend2.py +35 -0
  19. package/deploy/check_pks.py +34 -0
  20. package/deploy/check_status.py +32 -0
  21. package/deploy/check_v2.py +8 -0
  22. package/deploy/clean_install.py +28 -0
  23. package/deploy/deploy_a2a.py +58 -0
  24. package/deploy/deploy_a2a.sh +33 -0
  25. package/deploy/deploy_a2a_bg.sh +40 -0
  26. package/deploy/deploy_a2a_v2.py +151 -0
  27. package/deploy/deploy_a2a_v2.sh +36 -0
  28. package/deploy/deploy_bin.py +64 -0
  29. package/deploy/deploy_config.py +34 -0
  30. package/deploy/deploy_final.py +42 -0
  31. package/deploy/deploy_frontend.py +108 -0
  32. package/deploy/deploy_mcp.py +108 -0
  33. package/deploy/deploy_now.py +49 -0
  34. package/deploy/deploy_push_all.py +128 -0
  35. package/deploy/deploy_script.py +64 -0
  36. package/deploy/deploy_sep_a2a.sh +18 -0
  37. package/deploy/dev_mode.py +51 -0
  38. package/deploy/diag.py +12 -0
  39. package/deploy/diff_test.py +22 -0
  40. package/deploy/final_check.py +33 -0
  41. package/deploy/final_deploy.py +50 -0
  42. package/deploy/final_fix.py +54 -0
  43. package/deploy/final_push.py +114 -0
  44. package/deploy/find_frontend.py +38 -0
  45. package/deploy/fix_a2a.py +43 -0
  46. package/deploy/fix_and_build.py +77 -0
  47. package/deploy/fix_and_verify.py +56 -0
  48. package/deploy/fix_build.py +51 -0
  49. package/deploy/forge_deploy.py +67 -0
  50. package/deploy/full_push.py +101 -0
  51. package/deploy/full_push2.py +106 -0
  52. package/deploy/go_prod.py +59 -0
  53. package/deploy/gw_deploy.py +133 -0
  54. package/deploy/gw_final.py +206 -0
  55. package/deploy/gw_restart.py +28 -0
  56. package/deploy/gw_status.py +18 -0
  57. package/deploy/inline_build.py +99 -0
  58. package/deploy/one_shot.py +84 -0
  59. package/deploy/ox_deploy.py +32 -0
  60. package/deploy/pg_setup.py +36 -0
  61. package/deploy/prod_deploy.py +126 -0
  62. package/deploy/prod_final.py +140 -0
  63. package/deploy/prod_go.py +61 -0
  64. package/deploy/prod_one_shot.py +139 -0
  65. package/deploy/prod_setup.sh +62 -0
  66. package/deploy/prod_v2.py +91 -0
  67. package/deploy/prod_v3.py +103 -0
  68. package/deploy/push_docs.py +93 -0
  69. package/deploy/push_mcp.py +66 -0
  70. package/deploy/push_mcp_doc.py +28 -0
  71. package/deploy/push_v063.py +80 -0
  72. package/deploy/push_v2.py +71 -0
  73. package/deploy/qcheck.py +13 -0
  74. package/deploy/qcheck2.py +18 -0
  75. package/deploy/quick_check.py +8 -0
  76. package/deploy/rebuild.py +47 -0
  77. package/deploy/rebuild_frontend.py +44 -0
  78. package/deploy/restart.py +28 -0
  79. package/deploy/restore_frontend.py +42 -0
  80. package/deploy/restore_frontend2.py +41 -0
  81. package/deploy/restore_gateway.py +34 -0
  82. package/deploy/restore_quick.py +33 -0
  83. package/deploy/run_bash.py +19 -0
  84. package/deploy/run_sep.py +20 -0
  85. package/deploy/sdk_upgrade.py +50 -0
  86. package/deploy/server_prepare.py +41 -0
  87. package/deploy/server_setup.py +41 -0
  88. package/deploy/ssh_check.py +14 -0
  89. package/deploy/ssh_push.py +24 -0
  90. package/deploy/start_deploy.py +35 -0
  91. package/deploy/start_frontend.py +12 -0
  92. package/deploy/start_v2.py +29 -0
  93. package/deploy/stat.py +18 -0
  94. package/deploy/tail_log.py +11 -0
  95. package/deploy/update_env.py +32 -0
  96. package/deploy/upload_and_start.py +107 -0
  97. package/deploy/verify_a2a.py +13 -0
  98. package/deploy/verify_mcp.py +37 -0
  99. package/deploy/verify_sdk.py +21 -0
  100. package/dist/config.d.ts +26 -0
  101. package/dist/config.js +41 -0
  102. package/dist/config.js.map +1 -0
  103. package/dist/index.d.ts +1 -0
  104. package/dist/index.js +149 -0
  105. package/dist/index.js.map +1 -0
  106. package/dist/lib/crypto.d.ts +2 -0
  107. package/dist/lib/crypto.js +43 -0
  108. package/dist/lib/crypto.js.map +1 -0
  109. package/dist/lib/db.d.ts +3 -0
  110. package/dist/lib/db.js +26 -0
  111. package/dist/lib/db.js.map +1 -0
  112. package/dist/lib/ethers-utils.d.ts +60 -0
  113. package/dist/lib/ethers-utils.js +186 -0
  114. package/dist/lib/ethers-utils.js.map +1 -0
  115. package/dist/middleware/adminAuth.d.ts +2 -0
  116. package/dist/middleware/adminAuth.js +31 -0
  117. package/dist/middleware/adminAuth.js.map +1 -0
  118. package/dist/middleware/auth.d.ts +22 -0
  119. package/dist/middleware/auth.js +192 -0
  120. package/dist/middleware/auth.js.map +1 -0
  121. package/dist/middleware/error-handler.d.ts +36 -0
  122. package/dist/middleware/error-handler.js +100 -0
  123. package/dist/middleware/error-handler.js.map +1 -0
  124. package/dist/middleware/rate-limiter.d.ts +5 -0
  125. package/dist/middleware/rate-limiter.js +107 -0
  126. package/dist/middleware/rate-limiter.js.map +1 -0
  127. package/dist/routes/a2a.d.ts +2 -0
  128. package/dist/routes/a2a.js +84 -0
  129. package/dist/routes/a2a.js.map +1 -0
  130. package/dist/routes/admin.d.ts +2 -0
  131. package/dist/routes/admin.js +180 -0
  132. package/dist/routes/admin.js.map +1 -0
  133. package/dist/routes/agents.d.ts +2 -0
  134. package/dist/routes/agents.js +43 -0
  135. package/dist/routes/agents.js.map +1 -0
  136. package/dist/routes/chat.d.ts +2 -0
  137. package/dist/routes/chat.js +183 -0
  138. package/dist/routes/chat.js.map +1 -0
  139. package/dist/routes/history.d.ts +2 -0
  140. package/dist/routes/history.js +92 -0
  141. package/dist/routes/history.js.map +1 -0
  142. package/dist/routes/mcp.d.ts +13 -0
  143. package/dist/routes/mcp.js +513 -0
  144. package/dist/routes/mcp.js.map +1 -0
  145. package/dist/routes/tenant.d.ts +2 -0
  146. package/dist/routes/tenant.js +154 -0
  147. package/dist/routes/tenant.js.map +1 -0
  148. package/dist/services/a2a-worker.d.ts +7 -0
  149. package/dist/services/a2a-worker.js +406 -0
  150. package/dist/services/a2a-worker.js.map +1 -0
  151. package/dist/services/agent-indexer.d.ts +4 -0
  152. package/dist/services/agent-indexer.js +124 -0
  153. package/dist/services/agent-indexer.js.map +1 -0
  154. package/e2e_wallet.js +175 -0
  155. package/ecosystem.config.js +17 -0
  156. package/package.json +33 -0
  157. package/src/config.ts +47 -0
  158. package/src/index.ts +143 -0
  159. package/src/lib/crypto.ts +44 -0
  160. package/src/lib/db.ts +25 -0
  161. package/src/lib/ethers-utils.ts +229 -0
  162. package/src/middleware/adminAuth.ts +34 -0
  163. package/src/middleware/auth.ts +244 -0
  164. package/src/middleware/error-handler.ts +109 -0
  165. package/src/middleware/rate-limiter.ts +115 -0
  166. package/src/routes/a2a.ts +97 -0
  167. package/src/routes/admin.ts +219 -0
  168. package/src/routes/agents.ts +52 -0
  169. package/src/routes/chat.ts +219 -0
  170. package/src/routes/history.ts +114 -0
  171. package/src/routes/mcp.ts +567 -0
  172. package/src/routes/tenant.ts +196 -0
  173. package/src/services/a2a-worker.ts +486 -0
  174. package/src/services/agent-indexer.ts +127 -0
  175. 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