diff --git a/cert_gen.py b/cert_gen.py index d797bc62..4761505e 100644 --- a/cert_gen.py +++ b/cert_gen.py @@ -1,9 +1,11 @@ +import datetime +import os + from cryptography import x509 -from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa -import datetime -import os +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + def generate_certificates(cert_dir="certs"): """Generate self-signed certificates with proper extensions for HTTPS proxy""" @@ -22,12 +24,14 @@ def generate_certificates(cert_dir="certs"): ca_public_key = ca_private_key.public_key() # CA BEGIN - name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "Proxy Pilot CA"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Proxy Pilot"), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Development"), - x509.NameAttribute(NameOID.COUNTRY_NAME, "UK"), - ]) + name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "Proxy Pilot CA"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Proxy Pilot"), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Development"), + x509.NameAttribute(NameOID.COUNTRY_NAME, "UK"), + ] + ) builder = x509.CertificateBuilder() builder = builder.subject_name(name) @@ -54,11 +58,11 @@ def generate_certificates(cert_dir="certs"): key_cert_sign=True, # This is a CA crl_sign=True, encipher_only=False, - decipher_only=False + decipher_only=False, ), critical=True, ) - + ca_cert = builder.sign( private_key=ca_private_key, algorithm=hashes.SHA256(), @@ -67,28 +71,32 @@ def generate_certificates(cert_dir="certs"): # Save CA certificate and key with open("certs/ca.crt", "wb") as f: - f.write(ca_cert.public_bytes(serialization.Encoding.PEM)) + f.write(ca_cert.public_bytes(serialization.Encoding.PEM)) with open("certs/ca.key", "wb") as f: - f.write(ca_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) + f.write( + ca_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) # CA END # SERVER BEGIN - ## Generate new certificate for domain + # Generate new certificate for domain server_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, # 2048 bits is sufficient for domain certs ) - name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "Proxy Pilot CA"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Proxy Pilot Generated"), - ]) + name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "Proxy Pilot CA"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Proxy Pilot Generated"), + ] + ) builder = x509.CertificateBuilder() builder = builder.subject_name(name) @@ -96,11 +104,9 @@ def generate_certificates(cert_dir="certs"): builder = builder.public_key(server_key.public_key()) builder = builder.serial_number(x509.random_serial_number()) builder = builder.not_valid_before(datetime.datetime.utcnow()) - builder = builder.not_valid_after( - datetime.datetime.utcnow() + datetime.timedelta(days=365) - ) + builder = builder.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) - # Add domain to SAN + # Add domain to SAN builder = builder.add_extension( x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False, @@ -108,10 +114,12 @@ def generate_certificates(cert_dir="certs"): # Add extended key usage builder = builder.add_extension( - x509.ExtendedKeyUsage([ - ExtendedKeyUsageOID.SERVER_AUTH, - ExtendedKeyUsageOID.CLIENT_AUTH, - ]), + x509.ExtendedKeyUsage( + [ + ExtendedKeyUsageOID.SERVER_AUTH, + ExtendedKeyUsageOID.CLIENT_AUTH, + ] + ), critical=False, ) @@ -130,31 +138,41 @@ def generate_certificates(cert_dir="certs"): f.write(certificate.public_bytes(serialization.Encoding.PEM)) with open("certs/server.key", "wb") as f: - f.write(server_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) - + f.write( + server_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) print("Certificates generated successfully in the 'certs' directory") print("\nTo trust these certificates:") print("\nOn macOS:") - print("sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/server.crt") + print( + "sudo security add-trusted-cert -d -r trustRoot " + "-k /Library/Keychains/System.keychain certs/server.crt" + ) print("\nOn Windows (PowerShell as Admin):") - print("Import-Certificate -FilePath \"certs\\server.crt\" -CertStoreLocation Cert:\\LocalMachine\\Root") + print( + 'Import-Certificate -FilePath "certs\\server.crt" ' + '-CertStoreLocation Cert:\\LocalMachine\\Root' + ) print("\nOn Linux:") print("sudo cp certs/server.crt /usr/local/share/ca-certificates/proxy-pilot.crt") print("sudo update-ca-certificates") print("\nFor VSCode, add to settings.json:") - print('''{ + print( + """{ "http.proxy": "https://localhost:8989", "http.proxySupport": "on", "github.copilot.advanced": { "debug.testOverrideProxyUrl": "https://localhost:8989", "debug.overrideProxyUrl": "https://localhost:8989" } -}''') +}""" + ) + if __name__ == "__main__": generate_certificates() diff --git a/scripts/import_packages.py b/scripts/import_packages.py index d4eb9573..75be7c7d 100644 --- a/scripts/import_packages.py +++ b/scripts/import_packages.py @@ -14,7 +14,7 @@ class PackageImporter: - def __init__(self, jsonl_dir='data', take_backup=True, restore_backup=True): + def __init__(self, jsonl_dir="data", take_backup=True, restore_backup=True): self.take_backup_flag = take_backup self.restore_backup_flag = restore_backup @@ -153,12 +153,13 @@ async def run_import(self): "--jsonl-dir", type=str, default="data", - help="Directory containing JSONL files. Default is 'data'." + help="Directory containing JSONL files. Default is 'data'.", ) args = parser.parse_args() - importer = PackageImporter(jsonl_dir=args.jsonl_dir, take_backup=args.take_backup, - restore_backup=args.restore_backup) + importer = PackageImporter( + jsonl_dir=args.jsonl_dir, take_backup=args.take_backup, restore_backup=args.restore_backup + ) asyncio.run(importer.run_import()) try: assert importer.client.is_live() diff --git a/src/codegate/ca/codegate_ca.py b/src/codegate/ca/codegate_ca.py index 71587cc2..5f33810d 100644 --- a/src/codegate/ca/codegate_ca.py +++ b/src/codegate/ca/codegate_ca.py @@ -1,25 +1,29 @@ -from cryptography import x509 -from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa import datetime import os import ssl +from typing import Dict, Optional, Tuple + import structlog -from typing import Tuple, Dict, Optional +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + from codegate.config import Config logger = structlog.get_logger("codegate") + class CertificateAuthority: """ Singleton class for Certificate Authority management. Access the instance using CertificateAuthority.get_instance() """ - _instance: Optional['CertificateAuthority'] = None + + _instance: Optional["CertificateAuthority"] = None @classmethod - def get_instance(cls) -> 'CertificateAuthority': + def get_instance(cls) -> "CertificateAuthority": """Get or create the singleton instance of CertificateAuthority""" if cls._instance is None: logger.debug("Creating new CertificateAuthority instance") @@ -63,12 +67,14 @@ def _load_or_generate_ca(self): key_size=4096, ) - name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "CodeGate CA"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CodeGate"), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "CodeGate"), - x509.NameAttribute(NameOID.COUNTRY_NAME, "UK"), - ]) + name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "CodeGate CA"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CodeGate"), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "CodeGate"), + x509.NameAttribute(NameOID.COUNTRY_NAME, "UK"), + ] + ) builder = x509.CertificateBuilder() builder = builder.subject_name(name) @@ -95,7 +101,7 @@ def _load_or_generate_ca(self): key_cert_sign=True, # This is a CA crl_sign=True, encipher_only=False, - decipher_only=False + decipher_only=False, ), critical=True, ) @@ -116,11 +122,13 @@ def _load_or_generate_ca(self): with open(ca_key, "wb") as f: logger.debug(f"Saving CA key to {ca_key}") - f.write(self._ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) + f.write( + self._ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) def get_domain_certificate(self, domain: str) -> Tuple[str, str]: """Get or generate a certificate for a specific domain""" @@ -135,10 +143,12 @@ def get_domain_certificate(self, domain: str) -> Tuple[str, str]: key_size=2048, # 2048 bits is sufficient for domain certs ) - name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, domain), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Proxy Pilot Generated"), - ]) + name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, domain), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Proxy Pilot Generated"), + ] + ) builder = x509.CertificateBuilder() builder = builder.subject_name(name) @@ -146,9 +156,7 @@ def get_domain_certificate(self, domain: str) -> Tuple[str, str]: builder = builder.public_key(key.public_key()) builder = builder.serial_number(x509.random_serial_number()) builder = builder.not_valid_before(datetime.datetime.utcnow()) - builder = builder.not_valid_after( - datetime.datetime.utcnow() + datetime.timedelta(days=365) - ) + builder = builder.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) # Add domain to SAN builder = builder.add_extension( @@ -158,10 +166,12 @@ def get_domain_certificate(self, domain: str) -> Tuple[str, str]: # Add extended key usage builder = builder.add_extension( - x509.ExtendedKeyUsage([ - ExtendedKeyUsageOID.SERVER_AUTH, - ExtendedKeyUsageOID.CLIENT_AUTH, - ]), + x509.ExtendedKeyUsage( + [ + ExtendedKeyUsageOID.SERVER_AUTH, + ExtendedKeyUsageOID.CLIENT_AUTH, + ] + ), critical=False, ) @@ -188,11 +198,13 @@ def get_domain_certificate(self, domain: str) -> Tuple[str, str]: with open(domain_key_path, "wb") as f: logger.debug(f"Saving key to {domain_key_path}") - f.write(key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) + f.write( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) self._cert_cache[domain] = (domain_cert_path, domain_key_path) return domain_cert_path, domain_key_path @@ -217,12 +229,14 @@ def generate_certificates(self) -> Tuple[str, str]: ca_public_key = ca_private_key.public_key() # Add name attributes - name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "CodeGate CA"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CodeGate"), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "CodeGate"), - x509.NameAttribute(NameOID.COUNTRY_NAME, "UK"), - ]) + name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "CodeGate CA"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CodeGate"), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "CodeGate"), + x509.NameAttribute(NameOID.COUNTRY_NAME, "UK"), + ] + ) # Create certificate builder builder = x509.CertificateBuilder() @@ -233,9 +247,7 @@ def generate_certificates(self) -> Tuple[str, str]: builder = builder.public_key(ca_public_key) builder = builder.serial_number(x509.random_serial_number()) builder = builder.not_valid_before(datetime.datetime.utcnow()) - builder = builder.not_valid_after( - datetime.datetime.utcnow() + datetime.timedelta(days=365) - ) + builder = builder.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) builder = builder.add_extension( x509.BasicConstraints(ca=True, path_length=None), @@ -253,7 +265,7 @@ def generate_certificates(self) -> Tuple[str, str]: key_cert_sign=True, # This is a CA crl_sign=True, encipher_only=False, - decipher_only=False + decipher_only=False, ), critical=True, ) @@ -265,31 +277,39 @@ def generate_certificates(self) -> Tuple[str, str]: ) # Save CA certificate and key - with open(os.path.join(Config.get_config().certs_dir, Config.get_config().ca_cert), "wb") as f: + with open( + os.path.join(Config.get_config().certs_dir, Config.get_config().ca_cert), "wb" + ) as f: logger.debug(f"Saving CA certificate to {Config.get_config().ca_cert}") f.write(ca_cert.public_bytes(serialization.Encoding.PEM)) - with open(os.path.join(Config.get_config().certs_dir, Config.get_config().ca_key), "wb") as f: + with open( + os.path.join(Config.get_config().certs_dir, Config.get_config().ca_key), "wb" + ) as f: logger.debug(f"Saving CA key to {Config.get_config().ca_key}") - f.write(ca_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) + f.write( + ca_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) # CA generated, now generate server certificate - ## Generate new certificate for domain + # Generate new certificate for domain logger.debug("Generating private key for server") server_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, # 2048 bits is sufficient for domain certs ) - name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "CodeGate Server"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CodeGate"), - ]) + name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "CodeGate Server"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CodeGate"), + ] + ) # Add extended key usage extension builder = x509.CertificateBuilder() @@ -298,9 +318,7 @@ def generate_certificates(self) -> Tuple[str, str]: builder = builder.public_key(server_key.public_key()) builder = builder.serial_number(x509.random_serial_number()) builder = builder.not_valid_before(datetime.datetime.utcnow()) - builder = builder.not_valid_after( - datetime.datetime.utcnow() + datetime.timedelta(days=365) - ) + builder = builder.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) # Add domain to SAN builder = builder.add_extension( @@ -310,10 +328,12 @@ def generate_certificates(self) -> Tuple[str, str]: # Add extended key usage builder = builder.add_extension( - x509.ExtendedKeyUsage([ - ExtendedKeyUsageOID.SERVER_AUTH, - ExtendedKeyUsageOID.CLIENT_AUTH, - ]), + x509.ExtendedKeyUsage( + [ + ExtendedKeyUsageOID.SERVER_AUTH, + ExtendedKeyUsageOID.CLIENT_AUTH, + ] + ), critical=False, ) @@ -330,37 +350,51 @@ def generate_certificates(self) -> Tuple[str, str]: ) # os.path.join(Config.get_config().server_key) - with open(os.path.join(Config.get_config().certs_dir, Config.get_config().server_cert), "wb") as f: + with open( + os.path.join(Config.get_config().certs_dir, Config.get_config().server_cert), "wb" + ) as f: logger.debug(f"Saving server certificate to {Config.get_config().server_cert}") f.write(server_cert.public_bytes(serialization.Encoding.PEM)) - with open(os.path.join(Config.get_config().certs_dir, Config.get_config().server_key), "wb") as f: + with open( + os.path.join(Config.get_config().certs_dir, Config.get_config().server_key), "wb" + ) as f: logger.debug(f"Saving server key to {Config.get_config().server_key}") - f.write(server_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) + f.write( + server_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) # Print instructions for trusting the certificates print("Certificates generated successfully in the 'certs' directory") print("\nTo trust these certificates:") print("\nOn macOS:") - print("`sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/ca.crt") + print( + "`sudo security add-trusted-cert -d -r trustRoot " + "-k /Library/Keychains/System.keychain certs/ca.crt" + ) print("\nOn Windows (PowerShell as Admin):") - print("Import-Certificate -FilePath \"certs\\ca.crt\" -CertStoreLocation Cert:\\LocalMachine\\Root") + print( + 'Import-Certificate -FilePath "certs\\ca.crt" ' + '-CertStoreLocation Cert:\\LocalMachine\\Root' + ) print("\nOn Linux:") print("sudo cp certs/ca.crt /usr/local/share/ca-certificates/codegate.crt") print("sudo update-ca-certificates") print("\nFor VSCode, add to settings.json:") - print('''{ + print( + """{ "http.proxy": "https://localhost:8990", "http.proxySupport": "on", "github.copilot.advanced": { "debug.testOverrideProxyUrl": "https://localhost:8990", "debug.overrideProxyUrl": "https://localhost:8990" } -}''') +}""" + ) logger.debug("Certificates generated successfully") return server_cert, server_key @@ -368,29 +402,42 @@ def create_ssl_context(self) -> ssl.SSLContext: """Create SSL context with secure configuration""" logger.debug("Creating SSL context fn: create_ssl_context") ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - logger.debug(f"Loading server certificate for ssl_context from: {Config.get_config().server_cert}") + logger.debug( + f"Loading server certificate for ssl_context from: {Config.get_config().server_cert}" + ) ssl_context.load_cert_chain( - os.path.join(Config.get_config().certs_dir, Config.get_config().server_cert), - os.path.join(Config.get_config().certs_dir, Config.get_config().server_key)) + os.path.join(Config.get_config().certs_dir, Config.get_config().server_cert), + os.path.join(Config.get_config().certs_dir, Config.get_config().server_key), + ) ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 ssl_context.options |= ( - ssl.OP_NO_SSLv2 | - ssl.OP_NO_SSLv3 | - ssl.OP_NO_COMPRESSION | - ssl.OP_CIPHER_SERVER_PREFERENCE + ssl.OP_NO_SSLv2 + | ssl.OP_NO_SSLv3 + | ssl.OP_NO_COMPRESSION + | ssl.OP_CIPHER_SERVER_PREFERENCE ) - ssl_context.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20') + ssl_context.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20") logger.debug("SSL context created successfully") return ssl_context def ensure_certificates_exist(self) -> None: """Ensure SSL certificates exist, generate if they don't""" logger.debug("Ensuring certificates exist. fn ensure_certificates_exist") - if not (os.path.exists(os.path.join(Config.get_config().certs_dir, Config.get_config().server_cert)) and os.path.exists(os.path.join(Config.get_config().certs_dir, Config.get_config().server_key))): + if not ( + os.path.exists( + os.path.join(Config.get_config().certs_dir, Config.get_config().server_cert) + ) + and os.path.exists( + os.path.join(Config.get_config().certs_dir, Config.get_config().server_key) + ) + ): logger.debug("Certificates not found, generating new certificates") self.generate_certificates() else: - logger.debug(f"Certificates found at: {Config.get_config().server_cert} and {Config.get_config().server_key}") + logger.debug( + f"Certificates found at: {Config.get_config().server_cert} " + "and {Config.get_config().server_key}" + ) def get_ssl_context(self) -> ssl.SSLContext: """Get SSL context with certificates""" diff --git a/src/codegate/cli.py b/src/codegate/cli.py index 373f9596..8ed5b62e 100644 --- a/src/codegate/cli.py +++ b/src/codegate/cli.py @@ -1,26 +1,26 @@ """Command-line interface for codegate.""" -import sys import asyncio -import signal +import sys from pathlib import Path from typing import Dict, Optional -from uvicorn.config import Config as UvicornConfig -from uvicorn.server import Server import click import structlog +from uvicorn.config import Config as UvicornConfig +from uvicorn.server import Server +from codegate.ca.codegate_ca import CertificateAuthority from codegate.codegate_logging import LogFormat, LogLevel, setup_logging from codegate.config import Config, ConfigurationError from codegate.db.connection import init_db_sync +from codegate.providers.copilot.provider import CopilotProvider from codegate.server import init_app from codegate.storage.utils import restore_storage_backup -from codegate.providers.copilot.provider import CopilotProvider -from codegate.ca.codegate_ca import CertificateAuthority logger = structlog.get_logger("codegate") + class UvicornServer: def __init__(self, config: UvicornConfig, server: Server): self.server = server @@ -63,7 +63,7 @@ async def cleanup(self) -> None: logger.debug("Initiating server shutdown") self._shutdown_event.set() - if hasattr(self.server, 'shutdown'): + if hasattr(self.server, "shutdown"): logger.debug("Shutting down server") await self.server.shutdown() @@ -74,6 +74,7 @@ async def cleanup(self) -> None: await asyncio.gather(*tasks, return_exceptions=True) logger.debug("Server shutdown complete") + def validate_port(ctx: click.Context, param: click.Parameter, value: int) -> int: logger.debug(f"Validating port number: {value}") """Validate the port number is in valid range.""" @@ -287,7 +288,6 @@ def serve( init_db_sync(cfg.db_path) - # Check certificates and create CA if necessary logger.info("Checking certificates and creating CA our created") ca = CertificateAuthority.get_instance() @@ -354,8 +354,8 @@ async def run_servers(cfg: Config, app) -> None: copilot_provider = CopilotProvider(cfg) tasks = [ - asyncio.create_task(server.serve()), # Uvicorn server - asyncio.create_task(copilot_provider.run_proxy_server()) # Proxy server + asyncio.create_task(server.serve()), # Uvicorn server + asyncio.create_task(copilot_provider.run_proxy_server()), # Proxy server ] try: @@ -379,6 +379,7 @@ async def run_servers(cfg: Config, app) -> None: logger.exception("Error running servers") raise e + @cli.command() @click.option( "--backup-path", @@ -401,9 +402,11 @@ def restore_backup(backup_path: Path, backup_name: str) -> None: click.echo(f"Error restoring backup: {e}", err=True) sys.exit(1) + def main() -> None: """Main entry point for the CLI.""" cli() + if __name__ == "__main__": main() diff --git a/src/codegate/codegate_logging.py b/src/codegate/codegate_logging.py index 3d93cda1..d99f55ae 100644 --- a/src/codegate/codegate_logging.py +++ b/src/codegate/codegate_logging.py @@ -1,10 +1,9 @@ +import json import logging import sys -import json -from enum import Enum -from typing import Optional from datetime import datetime -from typing import Any, Dict +from enum import Enum +from typing import Any, Dict, Optional import structlog @@ -153,7 +152,7 @@ def setup_logging( def serialize_for_logging(obj: Any) -> Any: """Serialize objects for logging, handling non-JSON serializable types""" - if hasattr(obj, '__dict__'): + if hasattr(obj, "__dict__"): return str(obj) elif isinstance(obj, (datetime, bytes)): return str(obj) @@ -163,6 +162,7 @@ def serialize_for_logging(obj: Any) -> Any: return [serialize_for_logging(item) for item in obj] return obj + def log_request(method: str, path: str, status_code: int, client: Any) -> None: """Log HTTP request details""" logger = logging.getLogger("proxy_pilot") @@ -172,10 +172,11 @@ def log_request(method: str, path: str, status_code: int, client: Any) -> None: "method": method, "path": path, "status_code": status_code, - "client": serialize_for_logging(client) + "client": serialize_for_logging(client), } logger.info(f"Request: {json.dumps(log_data, indent=2)}") + def log_proxy_forward(target_url: str, method: str, status_code: int) -> None: """Log proxy forwarding details""" logger = logging.getLogger("proxy_pilot") @@ -184,10 +185,11 @@ def log_proxy_forward(target_url: str, method: str, status_code: int) -> None: "type": "proxy_forward", "target_url": target_url, "method": method, - "status_code": status_code + "status_code": status_code, } logger.info(f"Proxy Forward: {json.dumps(log_data, indent=2)}") + def log_error(error_type: str, message: str, details: Dict = None) -> None: """Log error details""" logger = logging.getLogger("proxy_pilot") @@ -196,6 +198,6 @@ def log_error(error_type: str, message: str, details: Dict = None) -> None: "type": "error", "error_type": error_type, "message": message, - "details": serialize_for_logging(details or {}) + "details": serialize_for_logging(details or {}), } logger.error(f"Error: {json.dumps(log_data, indent=2)}") diff --git a/src/codegate/config.py b/src/codegate/config.py index d8ad43fd..59ec9d86 100644 --- a/src/codegate/config.py +++ b/src/codegate/config.py @@ -58,7 +58,7 @@ def __post_init__(self) -> None: """Validate configuration after initialization.""" if not isinstance(self.port, int) or not (1 <= self.port <= 65535): raise ConfigurationError("Port must be between 1 and 65535") - + if not isinstance(self.proxy_port, int) or not (1 <= self.proxy_port <= 65535): raise ConfigurationError("Proxy port must be between 1 and 65535") diff --git a/src/codegate/pipeline/secrets/secrets.py b/src/codegate/pipeline/secrets/secrets.py index e605f18b..01eb40e6 100644 --- a/src/codegate/pipeline/secrets/secrets.py +++ b/src/codegate/pipeline/secrets/secrets.py @@ -239,7 +239,7 @@ def _find_complete_redaction(self, text: str) -> tuple[Optional[re.Match[str]], # Get the first complete match match = matches[0] - return match, text[match.end() :] + return match, text[match.end():] async def process_chunk( self, diff --git a/src/codegate/providers/copilot/logging.py b/src/codegate/providers/copilot/logging.py index 264fcd22..2d1b6ae7 100644 --- a/src/codegate/providers/copilot/logging.py +++ b/src/codegate/providers/copilot/logging.py @@ -1,8 +1,9 @@ -import logging import json +import logging from datetime import datetime from typing import Any, Dict + def setup_logging() -> logging.Logger: """Setup logging configuration""" @@ -12,8 +13,7 @@ def setup_logging() -> logging.Logger: # Create formatter formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S%f' + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S%f" ) # Create console handler @@ -23,9 +23,10 @@ def setup_logging() -> logging.Logger: return logger + def serialize_for_logging(obj: Any) -> Any: """Serialize objects for logging, handling non-JSON serializable types""" - if hasattr(obj, '__dict__'): + if hasattr(obj, "__dict__"): return str(obj) elif isinstance(obj, (datetime, bytes)): return str(obj) @@ -35,6 +36,7 @@ def serialize_for_logging(obj: Any) -> Any: return [serialize_for_logging(item) for item in obj] return obj + def log_request(method: str, path: str, status_code: int, client: Any) -> None: """Log HTTP request details""" logger = logging.getLogger("proxy_pilot") @@ -44,10 +46,11 @@ def log_request(method: str, path: str, status_code: int, client: Any) -> None: "method": method, "path": path, "status_code": status_code, - "client": serialize_for_logging(client) + "client": serialize_for_logging(client), } logger.info(f"Request: {json.dumps(log_data, indent=2)}") + def log_proxy_forward(target_url: str, method: str, status_code: int) -> None: """Log proxy forwarding details""" logger = logging.getLogger("proxy_pilot") @@ -56,10 +59,11 @@ def log_proxy_forward(target_url: str, method: str, status_code: int) -> None: "type": "proxy_forward", "target_url": target_url, "method": method, - "status_code": status_code + "status_code": status_code, } logger.info(f"Proxy Forward: {json.dumps(log_data, indent=2)}") + def log_error(error_type: str, message: str, details: Dict = None) -> None: """Log error details""" logger = logging.getLogger("proxy_pilot") @@ -68,12 +72,13 @@ def log_error(error_type: str, message: str, details: Dict = None) -> None: "type": "error", "error_type": error_type, "message": message, - "details": serialize_for_logging(details or {}) + "details": serialize_for_logging(details or {}), } logger.error(f"Error: {json.dumps(log_data, indent=2)}") + # Initialize logger logger = setup_logging() -logger \ No newline at end of file +logger diff --git a/src/codegate/providers/copilot/mapping.py b/src/codegate/providers/copilot/mapping.py index 9bda5139..61802a59 100644 --- a/src/codegate/providers/copilot/mapping.py +++ b/src/codegate/providers/copilot/mapping.py @@ -1,12 +1,16 @@ -from typing import List, Dict +from typing import List + from pydantic import BaseModel, HttpUrl from pydantic_settings import BaseSettings + class CopilotProxyRoute(BaseModel): """Pydantic model for proxy route validation""" + path: str target: HttpUrl + class CoPilotMappings(BaseSettings): # Proxy routes configuration @@ -31,12 +35,11 @@ class CoPilotMappings(BaseSettings): ("/v1", "https://copilot-proxy.githubusercontent.com/v1"), ] + # Create settings instance mappings = CoPilotMappings() # Convert routes to validated ProxyRoute objects VALIDATED_ROUTES: List[CopilotProxyRoute] = [ - CopilotProxyRoute(path=path, target=target) - for path, target in mappings.PROXY_ROUTES + CopilotProxyRoute(path=path, target=target) for path, target in mappings.PROXY_ROUTES ] - diff --git a/src/codegate/providers/copilot/provider.py b/src/codegate/providers/copilot/provider.py index ebae6947..a6b26f8c 100644 --- a/src/codegate/providers/copilot/provider.py +++ b/src/codegate/providers/copilot/provider.py @@ -1,13 +1,13 @@ import asyncio import re import ssl -from typing import Dict, Optional, Tuple, Union +from typing import Dict, Optional, Tuple from urllib.parse import unquote, urljoin, urlparse import structlog -from codegate.config import Config from codegate.ca.codegate_ca import CertificateAuthority +from codegate.config import Config from codegate.providers.copilot.mapping import VALIDATED_ROUTES logger = structlog.get_logger("codegate") @@ -16,6 +16,7 @@ MAX_BUFFER_SIZE = 10 * 1024 * 1024 # 10MB CHUNK_SIZE = 64 * 1024 # 64KB + class CopilotProvider(asyncio.Protocol): def __init__(self, loop): logger.debug("Initializing CopilotProvider class: CopilotProvider") @@ -45,19 +46,19 @@ def __init__(self, loop): def connection_made(self, transport: asyncio.Transport): logger.debug("Client connected fn: connection_made") self.transport = transport - self.peername = transport.get_extra_info('peername') + self.peername = transport.get_extra_info("peername") logger.debug(f"Client connected from {self.peername}") def extract_path(self, full_path: str) -> str: logger.debug(f"Extracting path from {full_path} fn: extract_path") - if full_path.startswith('http://') or full_path.startswith('https://'): + if full_path.startswith("http://") or full_path.startswith("https://"): parsed = urlparse(full_path) path = parsed.path if parsed.query: path = f"{path}?{parsed.query}" - return path.lstrip('/') - elif full_path.startswith('/'): - return full_path.lstrip('/') + return path.lstrip("/") + elif full_path.startswith("/"): + return full_path.lstrip("/") return full_path def get_headers(self) -> Dict[str, str]: @@ -66,15 +67,15 @@ def get_headers(self) -> Dict[str, str]: headers_dict = {} try: - if b'\r\n\r\n' not in self.buffer: + if b"\r\n\r\n" not in self.buffer: return {} - headers_end = self.buffer.index(b'\r\n\r\n') - headers = self.buffer[:headers_end].split(b'\r\n')[1:] # Skip request line + headers_end = self.buffer.index(b"\r\n\r\n") + headers = self.buffer[:headers_end].split(b"\r\n")[1:] # Skip request line for header in headers: try: - name, value = header.decode('utf-8').split(':', 1) + name, value = header.decode("utf-8").split(":", 1) headers_dict[name.strip().lower()] = value.strip() except ValueError: continue @@ -87,18 +88,18 @@ def get_headers(self) -> Dict[str, str]: def parse_headers(self) -> bool: logger.debug("Parsing headers fn: parse_headers") try: - if b'\r\n\r\n' not in self.buffer: + if b"\r\n\r\n" not in self.buffer: return False - headers_end = self.buffer.index(b'\r\n\r\n') - headers = self.buffer[:headers_end].split(b'\r\n') + headers_end = self.buffer.index(b"\r\n\r\n") + headers = self.buffer[:headers_end].split(b"\r\n") - request = headers[0].decode('utf-8') - self.method, full_path, self.version = request.split(' ') + request = headers[0].decode("utf-8") + self.method, full_path, self.version = request.split(" ") self.original_path = full_path - if self.method == 'CONNECT': + if self.method == "CONNECT": logger.debug(f"CONNECT request to {full_path}") self.target = full_path self.path = "" @@ -106,16 +107,16 @@ def parse_headers(self) -> bool: logger.debug(f"Request: {self.method} {full_path} {self.version}") self.path = self.extract_path(full_path) - self.headers = [header.decode('utf-8') for header in headers[1:]] + self.headers = [header.decode("utf-8") for header in headers[1:]] return True except Exception as e: logger.error(f"Error parsing headers: {e}") return False def log_decrypted_data(self, data: bytes, direction: str): - ''' + """ Uncomment to log data from payload - ''' + """ try: # decoded = data.decode('utf-8') # logger.debug(f"=== Decrypted {direction} Data ===") @@ -137,7 +138,7 @@ async def handle_http_request(self): try: # Extract proxy endpoint from authorization header if present headers_dict = self.get_headers() - auth_header = headers_dict.get('authorization', '') + auth_header = headers_dict.get("authorization", "") if auth_header: match = re.search(r"proxy-ep=([^;]+)", auth_header) if match: @@ -166,7 +167,7 @@ async def handle_http_request(self): logger.debug(f"Parsed URL {parsed_url}") self.target_host = parsed_url.hostname - self.target_port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80) + self.target_port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80) target_protocol = CopilotProxyTargetProtocol(self) logger.debug(f"Connecting to {self.target_host}:{self.target_port}") @@ -174,14 +175,14 @@ async def handle_http_request(self): lambda: target_protocol, self.target_host, self.target_port, - ssl=parsed_url.scheme == 'https' + ssl=parsed_url.scheme == "https", ) has_host = False new_headers = [] for header in self.headers: - if header.lower().startswith('host:'): + if header.lower().startswith("host:"): has_host = True new_headers.append(f"Host: {self.target_host}") else: @@ -192,8 +193,8 @@ async def handle_http_request(self): request_line = f"{self.method} /{self.path} {self.version}\r\n".encode() logger.debug(f"Request Line: {request_line}") - header_block = '\r\n'.join(new_headers).encode() - headers = request_line + header_block + b'\r\n\r\n' + header_block = "\r\n".join(new_headers).encode() + headers = request_line + header_block + b"\r\n\r\n" if self.target_transport: logger.debug("=" * 40) @@ -201,14 +202,14 @@ async def handle_http_request(self): self.target_transport.write(headers) logger.debug("=" * 40) - body_start = self.buffer.index(b'\r\n\r\n') + 4 + body_start = self.buffer.index(b"\r\n\r\n") + 4 body = self.buffer[body_start:] if body: self.log_decrypted_data(body, "Request Body") for i in range(0, len(body), CHUNK_SIZE): - chunk = body[i:i + CHUNK_SIZE] + chunk = body[i: i + CHUNK_SIZE] self.target_transport.write(chunk) else: logger.debug("=" * 40) @@ -226,7 +227,7 @@ def _check_buffer_size(self, new_data: bytes) -> bool: def _handle_parsed_headers(self) -> None: """Handle the request based on parsed headers""" - if self.method == 'CONNECT': + if self.method == "CONNECT": logger.debug("Handling CONNECT request") self.handle_connect() else: @@ -273,18 +274,18 @@ def data_received(self, data: bytes) -> None: self.send_error_response(502, str(e).encode()) def handle_connect(self): - ''' + """ This where requests are sent directly via the tunnel created during a CONNECT request. This is where the SSL context is created and the internal connection is made to the target host. We do not need to do a URL to mapping, as this passes through the tunnel with a FQDN already set by the source (client) request. - ''' + """ try: path = unquote(self.target) - if ':' in path: - self.target_host, port = path.split(':') + if ":" in path: + self.target_host, port = path.split(":") self.target_port = int(port) logger.debug("=" * 40) logger.debug(f"CONNECT request to {self.target_host}:{self.target_port}") @@ -332,12 +333,14 @@ def get_status_text(self, status: int) -> str: 400: "Bad Request", 404: "Not Found", 413: "Request Entity Too Large", - 502: "Bad Gateway" + 502: "Bad Gateway", } return status_texts.get(status, "Error") async def connect_to_target(self): - logger.debug(f"Connecting to target {self.target_host}:{self.target_port} fn: connect_to_target") + logger.debug( + f"Connecting to target {self.target_host}:{self.target_port} fn: connect_to_target" + ) try: if not self.target_host or not self.target_port: raise ValueError("Target host and port not set") @@ -355,10 +358,7 @@ async def connect_to_target(self): logger.debug(f"Connecting to {self.target_host}:{self.target_port}") target_protocol = CopilotProxyTargetProtocol(self) transport, _ = await self.loop.create_connection( - lambda: target_protocol, - self.target_host, - self.target_port, - ssl=target_ssl_context + lambda: target_protocol, self.target_host, self.target_port, ssl=target_ssl_context ) logger.debug(f"Successfully connected to {self.target_host}:{self.target_port}") @@ -367,18 +367,15 @@ async def connect_to_target(self): if self.transport and not self.transport.is_closing(): logger.debug("Sending 200 Connection Established response") self.transport.write( - b'HTTP/1.1 200 Connection Established\r\n' - b'Proxy-Agent: ProxyPilot\r\n' - b'Connection: keep-alive\r\n\r\n' + b"HTTP/1.1 200 Connection Established\r\n" + b"Proxy-Agent: ProxyPilot\r\n" + b"Connection: keep-alive\r\n\r\n" ) # Upgrade client connection to SSL logger.debug("Upgrading client connection to SSL") transport = await self.loop.start_tls( - self.transport, - self, - self.ssl_context, - server_side=True + self.transport, self, self.ssl_context, server_side=True ) self.transport = transport @@ -393,7 +390,9 @@ def connection_lost(self, exc): self.target_transport.close() @classmethod - async def create_proxy_server(cls, host: str, port: int, ssl_context: Optional[ssl.SSLContext] = None): + async def create_proxy_server( + cls, host: str, port: int, ssl_context: Optional[ssl.SSLContext] = None + ): logger.debug(f"Creating proxy server on {host}:{port} fn: create_proxy_server") loop = asyncio.get_event_loop() @@ -403,12 +402,7 @@ def create_protocol(): logger.debug(f"Starting proxy server on {host}:{port}") server = await loop.create_server( - create_protocol, - host, - port, - ssl=ssl_context, - reuse_port=True, - start_serving=True + create_protocol, host, port, ssl=ssl_context, reuse_port=True, start_serving=True ) logger.debug(f"Proxy server running on {host}:{port}") @@ -423,9 +417,7 @@ async def run_proxy_server(cls): logger.debug("Creating SSL context for proxy server") ssl_context = ca.create_ssl_context() server = await cls.create_proxy_server( - Config.get_config().host, - Config.get_config().proxy_port, - ssl_context + Config.get_config().host, Config.get_config().proxy_port, ssl_context ) logger.debug("Proxy server created") async with server: @@ -454,15 +446,19 @@ async def get_target_url(cls, path: str) -> Optional[str]: remaining_path = path[len(route.path):] logger.debug(f"Remaining path: {remaining_path}") # Make sure we don't end up with double slashes - if remaining_path and remaining_path.startswith('/'): + if remaining_path and remaining_path.startswith("/"): remaining_path = remaining_path[1:] target = urljoin(str(route.target), remaining_path) - logger.debug(f"Found prefix match: {path} -> {target} (using route {route.path} -> {route.target})") + logger.debug( + f"Found prefix match: {path} -> {target} " + "(using route {route.path} -> {route.target})" + ) return target logger.warning(f"No route found for path: {path}") return None + class CopilotProxyTargetProtocol(asyncio.Protocol): def __init__(self, proxy: CopilotProvider): logger.debug("Initializing CopilotProxyTargetProtocol class: CopilotProxyTargetProtocol") @@ -481,6 +477,8 @@ def data_received(self, data: bytes): self.proxy.transport.write(data) def connection_lost(self, exc): - logger.debug(f"Connection lost from target {self.proxy.target_host}:{self.proxy.target_port}") + logger.debug( + f"Connection lost from target {self.proxy.target_host}:{self.proxy.target_port}" + ) if self.proxy.transport and not self.proxy.transport.is_closing(): self.proxy.transport.close() diff --git a/tests/test_cli.py b/tests/test_cli.py index 69c39b2d..41a82f92 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,16 +1,16 @@ """Tests for the CLI module.""" import asyncio +import signal from pathlib import Path from typing import Any, AsyncGenerator -import signal -from unittest.mock import MagicMock, patch, AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from click.testing import CliRunner from uvicorn.config import Config as UvicornConfig -from codegate.cli import cli, UvicornServer +from codegate.cli import UvicornServer, cli from codegate.codegate_logging import LogFormat, LogLevel from codegate.config import DEFAULT_PROVIDER_URLS @@ -78,13 +78,13 @@ async def test_uvicorn_server_serve(mock_uvicorn_server: UvicornServer) -> None: """Test UvicornServer serve method.""" # Start server in background task server_task = asyncio.create_task(mock_uvicorn_server.serve()) - + # Wait for startup to complete await mock_uvicorn_server.wait_startup_complete() - + # Verify server started assert mock_uvicorn_server.server.serve.called - + # Cleanup await mock_uvicorn_server.cleanup() await server_task @@ -96,14 +96,14 @@ async def test_uvicorn_server_cleanup(mock_uvicorn_server: UvicornServer) -> Non # Start server server_task = asyncio.create_task(mock_uvicorn_server.serve()) await mock_uvicorn_server.wait_startup_complete() - + # Trigger cleanup await mock_uvicorn_server.cleanup() - + # Verify shutdown was called assert mock_uvicorn_server.server.shutdown.called assert mock_uvicorn_server._shutdown_event.is_set() - + await server_task @@ -111,26 +111,24 @@ async def test_uvicorn_server_cleanup(mock_uvicorn_server: UvicornServer) -> Non async def test_uvicorn_server_signal_handling(mock_uvicorn_server: UvicornServer) -> None: """Test signal handling in UvicornServer.""" # Mock signal handlers - with patch('asyncio.get_running_loop') as mock_loop: + with patch("asyncio.get_running_loop") as mock_loop: mock_loop_instance = MagicMock() mock_loop.return_value = mock_loop_instance - + # Start server server_task = asyncio.create_task(mock_uvicorn_server.serve()) await mock_uvicorn_server.wait_startup_complete() - + # Simulate SIGTERM mock_loop_instance.add_signal_handler.assert_any_call( - signal.SIGTERM, - pytest.approx(type(lambda: None)) # Check if a callable was passed + signal.SIGTERM, pytest.approx(type(lambda: None)) # Check if a callable was passed ) - + # Simulate SIGINT mock_loop_instance.add_signal_handler.assert_any_call( - signal.SIGINT, - pytest.approx(type(lambda: None)) # Check if a callable was passed + signal.SIGINT, pytest.approx(type(lambda: None)) # Check if a callable was passed ) - + await mock_uvicorn_server.cleanup() await server_task diff --git a/tests/test_config.py b/tests/test_config.py index 1b9bf4b9..290a96c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -155,13 +155,15 @@ def test_certificate_config_from_file(tmp_path: Path) -> None: def test_certificate_config_from_env() -> None: """Test loading certificate configuration from environment variables.""" - os.environ.update({ - "CODEGATE_CERTS_DIR": "./env-certs", - "CODEGATE_CA_CERT": "env-ca.crt", - "CODEGATE_CA_KEY": "env-ca.key", - "CODEGATE_SERVER_CERT": "env-server.crt", - "CODEGATE_SERVER_KEY": "env-server.key", - }) + os.environ.update( + { + "CODEGATE_CERTS_DIR": "./env-certs", + "CODEGATE_CA_CERT": "env-ca.crt", + "CODEGATE_CA_KEY": "env-ca.key", + "CODEGATE_SERVER_CERT": "env-server.crt", + "CODEGATE_SERVER_KEY": "env-server.key", + } + ) try: config = Config.from_env() assert config.certs_dir == "./env-certs" diff --git a/tests/test_server.py b/tests/test_server.py index 0e4db9f8..d4e75818 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,13 +3,13 @@ from unittest.mock import MagicMock, patch import pytest -from fastapi.testclient import TestClient from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient from codegate import __version__ -from codegate.server import init_app -from codegate.providers.registry import ProviderRegistry from codegate.pipeline.secrets.manager import SecretsManager +from codegate.providers.registry import ProviderRegistry +from codegate.server import init_app @pytest.fixture @@ -42,9 +42,7 @@ def test_app_initialization() -> None: def test_cors_middleware() -> None: """Test that CORS middleware is properly configured.""" app = init_app() - cors_middleware = next( - m for m in app.middleware if isinstance(m, CORSMiddleware) - ) + cors_middleware = next(m for m in app.middleware if isinstance(m, CORSMiddleware)) assert cors_middleware.options.allow_origins == ["*"] assert cors_middleware.options.allow_credentials is True assert cors_middleware.options.allow_methods == ["*"] @@ -63,21 +61,21 @@ def test_health_check(test_client: TestClient) -> None: def test_provider_registration(mock_secrets_mgr, mock_registry) -> None: """Test that all providers are registered correctly.""" init_app() - + # Verify SecretsManager was initialized mock_secrets_mgr.assert_called_once() - + # Verify ProviderRegistry was initialized with the app mock_registry.assert_called_once() - + # Verify all providers were registered registry_instance = mock_registry.return_value - assert registry_instance.add_provider.call_count == 5 # openai, anthropic, llamacpp, vllm, ollama - + assert ( + registry_instance.add_provider.call_count == 5 + ) # openai, anthropic, llamacpp, vllm, ollama + # Verify specific providers were registered - provider_names = [ - call.args[0] for call in registry_instance.add_provider.call_args_list - ] + provider_names = [call.args[0] for call in registry_instance.add_provider.call_args_list] assert "openai" in provider_names assert "anthropic" in provider_names assert "llamacpp" in provider_names @@ -95,13 +93,10 @@ def test_signatures_initialization(mock_signatures) -> None: def test_pipeline_initialization() -> None: """Test that pipelines are initialized correctly.""" app = init_app() - + # Access the provider registry to check pipeline configuration - registry = next( - (route for route in app.routes if hasattr(route, "registry")), - None - ) - + registry = next((route for route in app.routes if hasattr(route, "registry")), None) + if registry: for provider in registry.registry.values(): # Verify each provider has the required pipelines @@ -114,7 +109,7 @@ def test_dashboard_routes() -> None: """Test that dashboard routes are included.""" app = init_app() routes = [route.path for route in app.routes] - + # Verify dashboard endpoints are included dashboard_routes = [route for route in routes if route.startswith("/dashboard")] assert len(dashboard_routes) > 0 @@ -124,7 +119,7 @@ def test_system_routes() -> None: """Test that system routes are included.""" app = init_app() routes = [route.path for route in app.routes] - + # Verify system endpoints are included assert "/health" in routes @@ -133,7 +128,7 @@ def test_system_routes() -> None: async def test_async_health_check() -> None: """Test the health check endpoint with async client.""" app = init_app() - + async with TestClient(app) as ac: response = await ac.get("/health") assert response.status_code == 200 @@ -144,7 +139,7 @@ def test_error_handling(test_client: TestClient) -> None: """Test error handling for non-existent endpoints.""" response = test_client.get("/nonexistent") assert response.status_code == 404 - + # Test method not allowed response = test_client.post("/health") # Health endpoint only allows GET assert response.status_code == 405