Resolve the OpenRouter key from OpenBao before the environment
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 7s

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
This commit is contained in:
tegwick 2026-10-08 19:35:00 +02:00
parent 965c4b3386
commit e1ca67e509
4 changed files with 173 additions and 11 deletions

View file

@ -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/<file>.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 "<image-model>" \

View file

@ -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

View file

@ -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 []

82
tests/test_openbao_key.py Normal file
View file

@ -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"