webagents 0.1.0__py3-none-any.whl
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.
- webagents/__init__.py +18 -0
- webagents/__main__.py +55 -0
- webagents/agents/__init__.py +13 -0
- webagents/agents/core/__init__.py +19 -0
- webagents/agents/core/base_agent.py +1834 -0
- webagents/agents/core/handoffs.py +293 -0
- webagents/agents/handoffs/__init__.py +0 -0
- webagents/agents/interfaces/__init__.py +0 -0
- webagents/agents/lifecycle/__init__.py +0 -0
- webagents/agents/skills/__init__.py +109 -0
- webagents/agents/skills/base.py +136 -0
- webagents/agents/skills/core/__init__.py +8 -0
- webagents/agents/skills/core/guardrails/__init__.py +0 -0
- webagents/agents/skills/core/llm/__init__.py +0 -0
- webagents/agents/skills/core/llm/anthropic/__init__.py +1 -0
- webagents/agents/skills/core/llm/litellm/__init__.py +10 -0
- webagents/agents/skills/core/llm/litellm/skill.py +538 -0
- webagents/agents/skills/core/llm/openai/__init__.py +1 -0
- webagents/agents/skills/core/llm/xai/__init__.py +1 -0
- webagents/agents/skills/core/mcp/README.md +375 -0
- webagents/agents/skills/core/mcp/__init__.py +15 -0
- webagents/agents/skills/core/mcp/skill.py +731 -0
- webagents/agents/skills/core/memory/__init__.py +11 -0
- webagents/agents/skills/core/memory/long_term_memory/__init__.py +10 -0
- webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +639 -0
- webagents/agents/skills/core/memory/short_term_memory/__init__.py +9 -0
- webagents/agents/skills/core/memory/short_term_memory/skill.py +341 -0
- webagents/agents/skills/core/memory/vector_memory/skill.py +447 -0
- webagents/agents/skills/core/planning/__init__.py +9 -0
- webagents/agents/skills/core/planning/planner.py +343 -0
- webagents/agents/skills/ecosystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/crewai/__init__.py +1 -0
- webagents/agents/skills/ecosystem/database/__init__.py +1 -0
- webagents/agents/skills/ecosystem/filesystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/calendar/__init__.py +6 -0
- webagents/agents/skills/ecosystem/google/calendar/skill.py +306 -0
- webagents/agents/skills/ecosystem/n8n/__init__.py +0 -0
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- webagents/agents/skills/ecosystem/web/__init__.py +0 -0
- webagents/agents/skills/ecosystem/zapier/__init__.py +0 -0
- webagents/agents/skills/robutler/__init__.py +11 -0
- webagents/agents/skills/robutler/auth/README.md +63 -0
- webagents/agents/skills/robutler/auth/__init__.py +17 -0
- webagents/agents/skills/robutler/auth/skill.py +354 -0
- webagents/agents/skills/robutler/crm/__init__.py +18 -0
- webagents/agents/skills/robutler/crm/skill.py +368 -0
- webagents/agents/skills/robutler/discovery/README.md +281 -0
- webagents/agents/skills/robutler/discovery/__init__.py +16 -0
- webagents/agents/skills/robutler/discovery/skill.py +230 -0
- webagents/agents/skills/robutler/kv/__init__.py +6 -0
- webagents/agents/skills/robutler/kv/skill.py +80 -0
- webagents/agents/skills/robutler/message_history/__init__.py +9 -0
- webagents/agents/skills/robutler/message_history/skill.py +270 -0
- webagents/agents/skills/robutler/messages/__init__.py +0 -0
- webagents/agents/skills/robutler/nli/__init__.py +13 -0
- webagents/agents/skills/robutler/nli/skill.py +687 -0
- webagents/agents/skills/robutler/notifications/__init__.py +5 -0
- webagents/agents/skills/robutler/notifications/skill.py +141 -0
- webagents/agents/skills/robutler/payments/__init__.py +41 -0
- webagents/agents/skills/robutler/payments/exceptions.py +255 -0
- webagents/agents/skills/robutler/payments/skill.py +610 -0
- webagents/agents/skills/robutler/storage/__init__.py +10 -0
- webagents/agents/skills/robutler/storage/files/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/files/skill.py +445 -0
- webagents/agents/skills/robutler/storage/json/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/json/skill.py +336 -0
- webagents/agents/skills/robutler/storage/kv/skill.py +88 -0
- webagents/agents/skills/robutler/storage.py +389 -0
- webagents/agents/tools/__init__.py +0 -0
- webagents/agents/tools/decorators.py +426 -0
- webagents/agents/tracing/__init__.py +0 -0
- webagents/agents/workflows/__init__.py +0 -0
- webagents/scripts/__init__.py +0 -0
- webagents/server/__init__.py +28 -0
- webagents/server/context/__init__.py +0 -0
- webagents/server/context/context_vars.py +121 -0
- webagents/server/core/__init__.py +0 -0
- webagents/server/core/app.py +843 -0
- webagents/server/core/middleware.py +69 -0
- webagents/server/core/models.py +98 -0
- webagents/server/core/monitoring.py +59 -0
- webagents/server/endpoints/__init__.py +0 -0
- webagents/server/interfaces/__init__.py +0 -0
- webagents/server/middleware.py +330 -0
- webagents/server/models.py +92 -0
- webagents/server/monitoring.py +659 -0
- webagents/utils/__init__.py +0 -0
- webagents/utils/logging.py +359 -0
- webagents-0.1.0.dist-info/METADATA +230 -0
- webagents-0.1.0.dist-info/RECORD +94 -0
- webagents-0.1.0.dist-info/WHEEL +4 -0
- webagents-0.1.0.dist-info/entry_points.txt +2 -0
- webagents-0.1.0.dist-info/licenses/LICENSE +20 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
"""
|
2
|
+
FastAPI Middleware - WebAgents V2.0
|
3
|
+
|
4
|
+
Request logging and rate limiting middleware for the WebAgents server.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import time
|
8
|
+
import logging
|
9
|
+
from typing import Dict, Any, Optional
|
10
|
+
from dataclasses import dataclass
|
11
|
+
from fastapi import Request, Response
|
12
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
13
|
+
|
14
|
+
# Get logger for middleware
|
15
|
+
middleware_logger = logging.getLogger("webagents.server.middleware")
|
16
|
+
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class RateLimitRule:
|
20
|
+
"""Rate limiting rule configuration"""
|
21
|
+
requests_per_minute: int = 60
|
22
|
+
requests_per_hour: int = 1000
|
23
|
+
burst_limit: int = 10
|
24
|
+
|
25
|
+
|
26
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
27
|
+
"""Middleware for logging HTTP requests"""
|
28
|
+
|
29
|
+
def __init__(self, app, timeout: float = 300.0):
|
30
|
+
super().__init__(app)
|
31
|
+
self.timeout = timeout
|
32
|
+
|
33
|
+
async def dispatch(self, request: Request, call_next):
|
34
|
+
start_time = time.time()
|
35
|
+
|
36
|
+
# Log request using proper logging (INFO level for important requests)
|
37
|
+
middleware_logger.info(f"{request.method} {request.url.path}")
|
38
|
+
|
39
|
+
# Process request
|
40
|
+
response = await call_next(request)
|
41
|
+
|
42
|
+
# Log response
|
43
|
+
duration = time.time() - start_time
|
44
|
+
|
45
|
+
# Use different log levels based on status code
|
46
|
+
if response.status_code >= 400:
|
47
|
+
middleware_logger.warning(f"{response.status_code} - {duration:.3f}s")
|
48
|
+
else:
|
49
|
+
middleware_logger.info(f"{response.status_code} - {duration:.3f}s")
|
50
|
+
|
51
|
+
return response
|
52
|
+
|
53
|
+
|
54
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
55
|
+
"""Middleware for rate limiting requests"""
|
56
|
+
|
57
|
+
def __init__(self, app, default_rule: RateLimitRule, user_rules: Dict[str, RateLimitRule]):
|
58
|
+
super().__init__(app)
|
59
|
+
self.default_rule = default_rule
|
60
|
+
self.user_rules = user_rules
|
61
|
+
self.request_counts = {}
|
62
|
+
|
63
|
+
async def dispatch(self, request: Request, call_next):
|
64
|
+
# Simple rate limiting (in production, use Redis or similar)
|
65
|
+
client_ip = request.client.host if request.client else "unknown"
|
66
|
+
|
67
|
+
# For now, just pass through - rate limiting would be implemented here
|
68
|
+
response = await call_next(request)
|
69
|
+
return response
|
@@ -0,0 +1,98 @@
|
|
1
|
+
"""
|
2
|
+
FastAPI Request/Response Models - WebAgents V2.0
|
3
|
+
|
4
|
+
Pydantic models for OpenAI-compatible API endpoints and server responses.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import List, Dict, Any, Optional, Union
|
8
|
+
from pydantic import BaseModel, Field
|
9
|
+
|
10
|
+
|
11
|
+
class ChatMessage(BaseModel):
|
12
|
+
"""OpenAI-compatible chat message"""
|
13
|
+
role: str = Field(..., description="Message role: 'system', 'user', 'assistant', or 'tool'")
|
14
|
+
content: Optional[str] = Field(None, description="Message content")
|
15
|
+
tool_calls: Optional[List[Dict[str, Any]]] = Field(None, description="Tool calls in the message")
|
16
|
+
tool_call_id: Optional[str] = Field(None, description="Tool call ID for tool responses")
|
17
|
+
|
18
|
+
|
19
|
+
class ToolFunction(BaseModel):
|
20
|
+
"""OpenAI-compatible tool function definition"""
|
21
|
+
name: str = Field(..., description="Function name")
|
22
|
+
description: str = Field(..., description="Function description")
|
23
|
+
parameters: Dict[str, Any] = Field(..., description="JSON schema for function parameters")
|
24
|
+
|
25
|
+
|
26
|
+
class Tool(BaseModel):
|
27
|
+
"""OpenAI-compatible tool definition"""
|
28
|
+
type: str = Field("function", description="Tool type")
|
29
|
+
function: ToolFunction = Field(..., description="Function definition")
|
30
|
+
|
31
|
+
|
32
|
+
class ChatCompletionRequest(BaseModel):
|
33
|
+
"""OpenAI-compatible chat completion request"""
|
34
|
+
model: str = Field(..., description="Model name")
|
35
|
+
messages: List[ChatMessage] = Field(..., description="List of messages")
|
36
|
+
tools: Optional[List[Tool]] = Field(None, description="Available tools")
|
37
|
+
tool_choice: Optional[Union[str, Dict[str, Any]]] = Field(None, description="Tool choice strategy")
|
38
|
+
temperature: Optional[float] = Field(None, ge=0, le=2, description="Sampling temperature")
|
39
|
+
max_tokens: Optional[int] = Field(None, gt=0, description="Maximum tokens to generate")
|
40
|
+
stream: bool = Field(False, description="Whether to stream the response")
|
41
|
+
top_p: Optional[float] = Field(None, ge=0, le=1, description="Nucleus sampling parameter")
|
42
|
+
frequency_penalty: Optional[float] = Field(None, ge=-2, le=2, description="Frequency penalty")
|
43
|
+
presence_penalty: Optional[float] = Field(None, ge=-2, le=2, description="Presence penalty")
|
44
|
+
stop: Optional[Union[str, List[str]]] = Field(None, description="Stop sequences")
|
45
|
+
|
46
|
+
|
47
|
+
class Usage(BaseModel):
|
48
|
+
"""OpenAI-compatible usage statistics"""
|
49
|
+
prompt_tokens: int = Field(..., description="Number of tokens in the prompt")
|
50
|
+
completion_tokens: int = Field(..., description="Number of tokens in the completion")
|
51
|
+
total_tokens: int = Field(..., description="Total number of tokens used")
|
52
|
+
|
53
|
+
|
54
|
+
class ChatCompletionChoice(BaseModel):
|
55
|
+
"""OpenAI-compatible chat completion choice"""
|
56
|
+
index: int = Field(..., description="Choice index")
|
57
|
+
message: ChatMessage = Field(..., description="Generated message")
|
58
|
+
finish_reason: str = Field(..., description="Reason for completion finish")
|
59
|
+
|
60
|
+
|
61
|
+
class ChatCompletionResponse(BaseModel):
|
62
|
+
"""OpenAI-compatible chat completion response"""
|
63
|
+
id: str = Field(..., description="Completion ID")
|
64
|
+
object: str = Field("chat.completion", description="Object type")
|
65
|
+
created: int = Field(..., description="Unix timestamp of creation")
|
66
|
+
model: str = Field(..., description="Model used for completion")
|
67
|
+
choices: List[ChatCompletionChoice] = Field(..., description="List of completion choices")
|
68
|
+
usage: Usage = Field(..., description="Token usage statistics")
|
69
|
+
|
70
|
+
|
71
|
+
class AgentInfoResponse(BaseModel):
|
72
|
+
"""Agent information response"""
|
73
|
+
name: str = Field(..., description="Agent name")
|
74
|
+
instructions: str = Field(..., description="Agent instructions")
|
75
|
+
model: str = Field(..., description="Model identifier")
|
76
|
+
endpoints: Dict[str, str] = Field(..., description="Available endpoints")
|
77
|
+
|
78
|
+
|
79
|
+
class HealthResponse(BaseModel):
|
80
|
+
"""Health check response"""
|
81
|
+
status: str = Field(..., description="Health status")
|
82
|
+
version: str = Field(..., description="Server version")
|
83
|
+
uptime_seconds: float = Field(..., description="Server uptime in seconds")
|
84
|
+
agents_count: int = Field(..., description="Number of registered agents")
|
85
|
+
dynamic_agents_enabled: bool = Field(..., description="Whether dynamic agents are enabled")
|
86
|
+
|
87
|
+
|
88
|
+
class AgentListResponse(BaseModel):
|
89
|
+
"""Agent list response"""
|
90
|
+
agents: List[Dict[str, Any]] = Field(..., description="List of available agents")
|
91
|
+
total_count: int = Field(..., description="Total number of agents")
|
92
|
+
|
93
|
+
|
94
|
+
class ServerStatsResponse(BaseModel):
|
95
|
+
"""Server statistics response"""
|
96
|
+
server: Dict[str, Any] = Field(..., description="Server information")
|
97
|
+
agents: Dict[str, Any] = Field(..., description="Agent statistics")
|
98
|
+
performance: Optional[Dict[str, Any]] = Field(None, description="Performance metrics")
|
@@ -0,0 +1,59 @@
|
|
1
|
+
"""
|
2
|
+
Server Monitoring - WebAgents V2.0
|
3
|
+
|
4
|
+
Prometheus metrics and structured logging for the WebAgents server.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import time
|
8
|
+
from typing import Dict, Any, Optional
|
9
|
+
from dataclasses import dataclass
|
10
|
+
|
11
|
+
|
12
|
+
@dataclass
|
13
|
+
class PrometheusMetrics:
|
14
|
+
"""Prometheus metrics collector"""
|
15
|
+
enable_prometheus: bool = True
|
16
|
+
|
17
|
+
def set_server_info(self, **kwargs):
|
18
|
+
"""Set server information metrics"""
|
19
|
+
pass
|
20
|
+
|
21
|
+
def get_metrics_response(self) -> str:
|
22
|
+
"""Get Prometheus metrics response"""
|
23
|
+
return "# Prometheus metrics would be here\n"
|
24
|
+
|
25
|
+
|
26
|
+
@dataclass
|
27
|
+
class MonitoringSystem:
|
28
|
+
"""Complete monitoring system"""
|
29
|
+
enable_prometheus: bool
|
30
|
+
enable_structured_logging: bool
|
31
|
+
prometheus: PrometheusMetrics
|
32
|
+
|
33
|
+
def get_performance_stats(self) -> Dict[str, Any]:
|
34
|
+
"""Get performance statistics"""
|
35
|
+
return {
|
36
|
+
"uptime": time.time(),
|
37
|
+
"memory_usage": "unknown",
|
38
|
+
"cpu_usage": "unknown"
|
39
|
+
}
|
40
|
+
|
41
|
+
def update_system_metrics(self, **kwargs):
|
42
|
+
"""Update system metrics"""
|
43
|
+
pass
|
44
|
+
|
45
|
+
|
46
|
+
def initialize_monitoring(
|
47
|
+
enable_prometheus: bool = True,
|
48
|
+
enable_structured_logging: bool = True,
|
49
|
+
metrics_port: int = 9090
|
50
|
+
) -> MonitoringSystem:
|
51
|
+
"""Initialize monitoring system"""
|
52
|
+
|
53
|
+
prometheus = PrometheusMetrics(enable_prometheus=enable_prometheus)
|
54
|
+
|
55
|
+
return MonitoringSystem(
|
56
|
+
enable_prometheus=enable_prometheus,
|
57
|
+
enable_structured_logging=enable_structured_logging,
|
58
|
+
prometheus=prometheus
|
59
|
+
)
|
File without changes
|
File without changes
|
@@ -0,0 +1,330 @@
|
|
1
|
+
"""
|
2
|
+
Server Middleware - WebAgents V2.0
|
3
|
+
|
4
|
+
Production-ready middleware for request timeout, rate limiting,
|
5
|
+
and comprehensive request management.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import time
|
9
|
+
import asyncio
|
10
|
+
from typing import Dict, Any, Optional, Tuple, Callable
|
11
|
+
from datetime import datetime, timedelta
|
12
|
+
from collections import defaultdict
|
13
|
+
from dataclasses import dataclass
|
14
|
+
import uuid
|
15
|
+
|
16
|
+
from fastapi import Request, HTTPException
|
17
|
+
from fastapi.responses import JSONResponse
|
18
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
19
|
+
|
20
|
+
from .context.context_vars import create_context, set_context
|
21
|
+
|
22
|
+
|
23
|
+
@dataclass
|
24
|
+
class RateLimitRule:
|
25
|
+
"""Rate limiting rule configuration"""
|
26
|
+
requests_per_minute: int = 60
|
27
|
+
requests_per_hour: int = 1000
|
28
|
+
requests_per_day: int = 10000
|
29
|
+
burst_limit: int = 10 # Max requests in burst window
|
30
|
+
burst_window_seconds: int = 1 # Burst window duration
|
31
|
+
|
32
|
+
|
33
|
+
@dataclass
|
34
|
+
class ClientUsage:
|
35
|
+
"""Track client usage for rate limiting"""
|
36
|
+
client_id: str
|
37
|
+
minute_count: int = 0
|
38
|
+
hour_count: int = 0
|
39
|
+
day_count: int = 0
|
40
|
+
burst_count: int = 0
|
41
|
+
last_minute_reset: datetime = None
|
42
|
+
last_hour_reset: datetime = None
|
43
|
+
last_day_reset: datetime = None
|
44
|
+
last_burst_reset: datetime = None
|
45
|
+
|
46
|
+
def __post_init__(self):
|
47
|
+
now = datetime.utcnow()
|
48
|
+
if self.last_minute_reset is None:
|
49
|
+
self.last_minute_reset = now
|
50
|
+
if self.last_hour_reset is None:
|
51
|
+
self.last_hour_reset = now
|
52
|
+
if self.last_day_reset is None:
|
53
|
+
self.last_day_reset = now
|
54
|
+
if self.last_burst_reset is None:
|
55
|
+
self.last_burst_reset = now
|
56
|
+
|
57
|
+
|
58
|
+
class TimeoutMiddleware(BaseHTTPMiddleware):
|
59
|
+
"""Request timeout middleware"""
|
60
|
+
|
61
|
+
def __init__(self, app, timeout_seconds: float = 300.0):
|
62
|
+
super().__init__(app)
|
63
|
+
self.timeout_seconds = timeout_seconds
|
64
|
+
|
65
|
+
async def dispatch(self, request: Request, call_next):
|
66
|
+
try:
|
67
|
+
# Apply timeout to request processing
|
68
|
+
response = await asyncio.wait_for(
|
69
|
+
call_next(request),
|
70
|
+
timeout=self.timeout_seconds
|
71
|
+
)
|
72
|
+
return response
|
73
|
+
|
74
|
+
except asyncio.TimeoutError:
|
75
|
+
return JSONResponse(
|
76
|
+
status_code=408,
|
77
|
+
content={
|
78
|
+
"error": {
|
79
|
+
"type": "timeout_error",
|
80
|
+
"message": f"Request timeout after {self.timeout_seconds} seconds",
|
81
|
+
"code": "request_timeout"
|
82
|
+
}
|
83
|
+
}
|
84
|
+
)
|
85
|
+
|
86
|
+
|
87
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
88
|
+
"""Comprehensive rate limiting middleware"""
|
89
|
+
|
90
|
+
def __init__(
|
91
|
+
self,
|
92
|
+
app,
|
93
|
+
default_rule: RateLimitRule = None,
|
94
|
+
user_rules: Dict[str, RateLimitRule] = None,
|
95
|
+
enable_rate_limiting: bool = True
|
96
|
+
):
|
97
|
+
super().__init__(app)
|
98
|
+
self.default_rule = default_rule or RateLimitRule()
|
99
|
+
self.user_rules = user_rules or {}
|
100
|
+
self.enable_rate_limiting = enable_rate_limiting
|
101
|
+
|
102
|
+
# In-memory storage for client usage (production should use Redis)
|
103
|
+
self.client_usage: Dict[str, ClientUsage] = {}
|
104
|
+
|
105
|
+
# Exempt paths from rate limiting
|
106
|
+
self.exempt_paths = {'/health', '/health/detailed', '/'}
|
107
|
+
|
108
|
+
def _get_client_id(self, request: Request) -> str:
|
109
|
+
"""Extract client identifier from request"""
|
110
|
+
# Priority order for client identification:
|
111
|
+
# 1. User ID header (if authenticated)
|
112
|
+
# 2. API key (if provided)
|
113
|
+
# 3. IP address (fallback)
|
114
|
+
|
115
|
+
user_id = request.headers.get("x-user-id")
|
116
|
+
if user_id and user_id != "anonymous":
|
117
|
+
return f"user:{user_id}"
|
118
|
+
|
119
|
+
api_key = request.headers.get("authorization")
|
120
|
+
if api_key:
|
121
|
+
# Hash for privacy but keep unique
|
122
|
+
return f"api:{hash(api_key) % 1000000}"
|
123
|
+
|
124
|
+
# Fallback to IP address
|
125
|
+
client_ip = request.client.host if request.client else "unknown"
|
126
|
+
return f"ip:{client_ip}"
|
127
|
+
|
128
|
+
def _get_rate_limit_rule(self, client_id: str, request: Request) -> RateLimitRule:
|
129
|
+
"""Get rate limit rule for client"""
|
130
|
+
|
131
|
+
# Check for user-specific rules
|
132
|
+
user_id = request.headers.get("x-user-id")
|
133
|
+
if user_id and user_id in self.user_rules:
|
134
|
+
return self.user_rules[user_id]
|
135
|
+
|
136
|
+
# Check for agent owner rules
|
137
|
+
agent_owner = request.headers.get("x-agent-owner-id")
|
138
|
+
if agent_owner and agent_owner in self.user_rules:
|
139
|
+
return self.user_rules[agent_owner]
|
140
|
+
|
141
|
+
# Default rule
|
142
|
+
return self.default_rule
|
143
|
+
|
144
|
+
def _reset_counters_if_needed(self, usage: ClientUsage) -> None:
|
145
|
+
"""Reset usage counters if time windows have expired"""
|
146
|
+
now = datetime.utcnow()
|
147
|
+
|
148
|
+
# Reset burst counter
|
149
|
+
if now - usage.last_burst_reset >= timedelta(seconds=1):
|
150
|
+
usage.burst_count = 0
|
151
|
+
usage.last_burst_reset = now
|
152
|
+
|
153
|
+
# Reset minute counter
|
154
|
+
if now - usage.last_minute_reset >= timedelta(minutes=1):
|
155
|
+
usage.minute_count = 0
|
156
|
+
usage.last_minute_reset = now
|
157
|
+
|
158
|
+
# Reset hour counter
|
159
|
+
if now - usage.last_hour_reset >= timedelta(hours=1):
|
160
|
+
usage.hour_count = 0
|
161
|
+
usage.last_hour_reset = now
|
162
|
+
|
163
|
+
# Reset day counter
|
164
|
+
if now - usage.last_day_reset >= timedelta(days=1):
|
165
|
+
usage.day_count = 0
|
166
|
+
usage.last_day_reset = now
|
167
|
+
|
168
|
+
def _check_rate_limits(self, client_id: str, rule: RateLimitRule) -> Tuple[bool, str, int]:
|
169
|
+
"""
|
170
|
+
Check if client has exceeded rate limits
|
171
|
+
|
172
|
+
Returns:
|
173
|
+
Tuple of (allowed: bool, error_message: str, retry_after_seconds: int)
|
174
|
+
"""
|
175
|
+
|
176
|
+
# Get or create client usage
|
177
|
+
if client_id not in self.client_usage:
|
178
|
+
self.client_usage[client_id] = ClientUsage(client_id=client_id)
|
179
|
+
|
180
|
+
usage = self.client_usage[client_id]
|
181
|
+
self._reset_counters_if_needed(usage)
|
182
|
+
|
183
|
+
# Check burst limit (most restrictive)
|
184
|
+
if usage.burst_count >= rule.burst_limit:
|
185
|
+
return False, f"Burst limit exceeded ({rule.burst_limit} requests per second)", 1
|
186
|
+
|
187
|
+
# Check per-minute limit
|
188
|
+
if usage.minute_count >= rule.requests_per_minute:
|
189
|
+
remaining_seconds = 60 - (datetime.utcnow() - usage.last_minute_reset).seconds
|
190
|
+
return False, f"Rate limit exceeded ({rule.requests_per_minute} requests per minute)", remaining_seconds
|
191
|
+
|
192
|
+
# Check per-hour limit
|
193
|
+
if usage.hour_count >= rule.requests_per_hour:
|
194
|
+
remaining_seconds = 3600 - (datetime.utcnow() - usage.last_hour_reset).seconds
|
195
|
+
return False, f"Rate limit exceeded ({rule.requests_per_hour} requests per hour)", remaining_seconds
|
196
|
+
|
197
|
+
# Check per-day limit
|
198
|
+
if usage.day_count >= rule.requests_per_day:
|
199
|
+
remaining_seconds = 86400 - (datetime.utcnow() - usage.last_day_reset).seconds
|
200
|
+
return False, f"Rate limit exceeded ({rule.requests_per_day} requests per day)", remaining_seconds
|
201
|
+
|
202
|
+
return True, "", 0
|
203
|
+
|
204
|
+
def _increment_counters(self, client_id: str) -> None:
|
205
|
+
"""Increment usage counters for client"""
|
206
|
+
usage = self.client_usage[client_id]
|
207
|
+
usage.burst_count += 1
|
208
|
+
usage.minute_count += 1
|
209
|
+
usage.hour_count += 1
|
210
|
+
usage.day_count += 1
|
211
|
+
|
212
|
+
def _cleanup_old_entries(self) -> None:
|
213
|
+
"""Clean up old client usage entries to prevent memory leaks"""
|
214
|
+
now = datetime.utcnow()
|
215
|
+
cutoff = now - timedelta(days=2) # Keep 2 days of history
|
216
|
+
|
217
|
+
# Remove entries older than cutoff
|
218
|
+
old_clients = [
|
219
|
+
client_id for client_id, usage in self.client_usage.items()
|
220
|
+
if usage.last_day_reset < cutoff
|
221
|
+
]
|
222
|
+
|
223
|
+
for client_id in old_clients:
|
224
|
+
del self.client_usage[client_id]
|
225
|
+
|
226
|
+
async def dispatch(self, request: Request, call_next):
|
227
|
+
# Skip rate limiting if disabled
|
228
|
+
if not self.enable_rate_limiting:
|
229
|
+
return await call_next(request)
|
230
|
+
|
231
|
+
# Skip rate limiting for exempt paths
|
232
|
+
if request.url.path in self.exempt_paths:
|
233
|
+
return await call_next(request)
|
234
|
+
|
235
|
+
# Get client ID and rate limit rule
|
236
|
+
client_id = self._get_client_id(request)
|
237
|
+
rule = self._get_rate_limit_rule(client_id, request)
|
238
|
+
|
239
|
+
# Check rate limits
|
240
|
+
allowed, error_message, retry_after = self._check_rate_limits(client_id, rule)
|
241
|
+
|
242
|
+
if not allowed:
|
243
|
+
# Return rate limit error
|
244
|
+
response = JSONResponse(
|
245
|
+
status_code=429,
|
246
|
+
content={
|
247
|
+
"error": {
|
248
|
+
"type": "rate_limit_exceeded",
|
249
|
+
"message": error_message,
|
250
|
+
"code": "too_many_requests",
|
251
|
+
"retry_after": retry_after
|
252
|
+
}
|
253
|
+
},
|
254
|
+
headers={"Retry-After": str(retry_after)}
|
255
|
+
)
|
256
|
+
return response
|
257
|
+
|
258
|
+
# Increment counters and continue
|
259
|
+
self._increment_counters(client_id)
|
260
|
+
|
261
|
+
# Periodic cleanup (every 1000 requests)
|
262
|
+
if len(self.client_usage) % 1000 == 0:
|
263
|
+
self._cleanup_old_entries()
|
264
|
+
|
265
|
+
# Add rate limit headers to response
|
266
|
+
response = await call_next(request)
|
267
|
+
|
268
|
+
# Add rate limit info headers
|
269
|
+
usage = self.client_usage[client_id]
|
270
|
+
response.headers["X-RateLimit-Limit-Minute"] = str(rule.requests_per_minute)
|
271
|
+
response.headers["X-RateLimit-Remaining-Minute"] = str(max(0, rule.requests_per_minute - usage.minute_count))
|
272
|
+
response.headers["X-RateLimit-Limit-Hour"] = str(rule.requests_per_hour)
|
273
|
+
response.headers["X-RateLimit-Remaining-Hour"] = str(max(0, rule.requests_per_hour - usage.hour_count))
|
274
|
+
response.headers["X-RateLimit-Limit-Day"] = str(rule.requests_per_day)
|
275
|
+
response.headers["X-RateLimit-Remaining-Day"] = str(max(0, rule.requests_per_day - usage.day_count))
|
276
|
+
|
277
|
+
return response
|
278
|
+
|
279
|
+
|
280
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
281
|
+
"""Request logging and metrics middleware"""
|
282
|
+
|
283
|
+
def __init__(self, app, enable_logging: bool = True):
|
284
|
+
super().__init__(app)
|
285
|
+
self.enable_logging = enable_logging
|
286
|
+
self.request_count = 0
|
287
|
+
self.error_count = 0
|
288
|
+
|
289
|
+
async def dispatch(self, request: Request, call_next):
|
290
|
+
if not self.enable_logging:
|
291
|
+
return await call_next(request)
|
292
|
+
|
293
|
+
start_time = time.time()
|
294
|
+
self.request_count += 1
|
295
|
+
request_id = str(uuid.uuid4())[:8]
|
296
|
+
|
297
|
+
# Log request start
|
298
|
+
print(f"[{request_id}] {request.method} {request.url.path} - Started")
|
299
|
+
|
300
|
+
try:
|
301
|
+
response = await call_next(request)
|
302
|
+
|
303
|
+
# Calculate duration
|
304
|
+
duration_ms = (time.time() - start_time) * 1000
|
305
|
+
|
306
|
+
# Log successful request
|
307
|
+
print(f"[{request_id}] {request.method} {request.url.path} - {response.status_code} ({duration_ms:.1f}ms)")
|
308
|
+
|
309
|
+
# Add request ID header
|
310
|
+
response.headers["X-Request-ID"] = request_id
|
311
|
+
|
312
|
+
return response
|
313
|
+
|
314
|
+
except Exception as e:
|
315
|
+
self.error_count += 1
|
316
|
+
duration_ms = (time.time() - start_time) * 1000
|
317
|
+
|
318
|
+
# Log error
|
319
|
+
print(f"[{request_id}] {request.method} {request.url.path} - ERROR ({duration_ms:.1f}ms): {str(e)}")
|
320
|
+
|
321
|
+
# Re-raise exception
|
322
|
+
raise
|
323
|
+
|
324
|
+
def get_stats(self) -> Dict[str, Any]:
|
325
|
+
"""Get middleware statistics"""
|
326
|
+
return {
|
327
|
+
"total_requests": self.request_count,
|
328
|
+
"error_count": self.error_count,
|
329
|
+
"success_rate": (self.request_count - self.error_count) / max(1, self.request_count)
|
330
|
+
}
|
@@ -0,0 +1,92 @@
|
|
1
|
+
"""
|
2
|
+
OpenAI-Compatible Request/Response Models for WebAgents V2.0 Server
|
3
|
+
"""
|
4
|
+
|
5
|
+
from pydantic import BaseModel, Field
|
6
|
+
from typing import List, Dict, Any, Optional, Union
|
7
|
+
from datetime import datetime
|
8
|
+
|
9
|
+
|
10
|
+
class ChatMessage(BaseModel):
|
11
|
+
"""OpenAI-compatible chat message"""
|
12
|
+
role: str = Field(..., description="Role of the message sender")
|
13
|
+
content: Optional[str] = Field(None, description="Message content (can be null for tool calls)")
|
14
|
+
name: Optional[str] = Field(None, description="Name of the sender")
|
15
|
+
tool_calls: Optional[List[Dict[str, Any]]] = Field(None, description="Tool calls in message")
|
16
|
+
tool_call_id: Optional[str] = Field(None, description="Tool call ID for tool responses")
|
17
|
+
|
18
|
+
|
19
|
+
class ChatCompletionRequest(BaseModel):
|
20
|
+
"""OpenAI-compatible chat completion request"""
|
21
|
+
model: Optional[str] = Field(None, description="Model name (agent name used as model)")
|
22
|
+
messages: List[ChatMessage] = Field(..., description="List of messages in conversation")
|
23
|
+
stream: Optional[bool] = Field(False, description="Whether to stream the response")
|
24
|
+
tools: Optional[List[Dict[str, Any]]] = Field(None, description="External tools to make available")
|
25
|
+
temperature: Optional[float] = Field(None, description="Sampling temperature")
|
26
|
+
max_tokens: Optional[int] = Field(None, description="Maximum tokens in response")
|
27
|
+
top_p: Optional[float] = Field(None, description="Nucleus sampling parameter")
|
28
|
+
frequency_penalty: Optional[float] = Field(None, description="Frequency penalty")
|
29
|
+
presence_penalty: Optional[float] = Field(None, description="Presence penalty")
|
30
|
+
stop: Optional[Union[str, List[str]]] = Field(None, description="Stop sequences")
|
31
|
+
|
32
|
+
|
33
|
+
class OpenAIUsage(BaseModel):
|
34
|
+
"""OpenAI-compatible usage information"""
|
35
|
+
prompt_tokens: int = Field(..., description="Number of prompt tokens")
|
36
|
+
completion_tokens: int = Field(..., description="Number of completion tokens")
|
37
|
+
total_tokens: int = Field(..., description="Total number of tokens")
|
38
|
+
|
39
|
+
|
40
|
+
class OpenAIChoice(BaseModel):
|
41
|
+
"""OpenAI-compatible choice in response"""
|
42
|
+
index: int = Field(..., description="Index of the choice")
|
43
|
+
message: ChatMessage = Field(..., description="Generated message")
|
44
|
+
finish_reason: Optional[str] = Field(None, description="Reason for finishing")
|
45
|
+
|
46
|
+
|
47
|
+
class OpenAIResponse(BaseModel):
|
48
|
+
"""OpenAI-compatible chat completion response"""
|
49
|
+
id: str = Field(..., description="Unique completion ID")
|
50
|
+
object: str = Field(default="chat.completion", description="Object type")
|
51
|
+
created: int = Field(..., description="Unix timestamp of creation")
|
52
|
+
model: str = Field(..., description="Model used for completion")
|
53
|
+
choices: List[OpenAIChoice] = Field(..., description="List of choices")
|
54
|
+
usage: OpenAIUsage = Field(..., description="Token usage information")
|
55
|
+
system_fingerprint: Optional[str] = Field(None, description="System fingerprint")
|
56
|
+
|
57
|
+
|
58
|
+
class OpenAIStreamChunk(BaseModel):
|
59
|
+
"""OpenAI-compatible streaming chunk"""
|
60
|
+
id: str = Field(..., description="Unique completion ID")
|
61
|
+
object: str = Field(default="chat.completion.chunk", description="Object type")
|
62
|
+
created: int = Field(..., description="Unix timestamp of creation")
|
63
|
+
model: str = Field(..., description="Model used for completion")
|
64
|
+
choices: List[Dict[str, Any]] = Field(..., description="Streaming choices with delta")
|
65
|
+
usage: Optional[OpenAIUsage] = Field(None, description="Token usage (final chunk only)")
|
66
|
+
|
67
|
+
|
68
|
+
class AgentInfoResponse(BaseModel):
|
69
|
+
"""Agent information response"""
|
70
|
+
name: str = Field(..., description="Agent name")
|
71
|
+
description: str = Field(..., description="Agent description")
|
72
|
+
capabilities: List[str] = Field(..., description="List of agent capabilities")
|
73
|
+
skills: List[str] = Field(..., description="List of configured skills")
|
74
|
+
tools: List[str] = Field(..., description="List of available tools")
|
75
|
+
model: Optional[str] = Field(None, description="Primary LLM model")
|
76
|
+
pricing: Dict[str, Any] = Field(default_factory=dict, description="Pricing information")
|
77
|
+
|
78
|
+
|
79
|
+
class ServerInfo(BaseModel):
|
80
|
+
"""Server information response"""
|
81
|
+
message: str = Field(default="WebAgents V2 Server", description="Server message")
|
82
|
+
version: str = Field(default="2.0.0", description="Server version")
|
83
|
+
agents: List[str] = Field(..., description="List of available agents")
|
84
|
+
endpoints: Dict[str, str] = Field(..., description="Available endpoints")
|
85
|
+
|
86
|
+
|
87
|
+
class HealthResponse(BaseModel):
|
88
|
+
"""Health check response"""
|
89
|
+
status: str = Field(..., description="Health status")
|
90
|
+
version: str = Field(default="2.0.0", description="Server version")
|
91
|
+
timestamp: datetime = Field(..., description="Health check timestamp")
|
92
|
+
agents: Optional[Dict[str, Dict[str, Any]]] = Field(None, description="Agent health status")
|