diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e967ae8 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and fill in. .env is gitignored. +OPENROUTER_API_KEY= + +# Prove-budget and `ousast index` embeddings via OpenRouter /embeddings. +OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small diff --git a/src/openultrasast/cli.py b/src/openultrasast/cli.py index 18b5345..a243fe4 100644 --- a/src/openultrasast/cli.py +++ b/src/openultrasast/cli.py @@ -29,7 +29,7 @@ from .calibration import ( ) from .complexity import map as complexity_map from .complexity.ledger import persist_verdicts -from .config import load_config +from .config import load_config, load_dotenv from .findings import StaticFinding, quick_scan_findings, write_findings from .fusion import FusionDecision, fuse_findings_dispatch from .gate import FALSE_POSITIVE_CEILING, RECALL_FLOOR @@ -83,6 +83,7 @@ class ScanOutcome: def main(argv: list[str] | None = None) -> int: + load_dotenv() parser = argparse.ArgumentParser(prog="ousast") subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/src/openultrasast/config.py b/src/openultrasast/config.py index d095a3a..3b2904d 100644 --- a/src/openultrasast/config.py +++ b/src/openultrasast/config.py @@ -9,6 +9,24 @@ from pathlib import Path DEFAULT_VECTOR_STORE = "json-local" +def load_dotenv(path: Path | None = None, *, force: bool = False) -> None: + """Load KEY=VALUE lines from .env into os.environ. Stdlib only; never overrides.""" + if not force and (os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("OPENULTRASAST_SKIP_DOTENV")): + return + env_path = path if path is not None else Path(".env") + if not env_path.is_file(): + return + for raw in env_path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'").strip('"') + if key and key not in os.environ: + os.environ[key] = value + + @dataclass(frozen=True) class ModelConfig: ranker: str | None = None @@ -129,6 +147,7 @@ class ResolvedConfig: def load_config(config_path: Path | None = None) -> ResolvedConfig: + load_dotenv() data: dict[str, object] = {} if config_path is not None and config_path.exists(): with config_path.open("rb") as handle: @@ -177,7 +196,10 @@ def _load_models(value: object) -> ModelConfig: def _load_embeddings(value: object) -> EmbeddingConfig: data = _section(value) - return EmbeddingConfig(model=_string(data.get("model")), store=_string(data.get("store")) or DEFAULT_VECTOR_STORE) + return EmbeddingConfig( + model=_string(data.get("model")) or _string(os.environ.get("OPENROUTER_EMBEDDING_MODEL")), + store=_string(data.get("store")) or DEFAULT_VECTOR_STORE, + ) def _load_sandbox(value: object) -> SandboxConfig: diff --git a/tests/test_config.py b/tests/test_config.py index c42ed54..6df0ac2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ +import os from pathlib import Path -from openultrasast.config import load_config, write_resolved_config +from openultrasast.config import load_config, load_dotenv, write_resolved_config def test_load_config_reads_toml(tmp_path: Path) -> None: @@ -159,3 +160,21 @@ def test_load_config_reads_regress_image_pins(tmp_path: Path) -> None: assert config.complexity.top_k == 20 assert config.complexity.max_hunter_hotspots == 8 assert config.sandbox.memory_mb == 2048 + + +def test_load_dotenv_sets_missing_keys_only(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def] + env_file = tmp_path / ".env" + env_file.write_text("OPENROUTER_API_KEY=from-file\nOPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small\n") + monkeypatch.setenv("OPENROUTER_API_KEY", "already-set") + monkeypatch.delenv("OPENROUTER_EMBEDDING_MODEL", raising=False) + + load_dotenv(env_file, force=True) + + assert os.environ["OPENROUTER_API_KEY"] == "already-set" + assert os.environ["OPENROUTER_EMBEDDING_MODEL"] == "openai/text-embedding-3-small" + + +def test_embeddings_model_from_env(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small") + config = load_config(None) + assert config.embeddings.model == "openai/text-embedding-3-small"