Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [1.7.1] - 2026-01-25

### Changed

- **Gateway Mode smart defaults**: `get_policy_approved_context()` and `audit_llm_call()` now use `"community"` as default client_id when not configured, enabling zero-config usage for community/self-hosted deployments

### Fixed

- **PolicyCategory**: Added `PII_SINGAPORE = "pii-singapore"` enum value for Singapore PII detection policies (NRIC, FIN, UEN patterns)
Expand Down
42 changes: 17 additions & 25 deletions axonflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,25 +394,17 @@ def _has_credentials(self) -> bool:
"""
return bool(self._config.client_id)

def _require_credentials(self, feature: str) -> None:
"""Require credentials for enterprise features.
def _get_effective_client_id(self) -> str:
"""Get the effective client_id, using smart default for community mode.

Raises AuthenticationError if client_id is not configured.
Note: client_secret is optional for community mode.
Returns the configured client_id if set, otherwise returns "community"
as a smart default. This enables zero-config usage for community/self-hosted
deployments while still supporting enterprise deployments with explicit credentials.

Args:
feature: Name of the feature requiring credentials (for error message)

Raises:
AuthenticationError: If client_id is not configured
Returns:
The client_id to use in requests
"""
if not self._has_credentials():
msg = (
f"{feature} requires client_id. "
"Set client_id when creating the client "
"(client_secret is optional for community mode)."
)
raise AuthenticationError(msg)
return self._config.client_id if self._config.client_id else "community"

async def __aenter__(self) -> AxonFlow:
"""Async context manager entry."""
Expand Down Expand Up @@ -1160,8 +1152,8 @@ async def get_policy_approved_context(
LLM call to ensure policy compliance.

Note:
This is an enterprise feature that requires credentials.
Set client_id and client_secret when creating the client.
Uses smart default "community" for client_id if not configured,
enabling zero-config usage for community/self-hosted deployments.

Args:
user_token: JWT token for the user making the request
Expand All @@ -1173,7 +1165,7 @@ async def get_policy_approved_context(
PolicyApprovalResult with context ID and approved data

Raises:
AuthenticationError: If credentials are not configured or user token is invalid
AuthenticationError: If user token is invalid
ConnectionError: If unable to reach AxonFlow Agent
TimeoutError: If request times out

Expand All @@ -1186,12 +1178,12 @@ async def get_policy_approved_context(
>>> if not result.approved:
... raise PolicyViolationError(result.block_reason)
"""
# Gateway Mode is an enterprise feature that requires credentials
self._require_credentials("Gateway Mode (get_policy_approved_context)")
# Use smart default for client_id - enables zero-config community mode
client_id = self._get_effective_client_id()

request_body = {
"user_token": user_token,
"client_id": self._config.client_id,
"client_id": client_id,
"query": query,
"data_sources": data_sources or [],
"context": context or {},
Expand Down Expand Up @@ -1322,12 +1314,12 @@ async def audit_llm_call(
... latency_ms=250
... )
"""
# Gateway Mode is an enterprise feature that requires credentials
self._require_credentials("Gateway Mode (audit_llm_call)")
# Use smart default for client_id - enables zero-config community mode
client_id = self._get_effective_client_id()

request_body = {
"context_id": context_id,
"client_id": self._config.client_id,
"client_id": client_id,
"response_summary": response_summary,
"provider": provider,
"model": model,
Expand Down
100 changes: 61 additions & 39 deletions tests/test_auth_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,67 +133,89 @@ async def test_no_auth_headers_for_health_check(self, httpx_mock):


class TestEnterpriseFeatureValidation:
"""Test that enterprise features require client_id before making requests."""
"""Test that Gateway Mode uses smart defaults for client_id."""

@pytest.mark.asyncio
async def test_pre_check_fails_without_client_id(self, httpx_mock):
"""get_policy_approved_context should fail before making request when no client_id."""
# Don't mock the endpoint - we should fail before making the request
async def test_pre_check_uses_smart_default_without_client_id(self, httpx_mock):
"""get_policy_approved_context should use 'community' as default client_id."""
httpx_mock.add_response(
url="http://localhost:8080/api/policy/pre-check",
json={
"context_id": "ctx_smart_default",
"approved": True,
"policies": [],
"expires_at": "2025-12-20T12:00:00Z",
},
)

client = AxonFlow(
endpoint="http://localhost:8080",
# No client_id - truly no credentials
# No client_id - should use "community" smart default
debug=True,
)

async with client:
with pytest.raises(AuthenticationError) as exc_info:
await client.get_policy_approved_context(
user_token="",
query="Test query",
)
result = await client.get_policy_approved_context(
user_token="test-user",
query="Test query",
)

assert "requires client_id" in str(exc_info.value)
assert "Gateway Mode" in str(exc_info.value)
assert result.approved is True
assert result.context_id == "ctx_smart_default"

# No request should have been made
# Verify request was made with smart default client_id
requests = httpx_mock.get_requests()
assert len(requests) == 0
assert len(requests) == 1
import json

body = json.loads(requests[0].content)
assert body["client_id"] == "community"

print("✅ get_policy_approved_context fails without client_id (no request made)")
print("✅ get_policy_approved_context uses 'community' smart default")

@pytest.mark.asyncio
async def test_audit_fails_without_client_id(self, httpx_mock):
"""audit_llm_call should fail before making request when no client_id."""
# Don't mock the endpoint - we should fail before making the request
async def test_audit_uses_smart_default_without_client_id(self, httpx_mock):
"""audit_llm_call should use 'community' as default client_id."""
httpx_mock.add_response(
url="http://localhost:8080/api/audit/llm-call",
json={
"success": True,
"audit_id": "audit_smart_default",
},
)

client = AxonFlow(
endpoint="http://localhost:8080",
# No client_id - truly no credentials
# No client_id - should use "community" smart default
debug=True,
)

async with client:
with pytest.raises(AuthenticationError) as exc_info:
await client.audit_llm_call(
context_id="ctx_123",
response_summary="Test response",
provider="openai",
model="gpt-4",
token_usage=TokenUsage(
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
),
latency_ms=250,
)

assert "requires client_id" in str(exc_info.value)
assert "Gateway Mode" in str(exc_info.value)

# No request should have been made
result = await client.audit_llm_call(
context_id="ctx_123",
response_summary="Test response",
provider="openai",
model="gpt-4",
token_usage=TokenUsage(
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
),
latency_ms=250,
)

assert result.success is True
assert result.audit_id == "audit_smart_default"

# Verify request was made with smart default client_id
requests = httpx_mock.get_requests()
assert len(requests) == 0
assert len(requests) == 1
import json

body = json.loads(requests[0].content)
assert body["client_id"] == "community"

print("✅ audit_llm_call fails without client_id (no request made)")
print("✅ audit_llm_call uses 'community' smart default")

@pytest.mark.asyncio
async def test_pre_check_works_with_credentials(self, httpx_mock):
Expand Down