From e1ca67e5093df0edbb15e1bed0308355f4ce6723 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 19:35:00 +0200 Subject: [PATCH] Resolve the OpenRouter key from OpenBao before the environment auto tries platform/suppliers/openrouter/llm-connect, then the live activity-core workload lane, then OPENROUTER_API_KEY. A denied token falls back to the environment. LLM_CONNECT_KEY_SOURCE selects env or openbao alone. Assistant: grok Assistant-Session: 01a11ab6-1534-7d63-8606-662b92a80ed6 --- README.md | 4 +- contracts/functional/images.md | 8 ++- llm_connect/config.py | 90 +++++++++++++++++++++++++++++++--- tests/test_openbao_key.py | 82 +++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 tests/test_openbao_key.py diff --git a/README.md b/README.md index a79f864..c0da0cb 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ llm-connect balance --json # Which model and quote: docs/image-model-selection.md # Render the one hall entry that is about to be published. Do not backfill the corpus. llm-connect image render-hall --hall ../hall-of-helix --entry entries/.md -# Receipt JSON on stdout; image bytes in --output. Credentials stay in the env. +# Receipt JSON on stdout; image bytes in --output. +# The OpenRouter key is read from OpenBao when the caller token can, then +# from OPENROUTER_API_KEY. LLM_CONNECT_KEY_SOURCE=openbao uses OpenBao only. llm-connect image render \ --prompt "precise technical illustration of a gear, no text" \ --model "" \ diff --git a/contracts/functional/images.md b/contracts/functional/images.md index 4d7f6e3..feb6b7c 100644 --- a/contracts/functional/images.md +++ b/contracts/functional/images.md @@ -39,8 +39,12 @@ requests share one gate for the life of the server process. OpenRouter only, via `POST {api_base}/images` (default `https://openrouter.ai/api/v1/images`). The request sends `n=1`, `aspect_ratio=1:1`, an explicit pixel `size`, and `output_format`. The key -comes from the explicit argument, `OPENROUTER_API_KEY`, or -`apikey-openrouter.txt`. Only `b64_json` is accepted. A URL in the response +comes from the explicit argument, OpenBao +(`platform/suppliers/openrouter/llm-connect`, then the live workload lane +`platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets`), +`OPENROUTER_API_KEY`, or `apikey-openrouter.txt`. Set +`LLM_CONNECT_KEY_SOURCE=env` to skip OpenBao, or `openbao` to use it alone. +Only `b64_json` is accepted. A URL in the response is not fetched. There is no default image model on `image render`. `image render-hall` renders diff --git a/llm_connect/config.py b/llm_connect/config.py index 61a40c3..84239d8 100644 --- a/llm_connect/config.py +++ b/llm_connect/config.py @@ -3,6 +3,8 @@ LLM configuration and API key resolution. """ import os +import re +import subprocess from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -33,6 +35,24 @@ class LLMConfig: extra: dict[str, Any] = field(default_factory=dict) +# Future supplier custody, then the live llm-connect workload lane. +_OPENROUTER_BAO_CANDIDATES: tuple[tuple[str, str], ...] = ( + ("platform/suppliers/openrouter/llm-connect", "OPENROUTER_API_KEY"), + ( + "platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets", + "OPENROUTER_API_KEY", + ), +) +_BAO_PATH_RE = re.compile(r"[A-Za-z0-9_./-]+") +_BAO_FIELD_RE = re.compile(r"[A-Za-z0-9_]+") +_bao_misses: set[tuple[str, str]] = set() + + +def clear_openbao_key_cache() -> None: + """Forget OpenBao misses. Tests use this so a later stub can succeed.""" + _bao_misses.clear() + + def resolve_api_key( explicit: str | None = None, env_var: str = "OPENROUTER_API_KEY", @@ -40,20 +60,31 @@ def resolve_api_key( ) -> str | None: """Return an API key from the first available source. - Resolution order: - 1. *explicit* argument (passed directly by caller) - 2. Environment variable *env_var* - 3. First readable file in *key_file_paths* whose content is non-empty + An explicit argument always wins. Otherwise ``LLM_CONNECT_KEY_SOURCE`` + selects the order. ``auto`` (the default) tries OpenBao, then the + environment, then a key file. ``env`` skips OpenBao. ``openbao`` uses + OpenBao only. - Returns ``None`` if no key can be found. + OpenBao is read with ``bao kv get -field=...``. A missing binary, a + denied token, or an empty field is skipped. The value is not logged. """ if explicit: return explicit + source = os.environ.get("LLM_CONNECT_KEY_SOURCE", "auto").strip().lower() + if source not in {"auto", "env", "openbao"}: + source = "auto" + if source == "openbao": + return _openbao_api_key(env_var) + if source == "env": + return _env_or_file_api_key(env_var, key_file_paths) + return _openbao_api_key(env_var) or _env_or_file_api_key(env_var, key_file_paths) + + +def _env_or_file_api_key(env_var: str, key_file_paths: list[Path] | None) -> str | None: from_env = os.environ.get(env_var) if from_env: return from_env.strip() - for path in key_file_paths or []: try: text = path.read_text().strip() @@ -61,10 +92,53 @@ def resolve_api_key( return text except OSError: continue - return None +def _openbao_api_key(env_var: str) -> str | None: + """Read one field from OpenBao. Return ``None`` when nothing is readable.""" + if env_var != "OPENROUTER_API_KEY": + return None + candidates: list[tuple[str, str]] = [] + custom = os.environ.get("LLM_CONNECT_OPENROUTER_BAO_PATH", "").strip() + if custom: + field = os.environ.get("LLM_CONNECT_OPENROUTER_BAO_FIELD", "OPENROUTER_API_KEY").strip() + candidates.append((custom, field)) + candidates.extend(_OPENROUTER_BAO_CANDIDATES) + for path, field in candidates: + value = _bao_kv_field(path, field) + if value: + return value + return None + + +def _bao_kv_field(path: str, field: str) -> str | None: + if not _BAO_PATH_RE.fullmatch(path) or not _BAO_FIELD_RE.fullmatch(field): + return None + key = (path, field) + if key in _bao_misses: + return None + try: + proc = subprocess.run( + ["bao", "kv", "get", f"-field={field}", path], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + _bao_misses.add(key) + return None + if proc.returncode != 0: + _bao_misses.add(key) + return None + value = proc.stdout.strip() + if not value: + _bao_misses.add(key) + return None + return value + + def find_project_root(start: Path | None = None) -> Path | None: """Walk up from *start* (default CWD) looking for ``pyproject.toml``. @@ -86,7 +160,7 @@ def load_config( """Build an :class:`LLMConfig` with sensible defaults. For the ``openrouter`` provider the API key is resolved via - :func:`resolve_api_key` (env var → project-root key file). + :func:`resolve_api_key` (OpenBao, then env var, then project-root key file). """ root = find_project_root() key_file_paths = [root / "apikey-openrouter.txt"] if root else [] diff --git a/tests/test_openbao_key.py b/tests/test_openbao_key.py new file mode 100644 index 0000000..375c54d --- /dev/null +++ b/tests/test_openbao_key.py @@ -0,0 +1,82 @@ +"""OpenRouter key resolution from OpenBao, then the environment.""" + +import subprocess +from pathlib import Path + +from llm_connect.config import clear_openbao_key_cache, resolve_api_key + + +def _bao(stdout: str = "", code: int = 0): + def run(argv, **kwargs): + return subprocess.CompletedProcess(argv, code, stdout=stdout, stderr="") + + return run + + +def test_explicit_key_skips_openbao(monkeypatch) -> None: + monkeypatch.setattr("llm_connect.config.subprocess.run", _bao("from-bao")) + clear_openbao_key_cache() + assert resolve_api_key(explicit="caller-key") == "caller-key" + + +def test_auto_prefers_openbao_over_the_environment(monkeypatch) -> None: + monkeypatch.setenv("LLM_CONNECT_KEY_SOURCE", "auto") + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + monkeypatch.setattr("llm_connect.config.subprocess.run", _bao("from-bao\n")) + clear_openbao_key_cache() + assert resolve_api_key() == "from-bao" + + +def test_denied_openbao_falls_back_to_the_environment(monkeypatch) -> None: + monkeypatch.setenv("LLM_CONNECT_KEY_SOURCE", "auto") + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + monkeypatch.setattr("llm_connect.config.subprocess.run", _bao(code=1)) + clear_openbao_key_cache() + assert resolve_api_key() == "from-env" + + +def test_openbao_source_ignores_the_environment(monkeypatch) -> None: + monkeypatch.setenv("LLM_CONNECT_KEY_SOURCE", "openbao") + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + monkeypatch.setattr("llm_connect.config.subprocess.run", _bao(code=1)) + clear_openbao_key_cache() + assert resolve_api_key() is None + + +def test_env_source_does_not_call_openbao(monkeypatch) -> None: + monkeypatch.setenv("LLM_CONNECT_KEY_SOURCE", "env") + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + + def fail(*args, **kwargs): + raise AssertionError("bao should not be called") + + monkeypatch.setattr("llm_connect.config.subprocess.run", fail) + clear_openbao_key_cache() + assert resolve_api_key() == "from-env" + + +def test_custom_path_is_tried_before_the_defaults(monkeypatch) -> None: + seen: list[str] = [] + + def run(argv, **kwargs): + seen.append(argv[-1]) + if argv[-1] == "platform/suppliers/openrouter/custom": + return subprocess.CompletedProcess(argv, 0, stdout="custom-key\n", stderr="") + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="") + + monkeypatch.setenv("LLM_CONNECT_KEY_SOURCE", "openbao") + monkeypatch.setenv("LLM_CONNECT_OPENROUTER_BAO_PATH", "platform/suppliers/openrouter/custom") + monkeypatch.setattr("llm_connect.config.subprocess.run", run) + clear_openbao_key_cache() + assert resolve_api_key() == "custom-key" + assert seen[0] == "platform/suppliers/openrouter/custom" + + +def test_key_file_is_used_when_openbao_and_env_are_empty(tmp_path: Path, monkeypatch) -> None: + key_file = tmp_path / "apikey-openrouter.txt" + key_file.write_text("file-key\n", encoding="utf-8") + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setenv("LLM_CONNECT_KEY_SOURCE", "auto") + monkeypatch.setattr("llm_connect.config.subprocess.run", _bao(code=1)) + clear_openbao_key_cache() + assert resolve_api_key(key_file_paths=[key_file]) == "file-key"