Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import logging
import os
import ssl
import warnings
from base64 import urlsafe_b64encode
from hashlib import sha256
from hmac import new as hmac_new
Expand All @@ -20,6 +22,11 @@

DEFAULT_TIMEOUT_SECS = 10.0
DEFAULT_INTERNAL_TOKEN_TTL_SECS = 3600
# Keep pooled-connection reuse shorter than typical server keepalive/worker
# recycle windows so requests do not pick up sockets the server already closed.
DEFAULT_KEEPALIVE_EXPIRY_SECS = 1.0
DEFAULT_MAX_CONNECTIONS = 100
DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 20
PUBLIC_SCORER_INVOKE_PATH = "/scorers/invoke"
INTERNAL_SCORER_INVOKE_PATH = "/internal/scorers/invoke"
AuthMode = Literal["public", "internal"]
Expand Down Expand Up @@ -56,6 +63,13 @@ def _env_auth_mode() -> AuthMode | None:
value = os.getenv("GALILEO_LUNA_AUTH_MODE")
if value is None or value.strip() == "":
return None
warnings.warn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warnings.warn(..., DeprecationWarning) is filtered out by default in production Python, so an operator still relying on GALILEO_LUNA_AUTH_MODE likely never sees the notice. Consider also emitting logger.warning(...) so the deprecation actually surfaces in logs (the class already logs the selected auth mode right after).

"GALILEO_LUNA_AUTH_MODE is deprecated. Configure exactly one credential "
"(GALILEO_API_KEY for public auth, GALILEO_API_SECRET_KEY for internal "
"auth) or pass auth_mode to GalileoLunaClient.",
DeprecationWarning,
stacklevel=2,
)
normalized = value.strip().lower()
if normalized == "public":
return "public"
Expand Down Expand Up @@ -164,9 +178,12 @@ class GalileoLunaClient:
"""Thin HTTP client for Galileo Luna direct scorer invocation.

Environment Variables:
GALILEO_API_SECRET_KEY or GALILEO_API_SECRET: Galileo API internal JWT signing secret.
GALILEO_API_SECRET_KEY: Deployment-provided Galileo API internal JWT signing secret.
GALILEO_API_KEY: Galileo API key fallback for public scorer invocation.
GALILEO_LUNA_AUTH_MODE: Auth mode, either "public" or "internal".
GALILEO_LUNA_API_URL: Galileo Luna scorer invoke API URL override.
GALILEO_API_URL: Galileo API URL fallback.
GALILEO_LUNA_CA_FILE: CA bundle used to verify the scorer API endpoint, for
deployments whose API serves an internally-issued TLS certificate.
GALILEO_CONSOLE_URL: Galileo Console URL (optional, defaults to production).
"""

Expand All @@ -177,23 +194,27 @@ def __init__(
console_url: str | None = None,
api_url: str | None = None,
auth_mode: AuthMode | None = None,
ca_file: str | None = None,
) -> None:
"""Initialize the Galileo Luna client.

Args:
api_key: Galileo API key. If not provided, reads from GALILEO_API_KEY.
api_secret: Galileo API secret for internal JWT auth. If not provided,
reads from GALILEO_API_SECRET_KEY or GALILEO_API_SECRET.
api_secret: Deployment-provided Galileo API secret for internal JWT auth.
If not provided, reads from GALILEO_API_SECRET_KEY.
console_url: Galileo Console URL. If not provided, reads from
GALILEO_CONSOLE_URL or uses the production console URL.
api_url: Galileo API URL. If not provided, reads from GALILEO_API_URL
before deriving from the console URL.
auth_mode: Auth mode to use. If not provided, reads from
GALILEO_LUNA_AUTH_MODE, or infers from the single available credential.
api_url: Galileo API URL. If not provided, reads from GALILEO_LUNA_API_URL,
then GALILEO_API_URL, before deriving from the console URL.
auth_mode: Auth mode to use. If not provided, inferred from the single
available credential.
ca_file: CA bundle path used to verify the scorer API endpoint. If not
provided, reads from GALILEO_LUNA_CA_FILE. Leave unset for endpoints
with publicly-trusted certificates.

Raises:
ValueError: If credentials are missing, ambiguous, or incompatible with
the selected auth mode.
the selected auth mode, or if the CA bundle cannot be loaded.
"""
resolved_api_secret = (
api_secret or os.getenv("GALILEO_API_SECRET_KEY") or os.getenv("GALILEO_API_SECRET")
Expand All @@ -211,12 +232,32 @@ def __init__(
self.console_url = (
console_url or os.getenv("GALILEO_CONSOLE_URL") or "https://console.galileo.ai"
)
self.api_base = (api_url or os.getenv("GALILEO_API_URL") or "").rstrip(
"/"
) or self._derive_api_url(self.console_url)
self.api_base = self._resolve_api_base(api_url)
self.ca_file = (ca_file or os.getenv("GALILEO_LUNA_CA_FILE") or "").strip() or None
self._ssl_context = self._load_ssl_context(self.ca_file)
self._client: httpx.AsyncClient | None = None
logger.info("[GalileoLunaClient] Auth mode selected: %s", self.auth_mode)

def _resolve_api_base(self, api_url: str | None) -> str:
"""Resolve the scorer invoke API base URL from explicit and environment config."""
candidates = [api_url, os.getenv("GALILEO_LUNA_API_URL")]
candidates.append(os.getenv("GALILEO_API_URL"))

for candidate in candidates:
if candidate and candidate.strip():
return candidate.strip().rstrip("/")
return self._derive_api_url(self.console_url)

@staticmethod
def _load_ssl_context(ca_file: str | None) -> ssl.SSLContext | None:
"""Build a TLS verification context from a CA bundle path, if configured."""
if ca_file is None:
return None
try:
return ssl.create_default_context(cafile=ca_file)
except (OSError, ssl.SSLError) as exc:
raise ValueError(f"Failed to load CA bundle from {ca_file!r}: {exc}") from exc

@staticmethod
def _resolve_auth_mode(
auth_mode: AuthMode | None,
Expand All @@ -226,24 +267,21 @@ def _resolve_auth_mode(
) -> AuthMode:
if auth_mode == "public":
if not api_key:
raise ValueError(
"GALILEO_API_KEY is required when GALILEO_LUNA_AUTH_MODE=public."
)
raise ValueError("GALILEO_API_KEY is required for public Luna auth.")
return "public"

if auth_mode == "internal":
if not api_secret:
raise ValueError(
"GALILEO_API_SECRET_KEY or GALILEO_API_SECRET is required when "
"GALILEO_LUNA_AUTH_MODE=internal."
"GALILEO_API_SECRET_KEY is required for internal Luna auth."
)
return "internal"

if api_key and api_secret:
raise ValueError(
"Both Galileo API key and API secret are configured. Set "
"GALILEO_LUNA_AUTH_MODE to 'public' or 'internal' to choose the "
"runtime auth mode explicitly."
"Both a Galileo API key and a Galileo API secret are configured. "
"Unset one credential so the auth mode can be inferred, or pass "
"auth_mode='public' or auth_mode='internal' explicitly."
)
if api_secret:
return "internal"
Expand Down Expand Up @@ -284,9 +322,18 @@ async def _get_client(self) -> httpx.AsyncClient:
headers = {"Content-Type": "application/json"}
if self.auth_mode == "public" and self.api_key is not None:
headers["Galileo-API-Key"] = self.api_key
verify: ssl.SSLContext | bool = (
self._ssl_context if self._ssl_context is not None else True
)
self._client = httpx.AsyncClient(
headers=headers,
timeout=httpx.Timeout(DEFAULT_TIMEOUT_SECS),
limits=httpx.Limits(
max_connections=DEFAULT_MAX_CONNECTIONS,
max_keepalive_connections=DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
keepalive_expiry=DEFAULT_KEEPALIVE_EXPIRY_SECS,
),
verify=verify,
)
return self._client

Expand Down
20 changes: 13 additions & 7 deletions evaluators/contrib/galileo/tests/test_luna_coverage_gaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,9 @@ def test_client_requires_explicit_mode_when_both_credentials_are_present(monkeyp
monkeypatch.delenv("GALILEO_LUNA_AUTH_MODE", raising=False)
from agent_control_evaluator_galileo.luna.client import GalileoLunaClient

with pytest.raises(ValueError, match="Both Galileo API key and API secret"):
with pytest.raises(
ValueError, match="Both a Galileo API key and a Galileo API secret are configured"
):
GalileoLunaClient()


Expand All @@ -468,7 +470,8 @@ def test_client_uses_explicit_public_mode_when_both_credentials_are_present(monk
monkeypatch.setenv("GALILEO_LUNA_AUTH_MODE", "public")
from agent_control_evaluator_galileo.luna.client import GalileoLunaClient

client = GalileoLunaClient()
with pytest.warns(DeprecationWarning, match="GALILEO_LUNA_AUTH_MODE is deprecated"):
client = GalileoLunaClient()

assert client.auth_mode == "public"
endpoint, request_headers = client._endpoint_and_headers(None)
Expand All @@ -483,7 +486,8 @@ def test_client_uses_explicit_internal_mode_when_both_credentials_are_present(mo
monkeypatch.setenv("GALILEO_LUNA_AUTH_MODE", "internal")
from agent_control_evaluator_galileo.luna.client import GalileoLunaClient

client = GalileoLunaClient()
with pytest.warns(DeprecationWarning, match="GALILEO_LUNA_AUTH_MODE is deprecated"):
client = GalileoLunaClient()

assert client.auth_mode == "internal"
endpoint, request_headers = client._endpoint_and_headers(None)
Expand All @@ -499,8 +503,9 @@ def test_client_rejects_mode_without_matching_credential(monkeypatch):
monkeypatch.setenv("GALILEO_LUNA_AUTH_MODE", "internal")
from agent_control_evaluator_galileo.luna.client import GalileoLunaClient

with pytest.raises(ValueError, match="GALILEO_API_SECRET_KEY"):
GalileoLunaClient()
with pytest.warns(DeprecationWarning, match="GALILEO_LUNA_AUTH_MODE is deprecated"):
with pytest.raises(ValueError, match="GALILEO_API_SECRET_KEY"):
GalileoLunaClient()


def test_client_rejects_invalid_auth_mode(monkeypatch):
Expand All @@ -509,8 +514,9 @@ def test_client_rejects_invalid_auth_mode(monkeypatch):
monkeypatch.setenv("GALILEO_LUNA_AUTH_MODE", "sideways")
from agent_control_evaluator_galileo.luna.client import GalileoLunaClient

with pytest.raises(ValueError, match="GALILEO_LUNA_AUTH_MODE"):
GalileoLunaClient()
with pytest.warns(DeprecationWarning, match="GALILEO_LUNA_AUTH_MODE is deprecated"):
with pytest.raises(ValueError, match="GALILEO_LUNA_AUTH_MODE"):
GalileoLunaClient()


class TestDeriveApiUrl:
Expand Down
126 changes: 123 additions & 3 deletions evaluators/contrib/galileo/tests/test_luna_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def test_client_uses_protect_api_url_derivation(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient

# Given: the same console URL shape used by Protect
with patch.dict(os.environ, {"GALILEO_API_KEY": "test-key"}):
with patch.dict(os.environ, {"GALILEO_API_KEY": "test-key"}, clear=True):
client = GalileoLunaClient(console_url="https://console.demo-v2.galileocloud.io")

# Then: the API URL is derived the same way
Expand All @@ -151,22 +151,142 @@ def test_client_uses_galileo_api_url_when_set(self) -> None:
"GALILEO_API_KEY": "test-key",
"GALILEO_API_URL": "https://api-test-luna.gcp-dev.galileo.ai/",
},
clear=True,
):
client = GalileoLunaClient(console_url="https://console-test-luna.gcp-dev.galileo.ai")

# Then: the explicit API URL wins over console URL derivation
assert client.api_base == "https://api-test-luna.gcp-dev.galileo.ai"

def test_client_uses_luna_api_url_when_set(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient

# Given: a Luna-specific API URL and a general API URL are both configured
with patch.dict(
os.environ,
{
"GALILEO_API_KEY": "test-key",
"GALILEO_LUNA_API_URL": "https://luna-api.example.com/",
"GALILEO_API_URL": "https://api.example.com",
},
clear=True,
):
client = GalileoLunaClient(console_url="https://console.example.com")

# Then: the Luna-specific URL wins without changing the general API URL contract
assert client.api_base == "https://luna-api.example.com"

def test_client_uses_luna_api_url_for_internal_auth(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient

# Given: internal auth and both Luna-specific and general API URLs are configured
with patch.dict(
os.environ,
{
"GALILEO_API_SECRET_KEY": "test-secret",
"GALILEO_LUNA_API_URL": "https://internal-api.example.com",
"GALILEO_API_URL": "https://api-public.example.com",
},
clear=True,
):
client = GalileoLunaClient(console_url="https://console.example.com")

# Then: internal scorer invocation uses the Luna-specific API base
assert client.api_base == "https://internal-api.example.com"

def test_client_derives_api_url_from_console_dash_hostname(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient

# Given: a console-<stack> devstack hostname
with patch.dict(os.environ, {"GALILEO_API_KEY": "test-key"}, clear=False):
with patch.dict(os.environ, {"GALILEO_API_KEY": "test-key"}, clear=True):
client = GalileoLunaClient(console_url="https://console-test-luna.gcp-dev.galileo.ai")

# Then: the matching api-<stack> hostname is used
assert client.api_base == "https://api-test-luna.gcp-dev.galileo.ai"

def test_client_strips_whitespace_from_env_url(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient

# Given: a URL override padded with whitespace and a trailing slash
with patch.dict(
os.environ,
{
"GALILEO_API_KEY": "test-key",
"GALILEO_LUNA_API_URL": " https://luna-api.example.com/ ",
},
clear=True,
):
client = GalileoLunaClient(console_url="https://console.example.com")

# Then: the resolved base URL is trimmed and slash-free
assert client.api_base == "https://luna-api.example.com"

def test_client_warns_when_deprecated_auth_mode_env_is_set(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient

# Given: the deprecated auth-mode environment variable
with patch.dict(
os.environ,
{"GALILEO_API_KEY": "test-key", "GALILEO_LUNA_AUTH_MODE": "public"},
clear=True,
):
# When/Then: construction still works but emits a deprecation warning
with pytest.warns(DeprecationWarning, match="GALILEO_LUNA_AUTH_MODE is deprecated"):
client = GalileoLunaClient(console_url="https://console.example.com")

assert client.auth_mode == "public"

def test_client_rejects_unreadable_ca_bundle(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient

# Given: a CA bundle path that does not exist
with patch.dict(
os.environ,
{
"GALILEO_API_SECRET_KEY": "test-secret",
"GALILEO_LUNA_CA_FILE": "/nonexistent/ca.pem",
},
clear=True,
):
# When/Then: client construction fails fast instead of at first request
with pytest.raises(ValueError, match="Failed to load CA bundle"):
GalileoLunaClient(console_url="https://console.example.com")

@pytest.mark.asyncio
async def test_client_applies_ca_bundle_and_connection_limits(self) -> None:
import certifi

from agent_control_evaluator_galileo.luna import GalileoLunaClient
from agent_control_evaluator_galileo.luna.client import DEFAULT_KEEPALIVE_EXPIRY_SECS

captured: dict[str, object] = {}
real_async_client = httpx.AsyncClient

def recording_client(**kwargs: object) -> httpx.AsyncClient:
captured.update(kwargs)
return real_async_client(**kwargs)

# Given: internal auth with a CA bundle configured
with patch.dict(os.environ, {"GALILEO_API_SECRET_KEY": "test-secret"}, clear=True):
client = GalileoLunaClient(
console_url="https://console.example.com", ca_file=certifi.where()
)

with patch(
"agent_control_evaluator_galileo.luna.client.httpx.AsyncClient", recording_client
):
try:
await client._get_client()
finally:
await client.close()

# Then: TLS verification uses the configured CA bundle and pooled
# connections expire quickly so closed server sockets are not reused
assert captured["verify"] is client._ssl_context
limits = captured["limits"]
assert isinstance(limits, httpx.Limits)
assert limits.keepalive_expiry == DEFAULT_KEEPALIVE_EXPIRY_SECS

@pytest.mark.asyncio
async def test_client_posts_to_scorers_invoke_without_protect_fields(self) -> None:
from agent_control_evaluator_galileo.luna import GalileoLunaClient
Expand All @@ -188,7 +308,7 @@ def handler(request: httpx.Request) -> httpx.Response:
)

# Given: a Luna client with a mock HTTP transport
with patch.dict(os.environ, {"GALILEO_API_KEY": "test-key"}):
with patch.dict(os.environ, {"GALILEO_API_KEY": "test-key"}, clear=True):
client = GalileoLunaClient(console_url="https://console.demo-v2.galileocloud.io")
client._client = httpx.AsyncClient(
transport=httpx.MockTransport(handler),
Expand Down
Loading
Loading