quraite 0.1.2__py3-none-any.whl → 0.1.4__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.
- quraite/__init__.py +4 -0
- quraite/adapters/__init__.py +1 -1
- quraite/adapters/agno_adapter.py +50 -92
- quraite/adapters/base.py +26 -76
- quraite/adapters/bedrock_agents_adapter.py +23 -76
- quraite/adapters/flowise_adapter.py +31 -72
- quraite/adapters/google_adk_adapter.py +28 -94
- quraite/adapters/http_adapter.py +28 -44
- quraite/adapters/langchain_adapter.py +51 -118
- quraite/adapters/langchain_server_adapter.py +37 -89
- quraite/adapters/langflow_adapter.py +15 -60
- quraite/adapters/n8n_adapter.py +19 -63
- quraite/adapters/openai_agents_adapter.py +35 -59
- quraite/adapters/pydantic_ai_adapter.py +27 -97
- quraite/adapters/smolagents_adapter.py +21 -82
- quraite/constants/framework.py +14 -0
- quraite/schema/__init__.py +4 -0
- quraite/schema/invoke.py +46 -0
- quraite/schema/message.py +20 -21
- quraite/serve/__init__.py +4 -0
- quraite/serve/cloudflared.py +3 -2
- quraite/serve/server.py +305 -0
- quraite/tracing/__init__.py +8 -5
- quraite/tracing/constants.py +0 -14
- quraite/tracing/setup.py +129 -0
- quraite/tracing/span_exporter.py +6 -6
- quraite/tracing/span_processor.py +6 -7
- quraite/tracing/tool_extractors.py +1 -1
- quraite/tracing/trace.py +36 -24
- quraite/utils/json_utils.py +2 -2
- {quraite-0.1.2.dist-info → quraite-0.1.4.dist-info}/METADATA +54 -62
- quraite-0.1.4.dist-info/RECORD +37 -0
- quraite/schema/response.py +0 -16
- quraite/serve/local_agent.py +0 -360
- quraite/traces/traces_adk_openinference.json +0 -379
- quraite/traces/traces_agno_multi_agent.json +0 -669
- quraite/traces/traces_agno_openinference.json +0 -321
- quraite/traces/traces_crewai_openinference.json +0 -155
- quraite/traces/traces_langgraph_openinference.json +0 -349
- quraite/traces/traces_langgraph_openinference_multi_agent.json +0 -2705
- quraite/traces/traces_langgraph_traceloop.json +0 -510
- quraite/traces/traces_openai_agents_multi_agent_1.json +0 -402
- quraite/traces/traces_openai_agents_openinference.json +0 -341
- quraite/traces/traces_pydantic_openinference.json +0 -286
- quraite/traces/traces_pydantic_openinference_multi_agent_1.json +0 -399
- quraite/traces/traces_pydantic_openinference_multi_agent_2.json +0 -398
- quraite/traces/traces_smol_agents_openinference.json +0 -397
- quraite/traces/traces_smol_agents_tool_calling_openinference.json +0 -704
- quraite-0.1.2.dist-info/RECORD +0 -49
- {quraite-0.1.2.dist-info → quraite-0.1.4.dist-info}/WHEEL +0 -0
quraite/schema/__init__.py
CHANGED
quraite/schema/invoke.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Request and response models for agent invocation endpoints."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from quraite.schema.message import AgentMessage, UserMessage
|
|
9
|
+
from quraite.tracing.trace import AgentTrace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvokeInput(BaseModel):
|
|
13
|
+
"""
|
|
14
|
+
Request model for agent invocation endpoints.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
user_message: UserMessage object
|
|
18
|
+
session_id: Optional conversation thread identifier
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
user_message: UserMessage
|
|
22
|
+
session_id: str | None = None
|
|
23
|
+
|
|
24
|
+
def user_message_str(self) -> str:
|
|
25
|
+
"""Extract text content from user message."""
|
|
26
|
+
if not self.user_message.content:
|
|
27
|
+
raise ValueError("User message has no content")
|
|
28
|
+
|
|
29
|
+
for content_item in self.user_message.content:
|
|
30
|
+
if content_item.type == "text" and content_item.text:
|
|
31
|
+
return content_item.text
|
|
32
|
+
|
|
33
|
+
raise ValueError("No text content found in user message")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvokeOutput(BaseModel):
|
|
37
|
+
"""
|
|
38
|
+
Output model for agent invocation endpoints.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
agent_trace: AgentTrace object representing agent trace
|
|
42
|
+
agent_trajectory: List of AgentMessage objects representing agent trajectory
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
agent_trace: AgentTrace | None = None
|
|
46
|
+
agent_trajectory: Sequence[AgentMessage] | None = None
|
quraite/schema/message.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any, Literal, TypeAlias
|
|
2
3
|
|
|
3
4
|
from pydantic import BaseModel, Field
|
|
4
5
|
|
|
@@ -15,18 +16,18 @@ class MessageContentReasoning(BaseModel):
|
|
|
15
16
|
|
|
16
17
|
class UserMessage(BaseModel):
|
|
17
18
|
role: Literal["user"] = "user"
|
|
18
|
-
name:
|
|
19
|
-
content:
|
|
19
|
+
name: str | None = None
|
|
20
|
+
content: list[MessageContentText]
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class DeveloperMessage(BaseModel):
|
|
23
24
|
role: Literal["developer"] = "developer"
|
|
24
|
-
content:
|
|
25
|
+
content: list[MessageContentText]
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
class SystemMessage(BaseModel):
|
|
28
29
|
role: Literal["system"] = "system"
|
|
29
|
-
content:
|
|
30
|
+
content: list[MessageContentText]
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class ToolCall(BaseModel):
|
|
@@ -58,34 +59,32 @@ class LatencyInfo(BaseModel):
|
|
|
58
59
|
class AssistantMessageMetadata(BaseModel):
|
|
59
60
|
"""Structured metadata for assistant messages."""
|
|
60
61
|
|
|
61
|
-
tokens: TokenInfo =
|
|
62
|
-
cost: CostInfo =
|
|
63
|
-
latency: LatencyInfo =
|
|
64
|
-
model_info: ModelInfo =
|
|
62
|
+
tokens: TokenInfo | None = None
|
|
63
|
+
cost: CostInfo | None = None
|
|
64
|
+
latency: LatencyInfo | None = None
|
|
65
|
+
model_info: ModelInfo | None = None
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
class ToolMessageMetadata(BaseModel):
|
|
68
69
|
"""Structured metadata for tool messages."""
|
|
69
70
|
|
|
70
|
-
latency: LatencyInfo =
|
|
71
|
+
latency: LatencyInfo | None = None
|
|
71
72
|
|
|
72
73
|
|
|
73
74
|
class AssistantMessage(BaseModel):
|
|
74
75
|
role: Literal["assistant"] = "assistant"
|
|
75
|
-
agent_name:
|
|
76
|
-
content:
|
|
77
|
-
tool_calls:
|
|
78
|
-
metadata: AssistantMessageMetadata =
|
|
76
|
+
agent_name: str | None = None
|
|
77
|
+
content: Sequence[MessageContentText | MessageContentReasoning] | None = None
|
|
78
|
+
tool_calls: list[ToolCall] | None = None
|
|
79
|
+
metadata: AssistantMessageMetadata | None = None
|
|
79
80
|
|
|
80
81
|
|
|
81
82
|
class ToolMessage(BaseModel):
|
|
82
83
|
role: Literal["tool"] = "tool"
|
|
83
|
-
tool_name:
|
|
84
|
-
tool_call_id:
|
|
85
|
-
content:
|
|
86
|
-
metadata: ToolMessageMetadata =
|
|
84
|
+
tool_name: str | None = None
|
|
85
|
+
tool_call_id: str | None = None
|
|
86
|
+
content: list[MessageContentText]
|
|
87
|
+
metadata: ToolMessageMetadata | None = None
|
|
87
88
|
|
|
88
89
|
|
|
89
|
-
AgentMessage: TypeAlias =
|
|
90
|
-
UserMessage, DeveloperMessage, SystemMessage, AssistantMessage, ToolMessage
|
|
91
|
-
]
|
|
90
|
+
AgentMessage: TypeAlias = UserMessage | DeveloperMessage | SystemMessage | AssistantMessage | ToolMessage
|
quraite/serve/__init__.py
CHANGED
quraite/serve/cloudflared.py
CHANGED
|
@@ -105,8 +105,9 @@ def download_cloudflared(force: bool = False) -> Path:
|
|
|
105
105
|
cloudflared_cmd = (
|
|
106
106
|
"cloudflared.exe" if platform.system() == "windows" else "cloudflared"
|
|
107
107
|
)
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
cloudflared_in_path = shutil.which(cloudflared_cmd)
|
|
109
|
+
if cloudflared_in_path and not force:
|
|
110
|
+
return Path(cloudflared_in_path)
|
|
110
111
|
|
|
111
112
|
cloudflared_path = get_cloudflared_path()
|
|
112
113
|
system_key = get_system()
|
quraite/serve/server.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Simplified agent runner API for Quraite."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import uvicorn
|
|
9
|
+
from fastapi import FastAPI, HTTPException
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from quraite.adapters.base import BaseAdapter
|
|
14
|
+
from quraite.logger import get_logger
|
|
15
|
+
from quraite.schema import InvokeInput, InvokeOutput
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TunnelConfig(BaseModel):
|
|
21
|
+
tunnel_obj: Any
|
|
22
|
+
agent_url: str
|
|
23
|
+
tunnel_type: Literal["ngrok", "cloudflare", "none"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def setup_tunnel(
|
|
27
|
+
port: int,
|
|
28
|
+
host: str = "0.0.0.0",
|
|
29
|
+
tunnel: Literal["ngrok", "cloudflare"] = "cloudflare",
|
|
30
|
+
) -> TunnelConfig:
|
|
31
|
+
"""
|
|
32
|
+
Setup tunnel connection.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
port: Local port to expose
|
|
36
|
+
host: Local host address
|
|
37
|
+
tunnel: Tunnel type
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of (public_url, tunnel_object)
|
|
41
|
+
"""
|
|
42
|
+
if tunnel == "ngrok":
|
|
43
|
+
try:
|
|
44
|
+
from pyngrok import ngrok
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"Failed to import pyngrok. Install with: pip install 'quraite[pyngrok]'"
|
|
48
|
+
) from e
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
ngrok_tunnel = ngrok.connect(port)
|
|
52
|
+
public_url = ngrok_tunnel.public_url
|
|
53
|
+
logger.info("Ngrok tunnel established: %s", public_url)
|
|
54
|
+
return TunnelConfig(
|
|
55
|
+
tunnel_obj=ngrok_tunnel, agent_url=public_url, tunnel_type="ngrok"
|
|
56
|
+
)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.error(
|
|
59
|
+
"Failed to create ngrok tunnel: %s. "
|
|
60
|
+
"Make sure ngrok is installed and authenticated: https://ngrok.com/download",
|
|
61
|
+
e,
|
|
62
|
+
)
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
elif tunnel == "cloudflare":
|
|
66
|
+
from quraite.serve.cloudflared import connect
|
|
67
|
+
|
|
68
|
+
cloudflared_tunnel = connect(
|
|
69
|
+
port, host=host if host != "0.0.0.0" else "localhost"
|
|
70
|
+
)
|
|
71
|
+
public_url = cloudflared_tunnel.public_url
|
|
72
|
+
logger.info("Cloudflare tunnel established: %s", public_url)
|
|
73
|
+
return TunnelConfig(
|
|
74
|
+
tunnel_obj=cloudflared_tunnel,
|
|
75
|
+
agent_url=public_url,
|
|
76
|
+
tunnel_type="cloudflare",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cleanup_tunnel(tunnel_obj: Any, tunnel_type: Literal["ngrok", "cloudflare", "none"]) -> None:
|
|
81
|
+
"""Clean up tunnel connection."""
|
|
82
|
+
try:
|
|
83
|
+
if tunnel_type == "ngrok":
|
|
84
|
+
from pyngrok import ngrok
|
|
85
|
+
|
|
86
|
+
ngrok.disconnect(tunnel_obj.public_url)
|
|
87
|
+
ngrok.kill()
|
|
88
|
+
logger.info("Ngrok tunnel closed")
|
|
89
|
+
elif tunnel_type == "cloudflare":
|
|
90
|
+
if hasattr(tunnel_obj, "disconnect"):
|
|
91
|
+
tunnel_obj.disconnect()
|
|
92
|
+
elif hasattr(tunnel_obj, "stop"):
|
|
93
|
+
tunnel_obj.stop()
|
|
94
|
+
elif hasattr(tunnel_obj, "close"):
|
|
95
|
+
tunnel_obj.close()
|
|
96
|
+
logger.info("Cloudflare tunnel closed")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.warning("Error closing %s tunnel: %s", tunnel_type, e)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _update_backend_agent_url(
|
|
102
|
+
agent_id: str,
|
|
103
|
+
agent_url: str | None,
|
|
104
|
+
quraite_endpoint: str = "https://api.quraite.ai",
|
|
105
|
+
) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Update backend with agent URL.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
agent_id: Quraite platform agent ID
|
|
111
|
+
agent_url: Agent base URL
|
|
112
|
+
quraite_endpoint: Quraite API endpoint
|
|
113
|
+
"""
|
|
114
|
+
if not agent_id or not agent_url:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
endpoint = f"{quraite_endpoint.rstrip('/')}/agents/{agent_id}/config/url"
|
|
118
|
+
full_url = f"{agent_url.rstrip('/')}/v1/agents/completions"
|
|
119
|
+
payload = {"config": {"url": full_url}}
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
123
|
+
response = await client.patch(endpoint, json=payload)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
logger.info("Agent URL registered with Quraite platform: %s", full_url)
|
|
126
|
+
except httpx.HTTPStatusError as e:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Failed to update agent URL in Quraite platform: HTTP %s - %s. Update manually with URL: %s",
|
|
129
|
+
e.response.status_code,
|
|
130
|
+
e.response.text,
|
|
131
|
+
full_url,
|
|
132
|
+
)
|
|
133
|
+
except httpx.RequestError as e:
|
|
134
|
+
logger.warning(
|
|
135
|
+
"Failed to connect to Quraite backend at %s: %s",
|
|
136
|
+
quraite_endpoint,
|
|
137
|
+
e,
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.warning("Unexpected error updating agent URL: %s", e)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def create_app(
|
|
144
|
+
agent_adapter: BaseAdapter,
|
|
145
|
+
*,
|
|
146
|
+
agent_id: str | None = None,
|
|
147
|
+
tunnel_config: TunnelConfig | None = None,
|
|
148
|
+
) -> FastAPI:
|
|
149
|
+
"""
|
|
150
|
+
Create a standalone FastAPI app for an agent.
|
|
151
|
+
|
|
152
|
+
Returns a FastAPI application that developers can run with any ASGI server
|
|
153
|
+
(uvicorn, hypercorn, etc.). The app is self-contained with lifespan management.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
agent_adapter: The agent adapter instance (must inherit from BaseAdapter)
|
|
157
|
+
agent_id: Optional Quraite platform agent ID for backend registration
|
|
158
|
+
tunnel_config: Optional TunnelConfig object. This is required if you are using a tunnel. To get the tunnel config, use the setup_tunnel function.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
FastAPI application instance with agent endpoints
|
|
162
|
+
"""
|
|
163
|
+
logger.info("Creating FastAPI app with agent adapter")
|
|
164
|
+
|
|
165
|
+
adapter = agent_adapter
|
|
166
|
+
|
|
167
|
+
# Extract tunnel info
|
|
168
|
+
tunnel_obj = tunnel_config.tunnel_obj if tunnel_config else None
|
|
169
|
+
agent_url = tunnel_config.agent_url if tunnel_config else None
|
|
170
|
+
tunnel_type = tunnel_config.tunnel_type if tunnel_config else None
|
|
171
|
+
|
|
172
|
+
# Create lifespan with tunnel and backend registration
|
|
173
|
+
from contextlib import asynccontextmanager
|
|
174
|
+
|
|
175
|
+
@asynccontextmanager
|
|
176
|
+
async def lifespan(app: FastAPI):
|
|
177
|
+
# Startup
|
|
178
|
+
logger.info("Agent server started successfully")
|
|
179
|
+
path = "/v1/agents/completions"
|
|
180
|
+
|
|
181
|
+
# Register with Quraite backend if agent_id and tunnel provided
|
|
182
|
+
if agent_id and tunnel_type:
|
|
183
|
+
quraite_endpoint = os.getenv("QURAITE_ENDPOINT", "https://api.quraite.ai")
|
|
184
|
+
await _update_backend_agent_url(agent_id, agent_url, quraite_endpoint)
|
|
185
|
+
|
|
186
|
+
if tunnel_type:
|
|
187
|
+
logger.info(f"Agent publicly available at {agent_url}{path}")
|
|
188
|
+
if not agent_id:
|
|
189
|
+
logger.info(
|
|
190
|
+
f"Add this URL to your agent in the Quraite platform: {agent_url}{path}"
|
|
191
|
+
)
|
|
192
|
+
elif agent_url:
|
|
193
|
+
logger.info(
|
|
194
|
+
f"Agent running locally at {agent_url}{path}. Use tunnel option to make it publicly available."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
yield
|
|
198
|
+
|
|
199
|
+
# Shutdown - cleanup tunnel
|
|
200
|
+
if tunnel_obj and tunnel_type:
|
|
201
|
+
logger.info("Closing %s tunnel...", tunnel_type)
|
|
202
|
+
loop = asyncio.get_event_loop()
|
|
203
|
+
await loop.run_in_executor(None, _cleanup_tunnel, tunnel_obj, tunnel_type)
|
|
204
|
+
|
|
205
|
+
# Create FastAPI app with lifespan
|
|
206
|
+
app = FastAPI(title="Quraite Agent Server", lifespan=lifespan)
|
|
207
|
+
|
|
208
|
+
# Add CORS middleware
|
|
209
|
+
app.add_middleware(
|
|
210
|
+
CORSMiddleware,
|
|
211
|
+
allow_origins=["*"],
|
|
212
|
+
allow_credentials=True,
|
|
213
|
+
allow_methods=["*"],
|
|
214
|
+
allow_headers=["*"],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Health check endpoint
|
|
218
|
+
@app.get("/")
|
|
219
|
+
def health_check():
|
|
220
|
+
"""Health check endpoint."""
|
|
221
|
+
return {
|
|
222
|
+
"status": "ok",
|
|
223
|
+
"message": "Agent server is running",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Agent invocation endpoint
|
|
227
|
+
@app.post("/v1/agents/completions", response_model=InvokeOutput)
|
|
228
|
+
async def invoke_agent(request: InvokeInput) -> InvokeOutput:
|
|
229
|
+
"""
|
|
230
|
+
Agent invocation endpoint.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
request: InvokeInput containing user_message and session_id
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
InvokeOutput with agent response
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
HTTPException: If agent invocation fails
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
response = await adapter.ainvoke(input=request)
|
|
243
|
+
return response
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.exception("Agent invocation failed")
|
|
246
|
+
raise HTTPException(
|
|
247
|
+
status_code=500,
|
|
248
|
+
detail=f"Agent invocation failed: {str(e)}",
|
|
249
|
+
) from e
|
|
250
|
+
|
|
251
|
+
logger.info("FastAPI app created successfully")
|
|
252
|
+
return app
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def run_agent(
|
|
256
|
+
agent_adapter: BaseAdapter,
|
|
257
|
+
*,
|
|
258
|
+
agent_id: str | None = None,
|
|
259
|
+
port: int = 8080,
|
|
260
|
+
host: str = "0.0.0.0",
|
|
261
|
+
tunnel: Literal["none", "cloudflare", "ngrok"] = "none",
|
|
262
|
+
) -> None:
|
|
263
|
+
"""
|
|
264
|
+
Run an agent with a single line of code.
|
|
265
|
+
|
|
266
|
+
This function starts a FastAPI server for the provided agent adapter,
|
|
267
|
+
providing the best developer experience.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
agent_adapter: The agent adapter instance (must inherit from BaseAdapter)
|
|
271
|
+
agent_id: Optional Quraite platform agent ID
|
|
272
|
+
port: Port number for the local server (default: 8080)
|
|
273
|
+
host: Host address (default: "0.0.0.0")
|
|
274
|
+
tunnel: Tunnel type ("none", "cloudflare", "ngrok") for public access
|
|
275
|
+
|
|
276
|
+
Example:
|
|
277
|
+
```python
|
|
278
|
+
from quraite import run_agent
|
|
279
|
+
from quraite.adapters.langchain_adapter import LangchainAdapter
|
|
280
|
+
|
|
281
|
+
# Create adapter with tracing enabled
|
|
282
|
+
adapter = LangchainAdapter(agent_graph=my_graph, tracing=True)
|
|
283
|
+
|
|
284
|
+
# Run with tunnel
|
|
285
|
+
run_agent(adapter, tunnel="cloudflare", agent_id="agent-123")
|
|
286
|
+
```
|
|
287
|
+
"""
|
|
288
|
+
logger.info(
|
|
289
|
+
"Starting agent runner (port=%d, tunnel=%s, agent_id=%s)",
|
|
290
|
+
port,
|
|
291
|
+
tunnel,
|
|
292
|
+
agent_id,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Setup tunnel if requested
|
|
296
|
+
tunnel_config = None
|
|
297
|
+
if tunnel != "none":
|
|
298
|
+
logger.info("Setting up %s tunnel on port %s...", tunnel, port)
|
|
299
|
+
tunnel_config = setup_tunnel(port, host, tunnel)
|
|
300
|
+
|
|
301
|
+
# Create app with tunnel config
|
|
302
|
+
app = create_app(agent_adapter, agent_id=agent_id, tunnel_config=tunnel_config)
|
|
303
|
+
|
|
304
|
+
# Run server
|
|
305
|
+
uvicorn.run(app, host=host, port=port)
|
quraite/tracing/__init__.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Tracing infrastructure for OpenTelemetry span collection and processing."""
|
|
2
2
|
|
|
3
|
-
from quraite.
|
|
4
|
-
from quraite.tracing.
|
|
5
|
-
from quraite.tracing.
|
|
3
|
+
from quraite.constants.framework import Framework
|
|
4
|
+
from quraite.tracing.setup import setup_tracing
|
|
5
|
+
from quraite.tracing.span_exporter import QuraiteSpanExporter
|
|
6
|
+
from quraite.tracing.span_processor import QuraiteSpanProcessor
|
|
7
|
+
from quraite.tracing.tool_extractors import ToolCallInfo, get_tool_extractor
|
|
6
8
|
from quraite.tracing.trace import AgentSpan, AgentTrace, CostInfo, TokenInfo
|
|
7
9
|
from quraite.tracing.types import Event, Link, Resource, SpanContext, Status
|
|
8
10
|
|
|
@@ -15,11 +17,12 @@ __all__ = [
|
|
|
15
17
|
"Tool",
|
|
16
18
|
"ToolCallInfo",
|
|
17
19
|
"get_tool_extractor",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
+
"QuraiteSpanExporter",
|
|
21
|
+
"QuraiteSpanProcessor",
|
|
20
22
|
"Event",
|
|
21
23
|
"Link",
|
|
22
24
|
"Resource",
|
|
23
25
|
"SpanContext",
|
|
24
26
|
"Status",
|
|
27
|
+
"setup_tracing",
|
|
25
28
|
]
|
quraite/tracing/constants.py
CHANGED
|
@@ -1,15 +1 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
|
|
3
1
|
QURAITE_TRACER_NAME = "quraite.instrumentation"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class Framework(str, Enum):
|
|
7
|
-
"""Supported agent frameworks."""
|
|
8
|
-
|
|
9
|
-
DEFAULT = "default"
|
|
10
|
-
PYDANTIC_AI = "pydantic_ai"
|
|
11
|
-
LANGCHAIN = "langchain"
|
|
12
|
-
GOOGLE_ADK = "google_adk"
|
|
13
|
-
OPENAI_AGENTS = "openai_agents"
|
|
14
|
-
AGNO = "agno"
|
|
15
|
-
SMOLAGENTS = "smolagents"
|
quraite/tracing/setup.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Centralized tracing setup and framework instrumentation."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from opentelemetry import trace
|
|
5
|
+
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
|
|
6
|
+
|
|
7
|
+
from quraite.constants.framework import Framework
|
|
8
|
+
from quraite.logger import get_logger
|
|
9
|
+
from quraite.tracing.span_exporter import QuraiteSpanExporter
|
|
10
|
+
from quraite.tracing.span_processor import QuraiteSpanProcessor
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def setup_tracing(
|
|
16
|
+
frameworks: list[Framework | str] = [],
|
|
17
|
+
tracer_provider: SDKTracerProvider | None = None,
|
|
18
|
+
) -> SDKTracerProvider:
|
|
19
|
+
"""
|
|
20
|
+
Configure tracing once at app startup.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
frameworks: Frameworks to instrument (e.g., [Framework.LANGCHAIN])
|
|
24
|
+
tracer_provider: Optional existing provider (auto-creates if None)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Configured TracerProvider to pass to adapters
|
|
28
|
+
"""
|
|
29
|
+
provider = tracer_provider or _create_tracer_provider()
|
|
30
|
+
_inject_quraite_processor(provider)
|
|
31
|
+
|
|
32
|
+
for framework in frameworks:
|
|
33
|
+
_instrument_framework(framework, provider)
|
|
34
|
+
|
|
35
|
+
logger.info("Tracing setup complete for frameworks: %s", frameworks)
|
|
36
|
+
return provider
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _create_tracer_provider() -> SDKTracerProvider:
|
|
40
|
+
"""Create TracerProvider or use existing global provider."""
|
|
41
|
+
global_provider = trace.get_tracer_provider()
|
|
42
|
+
if isinstance(global_provider, SDKTracerProvider):
|
|
43
|
+
logger.debug("Using existing global TracerProvider")
|
|
44
|
+
return global_provider
|
|
45
|
+
|
|
46
|
+
logger.debug("Creating new TracerProvider")
|
|
47
|
+
return SDKTracerProvider()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _inject_quraite_processor(provider: SDKTracerProvider) -> None:
|
|
51
|
+
"""Inject QuraiteSpanProcessor if not already present."""
|
|
52
|
+
has_quraite_processor = any(
|
|
53
|
+
isinstance(processor, QuraiteSpanProcessor)
|
|
54
|
+
for processor in provider._active_span_processor._span_processors
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if not has_quraite_processor:
|
|
58
|
+
logger.debug("Injecting QuraiteSpanProcessor")
|
|
59
|
+
exporter = QuraiteSpanExporter()
|
|
60
|
+
processor = QuraiteSpanProcessor(exporter)
|
|
61
|
+
provider.add_span_processor(processor)
|
|
62
|
+
logger.info("QuraiteSpanProcessor injected")
|
|
63
|
+
else:
|
|
64
|
+
logger.debug("QuraiteSpanProcessor already present")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _instrument_framework(framework: Framework | str, provider: SDKTracerProvider) -> None:
|
|
68
|
+
"""Instrument a specific framework (idempotent)."""
|
|
69
|
+
framework_str = framework.value if isinstance(framework, Framework) else framework
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
match framework_str:
|
|
73
|
+
case Framework.LANGCHAIN | "langchain":
|
|
74
|
+
from openinference.instrumentation.langchain import (
|
|
75
|
+
LangChainInstrumentor,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
LangChainInstrumentor().instrument(tracer_provider=provider)
|
|
79
|
+
|
|
80
|
+
case Framework.AGNO | "agno":
|
|
81
|
+
from openinference.instrumentation.agno import AgnoInstrumentor
|
|
82
|
+
|
|
83
|
+
AgnoInstrumentor().instrument(tracer_provider=provider)
|
|
84
|
+
|
|
85
|
+
case Framework.PYDANTIC_AI | "pydantic_ai":
|
|
86
|
+
from openinference.instrumentation.pydantic_ai import (
|
|
87
|
+
OpenInferenceSpanProcessor,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Pydantic AI instrumentation requires the tracer provider to be set globally
|
|
91
|
+
trace.set_tracer_provider(provider)
|
|
92
|
+
provider.add_span_processor(OpenInferenceSpanProcessor())
|
|
93
|
+
|
|
94
|
+
case Framework.GOOGLE_ADK | "google_adk":
|
|
95
|
+
from openinference.instrumentation.google_adk import (
|
|
96
|
+
GoogleADKInstrumentor,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
GoogleADKInstrumentor().instrument(tracer_provider=provider)
|
|
100
|
+
|
|
101
|
+
case Framework.SMOLAGENTS | "smolagents":
|
|
102
|
+
from openinference.instrumentation.smolagents import (
|
|
103
|
+
SmolagentsInstrumentor,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
SmolagentsInstrumentor().instrument(tracer_provider=provider)
|
|
107
|
+
|
|
108
|
+
case Framework.OPENAI_AGENTS | "openai_agents":
|
|
109
|
+
from openinference.instrumentation.openai_agents import (
|
|
110
|
+
OpenAIAgentsInstrumentor,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
OpenAIAgentsInstrumentor().instrument(tracer_provider=provider)
|
|
114
|
+
case Framework.OPENAI | "openai":
|
|
115
|
+
from openinference.instrumentation.openai import OpenAIInstrumentor
|
|
116
|
+
|
|
117
|
+
OpenAIInstrumentor().instrument(tracer_provider=provider)
|
|
118
|
+
case _:
|
|
119
|
+
logger.warning("Unknown framework: %s", framework_str)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
logger.info("Instrumented framework: %s", framework_str)
|
|
123
|
+
|
|
124
|
+
except ImportError as e:
|
|
125
|
+
logger.warning(
|
|
126
|
+
"Failed to import instrumentor for %s: %s. Install the instrumentation package.",
|
|
127
|
+
framework_str,
|
|
128
|
+
e,
|
|
129
|
+
)
|
quraite/tracing/span_exporter.py
CHANGED
|
@@ -8,10 +8,10 @@ from opentelemetry.sdk.trace import ReadableSpan
|
|
|
8
8
|
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class
|
|
11
|
+
class QuraiteSpanExporter(SpanExporter):
|
|
12
12
|
def __init__(self) -> None:
|
|
13
13
|
# self.spans: typing.List[ReadableSpan] = []
|
|
14
|
-
self.traces:
|
|
14
|
+
self.traces: dict[int, list[ReadableSpan]] = defaultdict(list)
|
|
15
15
|
self._stopped = False
|
|
16
16
|
self._lock = threading.Lock()
|
|
17
17
|
|
|
@@ -28,7 +28,7 @@ class QuraiteInMemorySpanExporter(SpanExporter):
|
|
|
28
28
|
|
|
29
29
|
return SpanExportResult.SUCCESS
|
|
30
30
|
|
|
31
|
-
def get_spans_by_trace_id(self, trace_id: int) ->
|
|
31
|
+
def get_spans_by_trace_id(self, trace_id: int) -> list[ReadableSpan]:
|
|
32
32
|
"""Get all spans for a specific trace ID"""
|
|
33
33
|
return self.traces.get(trace_id, [])
|
|
34
34
|
|
|
@@ -43,11 +43,11 @@ class QuraiteInMemorySpanExporter(SpanExporter):
|
|
|
43
43
|
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
44
44
|
return True
|
|
45
45
|
|
|
46
|
-
def get_traces(self) ->
|
|
46
|
+
def get_traces(self) -> dict[int, list[ReadableSpan]]:
|
|
47
47
|
"""Get all spans grouped by trace ID"""
|
|
48
48
|
return dict(self.traces)
|
|
49
49
|
|
|
50
|
-
def get_trace(self, trace_id: int) ->
|
|
50
|
+
def get_trace(self, trace_id: int) -> list[ReadableSpan]:
|
|
51
51
|
"""Get all spans for a specific trace"""
|
|
52
52
|
return self.traces.get(trace_id, [])
|
|
53
53
|
|
|
@@ -85,7 +85,7 @@ class QuraiteInMemorySpanExporter(SpanExporter):
|
|
|
85
85
|
# print(f" {indent} └─ Attributes: {dict(span.attributes)}")
|
|
86
86
|
# print()
|
|
87
87
|
|
|
88
|
-
def save_traces_to_file(self, filename:
|
|
88
|
+
def save_traces_to_file(self, filename: str | None = "traces.json"):
|
|
89
89
|
"""Save a trace to a file"""
|
|
90
90
|
traces = []
|
|
91
91
|
for trace_id, spans in self.traces.items():
|