agentfriendly 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentfriendly-0.1.0/.gitignore +36 -0
- agentfriendly-0.1.0/LICENSE +3 -0
- agentfriendly-0.1.0/PKG-INFO +59 -0
- agentfriendly-0.1.0/README.md +0 -0
- agentfriendly-0.1.0/agentfriendly/__init__.py +82 -0
- agentfriendly-0.1.0/agentfriendly/access/__init__.py +18 -0
- agentfriendly-0.1.0/agentfriendly/access/policy_engine.py +137 -0
- agentfriendly-0.1.0/agentfriendly/access/rate_limiter.py +66 -0
- agentfriendly-0.1.0/agentfriendly/adapters/__init__.py +3 -0
- agentfriendly-0.1.0/agentfriendly/adapters/django.py +142 -0
- agentfriendly-0.1.0/agentfriendly/adapters/fastapi.py +113 -0
- agentfriendly-0.1.0/agentfriendly/adapters/flask.py +99 -0
- agentfriendly-0.1.0/agentfriendly/config.py +213 -0
- agentfriendly-0.1.0/agentfriendly/content/__init__.py +21 -0
- agentfriendly-0.1.0/agentfriendly/content/html_to_markdown.py +143 -0
- agentfriendly-0.1.0/agentfriendly/content/negotiator.py +87 -0
- agentfriendly-0.1.0/agentfriendly/detection/__init__.py +19 -0
- agentfriendly-0.1.0/agentfriendly/detection/pipeline.py +159 -0
- agentfriendly-0.1.0/agentfriendly/detection/signal_accept_header.py +99 -0
- agentfriendly-0.1.0/agentfriendly/detection/signal_header_heuristics.py +147 -0
- agentfriendly-0.1.0/agentfriendly/detection/signal_ua_database.py +145 -0
- agentfriendly-0.1.0/agentfriendly/discovery/__init__.py +21 -0
- agentfriendly-0.1.0/agentfriendly/discovery/generators.py +103 -0
- agentfriendly-0.1.0/agentfriendly/discovery/router.py +90 -0
- agentfriendly-0.1.0/agentfriendly/middleware.py +271 -0
- agentfriendly-0.1.0/agentfriendly/monetization/__init__.py +5 -0
- agentfriendly-0.1.0/agentfriendly/monetization/x402.py +145 -0
- agentfriendly-0.1.0/agentfriendly/multitenancy/__init__.py +17 -0
- agentfriendly-0.1.0/agentfriendly/multitenancy/token_issuer.py +130 -0
- agentfriendly-0.1.0/agentfriendly/privacy/__init__.py +11 -0
- agentfriendly-0.1.0/agentfriendly/privacy/masker.py +78 -0
- agentfriendly-0.1.0/agentfriendly/privacy/pii_patterns.py +64 -0
- agentfriendly-0.1.0/agentfriendly/types.py +190 -0
- agentfriendly-0.1.0/pyproject.toml +93 -0
- agentfriendly-0.1.0/tests/__init__.py +0 -0
- agentfriendly-0.1.0/tests/detection/__init__.py +0 -0
- agentfriendly-0.1.0/tests/detection/test_signal_accept_header.py +45 -0
- agentfriendly-0.1.0/tests/detection/test_signal_header_heuristics.py +44 -0
- agentfriendly-0.1.0/tests/test_access_policy.py +83 -0
- agentfriendly-0.1.0/tests/test_content.py +73 -0
- agentfriendly-0.1.0/tests/test_middleware.py +77 -0
- agentfriendly-0.1.0/tests/test_privacy.py +85 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
.pnpm-store/
|
|
3
|
+
dist/
|
|
4
|
+
build/
|
|
5
|
+
.next/
|
|
6
|
+
.turbo/
|
|
7
|
+
coverage/
|
|
8
|
+
*.tsbuildinfo
|
|
9
|
+
.DS_Store
|
|
10
|
+
.env
|
|
11
|
+
.env.local
|
|
12
|
+
.env.*.local
|
|
13
|
+
*.log
|
|
14
|
+
*.db
|
|
15
|
+
*.db-shm
|
|
16
|
+
*.db-wal
|
|
17
|
+
|
|
18
|
+
# Python
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.py[cod]
|
|
21
|
+
*$py.class
|
|
22
|
+
*.egg-info/
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
dist/
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
.mypy_cache/
|
|
28
|
+
.ruff_cache/
|
|
29
|
+
htmlcov/
|
|
30
|
+
.coverage
|
|
31
|
+
|
|
32
|
+
# IDE
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/settings.json
|
|
35
|
+
*.swp
|
|
36
|
+
*.swo
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentfriendly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Make your Python web app agent-friendly — detection, markdown serving, tool registry, x402 payments, multi-tenancy
|
|
5
|
+
Project-URL: Homepage, https://agentfriendly.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/Jana-kabrit/AgentFriendly
|
|
7
|
+
Project-URL: Documentation, https://docs.agentfriendly.dev
|
|
8
|
+
Author-email: AgentFriendly <sdk@agentfriendly.dev>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 AgentFriendly
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,ai,django,fastapi,flask,llm,middleware
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: FastAPI
|
|
17
|
+
Classifier: Framework :: Flask
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: cryptography>=42.0.0
|
|
27
|
+
Requires-Dist: fnmatch2>=0.0.7
|
|
28
|
+
Requires-Dist: httpx>=0.27.0
|
|
29
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
30
|
+
Provides-Extra: analytics-clickhouse
|
|
31
|
+
Requires-Dist: clickhouse-connect>=0.7.0; extra == 'analytics-clickhouse'
|
|
32
|
+
Provides-Extra: analytics-postgres
|
|
33
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'analytics-postgres'
|
|
34
|
+
Provides-Extra: analytics-sqlite
|
|
35
|
+
Provides-Extra: content
|
|
36
|
+
Requires-Dist: beautifulsoup4>=4.12.0; extra == 'content'
|
|
37
|
+
Requires-Dist: lxml>=5.0.0; extra == 'content'
|
|
38
|
+
Requires-Dist: markdownify>=0.12.0; extra == 'content'
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: beautifulsoup4>=4.12.0; extra == 'dev'
|
|
41
|
+
Requires-Dist: django>=4.2; extra == 'dev'
|
|
42
|
+
Requires-Dist: fastapi>=0.111.0; extra == 'dev'
|
|
43
|
+
Requires-Dist: flask>=3.0.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
45
|
+
Requires-Dist: lxml>=5.0.0; extra == 'dev'
|
|
46
|
+
Requires-Dist: markdownify>=0.12.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
52
|
+
Requires-Dist: starlette>=0.37.0; extra == 'dev'
|
|
53
|
+
Provides-Extra: django
|
|
54
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
55
|
+
Provides-Extra: fastapi
|
|
56
|
+
Requires-Dist: fastapi>=0.111.0; extra == 'fastapi'
|
|
57
|
+
Requires-Dist: starlette>=0.37.0; extra == 'fastapi'
|
|
58
|
+
Provides-Extra: flask
|
|
59
|
+
Requires-Dist: flask>=3.0.0; extra == 'flask'
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentfriendly — Python SDK
|
|
3
|
+
|
|
4
|
+
Make your Python web application agent-friendly.
|
|
5
|
+
|
|
6
|
+
Supports FastAPI, Django, Flask, and any ASGI/WSGI framework.
|
|
7
|
+
|
|
8
|
+
Quick start:
|
|
9
|
+
pip install agentfriendly[fastapi]
|
|
10
|
+
|
|
11
|
+
FastAPI:
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from agentfriendly.adapters.fastapi import AgentFriendlyMiddleware
|
|
14
|
+
from agentfriendly import AgentFriendlyConfig
|
|
15
|
+
|
|
16
|
+
app = FastAPI()
|
|
17
|
+
app.add_middleware(AgentFriendlyMiddleware, config=AgentFriendlyConfig())
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .config import (
|
|
21
|
+
AccessConfig,
|
|
22
|
+
AgentFriendlyConfig,
|
|
23
|
+
AnalyticsConfig,
|
|
24
|
+
ContentConfig,
|
|
25
|
+
DetectionConfig,
|
|
26
|
+
DiscoveryConfig,
|
|
27
|
+
MonetizationConfig,
|
|
28
|
+
MultiTenancyConfig,
|
|
29
|
+
PrivacyConfig,
|
|
30
|
+
ToolsConfig,
|
|
31
|
+
)
|
|
32
|
+
from .middleware import (
|
|
33
|
+
AgentFriendlyMiddleware,
|
|
34
|
+
ContentInstructions,
|
|
35
|
+
EarlyResponse,
|
|
36
|
+
OrchestratorResult,
|
|
37
|
+
get_agent_context,
|
|
38
|
+
)
|
|
39
|
+
from .multitenancy import issue_delegation_token, revoke_session, validate_delegation_token
|
|
40
|
+
from .types import (
|
|
41
|
+
TIER_ORDER,
|
|
42
|
+
AgentContext,
|
|
43
|
+
AgentEntry,
|
|
44
|
+
DetectionSignal,
|
|
45
|
+
TenantContext,
|
|
46
|
+
TrustTier,
|
|
47
|
+
VerifiedIdentity,
|
|
48
|
+
meets_minimum_tier,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# Config
|
|
53
|
+
"AgentFriendlyConfig",
|
|
54
|
+
"DetectionConfig",
|
|
55
|
+
"DiscoveryConfig",
|
|
56
|
+
"ContentConfig",
|
|
57
|
+
"AnalyticsConfig",
|
|
58
|
+
"AccessConfig",
|
|
59
|
+
"PrivacyConfig",
|
|
60
|
+
"ToolsConfig",
|
|
61
|
+
"MonetizationConfig",
|
|
62
|
+
"MultiTenancyConfig",
|
|
63
|
+
# Middleware
|
|
64
|
+
"AgentFriendlyMiddleware",
|
|
65
|
+
"get_agent_context",
|
|
66
|
+
"OrchestratorResult",
|
|
67
|
+
"EarlyResponse",
|
|
68
|
+
"ContentInstructions",
|
|
69
|
+
# Types
|
|
70
|
+
"TrustTier",
|
|
71
|
+
"DetectionSignal",
|
|
72
|
+
"AgentContext",
|
|
73
|
+
"AgentEntry",
|
|
74
|
+
"VerifiedIdentity",
|
|
75
|
+
"TenantContext",
|
|
76
|
+
"TIER_ORDER",
|
|
77
|
+
"meets_minimum_tier",
|
|
78
|
+
# Multi-tenancy
|
|
79
|
+
"issue_delegation_token",
|
|
80
|
+
"validate_delegation_token",
|
|
81
|
+
"revoke_session",
|
|
82
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Layer 4 — Access Control"""
|
|
2
|
+
|
|
3
|
+
from .policy_engine import (
|
|
4
|
+
PolicyDecision,
|
|
5
|
+
PolicyResult,
|
|
6
|
+
evaluate_policy,
|
|
7
|
+
generate_robots_txt_ai_section,
|
|
8
|
+
)
|
|
9
|
+
from .rate_limiter import InMemoryRateLimiter, get_rate_limit_key
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"evaluate_policy",
|
|
13
|
+
"PolicyDecision",
|
|
14
|
+
"PolicyResult",
|
|
15
|
+
"generate_robots_txt_ai_section",
|
|
16
|
+
"InMemoryRateLimiter",
|
|
17
|
+
"get_rate_limit_key",
|
|
18
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layer 4 — Access Control Policy Engine
|
|
3
|
+
|
|
4
|
+
Mirrors packages/core/src/access/policy-engine.ts
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from ..config import AccessConfig, AgentTypePolicy
|
|
14
|
+
from ..types import AgentContext
|
|
15
|
+
|
|
16
|
+
PolicyDecision = Literal["allow", "deny", "rate-limit"]
|
|
17
|
+
|
|
18
|
+
CATEGORY_UA_REPRESENTATIVES: dict[str, list[str]] = {
|
|
19
|
+
"training-crawler": [
|
|
20
|
+
"GPTBot", "ClaudeBot", "Google-Extended", "CCBot",
|
|
21
|
+
"Bytespider", "anthropic-ai", "Meta-ExternalAgent",
|
|
22
|
+
"Amazonbot", "Applebot-Extended", "cohere-ai", "AI2Bot",
|
|
23
|
+
],
|
|
24
|
+
"search-bot": [
|
|
25
|
+
"OAI-SearchBot", "ChatGPT-User", "PerplexityBot",
|
|
26
|
+
"YouBot", "DuckAssistBot",
|
|
27
|
+
],
|
|
28
|
+
"interactive-agent": [
|
|
29
|
+
"GoogleAgent-URLContext", "Claude-Web", "Claude-SearchBot",
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class PolicyResult:
|
|
36
|
+
decision: PolicyDecision
|
|
37
|
+
reason: str
|
|
38
|
+
status_code: int | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_ALLOW = PolicyResult(decision="allow", reason="No matching deny rules", status_code=None)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _deny(reason: str) -> PolicyResult:
|
|
45
|
+
return PolicyResult(decision="deny", reason=reason, status_code=403)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _apply_agent_policy(
|
|
49
|
+
policy: AgentTypePolicy,
|
|
50
|
+
path: str,
|
|
51
|
+
allow_patterns: list[str],
|
|
52
|
+
) -> PolicyResult | None:
|
|
53
|
+
"""Apply a named policy. Returns None to continue evaluation, or a final result."""
|
|
54
|
+
if policy == "deny-all":
|
|
55
|
+
return _deny("Agent type policy: deny-all")
|
|
56
|
+
if policy == "allow-all":
|
|
57
|
+
return None
|
|
58
|
+
if policy == "allow-public":
|
|
59
|
+
if allow_patterns and not any(fnmatch.fnmatch(path, p) for p in allow_patterns):
|
|
60
|
+
return _deny("Agent type policy: allow-public — path not in allow list")
|
|
61
|
+
return None
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def evaluate_policy(context: AgentContext, config: AccessConfig) -> PolicyResult:
|
|
66
|
+
"""Evaluate the access policy for an agent request."""
|
|
67
|
+
if not context.is_agent:
|
|
68
|
+
return _ALLOW
|
|
69
|
+
|
|
70
|
+
path = context.path
|
|
71
|
+
allow_patterns = config.allow or []
|
|
72
|
+
|
|
73
|
+
# 1. Per-operator overrides
|
|
74
|
+
if config.operators and context.matched_agent:
|
|
75
|
+
op_policy = config.operators.get(context.matched_agent.operator)
|
|
76
|
+
if op_policy:
|
|
77
|
+
result = _apply_agent_policy(op_policy, path, allow_patterns)
|
|
78
|
+
if result:
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
# 2. Per-category overrides
|
|
82
|
+
if config.agent_types and context.agent_category:
|
|
83
|
+
cat_policy = config.agent_types.get(context.agent_category)
|
|
84
|
+
if cat_policy:
|
|
85
|
+
result = _apply_agent_policy(cat_policy, path, allow_patterns)
|
|
86
|
+
if result:
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
# 3. Suspected-agent policy
|
|
90
|
+
if context.tier == "suspected-agent":
|
|
91
|
+
sus_policy = config.agent_types.get("suspected-agent") if config.agent_types else None
|
|
92
|
+
if sus_policy:
|
|
93
|
+
result = _apply_agent_policy(sus_policy, path, allow_patterns)
|
|
94
|
+
if result:
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
# 4. Route-level deny rules
|
|
98
|
+
deny_patterns = config.deny or []
|
|
99
|
+
if deny_patterns and any(fnmatch.fnmatch(path, p) for p in deny_patterns):
|
|
100
|
+
if allow_patterns and any(fnmatch.fnmatch(path, p) for p in allow_patterns):
|
|
101
|
+
return _ALLOW
|
|
102
|
+
return _deny(f'Path "{path}" matches deny pattern')
|
|
103
|
+
|
|
104
|
+
return _ALLOW
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_robots_txt_ai_section(config: AccessConfig) -> str:
|
|
108
|
+
"""Generate the AI/agent section of robots.txt from config."""
|
|
109
|
+
lines = [
|
|
110
|
+
"# AI Agent Access Control — generated by agentfriendly",
|
|
111
|
+
"# Modify these rules via access.agent_types in AgentFriendlyConfig",
|
|
112
|
+
"",
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
for category, policy in (config.agent_types or {}).items():
|
|
116
|
+
reps = CATEGORY_UA_REPRESENTATIVES.get(str(category), [])
|
|
117
|
+
if not reps:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
lines.append(f"# {category} — policy: {policy}")
|
|
121
|
+
for ua in reps:
|
|
122
|
+
lines.append(f"User-agent: {ua}")
|
|
123
|
+
|
|
124
|
+
if policy == "deny-all":
|
|
125
|
+
lines.append("Disallow: /")
|
|
126
|
+
elif policy == "allow-public":
|
|
127
|
+
denied = [p for p in (config.deny or []) if "*" not in p]
|
|
128
|
+
for path in denied:
|
|
129
|
+
lines.append(f"Disallow: {path}")
|
|
130
|
+
if not denied:
|
|
131
|
+
lines.append("Allow: /")
|
|
132
|
+
else:
|
|
133
|
+
lines.append("Allow: /")
|
|
134
|
+
|
|
135
|
+
lines.append("")
|
|
136
|
+
|
|
137
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Layer 4 — In-Memory Rate Limiter"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from ..types import AgentContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryRateLimiter:
|
|
12
|
+
"""
|
|
13
|
+
Simple sliding window rate limiter.
|
|
14
|
+
Thread-safe for single-process deployments.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, max_requests: int, window_seconds: int = 60) -> None:
|
|
18
|
+
self.max_requests = max_requests
|
|
19
|
+
self.window_seconds = window_seconds
|
|
20
|
+
self._windows: dict[str, list[float]] = defaultdict(list)
|
|
21
|
+
|
|
22
|
+
def check(self, key: str) -> bool:
|
|
23
|
+
"""Returns True if the request is within the limit, False if rate-limited."""
|
|
24
|
+
now = time.monotonic()
|
|
25
|
+
window_start = now - self.window_seconds
|
|
26
|
+
timestamps = self._windows[key]
|
|
27
|
+
|
|
28
|
+
# Prune old timestamps
|
|
29
|
+
self._windows[key] = [t for t in timestamps if t > window_start]
|
|
30
|
+
|
|
31
|
+
if len(self._windows[key]) >= self.max_requests:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
self._windows[key].append(now)
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def get_count(self, key: str) -> int:
|
|
38
|
+
now = time.monotonic()
|
|
39
|
+
window_start = now - self.window_seconds
|
|
40
|
+
return sum(1 for t in self._windows[key] if t > window_start)
|
|
41
|
+
|
|
42
|
+
def clear(self) -> None:
|
|
43
|
+
self._windows.clear()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_rate_limit_key(
|
|
47
|
+
context: AgentContext,
|
|
48
|
+
key_by: str = "identity",
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Derive the rate limit key from an agent context."""
|
|
51
|
+
if key_by == "ip":
|
|
52
|
+
return (
|
|
53
|
+
context.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
54
|
+
or context.headers.get("x-real-ip", "unknown-ip")
|
|
55
|
+
)
|
|
56
|
+
if key_by == "ua":
|
|
57
|
+
return context.user_agent or "no-ua"
|
|
58
|
+
# Default: "identity"
|
|
59
|
+
if context.verified_identity:
|
|
60
|
+
return context.verified_identity.agent_id
|
|
61
|
+
if context.matched_agent:
|
|
62
|
+
return context.matched_agent.agent_name
|
|
63
|
+
return (
|
|
64
|
+
context.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
65
|
+
or "unknown"
|
|
66
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django Middleware Adapter
|
|
3
|
+
|
|
4
|
+
Add to MIDDLEWARE in settings.py:
|
|
5
|
+
|
|
6
|
+
MIDDLEWARE = [
|
|
7
|
+
"agentfriendly.adapters.django.AgentFriendlyMiddleware",
|
|
8
|
+
# ... other middleware
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
Configure in settings.py:
|
|
12
|
+
|
|
13
|
+
AGENTFRIENDLY = {
|
|
14
|
+
"detection": {"proactive_markdown": "known"},
|
|
15
|
+
"content": {"markdown": True},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Access context in views:
|
|
19
|
+
|
|
20
|
+
from agentfriendly import get_agent_context
|
|
21
|
+
|
|
22
|
+
def my_view(request):
|
|
23
|
+
ctx = get_agent_context()
|
|
24
|
+
# or via request
|
|
25
|
+
ctx = getattr(request, "agent_context", None)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from django.conf import settings # type: ignore[import-untyped]
|
|
31
|
+
from django.http import HttpRequest, HttpResponse # type: ignore[import-untyped]
|
|
32
|
+
|
|
33
|
+
from ..config import AgentFriendlyConfig
|
|
34
|
+
from ..content.html_to_markdown import html_to_markdown
|
|
35
|
+
from ..middleware import AgentFriendlyMiddleware as CoreMiddleware
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_config_from_settings() -> AgentFriendlyConfig:
|
|
39
|
+
"""Load config from Django settings.AGENTFRIENDLY dict."""
|
|
40
|
+
raw = getattr(settings, "AGENTFRIENDLY", {})
|
|
41
|
+
if not isinstance(raw, dict):
|
|
42
|
+
return AgentFriendlyConfig()
|
|
43
|
+
return _dict_to_config(raw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dict_to_config(raw: dict[str, object]) -> AgentFriendlyConfig:
|
|
47
|
+
"""Convert a plain settings dict to AgentFriendlyConfig."""
|
|
48
|
+
from ..config import (
|
|
49
|
+
AccessConfig,
|
|
50
|
+
AnalyticsConfig,
|
|
51
|
+
ContentConfig,
|
|
52
|
+
DetectionConfig,
|
|
53
|
+
DiscoveryConfig,
|
|
54
|
+
MonetizationConfig,
|
|
55
|
+
MultiTenancyConfig,
|
|
56
|
+
PrivacyConfig,
|
|
57
|
+
ToolsConfig,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def section(name: str, cls): # type: ignore[no-untyped-def]
|
|
61
|
+
data = raw.get(name, {})
|
|
62
|
+
if isinstance(data, dict):
|
|
63
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
64
|
+
return cls()
|
|
65
|
+
|
|
66
|
+
return AgentFriendlyConfig(
|
|
67
|
+
detection=section("detection", DetectionConfig),
|
|
68
|
+
discovery=section("discovery", DiscoveryConfig),
|
|
69
|
+
content=section("content", ContentConfig),
|
|
70
|
+
analytics=section("analytics", AnalyticsConfig),
|
|
71
|
+
access=section("access", AccessConfig),
|
|
72
|
+
privacy=section("privacy", PrivacyConfig),
|
|
73
|
+
tools=section("tools", ToolsConfig),
|
|
74
|
+
monetization=section("monetization", MonetizationConfig),
|
|
75
|
+
multi_tenancy=section("multi_tenancy", MultiTenancyConfig),
|
|
76
|
+
debug=bool(raw.get("debug", False)),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AgentFriendlyMiddleware:
|
|
81
|
+
"""Django middleware class (get_response pattern)."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, get_response): # type: ignore[no-untyped-def]
|
|
84
|
+
self.get_response = get_response
|
|
85
|
+
config = _load_config_from_settings()
|
|
86
|
+
self._sdk = CoreMiddleware(config)
|
|
87
|
+
|
|
88
|
+
def __call__(self, request: HttpRequest): # type: ignore[no-untyped-def]
|
|
89
|
+
import asyncio
|
|
90
|
+
|
|
91
|
+
# Run the async SDK process in the sync Django context
|
|
92
|
+
loop = asyncio.new_event_loop()
|
|
93
|
+
try:
|
|
94
|
+
result = loop.run_until_complete(
|
|
95
|
+
self._sdk.process(
|
|
96
|
+
method=request.method,
|
|
97
|
+
path=request.path,
|
|
98
|
+
headers={k.lower(): v for k, v in request.META.items() if k.startswith("HTTP_")},
|
|
99
|
+
url=request.build_absolute_uri(),
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
finally:
|
|
103
|
+
loop.close()
|
|
104
|
+
|
|
105
|
+
# Attach context to the request
|
|
106
|
+
request.agent_context = result.context # type: ignore[attr-defined]
|
|
107
|
+
|
|
108
|
+
# Serve early responses
|
|
109
|
+
if result.early_response:
|
|
110
|
+
er = result.early_response
|
|
111
|
+
response = HttpResponse(
|
|
112
|
+
content=er.body,
|
|
113
|
+
status=er.status,
|
|
114
|
+
content_type=er.content_type,
|
|
115
|
+
)
|
|
116
|
+
for key, value in er.headers.items():
|
|
117
|
+
response[key] = value
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
# Let Django handle the request
|
|
121
|
+
response = self.get_response(request)
|
|
122
|
+
|
|
123
|
+
# Inject agent headers
|
|
124
|
+
for key, value in result.content_instructions.agent_headers.items():
|
|
125
|
+
response[key] = value
|
|
126
|
+
|
|
127
|
+
# Convert HTML→Markdown for agent requests
|
|
128
|
+
if result.content_instructions.convert_to_markdown:
|
|
129
|
+
ct = response.get("Content-Type", "")
|
|
130
|
+
if "text/html" in ct:
|
|
131
|
+
body = response.content.decode("utf-8", errors="replace")
|
|
132
|
+
md_result = html_to_markdown(
|
|
133
|
+
body,
|
|
134
|
+
request.build_absolute_uri(),
|
|
135
|
+
result.content_instructions.additional_strip_selectors,
|
|
136
|
+
)
|
|
137
|
+
response.content = md_result.markdown.encode("utf-8")
|
|
138
|
+
response["Content-Type"] = "text/markdown; charset=utf-8"
|
|
139
|
+
response["x-markdown-tokens"] = str(md_result.estimated_tokens)
|
|
140
|
+
del response["Content-Length"]
|
|
141
|
+
|
|
142
|
+
return response
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI / Starlette Adapter
|
|
3
|
+
|
|
4
|
+
Add as ASGI middleware:
|
|
5
|
+
|
|
6
|
+
from agentfriendly.adapters.fastapi import AgentFriendlyMiddleware as AFMiddleware
|
|
7
|
+
from agentfriendly import AgentFriendlyConfig
|
|
8
|
+
|
|
9
|
+
app.add_middleware(
|
|
10
|
+
AFMiddleware,
|
|
11
|
+
config=AgentFriendlyConfig(
|
|
12
|
+
detection=DetectionConfig(proactive_markdown="known"),
|
|
13
|
+
),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Access context in route handlers:
|
|
17
|
+
|
|
18
|
+
from agentfriendly import get_agent_context
|
|
19
|
+
|
|
20
|
+
@app.get("/docs")
|
|
21
|
+
async def docs(request: Request):
|
|
22
|
+
ctx = get_agent_context()
|
|
23
|
+
return {"is_agent": ctx.is_agent if ctx else False}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
31
|
+
from starlette.requests import Request
|
|
32
|
+
from starlette.responses import Response
|
|
33
|
+
|
|
34
|
+
from ..config import AgentFriendlyConfig
|
|
35
|
+
from ..content.html_to_markdown import html_to_markdown
|
|
36
|
+
from ..middleware import AgentFriendlyMiddleware as CoreMiddleware
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentFriendlyMiddleware(BaseHTTPMiddleware):
|
|
40
|
+
"""
|
|
41
|
+
Starlette/FastAPI ASGI middleware.
|
|
42
|
+
Compatible with FastAPI, Starlette, and any ASGI framework.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, app: object, config: AgentFriendlyConfig | None = None) -> None:
|
|
46
|
+
super().__init__(app) # type: ignore[arg-type]
|
|
47
|
+
self._sdk = CoreMiddleware(config)
|
|
48
|
+
|
|
49
|
+
async def dispatch(
|
|
50
|
+
self,
|
|
51
|
+
request: Request,
|
|
52
|
+
call_next: Callable[[Request], Awaitable[Response]],
|
|
53
|
+
) -> Response:
|
|
54
|
+
# Build header dict (lowercased)
|
|
55
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
56
|
+
path = request.url.path
|
|
57
|
+
url = str(request.url)
|
|
58
|
+
|
|
59
|
+
result = await self._sdk.process(
|
|
60
|
+
method=request.method,
|
|
61
|
+
path=path,
|
|
62
|
+
headers=headers,
|
|
63
|
+
url=url,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Attach context to request state for downstream access
|
|
67
|
+
request.state.agent_context = result.context
|
|
68
|
+
|
|
69
|
+
# Serve early responses directly
|
|
70
|
+
if result.early_response:
|
|
71
|
+
er = result.early_response
|
|
72
|
+
return Response(
|
|
73
|
+
content=er.body,
|
|
74
|
+
status_code=er.status,
|
|
75
|
+
headers=er.headers,
|
|
76
|
+
media_type=er.content_type,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Let the route handler produce a response
|
|
80
|
+
response = await call_next(request)
|
|
81
|
+
|
|
82
|
+
# Inject agent headers
|
|
83
|
+
for key, value in result.content_instructions.agent_headers.items():
|
|
84
|
+
response.headers[key] = value
|
|
85
|
+
|
|
86
|
+
# Convert HTML→Markdown for agent requests
|
|
87
|
+
if result.content_instructions.convert_to_markdown:
|
|
88
|
+
content_type = response.headers.get("content-type", "")
|
|
89
|
+
if "text/html" in content_type:
|
|
90
|
+
body_bytes = b""
|
|
91
|
+
async for chunk in response.body_iterator: # type: ignore[attr-defined]
|
|
92
|
+
body_bytes += chunk if isinstance(chunk, bytes) else chunk.encode()
|
|
93
|
+
html_body = body_bytes.decode("utf-8", errors="replace")
|
|
94
|
+
|
|
95
|
+
md_result = html_to_markdown(
|
|
96
|
+
html_body,
|
|
97
|
+
url,
|
|
98
|
+
result.content_instructions.additional_strip_selectors,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
new_headers = dict(response.headers)
|
|
102
|
+
new_headers["content-type"] = "text/markdown; charset=utf-8"
|
|
103
|
+
new_headers["x-markdown-tokens"] = str(md_result.estimated_tokens)
|
|
104
|
+
new_headers.pop("content-length", None)
|
|
105
|
+
|
|
106
|
+
return Response(
|
|
107
|
+
content=md_result.markdown.encode("utf-8"),
|
|
108
|
+
status_code=response.status_code,
|
|
109
|
+
headers=new_headers,
|
|
110
|
+
media_type="text/markdown",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return response
|