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.
Files changed (42) hide show
  1. agentfriendly-0.1.0/.gitignore +36 -0
  2. agentfriendly-0.1.0/LICENSE +3 -0
  3. agentfriendly-0.1.0/PKG-INFO +59 -0
  4. agentfriendly-0.1.0/README.md +0 -0
  5. agentfriendly-0.1.0/agentfriendly/__init__.py +82 -0
  6. agentfriendly-0.1.0/agentfriendly/access/__init__.py +18 -0
  7. agentfriendly-0.1.0/agentfriendly/access/policy_engine.py +137 -0
  8. agentfriendly-0.1.0/agentfriendly/access/rate_limiter.py +66 -0
  9. agentfriendly-0.1.0/agentfriendly/adapters/__init__.py +3 -0
  10. agentfriendly-0.1.0/agentfriendly/adapters/django.py +142 -0
  11. agentfriendly-0.1.0/agentfriendly/adapters/fastapi.py +113 -0
  12. agentfriendly-0.1.0/agentfriendly/adapters/flask.py +99 -0
  13. agentfriendly-0.1.0/agentfriendly/config.py +213 -0
  14. agentfriendly-0.1.0/agentfriendly/content/__init__.py +21 -0
  15. agentfriendly-0.1.0/agentfriendly/content/html_to_markdown.py +143 -0
  16. agentfriendly-0.1.0/agentfriendly/content/negotiator.py +87 -0
  17. agentfriendly-0.1.0/agentfriendly/detection/__init__.py +19 -0
  18. agentfriendly-0.1.0/agentfriendly/detection/pipeline.py +159 -0
  19. agentfriendly-0.1.0/agentfriendly/detection/signal_accept_header.py +99 -0
  20. agentfriendly-0.1.0/agentfriendly/detection/signal_header_heuristics.py +147 -0
  21. agentfriendly-0.1.0/agentfriendly/detection/signal_ua_database.py +145 -0
  22. agentfriendly-0.1.0/agentfriendly/discovery/__init__.py +21 -0
  23. agentfriendly-0.1.0/agentfriendly/discovery/generators.py +103 -0
  24. agentfriendly-0.1.0/agentfriendly/discovery/router.py +90 -0
  25. agentfriendly-0.1.0/agentfriendly/middleware.py +271 -0
  26. agentfriendly-0.1.0/agentfriendly/monetization/__init__.py +5 -0
  27. agentfriendly-0.1.0/agentfriendly/monetization/x402.py +145 -0
  28. agentfriendly-0.1.0/agentfriendly/multitenancy/__init__.py +17 -0
  29. agentfriendly-0.1.0/agentfriendly/multitenancy/token_issuer.py +130 -0
  30. agentfriendly-0.1.0/agentfriendly/privacy/__init__.py +11 -0
  31. agentfriendly-0.1.0/agentfriendly/privacy/masker.py +78 -0
  32. agentfriendly-0.1.0/agentfriendly/privacy/pii_patterns.py +64 -0
  33. agentfriendly-0.1.0/agentfriendly/types.py +190 -0
  34. agentfriendly-0.1.0/pyproject.toml +93 -0
  35. agentfriendly-0.1.0/tests/__init__.py +0 -0
  36. agentfriendly-0.1.0/tests/detection/__init__.py +0 -0
  37. agentfriendly-0.1.0/tests/detection/test_signal_accept_header.py +45 -0
  38. agentfriendly-0.1.0/tests/detection/test_signal_header_heuristics.py +44 -0
  39. agentfriendly-0.1.0/tests/test_access_policy.py +83 -0
  40. agentfriendly-0.1.0/tests/test_content.py +73 -0
  41. agentfriendly-0.1.0/tests/test_middleware.py +77 -0
  42. 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,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AgentFriendly
@@ -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,3 @@
1
+ """
2
+ Framework adapters for the agentfriendly Python SDK.
3
+ """
@@ -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