From a4375274b29c4cc4a0ecfc4e88cdda97b3017783 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 4 Mar 2026 18:04:59 -0500 Subject: [PATCH] Add Settings subclasses to all services and auto-discovered init tests - Add dedicated Settings subclasses to 20 LLM services that were borrowing parent Settings classes (e.g. AzureLLMSettings, GroqLLMSettings) so users don't need cross-module imports - Fix field defaults to NOT_GIVEN in BaseWhisperSTTSettings, OpenAIRealtimeSTTSettings, and NvidiaSegmentedSTTSettings for delta-mode safety - Fix incomplete default_settings in AWS, Cartesia, ElevenLabs, Fish, and Whisper services so validate_complete() passes - Add auto-discovered tests that verify all Settings classes default to NOT_GIVEN (delta safety) and all services initialize with complete settings (store completeness) --- src/pipecat/services/aws/stt.py | 1 + src/pipecat/services/azure/llm.py | 14 +- src/pipecat/services/azure/realtime/llm.py | 11 +- src/pipecat/services/cartesia/tts.py | 2 + src/pipecat/services/cerebras/llm.py | 14 +- src/pipecat/services/deepseek/llm.py | 14 +- src/pipecat/services/elevenlabs/tts.py | 17 ++ src/pipecat/services/fireworks/llm.py | 14 +- src/pipecat/services/fish/tts.py | 1 + .../services/google/gemini_live/llm_vertex.py | 16 +- src/pipecat/services/google/llm_openai.py | 14 +- src/pipecat/services/google/llm_vertex.py | 16 +- src/pipecat/services/grok/llm.py | 13 +- src/pipecat/services/groq/llm.py | 14 +- src/pipecat/services/mistral/llm.py | 14 +- src/pipecat/services/nvidia/llm.py | 14 +- src/pipecat/services/nvidia/stt.py | 14 +- src/pipecat/services/ollama/llm.py | 14 +- src/pipecat/services/openai/stt.py | 6 +- .../services/openai_realtime_beta/azure.py | 10 +- src/pipecat/services/openpipe/llm.py | 14 +- src/pipecat/services/openrouter/llm.py | 14 +- src/pipecat/services/perplexity/llm.py | 14 +- src/pipecat/services/qwen/llm.py | 14 +- src/pipecat/services/sambanova/llm.py | 14 +- src/pipecat/services/together/llm.py | 14 +- src/pipecat/services/whisper/base_stt.py | 10 +- tests/test_service_init.py | 181 ++++++++++++++++++ 28 files changed, 436 insertions(+), 72 deletions(-) create mode 100644 tests/test_service_init.py diff --git a/src/pipecat/services/aws/stt.py b/src/pipecat/services/aws/stt.py index dd2f2f97b..f46f5259c 100644 --- a/src/pipecat/services/aws/stt.py +++ b/src/pipecat/services/aws/stt.py @@ -98,6 +98,7 @@ class AWSTranscribeSTTService(WebsocketSTTService): """ # 1. Initialize default_settings with hardcoded defaults default_settings = AWSTranscribeSTTSettings( + model=None, language=self.language_to_service_language(Language.EN) or "en-US", ) diff --git a/src/pipecat/services/azure/llm.py b/src/pipecat/services/azure/llm.py index 19a31320e..5f1ce2698 100644 --- a/src/pipecat/services/azure/llm.py +++ b/src/pipecat/services/azure/llm.py @@ -6,6 +6,7 @@ """Azure OpenAI service implementation for the Pipecat AI framework.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -16,6 +17,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class AzureLLMSettings(OpenAILLMSettings): + """Settings for Azure OpenAI LLM service.""" + + pass + + class AzureLLMService(OpenAILLMService): """A service for interacting with Azure OpenAI using the OpenAI-compatible interface. @@ -30,7 +38,7 @@ class AzureLLMService(OpenAILLMService): endpoint: str, model: Optional[str] = None, api_version: str = "2024-09-01-preview", - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[AzureLLMSettings] = None, **kwargs, ): """Initialize the Azure LLM service. @@ -49,11 +57,11 @@ class AzureLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="gpt-4o") + default_settings = AzureLLMSettings(model="gpt-4o") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", AzureLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/azure/realtime/llm.py b/src/pipecat/services/azure/realtime/llm.py index 39c9bd707..fa645d8c1 100644 --- a/src/pipecat/services/azure/realtime/llm.py +++ b/src/pipecat/services/azure/realtime/llm.py @@ -6,9 +6,11 @@ """Azure OpenAI Realtime LLM service implementation.""" +from dataclasses import dataclass + from loguru import logger -from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService +from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService, OpenAIRealtimeLLMSettings try: from websockets.asyncio.client import connect as websocket_connect @@ -18,6 +20,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class AzureRealtimeLLMSettings(OpenAIRealtimeLLMSettings): + """Settings for Azure Realtime LLM service.""" + + pass + + class AzureRealtimeLLMService(OpenAIRealtimeLLMService): """Azure OpenAI Realtime LLM service with Azure-specific authentication. diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 71017074b..166aa70af 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -301,6 +301,7 @@ class CartesiaTTSService(AudioContextTTSService): # 1. Initialize default_settings with hardcoded defaults default_settings = CartesiaTTSSettings( model="sonic-3", + voice=None, language=language_to_cartesia_language(Language.EN), generation_config=None, pronunciation_dict_id=None, @@ -745,6 +746,7 @@ class CartesiaHttpTTSService(TTSService): # 1. Initialize default_settings with hardcoded defaults default_settings = CartesiaTTSSettings( model="sonic-3", + voice=None, language=language_to_cartesia_language(Language.EN), generation_config=None, pronunciation_dict_id=None, diff --git a/src/pipecat/services/cerebras/llm.py b/src/pipecat/services/cerebras/llm.py index c952b9552..40862f252 100644 --- a/src/pipecat/services/cerebras/llm.py +++ b/src/pipecat/services/cerebras/llm.py @@ -6,6 +6,7 @@ """Cerebras LLM service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -16,6 +17,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class CerebrasLLMSettings(OpenAILLMSettings): + """Settings for Cerebras LLM service.""" + + pass + + class CerebrasLLMService(OpenAILLMService): """A service for interacting with Cerebras's API using the OpenAI-compatible interface. @@ -29,7 +37,7 @@ class CerebrasLLMService(OpenAILLMService): api_key: str, base_url: str = "https://api.cerebras.ai/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[CerebrasLLMSettings] = None, **kwargs, ): """Initialize the Cerebras LLM service. @@ -47,11 +55,11 @@ class CerebrasLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="gpt-oss-120b") + default_settings = CerebrasLLMSettings(model="gpt-oss-120b") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", CerebrasLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/deepseek/llm.py b/src/pipecat/services/deepseek/llm.py index 3d7c67e2a..d778639ef 100644 --- a/src/pipecat/services/deepseek/llm.py +++ b/src/pipecat/services/deepseek/llm.py @@ -6,6 +6,7 @@ """DeepSeek LLM service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -16,6 +17,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class DeepSeekLLMSettings(OpenAILLMSettings): + """Settings for DeepSeek LLM service.""" + + pass + + class DeepSeekLLMService(OpenAILLMService): """A service for interacting with DeepSeek's API using the OpenAI-compatible interface. @@ -29,7 +37,7 @@ class DeepSeekLLMService(OpenAILLMService): api_key: str, base_url: str = "https://api.deepseek.com/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[DeepSeekLLMSettings] = None, **kwargs, ): """Initialize the DeepSeek LLM service. @@ -47,11 +55,11 @@ class DeepSeekLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="deepseek-chat") + default_settings = DeepSeekLLMSettings(model="deepseek-chat") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", DeepSeekLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index c22455e7a..cfb08eb6d 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -418,6 +418,14 @@ class ElevenLabsTTSService(AudioContextTTSService): # 1. Initialize default_settings with hardcoded defaults default_settings = ElevenLabsTTSSettings( model="eleven_turbo_v2_5", + voice=None, + language=None, + stability=None, + similarity_boost=None, + style=None, + use_speaker_boost=None, + speed=None, + apply_text_normalization=None, ) # Track init-only URL params through the override chain @@ -986,6 +994,15 @@ class ElevenLabsHttpTTSService(TTSService): # 1. Initialize default_settings with hardcoded defaults default_settings = ElevenLabsHttpTTSSettings( model="eleven_turbo_v2_5", + voice=None, + language=None, + optimize_streaming_latency=None, + stability=None, + similarity_boost=None, + style=None, + use_speaker_boost=None, + speed=None, + apply_text_normalization=None, ) # 2. Apply direct init arg overrides (deprecated) diff --git a/src/pipecat/services/fireworks/llm.py b/src/pipecat/services/fireworks/llm.py index e7866c55d..7ba3f9eac 100644 --- a/src/pipecat/services/fireworks/llm.py +++ b/src/pipecat/services/fireworks/llm.py @@ -6,6 +6,7 @@ """Fireworks AI service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -16,6 +17,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class FireworksLLMSettings(OpenAILLMSettings): + """Settings for Fireworks LLM service.""" + + pass + + class FireworksLLMService(OpenAILLMService): """A service for interacting with Fireworks AI using the OpenAI-compatible interface. @@ -29,7 +37,7 @@ class FireworksLLMService(OpenAILLMService): api_key: str, model: Optional[str] = None, base_url: str = "https://api.fireworks.ai/inference/v1", - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[FireworksLLMSettings] = None, **kwargs, ): """Initialize the Fireworks LLM service. @@ -47,11 +55,11 @@ class FireworksLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="accounts/fireworks/models/firefunction-v2") + default_settings = FireworksLLMSettings(model="accounts/fireworks/models/firefunction-v2") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", FireworksLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/fish/tts.py b/src/pipecat/services/fish/tts.py index 78b021c51..9ea749546 100644 --- a/src/pipecat/services/fish/tts.py +++ b/src/pipecat/services/fish/tts.py @@ -173,6 +173,7 @@ class FishAudioTTSService(InterruptibleTTSService): default_settings = FishAudioTTSSettings( model="s1", voice=None, + language=None, latency="normal", normalize=True, prosody_speed=1.0, diff --git a/src/pipecat/services/google/gemini_live/llm_vertex.py b/src/pipecat/services/google/gemini_live/llm_vertex.py index 5d63251d9..43d3ab207 100644 --- a/src/pipecat/services/google/gemini_live/llm_vertex.py +++ b/src/pipecat/services/google/gemini_live/llm_vertex.py @@ -12,6 +12,7 @@ streaming responses, and tool usage. """ import json +from dataclasses import dataclass from typing import List, Optional, Union from loguru import logger @@ -41,6 +42,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class GeminiLiveVertexLLMSettings(GeminiLiveLLMSettings): + """Settings for Gemini Live Vertex LLM service.""" + + pass + + class GeminiLiveVertexLLMService(GeminiLiveLLMService): """Provides access to Google's Gemini Live model via Vertex AI. @@ -63,7 +71,7 @@ class GeminiLiveVertexLLMService(GeminiLiveLLMService): system_instruction: Optional[str] = None, tools: Optional[Union[List[dict], ToolsSchema]] = None, params: Optional[InputParams] = None, - settings: Optional[GeminiLiveLLMSettings] = None, + settings: Optional[GeminiLiveVertexLLMSettings] = None, inference_on_context_initialization: bool = True, file_api_base_url: str = "https://generativelanguage.googleapis.com/v1beta/files", http_options: Optional[HttpOptions] = None, @@ -122,7 +130,7 @@ class GeminiLiveVertexLLMService(GeminiLiveLLMService): # double deprecation warnings from the parent. # 1. Initialize default_settings with hardcoded defaults - default_settings = GeminiLiveLLMSettings( + default_settings = GeminiLiveVertexLLMSettings( model="google/gemini-live-2.5-flash-native-audio", frequency_penalty=None, max_tokens=4096, @@ -146,12 +154,12 @@ class GeminiLiveVertexLLMService(GeminiLiveLLMService): # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", GeminiLiveLLMSettings, "model") + _warn_deprecated_param("model", GeminiLiveVertexLLMSettings, "model") default_settings.model = model # 3. Apply params overrides — only if settings not provided if params is not None: - _warn_deprecated_param("params", GeminiLiveLLMSettings) + _warn_deprecated_param("params", GeminiLiveVertexLLMSettings) if not settings: default_settings.frequency_penalty = params.frequency_penalty default_settings.max_tokens = params.max_tokens diff --git a/src/pipecat/services/google/llm_openai.py b/src/pipecat/services/google/llm_openai.py index b33eefd9a..1d7ae6bc4 100644 --- a/src/pipecat/services/google/llm_openai.py +++ b/src/pipecat/services/google/llm_openai.py @@ -12,6 +12,7 @@ API format through Google's Gemini API OpenAI compatibility layer. import json import os +from dataclasses import dataclass from typing import Optional from openai import AsyncStream @@ -32,6 +33,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class GoogleOpenAILLMSettings(OpenAILLMSettings): + """Settings for Google OpenAI-compatible LLM service.""" + + pass + + class GoogleLLMOpenAIBetaService(OpenAILLMService): """Google LLM service using OpenAI-compatible API format. @@ -56,7 +64,7 @@ class GoogleLLMOpenAIBetaService(OpenAILLMService): api_key: str, base_url: str = "https://generativelanguage.googleapis.com/v1beta/openai/", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[GoogleOpenAILLMSettings] = None, **kwargs, ): """Initialize the Google LLM service. @@ -85,11 +93,11 @@ class GoogleLLMOpenAIBetaService(OpenAILLMService): ) # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="gemini-2.0-flash") + default_settings = GoogleOpenAILLMSettings(model="gemini-2.0-flash") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", GoogleOpenAILLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/google/llm_vertex.py b/src/pipecat/services/google/llm_vertex.py index b1a9de584..e21341614 100644 --- a/src/pipecat/services/google/llm_vertex.py +++ b/src/pipecat/services/google/llm_vertex.py @@ -12,6 +12,7 @@ extending the GoogleLLMService with Vertex AI authentication. import json import os +from dataclasses import dataclass # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" @@ -39,6 +40,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class GoogleVertexLLMSettings(GoogleLLMSettings): + """Settings for Google Vertex LLM service.""" + + pass + + class GoogleVertexLLMService(GoogleLLMService): """Google Vertex AI LLM service extending GoogleLLMService. @@ -104,7 +112,7 @@ class GoogleVertexLLMService(GoogleLLMService): location: Optional[str] = None, project_id: Optional[str] = None, params: Optional[GoogleLLMService.InputParams] = None, - settings: Optional[GoogleLLMSettings] = None, + settings: Optional[GoogleVertexLLMSettings] = None, system_instruction: Optional[str] = None, tools: Optional[list] = None, tool_config: Optional[dict] = None, @@ -183,7 +191,7 @@ class GoogleVertexLLMService(GoogleLLMService): self._location = location # 1. Initialize default_settings with hardcoded defaults - default_settings = GoogleLLMSettings( + default_settings = GoogleVertexLLMSettings( model="gemini-2.5-flash", max_tokens=4096, temperature=None, @@ -200,12 +208,12 @@ class GoogleVertexLLMService(GoogleLLMService): # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", GoogleLLMSettings, "model") + _warn_deprecated_param("model", GoogleVertexLLMSettings, "model") default_settings.model = model # 3. Apply params overrides — only if settings not provided if params is not None: - _warn_deprecated_param("params", GoogleLLMSettings) + _warn_deprecated_param("params", GoogleVertexLLMSettings) if not settings: default_settings.max_tokens = params.max_tokens default_settings.temperature = params.temperature diff --git a/src/pipecat/services/grok/llm.py b/src/pipecat/services/grok/llm.py index 98c925ac5..7f9ac643b 100644 --- a/src/pipecat/services/grok/llm.py +++ b/src/pipecat/services/grok/llm.py @@ -70,6 +70,13 @@ class GrokContextAggregatorPair: return self._assistant +@dataclass +class GrokLLMSettings(OpenAILLMSettings): + """Settings for Grok LLM service.""" + + pass + + class GrokLLMService(OpenAILLMService): """A service for interacting with Grok's API using the OpenAI-compatible interface. @@ -85,7 +92,7 @@ class GrokLLMService(OpenAILLMService): api_key: str, base_url: str = "https://api.x.ai/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[GrokLLMSettings] = None, **kwargs, ): """Initialize the GrokLLMService with API key and model. @@ -103,11 +110,11 @@ class GrokLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="grok-3-beta") + default_settings = GrokLLMSettings(model="grok-3-beta") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", GrokLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/groq/llm.py b/src/pipecat/services/groq/llm.py index c69776ad0..5b81a4c7d 100644 --- a/src/pipecat/services/groq/llm.py +++ b/src/pipecat/services/groq/llm.py @@ -6,6 +6,7 @@ """Groq LLM Service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -15,6 +16,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class GroqLLMSettings(OpenAILLMSettings): + """Settings for Groq LLM service.""" + + pass + + class GroqLLMService(OpenAILLMService): """A service for interacting with Groq's API using the OpenAI-compatible interface. @@ -28,7 +36,7 @@ class GroqLLMService(OpenAILLMService): api_key: str, base_url: str = "https://api.groq.com/openai/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[GroqLLMSettings] = None, **kwargs, ): """Initialize Groq LLM service. @@ -46,11 +54,11 @@ class GroqLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="llama-3.3-70b-versatile") + default_settings = GroqLLMSettings(model="llama-3.3-70b-versatile") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", GroqLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/mistral/llm.py b/src/pipecat/services/mistral/llm.py index 57ab45d6f..c4deab4c3 100644 --- a/src/pipecat/services/mistral/llm.py +++ b/src/pipecat/services/mistral/llm.py @@ -6,6 +6,7 @@ """Mistral LLM service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass from typing import List, Optional, Sequence from loguru import logger @@ -18,6 +19,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class MistralLLMSettings(OpenAILLMSettings): + """Settings for Mistral LLM service.""" + + pass + + class MistralLLMService(OpenAILLMService): """A service for interacting with Mistral's API using the OpenAI-compatible interface. @@ -31,7 +39,7 @@ class MistralLLMService(OpenAILLMService): api_key: str, base_url: str = "https://api.mistral.ai/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[MistralLLMSettings] = None, **kwargs, ): """Initialize the Mistral LLM service. @@ -49,11 +57,11 @@ class MistralLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="mistral-small-latest") + default_settings = MistralLLMSettings(model="mistral-small-latest") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", MistralLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/nvidia/llm.py b/src/pipecat/services/nvidia/llm.py index 88245464e..4ebfac0d8 100644 --- a/src/pipecat/services/nvidia/llm.py +++ b/src/pipecat/services/nvidia/llm.py @@ -10,6 +10,7 @@ This module provides a service for interacting with NVIDIA's NIM (NVIDIA Inferen Microservice) API while maintaining compatibility with the OpenAI-style interface. """ +from dataclasses import dataclass from typing import Optional from pipecat.metrics.metrics import LLMTokenUsage @@ -20,6 +21,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class NvidiaLLMSettings(OpenAILLMSettings): + """Settings for NVIDIA LLM service.""" + + pass + + class NvidiaLLMService(OpenAILLMService): """A service for interacting with NVIDIA's NIM (NVIDIA Inference Microservice) API. @@ -34,7 +42,7 @@ class NvidiaLLMService(OpenAILLMService): api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[NvidiaLLMSettings] = None, **kwargs, ): """Initialize the NvidiaLLMService. @@ -53,11 +61,11 @@ class NvidiaLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="nvidia/llama-3.1-nemotron-70b-instruct") + default_settings = NvidiaLLMSettings(model="nvidia/llama-3.1-nemotron-70b-instruct") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", NvidiaLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/nvidia/stt.py b/src/pipecat/services/nvidia/stt.py index 9b9b2bcf9..6823965a9 100644 --- a/src/pipecat/services/nvidia/stt.py +++ b/src/pipecat/services/nvidia/stt.py @@ -8,7 +8,7 @@ import asyncio from concurrent.futures import CancelledError as FuturesCancelledError -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, AsyncGenerator, List, Mapping, Optional from loguru import logger @@ -23,7 +23,7 @@ from pipecat.frames.frames import ( StartFrame, TranscriptionFrame, ) -from pipecat.services.settings import STTSettings, _warn_deprecated_param +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven, _warn_deprecated_param from pipecat.services.stt_latency import NVIDIA_TTFS_P99 from pipecat.services.stt_service import SegmentedSTTService, STTService from pipecat.transcriptions.language import Language, resolve_language @@ -110,11 +110,11 @@ class NvidiaSegmentedSTTSettings(STTSettings): boosted_lm_score: Score boost for specified words. """ - profanity_filter: bool = False - automatic_punctuation: bool = True - verbatim_transcripts: bool = False - boosted_lm_words: Optional[List[str]] = None - boosted_lm_score: float = 4.0 + profanity_filter: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + automatic_punctuation: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + verbatim_transcripts: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + boosted_lm_words: List[str] | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + boosted_lm_score: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) class NvidiaSTTService(STTService): diff --git a/src/pipecat/services/ollama/llm.py b/src/pipecat/services/ollama/llm.py index 477a80b5f..cd0c68099 100644 --- a/src/pipecat/services/ollama/llm.py +++ b/src/pipecat/services/ollama/llm.py @@ -6,6 +6,7 @@ """OLLama LLM service implementation for Pipecat AI framework.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -15,6 +16,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class OllamaLLMSettings(OpenAILLMSettings): + """Settings for Ollama LLM service.""" + + pass + + class OLLamaLLMService(OpenAILLMService): """OLLama LLM service that provides local language model capabilities. @@ -27,7 +35,7 @@ class OLLamaLLMService(OpenAILLMService): *, model: Optional[str] = None, base_url: str = "http://localhost:11434/v1", - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[OllamaLLMSettings] = None, **kwargs, ): """Initialize OLLama LLM service. @@ -45,11 +53,11 @@ class OLLamaLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="llama2") + default_settings = OllamaLLMSettings(model="llama2") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", OllamaLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/openai/stt.py b/src/pipecat/services/openai/stt.py index fbd372523..0e1e4f594 100644 --- a/src/pipecat/services/openai/stt.py +++ b/src/pipecat/services/openai/stt.py @@ -16,7 +16,7 @@ Provides two STT services: import base64 import json -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, AsyncGenerator, Literal, Optional, Union from loguru import logger @@ -35,7 +35,7 @@ from pipecat.frames.frames import ( VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.settings import STTSettings, _NotGiven, _warn_deprecated_param +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven, _warn_deprecated_param from pipecat.services.stt_latency import OPENAI_REALTIME_TTFS_P99, OPENAI_TTFS_P99 from pipecat.services.stt_service import WebsocketSTTService from pipecat.services.whisper.base_stt import ( @@ -188,7 +188,7 @@ class OpenAIRealtimeSTTSettings(STTSettings): prompt: Optional prompt text to guide transcription style. """ - prompt: str | None | _NotGiven = None + prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) class OpenAIRealtimeSTTService(WebsocketSTTService): diff --git a/src/pipecat/services/openai_realtime_beta/azure.py b/src/pipecat/services/openai_realtime_beta/azure.py index 6370ac0f4..fb85b1e79 100644 --- a/src/pipecat/services/openai_realtime_beta/azure.py +++ b/src/pipecat/services/openai_realtime_beta/azure.py @@ -7,10 +7,11 @@ """Azure OpenAI Realtime Beta LLM service implementation.""" import warnings +from dataclasses import dataclass from loguru import logger -from .openai import OpenAIRealtimeBetaLLMService +from .openai import OpenAIRealtimeBetaLLMService, OpenAIRealtimeBetaLLMSettings try: from websockets.asyncio.client import connect as websocket_connect @@ -22,6 +23,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class AzureRealtimeBetaLLMSettings(OpenAIRealtimeBetaLLMSettings): + """Settings for Azure Realtime Beta LLM service.""" + + pass + + class AzureRealtimeBetaLLMService(OpenAIRealtimeBetaLLMService): """Azure OpenAI Realtime Beta LLM service with Azure-specific authentication. diff --git a/src/pipecat/services/openpipe/llm.py b/src/pipecat/services/openpipe/llm.py index 431de832d..b2e953e64 100644 --- a/src/pipecat/services/openpipe/llm.py +++ b/src/pipecat/services/openpipe/llm.py @@ -10,6 +10,7 @@ This module provides an OpenPipe-specific implementation of the OpenAI LLM servi enabling integration with OpenPipe's fine-tuning and monitoring capabilities. """ +from dataclasses import dataclass from typing import Dict, Optional from loguru import logger @@ -27,6 +28,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class OpenPipeLLMSettings(OpenAILLMSettings): + """Settings for OpenPipe LLM service.""" + + pass + + class OpenPipeLLMService(OpenAILLMService): """OpenPipe-powered Large Language Model service. @@ -44,7 +52,7 @@ class OpenPipeLLMService(OpenAILLMService): openpipe_api_key: Optional[str] = None, openpipe_base_url: str = "https://app.openpipe.ai/api/v1", tags: Optional[Dict[str, str]] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[OpenPipeLLMSettings] = None, **kwargs, ): """Initialize OpenPipe LLM service. @@ -65,11 +73,11 @@ class OpenPipeLLMService(OpenAILLMService): **kwargs: Additional arguments passed to parent OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="gpt-4.1") + default_settings = OpenPipeLLMSettings(model="gpt-4.1") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", OpenPipeLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/openrouter/llm.py b/src/pipecat/services/openrouter/llm.py index 8c42069d8..fa2f170ee 100644 --- a/src/pipecat/services/openrouter/llm.py +++ b/src/pipecat/services/openrouter/llm.py @@ -10,6 +10,7 @@ This module provides an OpenAI-compatible interface for interacting with OpenRou extending the base OpenAI LLM service functionality. """ +from dataclasses import dataclass from typing import Any, Dict, Optional from loguru import logger @@ -19,6 +20,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class OpenRouterLLMSettings(OpenAILLMSettings): + """Settings for OpenRouter LLM service.""" + + pass + + class OpenRouterLLMService(OpenAILLMService): """A service for interacting with OpenRouter's API using the OpenAI-compatible interface. @@ -32,7 +40,7 @@ class OpenRouterLLMService(OpenAILLMService): api_key: Optional[str] = None, model: Optional[str] = None, base_url: str = "https://openrouter.ai/api/v1", - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[OpenRouterLLMSettings] = None, **kwargs, ): """Initialize the OpenRouter LLM service. @@ -51,11 +59,11 @@ class OpenRouterLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="openai/gpt-4o-2024-11-20") + default_settings = OpenRouterLLMSettings(model="openai/gpt-4o-2024-11-20") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", OpenRouterLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/perplexity/llm.py b/src/pipecat/services/perplexity/llm.py index 8195dd50e..d90aea6a4 100644 --- a/src/pipecat/services/perplexity/llm.py +++ b/src/pipecat/services/perplexity/llm.py @@ -11,6 +11,7 @@ an OpenAI-compatible interface. It handles Perplexity's unique token usage reporting patterns while maintaining compatibility with the Pipecat framework. """ +from dataclasses import dataclass from typing import Optional from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams @@ -22,6 +23,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class PerplexityLLMSettings(OpenAILLMSettings): + """Settings for Perplexity LLM service.""" + + pass + + class PerplexityLLMService(OpenAILLMService): """A service for interacting with Perplexity's API. @@ -36,7 +44,7 @@ class PerplexityLLMService(OpenAILLMService): api_key: str, base_url: str = "https://api.perplexity.ai", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[PerplexityLLMSettings] = None, **kwargs, ): """Initialize the Perplexity LLM service. @@ -54,11 +62,11 @@ class PerplexityLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="sonar") + default_settings = PerplexityLLMSettings(model="sonar") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", PerplexityLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/qwen/llm.py b/src/pipecat/services/qwen/llm.py index b983e00cd..6d9abeefe 100644 --- a/src/pipecat/services/qwen/llm.py +++ b/src/pipecat/services/qwen/llm.py @@ -6,6 +6,7 @@ """Qwen LLM service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -15,6 +16,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class QwenLLMSettings(OpenAILLMSettings): + """Settings for Qwen LLM service.""" + + pass + + class QwenLLMService(OpenAILLMService): """A service for interacting with Alibaba Cloud's Qwen LLM API using the OpenAI-compatible interface. @@ -28,7 +36,7 @@ class QwenLLMService(OpenAILLMService): api_key: str, base_url: str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[QwenLLMSettings] = None, **kwargs, ): """Initialize the Qwen LLM service. @@ -46,11 +54,11 @@ class QwenLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="qwen-plus") + default_settings = QwenLLMSettings(model="qwen-plus") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", QwenLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index 2468dad13..f87b432cc 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -7,6 +7,7 @@ """SambaNova LLM service implementation using OpenAI-compatible interface.""" import json +from dataclasses import dataclass from typing import Any, Dict, Optional from loguru import logger @@ -27,6 +28,13 @@ from pipecat.services.settings import _warn_deprecated_param from pipecat.utils.tracing.service_decorators import traced_llm +@dataclass +class SambaNovaLLMSettings(OpenAILLMSettings): + """Settings for SambaNova LLM service.""" + + pass + + class SambaNovaLLMService(OpenAILLMService): # type: ignore """A service for interacting with SambaNova using the OpenAI-compatible interface. @@ -40,7 +48,7 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore api_key: str, model: Optional[str] = None, base_url: str = "https://api.sambanova.ai/v1", - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[SambaNovaLLMSettings] = None, **kwargs: Dict[Any, Any], ) -> None: """Initialize SambaNova LLM service. @@ -58,11 +66,11 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="Llama-4-Maverick-17B-128E-Instruct") + default_settings = SambaNovaLLMSettings(model="Llama-4-Maverick-17B-128E-Instruct") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", SambaNovaLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/together/llm.py b/src/pipecat/services/together/llm.py index 9ec8e9fc2..23e38f752 100644 --- a/src/pipecat/services/together/llm.py +++ b/src/pipecat/services/together/llm.py @@ -6,6 +6,7 @@ """Together.ai LLM service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass from typing import Optional from loguru import logger @@ -15,6 +16,13 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import _warn_deprecated_param +@dataclass +class TogetherLLMSettings(OpenAILLMSettings): + """Settings for Together LLM service.""" + + pass + + class TogetherLLMService(OpenAILLMService): """A service for interacting with Together.ai's API using the OpenAI-compatible interface. @@ -28,7 +36,7 @@ class TogetherLLMService(OpenAILLMService): api_key: str, base_url: str = "https://api.together.xyz/v1", model: Optional[str] = None, - settings: Optional[OpenAILLMSettings] = None, + settings: Optional[TogetherLLMSettings] = None, **kwargs, ): """Initialize Together.ai LLM service. @@ -46,11 +54,11 @@ class TogetherLLMService(OpenAILLMService): **kwargs: Additional keyword arguments passed to OpenAILLMService. """ # 1. Initialize default_settings with hardcoded defaults - default_settings = OpenAILLMSettings(model="meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo") + default_settings = TogetherLLMSettings(model="meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo") # 2. Apply direct init arg overrides (deprecated) if model is not None: - _warn_deprecated_param("model", OpenAILLMSettings, "model") + _warn_deprecated_param("model", TogetherLLMSettings, "model") default_settings.model = model # 4. Apply settings delta (canonical API, always wins) diff --git a/src/pipecat/services/whisper/base_stt.py b/src/pipecat/services/whisper/base_stt.py index f89d7f5d0..13de0f251 100644 --- a/src/pipecat/services/whisper/base_stt.py +++ b/src/pipecat/services/whisper/base_stt.py @@ -10,7 +10,7 @@ This module provides common functionality for services implementing the Whisper interface, including language mapping, metrics generation, and error handling. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import AsyncGenerator, Optional from loguru import logger @@ -18,7 +18,7 @@ from openai import AsyncOpenAI from openai.types.audio import Transcription from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame -from pipecat.services.settings import STTSettings, _NotGiven, _warn_deprecated_param +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven, _warn_deprecated_param from pipecat.services.stt_latency import WHISPER_TTFS_P99 from pipecat.services.stt_service import SegmentedSTTService from pipecat.transcriptions.language import Language, resolve_language @@ -36,8 +36,8 @@ class BaseWhisperSTTSettings(STTSettings): temperature: Sampling temperature between 0 and 1. """ - prompt: str | None | _NotGiven = None - temperature: float | None | _NotGiven = None + prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) def language_to_whisper_language(language: Language) -> Optional[str]: @@ -183,6 +183,8 @@ class BaseWhisperSTTService(SegmentedSTTService): default_settings = BaseWhisperSTTSettings( model=None, language=None, + prompt=None, + temperature=None, ) # --- 2. Deprecated direct-arg overrides --- diff --git a/tests/test_service_init.py b/tests/test_service_init.py new file mode 100644 index 000000000..73cb6a337 --- /dev/null +++ b/tests/test_service_init.py @@ -0,0 +1,181 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for service settings and initialization patterns. + +Settings objects operate in two modes: + +- **Store mode** (``self._settings``): the live state inside a service. + Every field must hold a real value (``None`` is fine, ``NOT_GIVEN`` is not). +- **Delta mode** (``FooSettings()`` with no args): a sparse update. + Every field must default to ``NOT_GIVEN`` so ``apply_update()`` skips + untouched fields and doesn't accidentally overwrite the store. + +These tests verify both sides of that contract automatically: + +1. **Delta defaults** — Instantiate every ``ServiceSettings`` subclass with + no arguments and assert that every field is ``NOT_GIVEN``. Catches the + bug where a field defaults to ``None`` instead of ``NOT_GIVEN``, which + would cause partial deltas to silently overwrite unrelated store values. + +2. **Store completeness** — Instantiate every concrete service with dummy + args and assert that ``_settings`` contains no ``NOT_GIVEN`` values. + This is the same check that ``validate_complete()`` runs in ``start()``, + but caught here at unit-test time without needing a running pipeline. + Catches services that forget to initialize a field in ``default_settings``. + +All Settings and Service classes are auto-discovered via ``pkgutil``; +new services are covered automatically with no per-service maintenance. +""" + +import importlib +import inspect +import pkgutil +import warnings +from dataclasses import fields + +import pytest + +import pipecat.services +from pipecat.services.ai_service import AIService +from pipecat.services.settings import ServiceSettings, is_given + +# Modules that define abstract base service classes (not concrete services). +_BASE_MODULES = frozenset( + { + "pipecat.services.ai_service", + "pipecat.services.llm_service", + "pipecat.services.stt_service", + "pipecat.services.tts_service", + "pipecat.services.image_gen_service", + "pipecat.services.vision_service", + } +) + + +# --------------------------------------------------------------------------- +# Auto-discovery +# --------------------------------------------------------------------------- + + +def _all_subclasses(cls): + result = set() + for sub in cls.__subclasses__(): + result.add(sub) + result.update(_all_subclasses(sub)) + return result + + +def _import_all_service_modules(): + """Import every module under pipecat.services (skipping missing deps).""" + package = pipecat.services + for _importer, modname, _ispkg in pkgutil.walk_packages( + package.__path__, prefix=package.__name__ + "." + ): + try: + importlib.import_module(modname) + except Exception: + continue + + +_import_all_service_modules() + +ALL_SETTINGS_CLASSES = sorted(_all_subclasses(ServiceSettings), key=lambda c: c.__qualname__) +assert ALL_SETTINGS_CLASSES, "No settings classes discovered" + + +# --------------------------------------------------------------------------- +# Service instantiation helpers +# --------------------------------------------------------------------------- + + +def _try_instantiate(cls): + """Try to instantiate a service with dummy values for required args. + + Inspects the __init__ signature and passes "test" for every required + keyword-only parameter. Services that need non-string required args + or fail for other reasons will raise and be skipped by the test. + """ + sig = inspect.signature(cls.__init__) + kwargs = {} + for name, param in sig.parameters.items(): + if name == "self": + continue + if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): + continue + if param.default is not param.empty: + continue + # Required parameter — pass a dummy string + kwargs[name] = "test" + return cls(**kwargs) + + +def _discover_service_classes(): + """Return concrete service classes that can be instantiated with dummy args.""" + result = [] + for cls in sorted(_all_subclasses(AIService), key=lambda c: c.__qualname__): + # Skip abstract base classes defined in framework modules. + if cls.__module__ in _BASE_MODULES: + continue + try: + svc = _try_instantiate(cls) + except Exception: + continue + if hasattr(svc, "_settings"): + result.append(cls) + return result + + +ALL_SERVICE_CLASSES = _discover_service_classes() +assert ALL_SERVICE_CLASSES, "No service classes could be instantiated" + + +# --------------------------------------------------------------------------- +# 1. Settings defaults: delta-mode safety +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("settings_cls", ALL_SETTINGS_CLASSES, ids=lambda c: c.__qualname__) +def test_delta_defaults_are_not_given(settings_cls): + """Every field must default to NOT_GIVEN so empty deltas are no-ops. + + A field that defaults to None instead of NOT_GIVEN will cause + apply_update() to overwrite the corresponding store value whenever + a partial delta is applied. + """ + instance = settings_cls() + for f in fields(instance): + if f.name == "extra": + continue + val = getattr(instance, f.name) + assert not is_given(val), ( + f"{settings_cls.__qualname__}.{f.name} defaults to {val!r}, expected NOT_GIVEN" + ) + + +# --------------------------------------------------------------------------- +# 2. Service construction: store-mode completeness +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("service_cls", ALL_SERVICE_CLASSES, ids=lambda c: c.__qualname__) +def test_service_settings_complete(service_cls): + """After construction, _settings must have no NOT_GIVEN values. + + This is what validate_complete() checks in start(). Catching it + here means we don't need a running pipeline to find missing defaults. + """ + try: + svc = _try_instantiate(service_cls) + except Exception: + pytest.skip("Cannot re-instantiate (environment issue)") + for f in fields(svc._settings): + if f.name == "extra": + continue + val = getattr(svc._settings, f.name) + assert is_given(val), ( + f"{service_cls.__qualname__}._settings.{f.name} is NOT_GIVEN after construction" + )