signalwire-agents 0.1.13__py3-none-any.whl → 0.1.15__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.
@@ -25,7 +25,7 @@ import re
25
25
  import signal
26
26
  import sys
27
27
  from typing import Optional, Union, List, Dict, Any, Tuple, Callable, Type
28
- from urllib.parse import urlparse, urlencode
28
+ from urllib.parse import urlparse, urlencode, urlunparse
29
29
 
30
30
  try:
31
31
  import fastapi
@@ -44,31 +44,7 @@ except ImportError:
44
44
  "uvicorn is required. Install it with: pip install uvicorn"
45
45
  )
46
46
 
47
- try:
48
- import structlog
49
- # Configure structlog only if not already configured
50
- if not structlog.is_configured():
51
- structlog.configure(
52
- processors=[
53
- structlog.stdlib.filter_by_level,
54
- structlog.stdlib.add_logger_name,
55
- structlog.stdlib.add_log_level,
56
- structlog.stdlib.PositionalArgumentsFormatter(),
57
- structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
58
- structlog.processors.StackInfoRenderer(),
59
- structlog.processors.format_exc_info,
60
- structlog.processors.UnicodeDecoder(),
61
- structlog.dev.ConsoleRenderer()
62
- ],
63
- context_class=dict,
64
- logger_factory=structlog.stdlib.LoggerFactory(),
65
- wrapper_class=structlog.stdlib.BoundLogger,
66
- cache_logger_on_first_use=True,
67
- )
68
- except ImportError:
69
- raise ImportError(
70
- "structlog is required. Install it with: pip install structlog"
71
- )
47
+
72
48
 
73
49
  from signalwire_agents.core.pom_builder import PomBuilder
74
50
  from signalwire_agents.core.swaig_function import SWAIGFunction
@@ -82,8 +58,8 @@ from signalwire_agents.core.skill_manager import SkillManager
82
58
  from signalwire_agents.utils.schema_utils import SchemaUtils
83
59
  from signalwire_agents.core.logging_config import get_logger, get_execution_mode
84
60
 
85
- # Create a logger
86
- logger = structlog.get_logger("agent_base")
61
+ # Create a logger using centralized system
62
+ logger = get_logger("agent_base")
87
63
 
88
64
  class EphemeralAgentConfig:
89
65
  """
@@ -1323,51 +1299,61 @@ class AgentBase(SWMLService):
1323
1299
  script_name = os.getenv('SCRIPT_NAME', '')
1324
1300
  base_url = f"{protocol}://{host}{script_name}"
1325
1301
  elif mode == 'lambda':
1326
- function_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
1327
- if function_url and ('amazonaws.com' in function_url or 'on.aws' in function_url):
1328
- base_url = function_url.rstrip('/')
1302
+ # AWS Lambda Function URL format
1303
+ lambda_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
1304
+ if lambda_url:
1305
+ base_url = lambda_url.rstrip('/')
1329
1306
  else:
1330
- api_id = os.getenv('AWS_API_GATEWAY_ID')
1331
- if api_id:
1332
- region = os.getenv('AWS_REGION', 'us-east-1')
1333
- stage = os.getenv('AWS_API_GATEWAY_STAGE', 'prod')
1334
- base_url = f"https://{api_id}.execute-api.{region}.amazonaws.com/{stage}"
1335
- else:
1336
- import logging
1337
- logging.warning("Lambda mode detected but no URL configuration found")
1338
- base_url = "https://lambda-url-not-configured"
1339
- elif mode == 'cloud_function':
1340
- function_url = os.getenv('FUNCTION_URL')
1341
- if function_url:
1342
- base_url = function_url
1307
+ # Fallback construction for Lambda
1308
+ region = os.getenv('AWS_REGION', 'us-east-1')
1309
+ function_name = os.getenv('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
1310
+ base_url = f"https://{function_name}.lambda-url.{region}.on.aws"
1311
+ elif mode == 'google_cloud_function':
1312
+ # Google Cloud Functions URL format
1313
+ project_id = os.getenv('GOOGLE_CLOUD_PROJECT') or os.getenv('GCP_PROJECT')
1314
+ region = os.getenv('FUNCTION_REGION') or os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
1315
+ service_name = os.getenv('K_SERVICE') or os.getenv('FUNCTION_TARGET', 'unknown')
1316
+
1317
+ if project_id:
1318
+ base_url = f"https://{region}-{project_id}.cloudfunctions.net/{service_name}"
1343
1319
  else:
1344
- project = os.getenv('GOOGLE_CLOUD_PROJECT')
1345
- if project:
1346
- region = os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
1347
- service = os.getenv('K_SERVICE', 'function')
1348
- base_url = f"https://{region}-{project}.cloudfunctions.net/{service}"
1349
- else:
1350
- import logging
1351
- logging.warning("Cloud Function mode detected but no URL configuration found")
1352
- base_url = "https://cloud-function-url-not-configured"
1353
- else:
1354
- # Server mode - preserve existing logic
1355
- if self._proxy_url_base:
1356
- proxy_base = self._proxy_url_base.rstrip('/')
1357
- route = self.route if self.route.startswith('/') else f"/{self.route}"
1358
- base_url = f"{proxy_base}{route}"
1320
+ # Fallback for local testing or incomplete environment
1321
+ base_url = f"https://localhost:8080"
1322
+ elif mode == 'azure_function':
1323
+ # Azure Functions URL format
1324
+ function_app_name = os.getenv('WEBSITE_SITE_NAME') or os.getenv('AZURE_FUNCTIONS_APP_NAME')
1325
+ function_name = os.getenv('AZURE_FUNCTION_NAME', 'unknown')
1326
+
1327
+ if function_app_name:
1328
+ base_url = f"https://{function_app_name}.azurewebsites.net/api/{function_name}"
1359
1329
  else:
1360
- if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
1361
- host = "localhost"
1362
- else:
1363
- host = self.host
1364
- base_url = f"http://{host}:{self.port}{self.route}"
1365
-
1366
- # Add auth if requested (only for server mode)
1367
- if include_auth and mode == 'server':
1368
- username, password = self._basic_auth
1369
- url = urlparse(base_url)
1370
- return url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
1330
+ # Fallback for local testing
1331
+ base_url = f"https://localhost:7071/api/{function_name}"
1332
+ else:
1333
+ # Server mode
1334
+ protocol = 'https' if self.ssl_cert and self.ssl_key else 'http'
1335
+ base_url = f"{protocol}://{self.host}:{self.port}"
1336
+
1337
+ # Add route if not already included (for server mode)
1338
+ if mode == 'server' and self.route and not base_url.endswith(self.route):
1339
+ base_url = f"{base_url}/{self.route.lstrip('/')}"
1340
+
1341
+ # Add authentication if requested
1342
+ if include_auth:
1343
+ username, password = self.get_basic_auth_credentials()
1344
+ if username and password:
1345
+ # Parse URL to insert auth
1346
+ from urllib.parse import urlparse, urlunparse
1347
+ parsed = urlparse(base_url)
1348
+ # Reconstruct with auth
1349
+ base_url = urlunparse((
1350
+ parsed.scheme,
1351
+ f"{username}:{password}@{parsed.netloc}",
1352
+ parsed.path,
1353
+ parsed.params,
1354
+ parsed.query,
1355
+ parsed.fragment
1356
+ ))
1371
1357
 
1372
1358
  return base_url
1373
1359
 
@@ -1386,11 +1372,8 @@ class AgentBase(SWMLService):
1386
1372
  mode = get_execution_mode()
1387
1373
 
1388
1374
  if mode != 'server':
1389
- # In serverless mode, use the serverless-appropriate URL
1390
- base_url = self.get_full_url()
1391
-
1392
- # For serverless, we don't need auth in webhook URLs since auth is handled differently
1393
- # and we want to return the actual platform URL
1375
+ # In serverless mode, use the serverless-appropriate URL with auth
1376
+ base_url = self.get_full_url(include_auth=True)
1394
1377
 
1395
1378
  # Ensure the endpoint has a trailing slash to prevent redirects
1396
1379
  if endpoint in ["swaig", "post_prompt"]:
@@ -1905,6 +1888,124 @@ class AgentBase(SWMLService):
1905
1888
  else:
1906
1889
  raise
1907
1890
 
1891
+ def _check_cgi_auth(self) -> bool:
1892
+ """
1893
+ Check basic auth in CGI mode using environment variables
1894
+
1895
+ Returns:
1896
+ True if auth is valid, False otherwise
1897
+ """
1898
+ # Check for HTTP_AUTHORIZATION environment variable
1899
+ auth_header = os.getenv('HTTP_AUTHORIZATION')
1900
+ if not auth_header:
1901
+ # Also check for REMOTE_USER (if web server handled auth)
1902
+ remote_user = os.getenv('REMOTE_USER')
1903
+ if remote_user:
1904
+ # If web server handled auth, trust it
1905
+ return True
1906
+ return False
1907
+
1908
+ if not auth_header.startswith('Basic '):
1909
+ return False
1910
+
1911
+ try:
1912
+ # Decode the base64 credentials
1913
+ credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
1914
+ username, password = credentials.split(":", 1)
1915
+ return self.validate_basic_auth(username, password)
1916
+ except Exception:
1917
+ return False
1918
+
1919
+ def _send_cgi_auth_challenge(self) -> str:
1920
+ """
1921
+ Send authentication challenge in CGI mode
1922
+
1923
+ Returns:
1924
+ HTTP response with 401 status and WWW-Authenticate header
1925
+ """
1926
+ # In CGI, we need to output the complete HTTP response
1927
+ response = "Status: 401 Unauthorized\r\n"
1928
+ response += "WWW-Authenticate: Basic realm=\"SignalWire Agent\"\r\n"
1929
+ response += "Content-Type: application/json\r\n"
1930
+ response += "\r\n"
1931
+ response += json.dumps({"error": "Unauthorized"})
1932
+ return response
1933
+
1934
+ def _check_lambda_auth(self, event) -> bool:
1935
+ """
1936
+ Check basic auth in Lambda mode using event headers
1937
+
1938
+ Args:
1939
+ event: Lambda event object containing headers
1940
+
1941
+ Returns:
1942
+ True if auth is valid, False otherwise
1943
+ """
1944
+ if not event or 'headers' not in event:
1945
+ return False
1946
+
1947
+ headers = event['headers']
1948
+
1949
+ # Check for authorization header (case-insensitive)
1950
+ auth_header = None
1951
+ for key, value in headers.items():
1952
+ if key.lower() == 'authorization':
1953
+ auth_header = value
1954
+ break
1955
+
1956
+ if not auth_header or not auth_header.startswith('Basic '):
1957
+ return False
1958
+
1959
+ try:
1960
+ # Decode the base64 credentials
1961
+ credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
1962
+ username, password = credentials.split(":", 1)
1963
+ return self.validate_basic_auth(username, password)
1964
+ except Exception:
1965
+ return False
1966
+
1967
+ def _send_lambda_auth_challenge(self) -> dict:
1968
+ """
1969
+ Send authentication challenge in Lambda mode
1970
+
1971
+ Returns:
1972
+ Lambda response with 401 status and WWW-Authenticate header
1973
+ """
1974
+ return {
1975
+ "statusCode": 401,
1976
+ "headers": {
1977
+ "WWW-Authenticate": "Basic realm=\"SignalWire Agent\"",
1978
+ "Content-Type": "application/json"
1979
+ },
1980
+ "body": json.dumps({"error": "Unauthorized"})
1981
+ }
1982
+
1983
+ def _check_cloud_function_auth(self, request) -> bool:
1984
+ """
1985
+ Check basic auth in Cloud Function mode
1986
+
1987
+ Args:
1988
+ request: Cloud Function request object
1989
+
1990
+ Returns:
1991
+ True if auth is valid, False otherwise
1992
+ """
1993
+ # This would need to be implemented based on the specific
1994
+ # cloud function framework being used (Flask, etc.)
1995
+ # For now, return True to maintain existing behavior
1996
+ return True
1997
+
1998
+ def _send_cloud_function_auth_challenge(self):
1999
+ """
2000
+ Send authentication challenge in Cloud Function mode
2001
+
2002
+ Returns:
2003
+ Cloud Function response with 401 status
2004
+ """
2005
+ # This would need to be implemented based on the specific
2006
+ # cloud function framework being used
2007
+ return {"error": "Unauthorized", "status": 401}
2008
+
1908
2009
  def handle_serverless_request(self, event=None, context=None, mode=None):
1909
2010
  """
1910
2011
  Handle serverless environment requests (CGI, Lambda, Cloud Functions)
@@ -1922,6 +2023,10 @@ class AgentBase(SWMLService):
1922
2023
 
1923
2024
  try:
1924
2025
  if mode == 'cgi':
2026
+ # Check authentication in CGI mode
2027
+ if not self._check_cgi_auth():
2028
+ return self._send_cgi_auth_challenge()
2029
+
1925
2030
  path_info = os.getenv('PATH_INFO', '').strip('/')
1926
2031
  if not path_info:
1927
2032
  return self._render_swml()
@@ -1957,6 +2062,10 @@ class AgentBase(SWMLService):
1957
2062
  return self._execute_swaig_function(path_info, args, call_id, raw_data)
1958
2063
 
1959
2064
  elif mode == 'lambda':
2065
+ # Check authentication in Lambda mode
2066
+ if not self._check_lambda_auth(event):
2067
+ return self._send_lambda_auth_challenge()
2068
+
1960
2069
  if event:
1961
2070
  path = event.get('pathParameters', {}).get('proxy', '') if event.get('pathParameters') else ''
1962
2071
  if not path:
@@ -2011,7 +2120,26 @@ class AgentBase(SWMLService):
2011
2120
  "body": swml_response
2012
2121
  }
2013
2122
 
2014
- elif mode in ['cloud_function', 'azure_function']:
2123
+ elif mode == 'google_cloud_function':
2124
+ # Check authentication in Google Cloud Functions mode
2125
+ if not self._check_google_cloud_function_auth(event):
2126
+ return self._send_google_cloud_function_auth_challenge()
2127
+
2128
+ return self._handle_google_cloud_function_request(event)
2129
+
2130
+ elif mode == 'azure_function':
2131
+ # Check authentication in Azure Functions mode
2132
+ if not self._check_azure_function_auth(event):
2133
+ return self._send_azure_function_auth_challenge()
2134
+
2135
+ return self._handle_azure_function_request(event)
2136
+
2137
+ elif mode in ['cloud_function']:
2138
+ # Legacy cloud function mode - deprecated
2139
+ # Check authentication in Cloud Function mode
2140
+ if not self._check_cloud_function_auth(event):
2141
+ return self._send_cloud_function_auth_challenge()
2142
+
2015
2143
  return self._handle_cloud_function_request(event)
2016
2144
 
2017
2145
  except Exception as e:
@@ -2053,8 +2181,6 @@ class AgentBase(SWMLService):
2053
2181
  Returns:
2054
2182
  Function execution result
2055
2183
  """
2056
- import structlog
2057
-
2058
2184
  # Use the existing logger
2059
2185
  req_log = self.log.bind(
2060
2186
  endpoint="serverless_swaig",
@@ -3439,3 +3565,245 @@ class AgentBase(SWMLService):
3439
3565
  def has_skill(self, skill_name: str) -> bool:
3440
3566
  """Check if skill is loaded"""
3441
3567
  return self.skill_manager.has_skill(skill_name)
3568
+
3569
+ def _check_google_cloud_function_auth(self, request) -> bool:
3570
+ """
3571
+ Check basic auth in Google Cloud Functions mode using request headers
3572
+
3573
+ Args:
3574
+ request: Flask request object or similar containing headers
3575
+
3576
+ Returns:
3577
+ True if auth is valid, False otherwise
3578
+ """
3579
+ if not hasattr(request, 'headers'):
3580
+ return False
3581
+
3582
+ # Check for authorization header (case-insensitive)
3583
+ auth_header = None
3584
+ for key in request.headers:
3585
+ if key.lower() == 'authorization':
3586
+ auth_header = request.headers[key]
3587
+ break
3588
+
3589
+ if not auth_header or not auth_header.startswith('Basic '):
3590
+ return False
3591
+
3592
+ try:
3593
+ import base64
3594
+ encoded_credentials = auth_header[6:] # Remove 'Basic '
3595
+ decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
3596
+ provided_username, provided_password = decoded_credentials.split(':', 1)
3597
+
3598
+ expected_username, expected_password = self.get_basic_auth_credentials()
3599
+ return (provided_username == expected_username and
3600
+ provided_password == expected_password)
3601
+ except Exception:
3602
+ return False
3603
+
3604
+ def _check_azure_function_auth(self, req) -> bool:
3605
+ """
3606
+ Check basic auth in Azure Functions mode using request object
3607
+
3608
+ Args:
3609
+ req: Azure Functions request object containing headers
3610
+
3611
+ Returns:
3612
+ True if auth is valid, False otherwise
3613
+ """
3614
+ if not hasattr(req, 'headers'):
3615
+ return False
3616
+
3617
+ # Check for authorization header (case-insensitive)
3618
+ auth_header = None
3619
+ for key, value in req.headers.items():
3620
+ if key.lower() == 'authorization':
3621
+ auth_header = value
3622
+ break
3623
+
3624
+ if not auth_header or not auth_header.startswith('Basic '):
3625
+ return False
3626
+
3627
+ try:
3628
+ import base64
3629
+ encoded_credentials = auth_header[6:] # Remove 'Basic '
3630
+ decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
3631
+ provided_username, provided_password = decoded_credentials.split(':', 1)
3632
+
3633
+ expected_username, expected_password = self.get_basic_auth_credentials()
3634
+ return (provided_username == expected_username and
3635
+ provided_password == expected_password)
3636
+ except Exception:
3637
+ return False
3638
+
3639
+ def _send_google_cloud_function_auth_challenge(self):
3640
+ """
3641
+ Send authentication challenge in Google Cloud Functions mode
3642
+
3643
+ Returns:
3644
+ Flask-compatible response with 401 status and WWW-Authenticate header
3645
+ """
3646
+ from flask import Response
3647
+ return Response(
3648
+ response=json.dumps({"error": "Unauthorized"}),
3649
+ status=401,
3650
+ headers={
3651
+ "WWW-Authenticate": "Basic realm=\"SignalWire Agent\"",
3652
+ "Content-Type": "application/json"
3653
+ }
3654
+ )
3655
+
3656
+ def _send_azure_function_auth_challenge(self):
3657
+ """
3658
+ Send authentication challenge in Azure Functions mode
3659
+
3660
+ Returns:
3661
+ Azure Functions response with 401 status and WWW-Authenticate header
3662
+ """
3663
+ import azure.functions as func
3664
+ return func.HttpResponse(
3665
+ body=json.dumps({"error": "Unauthorized"}),
3666
+ status_code=401,
3667
+ headers={
3668
+ "WWW-Authenticate": "Basic realm=\"SignalWire Agent\"",
3669
+ "Content-Type": "application/json"
3670
+ }
3671
+ )
3672
+
3673
+ def _handle_google_cloud_function_request(self, request):
3674
+ """
3675
+ Handle Google Cloud Functions specific requests
3676
+
3677
+ Args:
3678
+ request: Flask request object from Google Cloud Functions
3679
+
3680
+ Returns:
3681
+ Flask response object
3682
+ """
3683
+ try:
3684
+ # Get the path from the request
3685
+ path = request.path.strip('/')
3686
+
3687
+ if not path:
3688
+ # Root request - return SWML
3689
+ swml_response = self._render_swml()
3690
+ from flask import Response
3691
+ return Response(
3692
+ response=swml_response,
3693
+ status=200,
3694
+ headers={"Content-Type": "application/json"}
3695
+ )
3696
+ else:
3697
+ # SWAIG function call
3698
+ args = {}
3699
+ call_id = None
3700
+ raw_data = None
3701
+
3702
+ # Parse request data
3703
+ if request.method == 'POST':
3704
+ try:
3705
+ if request.is_json:
3706
+ raw_data = request.get_json()
3707
+ else:
3708
+ raw_data = json.loads(request.get_data(as_text=True))
3709
+
3710
+ call_id = raw_data.get("call_id")
3711
+
3712
+ # Extract arguments like the FastAPI handler does
3713
+ if "argument" in raw_data and isinstance(raw_data["argument"], dict):
3714
+ if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
3715
+ args = raw_data["argument"]["parsed"][0]
3716
+ elif "raw" in raw_data["argument"]:
3717
+ try:
3718
+ args = json.loads(raw_data["argument"]["raw"])
3719
+ except Exception:
3720
+ pass
3721
+ except Exception:
3722
+ # If parsing fails, continue with empty args
3723
+ pass
3724
+
3725
+ result = self._execute_swaig_function(path, args, call_id, raw_data)
3726
+ from flask import Response
3727
+ return Response(
3728
+ response=json.dumps(result) if isinstance(result, dict) else str(result),
3729
+ status=200,
3730
+ headers={"Content-Type": "application/json"}
3731
+ )
3732
+
3733
+ except Exception as e:
3734
+ import logging
3735
+ logging.error(f"Error in Google Cloud Function request handler: {e}")
3736
+ from flask import Response
3737
+ return Response(
3738
+ response=json.dumps({"error": str(e)}),
3739
+ status=500,
3740
+ headers={"Content-Type": "application/json"}
3741
+ )
3742
+
3743
+ def _handle_azure_function_request(self, req):
3744
+ """
3745
+ Handle Azure Functions specific requests
3746
+
3747
+ Args:
3748
+ req: Azure Functions HttpRequest object
3749
+
3750
+ Returns:
3751
+ Azure Functions HttpResponse object
3752
+ """
3753
+ try:
3754
+ import azure.functions as func
3755
+
3756
+ # Get the path from the request
3757
+ path = req.url.split('/')[-1] if req.url else ''
3758
+
3759
+ if not path or path == 'api':
3760
+ # Root request - return SWML
3761
+ swml_response = self._render_swml()
3762
+ return func.HttpResponse(
3763
+ body=swml_response,
3764
+ status_code=200,
3765
+ headers={"Content-Type": "application/json"}
3766
+ )
3767
+ else:
3768
+ # SWAIG function call
3769
+ args = {}
3770
+ call_id = None
3771
+ raw_data = None
3772
+
3773
+ # Parse request data
3774
+ if req.method == 'POST':
3775
+ try:
3776
+ body = req.get_body()
3777
+ if body:
3778
+ raw_data = json.loads(body.decode('utf-8'))
3779
+ call_id = raw_data.get("call_id")
3780
+
3781
+ # Extract arguments like the FastAPI handler does
3782
+ if "argument" in raw_data and isinstance(raw_data["argument"], dict):
3783
+ if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
3784
+ args = raw_data["argument"]["parsed"][0]
3785
+ elif "raw" in raw_data["argument"]:
3786
+ try:
3787
+ args = json.loads(raw_data["argument"]["raw"])
3788
+ except Exception:
3789
+ pass
3790
+ except Exception:
3791
+ # If parsing fails, continue with empty args
3792
+ pass
3793
+
3794
+ result = self._execute_swaig_function(path, args, call_id, raw_data)
3795
+ return func.HttpResponse(
3796
+ body=json.dumps(result) if isinstance(result, dict) else str(result),
3797
+ status_code=200,
3798
+ headers={"Content-Type": "application/json"}
3799
+ )
3800
+
3801
+ except Exception as e:
3802
+ import logging
3803
+ logging.error(f"Error in Azure Function request handler: {e}")
3804
+ import azure.functions as func
3805
+ return func.HttpResponse(
3806
+ body=json.dumps({"error": str(e)}),
3807
+ status_code=500,
3808
+ headers={"Content-Type": "application/json"}
3809
+ )