From 14c3931e86b70002cbe7ce2b32fa854802a6db9f Mon Sep 17 00:00:00 2001 From: unknown <2529204828@qq.com> Date: Thu, 3 Sep 2026 20:47:50 +0800 Subject: [PATCH] fix: update validation to accept minimal server environment without inference models --- .../how-to/configure-server-environment.md | 4 +- .../how-to/configure-server-environment.md | 3 +- src/powercontext/builtin/runtime/__init__.py | 2 + .../builtin/runtime/composition.py | 25 ++++- src/powercontext/cli/config.py | 61 ++++++++--- tests/test_config_cli.py | 102 +++++++++++++++++- 6 files changed, 176 insertions(+), 21 deletions(-) diff --git a/docs/en/docs/how-to/configure-server-environment.md b/docs/en/docs/how-to/configure-server-environment.md index c2f85fabe..a3f3fa4d9 100644 --- a/docs/en/docs/how-to/configure-server-environment.md +++ b/docs/en/docs/how-to/configure-server-environment.md @@ -23,8 +23,8 @@ powercontext config show --env-file .env powercontext config validate --env-file .env ``` -`config show` redacts recognized credentials. Validation checks the document and the selected model settings without -printing secrets. +`config show` redacts recognized credentials. Validation accepts minimal Server-only files; when inference models or +inference-dependent runtime features are configured, it also checks the Runtime composition without printing secrets. ## 3. Run the same configuration diff --git a/docs/zh/docs/how-to/configure-server-environment.md b/docs/zh/docs/how-to/configure-server-environment.md index 6e8f690bd..631337c88 100644 --- a/docs/zh/docs/how-to/configure-server-environment.md +++ b/docs/zh/docs/how-to/configure-server-environment.md @@ -22,7 +22,8 @@ powercontext config show --env-file .env powercontext config validate --env-file .env ``` -`config show` 会隐藏已识别的凭据。校验会检查文件和所选 model 设置,但不会输出机密。 +`config show` 会隐藏已识别的凭据。校验接受只包含 Server 设置的最小环境文件;配置 inference model 或依赖 inference +的 Runtime 功能时,还会检查 Runtime 组装,但不会输出机密。 ## 3. 使用同一份配置启动 diff --git a/src/powercontext/builtin/runtime/__init__.py b/src/powercontext/builtin/runtime/__init__.py index a0e9b4bda..1be806514 100644 --- a/src/powercontext/builtin/runtime/__init__.py +++ b/src/powercontext/builtin/runtime/__init__.py @@ -68,6 +68,7 @@ BuiltinConfigurationError, open_builtin_contexts, open_builtin_runtime, + preflight_builtin_runtime, ) from powercontext.builtin.runtime.config import ( BuiltinConfig, @@ -297,4 +298,5 @@ "dependency_readiness_probe", "open_builtin_contexts", "open_builtin_runtime", + "preflight_builtin_runtime", ] diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index f3713d6d2..4242be3a8 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -653,6 +653,29 @@ async def probe_rerank() -> None: ) +async def preflight_builtin_runtime(config: BuiltinConfig) -> None: + """Validate Runtime composition without opening persistence or making requests.""" + + async with AsyncExitStack() as resources: + await _generation_pipelines( + config.inference, + config.runtime, + resources, + None, + BUILTIN_SOURCE_REGISTRY, + ) + if config.inference.embedding_model is not None: + await _embedding_models(config.inference, resources, None) + if config.runtime.schedule_seconds is not None and config.inference.generation_model is None: + raise BuiltinConfigurationError("scheduled-pipeline") + if config.runtime.experience_schedule_seconds is not None and config.inference.generation_model is None: + raise BuiltinConfigurationError("scheduled-experience-pipeline") + if config.runtime.memory_rerank_enabled and ( + config.inference.generation_model is None and config.inference.rerank_model is None + ): + raise BuiltinConfigurationError("memory-reranker") + + async def _open_pydantic_ai_model( model_name: str, *, @@ -849,4 +872,4 @@ def _search_modes(capabilities: MemoryCapabilities) -> tuple[MemorySearchMode, . return tuple(modes) -__all__ = ["BuiltinConfigurationError", "open_builtin_contexts", "open_builtin_runtime"] +__all__ = ["BuiltinConfigurationError", "open_builtin_contexts", "open_builtin_runtime", "preflight_builtin_runtime"] diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index 5a7a1ddf5..63c5fbd35 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -28,7 +28,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Annotated, Never +from typing import TYPE_CHECKING, Annotated, Never from urllib.parse import urlsplit import typer @@ -36,6 +36,9 @@ from powercontext.cli.env_file import EnvironmentFileError, parse_environment +if TYPE_CHECKING: + from powercontext.server.settings import ServerSettings + HELP_OPTION_NAMES = ("-h", "--help") MANAGED_BEGIN = "# >>> powercontext managed configuration >>>" MANAGED_END = "# <<< powercontext managed configuration <<<" @@ -287,13 +290,16 @@ def show_command( def validate_command( env_file: Annotated[Path, typer.Option(help="Environment file to validate.")] = Path(".env"), ) -> None: - """Validate syntax, model configuration, and Server settings.""" + """Validate syntax, configured model adapters, and Server settings.""" try: content = env_file.read_text(encoding="utf-8") values = parse_environment(content, source=str(env_file)) - configuration = configuration_from_document(content) - _validate_operational_configuration(configuration, values=values) + if _has_complete_generated_configuration(values) or _managed_metadata(content): + configuration = configuration_from_document(content) + validate_configuration(configuration) + _validate_server_settings(values) + _validate_builtin_runtime(values) except (ConfigError, EnvironmentFileError, OSError, UnicodeError, ValidationError) as error: _fail(str(error)) typer.echo(f"Configuration is valid: {env_file.resolve()}") @@ -499,23 +505,50 @@ def _validate_operational_configuration( validate_configuration(configuration) rendered = render_environment(configuration) if values is None else dict(values) _validate_server_settings(rendered) - _validate_provider_models(configuration, rendered) + _validate_builtin_runtime(rendered) -def _validate_provider_models(configuration: GeneratedConfiguration, values: Mapping[str, str]) -> None: - with _temporary_environment(values, clear=set()): +def _validate_builtin_runtime(values: Mapping[str, str]) -> None: + server_environment = {name for name in os.environ if name.startswith("POWERCONTEXT_SERVER_")} + with _temporary_environment(values, clear=server_environment): try: - asyncio.run(_construct_provider_models(configuration)) + settings = _server_settings_from_environment() + from powercontext.builtin.runtime.composition import preflight_builtin_runtime + from powercontext.builtin.runtime.config import BuiltinConfig + + asyncio.run( + preflight_builtin_runtime( + BuiltinConfig( + runtime=settings.runtime, + database=settings.database, + handoff_report=settings.handoff_report, + inference=settings.inference, + external_skills=settings.external_skills, + ) + ) + ) + except ConfigError: + raise except Exception as error: - raise ConfigError(f"provider models cannot be configured: {error}") from error # noqa: TRY003 + raise ConfigError(f"built-in runtime cannot be configured: {error}") from error # noqa: TRY003 -async def _construct_provider_models(configuration: GeneratedConfiguration) -> None: - from pydantic_ai.embeddings import infer_embedding_model - from pydantic_ai.models import infer_model +def _server_settings_from_environment() -> ServerSettings: + from powercontext.server.settings import ServerSettings + + return ServerSettings() - async with infer_model(configuration.generation.model): - infer_embedding_model(configuration.embedding.model) + +def _has_complete_generated_configuration(values: Mapping[str, str]) -> bool: + return all( + name in values + for name in ( + "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL", + "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL", + "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_PROFILE_ID", + "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_DIMENSION", + ) + ) def write_environment(path: Path, content: str, *, backup: bool) -> Path | None: diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index 4c0b1658f..31921e899 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -21,6 +21,7 @@ from typer.testing import CliRunner import powercontext.cli.config as config_cli +from powercontext.server.configuration import server_settings_context def test_init_asks_for_protocol_endpoint_key_and_plain_model_name(tmp_path: Path) -> None: @@ -120,6 +121,101 @@ def test_init_validate_and_show_round_trip_managed_environment( assert "initial-secret" not in shown.output +def test_validate_accepts_minimal_server_environment_without_inference_models(tmp_path: Path) -> None: + environment = tmp_path / "server.env" + environment.write_text( + "\n".join(( + "POWERCONTEXT_SERVER_DATABASE_KIND=seekdb", + "POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1", + "POWERCONTEXT_SERVER_HTTP_PORT=8888", + "", + )), + encoding="utf-8", + ) + + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + + assert result.exit_code == 0 + assert "Configuration is valid" in result.output + with server_settings_context(env_file=environment) as settings: + assert settings.database.kind == "seekdb" + assert settings.http.host == "127.0.0.1" + assert settings.http.port == 8888 + + +@pytest.mark.parametrize( + "runtime_setting", + ( + "POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS=60", + "POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS=60", + "POWERCONTEXT_SERVER_RUNTIME_MEMORY_RERANK_ENABLED=true", + ), +) +def test_validate_rejects_runtime_features_without_required_inference( + runtime_setting: str, + tmp_path: Path, +) -> None: + environment = tmp_path / "server.env" + environment.write_text(f"{runtime_setting}\n", encoding="utf-8") + + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + + assert result.exit_code == 2 + assert "built-in runtime cannot be configured" in result.output + + +def test_validate_uses_runtime_provider_factory_for_custom_headers(tmp_path: Path) -> None: + environment = tmp_path / "server.env" + environment.write_text( + "\n".join(( + "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=openai-chat:test-model", + "POWERCONTEXT_SERVER_INFERENCE_GENERATION_BASE_URL=https://provider.example/v1", + 'POWERCONTEXT_SERVER_INFERENCE_GENERATION_HEADERS=\'{"Authorization":"Bearer test"}\'', + "", + )), + encoding="utf-8", + ) + + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + + assert result.exit_code == 0 + + +def test_validate_uses_runtime_provider_factory_for_active_reranking(tmp_path: Path) -> None: + environment = tmp_path / "server.env" + environment.write_text( + "\n".join(( + "POWERCONTEXT_SERVER_RUNTIME_MEMORY_RERANK_ENABLED=true", + "POWERCONTEXT_SERVER_INFERENCE_RERANK_MODEL=openai-chat:rerank-model", + "POWERCONTEXT_SERVER_INFERENCE_RERANK_BASE_URL=https://provider.example/v1", + 'POWERCONTEXT_SERVER_INFERENCE_RERANK_HEADERS=\'{"Authorization":"Bearer test"}\'', + "", + )), + encoding="utf-8", + ) + + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + + assert result.exit_code == 0 + + +def test_validate_rejects_unsupported_custom_endpoint_provider(tmp_path: Path) -> None: + environment = tmp_path / "server.env" + environment.write_text( + "\n".join(( + "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=deepseek:deepseek-chat", + "POWERCONTEXT_SERVER_INFERENCE_GENERATION_BASE_URL=https://provider.example/v1", + "", + )), + encoding="utf-8", + ) + + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + + assert result.exit_code == 2 + assert "custom inference endpoints require an OpenAI- or Anthropic-compatible model identifier" in result.output + + def test_init_rejects_configuration_that_validation_rejects( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -148,7 +244,7 @@ def test_init_rejects_provider_models_that_cannot_be_constructed( result = CliRunner().invoke(config_cli.app, ["init", "--output", str(environment)]) assert result.exit_code == 2 - assert "provider models cannot be configured" in result.output + assert "built-in runtime cannot be configured" in result.output assert not environment.exists() @@ -284,7 +380,7 @@ def test_init_records_generated_credential_names_for_show_redaction( credentials=("SERVICE_CREDENTIAL",), ) monkeypatch.setattr(config_cli, "collect_configuration", lambda **_kwargs: configuration) - monkeypatch.setattr(config_cli, "_validate_provider_models", lambda *_args, **_kwargs: None) + monkeypatch.setattr(config_cli, "_validate_builtin_runtime", lambda *_args, **_kwargs: None) generated = CliRunner().invoke(config_cli.app, ["init", "--output", str(environment)], input="\n") text = environment.read_text(encoding="utf-8") @@ -376,7 +472,7 @@ def test_init_hides_and_redacts_marked_additional_credentials( ) -> None: environment = tmp_path / ".env" monkeypatch.setattr(config_cli, "_select_value", lambda *_args, **_kwargs: "custom") - monkeypatch.setattr(config_cli, "_validate_provider_models", lambda *_args, **_kwargs: None) + monkeypatch.setattr(config_cli, "_validate_builtin_runtime", lambda *_args, **_kwargs: None) result = CliRunner().invoke( config_cli.app,