codetether 1.2.2__py3-none-any.whl
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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stripe Webhook Handler for A2A Server Billing.
|
|
3
|
+
|
|
4
|
+
Handles Stripe webhook events for subscription lifecycle management:
|
|
5
|
+
- customer.subscription.created/updated/deleted
|
|
6
|
+
- invoice.paid/payment_failed
|
|
7
|
+
- checkout.session.completed
|
|
8
|
+
|
|
9
|
+
Configuration:
|
|
10
|
+
STRIPE_WEBHOOK_SECRET: Webhook signing secret (env var or Vault)
|
|
11
|
+
Vault path: kv/codetether/stripe (key: webhook_secret)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Dict, Optional, Set
|
|
19
|
+
|
|
20
|
+
import stripe
|
|
21
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
22
|
+
|
|
23
|
+
from .database import get_tenant_by_id, update_tenant, list_tenants
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Router for webhook endpoints
|
|
28
|
+
billing_webhook_router = APIRouter(prefix='/v1/webhooks', tags=['billing'])
|
|
29
|
+
|
|
30
|
+
# Plan definitions - maps Stripe price_ids to plan names
|
|
31
|
+
# These should match your Stripe product/price configuration
|
|
32
|
+
PLANS = {
|
|
33
|
+
# Free tier (no subscription required)
|
|
34
|
+
'free': {
|
|
35
|
+
'name': 'Free',
|
|
36
|
+
'workers': 1,
|
|
37
|
+
'codebases': 3,
|
|
38
|
+
'tasks_per_month': 100,
|
|
39
|
+
},
|
|
40
|
+
# Pro tier
|
|
41
|
+
'pro': {
|
|
42
|
+
'name': 'Pro',
|
|
43
|
+
'workers': 5,
|
|
44
|
+
'codebases': 20,
|
|
45
|
+
'tasks_per_month': 5000,
|
|
46
|
+
},
|
|
47
|
+
# Enterprise tier
|
|
48
|
+
'enterprise': {
|
|
49
|
+
'name': 'Enterprise',
|
|
50
|
+
'workers': -1, # unlimited
|
|
51
|
+
'codebases': -1, # unlimited
|
|
52
|
+
'tasks_per_month': -1, # unlimited
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Map Stripe price IDs to plan names
|
|
57
|
+
PRICE_TO_PLAN: Dict[str, str] = {
|
|
58
|
+
'price_1SoawKE8yr4fu4JjkHQA2Y2c': 'pro', # $49/month
|
|
59
|
+
'price_1SoawKE8yr4fu4Jj7iDEjsk6': 'enterprise', # $199/month
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Idempotency: Track processed event IDs to avoid duplicate processing
|
|
63
|
+
# In production, use Redis or database for persistence across restarts
|
|
64
|
+
_processed_events: Set[str] = set()
|
|
65
|
+
_processed_events_lock = asyncio.Lock()
|
|
66
|
+
|
|
67
|
+
# Maximum events to keep in memory (prevent unbounded growth)
|
|
68
|
+
MAX_PROCESSED_EVENTS = 10000
|
|
69
|
+
|
|
70
|
+
# Webhook secret - loaded lazily
|
|
71
|
+
_webhook_secret: Optional[str] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _get_webhook_secret() -> str:
|
|
75
|
+
"""
|
|
76
|
+
Get Stripe webhook secret from Vault or environment.
|
|
77
|
+
|
|
78
|
+
Priority:
|
|
79
|
+
1. Environment variable STRIPE_WEBHOOK_SECRET
|
|
80
|
+
2. Vault at kv/codetether/stripe (key: webhook_secret)
|
|
81
|
+
"""
|
|
82
|
+
global _webhook_secret
|
|
83
|
+
|
|
84
|
+
if _webhook_secret:
|
|
85
|
+
return _webhook_secret
|
|
86
|
+
|
|
87
|
+
# Try environment variable first
|
|
88
|
+
env_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
|
|
89
|
+
if env_secret:
|
|
90
|
+
_webhook_secret = env_secret
|
|
91
|
+
logger.info('Using Stripe webhook secret from environment')
|
|
92
|
+
return _webhook_secret
|
|
93
|
+
|
|
94
|
+
# Try Vault
|
|
95
|
+
try:
|
|
96
|
+
from .vault_client import get_vault_client
|
|
97
|
+
|
|
98
|
+
client = get_vault_client()
|
|
99
|
+
secret_data = await client.read_secret('codetether/stripe')
|
|
100
|
+
if secret_data and 'webhook_secret' in secret_data:
|
|
101
|
+
_webhook_secret = secret_data['webhook_secret']
|
|
102
|
+
logger.info('Using Stripe webhook secret from Vault')
|
|
103
|
+
return _webhook_secret
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.warning(f'Failed to fetch webhook secret from Vault: {e}')
|
|
106
|
+
|
|
107
|
+
raise ValueError(
|
|
108
|
+
'Stripe webhook secret not configured. '
|
|
109
|
+
'Set STRIPE_WEBHOOK_SECRET env var or configure in Vault at kv/codetether/stripe'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def price_id_to_plan(price_id: str) -> str:
|
|
114
|
+
"""
|
|
115
|
+
Convert a Stripe price_id to a plan name.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
price_id: Stripe price identifier
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Plan name (free, pro, enterprise)
|
|
122
|
+
"""
|
|
123
|
+
return PRICE_TO_PLAN.get(price_id, 'free')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def get_tenant_by_customer_id(
|
|
127
|
+
customer_id: str,
|
|
128
|
+
) -> Optional[Dict[str, Any]]:
|
|
129
|
+
"""
|
|
130
|
+
Look up a tenant by their Stripe customer ID.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
customer_id: Stripe customer ID (cus_xxx)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Tenant dict or None if not found
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
# List all tenants and find the one with matching customer_id
|
|
140
|
+
# In production, you'd want an index on stripe_customer_id
|
|
141
|
+
tenants = await list_tenants(limit=10000)
|
|
142
|
+
for tenant in tenants:
|
|
143
|
+
if tenant.get('stripe_customer_id') == customer_id:
|
|
144
|
+
return tenant
|
|
145
|
+
return None
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(
|
|
148
|
+
f'Error looking up tenant by customer_id {customer_id}: {e}'
|
|
149
|
+
)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _is_event_processed(event_id: str) -> bool:
|
|
154
|
+
"""Check if an event has already been processed (idempotency)."""
|
|
155
|
+
async with _processed_events_lock:
|
|
156
|
+
return event_id in _processed_events
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _mark_event_processed(event_id: str) -> None:
|
|
160
|
+
"""Mark an event as processed (idempotency)."""
|
|
161
|
+
async with _processed_events_lock:
|
|
162
|
+
# Prevent unbounded growth - remove oldest events if at limit
|
|
163
|
+
if len(_processed_events) >= MAX_PROCESSED_EVENTS:
|
|
164
|
+
# Remove first 1000 events (FIFO approximation with set)
|
|
165
|
+
events_list = list(_processed_events)
|
|
166
|
+
_processed_events.clear()
|
|
167
|
+
_processed_events.update(events_list[1000:])
|
|
168
|
+
|
|
169
|
+
_processed_events.add(event_id)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _handle_subscription_created(subscription: Dict[str, Any]) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Handle customer.subscription.created event.
|
|
175
|
+
|
|
176
|
+
Updates tenant with subscription_id and sets plan based on price_id.
|
|
177
|
+
"""
|
|
178
|
+
customer_id = subscription.get('customer')
|
|
179
|
+
subscription_id = subscription.get('id')
|
|
180
|
+
|
|
181
|
+
if not customer_id or not subscription_id:
|
|
182
|
+
logger.warning('Subscription created event missing customer or id')
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
tenant = await get_tenant_by_customer_id(customer_id)
|
|
186
|
+
if not tenant:
|
|
187
|
+
logger.warning(f'No tenant found for customer {customer_id}')
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Get plan from price_id
|
|
191
|
+
items = subscription.get('items', {}).get('data', [])
|
|
192
|
+
plan = 'free'
|
|
193
|
+
if items:
|
|
194
|
+
price_id = items[0].get('price', {}).get('id', '')
|
|
195
|
+
plan = price_id_to_plan(price_id)
|
|
196
|
+
|
|
197
|
+
# Update tenant
|
|
198
|
+
try:
|
|
199
|
+
await update_tenant(
|
|
200
|
+
tenant['id'],
|
|
201
|
+
plan=plan,
|
|
202
|
+
)
|
|
203
|
+
# Also update stripe_subscription_id
|
|
204
|
+
from .database import update_tenant_stripe
|
|
205
|
+
|
|
206
|
+
await update_tenant_stripe(
|
|
207
|
+
tenant['id'],
|
|
208
|
+
customer_id=customer_id,
|
|
209
|
+
subscription_id=subscription_id,
|
|
210
|
+
)
|
|
211
|
+
logger.info(
|
|
212
|
+
f'Tenant {tenant["id"]} subscription created: {subscription_id}, plan: {plan}'
|
|
213
|
+
)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error(f'Failed to update tenant {tenant["id"]}: {e}')
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _handle_subscription_updated(subscription: Dict[str, Any]) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Handle customer.subscription.updated event.
|
|
221
|
+
|
|
222
|
+
Updates tenant plan if price changed, handles status changes.
|
|
223
|
+
"""
|
|
224
|
+
customer_id = subscription.get('customer')
|
|
225
|
+
subscription_id = subscription.get('id')
|
|
226
|
+
status = subscription.get('status')
|
|
227
|
+
|
|
228
|
+
if not customer_id:
|
|
229
|
+
logger.warning('Subscription updated event missing customer')
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
tenant = await get_tenant_by_customer_id(customer_id)
|
|
233
|
+
if not tenant:
|
|
234
|
+
logger.warning(f'No tenant found for customer {customer_id}')
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# Get plan from price_id
|
|
238
|
+
items = subscription.get('items', {}).get('data', [])
|
|
239
|
+
plan = tenant.get('plan', 'free') # Keep current plan as default
|
|
240
|
+
if items:
|
|
241
|
+
price_id = items[0].get('price', {}).get('id', '')
|
|
242
|
+
plan = price_id_to_plan(price_id)
|
|
243
|
+
|
|
244
|
+
# Handle status changes
|
|
245
|
+
if status == 'past_due':
|
|
246
|
+
logger.warning(
|
|
247
|
+
f'Tenant {tenant["id"]} subscription {subscription_id} is past due'
|
|
248
|
+
)
|
|
249
|
+
# Could trigger notification here
|
|
250
|
+
elif status == 'unpaid':
|
|
251
|
+
logger.warning(
|
|
252
|
+
f'Tenant {tenant["id"]} subscription {subscription_id} is unpaid, '
|
|
253
|
+
'downgrading to free'
|
|
254
|
+
)
|
|
255
|
+
plan = 'free'
|
|
256
|
+
elif status == 'canceled':
|
|
257
|
+
logger.info(
|
|
258
|
+
f'Tenant {tenant["id"]} subscription {subscription_id} canceled'
|
|
259
|
+
)
|
|
260
|
+
plan = 'free'
|
|
261
|
+
elif status == 'active':
|
|
262
|
+
logger.info(
|
|
263
|
+
f'Tenant {tenant["id"]} subscription {subscription_id} is active'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Update tenant plan
|
|
267
|
+
try:
|
|
268
|
+
await update_tenant(tenant['id'], plan=plan)
|
|
269
|
+
logger.info(f'Tenant {tenant["id"]} updated to plan: {plan}')
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.error(f'Failed to update tenant {tenant["id"]}: {e}')
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _handle_subscription_deleted(subscription: Dict[str, Any]) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Handle customer.subscription.deleted event.
|
|
277
|
+
|
|
278
|
+
Sets tenant plan back to 'free' and clears subscription_id.
|
|
279
|
+
"""
|
|
280
|
+
customer_id = subscription.get('customer')
|
|
281
|
+
subscription_id = subscription.get('id')
|
|
282
|
+
|
|
283
|
+
if not customer_id:
|
|
284
|
+
logger.warning('Subscription deleted event missing customer')
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
tenant = await get_tenant_by_customer_id(customer_id)
|
|
288
|
+
if not tenant:
|
|
289
|
+
logger.warning(f'No tenant found for customer {customer_id}')
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# Downgrade to free and clear subscription
|
|
293
|
+
try:
|
|
294
|
+
await update_tenant(tenant['id'], plan='free')
|
|
295
|
+
|
|
296
|
+
# Clear subscription_id by updating with empty string
|
|
297
|
+
from .database import update_tenant_stripe
|
|
298
|
+
|
|
299
|
+
await update_tenant_stripe(
|
|
300
|
+
tenant['id'],
|
|
301
|
+
customer_id=customer_id,
|
|
302
|
+
subscription_id='',
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
logger.info(
|
|
306
|
+
f'Tenant {tenant["id"]} subscription {subscription_id} deleted, '
|
|
307
|
+
'downgraded to free'
|
|
308
|
+
)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.error(f'Failed to update tenant {tenant["id"]}: {e}')
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _handle_invoice_paid(invoice: Dict[str, Any]) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Handle invoice.paid event.
|
|
316
|
+
|
|
317
|
+
Logs successful payment. Could trigger email notification.
|
|
318
|
+
"""
|
|
319
|
+
customer_id = invoice.get('customer')
|
|
320
|
+
amount_paid = (
|
|
321
|
+
invoice.get('amount_paid', 0) / 100
|
|
322
|
+
) # Convert cents to dollars
|
|
323
|
+
invoice_id = invoice.get('id')
|
|
324
|
+
|
|
325
|
+
if not customer_id:
|
|
326
|
+
logger.warning('Invoice paid event missing customer')
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
tenant = await get_tenant_by_customer_id(customer_id)
|
|
330
|
+
tenant_id = tenant['id'] if tenant else 'unknown'
|
|
331
|
+
|
|
332
|
+
logger.info(
|
|
333
|
+
f'Invoice {invoice_id} paid: ${amount_paid:.2f} for tenant {tenant_id}'
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# TODO: Trigger email notification
|
|
337
|
+
# await send_payment_confirmation_email(tenant, invoice)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def _handle_invoice_payment_failed(invoice: Dict[str, Any]) -> None:
|
|
341
|
+
"""
|
|
342
|
+
Handle invoice.payment_failed event.
|
|
343
|
+
|
|
344
|
+
Logs failed payment. Could trigger email/notification to tenant admin.
|
|
345
|
+
"""
|
|
346
|
+
customer_id = invoice.get('customer')
|
|
347
|
+
amount_due = invoice.get('amount_due', 0) / 100 # Convert cents to dollars
|
|
348
|
+
invoice_id = invoice.get('id')
|
|
349
|
+
attempt_count = invoice.get('attempt_count', 0)
|
|
350
|
+
|
|
351
|
+
if not customer_id:
|
|
352
|
+
logger.warning('Invoice payment failed event missing customer')
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
tenant = await get_tenant_by_customer_id(customer_id)
|
|
356
|
+
tenant_id = tenant['id'] if tenant else 'unknown'
|
|
357
|
+
|
|
358
|
+
logger.warning(
|
|
359
|
+
f'Invoice {invoice_id} payment failed: ${amount_due:.2f} for tenant '
|
|
360
|
+
f'{tenant_id} (attempt {attempt_count})'
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# TODO: Trigger notification to tenant admin
|
|
364
|
+
# await send_payment_failed_notification(tenant, invoice)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
async def _handle_checkout_completed(session: Dict[str, Any]) -> None:
|
|
368
|
+
"""
|
|
369
|
+
Handle checkout.session.completed event.
|
|
370
|
+
|
|
371
|
+
For subscription checkouts, ensures tenant is updated with customer_id
|
|
372
|
+
and subscription_id.
|
|
373
|
+
"""
|
|
374
|
+
mode = session.get('mode')
|
|
375
|
+
|
|
376
|
+
# Only process subscription checkouts
|
|
377
|
+
if mode != 'subscription':
|
|
378
|
+
logger.debug(f'Ignoring checkout session with mode: {mode}')
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
customer_id = session.get('customer')
|
|
382
|
+
subscription_id = session.get('subscription')
|
|
383
|
+
client_reference_id = session.get('client_reference_id') # tenant_id
|
|
384
|
+
|
|
385
|
+
if not customer_id or not subscription_id:
|
|
386
|
+
logger.warning('Checkout completed missing customer or subscription')
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# Try to find tenant by client_reference_id (tenant_id passed at checkout)
|
|
390
|
+
tenant = None
|
|
391
|
+
if client_reference_id:
|
|
392
|
+
tenant = await get_tenant_by_id(client_reference_id)
|
|
393
|
+
|
|
394
|
+
# Fallback: try to find by customer_id (if tenant already linked)
|
|
395
|
+
if not tenant and customer_id:
|
|
396
|
+
tenant = await get_tenant_by_customer_id(customer_id)
|
|
397
|
+
|
|
398
|
+
if not tenant:
|
|
399
|
+
logger.warning(
|
|
400
|
+
f'No tenant found for checkout - customer: {customer_id}, '
|
|
401
|
+
f'ref: {client_reference_id}'
|
|
402
|
+
)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# Update tenant with Stripe IDs
|
|
406
|
+
try:
|
|
407
|
+
from .database import update_tenant_stripe
|
|
408
|
+
|
|
409
|
+
await update_tenant_stripe(
|
|
410
|
+
tenant['id'],
|
|
411
|
+
customer_id=customer_id,
|
|
412
|
+
subscription_id=subscription_id,
|
|
413
|
+
)
|
|
414
|
+
logger.info(
|
|
415
|
+
f'Tenant {tenant["id"]} linked to Stripe customer {customer_id}'
|
|
416
|
+
)
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.error(f'Failed to update tenant {tenant["id"]}: {e}')
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@billing_webhook_router.post('/stripe')
|
|
422
|
+
async def stripe_webhook(request: Request):
|
|
423
|
+
"""
|
|
424
|
+
Handle Stripe webhook events.
|
|
425
|
+
|
|
426
|
+
Verifies webhook signature and processes supported events.
|
|
427
|
+
Returns 200 OK quickly; heavy processing is done async.
|
|
428
|
+
"""
|
|
429
|
+
# Get raw body for signature verification
|
|
430
|
+
payload = await request.body()
|
|
431
|
+
sig_header = request.headers.get('stripe-signature')
|
|
432
|
+
|
|
433
|
+
if not sig_header:
|
|
434
|
+
raise HTTPException(
|
|
435
|
+
status_code=400, detail='Missing stripe-signature header'
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Get webhook secret
|
|
439
|
+
try:
|
|
440
|
+
webhook_secret = await _get_webhook_secret()
|
|
441
|
+
except ValueError as e:
|
|
442
|
+
logger.error(f'Webhook secret not configured: {e}')
|
|
443
|
+
raise HTTPException(status_code=500, detail='Webhook not configured')
|
|
444
|
+
|
|
445
|
+
# Verify signature and construct event
|
|
446
|
+
try:
|
|
447
|
+
event = stripe.Webhook.construct_event(
|
|
448
|
+
payload, sig_header, webhook_secret
|
|
449
|
+
)
|
|
450
|
+
except stripe.error.SignatureVerificationError as e:
|
|
451
|
+
logger.warning(f'Invalid webhook signature: {e}')
|
|
452
|
+
raise HTTPException(status_code=400, detail='Invalid signature')
|
|
453
|
+
except Exception as e:
|
|
454
|
+
logger.error(f'Error constructing webhook event: {e}')
|
|
455
|
+
raise HTTPException(status_code=400, detail='Invalid payload')
|
|
456
|
+
|
|
457
|
+
event_id = event.get('id')
|
|
458
|
+
event_type = event.get('type')
|
|
459
|
+
|
|
460
|
+
logger.info(f'Received Stripe webhook: {event_type} ({event_id})')
|
|
461
|
+
|
|
462
|
+
# Idempotency check
|
|
463
|
+
if await _is_event_processed(event_id):
|
|
464
|
+
logger.info(f'Event {event_id} already processed, skipping')
|
|
465
|
+
return {'status': 'ok', 'message': 'already processed'}
|
|
466
|
+
|
|
467
|
+
# Mark as processed early to prevent duplicates during async processing
|
|
468
|
+
await _mark_event_processed(event_id)
|
|
469
|
+
|
|
470
|
+
# Get event data
|
|
471
|
+
data = event.get('data', {}).get('object', {})
|
|
472
|
+
|
|
473
|
+
# Process event based on type
|
|
474
|
+
# Use create_task for async processing to return 200 quickly
|
|
475
|
+
try:
|
|
476
|
+
if event_type == 'customer.subscription.created':
|
|
477
|
+
asyncio.create_task(_handle_subscription_created(data))
|
|
478
|
+
|
|
479
|
+
elif event_type == 'customer.subscription.updated':
|
|
480
|
+
asyncio.create_task(_handle_subscription_updated(data))
|
|
481
|
+
|
|
482
|
+
elif event_type == 'customer.subscription.deleted':
|
|
483
|
+
asyncio.create_task(_handle_subscription_deleted(data))
|
|
484
|
+
|
|
485
|
+
elif event_type == 'invoice.paid':
|
|
486
|
+
asyncio.create_task(_handle_invoice_paid(data))
|
|
487
|
+
|
|
488
|
+
elif event_type == 'invoice.payment_failed':
|
|
489
|
+
asyncio.create_task(_handle_invoice_payment_failed(data))
|
|
490
|
+
|
|
491
|
+
elif event_type == 'checkout.session.completed':
|
|
492
|
+
asyncio.create_task(_handle_checkout_completed(data))
|
|
493
|
+
|
|
494
|
+
else:
|
|
495
|
+
logger.debug(f'Unhandled event type: {event_type}')
|
|
496
|
+
|
|
497
|
+
except Exception as e:
|
|
498
|
+
logger.error(f'Error processing webhook event {event_type}: {e}')
|
|
499
|
+
# Still return 200 to prevent Stripe retries for processing errors
|
|
500
|
+
|
|
501
|
+
return {'status': 'ok', 'event_type': event_type}
|
a2a_server/config.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for A2A Server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
# Load environment variables from .env file
|
|
11
|
+
load_dotenv()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServerConfig(BaseModel):
|
|
15
|
+
"""Configuration for the A2A server."""
|
|
16
|
+
|
|
17
|
+
host: str = '0.0.0.0'
|
|
18
|
+
port: int = 8000
|
|
19
|
+
redis_url: str = 'redis://localhost:6379'
|
|
20
|
+
database_url: str = (
|
|
21
|
+
'postgresql://postgres:spike2@192.168.50.70:5432/a2a_server'
|
|
22
|
+
)
|
|
23
|
+
auth_enabled: bool = False
|
|
24
|
+
auth_tokens: Optional[Dict[str, str]] = None
|
|
25
|
+
log_level: str = 'INFO'
|
|
26
|
+
# OpenCode host configuration - use host.docker.internal for container->host communication
|
|
27
|
+
opencode_host: str = 'localhost'
|
|
28
|
+
opencode_port: int = 9777
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentConfig(BaseModel):
|
|
32
|
+
"""Configuration for an A2A agent."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
organization: str
|
|
37
|
+
organization_url: str
|
|
38
|
+
base_url: Optional[str] = None
|
|
39
|
+
capabilities_streaming: bool = True
|
|
40
|
+
capabilities_push_notifications: bool = True
|
|
41
|
+
capabilities_state_history: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config() -> ServerConfig:
|
|
45
|
+
"""Load configuration from environment variables."""
|
|
46
|
+
return ServerConfig(
|
|
47
|
+
host=os.getenv('A2A_HOST', '0.0.0.0'),
|
|
48
|
+
port=int(os.getenv('A2A_PORT', '8000')),
|
|
49
|
+
redis_url=os.getenv('A2A_REDIS_URL', 'redis://localhost:6379'),
|
|
50
|
+
database_url=os.getenv(
|
|
51
|
+
'DATABASE_URL',
|
|
52
|
+
'postgresql://postgres:spike2@192.168.50.70:5432/a2a_server',
|
|
53
|
+
),
|
|
54
|
+
auth_enabled=os.getenv('A2A_AUTH_ENABLED', 'false').lower() == 'true',
|
|
55
|
+
auth_tokens=_parse_auth_tokens(os.getenv('A2A_AUTH_TOKENS')),
|
|
56
|
+
log_level=os.getenv('A2A_LOG_LEVEL', 'INFO'),
|
|
57
|
+
# OpenCode host - use host.docker.internal when running in container
|
|
58
|
+
opencode_host=os.getenv('OPENCODE_HOST', 'localhost'),
|
|
59
|
+
opencode_port=int(os.getenv('OPENCODE_PORT', '9777')),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_auth_tokens(tokens_str: Optional[str]) -> Optional[Dict[str, str]]:
|
|
64
|
+
"""Parse auth tokens from environment variable."""
|
|
65
|
+
if not tokens_str:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
tokens = {}
|
|
69
|
+
for token_pair in tokens_str.split(','):
|
|
70
|
+
if ':' in token_pair:
|
|
71
|
+
name, token = token_pair.split(':', 1)
|
|
72
|
+
tokens[name.strip()] = token.strip()
|
|
73
|
+
|
|
74
|
+
return tokens if tokens else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_agent_config(
|
|
78
|
+
name: str,
|
|
79
|
+
description: str,
|
|
80
|
+
organization: str = 'A2A Server',
|
|
81
|
+
organization_url: str = 'https://github.com/rileyseaburg/codetether',
|
|
82
|
+
port: Optional[int] = None,
|
|
83
|
+
) -> AgentConfig:
|
|
84
|
+
"""Create an agent configuration."""
|
|
85
|
+
if port and not port == 8000:
|
|
86
|
+
base_url = f'http://localhost:{port}'
|
|
87
|
+
else:
|
|
88
|
+
base_url = None
|
|
89
|
+
|
|
90
|
+
return AgentConfig(
|
|
91
|
+
name=name,
|
|
92
|
+
description=description,
|
|
93
|
+
organization=organization,
|
|
94
|
+
organization_url=organization_url,
|
|
95
|
+
base_url=base_url,
|
|
96
|
+
)
|