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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to the AxonFlow Python SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.1.0] - 2026-03-14

### Added

- `audit_tool_call()` — record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+
- `get_audit_logs_by_tenant()` — retrieve audit logs for a tenant with optional pagination
- `search_audit_logs()` — search audit logs with filters (client ID, request type, limit)

### Fixed

- Telemetry pings now suppressed for localhost/127.0.0.1/::1 endpoints unless `telemetry_enabled` is explicitly set to `True`. Prevents telemetry noise during local development.

---

## [4.0.0] - 2026-03-09

### Breaking Changes
Expand Down
34 changes: 34 additions & 0 deletions axonflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@
FEATAssessment,
FEATAssessmentStatus,
FEATPillar,
Finding,
FindingSeverity,
FindingStatus,
KillSwitch,
KillSwitchEvent,
KillSwitchEventType,
Expand Down Expand Up @@ -128,7 +131,14 @@
CATEGORY_MEDIA_DOCUMENT,
CATEGORY_MEDIA_PII,
CATEGORY_MEDIA_SAFETY,
AuditLogEntry,
AuditQueryOptions,
AuditResult,
AuditSearchRequest,
AuditSearchResponse,
AuditToolCallRequest,
AuditToolCallResponse,
AxonFlowConfig,
Budget,
BudgetAlert,
BudgetAlertsResponse,
Expand All @@ -145,15 +155,20 @@
ClientRequest,
ClientResponse,
CodeArtifact,
ConnectorHealthStatus,
ConnectorInstallRequest,
ConnectorMetadata,
ConnectorPolicyInfo,
ConnectorResponse,
CreateBudgetRequest,
DynamicPolicyInfo,
DynamicPolicyMatch,
ExecutionDetail,
ExecutionExportOptions,
ExecutionMode,
ExecutionSnapshot,
ExecutionSummary,
ExfiltrationCheckInfo,
ListBudgetsOptions,
ListExecutionsOptions,
ListExecutionsResponse,
Expand All @@ -178,6 +193,7 @@
PolicyApprovalResult,
PolicyEvaluationInfo,
PolicyEvaluationResult,
PolicyMatchInfo,
PricingInfo,
PricingListResponse,
RateLimitInfo,
Expand Down Expand Up @@ -232,6 +248,7 @@
"SDKCompatibility",
"HealthResponse",
# Configuration
"AxonFlowConfig",
"Mode",
"RetryConfig",
"CacheConfig",
Expand All @@ -257,6 +274,8 @@
"ConnectorMetadata",
"ConnectorInstallRequest",
"ConnectorResponse",
"ConnectorHealthStatus",
"ConnectorPolicyInfo",
# MCP Policy Check types
"MCPCheckInputRequest",
"MCPCheckInputResponse",
Expand All @@ -279,8 +298,20 @@
# Gateway Mode types
"RateLimitInfo",
"PolicyApprovalResult",
"PolicyMatchInfo",
"ExfiltrationCheckInfo",
"DynamicPolicyMatch",
"DynamicPolicyInfo",
"TokenUsage",
"AuditResult",
# Audit Log Read types (Issue #878)
"AuditSearchRequest",
"AuditSearchResponse",
"AuditLogEntry",
"AuditQueryOptions",
# Audit Tool Call types (Issue #1260)
"AuditToolCallRequest",
"AuditToolCallResponse",
# Execution Replay types
"ExecutionSummary",
"ExecutionSnapshot",
Expand Down Expand Up @@ -393,6 +424,9 @@
"WebhookSubscription",
"ListWebhooksResponse",
# MAS FEAT Compliance types (Enterprise)
"FindingSeverity",
"FindingStatus",
"Finding",
"MaterialityClassification",
"SystemStatus",
"FEATAssessmentStatus",
Expand Down
2 changes: 1 addition & 1 deletion axonflow/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the AxonFlow SDK version."""

__version__ = "4.0.0"
__version__ = "4.1.0"
74 changes: 74 additions & 0 deletions axonflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@
AuditResult,
AuditSearchRequest,
AuditSearchResponse,
AuditToolCallRequest,
AuditToolCallResponse,
AxonFlowConfig,
Budget,
BudgetAlertsResponse,
Expand Down Expand Up @@ -1783,6 +1785,71 @@ async def audit_llm_call(
audit_id=response["audit_id"],
)

async def audit_tool_call(
self,
request: AuditToolCallRequest,
) -> AuditToolCallResponse:
"""Record a non-LLM tool call in the audit trail.

Use this to audit tool invocations (MCP tools, API calls, function
calls) that are not LLM calls but should still appear in the audit
trail for governance and compliance.

Args:
request: Tool call details including tool name, type, input/output,
and associated workflow/step information.

Returns:
AuditToolCallResponse confirming the audit entry was recorded.

Raises:
ValueError: If tool_name is empty.
AxonFlowError: If audit recording fails.

Example:
>>> from axonflow.types import AuditToolCallRequest
>>> result = await client.audit_tool_call(
... AuditToolCallRequest(
... tool_name="getUserInfo",
... tool_type="mcp",
... workflow_id="wf_abc123",
... success=True,
... duration_ms=45,
... )
... )
>>> print(result.audit_id)
"""
if not request.tool_name or not request.tool_name.strip():
msg = "tool_name is required and cannot be empty"
raise ValueError(msg)

request_body = request.model_dump(by_alias=True, exclude_none=True)

if self._config.debug:
self._logger.debug(
"Audit tool call request",
tool_name=request.tool_name,
tool_type=request.tool_type,
)

response = await self._request(
"POST",
"/api/v1/audit/tool-call",
json_data=request_body,
)

if self._config.debug:
self._logger.debug(
"Audit tool call complete",
audit_id=response.get("audit_id"),
)

return AuditToolCallResponse(
audit_id=response["audit_id"],
status=response["status"],
timestamp=response["timestamp"],
)

# =========================================================================
# Audit Log Read Methods
# =========================================================================
Expand Down Expand Up @@ -6197,6 +6264,13 @@ def audit_llm_call(
)
)

def audit_tool_call(
self,
request: AuditToolCallRequest,
) -> AuditToolCallResponse:
"""Record a non-LLM tool call in the audit trail."""
return self._run_sync(self._async_client.audit_tool_call(request))

# Policy CRUD sync wrappers

def list_static_policies(
Expand Down
67 changes: 55 additions & 12 deletions axonflow/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,29 +57,69 @@ def _is_telemetry_enabled(
return mode != "sandbox"


def _build_payload(mode: str) -> dict[str, object]:
def _detect_platform_version(endpoint: str) -> str | None:
"""Detect platform version by calling the agent's /health endpoint.

Returns the version string or None on any failure.
"""
try:
resp = httpx.get(f"{endpoint}/health", timeout=2)
if resp.status_code == _HTTP_OK:
body = resp.json()
version = body.get("version")
if isinstance(version, str) and version:
return version
except (httpx.HTTPError, OSError, ValueError, KeyError, TypeError, AttributeError):
pass
return None


def _is_localhost(endpoint: str) -> bool:
"""Check whether the endpoint is a localhost address."""
try:
from urllib.parse import urlparse # noqa: PLC0415

host = urlparse(endpoint).hostname or ""
except ValueError:
return False
else:
return host in ("localhost", "127.0.0.1", "::1")


def _normalize_arch(arch: str) -> str:
"""Normalize architecture names to match other SDKs."""
if arch == "aarch64":
return "arm64"
if arch == "x86_64":
return "x64"
return arch


def _build_payload(mode: str, platform_version: str | None = None) -> dict[str, object]:
"""Build the JSON payload for the checkpoint ping."""
return {
"sdk": "python",
"sdk_version": _SDK_VERSION,
"platform_version": None,
"os": platform.system(),
"arch": platform.machine(),
"platform_version": platform_version,
"os": platform.system().lower(),
"arch": _normalize_arch(platform.machine()),
"runtime_version": platform.python_version(),
"deployment_mode": mode,
"features": [],
"instance_id": str(uuid.uuid4()),
}


def _do_ping(url: str, payload: dict[str, object], debug: bool) -> None:
def _do_ping(url: str, mode: str, endpoint: str, debug: bool) -> None:
"""Execute the HTTP POST (runs inside a daemon thread)."""
try:
platform_version = _detect_platform_version(endpoint) if endpoint else None
payload = _build_payload(mode, platform_version)
resp = httpx.post(url, json=payload, timeout=_TIMEOUT_SECONDS)
if resp.status_code == _HTTP_OK:
try:
body = resp.json()
except (ValueError, KeyError):
except (ValueError, KeyError, TypeError, AttributeError):
return
latest = body.get("latest_version")
if latest and latest != _SDK_VERSION:
Expand All @@ -91,15 +131,15 @@ def _do_ping(url: str, payload: dict[str, object], debug: bool) -> None:
)
if debug:
logger.debug("Telemetry ping successful: %s", body)
except (httpx.HTTPError, OSError, ValueError):
except (httpx.HTTPError, OSError, ValueError, TypeError, AttributeError):
# Silent failure -- never disrupt the caller.
if debug:
logger.debug("Telemetry ping failed (non-fatal)", exc_info=True)


def send_telemetry_ping(
mode: str,
endpoint: str, # noqa: ARG001 kept for future platform_version detection
endpoint: str,
telemetry_enabled: bool | None,
has_credentials: bool = False,
debug: bool = False,
Expand All @@ -108,8 +148,8 @@ def send_telemetry_ping(

Args:
mode: SDK operation mode (``"production"`` or ``"sandbox"``).
endpoint: The AxonFlow agent endpoint (reserved for future
platform_version detection).
endpoint: The AxonFlow agent endpoint, used to detect the platform
version via ``/health``.
telemetry_enabled: Explicit config override. ``None`` means use the
mode-based default.
has_credentials: Whether the client was initialized with credentials
Expand All @@ -120,13 +160,16 @@ def send_telemetry_ping(
if not _is_telemetry_enabled(mode, telemetry_enabled, has_credentials):
return

# Suppress telemetry for localhost endpoints unless explicitly enabled.
if telemetry_enabled is not True and _is_localhost(endpoint):
return

logger.info(
"AxonFlow: anonymous telemetry enabled. "
"Opt out: AXONFLOW_TELEMETRY=off | https://docs.getaxonflow.com/telemetry"
)

url = os.environ.get("AXONFLOW_CHECKPOINT_URL", "").strip() or _DEFAULT_CHECKPOINT_URL
payload = _build_payload(mode)

t = threading.Thread(target=_do_ping, args=(url, payload, debug), daemon=True)
t = threading.Thread(target=_do_ping, args=(url, mode, endpoint, debug), daemon=True)
t.start()
41 changes: 41 additions & 0 deletions axonflow/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1127,3 +1127,44 @@ class UpdateMediaGovernanceConfigRequest(BaseModel):
CATEGORY_MEDIA_BIOMETRIC: str = "media-biometric"
CATEGORY_MEDIA_DOCUMENT: str = "media-document"
CATEGORY_MEDIA_PII: str = "media-pii"


# =========================================================================
# Audit Tool Call Types
# =========================================================================


class AuditToolCallRequest(BaseModel):
"""Request to record a non-LLM tool call in the audit trail."""

model_config = ConfigDict(populate_by_name=True)

tool_name: str = Field(description="Name of the tool that was called")
tool_type: str | None = Field(
default=None, description="Type of tool (e.g., mcp, api, function)"
)
input: dict[str, Any] | None = Field(default=None, alias="input", description="Tool input data")
output: dict[str, Any] | None = Field(
default=None, alias="output", description="Tool output data"
)
workflow_id: str | None = Field(default=None, description="Associated workflow ID")
step_id: str | None = Field(default=None, description="Associated step ID")
user_id: str | None = Field(default=None, description="User who triggered the tool call")
duration_ms: int | None = Field(
default=None, description="Duration of the tool call in milliseconds"
)
policies_applied: list[str] | None = Field(
default=None, description="List of policies applied to this tool call"
)
success: bool | None = Field(default=None, description="Whether the tool call succeeded")
error_message: str | None = Field(
default=None, description="Error message if the tool call failed"
)


class AuditToolCallResponse(BaseModel):
"""Response from recording a tool call audit entry."""

audit_id: str = Field(description="Unique ID for the audit entry")
status: str = Field(description="Recording status (e.g., recorded)")
timestamp: str = Field(description="Timestamp when the audit entry was recorded")
Loading
Loading