Skip to content

Commit 0f5c47e

Browse files
author
Mateusz
committed
refactor(config): replace mutable hydration with immutable ResolvedAppConfig and deprecate IConfig.set()
Architecture changes: - Introduce immutable ResolvedAppConfig for startup-derived values - Replace mutating hydrate_auto_append_first_prompt() with pure resolve functions that return values without modifying config - Refactor _prune_unavailable_routes() to store effective routes in runtime state via set_failover_route() instead of mutating app_config.failover_routes - Add DeprecationWarning to IConfig.set()/AppConfig.set() — callers should use model_copy(update=...) or ApplicationState instead - Remove post-load config mutation from load_config(), from_env(), and ConfigurationApplicator - Update RequestTransformPipeline to read resolved_app_config from runtime state with lazy fallback to resolve_app_config() - Populate app.state.resolved_app_config during application startup This is the first slice of a staged refactor toward immutable AppConfig + mutable ApplicationRuntime separation.
1 parent 2169114 commit 0f5c47e

13 files changed

Lines changed: 309 additions & 59 deletions

src/anthropic_server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ async def create_anthropic_app_async(
5151

5252
_register_anthropic_endpoints(app, prefix="")
5353
app.state.app_config = app_config
54+
try:
55+
from src.core.config.auto_append_first_prompt_hydration import (
56+
resolve_app_config,
57+
)
58+
59+
app.state.resolved_app_config = resolve_app_config(app_config)
60+
except Exception:
61+
app.state.resolved_app_config = None
5462

5563
# Register Codebuff WebSocket endpoint if enabled
5664
if app_config.codebuff.enabled:

src/core/app/application_builder.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,14 @@ def _create_fastapi_app(
442442
# Store essential state
443443
app.state.service_provider = service_provider
444444
app.state.app_config = config
445+
try:
446+
from src.core.config.auto_append_first_prompt_hydration import (
447+
resolve_app_config,
448+
)
449+
450+
app.state.resolved_app_config = resolve_app_config(config)
451+
except Exception:
452+
app.state.resolved_app_config = None
445453

446454
# Bridge application state service methods onto FastAPI state for compatibility
447455
try:

src/core/cli_support/configuration_applicator.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,6 @@ def apply_overrides(
196196

197197
# Validate and apply command prefix (ensures it's never None)
198198
final_cfg = self._validate_and_apply_prefix(final_cfg, validate_command_prefix)
199-
from src.core.config.auto_append_first_prompt_hydration import (
200-
hydrate_auto_append_first_prompt,
201-
)
202-
203-
hydrate_auto_append_first_prompt(final_cfg)
204199
return final_cfg
205200

206201
def _set_auxiliary_routing_base_config_disable(

src/core/config/app_config.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import os
5+
import warnings
56
from collections.abc import Mapping
67
from pathlib import Path
78
from typing import Any, cast
@@ -161,11 +162,6 @@ def from_env(
161162
loader = AppConfigLoader(backend_instances_dir=BACKEND_INSTANCES_DIR)
162163
model = loader.load(None, environ=env, resolution=res)
163164
cfg = cls.model_validate(model.model_dump())
164-
from src.core.config.auto_append_first_prompt_hydration import (
165-
hydrate_auto_append_first_prompt,
166-
)
167-
168-
hydrate_auto_append_first_prompt(cfg)
169165
return cfg
170166

171167
def get(self, key: str, default: Any = None) -> Any:
@@ -190,7 +186,19 @@ def get(self, key: str, default: Any = None) -> Any:
190186
return default
191187

192188
def set(self, key: str, value: Any) -> None:
193-
"""Set a configuration value (legacy convenience)."""
189+
"""Set a configuration value (legacy convenience).
190+
191+
.. deprecated::
192+
``IConfig.set()`` is deprecated. Use ``model_copy(update=...)`` on
193+
the immutable model or mutate runtime state via ``ApplicationState``
194+
instead.
195+
"""
196+
warnings.warn(
197+
"IConfig.set() is deprecated; use model_copy(update=...) or mutate "
198+
"runtime state via ApplicationState instead.",
199+
DeprecationWarning,
200+
stacklevel=2,
201+
)
194202
setattr(self, key, value)
195203

196204
def get_gcp_project_id(self) -> str | None:
@@ -214,11 +222,6 @@ def load_config(
214222

215223
# Return the legacy concrete type (subclass) for compatibility.
216224
cfg = AppConfig.model_validate(model.model_dump())
217-
from src.core.config.auto_append_first_prompt_hydration import (
218-
hydrate_auto_append_first_prompt,
219-
)
220-
221-
hydrate_auto_append_first_prompt(cfg)
222225
return cfg
223226

224227

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
1-
"""Load first-user-message append suffix from configured file at startup."""
1+
"""Resolve first-user-message append suffix from configured file at startup."""
22

33
from __future__ import annotations
44

55
import logging
66
from pathlib import Path
77
from typing import TYPE_CHECKING
88

9+
from src.core.config.models.resolved_app_config import ResolvedAppConfig
10+
911
if TYPE_CHECKING:
1012
from src.core.config.app_config import AppConfig
1113

1214
logger = logging.getLogger(__name__)
1315

1416

15-
def hydrate_auto_append_first_prompt(cfg: AppConfig) -> None:
16-
"""Read ``auto_append_first_prompt_filename`` into ``auto_append_first_prompt_text``.
17+
def resolve_auto_append_first_prompt_text(cfg: AppConfig) -> str | None:
18+
"""Resolve ``auto_append_first_prompt_filename`` into text content.
1719
18-
Clears ``auto_append_first_prompt_text`` when filename is unset. Raises
19-
``ValueError`` when a path is set but is not a readable regular file.
20+
Returns ``None`` when filename is unset. Raises ``ValueError`` when a path is
21+
set but is not a readable regular file.
2022
"""
23+
2124
raw = getattr(cfg, "auto_append_first_prompt_filename", None)
2225
if raw is None or not isinstance(raw, str) or not raw.strip():
23-
cfg.auto_append_first_prompt_text = None
24-
return
26+
return None
2527

2628
path = Path(raw.strip()).expanduser()
2729
resolved = path.resolve()
@@ -32,7 +34,6 @@ def hydrate_auto_append_first_prompt(cfg: AppConfig) -> None:
3234

3335
text = path.read_text(encoding="utf-8")
3436
stripped = text.strip()
35-
cfg.auto_append_first_prompt_text = stripped if stripped else None
3637

3738
if stripped:
3839
if logger.isEnabledFor(logging.INFO):
@@ -41,9 +42,20 @@ def hydrate_auto_append_first_prompt(cfg: AppConfig) -> None:
4142
len(stripped),
4243
resolved,
4344
)
44-
elif logger.isEnabledFor(logging.INFO):
45+
return stripped
46+
47+
if logger.isEnabledFor(logging.INFO):
4548
logger.info(
4649
"Auto-append first prompt: file %s is empty or whitespace-only; "
4750
"nothing will be appended",
4851
resolved,
4952
)
53+
return None
54+
55+
56+
def resolve_app_config(cfg: AppConfig) -> ResolvedAppConfig:
57+
"""Resolve startup-derived configuration values for runtime use."""
58+
59+
return ResolvedAppConfig(
60+
auto_append_first_prompt_text=resolve_auto_append_first_prompt_text(cfg)
61+
)

src/core/config/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from src.core.config.models.non_forwardable_config import NonForwardableTaggingConfig
2424
from src.core.config.models.notification import NotificationConfig
25+
from src.core.config.models.resolved_app_config import ResolvedAppConfig
2526
from src.core.config.models.rewriting import (
2627
EditPrecisionConfig,
2728
ModelAliasRule,
@@ -57,6 +58,7 @@
5758
"PlanningPhaseConfig",
5859
"CanonicalRequestProcessingConfig",
5960
"ReasoningModelTokenFloorConfig",
61+
"ResolvedAppConfig",
6062
"RewritingConfig",
6163
"ResilienceConfig",
6264
"RoutingConfig",

src/core/config/models/app_config_model.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
import warnings
45
from pathlib import Path
56
from typing import Any
67

@@ -195,5 +196,17 @@ def get(self, key: str, default: Any = None) -> Any:
195196
return default
196197

197198
def set(self, key: str, value: Any) -> None:
198-
"""Set a configuration value (legacy convenience)."""
199+
"""Set a configuration value (legacy convenience).
200+
201+
.. deprecated::
202+
``IConfig.set()`` is deprecated. Use ``model_copy(update=...)`` on
203+
the immutable model or mutate runtime state via ``ApplicationState``
204+
instead.
205+
"""
206+
warnings.warn(
207+
"IConfig.set() is deprecated; use model_copy(update=...) or mutate "
208+
"runtime state via ApplicationState instead.",
209+
DeprecationWarning,
210+
stacklevel=2,
211+
)
199212
setattr(self, key, value)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
from pydantic import ConfigDict
4+
5+
from src.core.interfaces.model_bases import DomainModel
6+
7+
8+
class ResolvedAppConfig(DomainModel):
9+
"""Startup-resolved configuration values derived from AppConfig."""
10+
11+
model_config = ConfigDict(frozen=True)
12+
13+
auto_append_first_prompt_text: str | None = None

src/core/persistence.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -541,22 +541,37 @@ def _parse_and_validate_failover_element(
541541
return f"{backend_name}:{model_name}", None
542542

543543
def _prune_unavailable_routes(self) -> None:
544+
"""Compute effective failover routes and store in runtime state.
545+
546+
This method no longer mutates ``app_config.failover_routes``. Instead
547+
it computes the subset of declared routes whose elements are all
548+
functional and stores them as effective routes in the application
549+
state service.
550+
"""
544551
if not self.app_state or not self.app_state.app_config:
545552
return
546553

547-
self.app_state.app_config.failover_routes = {
548-
name: route
549-
for name, route in self.app_state.app_config.failover_routes.items()
550-
if (
551-
getattr(route, "elements", None) if hasattr(route, "elements") else route.get("elements") # type: ignore[attr-defined]
554+
declared_routes = self.app_state.app_config.failover_routes
555+
for name, route in declared_routes.items():
556+
elements = (
557+
getattr(route, "elements", [])
558+
if hasattr(route, "elements")
559+
else route.get("elements", []) # type: ignore[attr-defined]
552560
)
553-
and all(
561+
if not elements:
562+
continue
563+
if not all(
554564
self.app_state.app_config.model_is_functional(element)
555-
for element in (
556-
getattr(route, "elements", []) if hasattr(route, "elements") else route.get("elements", []) # type: ignore[attr-defined]
557-
)
565+
for element in elements
566+
):
567+
continue
568+
self.app_state.set_failover_route(
569+
name,
570+
{
571+
"policy": getattr(route, "policy", "k"),
572+
"elements": list(elements),
573+
},
558574
)
559-
}
560575

561576
def _apply_failover_routes(self, froutes_value: Any) -> list[str]:
562577
warnings: list[str] = []

src/core/services/request_transform_pipeline.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,10 @@ async def _apply_auto_append_first_user_suffix(
288288
) and context.extensions.get("auxiliary_request"):
289289
return request
290290

291-
app_config = self._get_app_config()
291+
resolved_app_config = self._get_resolved_app_config()
292292
suffix_raw = (
293-
getattr(app_config, "auto_append_first_prompt_text", None)
294-
if app_config is not None
293+
getattr(resolved_app_config, "auto_append_first_prompt_text", None)
294+
if resolved_app_config is not None
295295
else None
296296
)
297297
suffix = str(suffix_raw).strip() if suffix_raw is not None else ""
@@ -455,6 +455,31 @@ def _get_app_config(self) -> Any | None:
455455
except (AttributeError, KeyError, TypeError):
456456
return None
457457

458+
def _get_resolved_app_config(self) -> Any | None:
459+
if self._app_state is None:
460+
return None
461+
462+
try:
463+
resolved = self._app_state.get_setting("resolved_app_config")
464+
except (AttributeError, KeyError, TypeError):
465+
resolved = None
466+
467+
if resolved is not None:
468+
return resolved
469+
470+
app_config = self._get_app_config()
471+
if app_config is None:
472+
return None
473+
474+
try:
475+
from src.core.config.auto_append_first_prompt_hydration import (
476+
resolve_app_config,
477+
)
478+
479+
return resolve_app_config(app_config)
480+
except Exception:
481+
return None
482+
458483
def _get_session_state(self, session: object) -> Any | None:
459484
try:
460485
return getattr(session, "state", None)

0 commit comments

Comments
 (0)