4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
481 lines
16 KiB
Python
481 lines
16 KiB
Python
"""Prompt-safe LLM auth status and request-scoped credential resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
|
|
from config.llm_auth.provider_catalog import (
|
|
API_KEY_PROVIDER_ENVS,
|
|
ProviderSpec,
|
|
provider_spec,
|
|
require_provider_spec,
|
|
)
|
|
from config.llm_auth.records import (
|
|
delete_provider_auth_record,
|
|
resolve_provider_auth_record,
|
|
save_provider_auth_record,
|
|
save_provider_auth_record_values,
|
|
)
|
|
|
|
CredentialSource = Literal[
|
|
"env",
|
|
"keyring",
|
|
"metadata",
|
|
"cli",
|
|
"ambient",
|
|
"local",
|
|
"none",
|
|
"unknown",
|
|
]
|
|
|
|
|
|
class MissingLLMCredentialError(RuntimeError):
|
|
"""Raised when a selected provider lacks request-time credentials."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CredentialStatus:
|
|
"""Prompt-safe status for a provider auth path."""
|
|
|
|
provider: str
|
|
configured: bool
|
|
source: CredentialSource
|
|
verified: bool
|
|
stale: bool
|
|
detail: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CredentialResolution:
|
|
"""Request-time credential resolution for one provider."""
|
|
|
|
provider: str
|
|
api_key: str
|
|
source: CredentialSource
|
|
detail: str
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return bool(self.api_key) or self.source in {"cli", "ambient", "local"}
|
|
|
|
def __repr__(self) -> str:
|
|
redacted = "<set>" if self.api_key else "<empty>"
|
|
return (
|
|
"CredentialResolution("
|
|
f"provider={self.provider!r}, api_key={redacted}, "
|
|
f"source={self.source!r}, detail={self.detail!r})"
|
|
)
|
|
|
|
|
|
def _bool_record_value(record: dict[str, str], key: str, default: bool) -> bool:
|
|
raw = record.get(key)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _normalize_source(raw: str | None, *, fallback: CredentialSource) -> CredentialSource:
|
|
value = (raw or "").strip().lower()
|
|
allowed = {"env", "keyring", "metadata", "cli", "ambient", "local", "none", "unknown"}
|
|
return value if value in allowed else fallback # type: ignore[return-value]
|
|
|
|
|
|
def _env_value(env_var: str) -> str:
|
|
return os.getenv(env_var, "").strip()
|
|
|
|
|
|
def _source_status(provider: str, source: CredentialSource, detail: str) -> CredentialStatus:
|
|
return CredentialStatus(
|
|
provider=provider,
|
|
configured=source not in {"none", "unknown"},
|
|
source=source,
|
|
verified=source not in {"metadata", "unknown"},
|
|
stale=False,
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
def _record_status(spec: ProviderSpec, record: dict[str, str]) -> CredentialStatus:
|
|
source = _normalize_source(record.get("source"), fallback="metadata")
|
|
stale = _bool_record_value(record, "stale", False)
|
|
verified = _bool_record_value(record, "verified", not stale)
|
|
detail = record.get("detail") or (
|
|
f"{spec.api_key_env} was previously saved; run `opensre auth verify {spec.value}` "
|
|
"to confirm it is still available."
|
|
)
|
|
return CredentialStatus(
|
|
provider=spec.value,
|
|
configured=True,
|
|
source=source if source != "keyring" else "metadata",
|
|
verified=verified and not stale,
|
|
stale=stale,
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
def status(provider: str) -> CredentialStatus:
|
|
"""Return prompt-safe provider auth status.
|
|
|
|
This function must not read Keychain secrets. It may inspect environment,
|
|
non-secret metadata, CLI adapter probes, and ambient/local config markers.
|
|
"""
|
|
spec = provider_spec(provider)
|
|
if spec is None:
|
|
return CredentialStatus(
|
|
provider=provider.strip().lower(),
|
|
configured=False,
|
|
source="unknown",
|
|
verified=False,
|
|
stale=False,
|
|
detail=f"Unsupported LLM provider: {provider}",
|
|
)
|
|
|
|
if spec.credential_kind == "api_key":
|
|
if spec.api_key_env and _env_value(spec.api_key_env):
|
|
return _source_status(
|
|
spec.value,
|
|
"env",
|
|
f"{spec.api_key_env} is set in the environment.",
|
|
)
|
|
record = resolve_provider_auth_record(spec.value)
|
|
if record:
|
|
return _record_status(spec, record)
|
|
return CredentialStatus(
|
|
provider=spec.value,
|
|
configured=False,
|
|
source="none",
|
|
verified=False,
|
|
stale=False,
|
|
detail=f"{spec.api_key_env} is not configured.",
|
|
)
|
|
|
|
if spec.credential_kind == "cli":
|
|
record = resolve_provider_auth_record(spec.value)
|
|
record_source = _normalize_source(record.get("source"), fallback="metadata")
|
|
stale = _bool_record_value(record, "stale", False)
|
|
verified = _bool_record_value(record, "verified", False)
|
|
if record and verified and not stale:
|
|
return CredentialStatus(
|
|
provider=spec.value,
|
|
configured=True,
|
|
source="cli" if record_source in {"metadata", "unknown", "none"} else record_source,
|
|
verified=True,
|
|
stale=False,
|
|
detail=record.get("detail") or f"{spec.label} auth metadata is present.",
|
|
)
|
|
try:
|
|
from integrations.llm_cli.registry import get_cli_provider_registration
|
|
|
|
reg = get_cli_provider_registration(spec.value)
|
|
if reg is None:
|
|
return CredentialStatus(
|
|
spec.value,
|
|
False,
|
|
"none",
|
|
False,
|
|
False,
|
|
"No CLI adapter registered.",
|
|
)
|
|
probe = reg.adapter_factory().detect()
|
|
except Exception as exc:
|
|
return CredentialStatus(
|
|
spec.value,
|
|
False,
|
|
"unknown",
|
|
False,
|
|
False,
|
|
f"CLI auth status could not be checked: {exc}",
|
|
)
|
|
configured = probe.installed and probe.logged_in is True
|
|
source: CredentialSource = "cli" if configured else "none"
|
|
return CredentialStatus(
|
|
provider=spec.value,
|
|
configured=configured,
|
|
source=source,
|
|
verified=configured,
|
|
stale=False,
|
|
detail=probe.detail,
|
|
)
|
|
|
|
if spec.credential_kind == "ambient":
|
|
region = os.getenv("AWS_REGION", "").strip() or os.getenv("AWS_DEFAULT_REGION", "").strip()
|
|
return CredentialStatus(
|
|
provider=spec.value,
|
|
configured=bool(region),
|
|
source="ambient" if region else "none",
|
|
verified=bool(region),
|
|
stale=False,
|
|
detail=(
|
|
f"AWS region is configured ({region}); Bedrock uses the AWS credential chain."
|
|
if region
|
|
else "AWS_REGION or AWS_DEFAULT_REGION is not set."
|
|
),
|
|
)
|
|
|
|
if spec.credential_kind == "local":
|
|
host = os.getenv("OLLAMA_HOST", "").strip() or "http://localhost:11434"
|
|
return CredentialStatus(
|
|
provider=spec.value,
|
|
configured=True,
|
|
source="local",
|
|
verified=True,
|
|
stale=False,
|
|
detail=f"Ollama host: {host}.",
|
|
)
|
|
|
|
return CredentialStatus(spec.value, False, "unknown", False, False, "Unknown auth kind.")
|
|
|
|
|
|
def _mark_stale(spec: ProviderSpec, detail: str) -> None:
|
|
record = resolve_provider_auth_record(spec.value)
|
|
if not record:
|
|
return
|
|
save_provider_auth_record_values(
|
|
spec.value,
|
|
{
|
|
**record,
|
|
"source": record.get("source") or "metadata",
|
|
"detail": detail,
|
|
"verified": "false",
|
|
"stale": "true",
|
|
},
|
|
)
|
|
|
|
|
|
def resolve_for_request(provider: str) -> CredentialResolution:
|
|
"""Resolve request-time auth for exactly one selected provider."""
|
|
spec = require_provider_spec(provider)
|
|
if spec.credential_kind == "api_key":
|
|
env_value = _env_value(spec.api_key_env)
|
|
if env_value:
|
|
return CredentialResolution(
|
|
provider=spec.value,
|
|
api_key=env_value,
|
|
source="env",
|
|
detail=f"{spec.api_key_env} resolved from environment.",
|
|
)
|
|
|
|
import keyring.errors
|
|
|
|
from config.llm_keyring import keyring_is_disabled, read_keychain_secret
|
|
|
|
if not keyring_is_disabled():
|
|
try:
|
|
key = read_keychain_secret(spec.api_key_env)
|
|
except keyring.errors.KeyringError as exc:
|
|
# The backend itself couldn't be reached (e.g. no D-Bus/Secret
|
|
# Service session in this process) — this is not evidence the
|
|
# credential is missing, so leave any previously-verified
|
|
# metadata untouched instead of marking it stale.
|
|
return CredentialResolution(
|
|
provider=spec.value,
|
|
api_key="",
|
|
source="unknown",
|
|
detail=(
|
|
f"Could not reach the system keychain to check {spec.api_key_env}: "
|
|
f"{exc}. Retry once the keychain is reachable."
|
|
),
|
|
)
|
|
if key:
|
|
save_provider_auth_record(
|
|
provider=spec.value,
|
|
auth_name=spec.value,
|
|
kind="api_key",
|
|
source="keyring",
|
|
detail=f"{spec.api_key_env} stored in the system keychain.",
|
|
verified=True,
|
|
stale=False,
|
|
env_var=spec.api_key_env,
|
|
)
|
|
return CredentialResolution(
|
|
provider=spec.value,
|
|
api_key=key,
|
|
source="keyring",
|
|
detail=f"{spec.api_key_env} resolved from secure local storage.",
|
|
)
|
|
|
|
detail = (
|
|
f"Missing credential for LLM provider '{spec.value}'. Set {spec.api_key_env} "
|
|
f"or run `opensre auth login {spec.value}`."
|
|
)
|
|
_mark_stale(spec, detail)
|
|
return CredentialResolution(spec.value, "", "none", detail)
|
|
|
|
if spec.credential_kind == "cli":
|
|
return CredentialResolution(
|
|
provider=spec.value,
|
|
api_key="",
|
|
source="cli",
|
|
detail=f"{spec.label} uses vendor CLI authentication.",
|
|
)
|
|
if spec.credential_kind == "ambient":
|
|
return CredentialResolution(
|
|
provider=spec.value,
|
|
api_key="",
|
|
source="ambient",
|
|
detail=f"{spec.label} uses ambient credentials.",
|
|
)
|
|
if spec.credential_kind == "local":
|
|
return CredentialResolution(
|
|
provider=spec.value,
|
|
api_key="",
|
|
source="local",
|
|
detail=f"{spec.label} uses local runtime configuration.",
|
|
)
|
|
return CredentialResolution(spec.value, "", "unknown", "Unsupported provider auth kind.")
|
|
|
|
|
|
def require_for_request(provider: str) -> CredentialResolution:
|
|
"""Resolve request-time auth or raise an actionable error."""
|
|
resolution = resolve_for_request(provider)
|
|
if not resolution.ok:
|
|
raise MissingLLMCredentialError(resolution.detail)
|
|
return resolution
|
|
|
|
|
|
def resolve_api_key_env_for_request(env_var: str) -> str:
|
|
"""Resolve one API-key env var through the request-time provider boundary."""
|
|
normalized = env_var.strip()
|
|
for provider, provider_env in API_KEY_PROVIDER_ENVS.items():
|
|
if provider_env == normalized:
|
|
return resolve_for_request(provider).api_key
|
|
from config.llm_keyring import resolve_llm_api_key
|
|
|
|
return resolve_llm_api_key(normalized)
|
|
|
|
|
|
def save_api_key(provider: str, value: str, *, detail: str | None = None) -> None:
|
|
"""Store an OpenSRE-managed API key and refresh prompt-safe metadata."""
|
|
spec = require_provider_spec(provider)
|
|
if not spec.uses_open_sre_api_key:
|
|
raise ValueError(f"{spec.value} does not use an OpenSRE-managed API key")
|
|
from config.llm_keyring import save_llm_api_key
|
|
|
|
save_llm_api_key(spec.api_key_env, value)
|
|
save_provider_auth_record(
|
|
provider=spec.value,
|
|
auth_name=spec.value,
|
|
kind="api_key",
|
|
source="keyring",
|
|
detail=detail or f"{spec.api_key_env} stored in the system keychain.",
|
|
verified=True,
|
|
stale=False,
|
|
env_var=spec.api_key_env,
|
|
)
|
|
|
|
|
|
def delete(provider: str) -> None:
|
|
"""Delete OpenSRE-managed provider auth metadata and API key when applicable."""
|
|
spec = require_provider_spec(provider)
|
|
if spec.uses_open_sre_api_key:
|
|
from config.llm_keyring import delete_llm_api_key
|
|
|
|
delete_llm_api_key(spec.api_key_env)
|
|
delete_provider_auth_record(spec.value)
|
|
|
|
|
|
def verify(provider: str) -> CredentialStatus:
|
|
"""Intentionally check request-time credentials and update metadata."""
|
|
resolution = resolve_for_request(provider)
|
|
if resolution.ok:
|
|
return CredentialStatus(
|
|
provider=resolution.provider,
|
|
configured=True,
|
|
source=resolution.source,
|
|
verified=True,
|
|
stale=False,
|
|
detail=resolution.detail,
|
|
)
|
|
return status(provider)
|
|
|
|
|
|
def source_for_api_key_env(env_var: str) -> CredentialSource:
|
|
"""Prompt-safe source lookup for legacy env-var-based callers."""
|
|
normalized = env_var.strip()
|
|
if _env_value(normalized):
|
|
return "env"
|
|
for provider, provider_env in API_KEY_PROVIDER_ENVS.items():
|
|
if provider_env == normalized:
|
|
provider_status = status(provider)
|
|
return provider_status.source if provider_status.configured else "none"
|
|
return "none"
|
|
|
|
|
|
def has_api_key_env_status(env_var: str) -> bool:
|
|
"""Prompt-safe availability check for legacy env-var-based callers."""
|
|
return source_for_api_key_env(env_var) != "none"
|
|
|
|
|
|
def llm_api_key_source(env_var: str) -> str:
|
|
"""Return where an LLM credential resolves from: ``env``, ``keyring``, or ``none``."""
|
|
import keyring
|
|
import keyring.errors
|
|
|
|
from config.llm_keyring import (
|
|
_KEYRING_SERVICE,
|
|
keyring_is_disabled,
|
|
macos_keychain_item_exists,
|
|
)
|
|
|
|
prompt_safe_source = source_for_api_key_env(env_var)
|
|
if prompt_safe_source != "none":
|
|
return prompt_safe_source
|
|
if env_var.strip() in set(API_KEY_PROVIDER_ENVS.values()):
|
|
return "none"
|
|
if os.getenv(env_var, "").strip():
|
|
return "env"
|
|
if keyring_is_disabled():
|
|
return "none"
|
|
item_exists = macos_keychain_item_exists(env_var)
|
|
if item_exists is not None:
|
|
return "keyring" if item_exists else "none"
|
|
try:
|
|
if (keyring.get_password(_KEYRING_SERVICE, env_var) or "").strip():
|
|
return "keyring"
|
|
except keyring.errors.KeyringError:
|
|
return "none"
|
|
return "none"
|
|
|
|
|
|
def has_llm_api_key(env_var: str) -> bool:
|
|
"""Return True when an API key is available from env or secure local storage."""
|
|
from config.llm_keyring import (
|
|
keyring_is_disabled,
|
|
macos_keychain_item_exists,
|
|
resolve_llm_api_key,
|
|
)
|
|
|
|
if has_api_key_env_status(env_var):
|
|
return True
|
|
if env_var.strip() in set(API_KEY_PROVIDER_ENVS.values()):
|
|
return False
|
|
if os.getenv(env_var, "").strip():
|
|
return True
|
|
if keyring_is_disabled():
|
|
return False
|
|
item_exists = macos_keychain_item_exists(env_var)
|
|
if item_exists is not None:
|
|
return item_exists
|
|
return bool(resolve_llm_api_key(env_var))
|
|
|
|
|
|
__all__ = [
|
|
"CredentialResolution",
|
|
"CredentialSource",
|
|
"CredentialStatus",
|
|
"MissingLLMCredentialError",
|
|
"delete",
|
|
"has_api_key_env_status",
|
|
"has_llm_api_key",
|
|
"llm_api_key_source",
|
|
"require_for_request",
|
|
"resolve_api_key_env_for_request",
|
|
"resolve_for_request",
|
|
"save_api_key",
|
|
"source_for_api_key_env",
|
|
"status",
|
|
"verify",
|
|
]
|