quraite 0.0.1__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.
Files changed (49) hide show
  1. quraite/__init__.py +3 -0
  2. quraite/adapters/__init__.py +134 -0
  3. quraite/adapters/agno_adapter.py +159 -0
  4. quraite/adapters/base.py +123 -0
  5. quraite/adapters/bedrock_agents_adapter.py +343 -0
  6. quraite/adapters/flowise_adapter.py +275 -0
  7. quraite/adapters/google_adk_adapter.py +209 -0
  8. quraite/adapters/http_adapter.py +239 -0
  9. quraite/adapters/langflow_adapter.py +192 -0
  10. quraite/adapters/langgraph_adapter.py +304 -0
  11. quraite/adapters/langgraph_server_adapter.py +252 -0
  12. quraite/adapters/n8n_adapter.py +220 -0
  13. quraite/adapters/openai_agents_adapter.py +269 -0
  14. quraite/adapters/pydantic_ai_adapter.py +312 -0
  15. quraite/adapters/smolagents_adapter.py +152 -0
  16. quraite/logger.py +62 -0
  17. quraite/schema/__init__.py +0 -0
  18. quraite/schema/message.py +54 -0
  19. quraite/schema/response.py +16 -0
  20. quraite/serve/__init__.py +1 -0
  21. quraite/serve/cloudflared.py +210 -0
  22. quraite/serve/local_agent.py +360 -0
  23. quraite/traces/traces_adk_openinference.json +379 -0
  24. quraite/traces/traces_agno_multi_agent.json +669 -0
  25. quraite/traces/traces_agno_openinference.json +321 -0
  26. quraite/traces/traces_crewai_openinference.json +155 -0
  27. quraite/traces/traces_langgraph_openinference.json +349 -0
  28. quraite/traces/traces_langgraph_openinference_multi_agent.json +2705 -0
  29. quraite/traces/traces_langgraph_traceloop.json +510 -0
  30. quraite/traces/traces_openai_agents_multi_agent_1.json +402 -0
  31. quraite/traces/traces_openai_agents_openinference.json +341 -0
  32. quraite/traces/traces_pydantic_openinference.json +286 -0
  33. quraite/traces/traces_pydantic_openinference_multi_agent_1.json +399 -0
  34. quraite/traces/traces_pydantic_openinference_multi_agent_2.json +398 -0
  35. quraite/traces/traces_smol_agents_openinference.json +397 -0
  36. quraite/traces/traces_smol_agents_tool_calling_openinference.json +704 -0
  37. quraite/tracing/__init__.py +24 -0
  38. quraite/tracing/constants.py +16 -0
  39. quraite/tracing/span_exporter.py +115 -0
  40. quraite/tracing/span_processor.py +49 -0
  41. quraite/tracing/tool_extractors.py +290 -0
  42. quraite/tracing/trace.py +494 -0
  43. quraite/tracing/types.py +179 -0
  44. quraite/tracing/utils.py +170 -0
  45. quraite/utils/__init__.py +0 -0
  46. quraite/utils/json_utils.py +269 -0
  47. quraite-0.0.1.dist-info/METADATA +44 -0
  48. quraite-0.0.1.dist-info/RECORD +49 -0
  49. quraite-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,210 @@
1
+ import os
2
+ import platform
3
+ import re
4
+ import shutil
5
+ import stat
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+ from urllib.request import urlopen
11
+
12
+ from quraite.logger import get_logger
13
+
14
+ # Cloudflare Tunnel download URLs
15
+ CLOUDFLARED_RELEASES_URL = (
16
+ "https://github.com/cloudflare/cloudflared/releases/latest/download"
17
+ )
18
+ PLATFORMS = {
19
+ "darwin_x86_64": f"{CLOUDFLARED_RELEASES_URL}/cloudflared-darwin-amd64",
20
+ "darwin_arm64": f"{CLOUDFLARED_RELEASES_URL}/cloudflared-darwin-arm64",
21
+ "windows_x86_64": f"{CLOUDFLARED_RELEASES_URL}/cloudflared-windows-amd64.exe",
22
+ "linux_x86_64": f"{CLOUDFLARED_RELEASES_URL}/cloudflared-linux-amd64",
23
+ "linux_arm64": f"{CLOUDFLARED_RELEASES_URL}/cloudflared-linux-arm64",
24
+ "linux_arm": f"{CLOUDFLARED_RELEASES_URL}/cloudflared-linux-arm",
25
+ }
26
+
27
+ logger = get_logger(__name__)
28
+
29
+
30
+ class CloudflaredError(Exception):
31
+ """Base exception for cloudflared operations."""
32
+
33
+
34
+ class CloudflaredTunnel:
35
+ """Represents a Cloudflare Tunnel connection."""
36
+
37
+ def __init__(self, public_url: str, process: subprocess.Popen):
38
+ self.public_url = public_url
39
+ self._process = process
40
+
41
+ def disconnect(self):
42
+ """Disconnect the tunnel."""
43
+ if self._process and self._process.poll() is None:
44
+ self._process.terminate()
45
+ try:
46
+ self._process.wait(timeout=5)
47
+ except subprocess.TimeoutExpired:
48
+ self._process.kill()
49
+
50
+
51
+ def get_system() -> str:
52
+ """Get the system platform identifier."""
53
+ system = platform.system().lower()
54
+ machine = platform.machine().lower()
55
+
56
+ if system == "darwin":
57
+ if machine in ("arm64", "aarch64"):
58
+ return "darwin_arm64"
59
+ return "darwin_x86_64"
60
+ elif system == "windows":
61
+ return "windows_x86_64"
62
+ elif system == "linux":
63
+ if machine in ("arm64", "aarch64"):
64
+ return "linux_arm64"
65
+ elif machine.startswith("arm"):
66
+ return "linux_arm"
67
+ return "linux_x86_64"
68
+ else:
69
+ raise CloudflaredError(f"Unsupported platform: {system} {machine}")
70
+
71
+
72
+ def get_cloudflared_path() -> Path:
73
+ """Get the path where cloudflared binary should be stored."""
74
+ user_home = Path.home()
75
+ system = platform.system().lower()
76
+
77
+ if system == "darwin":
78
+ config_dir = user_home / "Library" / "Application Support" / "cloudflared"
79
+ elif system == "windows":
80
+ config_dir = user_home / "AppData" / "Local" / "cloudflared"
81
+ else:
82
+ config_dir = (
83
+ Path(os.environ.get("XDG_CONFIG_HOME", user_home / ".config"))
84
+ / "cloudflared"
85
+ )
86
+
87
+ config_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ if system == "windows":
90
+ return config_dir / "cloudflared.exe"
91
+ return config_dir / "cloudflared"
92
+
93
+
94
+ def download_cloudflared(force: bool = False) -> Path:
95
+ """
96
+ Download cloudflared binary for the current platform.
97
+
98
+ Args:
99
+ force: If True, re-download even if binary exists
100
+
101
+ Returns:
102
+ Path to the cloudflared binary
103
+ """
104
+ # Check if cloudflared is already in PATH
105
+ cloudflared_cmd = (
106
+ "cloudflared.exe" if platform.system() == "windows" else "cloudflared"
107
+ )
108
+ if shutil.which(cloudflared_cmd) and not force:
109
+ return Path(shutil.which(cloudflared_cmd))
110
+
111
+ cloudflared_path = get_cloudflared_path()
112
+ system_key = get_system()
113
+
114
+ if cloudflared_path.exists() and not force:
115
+ # Check if binary is executable
116
+ if platform.system() != "windows":
117
+ st = os.stat(cloudflared_path)
118
+ os.chmod(cloudflared_path, st.st_mode | stat.S_IEXEC)
119
+ return cloudflared_path
120
+
121
+ if system_key not in PLATFORMS:
122
+ raise CloudflaredError(f"Unsupported platform: {system_key}")
123
+
124
+ download_url = PLATFORMS[system_key]
125
+ logger.info("Downloading cloudflared from %s...", download_url)
126
+
127
+ try:
128
+ with urlopen(download_url, timeout=30) as response:
129
+ with open(cloudflared_path, "wb") as f:
130
+ f.write(response.read())
131
+
132
+ # Make executable on Unix systems
133
+ if platform.system() != "windows":
134
+ st = os.stat(cloudflared_path)
135
+ os.chmod(cloudflared_path, st.st_mode | stat.S_IEXEC)
136
+
137
+ logger.info("Downloaded cloudflared to %s", cloudflared_path)
138
+ return cloudflared_path
139
+
140
+ except Exception as e:
141
+ raise CloudflaredError(f"Failed to download cloudflared: {e}") from e
142
+
143
+
144
+ def connect(port: int, host: str = "localhost") -> CloudflaredTunnel:
145
+ """
146
+ Create a Cloudflare Tunnel connection to the specified port.
147
+
148
+ Args:
149
+ port: Local port to tunnel
150
+ host: Local host (default: localhost)
151
+
152
+ Returns:
153
+ CloudflaredTunnel object with public_url attribute
154
+ """
155
+ cloudflared_path = download_cloudflared()
156
+
157
+ # Start cloudflared tunnel
158
+ cmd = [
159
+ str(cloudflared_path),
160
+ "tunnel",
161
+ "--url",
162
+ f"http://{host}:{port}",
163
+ ]
164
+
165
+ process = subprocess.Popen(
166
+ cmd,
167
+ stdout=subprocess.PIPE,
168
+ stderr=subprocess.STDOUT,
169
+ text=True,
170
+ bufsize=1,
171
+ )
172
+
173
+ # Parse output to get public URL
174
+ public_url = None
175
+ url_pattern = re.compile(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com")
176
+
177
+ def read_output():
178
+ nonlocal public_url
179
+ try:
180
+ for line in process.stdout:
181
+ if not line:
182
+ continue
183
+ line = line.strip()
184
+ if line:
185
+ logger.debug("[cloudflared] %s", line)
186
+ # Look for URL in the line
187
+ match = url_pattern.search(line)
188
+ if match:
189
+ public_url = match.group(0)
190
+ break
191
+ except Exception as e:
192
+ logger.error("[cloudflared] Error reading output: %s", e)
193
+
194
+ # Start reading output in a separate thread
195
+ output_thread = threading.Thread(target=read_output, daemon=True)
196
+ output_thread.start()
197
+
198
+ # Wait for URL to be available (max 30 seconds)
199
+ timeout = 30
200
+ start_time = time.time()
201
+ while public_url is None and (time.time() - start_time) < timeout:
202
+ if process.poll() is not None:
203
+ raise CloudflaredError("cloudflared process exited unexpectedly")
204
+ time.sleep(0.1)
205
+
206
+ if public_url is None:
207
+ process.terminate()
208
+ raise CloudflaredError("Failed to get public URL from cloudflared")
209
+
210
+ return CloudflaredTunnel(public_url=public_url, process=process)
@@ -0,0 +1,360 @@
1
+ import asyncio
2
+ import os
3
+ from contextlib import asynccontextmanager
4
+ from typing import List, Literal, Optional
5
+
6
+ import httpx
7
+ import uvicorn
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel
11
+
12
+ from quraite.adapters.base import BaseAdapter
13
+ from quraite.logger import get_logger
14
+ from quraite.schema.message import AgentMessage
15
+ from quraite.schema.response import AgentInvocationResponse
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class InvokeRequest(BaseModel):
21
+ """
22
+ Request model for agent invocation endpoints.
23
+
24
+ Attributes:
25
+ input: List of AgentMessage objects
26
+ session_id: Optional conversation thread identifier
27
+ """
28
+
29
+ input: List[AgentMessage]
30
+ session_id: Optional[str] = None
31
+
32
+
33
+ class InvokeResponse(BaseModel):
34
+ """
35
+ Response model for agent invocation endpoints.
36
+
37
+ Attributes:
38
+ agent_response: AgentInvocationResponse object representing agent responses
39
+ """
40
+
41
+ agent_response: Optional[AgentInvocationResponse] = None
42
+
43
+
44
+ class LocalAgentServer:
45
+ """
46
+ SDK for creating local agent servers that expose agents via HTTP.
47
+
48
+ Usage:
49
+ ```python
50
+ from quraite.serve.local_agent_server import LocalAgentServer
51
+ from quraite.adapters.langgraph_adapter import LangGraphAdapter
52
+
53
+ sdk = LocalAgentServer(wrapped_agent=LangGraphAdapter(agent_graph=agent_graph))
54
+ sdk.start(host="0.0.0.0", port=8000, reload=False)
55
+ ```
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ wrapped_agent: BaseAdapter = None,
61
+ agent_id: Optional[str] = None,
62
+ ):
63
+ """
64
+ Initialize the Local Agent Server SDK.
65
+
66
+ Args:
67
+ wrapped_agent: Optional pre-wrapped local agent instance to register immediately
68
+ agent_id: Optional Quraite platform agent ID. Falls back to QURAITE_AGENT_ID env var.
69
+ quraite_endpoint: Optional Quraite endpoint for updating agent config. Falls back to QURAITE_ENDPOINT env var.
70
+ """
71
+
72
+ self._agent = wrapped_agent
73
+ self.public_url = None
74
+ self._tunnel = None
75
+ self.agent_id = agent_id or os.getenv("QURAITE_AGENT_ID")
76
+ self._quraite_endpoint = (
77
+ os.getenv("QURAITE_ENDPOINT") or "https://api.quraite.ai"
78
+ )
79
+ self.agent_url = None
80
+ # Tunnel configuration (set when create_app is called with tunnel params)
81
+ self._tunnel_config = None
82
+
83
+ if self._agent is None:
84
+ raise RuntimeError("No local agent provided. Please provide a local agent.")
85
+
86
+ def _setup_tunnel_sync(
87
+ self,
88
+ port: int,
89
+ host: str = "0.0.0.0",
90
+ tunnel: Literal["ngrok", "cloudflare"] = "cloudflare",
91
+ ):
92
+ """Synchronous tunnel setup (called from async context)."""
93
+ # Prevent creating multiple tunnels if one already exists
94
+ if self._tunnel is not None:
95
+ return
96
+
97
+ if tunnel == "ngrok":
98
+ # TODO: Add debug info if ngrok fails to connect or auth token is not set
99
+
100
+ try:
101
+ from pyngrok import ngrok
102
+ except ImportError as e:
103
+ raise ImportError(
104
+ "Failed to import pyngrok. Please install the 'pyngrok' optional dependency: pip install 'quraite[pyngrok]'"
105
+ ) from e
106
+
107
+ try:
108
+ ngrok_tunnel = ngrok.connect(port)
109
+ self.public_url = ngrok_tunnel.public_url
110
+ self._tunnel = ngrok_tunnel
111
+ logger.info("Ngrok tunnel established: %s", self.public_url)
112
+ except Exception as e:
113
+ logger.error(
114
+ "Failed to create ngrok tunnel: %s. "
115
+ "Make sure ngrok is installed and authenticated: https://ngrok.com/download",
116
+ e,
117
+ )
118
+ raise
119
+
120
+ elif tunnel == "cloudflare":
121
+ from quraite.serve.cloudflared import connect
122
+
123
+ cloudflared_tunnel = connect(
124
+ port, host=host if host != "0.0.0.0" else "localhost"
125
+ )
126
+ self.public_url = cloudflared_tunnel.public_url
127
+ self._tunnel = cloudflared_tunnel
128
+ logger.info("Cloudflare tunnel established: %s", self.public_url)
129
+
130
+ self.agent_url = self.public_url
131
+
132
+ async def start(
133
+ self,
134
+ host: str = "0.0.0.0",
135
+ port: int = 8000,
136
+ tunnel: Literal["none", "ngrok", "cloudflare"] = "none",
137
+ **uvicorn_kwargs,
138
+ ):
139
+ """
140
+ Start the local agent server.
141
+ """
142
+
143
+ if tunnel == "none":
144
+ self.agent_url = f"http://{host}:{port}"
145
+
146
+ app = self.create_app(port=port, host=host, tunnel=tunnel)
147
+
148
+ loop = asyncio.get_event_loop()
149
+ if loop.is_running():
150
+ config = uvicorn.Config(app, host=host, port=port, **uvicorn_kwargs)
151
+ server = uvicorn.Server(config)
152
+ await server.serve()
153
+ else:
154
+ uvicorn.run(app, host=host, port=port, **uvicorn_kwargs)
155
+
156
+ async def _update_backend_agent_url(self) -> None:
157
+ """
158
+ Update the backend server with the agent URL configuration.
159
+
160
+ Makes a PATCH request to /agents/{agent_id}/config/url to update
161
+ the agent's URL in the Quraite platform. Sends the full URL including
162
+ the /v1/agents/completions path.
163
+ """
164
+ if not self.agent_id or not self._quraite_endpoint or not self.agent_url:
165
+ return
166
+
167
+ quraite_endpoint = self._quraite_endpoint.rstrip("/")
168
+ endpoint = f"{quraite_endpoint}/agents/{self.agent_id}/config/url"
169
+ # Construct full URL with path
170
+ full_url = f"{self.agent_url.rstrip('/')}/v1/agents/completions"
171
+ payload = {"config": {"url": full_url}}
172
+
173
+ try:
174
+ async with httpx.AsyncClient(timeout=10.0) as client:
175
+ response = await client.patch(endpoint, json=payload)
176
+ response.raise_for_status()
177
+ logger.info("Agent URL registered with Quraite platform: %s", full_url)
178
+ except httpx.HTTPStatusError as e:
179
+ logger.warning(
180
+ "Failed to update agent URL in Quraite platform: HTTP %s - %s. Update manually with URL: %s",
181
+ e.response.status_code,
182
+ e.response.text,
183
+ full_url,
184
+ )
185
+ except httpx.RequestError as e:
186
+ logger.warning(
187
+ "Failed to connect to Quraite backend at %s: %s",
188
+ quraite_endpoint,
189
+ e,
190
+ )
191
+ except Exception as e:
192
+ logger.warning("Unexpected error updating agent URL: %s", e)
193
+
194
+ def create_app(
195
+ self,
196
+ port: Optional[int] = None,
197
+ host: str = "0.0.0.0",
198
+ tunnel: Literal["none", "ngrok", "cloudflare"] = "none",
199
+ ) -> FastAPI:
200
+ """
201
+ Create FastAPI app with local agent invocation endpoints.
202
+
203
+ Args:
204
+ port: Optional port number (for tunnel setup)
205
+ host: Host address (for tunnel setup)
206
+ tunnel: Tunnel type to use ("none", "ngrok", or "cloudflare")
207
+
208
+ Returns:
209
+ FastAPI application instance
210
+ """
211
+
212
+ @asynccontextmanager
213
+ async def lifespan(app: FastAPI):
214
+ # Startup: Set up tunnel if requested
215
+ if tunnel != "none" and port is not None:
216
+ logger.info("Setting up %s tunnel on port %s...", tunnel, port)
217
+ # Run tunnel setup in thread pool since it's blocking
218
+ loop = asyncio.get_event_loop()
219
+ await loop.run_in_executor(
220
+ None, self._setup_tunnel_sync, port, host, tunnel
221
+ )
222
+ # Update backend after tunnel is created
223
+ if self.agent_id and self._quraite_endpoint:
224
+ await self._update_backend_agent_url()
225
+ elif tunnel == "none" and port is not None:
226
+ self.agent_url = f"http://{host}:{port}"
227
+
228
+ logger.info("Local Agent Server started successfully")
229
+ if self.public_url:
230
+ logger.info("Agent publicly available at %s", self.public_url)
231
+ if not self.agent_id or not self._quraite_endpoint:
232
+ logger.info(
233
+ "Add this URL to your agent in the Quraite platform: %s",
234
+ self.agent_url,
235
+ )
236
+ else:
237
+ logger.info(
238
+ "Agent running locally. Use a tunnel option to make it publicly available."
239
+ )
240
+ yield
241
+
242
+ # Shutdown: Clean up tunnel
243
+ if self._tunnel is not None:
244
+ logger.info("Closing %s tunnel...", tunnel)
245
+ if tunnel == "ngrok":
246
+ try:
247
+ from pyngrok import ngrok
248
+
249
+ ngrok.disconnect(self._tunnel.public_url)
250
+ ngrok.kill()
251
+ logger.info("Ngrok tunnel closed")
252
+ except Exception as e:
253
+ logger.warning("Error closing ngrok tunnel: %s", e)
254
+ elif tunnel == "cloudflare":
255
+ try:
256
+ loop = asyncio.get_event_loop()
257
+ if hasattr(self._tunnel, "disconnect"):
258
+ await loop.run_in_executor(None, self._tunnel.disconnect)
259
+ elif hasattr(self._tunnel, "stop"):
260
+ await loop.run_in_executor(None, self._tunnel.stop)
261
+ elif hasattr(self._tunnel, "close"):
262
+ await loop.run_in_executor(None, self._tunnel.close)
263
+ logger.info("Cloudflare tunnel closed")
264
+ except Exception as e:
265
+ logger.warning("Error closing cloudflare tunnel: %s", e)
266
+
267
+ app = FastAPI(title="Quraite Local Agent Server", lifespan=lifespan)
268
+
269
+ # Add CORS middleware
270
+ app.add_middleware(
271
+ CORSMiddleware,
272
+ allow_origins=["*"],
273
+ allow_credentials=True,
274
+ allow_methods=["*"],
275
+ allow_headers=["*"],
276
+ )
277
+
278
+ # Health check endpoint
279
+ @app.get("/")
280
+ def health_check():
281
+ """Health check endpoint."""
282
+ return {
283
+ "status": "ok",
284
+ "message": "Local Agent Server is running",
285
+ "local_agent_registered": self._agent is not None,
286
+ }
287
+
288
+ @app.post(
289
+ "/v1/agents/completions",
290
+ response_model=InvokeResponse,
291
+ tags=["agent_invocation"],
292
+ )
293
+ async def invoke_agent(request: InvokeRequest) -> InvokeResponse:
294
+ """
295
+ Agent invocation endpoint (async by default).
296
+
297
+ This endpoint receives an invocation request, deserializes it,
298
+ invokes the registered agent asynchronously, and returns the serialized response.
299
+
300
+ Args:
301
+ request: InvokeRequest containing input and parameters
302
+
303
+ Returns:
304
+ InvokeResponse with list of serialized messages
305
+
306
+ Raises:
307
+ HTTPException 400: If no agent is registered
308
+ HTTPException 422: If request format is invalid
309
+ HTTPException 500: If agent invocation fails
310
+
311
+ Example:
312
+ ```python
313
+ POST /v1/agents/completions
314
+ {
315
+ "input": [{
316
+ "role": "user",
317
+ "content": [{
318
+ "type": "text",
319
+ "text": "Hello"
320
+ }]
321
+ }],
322
+ "session_id": "session_123",
323
+ }
324
+ ```
325
+ """
326
+ if not self._agent:
327
+ raise HTTPException(
328
+ status_code=400,
329
+ detail="No agent registered. Please register an agent before invoking.",
330
+ )
331
+
332
+ try:
333
+ # Invoke agent (asynchronously)
334
+ messages: List[AgentMessage] = await self._agent.ainvoke(
335
+ input=request.input,
336
+ session_id=request.session_id,
337
+ )
338
+
339
+ return InvokeResponse(agent_response=messages)
340
+
341
+ except HTTPException:
342
+ raise
343
+ except Exception as e:
344
+ raise HTTPException(
345
+ status_code=500,
346
+ detail=f"Agent invocation failed: {str(e)}",
347
+ ) from e
348
+
349
+ return app
350
+
351
+
352
+ if __name__ == "__main__":
353
+ import asyncio
354
+
355
+ from quraite.adapters.base import DummyAdapter
356
+
357
+ server = LocalAgentServer(wrapped_agent=DummyAdapter())
358
+ asyncio.run(
359
+ server.start(host="0.0.0.0", port=8000, reload=False, tunnel="cloudflare")
360
+ )