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,333 @@
1
+ """
2
+ Tenant Management REST API for A2A Server.
3
+
4
+ Provides endpoints for tenant provisioning, management, and administration:
5
+ - Public signup for new tenants
6
+ - Tenant details retrieval
7
+ - Admin management of tenants
8
+ - Super-admin listing of all tenants
9
+ """
10
+
11
+ import logging
12
+ import re
13
+ import uuid
14
+ from typing import List, Optional
15
+
16
+ from fastapi import APIRouter, Depends, HTTPException, Query
17
+ from pydantic import BaseModel, Field, field_validator
18
+
19
+ from .database import (
20
+ create_tenant,
21
+ get_tenant_by_id,
22
+ get_tenant_by_realm,
23
+ list_tenants,
24
+ update_tenant,
25
+ )
26
+ from .keycloak_auth import require_admin, require_auth, UserSession
27
+ from .tenant_service import KeycloakTenantService, TenantAlreadyExistsError
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ router = APIRouter(prefix='/v1/tenants', tags=['tenants'])
32
+
33
+
34
+ # ========================================
35
+ # Request/Response Models
36
+ # ========================================
37
+
38
+
39
+ class TenantSignupRequest(BaseModel):
40
+ """Request model for tenant signup."""
41
+
42
+ org_name: str = Field(
43
+ ...,
44
+ min_length=3,
45
+ max_length=50,
46
+ description='Organization name (3-50 chars, alphanumeric and spaces)',
47
+ )
48
+ admin_email: str = Field(..., description='Admin user email address')
49
+ admin_password: str = Field(
50
+ ..., min_length=8, description='Admin user password (min 8 chars)'
51
+ )
52
+ plan: Optional[str] = Field(
53
+ default='free', description='Subscription plan (free, pro, enterprise)'
54
+ )
55
+
56
+ @field_validator('org_name')
57
+ @classmethod
58
+ def validate_org_name(cls, v: str) -> str:
59
+ """Validate org_name contains only alphanumeric characters and spaces."""
60
+ if not re.match(r'^[a-zA-Z0-9 ]+$', v):
61
+ raise ValueError(
62
+ 'Organization name must contain only alphanumeric characters and spaces'
63
+ )
64
+ return v
65
+
66
+
67
+ class TenantSignupResponse(BaseModel):
68
+ """Response model for successful tenant signup."""
69
+
70
+ tenant_id: str = Field(..., description='Unique tenant identifier')
71
+ realm_name: str = Field(..., description='Keycloak realm name')
72
+ login_url: str = Field(..., description='URL for user authentication')
73
+ spa_client_id: str = Field(
74
+ ..., description='Client ID for SPA applications'
75
+ )
76
+
77
+
78
+ class TenantResponse(BaseModel):
79
+ """Response model for tenant details."""
80
+
81
+ id: str
82
+ realm_name: str
83
+ display_name: Optional[str]
84
+ plan: str
85
+ stripe_customer_id: Optional[str]
86
+ stripe_subscription_id: Optional[str]
87
+ created_at: Optional[str]
88
+ updated_at: Optional[str]
89
+
90
+
91
+ class TenantUpdateRequest(BaseModel):
92
+ """Request model for tenant updates."""
93
+
94
+ display_name: Optional[str] = Field(
95
+ default=None, description='Human-readable tenant name'
96
+ )
97
+ plan: Optional[str] = Field(
98
+ default=None, description='Subscription plan (free, pro, enterprise)'
99
+ )
100
+
101
+
102
+ class TenantListResponse(BaseModel):
103
+ """Response model for tenant listing."""
104
+
105
+ tenants: List[TenantResponse]
106
+ total: int
107
+ limit: int
108
+ offset: int
109
+
110
+
111
+ # ========================================
112
+ # Helper Functions
113
+ # ========================================
114
+
115
+
116
+ def generate_slug(org_name: str) -> str:
117
+ """
118
+ Generate a URL-safe slug from organization name.
119
+
120
+ - Converts to lowercase
121
+ - Replaces spaces with hyphens
122
+ - Removes special characters
123
+ """
124
+ slug = org_name.lower()
125
+ slug = slug.replace(' ', '-')
126
+ slug = re.sub(r'[^a-z0-9-]', '', slug)
127
+ # Remove consecutive hyphens
128
+ slug = re.sub(r'-+', '-', slug)
129
+ # Remove leading/trailing hyphens
130
+ slug = slug.strip('-')
131
+ return slug
132
+
133
+
134
+ # ========================================
135
+ # Endpoints
136
+ # ========================================
137
+
138
+
139
+ @router.post('/signup', response_model=TenantSignupResponse, status_code=201)
140
+ async def signup_tenant(request: TenantSignupRequest):
141
+ """
142
+ Create a new tenant (public endpoint, no auth required).
143
+
144
+ This endpoint provisions:
145
+ - A new Keycloak realm for the organization
146
+ - Standard OAuth clients (SPA, API, Mobile)
147
+ - An admin user with the provided credentials
148
+ - A database record for the tenant
149
+
150
+ Returns login URL and client credentials for the new tenant.
151
+ """
152
+ # Generate slug from org name
153
+ slug = generate_slug(request.org_name)
154
+
155
+ if not slug:
156
+ raise HTTPException(
157
+ status_code=400,
158
+ detail='Invalid organization name - could not generate slug',
159
+ )
160
+
161
+ logger.info(f'Tenant signup request for: {request.org_name} (slug: {slug})')
162
+
163
+ try:
164
+ # Create tenant in Keycloak
165
+ keycloak_service = KeycloakTenantService()
166
+ tenant_info = await keycloak_service.create_tenant(
167
+ org_slug=slug,
168
+ admin_email=request.admin_email,
169
+ admin_password=request.admin_password,
170
+ )
171
+
172
+ # Store tenant in database
173
+ db_tenant = await create_tenant(
174
+ realm_name=tenant_info.realm_name,
175
+ display_name=request.org_name,
176
+ plan=request.plan or 'free',
177
+ )
178
+
179
+ # Build login URL
180
+ from .keycloak_auth import KEYCLOAK_URL
181
+
182
+ login_url = f'{KEYCLOAK_URL}/realms/{tenant_info.realm_name}/protocol/openid-connect/auth'
183
+
184
+ logger.info(
185
+ f'Tenant created successfully: {tenant_info.realm_name} (id: {db_tenant["id"]})'
186
+ )
187
+
188
+ return TenantSignupResponse(
189
+ tenant_id=db_tenant['id'],
190
+ realm_name=tenant_info.realm_name,
191
+ login_url=login_url,
192
+ spa_client_id=tenant_info.spa_client_id,
193
+ )
194
+
195
+ except TenantAlreadyExistsError as e:
196
+ logger.warning(f'Tenant already exists: {slug}')
197
+ raise HTTPException(
198
+ status_code=409,
199
+ detail=f'A tenant with this organization name already exists: {str(e)}',
200
+ )
201
+ except Exception as e:
202
+ logger.error(f'Failed to create tenant: {e}')
203
+ raise HTTPException(
204
+ status_code=500,
205
+ detail=f'Failed to create tenant: {str(e)}',
206
+ )
207
+
208
+
209
+ @router.get('/me', response_model=TenantResponse)
210
+ async def get_my_tenant(user: UserSession = Depends(require_auth)):
211
+ """
212
+ Get the current user's tenant details.
213
+
214
+ Requires authentication. Returns the tenant associated with the
215
+ authenticated user's tenant_id.
216
+ """
217
+ # Get tenant_id from user session
218
+ tenant_id = getattr(user, 'tenant_id', None)
219
+
220
+ if not tenant_id:
221
+ # Try to find tenant by realm from the token issuer
222
+ # The realm is typically in the format: org-slug.codetether.run
223
+ raise HTTPException(
224
+ status_code=404,
225
+ detail='No tenant associated with this user',
226
+ )
227
+
228
+ tenant = await get_tenant_by_id(tenant_id)
229
+
230
+ if not tenant:
231
+ raise HTTPException(
232
+ status_code=404,
233
+ detail='Tenant not found',
234
+ )
235
+
236
+ return TenantResponse(**tenant)
237
+
238
+
239
+ @router.get('/{tenant_id}', response_model=TenantResponse)
240
+ async def get_tenant(
241
+ tenant_id: str,
242
+ user: UserSession = Depends(require_admin),
243
+ ):
244
+ """
245
+ Get tenant details by ID.
246
+
247
+ Requires admin role.
248
+ """
249
+ tenant = await get_tenant_by_id(tenant_id)
250
+
251
+ if not tenant:
252
+ raise HTTPException(
253
+ status_code=404,
254
+ detail=f'Tenant {tenant_id} not found',
255
+ )
256
+
257
+ return TenantResponse(**tenant)
258
+
259
+
260
+ @router.patch('/{tenant_id}', response_model=TenantResponse)
261
+ async def update_tenant_details(
262
+ tenant_id: str,
263
+ request: TenantUpdateRequest,
264
+ user: UserSession = Depends(require_admin),
265
+ ):
266
+ """
267
+ Update tenant details.
268
+
269
+ Requires admin role. Only display_name and plan can be updated.
270
+ """
271
+ # Check if tenant exists
272
+ existing = await get_tenant_by_id(tenant_id)
273
+ if not existing:
274
+ raise HTTPException(
275
+ status_code=404,
276
+ detail=f'Tenant {tenant_id} not found',
277
+ )
278
+
279
+ # Build update kwargs (only include non-None values)
280
+ update_kwargs = {}
281
+ if request.display_name is not None:
282
+ update_kwargs['display_name'] = request.display_name
283
+ if request.plan is not None:
284
+ update_kwargs['plan'] = request.plan
285
+
286
+ if not update_kwargs:
287
+ # Nothing to update, return current tenant
288
+ return TenantResponse(**existing)
289
+
290
+ try:
291
+ updated_tenant = await update_tenant(tenant_id, **update_kwargs)
292
+ logger.info(f'Tenant {tenant_id} updated: {update_kwargs}')
293
+ return TenantResponse(**updated_tenant)
294
+ except ValueError as e:
295
+ raise HTTPException(status_code=404, detail=str(e))
296
+ except Exception as e:
297
+ logger.error(f'Failed to update tenant {tenant_id}: {e}')
298
+ raise HTTPException(
299
+ status_code=500,
300
+ detail=f'Failed to update tenant: {str(e)}',
301
+ )
302
+
303
+
304
+ @router.get('', response_model=TenantListResponse)
305
+ async def list_all_tenants(
306
+ limit: int = Query(
307
+ default=100, ge=1, le=1000, description='Maximum results'
308
+ ),
309
+ offset: int = Query(default=0, ge=0, description='Results offset'),
310
+ user: UserSession = Depends(require_auth),
311
+ ):
312
+ """
313
+ List all tenants.
314
+
315
+ Requires super-admin role (user must have 'super-admin' in their roles).
316
+ """
317
+ # Check for super-admin role
318
+ if 'super-admin' not in user.roles:
319
+ raise HTTPException(
320
+ status_code=403,
321
+ detail='Super-admin access required to list all tenants',
322
+ )
323
+
324
+ tenants = await list_tenants(limit=limit, offset=offset)
325
+
326
+ return TenantListResponse(
327
+ tenants=[TenantResponse(**t) for t in tenants],
328
+ total=len(
329
+ tenants
330
+ ), # Note: This is approximate; full count would require additional query
331
+ limit=limit,
332
+ offset=offset,
333
+ )
@@ -0,0 +1,219 @@
1
+ """
2
+ Tenant Context Middleware for A2A Server.
3
+
4
+ Extracts tenant context from JWT tokens and makes it available
5
+ in request.state for downstream handlers.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from starlette.requests import Request
13
+
14
+ try:
15
+ from jose import jwt
16
+ except ImportError:
17
+ jwt = None # type: ignore
18
+
19
+ from . import database
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class TenantContextMiddleware(BaseHTTPMiddleware):
25
+ """
26
+ Middleware that extracts tenant context from JWT tokens.
27
+
28
+ Extracts the realm from the 'iss' claim in the JWT and looks up
29
+ the corresponding tenant_id from the database. Stores tenant
30
+ information in request.state for use by downstream handlers.
31
+
32
+ Attributes stored in request.state:
33
+ - tenant_id: The database tenant ID (or None if not found)
34
+ - realm_name: The Keycloak realm name (or None if not found)
35
+ - tenant_plan: The tenant's subscription plan (or None if not found)
36
+ """
37
+
38
+ async def dispatch(self, request: Request, call_next):
39
+ """Process the request and extract tenant context."""
40
+ # Initialize tenant context as None
41
+ request.state.tenant_id = None
42
+ request.state.realm_name = None
43
+ request.state.tenant_plan = None
44
+
45
+ # Try to extract tenant from Authorization header
46
+ try:
47
+ tenant_info = await self._extract_tenant_from_request(request)
48
+ if tenant_info:
49
+ request.state.tenant_id = tenant_info.get('id')
50
+ request.state.realm_name = tenant_info.get('realm_name')
51
+ request.state.tenant_plan = tenant_info.get('plan')
52
+ logger.debug(
53
+ f'Tenant context set: {request.state.realm_name} '
54
+ f'(id={request.state.tenant_id}, plan={request.state.tenant_plan})'
55
+ )
56
+ except Exception as e:
57
+ # Log but don't fail - allow unauthenticated requests
58
+ logger.debug(f'Could not extract tenant context: {e}')
59
+
60
+ # Continue processing the request
61
+ response = await call_next(request)
62
+ return response
63
+
64
+ async def _extract_tenant_from_request(
65
+ self, request: Request
66
+ ) -> Optional[dict]:
67
+ """
68
+ Extract tenant information from the request's JWT token.
69
+
70
+ Args:
71
+ request: The incoming request
72
+
73
+ Returns:
74
+ Tenant dict if found, None otherwise
75
+ """
76
+ # Get Authorization header
77
+ auth_header = request.headers.get('Authorization')
78
+ if not auth_header:
79
+ return None
80
+
81
+ # Extract Bearer token
82
+ if not auth_header.startswith('Bearer '):
83
+ return None
84
+
85
+ token = auth_header[7:] # Remove 'Bearer ' prefix
86
+ if not token:
87
+ return None
88
+
89
+ # Extract realm from token
90
+ realm_name = self._extract_realm_from_token(token)
91
+ if not realm_name:
92
+ return None
93
+
94
+ # Look up tenant from database
95
+ tenant = await database.get_tenant_by_realm(realm_name)
96
+ return tenant
97
+
98
+ def _extract_realm_from_token(self, token: str) -> Optional[str]:
99
+ """
100
+ Extract the realm name from a JWT token's 'iss' claim.
101
+
102
+ Uses unverified claims extraction for performance - full
103
+ validation should be done by the auth layer.
104
+
105
+ Args:
106
+ token: The JWT token string
107
+
108
+ Returns:
109
+ The realm name or None if extraction fails
110
+ """
111
+ if jwt is None:
112
+ logger.warning(
113
+ 'python-jose not installed, cannot extract realm from JWT'
114
+ )
115
+ return None
116
+
117
+ try:
118
+ # Get unverified claims for fast extraction
119
+ # Full validation is done by the auth layer
120
+ claims = jwt.get_unverified_claims(token)
121
+
122
+ # Extract issuer claim
123
+ issuer = claims.get('iss')
124
+ if not issuer:
125
+ return None
126
+
127
+ # Parse realm from issuer URL
128
+ # Format: https://keycloak.example.com/realms/realm-name
129
+ # or: https://keycloak.example.com/auth/realms/realm-name
130
+ realm_name = self._parse_realm_from_issuer(issuer)
131
+ return realm_name
132
+
133
+ except Exception as e:
134
+ logger.debug(f'Failed to extract realm from token: {e}')
135
+ return None
136
+
137
+ def _parse_realm_from_issuer(self, issuer: str) -> Optional[str]:
138
+ """
139
+ Parse the realm name from a Keycloak issuer URL.
140
+
141
+ Args:
142
+ issuer: The issuer URL from the JWT
143
+
144
+ Returns:
145
+ The realm name or None if parsing fails
146
+ """
147
+ if not issuer:
148
+ return None
149
+
150
+ # Handle both /realms/ and /auth/realms/ formats
151
+ if '/realms/' in issuer:
152
+ # Extract everything after /realms/
153
+ parts = issuer.split('/realms/')
154
+ if len(parts) >= 2:
155
+ # Get the realm part, removing any trailing path
156
+ realm_part = parts[1]
157
+ # Remove any trailing path segments
158
+ realm_name = realm_part.split('/')[0]
159
+ return realm_name if realm_name else None
160
+
161
+ return None
162
+
163
+
164
+ def get_tenant_id(request: Request) -> Optional[str]:
165
+ """
166
+ Helper function to get tenant_id from request state.
167
+
168
+ Args:
169
+ request: The request object
170
+
171
+ Returns:
172
+ The tenant_id or None if not set
173
+ """
174
+ return getattr(request.state, 'tenant_id', None)
175
+
176
+
177
+ def get_realm_name(request: Request) -> Optional[str]:
178
+ """
179
+ Helper function to get realm_name from request state.
180
+
181
+ Args:
182
+ request: The request object
183
+
184
+ Returns:
185
+ The realm_name or None if not set
186
+ """
187
+ return getattr(request.state, 'realm_name', None)
188
+
189
+
190
+ def get_tenant_plan(request: Request) -> Optional[str]:
191
+ """
192
+ Helper function to get tenant_plan from request state.
193
+
194
+ Args:
195
+ request: The request object
196
+
197
+ Returns:
198
+ The tenant_plan or None if not set
199
+ """
200
+ return getattr(request.state, 'tenant_plan', None)
201
+
202
+
203
+ def require_tenant(request: Request) -> str:
204
+ """
205
+ Helper function that requires a tenant_id to be present.
206
+
207
+ Args:
208
+ request: The request object
209
+
210
+ Returns:
211
+ The tenant_id
212
+
213
+ Raises:
214
+ ValueError: If no tenant_id is present
215
+ """
216
+ tenant_id = get_tenant_id(request)
217
+ if not tenant_id:
218
+ raise ValueError('Tenant context required but not found')
219
+ return tenant_id