File size: 5,545 Bytes
3193174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import os
from pathlib import Path

from pydantic import Field, SecretStr, ValidationError, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

__all__ = ["FrameworkSettings", "load_env_file", "load_settings"]


class FrameworkSettings(BaseSettings):
    """
    Framework settings loaded from the environment with the `RWXF_` prefix.

    Key fields:
        - `api_key` / `api_key_file`: secret key directly or path to a file.
        - `base_url`: base URL for the LLM service.
        - `model_name`: generation model identifier.
        - `embedding_model`: embedding model identifier.
        - `log_*`: logging parameters.
        - `default_timeout`, `max_retries`: network timeouts and retries.
    """

    model_config = SettingsConfigDict(env_prefix="RWXF_", extra="ignore")

    api_key: SecretStr | None = Field(default=None, description="API key for LLM service")
    api_key_file: Path | None = Field(
        default=None,
        description="Path to a file that stores the API key securely",
    )
    base_url: str | None = Field(default=None, description="Base URL for LLM service")
    model_name: str = Field(default="gpt-4o-mini", description="LLM model identifier")
    embedding_model: str = Field(
        default="sentence-transformers/all-MiniLM-L6-v2",
        description="Embedding model identifier",
    )
    embedding_normalize: bool = Field(default=True, description="Normalize embeddings")
    embedding_fallback_dim: int = Field(default=384, description="Fallback dimension")
    log_level: str = Field(default="INFO", description="Logging level")
    log_file: str | None = Field(default=None, description="Log file path")
    log_backtrace: bool = Field(default=False, description="Enable backtrace")
    default_timeout: int = Field(default=60, description="Default timeout in seconds")
    max_retries: int = Field(default=3, description="Max retries for LLM calls")

    @field_validator("embedding_model")
    @classmethod
    def _validate_embedding_model(cls, value: str) -> str:
        """Validate the embedding model name."""
        if value == "hash" or value.startswith("hash:"):
            return value
        if value.startswith(("sentence-transformers/", "sentence-transformers:")):
            return value
        msg = "Unsupported embedding model. Use 'sentence-transformers/<model>' or 'hash[:<dim>]'"
        raise ValueError(msg)

    @field_validator("*", mode="before")
    @classmethod
    def _handle_empty_strings(cls, value):
        """Convert empty strings to None for correct validation."""
        if isinstance(value, str) and value.strip() == "":
            return None
        return value

    @field_validator("api_key_file")
    @classmethod
    def _validate_api_key_file(cls, value: Path | None) -> Path | None:
        """Ensure the key file exists before reading."""
        if value is None:
            return None
        if not value.is_file():
            msg = f"API key file not found: {value}"
            raise ValueError(msg)
        return value

    @model_validator(mode="after")
    def _load_secret_key(self) -> "FrameworkSettings":
        """Load the key from a file if not set directly, and require its presence."""
        if self.api_key is None and self.api_key_file is not None:
            content = self.api_key_file.read_text(encoding="utf-8").strip()
            if not content:
                msg = "API key file is empty"
                raise ValueError(msg)
            object.__setattr__(self, "api_key", SecretStr(content))

        if self.api_key is None:
            msg = "api_key is required via RWXF_API_KEY or RWXF_API_KEY_FILE"
            raise ValueError(msg)

        return self

    @property
    def resolved_api_key(self) -> str:
        """Return the secret api_key value or raise an error if it is absent."""
        if self.api_key is None:
            msg = "API key is not configured"
            raise RuntimeError(msg)

        return self.api_key.get_secret_value()


def load_env_file(path: Path | str | None = None) -> None:
    """
    Load environment variables from a .env file (if it exists).

    Args:
        path: Path to the .env file; defaults to the current directory.

    """
    env_path = Path(path or ".env")
    if not env_path.exists():
        return

    with env_path.open("r", encoding="utf-8") as handle:
        for raw_line in handle:
            line = raw_line.strip()
            if not line or line.startswith("#"):
                continue
            if "=" not in line:
                continue

            key, value = line.split("=", 1)
            key = key.strip()
            if not key:
                continue

            cleaned = value.strip().strip('"').strip("'")
            if key not in os.environ:
                os.environ[key] = cleaned


def load_settings(path: Path | str | None = None) -> FrameworkSettings:
    """
    Read the .env file (if provided), load, and validate settings.

    Args:
        path: Path to the .env file for pre-loading the environment.

    Returns:
        Validated `FrameworkSettings` instance.

    Raises:
        RuntimeError: if settings validation failed.

    """
    load_env_file(path)

    try:
        settings = FrameworkSettings()
    except ValidationError as exc:
        messages = [err.get("msg", "invalid configuration value") for err in exc.errors()]
        detail = "; ".join(messages)
        raise RuntimeError(detail) from exc

    return settings