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.
Files changed (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. 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
+ )