File size: 12,740 Bytes
a583ded
f3518c5
 
a583ded
f3518c5
f8272eb
f3518c5
ce9e9da
93d79eb
 
 
 
 
f3518c5
4f6a1a5
f3518c5
 
be6825e
 
 
 
 
 
 
 
f3518c5
 
 
 
 
 
 
 
 
be6825e
f3518c5
 
93d79eb
 
f3518c5
93d79eb
cd70b5d
 
ce9e9da
 
 
 
 
 
 
 
 
93d79eb
 
a583ded
 
 
 
 
 
 
 
 
 
 
 
 
f8272eb
 
a583ded
 
 
 
 
93d79eb
 
 
 
 
 
a583ded
 
 
 
 
ff54322
cd70b5d
 
 
 
4f6a1a5
7f67a14
 
 
 
 
f3518c5
a583ded
 
 
 
 
f8272eb
 
a583ded
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3518c5
 
 
 
6c09e56
f3518c5
 
 
 
 
 
 
 
 
 
 
 
cd70b5d
93d79eb
 
cd70b5d
93d79eb
cd70b5d
 
 
68b21f6
cd70b5d
 
 
68b21f6
 
 
 
 
cd70b5d
 
f3518c5
93d79eb
 
f3518c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd70b5d
 
 
 
 
f3518c5
 
 
 
 
 
 
 
 
93d79eb
4f6a1a5
f3518c5
 
 
 
 
 
 
 
 
 
 
 
93d79eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77830e0
 
93d79eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a583ded
 
 
 
93d79eb
f3518c5
 
a583ded
 
 
 
 
 
 
 
 
 
 
f8272eb
a583ded
f8272eb
a583ded
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3518c5
 
 
 
 
 
 
 
93d79eb
 
a583ded
 
93d79eb
 
 
 
 
 
 
a583ded
 
 
 
93d79eb
 
f3518c5
 
 
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
import ast
import os
import sys
from typing import Any, Literal, Optional

import orjson
from loguru import logger
from pydantic import BaseModel, Field, ValidationError, field_validator
from pydantic_settings import (
    BaseSettings,
    SettingsConfigDict,
    YamlConfigSettingsSource,
)

CONFIG_PATH = "config/config.yaml"


class HTTPSConfig(BaseModel):
    """HTTPS configuration"""

    enabled: bool = Field(default=False, description="Enable HTTPS")
    key_file: str = Field(default="certs/privkey.pem", description="SSL private key file path")
    cert_file: str = Field(default="certs/fullchain.pem", description="SSL certificate file path")


class ServerConfig(BaseModel):
    """Server configuration"""

    host: str = Field(default="0.0.0.0", description="Server host address")
    port: int = Field(default=8000, ge=1, le=65535, description="Server port number")
    api_key: Optional[str] = Field(
        default=None,
        description="API key for authentication, if set, will enable API key validation",
    )
    https: HTTPSConfig = Field(default=HTTPSConfig(), description="HTTPS configuration")


class GeminiClientSettings(BaseModel):
    """Credential set for one Gemini client."""

    id: str = Field(..., description="Unique identifier for the client")
    secure_1psid: str = Field(..., description="Gemini Secure 1PSID")
    secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS")
    proxy: Optional[str] = Field(default=None, description="Proxy URL for this Gemini client")

    @field_validator("proxy", mode="before")
    @classmethod
    def _blank_proxy_to_none(cls, value: Optional[str]) -> Optional[str]:
        if value is None:
            return None
        stripped = value.strip()
        return stripped or None


class GeminiModelConfig(BaseModel):
    """Configuration for a custom Gemini model."""

    model_name: Optional[str] = Field(default=None, description="Name of the model")
    model_header: Optional[dict[str, Optional[str]]] = Field(
        default=None, description="Header for the model"
    )

    @field_validator("model_header", mode="before")
    @classmethod
    def _parse_json_string(cls, v: Any) -> Any:
        if isinstance(v, str) and v.strip().startswith("{"):
            try:
                return orjson.loads(v)
            except orjson.JSONDecodeError:
                # Return the original value to let Pydantic handle the error or type mismatch
                return v
        return v


class GeminiConfig(BaseModel):
    """Gemini API configuration"""

    clients: list[GeminiClientSettings] = Field(
        ..., description="List of Gemini client credential pairs"
    )
    models: list[GeminiModelConfig] = Field(default=[], description="List of custom Gemini models")
    model_strategy: Literal["append", "overwrite"] = Field(
        default="append",
        description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom",
    )
    timeout: int = Field(default=120, ge=1, description="Init timeout")
    auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies")
    refresh_interval: int = Field(
        default=540, ge=1, description="Interval in seconds to refresh Gemini cookies"
    )
    verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests")
    max_chars_per_request: int = Field(
        default=1_000_000,
        ge=1,
        description="Maximum characters Gemini Web can accept per request",
    )

    @field_validator("models", mode="before")
    @classmethod
    def _parse_models_json(cls, v: Any) -> Any:
        if isinstance(v, str) and v.strip().startswith("["):
            try:
                return orjson.loads(v)
            except orjson.JSONDecodeError as e:
                logger.warning(f"Failed to parse models JSON string: {e}")
                return v
        return v

    @field_validator("models")
    @classmethod
    def _filter_valid_models(cls, v: list[GeminiModelConfig]) -> list[GeminiModelConfig]:
        """Filter out models that don't have all required fields set."""
        valid_models = []
        for model in v:
            if model.model_name and model.model_header:
                valid_models.append(model)
            else:
                missing = []
                if not model.model_name:
                    missing.append("model_name")
                if not model.model_header:
                    missing.append("model_header")
                logger.warning(
                    f"Discarding custom model due to missing {', '.join(missing)}: {model}"
                )
        return valid_models


class CORSConfig(BaseModel):
    """CORS configuration"""

    enabled: bool = Field(default=True, description="Enable CORS support")
    allow_origins: list[str] = Field(
        default=["*"], description="List of allowed origins for CORS requests"
    )
    allow_credentials: bool = Field(default=True, description="Allow credentials in CORS requests")
    allow_methods: list[str] = Field(
        default=["*"], description="List of allowed HTTP methods for CORS requests"
    )
    allow_headers: list[str] = Field(
        default=["*"], description="List of allowed headers for CORS requests"
    )


class StorageConfig(BaseModel):
    """LMDB Storage configuration"""

    path: str = Field(
        default="data/lmdb",
        description="Path to the storage directory where data will be saved",
    )
    max_size: int = Field(
        default=1024**2 * 256,  # 256 MB
        ge=1,
        description="Maximum size of the storage in bytes",
    )
    retention_days: int = Field(
        default=14,
        ge=0,
        description="Number of days to retain conversations before automatic cleanup (0 disables cleanup)",
    )


class LoggingConfig(BaseModel):
    """Logging configuration"""

    level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
        default="DEBUG",
        description="Logging level",
    )


class Config(BaseSettings):
    """Application configuration"""

    # Server configuration
    server: ServerConfig = Field(
        default=ServerConfig(),
        description="Server configuration, including host, port, and API key",
    )

    # CORS configuration
    cors: CORSConfig = Field(
        default=CORSConfig(),
        description="CORS configuration, allows cross-origin requests",
    )

    # Gemini API configuration
    gemini: GeminiConfig = Field(..., description="Gemini API configuration, must be set")

    storage: StorageConfig = Field(
        default=StorageConfig(),
        description="Storage configuration, defines where and how data will be stored",
    )

    # Logging configuration
    logging: LoggingConfig = Field(
        default=LoggingConfig(),
        description="Logging configuration",
    )

    model_config = SettingsConfigDict(
        env_prefix="CONFIG_",
        env_nested_delimiter="__",
        nested_model_default_partial_update=True,
        yaml_file=os.getenv("CONFIG_PATH", CONFIG_PATH),
    )

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls,
        init_settings,
        env_settings,
        dotenv_settings,
        file_secret_settings,
    ):
        """Read settings: env -> yaml -> default"""
        return (
            env_settings,
            YamlConfigSettingsSource(settings_cls),
        )


def extract_gemini_clients_env() -> dict[int, dict[str, str]]:
    """Extract and remove all Gemini clients related environment variables, return a mapping from index to field dict."""
    prefix = "CONFIG_GEMINI__CLIENTS__"
    env_overrides: dict[int, dict[str, str]] = {}
    to_delete = []
    for k, v in os.environ.items():
        if k.startswith(prefix):
            parts = k.split("__")
            if len(parts) < 4:
                continue
            index_str, field = parts[2], parts[3].lower()
            if not index_str.isdigit():
                continue
            idx = int(index_str)
            env_overrides.setdefault(idx, {})[field] = v
            to_delete.append(k)
    # Remove these environment variables to avoid Pydantic parsing errors
    for k in to_delete:
        del os.environ[k]
    return env_overrides


def _merge_clients_with_env(
    base_clients: list[GeminiClientSettings] | None,
    env_overrides: dict[int, dict[str, str]],
):
    """Override base_clients with env_overrides, return the new clients list."""
    if not env_overrides:
        return base_clients
    result_clients: list[GeminiClientSettings] = []
    if base_clients:
        result_clients = [client.model_copy() for client in base_clients]
    for idx in sorted(env_overrides):
        overrides = env_overrides[idx]
        if idx < len(result_clients):
            client_dict = result_clients[idx].model_dump()
            client_dict.update(overrides)
            result_clients[idx] = GeminiClientSettings(**client_dict)
        elif idx == len(result_clients):
            new_client = GeminiClientSettings(**overrides)
            result_clients.append(new_client)
        else:
            raise IndexError(
                f"Client index {idx} in env is out of range (current count: {len(result_clients)}). "
                "Client indices must be contiguous starting from 0."
            )
    return result_clients if result_clients else base_clients


def extract_gemini_models_env() -> dict[int, dict[str, Any]]:
    """Extract and remove all Gemini models related environment variables, supporting nested fields."""
    root_key = "CONFIG_GEMINI__MODELS"
    env_overrides: dict[int, dict[str, Any]] = {}

    if root_key in os.environ:
        val = os.environ[root_key]
        models_list = None
        parsed_successfully = False

        try:
            models_list = orjson.loads(val)
            parsed_successfully = True
        except orjson.JSONDecodeError:
            try:
                models_list = ast.literal_eval(val)
                parsed_successfully = True
            except (ValueError, SyntaxError) as e:
                logger.warning(f"Failed to parse {root_key} as JSON or Python literal: {e}")

        if parsed_successfully and isinstance(models_list, list):
            for idx, model_data in enumerate(models_list):
                if isinstance(model_data, dict):
                    env_overrides[idx] = model_data

            # Remove the environment variable to avoid Pydantic parsing errors
            del os.environ[root_key]

    return env_overrides


def _merge_models_with_env(
    base_models: list[GeminiModelConfig] | None,
    env_overrides: dict[int, dict[str, Any]],
):
    """Override base_models with env_overrides using standard update (replace whole fields)."""
    if not env_overrides:
        return base_models or []
    result_models: list[GeminiModelConfig] = []
    if base_models:
        result_models = [model.model_copy() for model in base_models]

    for idx in sorted(env_overrides):
        overrides = env_overrides[idx]
        if idx < len(result_models):
            # Update existing model: overwrite fields found in env
            model_dict = result_models[idx].model_dump()
            model_dict.update(overrides)
            result_models[idx] = GeminiModelConfig(**model_dict)
        elif idx == len(result_models):
            # Append new models
            new_model = GeminiModelConfig(**overrides)
            result_models.append(new_model)
        else:
            raise IndexError(
                f"Model index {idx} in env is out of range (current count: {len(result_models)}). "
                "Model indices must be contiguous starting from 0."
            )
    return result_models


def initialize_config() -> Config:
    """
    Initialize the configuration.

    Returns:
        Config: Configuration object
    """
    try:
        # First, extract and remove Gemini clients related environment variables
        env_clients_overrides = extract_gemini_clients_env()
        # Extract and remove Gemini models related environment variables
        env_models_overrides = extract_gemini_models_env()

        # Then, initialize Config with pydantic_settings
        config = Config()  # type: ignore

        # Synthesize clients
        config.gemini.clients = _merge_clients_with_env(
            config.gemini.clients, env_clients_overrides
        )

        # Synthesize models
        config.gemini.models = _merge_models_with_env(config.gemini.models, env_models_overrides)

        return config
    except ValidationError as e:
        logger.error(f"Configuration validation failed: {e!s}")
        sys.exit(1)