Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/en/docs/how-to/configure-server-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/zh/docs/how-to/configure-server-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 使用同一份配置启动

Expand Down
2 changes: 2 additions & 0 deletions src/powercontext/builtin/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
BuiltinConfigurationError,
open_builtin_contexts,
open_builtin_runtime,
preflight_builtin_runtime,
)
from powercontext.builtin.runtime.config import (
BuiltinConfig,
Expand Down Expand Up @@ -297,4 +298,5 @@
"dependency_readiness_probe",
"open_builtin_contexts",
"open_builtin_runtime",
"preflight_builtin_runtime",
]
25 changes: 24 additions & 1 deletion src/powercontext/builtin/runtime/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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"]
61 changes: 47 additions & 14 deletions src/powercontext/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,17 @@
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
from pydantic import ValidationError

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 <<<"
Expand Down Expand Up @@ -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()}")
Expand Down Expand Up @@ -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:
Expand Down
102 changes: 99 additions & 3 deletions tests/test_config_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
Loading