quraite 0.0.2__py3-none-any.whl → 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.
- quraite/__init__.py +3 -3
- quraite/adapters/__init__.py +134 -134
- quraite/adapters/agno_adapter.py +159 -159
- quraite/adapters/base.py +123 -123
- quraite/adapters/bedrock_agents_adapter.py +343 -343
- quraite/adapters/flowise_adapter.py +275 -275
- quraite/adapters/google_adk_adapter.py +209 -209
- quraite/adapters/http_adapter.py +239 -239
- quraite/adapters/langflow_adapter.py +192 -192
- quraite/adapters/langgraph_adapter.py +304 -304
- quraite/adapters/langgraph_server_adapter.py +252 -252
- quraite/adapters/n8n_adapter.py +220 -220
- quraite/adapters/openai_agents_adapter.py +269 -269
- quraite/adapters/pydantic_ai_adapter.py +312 -312
- quraite/adapters/smolagents_adapter.py +152 -152
- quraite/logger.py +61 -64
- quraite/schema/message.py +91 -54
- quraite/schema/response.py +16 -16
- quraite/serve/__init__.py +1 -1
- quraite/serve/cloudflared.py +210 -210
- quraite/serve/local_agent.py +360 -360
- quraite/tracing/__init__.py +24 -24
- quraite/tracing/constants.py +16 -16
- quraite/tracing/span_exporter.py +115 -115
- quraite/tracing/span_processor.py +49 -49
- quraite/tracing/tool_extractors.py +290 -290
- quraite/tracing/trace.py +564 -494
- quraite/tracing/types.py +179 -179
- quraite/tracing/utils.py +170 -170
- quraite/utils/json_utils.py +269 -269
- {quraite-0.0.2.dist-info → quraite-0.1.0.dist-info}/METADATA +9 -9
- quraite-0.1.0.dist-info/RECORD +35 -0
- {quraite-0.0.2.dist-info → quraite-0.1.0.dist-info}/WHEEL +1 -1
- 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.0.2.dist-info/RECORD +0 -49
quraite/serve/local_agent.py
CHANGED
|
@@ -1,360 +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
|
|
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
|
-
|
|
335
|
-
input=request.input,
|
|
336
|
-
session_id=request.session_id,
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
return InvokeResponse(agent_response=
|
|
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
|
-
)
|
|
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 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
|
+
response: AgentInvocationResponse = await self._agent.ainvoke(
|
|
335
|
+
input=request.input,
|
|
336
|
+
session_id=request.session_id,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return InvokeResponse(agent_response=response)
|
|
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
|
+
)
|