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
This commit is contained in:
parent
965c4b3386
commit
e1ca67e509
4 changed files with 173 additions and 11 deletions
|
|
@ -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>" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
82
tests/test_openbao_key.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue