diff --git a/features/domain_base_model.feature b/features/domain_base_model.feature new file mode 100644 index 000000000..bfaf740bc --- /dev/null +++ b/features/domain_base_model.feature @@ -0,0 +1,138 @@ +Feature: DomainBaseModel shared configuration + As a developer working on the domain layer + I want all domain models to inherit from DomainBaseModel + So that the shared model_config is defined in exactly one place + + Scenario: DomainBaseModel is importable from the domain models package + Given the cleveragents package is available + When I import DomainBaseModel from the domain models base module + Then the DomainBaseModel class should be available + And DomainBaseModel should be a subclass of pydantic BaseModel + + Scenario: DomainBaseModel carries the expected model_config + Given the cleveragents package is available + When I inspect the DomainBaseModel model_config + Then the DomainBaseModel str_strip_whitespace should be True + And the DomainBaseModel validate_assignment should be True + And the DomainBaseModel arbitrary_types_allowed should be False + And the DomainBaseModel populate_by_name should be True + And the DomainBaseModel use_enum_values should be True + + Scenario: ModelProviderOption inherits from DomainBaseModel + Given the cleveragents package is available + When I import ModelProviderOption from aimodelscredentials + Then ModelProviderOption should be a subclass of DomainBaseModel + And the ModelProviderOption model_config should match DomainBaseModel + + Scenario: ModelError inherits from DomainBaseModel + Given the cleveragents package is available + When I import ModelError from aimodelserrors + Then ModelError should be a subclass of DomainBaseModel + And the ModelError model_config should match DomainBaseModel + + Scenario: FallbackResult inherits from DomainBaseModel + Given the cleveragents package is available + When I import FallbackResult from aimodelserrors + Then FallbackResult should be a subclass of DomainBaseModel + And the FallbackResult model_config should match DomainBaseModel + + Scenario: ModelProviderExtraAuthVars inherits from DomainBaseModel + Given the cleveragents package is available + When I import ModelProviderExtraAuthVars from aimodelsproviders + Then ModelProviderExtraAuthVars should be a subclass of DomainBaseModel + And the ModelProviderExtraAuthVars model_config should match DomainBaseModel + + Scenario: ModelProviderConfigSchema inherits from DomainBaseModel + Given the cleveragents package is available + When I import ModelProviderConfigSchema from aimodelsproviders + Then ModelProviderConfigSchema should be a subclass of DomainBaseModel + And the ModelProviderConfigSchema model_config should match DomainBaseModel + + Scenario: AuthHeader inherits from DomainBaseModel + Given the cleveragents package is available + When I import AuthHeader from auth + Then AuthHeader should be a subclass of DomainBaseModel + And the AuthHeader model_config should match DomainBaseModel + + Scenario: TrialPlansExceededError inherits from DomainBaseModel + Given the cleveragents package is available + When I import TrialPlansExceededError from auth + Then TrialPlansExceededError should be a subclass of DomainBaseModel + And the TrialPlansExceededError model_config should match DomainBaseModel + + Scenario: TrialMessagesExceededError inherits from DomainBaseModel + Given the cleveragents package is available + When I import TrialMessagesExceededError from auth + Then TrialMessagesExceededError should be a subclass of DomainBaseModel + And the TrialMessagesExceededError model_config should match DomainBaseModel + + Scenario: BillingError inherits from DomainBaseModel + Given the cleveragents package is available + When I import BillingError from auth + Then BillingError should be a subclass of DomainBaseModel + And the BillingError model_config should match DomainBaseModel + + Scenario: ApiError inherits from DomainBaseModel + Given the cleveragents package is available + When I import ApiError from auth + Then ApiError should be a subclass of DomainBaseModel + And the ApiError model_config should match DomainBaseModel + + Scenario: ClientAccount inherits from DomainBaseModel + Given the cleveragents package is available + When I import ClientAccount from auth + Then ClientAccount should be a subclass of DomainBaseModel + And the ClientAccount model_config should match DomainBaseModel + + Scenario: ClientAuth inherits from DomainBaseModel + Given the cleveragents package is available + When I import ClientAuth from auth + Then ClientAuth should be a subclass of DomainBaseModel + And the ClientAuth model_config should match DomainBaseModel + + Scenario: PlanConfig inherits from DomainBaseModel + Given the cleveragents package is available + When I import PlanConfig from planconfig + Then PlanConfig should be a subclass of DomainBaseModel + And the PlanConfig model_config should match DomainBaseModel + + Scenario: ConfigSetting inherits from DomainBaseModel + Given the cleveragents package is available + When I import ConfigSetting from planconfig + Then ConfigSetting should be a subclass of DomainBaseModel + And the ConfigSetting model_config should match DomainBaseModel + + Scenario: DomainBaseModel config is applied to ModelProviderOption instances + Given the cleveragents package is available + When I create a ModelProviderOption with only priority set to 1 + Then the ModelProviderOption should have priority 1 + And the ModelProviderOption str_strip_whitespace should be True + And the ModelProviderOption validate_assignment should be True + And the ModelProviderOption arbitrary_types_allowed should be False + And the ModelProviderOption populate_by_name should be True + And the ModelProviderOption use_enum_values should be True + + Scenario: No inline model_config duplication in aimodelscredentials + Given the cleveragents package is available + When I inspect the ModelProviderOption class definition + Then ModelProviderOption should not define its own model_config + + Scenario: No inline model_config duplication in aimodelserrors + Given the cleveragents package is available + When I inspect the ModelError class definition + Then ModelError should not define its own model_config + + Scenario: No inline model_config duplication in aimodelsproviders + Given the cleveragents package is available + When I inspect the ModelProviderConfigSchema class definition + Then ModelProviderConfigSchema should not define its own model_config + + Scenario: No inline model_config duplication in auth + Given the cleveragents package is available + When I inspect the AuthHeader class definition + Then AuthHeader should not define its own model_config + + Scenario: No inline model_config duplication in planconfig + Given the cleveragents package is available + When I inspect the PlanConfig class definition + Then PlanConfig should not define its own model_config diff --git a/features/steps/domain_base_model_steps.py b/features/steps/domain_base_model_steps.py new file mode 100644 index 000000000..e31f5c594 --- /dev/null +++ b/features/steps/domain_base_model_steps.py @@ -0,0 +1,296 @@ +"""Step definitions for DomainBaseModel shared configuration tests. + +Only steps that are NOT already defined in other step modules are defined here. +Steps shared with aimodelscredentials_steps.py, aimodelserrors_steps.py, and +aimodelsproviders_steps.py are reused automatically by Behave's global step +registry. +""" + +from behave import then, when +from pydantic import BaseModel + +from cleveragents.domain.models.base import DomainBaseModel + +# --------------------------------------------------------------------------- +# Shared expected config values +# --------------------------------------------------------------------------- + +_EXPECTED_CONFIG: dict[str, object] = { + "str_strip_whitespace": True, + "validate_assignment": True, + "arbitrary_types_allowed": False, + "populate_by_name": True, + "use_enum_values": True, +} + + +# --------------------------------------------------------------------------- +# When — new imports (not defined in existing step files) +# --------------------------------------------------------------------------- + + +@when("I import DomainBaseModel from the domain models base module") +def step_import_domain_base_model(context: object) -> None: + """Import DomainBaseModel and store on context.""" + context.domain_base_model_class = DomainBaseModel + context.imported_class = DomainBaseModel + + +@when("I inspect the DomainBaseModel model_config") +def step_inspect_domain_base_model_config(context: object) -> None: + """Store the DomainBaseModel model_config on context.""" + context.inspected_config = DomainBaseModel.model_config + + +@when("I import ModelError from aimodelserrors") +def step_import_model_error(context: object) -> None: + """Import ModelError.""" + from cleveragents.domain.models.aimodelserrors.ai_models_errors import ModelError + + context.imported_class = ModelError + + +@when("I import FallbackResult from aimodelserrors") +def step_import_fallback_result(context: object) -> None: + """Import FallbackResult.""" + from cleveragents.domain.models.aimodelserrors.ai_models_errors import ( + FallbackResult, + ) + + context.imported_class = FallbackResult + + +@when("I import ModelProviderExtraAuthVars from aimodelsproviders") +def step_import_model_provider_extra_auth_vars(context: object) -> None: + """Import ModelProviderExtraAuthVars.""" + from cleveragents.domain.models.aimodelsproviders.ai_models_providers import ( + ModelProviderExtraAuthVars, + ) + + context.imported_class = ModelProviderExtraAuthVars + + +@when("I import ModelProviderConfigSchema from aimodelsproviders") +def step_import_model_provider_config_schema(context: object) -> None: + """Import ModelProviderConfigSchema.""" + from cleveragents.domain.models.aimodelsproviders.ai_models_providers import ( + ModelProviderConfigSchema, + ) + + context.imported_class = ModelProviderConfigSchema + + +@when("I import AuthHeader from auth") +def step_import_auth_header(context: object) -> None: + """Import AuthHeader.""" + from cleveragents.domain.models.auth.auth import AuthHeader + + context.imported_class = AuthHeader + + +@when("I import TrialPlansExceededError from auth") +def step_import_trial_plans_exceeded_error(context: object) -> None: + """Import TrialPlansExceededError.""" + from cleveragents.domain.models.auth.auth import TrialPlansExceededError + + context.imported_class = TrialPlansExceededError + + +@when("I import TrialMessagesExceededError from auth") +def step_import_trial_messages_exceeded_error(context: object) -> None: + """Import TrialMessagesExceededError.""" + from cleveragents.domain.models.auth.auth import TrialMessagesExceededError + + context.imported_class = TrialMessagesExceededError + + +@when("I import BillingError from auth") +def step_import_billing_error(context: object) -> None: + """Import BillingError.""" + from cleveragents.domain.models.auth.auth import BillingError + + context.imported_class = BillingError + + +@when("I import ApiError from auth") +def step_import_api_error(context: object) -> None: + """Import ApiError.""" + from cleveragents.domain.models.auth.auth import ApiError + + context.imported_class = ApiError + + +@when("I import ClientAccount from auth") +def step_import_client_account(context: object) -> None: + """Import ClientAccount.""" + from cleveragents.domain.models.auth.auth import ClientAccount + + context.imported_class = ClientAccount + + +@when("I import ClientAuth from auth") +def step_import_client_auth(context: object) -> None: + """Import ClientAuth.""" + from cleveragents.domain.models.auth.auth import ClientAuth + + context.imported_class = ClientAuth + + +@when("I import PlanConfig from planconfig") +def step_import_plan_config(context: object) -> None: + """Import PlanConfig.""" + from cleveragents.domain.models.planconfig.plan_config import PlanConfig + + context.imported_class = PlanConfig + + +@when("I import ConfigSetting from planconfig") +def step_import_config_setting(context: object) -> None: + """Import ConfigSetting.""" + from cleveragents.domain.models.planconfig.plan_config import ConfigSetting + + context.imported_class = ConfigSetting + + +# When — class definition inspection + + +@when("I inspect the ModelProviderOption class definition") +def step_inspect_model_provider_option(context: object) -> None: + """Store ModelProviderOption for inspection.""" + from cleveragents.domain.models.aimodelscredentials.ai_models_credentials import ( + ModelProviderOption, + ) + + context.inspected_class = ModelProviderOption + + +@when("I inspect the ModelError class definition") +def step_inspect_model_error(context: object) -> None: + """Store ModelError for inspection.""" + from cleveragents.domain.models.aimodelserrors.ai_models_errors import ModelError + + context.inspected_class = ModelError + + +@when("I inspect the ModelProviderConfigSchema class definition") +def step_inspect_model_provider_config_schema_def(context: object) -> None: + """Store ModelProviderConfigSchema for inspection.""" + from cleveragents.domain.models.aimodelsproviders.ai_models_providers import ( + ModelProviderConfigSchema, + ) + + context.inspected_class = ModelProviderConfigSchema + + +@when("I inspect the AuthHeader class definition") +def step_inspect_auth_header(context: object) -> None: + """Store AuthHeader for inspection.""" + from cleveragents.domain.models.auth.auth import AuthHeader + + context.inspected_class = AuthHeader + + +@when("I inspect the PlanConfig class definition") +def step_inspect_plan_config(context: object) -> None: + """Store PlanConfig for inspection.""" + from cleveragents.domain.models.planconfig.plan_config import PlanConfig + + context.inspected_class = PlanConfig + + +# --------------------------------------------------------------------------- +# Then — DomainBaseModel assertions +# --------------------------------------------------------------------------- + + +@then("the DomainBaseModel class should be available") +def step_domain_base_model_available(context: object) -> None: + """Verify DomainBaseModel is available.""" + assert context.domain_base_model_class is not None + assert context.domain_base_model_class.__name__ == "DomainBaseModel" + + +@then("DomainBaseModel should be a subclass of pydantic BaseModel") +def step_domain_base_model_is_pydantic(context: object) -> None: + """Verify DomainBaseModel inherits from pydantic BaseModel.""" + assert issubclass(context.domain_base_model_class, BaseModel) + + +@then("the DomainBaseModel str_strip_whitespace should be True") +def step_domain_base_model_str_strip(context: object) -> None: + """Verify str_strip_whitespace on DomainBaseModel.""" + assert context.inspected_config.get("str_strip_whitespace") is True + + +@then("the DomainBaseModel validate_assignment should be True") +def step_domain_base_model_validate_assignment(context: object) -> None: + """Verify validate_assignment on DomainBaseModel.""" + assert context.inspected_config.get("validate_assignment") is True + + +@then("the DomainBaseModel arbitrary_types_allowed should be False") +def step_domain_base_model_arbitrary_types(context: object) -> None: + """Verify arbitrary_types_allowed on DomainBaseModel.""" + assert context.inspected_config.get("arbitrary_types_allowed") is False + + +@then("the DomainBaseModel populate_by_name should be True") +def step_domain_base_model_populate_by_name(context: object) -> None: + """Verify populate_by_name on DomainBaseModel.""" + assert context.inspected_config.get("populate_by_name") is True + + +@then("the DomainBaseModel use_enum_values should be True") +def step_domain_base_model_use_enum_values(context: object) -> None: + """Verify use_enum_values on DomainBaseModel.""" + assert context.inspected_config.get("use_enum_values") is True + + +# --------------------------------------------------------------------------- +# Then — subclass assertions (generic, reused for all domain models) +# --------------------------------------------------------------------------- + + +@then("{class_name} should be a subclass of DomainBaseModel") +def step_is_subclass_of_domain_base_model(context: object, class_name: str) -> None: + """Verify the imported class is a subclass of DomainBaseModel.""" + assert issubclass(context.imported_class, DomainBaseModel), ( + f"{class_name} is not a subclass of DomainBaseModel" + ) + + +@then("the {class_name} model_config should match DomainBaseModel") +def step_config_matches_domain_base_model(context: object, class_name: str) -> None: + """Verify the imported class inherits the expected model_config values.""" + cfg = context.imported_class.model_config + for key, expected in _EXPECTED_CONFIG.items(): + actual = cfg.get(key) + assert actual == expected, ( + f"{class_name}.model_config[{key!r}] = {actual!r}, expected {expected!r}" + ) + + +# --------------------------------------------------------------------------- +# Then — no inline model_config duplication assertions +# --------------------------------------------------------------------------- + + +@then("{class_name} should not define its own model_config") +def step_no_own_model_config(context: object, class_name: str) -> None: + """Verify the class source does not contain an inline model_config assignment. + + Pydantic's metaclass copies model_config into every subclass __dict__ + automatically, so we inspect the source code instead of __dict__ to + confirm no *explicit* duplication exists. + """ + import inspect + + source = inspect.getsource(context.inspected_class) + # Remove the class header line so we only look at the class body + body_lines = source.splitlines()[1:] + body = "\n".join(body_lines) + assert "model_config" not in body, ( + f"{class_name} has an explicit model_config in its source — " + "should inherit from DomainBaseModel without redefinition" + ) diff --git a/robot/domain_base_model.robot b/robot/domain_base_model.robot new file mode 100644 index 000000000..2a0aebb49 --- /dev/null +++ b/robot/domain_base_model.robot @@ -0,0 +1,61 @@ +*** Settings *** +Documentation Integration smoke tests for DomainBaseModel refactor. +... +... Verifies that extracting the shared model_config into +... DomainBaseModel has not changed the observable behaviour of +... any affected domain model class. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER_SCRIPT} robot/helper_domain_base_model.py + +*** Test Cases *** +DomainBaseModel Is Importable + [Documentation] DomainBaseModel can be imported and is a pydantic BaseModel subclass + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} importable cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} domain-base-model-importable-ok + +DomainBaseModel Carries Expected Config + [Documentation] DomainBaseModel model_config has all expected settings + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} config cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} domain-base-model-config-ok + +ModelProviderOption Behaviour Unchanged + [Documentation] ModelProviderOption works correctly after inheriting from DomainBaseModel + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} model-provider-option cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} model-provider-option-ok + +ModelError Behaviour Unchanged + [Documentation] ModelError works correctly after inheriting from DomainBaseModel + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} model-error cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} model-error-ok + +FallbackResult Behaviour Unchanged + [Documentation] FallbackResult works correctly after inheriting from DomainBaseModel + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} fallback-result cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} fallback-result-ok + +Auth Models Behaviour Unchanged + [Documentation] All auth domain models work correctly after inheriting from DomainBaseModel + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} auth-models cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} auth-models-ok + +PlanConfig Models Behaviour Unchanged + [Documentation] PlanConfig and ConfigSetting work correctly after inheriting from DomainBaseModel + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} plan-config-models cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-config-models-ok + +Whitespace Stripping Active On Refactored Models + [Documentation] str_strip_whitespace config is active on all refactored models + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} whitespace-stripping cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} whitespace-stripping-ok diff --git a/robot/helper_domain_base_model.py b/robot/helper_domain_base_model.py new file mode 100644 index 000000000..4d51f047e --- /dev/null +++ b/robot/helper_domain_base_model.py @@ -0,0 +1,239 @@ +"""Helper script for DomainBaseModel Robot integration tests. + +Verifies that the structural refactor (extracting shared model_config into +DomainBaseModel) has not changed the observable behaviour of any affected +domain model class. +""" + +from __future__ import annotations + +import sys + +from pydantic import BaseModel + +from cleveragents.domain.models.base import DomainBaseModel + + +def _test_domain_base_model_importable() -> None: + """DomainBaseModel is importable and is a pydantic BaseModel subclass.""" + assert issubclass(DomainBaseModel, BaseModel) + print("domain-base-model-importable-ok") + + +def _test_domain_base_model_config() -> None: + """DomainBaseModel carries the expected shared model_config.""" + cfg = DomainBaseModel.model_config + assert cfg.get("str_strip_whitespace") is True + assert cfg.get("validate_assignment") is True + assert cfg.get("arbitrary_types_allowed") is False + assert cfg.get("populate_by_name") is True + assert cfg.get("use_enum_values") is True + print("domain-base-model-config-ok") + + +def _test_model_provider_option() -> None: + """ModelProviderOption behaviour is unchanged after refactor.""" + from cleveragents.domain.models.aimodelscredentials.ai_models_credentials import ( + ModelProviderOption, + ) + from cleveragents.domain.models.aimodelsproviders.ai_models_providers import ( + ModelProviderConfigSchema, + ) + from cleveragents.domain.models.core.enums import ModelProvider, ModelPublisher + + assert issubclass(ModelProviderOption, DomainBaseModel) + + config = ModelProviderConfigSchema( + provider=ModelProvider.OPENAI, baseUrl="https://api.openai.com/v1" + ) + publishers = {"primary": {ModelPublisher.OPENAI: True}} + model = ModelProviderOption(publishers=publishers, config=config, priority=1) + + assert model.priority == 1 + assert model.config is not None + assert model.config.base_url == "https://api.openai.com/v1" + # Verify str_strip_whitespace is active via inherited config + assert model.model_config.get("str_strip_whitespace") is True + print("model-provider-option-ok") + + +def _test_model_error() -> None: + """ModelError behaviour is unchanged after refactor.""" + from cleveragents.domain.models.aimodelserrors.ai_models_errors import ModelError + from cleveragents.domain.models.core.enums import ModelErrKind + + assert issubclass(ModelError, DomainBaseModel) + + err = ModelError(kind=ModelErrKind.OVERLOADED, retriable=True, retryafterseconds=30) + assert err.retriable is True + assert err.retryafterseconds == 30 + print("model-error-ok") + + +def _test_fallback_result() -> None: + """FallbackResult behaviour is unchanged after refactor.""" + from cleveragents.domain.models.aimodelserrors.ai_models_errors import ( + FallbackResult, + ) + from cleveragents.domain.models.core.enums import FallbackType + + assert issubclass(FallbackResult, DomainBaseModel) + + result = FallbackResult( + fallbacktype=FallbackType.ERROR, + isfallback=True, + modelroleconfig=None, + basemodelconfig=None, + ) + assert result.isfallback is True + print("fallback-result-ok") + + +def _test_auth_models() -> None: + """Auth model behaviour is unchanged after refactor.""" + from cleveragents.domain.models.auth.auth import ( + ApiError, + ApiErrorType, + AuthHeader, + BillingError, + ClientAccount, + ClientAuth, + TrialMessagesExceededError, + TrialPlansExceededError, + ) + + for cls in ( + AuthHeader, + TrialPlansExceededError, + TrialMessagesExceededError, + BillingError, + ApiError, + ClientAccount, + ClientAuth, + ): + assert issubclass(cls, DomainBaseModel), f"{cls.__name__} not DomainBaseModel" + + header = AuthHeader(token="tok", orgId="org-1", hash="abc") + assert header.org_id == "org-1" + + trial_plans = TrialPlansExceededError(maxPlans=5) + assert trial_plans.max_plans == 5 + + trial_msgs = TrialMessagesExceededError(maxMessages=100) + assert trial_msgs.max_messages == 100 + + billing = BillingError(hasBillingPermission=False, isTrial=True) + assert billing.is_trial is True + + api_err = ApiError(type_=ApiErrorType.AUTH, status=401, msg="Unauthorized") + assert api_err.status == 401 + + account = ClientAccount( + isCloud=True, + host="app.cleverthis.com", + email="user@example.com", + userName="testuser", + userId="uid-1", + token="tok-1", + isLocalMode=False, + isTrial=False, + ) + assert account.is_cloud is True + + auth = ClientAuth( + orgId="org-1", + orgName="Test Org", + orgIsTrial=False, + integratedModelsMode=True, + ) + assert auth.org_name == "Test Org" + + print("auth-models-ok") + + +def _test_plan_config_models() -> None: + """PlanConfig and ConfigSetting behaviour is unchanged after refactor.""" + from cleveragents.domain.models.planconfig.plan_config import ( + AutoModeType, + ConfigSetting, + PlanConfig, + ) + + assert issubclass(PlanConfig, DomainBaseModel) + assert issubclass(ConfigSetting, DomainBaseModel) + + plan = PlanConfig( + autoMode=AutoModeType.AUTO, + editor="vim", + editorCommand="vim", + editorOpenManually=False, + autoContinue=True, + autoBuild=False, + autoUpdateContext=True, + autoContext=True, + smartContext=True, + autoApply=False, + autoCommit=False, + skipCommit=False, + canExec=True, + autoExec=False, + autoDebug=False, + autoDebugTries=3, + autoRevertOnRewind=False, + skipChangesMenu=False, + ) + assert plan.editor == "vim" + assert plan.auto_mode == AutoModeType.AUTO + + setting = ConfigSetting( + name="autoMode", + desc="Auto mode setting", + hascustomchoice=False, + sortkey="a", + ) + assert setting.name == "autoMode" + + print("plan-config-models-ok") + + +def _test_whitespace_stripping() -> None: + """str_strip_whitespace is active on all refactored models.""" + from cleveragents.domain.models.aimodelsproviders.ai_models_providers import ( + ModelProviderConfigSchema, + ) + from cleveragents.domain.models.core.enums import ModelProvider + + schema = ModelProviderConfigSchema( + provider=ModelProvider.OPENAI, + baseUrl=" https://api.openai.com/v1 ", + ) + assert schema.base_url == "https://api.openai.com/v1", ( + f"Expected stripped URL, got: {schema.base_url!r}" + ) + print("whitespace-stripping-ok") + + +_COMMANDS: dict[str, object] = { + "importable": _test_domain_base_model_importable, + "config": _test_domain_base_model_config, + "model-provider-option": _test_model_provider_option, + "model-error": _test_model_error, + "fallback-result": _test_fallback_result, + "auth-models": _test_auth_models, + "plan-config-models": _test_plan_config_models, + "whitespace-stripping": _test_whitespace_stripping, +} + + +def main() -> None: + if len(sys.argv) < 2: + raise SystemExit("Expected command argument") + command = sys.argv[1] + fn = _COMMANDS.get(command) + if fn is None: + raise SystemExit(f"Unknown helper command: {command!r}") + fn() # type: ignore[operator] + + +if __name__ == "__main__": + main() diff --git a/src/cleveragents/domain/models/aimodelscredentials/ai_models_credentials.py b/src/cleveragents/domain/models/aimodelscredentials/ai_models_credentials.py index 98cf7eeac..d3012a948 100644 --- a/src/cleveragents/domain/models/aimodelscredentials/ai_models_credentials.py +++ b/src/cleveragents/domain/models/aimodelscredentials/ai_models_credentials.py @@ -1,22 +1,15 @@ """Domain models for CleverAgents - auto-generated from Phase 0 stubs.""" -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field from ..aimodelsproviders.ai_models_providers import ModelProviderConfigSchema +from ..base import DomainBaseModel from ..core.enums import ModelPublisher -class ModelProviderOption(BaseModel): +class ModelProviderOption(DomainBaseModel): """Data contract for ModelProviderOption.""" publishers: dict[str, dict[ModelPublisher, bool]] = Field(default_factory=dict) config: ModelProviderConfigSchema | None = Field(default=None) priority: int = Field(...) - - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) diff --git a/src/cleveragents/domain/models/aimodelserrors/ai_models_errors.py b/src/cleveragents/domain/models/aimodelserrors/ai_models_errors.py index 6d6dece7d..e106dcf94 100644 --- a/src/cleveragents/domain/models/aimodelserrors/ai_models_errors.py +++ b/src/cleveragents/domain/models/aimodelserrors/ai_models_errors.py @@ -1,39 +1,24 @@ """Domain models for CleverAgents - auto-generated from Phase 0 stubs.""" -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field from ..aimodelsdatamodels import BaseModelConfig, ModelRoleConfig +from ..base import DomainBaseModel from ..core.enums import FallbackType, ModelErrKind -class ModelError(BaseModel): +class ModelError(DomainBaseModel): """Data contract for ModelError.""" kind: ModelErrKind = Field(...) retriable: bool = Field(...) retryafterseconds: int = Field(...) - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class FallbackResult(BaseModel): +class FallbackResult(DomainBaseModel): """Data contract for FallbackResult.""" modelroleconfig: ModelRoleConfig | None = Field(default=None) isfallback: bool = Field(...) fallbacktype: FallbackType = Field(...) basemodelconfig: BaseModelConfig | None = Field(default=None) - - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) diff --git a/src/cleveragents/domain/models/aimodelsproviders/ai_models_providers.py b/src/cleveragents/domain/models/aimodelsproviders/ai_models_providers.py index eb271cf6d..913760037 100644 --- a/src/cleveragents/domain/models/aimodelsproviders/ai_models_providers.py +++ b/src/cleveragents/domain/models/aimodelsproviders/ai_models_providers.py @@ -1,11 +1,12 @@ """Domain models for CleverAgents - auto-generated from Phase 0 stubs.""" -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field +from ..base import DomainBaseModel from ..core.enums import ModelProvider -class ModelProviderExtraAuthVars(BaseModel): +class ModelProviderExtraAuthVars(DomainBaseModel): """Data contract for ModelProviderExtraAuthVars.""" var: str = Field(...) @@ -15,16 +16,8 @@ class ModelProviderExtraAuthVars(BaseModel): required: bool | None = Field(default=None) default: str | None = Field(default=None) - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class ModelProviderConfigSchema(BaseModel): +class ModelProviderConfigSchema(DomainBaseModel): """Data contract for ModelProviderConfigSchema.""" provider: ModelProvider = Field(...) @@ -38,11 +31,3 @@ class ModelProviderConfigSchema(BaseModel): extra_auth_vars: list[ModelProviderExtraAuthVars] | None = Field( alias="extraAuthVars", default=None ) - - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) diff --git a/src/cleveragents/domain/models/auth/auth.py b/src/cleveragents/domain/models/auth/auth.py index 78ba6b410..940b2f5eb 100644 --- a/src/cleveragents/domain/models/auth/auth.py +++ b/src/cleveragents/domain/models/auth/auth.py @@ -2,7 +2,9 @@ from enum import StrEnum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field + +from ..base import DomainBaseModel class ApiErrorType(StrEnum): @@ -16,66 +18,34 @@ class ApiErrorType(StrEnum): NOT_FOUND = "not_found" -class AuthHeader(BaseModel): +class AuthHeader(DomainBaseModel): """Data contract for AuthHeader.""" token: str = Field(...) org_id: str = Field(..., alias="orgId") hash: str = Field(...) - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class TrialPlansExceededError(BaseModel): +class TrialPlansExceededError(DomainBaseModel): """Data contract for TrialPlansExceededError.""" max_plans: int = Field(..., alias="maxPlans") - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class TrialMessagesExceededError(BaseModel): +class TrialMessagesExceededError(DomainBaseModel): """Data contract for TrialMessagesExceededError.""" max_messages: int = Field(..., alias="maxMessages") - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class BillingError(BaseModel): +class BillingError(DomainBaseModel): """Data contract for BillingError.""" has_billing_permission: bool = Field(..., alias="hasBillingPermission") is_trial: bool = Field(..., alias="isTrial") - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class ApiError(BaseModel): +class ApiError(DomainBaseModel): """Data contract for ApiError.""" type_: ApiErrorType = Field(...) @@ -89,16 +59,8 @@ class ApiError(BaseModel): ) billing_error: BillingError | None = Field(alias="billingError", default=None) - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class ClientAccount(BaseModel): +class ClientAccount(DomainBaseModel): """Data contract for ClientAccount.""" is_cloud: bool = Field(..., alias="isCloud") @@ -110,27 +72,11 @@ class ClientAccount(BaseModel): is_local_mode: bool = Field(..., alias="isLocalMode") is_trial: bool = Field(..., alias="isTrial") - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class ClientAuth(BaseModel): +class ClientAuth(DomainBaseModel): """Data contract for ClientAuth.""" org_id: str = Field(..., alias="orgId") org_name: str = Field(..., alias="orgName") org_is_trial: bool = Field(..., alias="orgIsTrial") integrated_models_mode: bool = Field(..., alias="integratedModelsMode") - - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) diff --git a/src/cleveragents/domain/models/base.py b/src/cleveragents/domain/models/base.py new file mode 100644 index 000000000..f0bd84a2f --- /dev/null +++ b/src/cleveragents/domain/models/base.py @@ -0,0 +1,38 @@ +"""Shared base Pydantic model for the domain layer. + +All domain models that share the standard configuration should inherit from +``DomainBaseModel`` instead of ``pydantic.BaseModel`` directly. This ensures +the shared ``model_config`` is defined in exactly one place and that any future +change to the configuration is automatically propagated to every consumer. +""" + +from pydantic import BaseModel, ConfigDict + +__all__ = ["DomainBaseModel"] + + +class DomainBaseModel(BaseModel): + """Base class for all standard domain-layer Pydantic models. + + Provides the shared ``model_config`` that was previously duplicated across + every model file in the domain layer: + + - ``str_strip_whitespace``: leading/trailing whitespace is stripped from + all ``str`` fields on assignment and validation. + - ``validate_assignment``: field assignments after construction are + validated just like constructor arguments. + - ``arbitrary_types_allowed``: disabled — all field types must be + Pydantic-compatible to keep the domain layer clean. + - ``populate_by_name``: models can be constructed using either the Python + field name or the JSON alias. + - ``use_enum_values``: enum fields are stored and serialised as their + underlying primitive values rather than as enum instances. + """ + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + arbitrary_types_allowed=False, + populate_by_name=True, + use_enum_values=True, + ) diff --git a/src/cleveragents/domain/models/planconfig/plan_config.py b/src/cleveragents/domain/models/planconfig/plan_config.py index 6b0791f55..6ccea515b 100644 --- a/src/cleveragents/domain/models/planconfig/plan_config.py +++ b/src/cleveragents/domain/models/planconfig/plan_config.py @@ -2,7 +2,9 @@ from enum import StrEnum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field + +from ..base import DomainBaseModel class AutoModeType(StrEnum): @@ -13,7 +15,7 @@ class AutoModeType(StrEnum): GUIDED = "guided" -class PlanConfig(BaseModel): +class PlanConfig(DomainBaseModel): """Data contract for PlanConfig.""" auto_mode: AutoModeType = Field(..., alias="autoMode") @@ -36,16 +38,8 @@ class PlanConfig(BaseModel): auto_revert_on_rewind: bool = Field(..., alias="autoRevertOnRewind") skip_changes_menu: bool = Field(..., alias="skipChangesMenu") - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - ) - -class ConfigSetting(BaseModel): +class ConfigSetting(DomainBaseModel): """Data contract for ConfigSetting.""" name: str = Field(...) @@ -53,11 +47,3 @@ class ConfigSetting(BaseModel): choices: list[str] | None = Field(default=None) hascustomchoice: bool = Field(...) sortkey: str = Field(...) - - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - arbitrary_types_allowed=False, - populate_by_name=True, - use_enum_values=True, - )