更新A2A
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
A2A LiteLLM Agent Package
|
||||||
|
"""
|
||||||
|
from .agent import LiteLLMAgent
|
||||||
|
from .config import get_config, LiteLLMConfig, AgentConfig, A2AConfig
|
||||||
|
from .a2a_server import A2AAgentServer, create_app
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LiteLLMAgent",
|
||||||
|
"get_config",
|
||||||
|
"LiteLLMConfig",
|
||||||
|
"AgentConfig",
|
||||||
|
"A2AConfig",
|
||||||
|
"A2AAgentServer",
|
||||||
|
"create_app"
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# A2A LiteLLM Agent Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY agents/a2a_litellm_agent/requirements.txt .
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt requests
|
||||||
|
|
||||||
|
# 复制 common 模块(回调工具)
|
||||||
|
COPY common/agent_callback_utils.py /app/common/
|
||||||
|
RUN touch /app/common/__init__.py
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY agents/a2a_litellm_agent/*.py /app/
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV SERVICE_HOST=0.0.0.0
|
||||||
|
ENV SERVICE_PORT=8080
|
||||||
|
ENV POD_NAME=a2a-litellm-agent
|
||||||
|
ENV TEMPLATE_TYPE=a2a_litellm_agent
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# 回调配置
|
||||||
|
ENV AGENT_CALLBACK_URL=http://mcp-server.taiji-ai.svc.cluster.local:8002/api/v1/billing/agent-callback
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health').read()" || exit 1
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["python", "main.py"]
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"""
|
||||||
|
A2A协议兼容的Agent服务
|
||||||
|
|
||||||
|
实现Google Agent2Agent协议规范
|
||||||
|
支持从请求传入 API key,也支持从环境变量获取
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
from typing import Optional, Dict, Any, AsyncGenerator
|
||||||
|
from datetime import datetime
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from agent import LiteLLMAgent
|
||||||
|
from config import get_config, AgentConfig, A2AConfig
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
# 环境变量配置
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8080"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "a2a-litellm-agent")
|
||||||
|
TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "a2a_litellm_agent")
|
||||||
|
|
||||||
|
# ============== A2A 协议数据模型 ==============
|
||||||
|
|
||||||
|
|
||||||
|
class A2APart(BaseModel):
|
||||||
|
"""A2A消息部分"""
|
||||||
|
kind: str = "text"
|
||||||
|
text: Optional[str] = None
|
||||||
|
data: Optional[Dict[str, Any]] = None
|
||||||
|
mime_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class A2AMessage(BaseModel):
|
||||||
|
"""A2A消息"""
|
||||||
|
role: str
|
||||||
|
parts: list[A2APart]
|
||||||
|
messageId: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
|
||||||
|
|
||||||
|
class A2AMessageSendParams(BaseModel):
|
||||||
|
"""A2A发送消息参数"""
|
||||||
|
message: A2AMessage
|
||||||
|
configuration: Optional[Dict[str, Any]] = None
|
||||||
|
api_key: Optional[str] = Field(None, description="LiteLLM API密钥(可选,优先使用,否则从环境变量获取)")
|
||||||
|
model: Optional[str] = Field(None, description="模型名称(可选,优先使用,否则从环境变量获取)")
|
||||||
|
|
||||||
|
|
||||||
|
class A2ARequest(BaseModel):
|
||||||
|
"""A2A JSON-RPC请求"""
|
||||||
|
jsonrpc: str = "2.0"
|
||||||
|
id: str
|
||||||
|
method: str
|
||||||
|
params: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class A2AArtifact(BaseModel):
|
||||||
|
"""A2A响应工件"""
|
||||||
|
artifactId: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
name: str = "response"
|
||||||
|
parts: list[A2APart]
|
||||||
|
|
||||||
|
|
||||||
|
class A2ATaskStatus(BaseModel):
|
||||||
|
"""A2A任务状态"""
|
||||||
|
state: str # submitted, working, input-required, completed, failed, canceled
|
||||||
|
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class A2ATask(BaseModel):
|
||||||
|
"""A2A任务"""
|
||||||
|
kind: str = "task"
|
||||||
|
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
contextId: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
status: A2ATaskStatus
|
||||||
|
artifacts: Optional[list[A2AArtifact]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class A2AResponse(BaseModel):
|
||||||
|
"""A2A JSON-RPC响应"""
|
||||||
|
jsonrpc: str = "2.0"
|
||||||
|
id: str
|
||||||
|
result: Optional[A2ATask] = None
|
||||||
|
error: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class A2AStreamEvent(BaseModel):
|
||||||
|
"""A2A流式事件"""
|
||||||
|
kind: str
|
||||||
|
taskId: str
|
||||||
|
contextId: str
|
||||||
|
data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Agent Card ==============
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkill(BaseModel):
|
||||||
|
"""Agent技能"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
inputSchema: Optional[Dict[str, Any]] = None
|
||||||
|
outputSchema: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCapabilities(BaseModel):
|
||||||
|
"""Agent能力"""
|
||||||
|
text: bool = True
|
||||||
|
streaming: bool = True
|
||||||
|
push_notifications: bool = False
|
||||||
|
forms: bool = False
|
||||||
|
files: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCard(BaseModel):
|
||||||
|
"""A2A Agent Card - 描述Agent能力"""
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version: str
|
||||||
|
url: str
|
||||||
|
capabilities: AgentCapabilities
|
||||||
|
skills: list[AgentSkill]
|
||||||
|
authentication: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============== A2A Server ==============
|
||||||
|
|
||||||
|
|
||||||
|
class A2AAgentServer:
|
||||||
|
"""A2A协议Agent服务器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
model: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化A2A Agent服务器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: LiteLLM API密钥(可选,优先使用,否则从环境变量获取)
|
||||||
|
model: 模型名称(可选,优先使用,否则从环境变量获取)
|
||||||
|
"""
|
||||||
|
# 获取配置
|
||||||
|
self.llm_config, self.agent_config, self.a2a_config = get_config(api_key, model)
|
||||||
|
|
||||||
|
# 创建Agent(使用默认配置)
|
||||||
|
self.default_agent = LiteLLMAgent(
|
||||||
|
litellm_config=self.llm_config,
|
||||||
|
agent_config=self.agent_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# 任务存储
|
||||||
|
self.tasks: Dict[str, A2ATask] = {}
|
||||||
|
|
||||||
|
# 创建FastAPI应用
|
||||||
|
self.app = self._create_app()
|
||||||
|
|
||||||
|
def _create_app(self) -> FastAPI:
|
||||||
|
"""创建FastAPI应用"""
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("A2A Agent服务启动", agent_name=self.agent_config.name)
|
||||||
|
yield
|
||||||
|
await self.default_agent.close()
|
||||||
|
logger.info("A2A Agent服务关闭")
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=f"{self.agent_config.name} - A2A Agent",
|
||||||
|
description=self.agent_config.description,
|
||||||
|
version=self.agent_config.version,
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS中间件
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册路由
|
||||||
|
self._register_routes(app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
def _get_agent(self, api_key: Optional[str] = None, model: Optional[str] = None) -> LiteLLMAgent:
|
||||||
|
"""
|
||||||
|
获取Agent实例
|
||||||
|
|
||||||
|
如果提供了api_key或model,创建新的Agent实例
|
||||||
|
否则使用默认Agent
|
||||||
|
"""
|
||||||
|
if api_key or model:
|
||||||
|
# 创建新的配置和Agent
|
||||||
|
llm_config, agent_config, _ = get_config(api_key, model)
|
||||||
|
return LiteLLMAgent(litellm_config=llm_config, agent_config=agent_config)
|
||||||
|
return self.default_agent
|
||||||
|
|
||||||
|
def _register_routes(self, app: FastAPI):
|
||||||
|
"""注册A2A协议路由"""
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""服务根路径"""
|
||||||
|
return {
|
||||||
|
"name": self.agent_config.name,
|
||||||
|
"version": self.agent_config.version,
|
||||||
|
"protocol": "A2A",
|
||||||
|
"status": "running",
|
||||||
|
"pod_name": POD_NAME,
|
||||||
|
"template_type": TEMPLATE_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"pod_name": POD_NAME,
|
||||||
|
"template_type": TEMPLATE_TYPE,
|
||||||
|
"configured": self.llm_config.api_key is not None,
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/.well-known/agent.json")
|
||||||
|
async def get_agent_card(request: Request):
|
||||||
|
"""获取Agent Card (A2A发现协议)"""
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
|
||||||
|
card = AgentCard(
|
||||||
|
name=self.agent_config.name,
|
||||||
|
description=self.agent_config.description,
|
||||||
|
version=self.agent_config.version,
|
||||||
|
url=base_url,
|
||||||
|
capabilities=AgentCapabilities(
|
||||||
|
text=True,
|
||||||
|
streaming=self.agent_config.enable_streaming,
|
||||||
|
push_notifications=False
|
||||||
|
),
|
||||||
|
skills=[
|
||||||
|
AgentSkill(
|
||||||
|
id="general-assistant",
|
||||||
|
name="通用助手",
|
||||||
|
description="回答问题、提供建议、协助完成各种任务"
|
||||||
|
),
|
||||||
|
AgentSkill(
|
||||||
|
id="code-helper",
|
||||||
|
name="代码助手",
|
||||||
|
description="编写、解释和调试代码"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return card.model_dump()
|
||||||
|
|
||||||
|
@app.post("/message/send")
|
||||||
|
async def send_message(request: Request):
|
||||||
|
"""A2A message/send 端点"""
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
# 解析JSON-RPC请求
|
||||||
|
try:
|
||||||
|
rpc_request = A2ARequest(**body)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": body.get("id", "unknown"),
|
||||||
|
"error": {
|
||||||
|
"code": -32600,
|
||||||
|
"message": f"Invalid Request: {str(e)}"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 处理 message/send 方法
|
||||||
|
if rpc_request.method == "message/send":
|
||||||
|
return await self._handle_message_send(rpc_request)
|
||||||
|
elif rpc_request.method == "message/stream":
|
||||||
|
return await self._handle_message_stream(rpc_request)
|
||||||
|
else:
|
||||||
|
return JSONResponse({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": rpc_request.id,
|
||||||
|
"error": {
|
||||||
|
"code": -32601,
|
||||||
|
"message": f"Method not found: {rpc_request.method}"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.post("/message/stream")
|
||||||
|
async def stream_message(request: Request):
|
||||||
|
"""A2A message/stream 端点 (SSE流式响应)"""
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
try:
|
||||||
|
rpc_request = A2ARequest(**body)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": body.get("id", "unknown"),
|
||||||
|
"error": {
|
||||||
|
"code": -32600,
|
||||||
|
"message": f"Invalid Request: {str(e)}"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return await self._handle_message_stream(rpc_request)
|
||||||
|
|
||||||
|
@app.get("/tasks/{task_id}")
|
||||||
|
async def get_task(task_id: str):
|
||||||
|
"""获取任务状态"""
|
||||||
|
if task_id not in self.tasks:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return self.tasks[task_id].model_dump()
|
||||||
|
|
||||||
|
async def _handle_message_send(self, request: A2ARequest) -> JSONResponse:
|
||||||
|
"""处理 message/send 请求"""
|
||||||
|
params = request.params or {}
|
||||||
|
message_data = params.get("message", {})
|
||||||
|
|
||||||
|
# 提取API key和model(如果提供)
|
||||||
|
api_key = params.get("api_key") or os.getenv("LITELLM_API_KEY")
|
||||||
|
model = params.get("model") or os.getenv("MODEL_NAME") or os.getenv("LITELLM_MODEL")
|
||||||
|
|
||||||
|
# 提取用户消息文本
|
||||||
|
user_text = ""
|
||||||
|
parts = message_data.get("parts", [])
|
||||||
|
for part in parts:
|
||||||
|
if part.get("kind") == "text":
|
||||||
|
user_text += part.get("text", "")
|
||||||
|
|
||||||
|
if not user_text:
|
||||||
|
return JSONResponse({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request.id,
|
||||||
|
"error": {
|
||||||
|
"code": -32602,
|
||||||
|
"message": "Invalid params: no text content found"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 创建任务
|
||||||
|
task_id = uuid.uuid4().hex
|
||||||
|
context_id = params.get("contextId", uuid.uuid4().hex)
|
||||||
|
|
||||||
|
task = A2ATask(
|
||||||
|
id=task_id,
|
||||||
|
contextId=context_id,
|
||||||
|
status=A2ATaskStatus(state="working")
|
||||||
|
)
|
||||||
|
self.tasks[task_id] = task
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取Agent实例(如果提供了api_key或model,使用新的实例)
|
||||||
|
agent = self._get_agent(api_key, model)
|
||||||
|
|
||||||
|
# 调用Agent获取响应
|
||||||
|
logger.info("处理消息", task_id=task_id, message_preview=user_text[:50])
|
||||||
|
|
||||||
|
response_text = await agent.chat(
|
||||||
|
message=user_text,
|
||||||
|
conversation_id=context_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果创建了新Agent,关闭它
|
||||||
|
if api_key or model:
|
||||||
|
await agent.close()
|
||||||
|
|
||||||
|
# 更新任务状态
|
||||||
|
task.status = A2ATaskStatus(state="completed")
|
||||||
|
task.artifacts = [
|
||||||
|
A2AArtifact(
|
||||||
|
name="response",
|
||||||
|
parts=[A2APart(kind="text", text=response_text)]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.tasks[task_id] = task
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request.id,
|
||||||
|
"result": task.model_dump()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("处理消息失败", error=str(e))
|
||||||
|
task.status = A2ATaskStatus(state="failed", message=str(e))
|
||||||
|
self.tasks[task_id] = task
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request.id,
|
||||||
|
"error": {
|
||||||
|
"code": -32000,
|
||||||
|
"message": f"Agent error: {str(e)}"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_message_stream(self, request: A2ARequest) -> StreamingResponse:
|
||||||
|
"""处理 message/stream 请求 (SSE)"""
|
||||||
|
params = request.params or {}
|
||||||
|
message_data = params.get("message", {})
|
||||||
|
|
||||||
|
# 提取API key和model(如果提供)
|
||||||
|
api_key = params.get("api_key") or os.getenv("LITELLM_API_KEY")
|
||||||
|
model = params.get("model") or os.getenv("MODEL_NAME") or os.getenv("LITELLM_MODEL")
|
||||||
|
|
||||||
|
# 提取用户消息
|
||||||
|
user_text = ""
|
||||||
|
parts = message_data.get("parts", [])
|
||||||
|
for part in parts:
|
||||||
|
if part.get("kind") == "text":
|
||||||
|
user_text += part.get("text", "")
|
||||||
|
|
||||||
|
task_id = uuid.uuid4().hex
|
||||||
|
context_id = params.get("contextId", uuid.uuid4().hex)
|
||||||
|
|
||||||
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
|
"""生成SSE事件流"""
|
||||||
|
agent = None
|
||||||
|
try:
|
||||||
|
# 获取Agent实例
|
||||||
|
agent = self._get_agent(api_key, model)
|
||||||
|
|
||||||
|
# 发送任务开始事件
|
||||||
|
start_event = {
|
||||||
|
"kind": "task-start",
|
||||||
|
"taskId": task_id,
|
||||||
|
"contextId": context_id
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(start_event)}\n\n"
|
||||||
|
|
||||||
|
# 获取流式响应
|
||||||
|
stream = await agent.chat(
|
||||||
|
message=user_text,
|
||||||
|
conversation_id=context_id,
|
||||||
|
stream=True
|
||||||
|
)
|
||||||
|
|
||||||
|
full_response = ""
|
||||||
|
async for chunk in stream:
|
||||||
|
full_response += chunk
|
||||||
|
# 发送文本增量事件
|
||||||
|
delta_event = {
|
||||||
|
"kind": "artifact-delta",
|
||||||
|
"taskId": task_id,
|
||||||
|
"contextId": context_id,
|
||||||
|
"data": {
|
||||||
|
"kind": "text",
|
||||||
|
"text": chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(delta_event)}\n\n"
|
||||||
|
|
||||||
|
# 发送完成事件
|
||||||
|
complete_event = {
|
||||||
|
"kind": "task-complete",
|
||||||
|
"taskId": task_id,
|
||||||
|
"contextId": context_id,
|
||||||
|
"data": {
|
||||||
|
"status": "completed",
|
||||||
|
"artifacts": [{
|
||||||
|
"name": "response",
|
||||||
|
"parts": [{"kind": "text", "text": full_response}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(complete_event)}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 发送错误事件
|
||||||
|
error_event = {
|
||||||
|
"kind": "task-error",
|
||||||
|
"taskId": task_id,
|
||||||
|
"contextId": context_id,
|
||||||
|
"data": {
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(error_event)}\n\n"
|
||||||
|
finally:
|
||||||
|
# 如果创建了新Agent,关闭它
|
||||||
|
if agent and (api_key or model):
|
||||||
|
await agent.close()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, host: Optional[str] = None, port: Optional[int] = None):
|
||||||
|
"""运行服务器"""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
host = host or self.agent_config.host
|
||||||
|
port = port or self.agent_config.port
|
||||||
|
|
||||||
|
logger.info(f"启动A2A Agent服务", host=host, port=port)
|
||||||
|
uvicorn.run(self.app, host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(api_key: Optional[str] = None, model: Optional[str] = None) -> FastAPI:
|
||||||
|
"""
|
||||||
|
创建FastAPI应用(用于uvicorn启动)
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
uvicorn a2a_server:app --host 0.0.0.0 --port 8080
|
||||||
|
|
||||||
|
或设置环境变量后:
|
||||||
|
export LITELLM_API_KEY="your-key"
|
||||||
|
export MODEL_NAME="your-model"
|
||||||
|
uvicorn a2a_server:app --host 0.0.0.0 --port 8080
|
||||||
|
"""
|
||||||
|
server = A2AAgentServer(api_key=api_key, model=model)
|
||||||
|
return server.app
|
||||||
|
|
||||||
|
|
||||||
|
# uvicorn 启动入口
|
||||||
|
# 环境变量: LITELLM_API_KEY, MODEL_NAME (或 LITELLM_MODEL)
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""
|
||||||
|
LiteLLM Agent 核心模块
|
||||||
|
|
||||||
|
基于LiteLLM框架的Agent实现,支持A2A协议
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import AsyncGenerator, Optional, Dict, Any, List
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from config import LiteLLMConfig, AgentConfig, get_config
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Message:
|
||||||
|
"""消息数据结构"""
|
||||||
|
role: str # user, assistant, system
|
||||||
|
content: str
|
||||||
|
message_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Conversation:
|
||||||
|
"""对话上下文"""
|
||||||
|
conversation_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
messages: List[Message] = field(default_factory=list)
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_message(self, role: str, content: str) -> Message:
|
||||||
|
"""添加消息到对话"""
|
||||||
|
msg = Message(role=role, content=content)
|
||||||
|
self.messages.append(msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def to_openai_format(self) -> List[Dict[str, str]]:
|
||||||
|
"""转换为OpenAI格式的消息列表"""
|
||||||
|
return [{"role": m.role, "content": m.content} for m in self.messages]
|
||||||
|
|
||||||
|
|
||||||
|
class LiteLLMAgent:
|
||||||
|
"""
|
||||||
|
基于LiteLLM的Agent实现
|
||||||
|
|
||||||
|
支持功能:
|
||||||
|
- 多轮对话
|
||||||
|
- 流式响应
|
||||||
|
- 工具调用
|
||||||
|
- A2A协议兼容
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
litellm_config: Optional[LiteLLMConfig] = None,
|
||||||
|
agent_config: Optional[AgentConfig] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化Agent
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: LiteLLM API密钥(可选,优先使用,否则从环境变量获取)
|
||||||
|
model: 模型名称(可选,优先使用,否则从环境变量获取)
|
||||||
|
litellm_config: LiteLLM配置对象
|
||||||
|
agent_config: Agent配置对象
|
||||||
|
"""
|
||||||
|
if litellm_config:
|
||||||
|
self.llm_config = litellm_config
|
||||||
|
else:
|
||||||
|
self.llm_config = LiteLLMConfig(api_key=api_key, model=model)
|
||||||
|
|
||||||
|
if agent_config:
|
||||||
|
self.agent_config = agent_config
|
||||||
|
else:
|
||||||
|
self.agent_config = AgentConfig()
|
||||||
|
|
||||||
|
# 验证配置
|
||||||
|
self.llm_config.validate()
|
||||||
|
|
||||||
|
# HTTP客户端
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
# 对话管理
|
||||||
|
self.conversations: Dict[str, Conversation] = {}
|
||||||
|
|
||||||
|
# 工具注册
|
||||||
|
self.tools: Dict[str, callable] = {}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Agent初始化完成",
|
||||||
|
agent_name=self.agent_config.name,
|
||||||
|
model=self.llm_config.model,
|
||||||
|
base_url=self.llm_config.base_url
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""获取或创建HTTP客户端"""
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(self.llm_config.timeout),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.llm_config.api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""关闭资源"""
|
||||||
|
if self._client and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
def register_tool(self, name: str, func: callable, description: str = ""):
|
||||||
|
"""注册工具函数"""
|
||||||
|
self.tools[name] = {
|
||||||
|
"function": func,
|
||||||
|
"description": description
|
||||||
|
}
|
||||||
|
logger.info(f"注册工具: {name}")
|
||||||
|
|
||||||
|
def get_or_create_conversation(self, conversation_id: Optional[str] = None) -> Conversation:
|
||||||
|
"""获取或创建对话"""
|
||||||
|
if conversation_id and conversation_id in self.conversations:
|
||||||
|
return self.conversations[conversation_id]
|
||||||
|
|
||||||
|
conv = Conversation(conversation_id=conversation_id or uuid.uuid4().hex)
|
||||||
|
# 添加系统提示
|
||||||
|
conv.add_message("system", self.agent_config.system_prompt)
|
||||||
|
self.conversations[conv.conversation_id] = conv
|
||||||
|
return conv
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
conversation_id: Optional[str] = None,
|
||||||
|
stream: bool = False
|
||||||
|
) -> str | AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
发送消息并获取回复
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 用户消息
|
||||||
|
conversation_id: 对话ID(用于多轮对话)
|
||||||
|
stream: 是否流式响应
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
如果stream=False,返回完整回复字符串
|
||||||
|
如果stream=True,返回异步生成器
|
||||||
|
"""
|
||||||
|
# 获取对话上下文
|
||||||
|
conversation = self.get_or_create_conversation(conversation_id)
|
||||||
|
conversation.add_message("user", message)
|
||||||
|
|
||||||
|
if stream:
|
||||||
|
return self._stream_chat(conversation)
|
||||||
|
else:
|
||||||
|
return await self._simple_chat(conversation)
|
||||||
|
|
||||||
|
async def _simple_chat(self, conversation: Conversation) -> str:
|
||||||
|
"""非流式对话"""
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
request_body = {
|
||||||
|
"model": self.llm_config.model,
|
||||||
|
"messages": conversation.to_openai_format(),
|
||||||
|
"temperature": self.llm_config.temperature,
|
||||||
|
"max_tokens": self.llm_config.max_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("发送请求", endpoint=self.llm_config.chat_endpoint)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
self.llm_config.chat_endpoint,
|
||||||
|
json=request_body
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
assistant_message = result["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# 保存助手回复到对话
|
||||||
|
conversation.add_message("assistant", assistant_message)
|
||||||
|
|
||||||
|
logger.info("收到回复", length=len(assistant_message))
|
||||||
|
return assistant_message
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error("HTTP错误", status_code=e.response.status_code, detail=e.response.text)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("请求失败", error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _stream_chat(self, conversation: Conversation) -> AsyncGenerator[str, None]:
|
||||||
|
"""流式对话"""
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
request_body = {
|
||||||
|
"model": self.llm_config.model,
|
||||||
|
"messages": conversation.to_openai_format(),
|
||||||
|
"temperature": self.llm_config.temperature,
|
||||||
|
"max_tokens": self.llm_config.max_tokens,
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
full_response = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
self.llm_config.chat_endpoint,
|
||||||
|
json=request_body
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if line.startswith("data: "):
|
||||||
|
data = line[6:]
|
||||||
|
if data == "[DONE]":
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data)
|
||||||
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||||
|
content = delta.get("content", "")
|
||||||
|
if content:
|
||||||
|
full_response += content
|
||||||
|
yield content
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 保存完整回复到对话
|
||||||
|
conversation.add_message("assistant", full_response)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("流式请求失败", error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def invoke_tool(self, tool_name: str, **kwargs) -> Any:
|
||||||
|
"""调用注册的工具"""
|
||||||
|
if tool_name not in self.tools:
|
||||||
|
raise ValueError(f"未找到工具: {tool_name}")
|
||||||
|
|
||||||
|
tool = self.tools[tool_name]
|
||||||
|
func = tool["function"]
|
||||||
|
|
||||||
|
logger.info(f"调用工具: {tool_name}", kwargs=kwargs)
|
||||||
|
|
||||||
|
if asyncio.iscoroutinefunction(func):
|
||||||
|
return await func(**kwargs)
|
||||||
|
else:
|
||||||
|
return func(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# 示例工具函数
|
||||||
|
def tool_get_current_time() -> str:
|
||||||
|
"""获取当前时间"""
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def tool_calculate(expression: str) -> str:
|
||||||
|
"""计算数学表达式"""
|
||||||
|
try:
|
||||||
|
# 安全的数学计算
|
||||||
|
allowed_chars = set("0123456789+-*/.() ")
|
||||||
|
if not all(c in allowed_chars for c in expression):
|
||||||
|
return "错误: 不支持的字符"
|
||||||
|
result = eval(expression)
|
||||||
|
return str(result)
|
||||||
|
except Exception as e:
|
||||||
|
return f"计算错误: {str(e)}"
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
LiteLLM Agent 配置模块
|
||||||
|
|
||||||
|
支持用户传入密钥和模型名称,同时支持从环境变量获取
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 加载环境变量
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LiteLLMConfig:
|
||||||
|
"""LiteLLM 配置"""
|
||||||
|
# 基础URL - 用户提供的LiteLLM服务地址
|
||||||
|
base_url: str = "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io"
|
||||||
|
|
||||||
|
# 完整的chat completions端点
|
||||||
|
chat_endpoint: str = field(init=False)
|
||||||
|
|
||||||
|
# API密钥 - 优先使用传入的,否则从环境变量获取
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
|
||||||
|
# 模型名称 - 优先使用传入的,否则从环境变量获取
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
# 请求超时时间(秒)
|
||||||
|
timeout: int = 120
|
||||||
|
|
||||||
|
# 最大重试次数
|
||||||
|
max_retries: int = 3
|
||||||
|
|
||||||
|
# 温度参数
|
||||||
|
temperature: float = 0.7
|
||||||
|
|
||||||
|
# 最大token数
|
||||||
|
max_tokens: int = 4096
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.chat_endpoint = f"{self.base_url}/chat/completions"
|
||||||
|
|
||||||
|
# 从环境变量读取(如果未直接提供)
|
||||||
|
if self.api_key is None:
|
||||||
|
self.api_key = os.getenv("LITELLM_API_KEY")
|
||||||
|
if self.model is None:
|
||||||
|
self.model = os.getenv("MODEL_NAME") or os.getenv("LITELLM_MODEL", "gpt-4")
|
||||||
|
|
||||||
|
def validate(self) -> bool:
|
||||||
|
"""验证配置是否完整"""
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError("API密钥未设置! 请设置 LITELLM_API_KEY 环境变量或直接传入 api_key")
|
||||||
|
if not self.model:
|
||||||
|
raise ValueError("模型名称未设置! 请设置 MODEL_NAME 环境变量或直接传入 model")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentConfig:
|
||||||
|
"""Agent 配置"""
|
||||||
|
# Agent名称
|
||||||
|
name: str = "xiaohei-agent"
|
||||||
|
|
||||||
|
# Agent描述
|
||||||
|
description: str = "一个基于LiteLLM的智能Agent,支持A2A协议"
|
||||||
|
|
||||||
|
# Agent版本
|
||||||
|
version: str = "1.0.0"
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
port: int = 8080
|
||||||
|
|
||||||
|
# 服务主机
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
|
||||||
|
# 是否启用流式响应
|
||||||
|
enable_streaming: bool = True
|
||||||
|
|
||||||
|
# 系统提示词
|
||||||
|
system_prompt: str = """你是小黑Agent,一个智能助手。
|
||||||
|
你可以帮助用户完成各种任务,包括:
|
||||||
|
|
||||||
|
- 回答问题
|
||||||
|
|
||||||
|
- 代码编写和解释
|
||||||
|
|
||||||
|
- 文档分析
|
||||||
|
|
||||||
|
- 任务规划
|
||||||
|
|
||||||
|
请用中文回答用户的问题,保持友好和专业。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class A2AConfig:
|
||||||
|
"""A2A协议配置"""
|
||||||
|
# A2A协议版本
|
||||||
|
protocol_version: str = "1.0"
|
||||||
|
|
||||||
|
# Agent Card配置
|
||||||
|
agent_card: dict = field(default_factory=lambda: {
|
||||||
|
"name": "xiaohei-agent",
|
||||||
|
"description": "基于LiteLLM的智能Agent,支持A2A协议通信",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capabilities": {
|
||||||
|
"text": True,
|
||||||
|
"streaming": True,
|
||||||
|
"push_notifications": False
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"id": "general-assistant",
|
||||||
|
"name": "通用助手",
|
||||||
|
"description": "回答问题、提供建议、协助任务"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "code-helper",
|
||||||
|
"name": "代码助手",
|
||||||
|
"description": "编写、解释和调试代码"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
model: Optional[str] = None
|
||||||
|
) -> tuple[LiteLLMConfig, AgentConfig, A2AConfig]:
|
||||||
|
"""
|
||||||
|
获取完整配置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: LiteLLM API密钥(可选,优先使用,否则从环境变量获取)
|
||||||
|
model: 模型名称(可选,优先使用,否则从环境变量获取)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(LiteLLMConfig, AgentConfig, A2AConfig) 配置元组
|
||||||
|
"""
|
||||||
|
litellm_config = LiteLLMConfig(api_key=api_key, model=model)
|
||||||
|
agent_config = AgentConfig()
|
||||||
|
a2a_config = A2AConfig()
|
||||||
|
|
||||||
|
return litellm_config, agent_config, a2a_config
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
A2A LiteLLM Agent 主入口
|
||||||
|
支持从环境变量或请求传入 API key
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import uvicorn
|
||||||
|
from a2a_server import create_app
|
||||||
|
|
||||||
|
# 环境变量配置
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8080"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "a2a-litellm-agent")
|
||||||
|
TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "a2a_litellm_agent")
|
||||||
|
|
||||||
|
# 从环境变量获取默认配置(可选)
|
||||||
|
default_api_key = os.getenv("LITELLM_API_KEY")
|
||||||
|
default_model = os.getenv("MODEL_NAME") or os.getenv("LITELLM_MODEL")
|
||||||
|
|
||||||
|
# 创建应用
|
||||||
|
app = create_app(api_key=default_api_key, model=default_model)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
print(f"🚀 启动 A2A LiteLLM Agent")
|
||||||
|
print(f" - Pod名称: {POD_NAME}")
|
||||||
|
print(f" - 模板类型: {TEMPLATE_TYPE}")
|
||||||
|
print(f" - 服务地址: http://{SERVICE_HOST}:{SERVICE_PORT}")
|
||||||
|
if default_api_key:
|
||||||
|
print(f" - 已配置默认 API key(可通过请求覆盖)")
|
||||||
|
else:
|
||||||
|
print(f" - 未配置默认 API key,需在请求中传入")
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=SERVICE_HOST,
|
||||||
|
port=SERVICE_PORT,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# LiteLLM Agent API服务依赖
|
||||||
|
httpx>=0.27.0
|
||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
structlog>=24.0.0
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi==0.109.0 \
|
||||||
|
uvicorn[standard]==0.27.0 \
|
||||||
|
pydantic==2.5.3 \
|
||||||
|
langchain==0.1.0 \
|
||||||
|
langchain-community==0.0.10 \
|
||||||
|
litellm==1.17.0 \
|
||||||
|
azure-storage-blob==12.19.0 \
|
||||||
|
azure-identity==1.15.0
|
||||||
|
|
||||||
|
# 复制agent代码和共享工具
|
||||||
|
COPY agents/azure_blob_agent/azure_blob_agent.py .
|
||||||
|
COPY common/agent_callback_utils.py /app/common/
|
||||||
|
COPY common/api_key_utils.py /app/common/
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV SERVICE_HOST=0.0.0.0
|
||||||
|
ENV SERVICE_PORT=8080
|
||||||
|
|
||||||
|
# 健康检查 - 使用Python避免僵尸进程
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health').read()" || exit 1
|
||||||
|
|
||||||
|
# 运行agent (直接使用Python,避免shell)
|
||||||
|
CMD ["python3", "-u", "azure_blob_agent.py"]
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
"""
|
||||||
|
Azure Blob Storage AI Agent - 使用LangChain + LiteLLM实现
|
||||||
|
通过HTTP API接收连接字符串,并提供智能文件操作功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from azure.storage.blob import BlobServiceClient, ContainerClient
|
||||||
|
from langchain.agents import Tool, AgentExecutor, create_react_agent
|
||||||
|
from langchain.prompts import PromptTemplate
|
||||||
|
from langchain_community.chat_models import ChatLiteLLM
|
||||||
|
import uvicorn
|
||||||
|
from agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 环境变量配置
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8080"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "azure-blob-agent")
|
||||||
|
TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "azure_blob_agent")
|
||||||
|
|
||||||
|
# LiteLLM配置(从环境变量获取)
|
||||||
|
LITELLM_API_BASE = os.getenv("LITELLM_API_BASE", "http://localhost:4000")
|
||||||
|
MODEL_NAME = os.getenv("MODEL_NAME") or os.getenv("LITELLM_MODEL", "gpt-3.5-turbo")
|
||||||
|
|
||||||
|
# Azure Storage 连接字符串(从环境变量获取)
|
||||||
|
AZURE_STORAGE_CONNECTION_STRING = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "")
|
||||||
|
|
||||||
|
# 全局变量
|
||||||
|
blob_service_client: Optional[BlobServiceClient] = None
|
||||||
|
connection_string: Optional[str] = None
|
||||||
|
callback_handler: Optional[AgentCallbackHandler] = None
|
||||||
|
|
||||||
|
# FastAPI应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="Azure Blob Storage AI Agent",
|
||||||
|
description="智能Azure Blob存储管理代理",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 请求/响应模型 ====================
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
"""连接请求"""
|
||||||
|
connection_string: str = Field(..., description="Azure Storage连接字符串")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryRequest(BaseModel):
|
||||||
|
"""查询请求"""
|
||||||
|
query: str = Field(..., description="自然语言查询或操作指令")
|
||||||
|
litellm_api_key: str = Field(..., description="LiteLLM API密钥")
|
||||||
|
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
|
||||||
|
container_name: Optional[str] = Field(None, description="指定容器名称")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""健康检查响应"""
|
||||||
|
status: str
|
||||||
|
connected: bool
|
||||||
|
connection_info: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Azure Blob Storage 工具函数 ====================
|
||||||
|
|
||||||
|
def list_containers_tool() -> str:
|
||||||
|
"""列出所有容器"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return "错误: 未连接到Azure Blob Storage"
|
||||||
|
|
||||||
|
try:
|
||||||
|
containers = blob_service_client.list_containers()
|
||||||
|
container_list = []
|
||||||
|
for container in containers:
|
||||||
|
container_list.append({
|
||||||
|
"name": container.name,
|
||||||
|
"last_modified": str(container.last_modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
if not container_list:
|
||||||
|
return "当前没有容器"
|
||||||
|
|
||||||
|
result = "容器列表:\n"
|
||||||
|
for i, c in enumerate(container_list, 1):
|
||||||
|
result += f"{i}. {c['name']} (最后修改: {c['last_modified']})\n"
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"列出容器失败: {str(e)}")
|
||||||
|
return f"错误: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def list_blobs_in_container(container_name: str) -> str:
|
||||||
|
"""列出指定容器中的所有blob"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return "错误: 未连接到Azure Blob Storage"
|
||||||
|
|
||||||
|
try:
|
||||||
|
container_client = blob_service_client.get_container_client(container_name)
|
||||||
|
blobs = container_client.list_blobs()
|
||||||
|
|
||||||
|
blob_list = []
|
||||||
|
for blob in blobs:
|
||||||
|
blob_list.append({
|
||||||
|
"name": blob.name,
|
||||||
|
"size": blob.size,
|
||||||
|
"content_type": blob.content_settings.content_type if blob.content_settings else "unknown",
|
||||||
|
"last_modified": str(blob.last_modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
if not blob_list:
|
||||||
|
return f"容器 '{container_name}' 中没有文件"
|
||||||
|
|
||||||
|
result = f"容器 '{container_name}' 中的文件列表:\n"
|
||||||
|
total_size = 0
|
||||||
|
for i, b in enumerate(blob_list, 1):
|
||||||
|
size_mb = b['size'] / (1024 * 1024)
|
||||||
|
result += f"{i}. {b['name']} ({size_mb:.2f}MB, {b['content_type']})\n"
|
||||||
|
total_size += b['size']
|
||||||
|
|
||||||
|
result += f"\n总计: {len(blob_list)} 个文件, {total_size / (1024 * 1024):.2f}MB"
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"列出blob失败: {str(e)}")
|
||||||
|
return f"错误: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_blob_info(container_name: str, blob_name: str) -> str:
|
||||||
|
"""获取blob的详细信息"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return "错误: 未连接到Azure Blob Storage"
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob_client = blob_service_client.get_blob_client(container_name, blob_name)
|
||||||
|
properties = blob_client.get_blob_properties()
|
||||||
|
|
||||||
|
info = f"文件信息: {blob_name}\n"
|
||||||
|
info += f"- 容器: {container_name}\n"
|
||||||
|
info += f"- 大小: {properties.size / (1024 * 1024):.2f}MB\n"
|
||||||
|
info += f"- 类型: {properties.content_settings.content_type if properties.content_settings else 'unknown'}\n"
|
||||||
|
info += f"- 创建时间: {properties.creation_time}\n"
|
||||||
|
info += f"- 最后修改: {properties.last_modified}\n"
|
||||||
|
info += f"- ETag: {properties.etag}\n"
|
||||||
|
|
||||||
|
if properties.metadata:
|
||||||
|
info += f"- 元数据: {properties.metadata}\n"
|
||||||
|
|
||||||
|
return info
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取blob信息失败: {str(e)}")
|
||||||
|
return f"错误: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def search_blobs(container_name: str, keyword: str) -> str:
|
||||||
|
"""在容器中搜索包含关键字的blob"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return "错误: 未连接到Azure Blob Storage"
|
||||||
|
|
||||||
|
try:
|
||||||
|
container_client = blob_service_client.get_container_client(container_name)
|
||||||
|
blobs = container_client.list_blobs()
|
||||||
|
|
||||||
|
matched_blobs = []
|
||||||
|
for blob in blobs:
|
||||||
|
if keyword.lower() in blob.name.lower():
|
||||||
|
matched_blobs.append({
|
||||||
|
"name": blob.name,
|
||||||
|
"size": blob.size,
|
||||||
|
"last_modified": str(blob.last_modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
if not matched_blobs:
|
||||||
|
return f"在容器 '{container_name}' 中没有找到包含 '{keyword}' 的文件"
|
||||||
|
|
||||||
|
result = f"搜索结果 (关键字: '{keyword}'):\n"
|
||||||
|
for i, b in enumerate(matched_blobs, 1):
|
||||||
|
result += f"{i}. {b['name']} ({b['size'] / 1024:.2f}KB)\n"
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"搜索blob失败: {str(e)}")
|
||||||
|
return f"错误: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_storage_stats() -> str:
|
||||||
|
"""获取存储统计信息"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return "错误: 未连接到Azure Blob Storage"
|
||||||
|
|
||||||
|
try:
|
||||||
|
containers = list(blob_service_client.list_containers())
|
||||||
|
total_containers = len(containers)
|
||||||
|
total_blobs = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
container_stats = []
|
||||||
|
for container in containers:
|
||||||
|
container_client = blob_service_client.get_container_client(container.name)
|
||||||
|
blobs = list(container_client.list_blobs())
|
||||||
|
blob_count = len(blobs)
|
||||||
|
container_size = sum(blob.size for blob in blobs)
|
||||||
|
|
||||||
|
total_blobs += blob_count
|
||||||
|
total_size += container_size
|
||||||
|
|
||||||
|
container_stats.append({
|
||||||
|
"name": container.name,
|
||||||
|
"blobs": blob_count,
|
||||||
|
"size_mb": container_size / (1024 * 1024)
|
||||||
|
})
|
||||||
|
|
||||||
|
result = "存储统计信息:\n"
|
||||||
|
result += f"- 总容器数: {total_containers}\n"
|
||||||
|
result += f"- 总文件数: {total_blobs}\n"
|
||||||
|
result += f"- 总大小: {total_size / (1024 * 1024):.2f}MB\n\n"
|
||||||
|
|
||||||
|
if container_stats:
|
||||||
|
result += "各容器详情:\n"
|
||||||
|
for stat in container_stats:
|
||||||
|
result += f" • {stat['name']}: {stat['blobs']} 个文件, {stat['size_mb']:.2f}MB\n"
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取统计信息失败: {str(e)}")
|
||||||
|
return f"错误: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 创建LangChain Agent ====================
|
||||||
|
|
||||||
|
def create_blob_agent(litellm_api_key: str) -> Optional[AgentExecutor]:
|
||||||
|
"""创建Azure Blob Storage Agent"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
logger.warning("尚未连接到Azure Blob Storage")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 初始化LiteLLM
|
||||||
|
try:
|
||||||
|
llm = ChatLiteLLM(
|
||||||
|
model=MODEL_NAME,
|
||||||
|
api_base=LITELLM_API_BASE,
|
||||||
|
api_key=litellm_api_key,
|
||||||
|
temperature=0
|
||||||
|
)
|
||||||
|
logger.info(f"✅ LiteLLM初始化成功: {MODEL_NAME} @ {LITELLM_API_BASE}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ LiteLLM初始化失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 定义工具
|
||||||
|
tools = [
|
||||||
|
Tool(
|
||||||
|
name="list_containers",
|
||||||
|
func=list_containers_tool,
|
||||||
|
description="列出所有Azure Blob Storage容器。当用户询问'有哪些容器'、'显示容器列表'时使用此工具。"
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="list_blobs",
|
||||||
|
func=lambda input_str: list_blobs_in_container(input_str),
|
||||||
|
description="列出指定容器中的所有文件。输入参数是容器名称。当用户询问'容器X中有什么文件'、'列出XXX容器的文件'时使用此工具。"
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="get_blob_info",
|
||||||
|
func=lambda input_str: get_blob_info(*input_str.split(",")),
|
||||||
|
description="获取特定文件的详细信息。输入格式: '容器名,文件名'。当用户询问'文件XXX的详细信息'、'XXX文件的属性'时使用此工具。"
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="search_blobs",
|
||||||
|
func=lambda input_str: search_blobs(*input_str.split(",", 1)),
|
||||||
|
description="在容器中搜索文件。输入格式: '容器名,关键字'。当用户询问'搜索包含XXX的文件'、'查找XXX'时使用此工具。"
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="get_storage_stats",
|
||||||
|
func=get_storage_stats,
|
||||||
|
description="获取存储的统计信息,包括容器数量、文件数量、总大小等。当用户询问'存储统计'、'有多少文件'、'占用多少空间'时使用此工具。"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 定义Agent Prompt
|
||||||
|
template = """你是一个Azure Blob Storage管理助手。你可以帮助用户管理和查询Azure存储中的文件。
|
||||||
|
|
||||||
|
可用工具:
|
||||||
|
{tools}
|
||||||
|
|
||||||
|
工具名称: {tool_names}
|
||||||
|
|
||||||
|
回答问题时请使用以下格式:
|
||||||
|
|
||||||
|
Question: 用户的输入问题
|
||||||
|
Thought: 你应该思考如何回答这个问题
|
||||||
|
Action: 要使用的工具名称,必须是以下之一: [{tool_names}]
|
||||||
|
Action Input: 传递给工具的输入
|
||||||
|
Observation: 工具返回的结果
|
||||||
|
... (这个 Thought/Action/Action Input/Observation 可以重复N次)
|
||||||
|
Thought: 我现在知道最终答案了
|
||||||
|
Final Answer: 对用户问题的最终回答
|
||||||
|
|
||||||
|
重要提示:
|
||||||
|
- 如果用户只是说"列出容器"或"显示容器",使用 list_containers 工具
|
||||||
|
- 如果用户说"显示XXX容器的文件",使用 list_blobs 工具,传入容器名
|
||||||
|
- 搜索时需要同时提供容器名和关键字
|
||||||
|
- 获取文件信息时需要提供容器名和文件名,用逗号分隔
|
||||||
|
- 始终用中文回答
|
||||||
|
|
||||||
|
开始!
|
||||||
|
|
||||||
|
Question: {input}
|
||||||
|
Thought: {agent_scratchpad}"""
|
||||||
|
|
||||||
|
prompt = PromptTemplate(
|
||||||
|
template=template,
|
||||||
|
input_variables=["input", "agent_scratchpad"],
|
||||||
|
partial_variables={
|
||||||
|
"tools": "\n".join([f"- {tool.name}: {tool.description}" for tool in tools]),
|
||||||
|
"tool_names": ", ".join([tool.name for tool in tools])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建Agent
|
||||||
|
agent = create_react_agent(llm, tools, prompt)
|
||||||
|
|
||||||
|
# 创建Agent执行器
|
||||||
|
agent_executor = AgentExecutor(
|
||||||
|
agent=agent,
|
||||||
|
tools=tools,
|
||||||
|
verbose=True,
|
||||||
|
handle_parsing_errors=True,
|
||||||
|
max_iterations=5
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Azure Blob Storage Agent创建成功")
|
||||||
|
return agent_executor
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API端点 ====================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
connected = blob_service_client is not None
|
||||||
|
|
||||||
|
connection_info = None
|
||||||
|
if connected:
|
||||||
|
try:
|
||||||
|
# 获取账户信息
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_info = {
|
||||||
|
"account_kind": account_info.get('account_kind', 'unknown'),
|
||||||
|
"sku_name": account_info.get('sku_name', 'unknown'),
|
||||||
|
"connected_at": str(datetime.now())
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取账户信息失败: {str(e)}")
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy" if connected else "not_connected",
|
||||||
|
connected=connected,
|
||||||
|
connection_info=connection_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/connect")
|
||||||
|
async def connect_to_storage(request: ConnectRequest):
|
||||||
|
"""连接到Azure Blob Storage"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 创建BlobServiceClient
|
||||||
|
blob_service_client = BlobServiceClient.from_connection_string(
|
||||||
|
request.connection_string
|
||||||
|
)
|
||||||
|
|
||||||
|
# 测试连接
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
|
||||||
|
connection_string = request.connection_string
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功连接到Azure Blob Storage")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "connected",
|
||||||
|
"message": "成功连接到Azure Blob Storage",
|
||||||
|
"account_info": {
|
||||||
|
"account_kind": account_info.get('account_kind'),
|
||||||
|
"sku_name": account_info.get('sku_name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 连接失败: {str(e)}")
|
||||||
|
blob_service_client = None
|
||||||
|
connection_string = None
|
||||||
|
raise HTTPException(status_code=400, detail=f"连接失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/query")
|
||||||
|
async def query_storage(request: QueryRequest):
|
||||||
|
"""使用自然语言查询存储"""
|
||||||
|
global blob_service_client, callback_handler
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未连接到Azure Blob Storage,请先调用 /connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 初始化回调处理器
|
||||||
|
if not callback_handler:
|
||||||
|
callback_handler = AgentCallbackHandler()
|
||||||
|
|
||||||
|
# 使用上下文管理器自动处理回调
|
||||||
|
with CallbackContextManager(
|
||||||
|
handler=callback_handler,
|
||||||
|
user_id=request.user_id,
|
||||||
|
request_id=f"blob-{int(time.time())}"
|
||||||
|
) as ctx:
|
||||||
|
try:
|
||||||
|
ctx.add_tool("azure_blob_storage")
|
||||||
|
|
||||||
|
# 创建Agent
|
||||||
|
agent = create_blob_agent(request.litellm_api_key)
|
||||||
|
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=500, detail="Agent创建失败")
|
||||||
|
|
||||||
|
# 执行查询
|
||||||
|
logger.info(f"收到查询: {request.query}")
|
||||||
|
result = agent.invoke({"input": request.query})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"query": request.query,
|
||||||
|
"answer": result.get("output", "无法生成答案"),
|
||||||
|
"intermediate_steps": str(result.get("intermediate_steps", []))
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询执行失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""根端点"""
|
||||||
|
return {
|
||||||
|
"service": "Azure Blob Storage AI Agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"pod_name": POD_NAME,
|
||||||
|
"template": TEMPLATE_TYPE,
|
||||||
|
"connected": blob_service_client is not None,
|
||||||
|
"endpoints": {
|
||||||
|
"health": "/health",
|
||||||
|
"connect": "POST /connect",
|
||||||
|
"query": "POST /query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主函数 ====================
|
||||||
|
|
||||||
|
def init_storage_connection():
|
||||||
|
"""启动时初始化存储连接"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
if AZURE_STORAGE_CONNECTION_STRING:
|
||||||
|
try:
|
||||||
|
logger.info("检测到环境变量中的连接字符串,尝试连接...")
|
||||||
|
blob_service_client = BlobServiceClient.from_connection_string(
|
||||||
|
AZURE_STORAGE_CONNECTION_STRING
|
||||||
|
)
|
||||||
|
|
||||||
|
# 测试连接
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_string = AZURE_STORAGE_CONNECTION_STRING
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功连接到Azure Blob Storage")
|
||||||
|
logger.info(f" - Account Kind: {account_info.get('account_kind')}")
|
||||||
|
logger.info(f" - SKU: {account_info.get('sku_name')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 启动时连接失败: {str(e)}")
|
||||||
|
logger.info("💡 提示: 可以稍后通过 /connect API 手动连接")
|
||||||
|
blob_service_client = None
|
||||||
|
connection_string = None
|
||||||
|
else:
|
||||||
|
logger.info("💡 未设置 AZURE_STORAGE_CONNECTION_STRING,需通过 /connect API 手动连接")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""启动服务"""
|
||||||
|
global callback_handler
|
||||||
|
|
||||||
|
logger.info(f"🚀 启动 Azure Blob Storage AI Agent")
|
||||||
|
logger.info(f" - Pod名称: {POD_NAME}")
|
||||||
|
logger.info(f" - 模板类型: {TEMPLATE_TYPE}")
|
||||||
|
logger.info(f" - LiteLLM: {MODEL_NAME} @ {LITELLM_API_BASE}")
|
||||||
|
logger.info(f" - 服务地址: http://{SERVICE_HOST}:{SERVICE_PORT}")
|
||||||
|
logger.info(f" ℹ️ API key 将从请求中获取")
|
||||||
|
|
||||||
|
# 初始化存储连接
|
||||||
|
init_storage_connection()
|
||||||
|
|
||||||
|
# 初始化回调处理器
|
||||||
|
callback_handler = AgentCallbackHandler()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=SERVICE_HOST,
|
||||||
|
port=SERVICE_PORT,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Azure Blob Agent - A2A 版本 Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制requirements文件
|
||||||
|
COPY common/requirements_a2a.txt /app/
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN pip install --no-cache-dir -r requirements_a2a.txt
|
||||||
|
|
||||||
|
# 复制应用代码和共享工具
|
||||||
|
COPY agents/azure_blob_agent_a2a/azure_blob_agent_a2a.py /app/
|
||||||
|
COPY common/api_key_utils.py /app/common/
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:8080/health', timeout=5)"
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
CMD ["python", "azure_blob_agent_a2a.py"]
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
"""
|
||||||
|
Azure Blob Storage AI Agent - A2A (Agent-to-Agent) 版本
|
||||||
|
支持 Agent 之间的协作和通信
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import FastAPI, HTTPException, Header
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from azure.storage.blob import BlobServiceClient, ContainerClient
|
||||||
|
import uvicorn
|
||||||
|
from api_key_utils import get_api_key
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 环境变量配置
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8080"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "azure-blob-agent-a2a")
|
||||||
|
TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "azure_blob_agent_a2a")
|
||||||
|
AGENT_FRAMEWORK = os.getenv("AGENT_FRAMEWORK", "a2a")
|
||||||
|
|
||||||
|
# 工具配置
|
||||||
|
TOOLS_CONFIG = json.loads(os.getenv("TOOLS_CONFIG", "{}"))
|
||||||
|
TOOL_ENDPOINT = os.getenv("TOOL_ENDPOINT", "")
|
||||||
|
TOOL_API_KEY = os.getenv("TOOL_API_KEY", "")
|
||||||
|
|
||||||
|
# 模型配置
|
||||||
|
MODEL_PROVIDER = os.getenv("MODEL_PROVIDER", "openai")
|
||||||
|
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4")
|
||||||
|
MODEL_API_KEY = os.getenv("MODEL_API_KEY", "")
|
||||||
|
MODEL_ENDPOINT = os.getenv("MODEL_ENDPOINT", "https://api.openai.com/v1")
|
||||||
|
|
||||||
|
# 存储配置
|
||||||
|
AZURE_STORAGE_CONNECTION_STRING = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "")
|
||||||
|
STORAGE_ACCOUNT_NAME = os.getenv("STORAGE_ACCOUNT_NAME", "")
|
||||||
|
|
||||||
|
# 用户标识
|
||||||
|
USER_ID = os.getenv("USER_ID", "")
|
||||||
|
TENANT_ID = os.getenv("TENANT_ID", "")
|
||||||
|
NAMESPACE = os.getenv("NAMESPACE", "ai-agents")
|
||||||
|
|
||||||
|
# A2A Agent 配置
|
||||||
|
AGENT_ID = os.getenv("AGENT_ID", POD_NAME)
|
||||||
|
AGENT_ROLE = os.getenv("AGENT_ROLE", "storage_manager")
|
||||||
|
AGENT_CAPABILITIES = json.loads(os.getenv("AGENT_CAPABILITIES", '["blob_storage", "file_operations"]'))
|
||||||
|
|
||||||
|
# 全局存储客户端
|
||||||
|
blob_service_client: Optional[BlobServiceClient] = None
|
||||||
|
connection_string: Optional[str] = None
|
||||||
|
|
||||||
|
# A2A Agent 注册表 (其他可协作的 Agent)
|
||||||
|
registered_agents: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
# FastAPI应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="Azure Blob Storage AI Agent (A2A)",
|
||||||
|
description="支持 Agent-to-Agent 协作的智能 Azure Blob 存储管理代理",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 请求/响应模型 ====================
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
"""连接请求"""
|
||||||
|
connection_string: str = Field(..., description="Azure Storage连接字符串")
|
||||||
|
|
||||||
|
|
||||||
|
class A2AMessage(BaseModel):
|
||||||
|
"""A2A 消息格式"""
|
||||||
|
message_id: str = Field(..., description="消息ID")
|
||||||
|
from_agent: str = Field(..., description="发送者 Agent ID")
|
||||||
|
to_agent: str = Field(..., description="接收者 Agent ID")
|
||||||
|
message_type: str = Field(..., description="消息类型: request/response/notification")
|
||||||
|
action: str = Field(..., description="请求的动作")
|
||||||
|
parameters: Dict[str, Any] = Field(default_factory=dict, description="参数")
|
||||||
|
context: Optional[Dict] = Field(default_factory=dict, description="上下文")
|
||||||
|
timestamp: Optional[str] = None
|
||||||
|
model_api_key: Optional[str] = Field(None, description="模型 API 密钥(可选,优先使用,否则从环境变量获取)")
|
||||||
|
|
||||||
|
|
||||||
|
class A2AQueryRequest(BaseModel):
|
||||||
|
"""A2A 查询请求"""
|
||||||
|
query: str = Field(..., description="自然语言查询")
|
||||||
|
container_name: Optional[str] = None
|
||||||
|
requester_agent: Optional[str] = Field(None, description="请求者 Agent ID")
|
||||||
|
context: Optional[Dict] = Field(default_factory=dict)
|
||||||
|
model_api_key: Optional[str] = Field(None, description="模型 API 密钥(可选,优先使用,否则从环境变量获取)")
|
||||||
|
|
||||||
|
|
||||||
|
class A2ARegisterRequest(BaseModel):
|
||||||
|
"""A2A Agent 注册请求"""
|
||||||
|
agent_id: str
|
||||||
|
agent_role: str
|
||||||
|
capabilities: List[str]
|
||||||
|
endpoint: str
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""健康检查响应"""
|
||||||
|
status: str
|
||||||
|
connected: bool
|
||||||
|
framework: str
|
||||||
|
agent_id: str
|
||||||
|
agent_role: str
|
||||||
|
capabilities: List[str]
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
namespace: Optional[str] = None
|
||||||
|
registered_agents_count: int = 0
|
||||||
|
connection_info: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== A2A 操作处理器 ====================
|
||||||
|
|
||||||
|
class A2AActionHandler:
|
||||||
|
"""A2A 动作处理器"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_list_containers(parameters: Dict) -> Dict:
|
||||||
|
"""处理列出容器请求"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
containers = blob_service_client.list_containers()
|
||||||
|
container_list = []
|
||||||
|
for container in containers:
|
||||||
|
container_list.append({
|
||||||
|
"name": container.name,
|
||||||
|
"last_modified": str(container.last_modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"containers": container_list,
|
||||||
|
"count": len(container_list)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"列出容器失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_list_blobs(parameters: Dict) -> Dict:
|
||||||
|
"""处理列出 blob 请求"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
container_name = parameters.get("container_name")
|
||||||
|
if not container_name:
|
||||||
|
return {"error": "缺少参数: container_name"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
container_client = blob_service_client.get_container_client(container_name)
|
||||||
|
blobs = container_client.list_blobs()
|
||||||
|
|
||||||
|
blob_list = []
|
||||||
|
total_size = 0
|
||||||
|
for blob in blobs:
|
||||||
|
blob_info = {
|
||||||
|
"name": blob.name,
|
||||||
|
"size": blob.size,
|
||||||
|
"size_mb": round(blob.size / (1024 * 1024), 2),
|
||||||
|
"content_type": blob.content_settings.content_type if blob.content_settings else "unknown",
|
||||||
|
"last_modified": str(blob.last_modified)
|
||||||
|
}
|
||||||
|
blob_list.append(blob_info)
|
||||||
|
total_size += blob.size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"container": container_name,
|
||||||
|
"blobs": blob_list,
|
||||||
|
"count": len(blob_list),
|
||||||
|
"total_size_mb": round(total_size / (1024 * 1024), 2)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"列出 blob 失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_get_blob_info(parameters: Dict) -> Dict:
|
||||||
|
"""处理获取 blob 信息请求"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
container_name = parameters.get("container_name")
|
||||||
|
blob_name = parameters.get("blob_name")
|
||||||
|
|
||||||
|
if not container_name or not blob_name:
|
||||||
|
return {"error": "缺少参数: container_name 或 blob_name"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob_client = blob_service_client.get_blob_client(container_name, blob_name)
|
||||||
|
properties = blob_client.get_blob_properties()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"blob_name": blob_name,
|
||||||
|
"container": container_name,
|
||||||
|
"size": properties.size,
|
||||||
|
"size_mb": round(properties.size / (1024 * 1024), 2),
|
||||||
|
"content_type": properties.content_settings.content_type if properties.content_settings else "unknown",
|
||||||
|
"creation_time": str(properties.creation_time),
|
||||||
|
"last_modified": str(properties.last_modified),
|
||||||
|
"etag": properties.etag,
|
||||||
|
"metadata": properties.metadata if properties.metadata else {}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取 blob 信息失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_search_blobs(parameters: Dict) -> Dict:
|
||||||
|
"""处理搜索 blob 请求"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
container_name = parameters.get("container_name")
|
||||||
|
keyword = parameters.get("keyword")
|
||||||
|
|
||||||
|
if not container_name or not keyword:
|
||||||
|
return {"error": "缺少参数: container_name 或 keyword"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
container_client = blob_service_client.get_container_client(container_name)
|
||||||
|
blobs = container_client.list_blobs()
|
||||||
|
|
||||||
|
matched_blobs = []
|
||||||
|
for blob in blobs:
|
||||||
|
if keyword.lower() in blob.name.lower():
|
||||||
|
matched_blobs.append({
|
||||||
|
"name": blob.name,
|
||||||
|
"size": blob.size,
|
||||||
|
"size_kb": round(blob.size / 1024, 2),
|
||||||
|
"last_modified": str(blob.last_modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"container": container_name,
|
||||||
|
"keyword": keyword,
|
||||||
|
"results": matched_blobs,
|
||||||
|
"count": len(matched_blobs)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"搜索 blob 失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_get_stats(parameters: Dict) -> Dict:
|
||||||
|
"""处理获取统计信息请求"""
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
containers = list(blob_service_client.list_containers())
|
||||||
|
total_containers = len(containers)
|
||||||
|
total_blobs = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
container_stats = []
|
||||||
|
for container in containers:
|
||||||
|
container_client = blob_service_client.get_container_client(container.name)
|
||||||
|
blobs = list(container_client.list_blobs())
|
||||||
|
blob_count = len(blobs)
|
||||||
|
container_size = sum(blob.size for blob in blobs)
|
||||||
|
|
||||||
|
total_blobs += blob_count
|
||||||
|
total_size += container_size
|
||||||
|
|
||||||
|
container_stats.append({
|
||||||
|
"name": container.name,
|
||||||
|
"blobs": blob_count,
|
||||||
|
"size_mb": round(container_size / (1024 * 1024), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"total_containers": total_containers,
|
||||||
|
"total_blobs": total_blobs,
|
||||||
|
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||||
|
"container_stats": container_stats
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取统计信息失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# 动作路由表
|
||||||
|
ACTION_HANDLERS = {
|
||||||
|
"list_containers": A2AActionHandler.handle_list_containers,
|
||||||
|
"list_blobs": A2AActionHandler.handle_list_blobs,
|
||||||
|
"get_blob_info": A2AActionHandler.handle_get_blob_info,
|
||||||
|
"search_blobs": A2AActionHandler.handle_search_blobs,
|
||||||
|
"get_stats": A2AActionHandler.handle_get_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API 端点 ====================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
connected = blob_service_client is not None
|
||||||
|
|
||||||
|
connection_info = None
|
||||||
|
if connected:
|
||||||
|
try:
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_info = {
|
||||||
|
"account_kind": account_info.get('account_kind', 'unknown'),
|
||||||
|
"sku_name": account_info.get('sku_name', 'unknown'),
|
||||||
|
"connected_at": str(datetime.now())
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取账户信息失败: {str(e)}")
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy" if connected else "not_connected",
|
||||||
|
connected=connected,
|
||||||
|
framework=AGENT_FRAMEWORK,
|
||||||
|
agent_id=AGENT_ID,
|
||||||
|
agent_role=AGENT_ROLE,
|
||||||
|
capabilities=AGENT_CAPABILITIES,
|
||||||
|
user_id=USER_ID,
|
||||||
|
namespace=NAMESPACE,
|
||||||
|
registered_agents_count=len(registered_agents),
|
||||||
|
connection_info=connection_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/connect")
|
||||||
|
async def connect_to_storage(request: ConnectRequest):
|
||||||
|
"""连接到 Azure Blob Storage"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob_service_client = BlobServiceClient.from_connection_string(
|
||||||
|
request.connection_string
|
||||||
|
)
|
||||||
|
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_string = request.connection_string
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功连接到 Azure Blob Storage (Agent: {AGENT_ID}, User: {USER_ID})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "connected",
|
||||||
|
"message": "成功连接到 Azure Blob Storage",
|
||||||
|
"framework": AGENT_FRAMEWORK,
|
||||||
|
"agent_id": AGENT_ID,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"account_info": {
|
||||||
|
"account_kind": account_info.get('account_kind'),
|
||||||
|
"sku_name": account_info.get('sku_name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 连接失败: {str(e)}")
|
||||||
|
blob_service_client = None
|
||||||
|
connection_string = None
|
||||||
|
raise HTTPException(status_code=400, detail=f"连接失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/a2a/capabilities")
|
||||||
|
async def get_capabilities():
|
||||||
|
"""获取 Agent 能力"""
|
||||||
|
return {
|
||||||
|
"agent_id": AGENT_ID,
|
||||||
|
"agent_role": AGENT_ROLE,
|
||||||
|
"capabilities": AGENT_CAPABILITIES,
|
||||||
|
"supported_actions": list(ACTION_HANDLERS.keys()),
|
||||||
|
"framework": AGENT_FRAMEWORK
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/a2a/register")
|
||||||
|
async def register_agent(request: A2ARegisterRequest):
|
||||||
|
"""注册其他 Agent"""
|
||||||
|
global registered_agents
|
||||||
|
|
||||||
|
registered_agents[request.agent_id] = {
|
||||||
|
"agent_id": request.agent_id,
|
||||||
|
"agent_role": request.agent_role,
|
||||||
|
"capabilities": request.capabilities,
|
||||||
|
"endpoint": request.endpoint,
|
||||||
|
"registered_at": str(datetime.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"✅ Agent '{request.agent_id}' 注册成功")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "registered",
|
||||||
|
"agent_id": request.agent_id,
|
||||||
|
"message": f"Agent '{request.agent_id}' 已注册"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/a2a/agents")
|
||||||
|
async def list_registered_agents():
|
||||||
|
"""列出已注册的 Agent"""
|
||||||
|
return {
|
||||||
|
"agents": list(registered_agents.values()),
|
||||||
|
"count": len(registered_agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/a2a/message")
|
||||||
|
async def handle_a2a_message(message: A2AMessage):
|
||||||
|
"""处理 A2A 消息"""
|
||||||
|
if not blob_service_client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未连接到 Azure Blob Storage,请先调用 /connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取 API key(优先使用请求传入的,否则从环境变量获取)
|
||||||
|
api_key = get_api_key(message.model_api_key, "MODEL_API_KEY", MODEL_API_KEY)
|
||||||
|
|
||||||
|
# 验证消息目标
|
||||||
|
if message.to_agent != AGENT_ID:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"消息目标不匹配: 期望 {AGENT_ID}, 收到 {message.to_agent}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 处理消息
|
||||||
|
if message.message_type == "request":
|
||||||
|
action = message.action
|
||||||
|
|
||||||
|
if action not in ACTION_HANDLERS:
|
||||||
|
return {
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"status": "error",
|
||||||
|
"error": f"不支持的动作: {action}",
|
||||||
|
"supported_actions": list(ACTION_HANDLERS.keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
handler = ACTION_HANDLERS[action]
|
||||||
|
result = await handler(message.parameters)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"from_agent": AGENT_ID,
|
||||||
|
"to_agent": message.from_agent,
|
||||||
|
"message_type": "response",
|
||||||
|
"action": action,
|
||||||
|
"result": result,
|
||||||
|
"timestamp": str(datetime.now()),
|
||||||
|
"api_key_used": "request" if message.model_api_key else ("env" if MODEL_API_KEY else "none")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"处理 A2A 消息失败: {str(e)}")
|
||||||
|
return {
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"status": "info",
|
||||||
|
"message": f"收到消息类型: {message.message_type}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/query")
|
||||||
|
async def query_storage(request: A2AQueryRequest):
|
||||||
|
"""查询存储(支持 A2A 上下文)"""
|
||||||
|
if not blob_service_client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未连接到 Azure Blob Storage,请先调用 /connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取 API key(优先使用请求传入的,否则从环境变量获取)
|
||||||
|
api_key = get_api_key(request.model_api_key, "MODEL_API_KEY", MODEL_API_KEY)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = request.query.lower()
|
||||||
|
result = None
|
||||||
|
action_used = None
|
||||||
|
|
||||||
|
# 简单的规则匹配
|
||||||
|
if "容器" in query and ("列出" in query or "显示" in query or "有哪些" in query):
|
||||||
|
result = await A2AActionHandler.handle_list_containers({})
|
||||||
|
action_used = "list_containers"
|
||||||
|
elif "统计" in query or "有多少" in query or "占用" in query:
|
||||||
|
result = await A2AActionHandler.handle_get_stats({})
|
||||||
|
action_used = "get_stats"
|
||||||
|
elif request.container_name:
|
||||||
|
if "文件" in query or "blob" in query.lower():
|
||||||
|
result = await A2AActionHandler.handle_list_blobs({"container_name": request.container_name})
|
||||||
|
action_used = "list_blobs"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success" if result else "info",
|
||||||
|
"query": request.query,
|
||||||
|
"action": action_used,
|
||||||
|
"result": result,
|
||||||
|
"agent_id": AGENT_ID,
|
||||||
|
"requester": request.requester_agent,
|
||||||
|
"framework": AGENT_FRAMEWORK,
|
||||||
|
"api_key_used": "request" if request.model_api_key else ("env" if MODEL_API_KEY else "none")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询执行失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/a2a/collaborate")
|
||||||
|
async def collaborate_with_agent(
|
||||||
|
target_agent_id: str,
|
||||||
|
action: str,
|
||||||
|
parameters: Dict[str, Any]
|
||||||
|
):
|
||||||
|
"""与其他 Agent 协作"""
|
||||||
|
if target_agent_id not in registered_agents:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Agent '{target_agent_id}' 未注册"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_agent = registered_agents[target_agent_id]
|
||||||
|
|
||||||
|
# 创建 A2A 消息
|
||||||
|
message = A2AMessage(
|
||||||
|
message_id=f"{AGENT_ID}_{datetime.now().timestamp()}",
|
||||||
|
from_agent=AGENT_ID,
|
||||||
|
to_agent=target_agent_id,
|
||||||
|
message_type="request",
|
||||||
|
action=action,
|
||||||
|
parameters=parameters,
|
||||||
|
timestamp=str(datetime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 发送请求到目标 Agent
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{target_agent['endpoint']}/a2a/message",
|
||||||
|
json=message.dict(),
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"target_agent": target_agent_id,
|
||||||
|
"action": action,
|
||||||
|
"response": response.json()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"协作失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"协作失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""根端点"""
|
||||||
|
return {
|
||||||
|
"service": "Azure Blob Storage AI Agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"framework": AGENT_FRAMEWORK,
|
||||||
|
"agent_id": AGENT_ID,
|
||||||
|
"agent_role": AGENT_ROLE,
|
||||||
|
"capabilities": AGENT_CAPABILITIES,
|
||||||
|
"pod_name": POD_NAME,
|
||||||
|
"template": TEMPLATE_TYPE,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"namespace": NAMESPACE,
|
||||||
|
"connected": blob_service_client is not None,
|
||||||
|
"registered_agents": len(registered_agents),
|
||||||
|
"endpoints": {
|
||||||
|
"health": "/health",
|
||||||
|
"connect": "POST /connect",
|
||||||
|
"capabilities": "GET /a2a/capabilities",
|
||||||
|
"register_agent": "POST /a2a/register",
|
||||||
|
"list_agents": "GET /a2a/agents",
|
||||||
|
"handle_message": "POST /a2a/message",
|
||||||
|
"collaborate": "POST /a2a/collaborate",
|
||||||
|
"query": "POST /query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主函数 ====================
|
||||||
|
|
||||||
|
def init_storage_connection():
|
||||||
|
"""启动时初始化存储连接"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
if AZURE_STORAGE_CONNECTION_STRING:
|
||||||
|
try:
|
||||||
|
logger.info("检测到环境变量中的连接字符串,尝试连接...")
|
||||||
|
blob_service_client = BlobServiceClient.from_connection_string(
|
||||||
|
AZURE_STORAGE_CONNECTION_STRING
|
||||||
|
)
|
||||||
|
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_string = AZURE_STORAGE_CONNECTION_STRING
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功连接到 Azure Blob Storage")
|
||||||
|
logger.info(f" - Account Kind: {account_info.get('account_kind')}")
|
||||||
|
logger.info(f" - SKU: {account_info.get('sku_name')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 启动时连接失败: {str(e)}")
|
||||||
|
logger.info("💡 提示: 可以稍后通过 /connect API 手动连接")
|
||||||
|
blob_service_client = None
|
||||||
|
connection_string = None
|
||||||
|
else:
|
||||||
|
logger.info("💡 未设置 AZURE_STORAGE_CONNECTION_STRING,需通过 /connect API 手动连接")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""启动服务"""
|
||||||
|
logger.info(f"🚀 启动 Azure Blob Storage AI Agent (A2A)")
|
||||||
|
logger.info(f" - Framework: {AGENT_FRAMEWORK}")
|
||||||
|
logger.info(f" - Agent ID: {AGENT_ID}")
|
||||||
|
logger.info(f" - Agent Role: {AGENT_ROLE}")
|
||||||
|
logger.info(f" - Capabilities: {AGENT_CAPABILITIES}")
|
||||||
|
logger.info(f" - Pod名称: {POD_NAME}")
|
||||||
|
logger.info(f" - 模板类型: {TEMPLATE_TYPE}")
|
||||||
|
logger.info(f" - User ID: {USER_ID}")
|
||||||
|
logger.info(f" - Namespace: {NAMESPACE}")
|
||||||
|
logger.info(f" - 模型: {MODEL_NAME} @ {MODEL_PROVIDER}")
|
||||||
|
logger.info(f" - 服务地址: http://{SERVICE_HOST}:{SERVICE_PORT}")
|
||||||
|
|
||||||
|
# 初始化存储连接
|
||||||
|
init_storage_connection()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=SERVICE_HOST,
|
||||||
|
port=SERVICE_PORT,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Azure Blob Agent - MCP 版本 Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制requirements文件
|
||||||
|
COPY common/requirements_mcp.txt /app/
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN pip install --no-cache-dir -r requirements_mcp.txt
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY agents/azure_blob_agent_mcp/azure_blob_agent_mcp.py /app/
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:8080/health', timeout=5)"
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
CMD ["python", "azure_blob_agent_mcp.py"]
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
"""
|
||||||
|
Azure Blob Storage AI Agent - MCP (Model Context Protocol) 版本
|
||||||
|
使用 MCP 协议实现智能文件操作功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from azure.storage.blob import BlobServiceClient, ContainerClient
|
||||||
|
import uvicorn
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 环境变量配置
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8080"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "azure-blob-agent-mcp")
|
||||||
|
TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "azure_blob_agent_mcp")
|
||||||
|
AGENT_FRAMEWORK = os.getenv("AGENT_FRAMEWORK", "mcp")
|
||||||
|
|
||||||
|
# 工具配置 (从环境变量传入的 JSON)
|
||||||
|
TOOLS_CONFIG = json.loads(os.getenv("TOOLS_CONFIG", "{}"))
|
||||||
|
TOOL_ENDPOINT = os.getenv("TOOL_ENDPOINT", "")
|
||||||
|
TOOL_API_KEY = os.getenv("TOOL_API_KEY", "")
|
||||||
|
|
||||||
|
# 模型配置
|
||||||
|
MODEL_PROVIDER = os.getenv("MODEL_PROVIDER", "openai")
|
||||||
|
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4")
|
||||||
|
MODEL_API_KEY = os.getenv("MODEL_API_KEY", "")
|
||||||
|
MODEL_ENDPOINT = os.getenv("MODEL_ENDPOINT", "https://api.openai.com/v1")
|
||||||
|
|
||||||
|
# 存储配置
|
||||||
|
AZURE_STORAGE_CONNECTION_STRING = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "")
|
||||||
|
STORAGE_ACCOUNT_NAME = os.getenv("STORAGE_ACCOUNT_NAME", "")
|
||||||
|
|
||||||
|
# 用户标识
|
||||||
|
USER_ID = os.getenv("USER_ID", "")
|
||||||
|
TENANT_ID = os.getenv("TENANT_ID", "")
|
||||||
|
NAMESPACE = os.getenv("NAMESPACE", "ai-agents")
|
||||||
|
|
||||||
|
# 全局存储客户端
|
||||||
|
blob_service_client: Optional[BlobServiceClient] = None
|
||||||
|
connection_string: Optional[str] = None
|
||||||
|
|
||||||
|
# MCP 工具注册表
|
||||||
|
mcp_tools: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# FastAPI应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="Azure Blob Storage AI Agent (MCP)",
|
||||||
|
description="基于 MCP 协议的智能 Azure Blob 存储管理代理",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 请求/响应模型 ====================
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
"""连接请求"""
|
||||||
|
connection_string: str = Field(..., description="Azure Storage连接字符串")
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolRequest(BaseModel):
|
||||||
|
"""MCP 工具调用请求"""
|
||||||
|
tool_name: str = Field(..., description="工具名称")
|
||||||
|
parameters: Dict[str, Any] = Field(default_factory=dict, description="工具参数")
|
||||||
|
|
||||||
|
|
||||||
|
class MCPQueryRequest(BaseModel):
|
||||||
|
"""MCP 查询请求"""
|
||||||
|
query: str = Field(..., description="自然语言查询或操作指令")
|
||||||
|
container_name: Optional[str] = Field(None, description="指定容器名称")
|
||||||
|
context: Optional[Dict] = Field(default_factory=dict, description="上下文信息")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""健康检查响应"""
|
||||||
|
status: str
|
||||||
|
connected: bool
|
||||||
|
framework: str
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
namespace: Optional[str] = None
|
||||||
|
connection_info: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== MCP 工具定义 ====================
|
||||||
|
|
||||||
|
class MCPTool:
|
||||||
|
"""MCP 工具基类"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, description: str, parameters_schema: Dict):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.parameters_schema = parameters_schema
|
||||||
|
|
||||||
|
async def execute(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""执行工具"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def to_mcp_spec(self) -> Dict:
|
||||||
|
"""转换为 MCP 工具规范"""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": self.parameters_schema,
|
||||||
|
"required": list(self.parameters_schema.keys())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ListContainersTool(MCPTool):
|
||||||
|
"""列出所有容器工具"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="list_containers",
|
||||||
|
description="列出 Azure Blob Storage 中的所有容器",
|
||||||
|
parameters_schema={}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
containers = blob_service_client.list_containers()
|
||||||
|
container_list = []
|
||||||
|
for container in containers:
|
||||||
|
container_list.append({
|
||||||
|
"name": container.name,
|
||||||
|
"last_modified": str(container.last_modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"containers": container_list,
|
||||||
|
"count": len(container_list)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"列出容器失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class ListBlobsTool(MCPTool):
|
||||||
|
"""列出容器中的 blob 工具"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="list_blobs",
|
||||||
|
description="列出指定容器中的所有文件",
|
||||||
|
parameters_schema={
|
||||||
|
"container_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "容器名称"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
container_name = parameters.get("container_name")
|
||||||
|
if not container_name:
|
||||||
|
return {"error": "缺少参数: container_name"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
container_client = blob_service_client.get_container_client(container_name)
|
||||||
|
blobs = container_client.list_blobs()
|
||||||
|
|
||||||
|
blob_list = []
|
||||||
|
total_size = 0
|
||||||
|
for blob in blobs:
|
||||||
|
blob_info = {
|
||||||
|
"name": blob.name,
|
||||||
|
"size": blob.size,
|
||||||
|
"size_mb": round(blob.size / (1024 * 1024), 2),
|
||||||
|
"content_type": blob.content_settings.content_type if blob.content_settings else "unknown",
|
||||||
|
"last_modified": str(blob.last_modified)
|
||||||
|
}
|
||||||
|
blob_list.append(blob_info)
|
||||||
|
total_size += blob.size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"container": container_name,
|
||||||
|
"blobs": blob_list,
|
||||||
|
"count": len(blob_list),
|
||||||
|
"total_size_mb": round(total_size / (1024 * 1024), 2)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"列出 blob 失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class GetBlobInfoTool(MCPTool):
|
||||||
|
"""获取 blob 信息工具"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="get_blob_info",
|
||||||
|
description="获取特定文件的详细信息",
|
||||||
|
parameters_schema={
|
||||||
|
"container_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "容器名称"
|
||||||
|
},
|
||||||
|
"blob_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名称"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
container_name = parameters.get("container_name")
|
||||||
|
blob_name = parameters.get("blob_name")
|
||||||
|
|
||||||
|
if not container_name or not blob_name:
|
||||||
|
return {"error": "缺少参数: container_name 或 blob_name"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob_client = blob_service_client.get_blob_client(container_name, blob_name)
|
||||||
|
properties = blob_client.get_blob_properties()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"blob_name": blob_name,
|
||||||
|
"container": container_name,
|
||||||
|
"size": properties.size,
|
||||||
|
"size_mb": round(properties.size / (1024 * 1024), 2),
|
||||||
|
"content_type": properties.content_settings.content_type if properties.content_settings else "unknown",
|
||||||
|
"creation_time": str(properties.creation_time),
|
||||||
|
"last_modified": str(properties.last_modified),
|
||||||
|
"etag": properties.etag,
|
||||||
|
"metadata": properties.metadata if properties.metadata else {}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取 blob 信息失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBlobsTool(MCPTool):
|
||||||
|
"""搜索 blob 工具"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="search_blobs",
|
||||||
|
description="在容器中搜索包含关键字的文件",
|
||||||
|
parameters_schema={
|
||||||
|
"container_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "容器名称"
|
||||||
|
},
|
||||||
|
"keyword": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "搜索关键字"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
container_name = parameters.get("container_name")
|
||||||
|
keyword = parameters.get("keyword")
|
||||||
|
|
||||||
|
if not container_name or not keyword:
|
||||||
|
return {"error": "缺少参数: container_name 或 keyword"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
container_client = blob_service_client.get_container_client(container_name)
|
||||||
|
blobs = container_client.list_blobs()
|
||||||
|
|
||||||
|
matched_blobs = []
|
||||||
|
for blob in blobs:
|
||||||
|
if keyword.lower() in blob.name.lower():
|
||||||
|
matched_blobs.append({
|
||||||
|
"name": blob.name,
|
||||||
|
"size": blob.size,
|
||||||
|
"size_kb": round(blob.size / 1024, 2),
|
||||||
|
"last_modified": str(blob.last_modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"container": container_name,
|
||||||
|
"keyword": keyword,
|
||||||
|
"results": matched_blobs,
|
||||||
|
"count": len(matched_blobs)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"搜索 blob 失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class GetStorageStatsTool(MCPTool):
|
||||||
|
"""获取存储统计工具"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="get_storage_stats",
|
||||||
|
description="获取存储的统计信息,包括容器数量、文件数量、总大小等",
|
||||||
|
parameters_schema={}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
global blob_service_client
|
||||||
|
|
||||||
|
if not blob_service_client:
|
||||||
|
return {"error": "未连接到 Azure Blob Storage"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
containers = list(blob_service_client.list_containers())
|
||||||
|
total_containers = len(containers)
|
||||||
|
total_blobs = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
container_stats = []
|
||||||
|
for container in containers:
|
||||||
|
container_client = blob_service_client.get_container_client(container.name)
|
||||||
|
blobs = list(container_client.list_blobs())
|
||||||
|
blob_count = len(blobs)
|
||||||
|
container_size = sum(blob.size for blob in blobs)
|
||||||
|
|
||||||
|
total_blobs += blob_count
|
||||||
|
total_size += container_size
|
||||||
|
|
||||||
|
container_stats.append({
|
||||||
|
"name": container.name,
|
||||||
|
"blobs": blob_count,
|
||||||
|
"size_mb": round(container_size / (1024 * 1024), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"total_containers": total_containers,
|
||||||
|
"total_blobs": total_blobs,
|
||||||
|
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||||
|
"container_stats": container_stats
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取统计信息失败: {str(e)}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== MCP 工具注册 ====================
|
||||||
|
|
||||||
|
def register_tools():
|
||||||
|
"""注册所有 MCP 工具"""
|
||||||
|
global mcp_tools
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
ListContainersTool(),
|
||||||
|
ListBlobsTool(),
|
||||||
|
GetBlobInfoTool(),
|
||||||
|
SearchBlobsTool(),
|
||||||
|
GetStorageStatsTool()
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
mcp_tools[tool.name] = tool
|
||||||
|
|
||||||
|
logger.info(f"✅ 注册了 {len(mcp_tools)} 个 MCP 工具")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API 端点 ====================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
connected = blob_service_client is not None
|
||||||
|
|
||||||
|
connection_info = None
|
||||||
|
if connected:
|
||||||
|
try:
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_info = {
|
||||||
|
"account_kind": account_info.get('account_kind', 'unknown'),
|
||||||
|
"sku_name": account_info.get('sku_name', 'unknown'),
|
||||||
|
"connected_at": str(datetime.now())
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取账户信息失败: {str(e)}")
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy" if connected else "not_connected",
|
||||||
|
connected=connected,
|
||||||
|
framework=AGENT_FRAMEWORK,
|
||||||
|
user_id=USER_ID,
|
||||||
|
namespace=NAMESPACE,
|
||||||
|
connection_info=connection_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/connect")
|
||||||
|
async def connect_to_storage(request: ConnectRequest):
|
||||||
|
"""连接到 Azure Blob Storage"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob_service_client = BlobServiceClient.from_connection_string(
|
||||||
|
request.connection_string
|
||||||
|
)
|
||||||
|
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_string = request.connection_string
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功连接到 Azure Blob Storage (User: {USER_ID})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "connected",
|
||||||
|
"message": "成功连接到 Azure Blob Storage",
|
||||||
|
"framework": AGENT_FRAMEWORK,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"account_info": {
|
||||||
|
"account_kind": account_info.get('account_kind'),
|
||||||
|
"sku_name": account_info.get('sku_name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 连接失败: {str(e)}")
|
||||||
|
blob_service_client = None
|
||||||
|
connection_string = None
|
||||||
|
raise HTTPException(status_code=400, detail=f"连接失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/mcp/tools")
|
||||||
|
async def list_mcp_tools():
|
||||||
|
"""列出所有可用的 MCP 工具"""
|
||||||
|
if not blob_service_client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未连接到 Azure Blob Storage,请先调用 /connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
tools_spec = [tool.to_mcp_spec() for tool in mcp_tools.values()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tools": tools_spec,
|
||||||
|
"count": len(tools_spec),
|
||||||
|
"framework": AGENT_FRAMEWORK
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/mcp/call")
|
||||||
|
async def call_mcp_tool(request: MCPToolRequest):
|
||||||
|
"""调用 MCP 工具"""
|
||||||
|
if not blob_service_client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未连接到 Azure Blob Storage,请先调用 /connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_name = request.tool_name
|
||||||
|
if tool_name not in mcp_tools:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"工具 '{tool_name}' 不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tool = mcp_tools[tool_name]
|
||||||
|
result = await tool.execute(request.parameters)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tool": tool_name,
|
||||||
|
"result": result,
|
||||||
|
"timestamp": str(datetime.now())
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"工具调用失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"工具调用失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/query")
|
||||||
|
async def query_storage(request: MCPQueryRequest):
|
||||||
|
"""使用自然语言查询存储 (简化版 - 实际应集成 LLM)"""
|
||||||
|
if not blob_service_client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未连接到 Azure Blob Storage,请先调用 /connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = request.query.lower()
|
||||||
|
result = None
|
||||||
|
|
||||||
|
# 简单的规则匹配 (实际应使用 LLM 进行意图识别)
|
||||||
|
if "容器" in query and ("列出" in query or "显示" in query or "有哪些" in query):
|
||||||
|
tool = mcp_tools["list_containers"]
|
||||||
|
result = await tool.execute({})
|
||||||
|
elif "统计" in query or "有多少" in query or "占用" in query:
|
||||||
|
tool = mcp_tools["get_storage_stats"]
|
||||||
|
result = await tool.execute({})
|
||||||
|
elif request.container_name:
|
||||||
|
if "文件" in query or "blob" in query.lower():
|
||||||
|
tool = mcp_tools["list_blobs"]
|
||||||
|
result = await tool.execute({"container_name": request.container_name})
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"query": request.query,
|
||||||
|
"result": result,
|
||||||
|
"framework": AGENT_FRAMEWORK
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "info",
|
||||||
|
"query": request.query,
|
||||||
|
"message": "未能匹配到合适的工具,请使用 /mcp/tools 查看可用工具",
|
||||||
|
"available_tools": list(mcp_tools.keys())
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询执行失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""根端点"""
|
||||||
|
return {
|
||||||
|
"service": "Azure Blob Storage AI Agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"framework": AGENT_FRAMEWORK,
|
||||||
|
"pod_name": POD_NAME,
|
||||||
|
"template": TEMPLATE_TYPE,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"namespace": NAMESPACE,
|
||||||
|
"connected": blob_service_client is not None,
|
||||||
|
"tools_count": len(mcp_tools),
|
||||||
|
"endpoints": {
|
||||||
|
"health": "/health",
|
||||||
|
"connect": "POST /connect",
|
||||||
|
"list_tools": "GET /mcp/tools",
|
||||||
|
"call_tool": "POST /mcp/call",
|
||||||
|
"query": "POST /query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主函数 ====================
|
||||||
|
|
||||||
|
def init_storage_connection():
|
||||||
|
"""启动时初始化存储连接"""
|
||||||
|
global blob_service_client, connection_string
|
||||||
|
|
||||||
|
if AZURE_STORAGE_CONNECTION_STRING:
|
||||||
|
try:
|
||||||
|
logger.info("检测到环境变量中的连接字符串,尝试连接...")
|
||||||
|
blob_service_client = BlobServiceClient.from_connection_string(
|
||||||
|
AZURE_STORAGE_CONNECTION_STRING
|
||||||
|
)
|
||||||
|
|
||||||
|
account_info = blob_service_client.get_account_information()
|
||||||
|
connection_string = AZURE_STORAGE_CONNECTION_STRING
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功连接到 Azure Blob Storage")
|
||||||
|
logger.info(f" - Account Kind: {account_info.get('account_kind')}")
|
||||||
|
logger.info(f" - SKU: {account_info.get('sku_name')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 启动时连接失败: {str(e)}")
|
||||||
|
logger.info("💡 提示: 可以稍后通过 /connect API 手动连接")
|
||||||
|
blob_service_client = None
|
||||||
|
connection_string = None
|
||||||
|
else:
|
||||||
|
logger.info("💡 未设置 AZURE_STORAGE_CONNECTION_STRING,需通过 /connect API 手动连接")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""启动服务"""
|
||||||
|
logger.info(f"🚀 启动 Azure Blob Storage AI Agent (MCP)")
|
||||||
|
logger.info(f" - Framework: {AGENT_FRAMEWORK}")
|
||||||
|
logger.info(f" - Pod名称: {POD_NAME}")
|
||||||
|
logger.info(f" - 模板类型: {TEMPLATE_TYPE}")
|
||||||
|
logger.info(f" - User ID: {USER_ID}")
|
||||||
|
logger.info(f" - Namespace: {NAMESPACE}")
|
||||||
|
logger.info(f" - 模型: {MODEL_NAME} @ {MODEL_PROVIDER}")
|
||||||
|
logger.info(f" - 服务地址: http://{SERVICE_HOST}:{SERVICE_PORT}")
|
||||||
|
|
||||||
|
# 注册 MCP 工具
|
||||||
|
register_tools()
|
||||||
|
|
||||||
|
# 初始化存储连接
|
||||||
|
init_storage_connection()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=SERVICE_HOST,
|
||||||
|
port=SERVICE_PORT,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi==0.109.0 \
|
||||||
|
uvicorn[standard]==0.27.0 \
|
||||||
|
pydantic==2.5.3 \
|
||||||
|
requests>=2.31.0
|
||||||
|
|
||||||
|
# 复制 common 模块(回调工具)
|
||||||
|
COPY common/agent_callback_utils.py /app/common/
|
||||||
|
RUN touch /app/common/__init__.py
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY agents/echo_agent/echo_agent.py /app/
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV SERVICE_HOST=0.0.0.0
|
||||||
|
ENV SERVICE_PORT=8000
|
||||||
|
|
||||||
|
# 回调配置
|
||||||
|
ENV AGENT_CALLBACK_URL=http://mcp-server:8002/api/v1/billing/agent-callback
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python3", "-u", "echo_agent.py"]
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Echo Agent - 简单的回显测试代理
|
||||||
|
用于测试 Agent Manager 的部署功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# 添加 common 模块路径
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# 导入回调工具
|
||||||
|
try:
|
||||||
|
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||||
|
CALLBACK_ENABLED = True
|
||||||
|
except ImportError:
|
||||||
|
CALLBACK_ENABLED = False
|
||||||
|
AgentCallbackHandler = None
|
||||||
|
CallbackContextManager = None
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "echo-agent")
|
||||||
|
USER_ID = os.getenv("USER_ID", "")
|
||||||
|
|
||||||
|
# FastAPI 应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="Echo Agent",
|
||||||
|
description="简单的回显测试代理,用于验证 Agent Manager 部署功能",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 回调处理器
|
||||||
|
callback_handler: Optional[AgentCallbackHandler] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 请求/响应模型 ====================
|
||||||
|
|
||||||
|
class EchoRequest(BaseModel):
|
||||||
|
"""回显请求"""
|
||||||
|
message: str = Field(..., description="要回显的消息")
|
||||||
|
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
|
||||||
|
metadata: Optional[dict] = Field(None, description="附加元数据")
|
||||||
|
|
||||||
|
|
||||||
|
class EchoResponse(BaseModel):
|
||||||
|
"""回显响应"""
|
||||||
|
message: str
|
||||||
|
echo: str
|
||||||
|
pod_name: str
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""健康检查响应"""
|
||||||
|
status: str
|
||||||
|
pod_name: str
|
||||||
|
version: str
|
||||||
|
callback_enabled: bool
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""应用启动时初始化回调处理器"""
|
||||||
|
global callback_handler
|
||||||
|
|
||||||
|
if CALLBACK_ENABLED:
|
||||||
|
callback_handler = AgentCallbackHandler(
|
||||||
|
agent_name=POD_NAME,
|
||||||
|
user_id=USER_ID
|
||||||
|
)
|
||||||
|
logger.info(f"回调处理器已初始化: callback_url={callback_handler.callback_url}")
|
||||||
|
else:
|
||||||
|
logger.warning("回调模块未加载,计费回调功能不可用")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API 端点 ====================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
@app.get("/", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
pod_name=POD_NAME,
|
||||||
|
version="1.0.0",
|
||||||
|
callback_enabled=CALLBACK_ENABLED,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/echo", response_model=EchoResponse)
|
||||||
|
async def echo(request: EchoRequest):
|
||||||
|
"""回显消息"""
|
||||||
|
logger.info(f"Echo: {request.message}")
|
||||||
|
|
||||||
|
# 使用回调上下文管理器
|
||||||
|
if CALLBACK_ENABLED and callback_handler and request.user_id:
|
||||||
|
with CallbackContextManager(
|
||||||
|
handler=callback_handler,
|
||||||
|
user_id=request.user_id,
|
||||||
|
request_id=f"echo-{int(datetime.utcnow().timestamp())}"
|
||||||
|
) as ctx:
|
||||||
|
ctx.add_tool("echo")
|
||||||
|
|
||||||
|
response = EchoResponse(
|
||||||
|
message=request.message,
|
||||||
|
echo=f"[Echo from {POD_NAME}] {request.message}",
|
||||||
|
pod_name=POD_NAME,
|
||||||
|
metadata=request.metadata,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = EchoResponse(
|
||||||
|
message=request.message,
|
||||||
|
echo=f"[Echo from {POD_NAME}] {request.message}",
|
||||||
|
pod_name=POD_NAME,
|
||||||
|
metadata=request.metadata,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/echo")
|
||||||
|
async def echo_get(message: str = "Hello", user_id: Optional[str] = None):
|
||||||
|
"""GET 方式回显"""
|
||||||
|
return await echo(EchoRequest(message=message, user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/info")
|
||||||
|
async def get_info():
|
||||||
|
"""获取 Agent 信息"""
|
||||||
|
return {
|
||||||
|
"agent_name": "Echo Agent",
|
||||||
|
"pod_name": POD_NAME,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"callback_enabled": CALLBACK_ENABLED,
|
||||||
|
"callback_url": callback_handler.callback_url if callback_handler else None,
|
||||||
|
"capabilities": ["echo", "health_check"],
|
||||||
|
"environment": {
|
||||||
|
"SERVICE_HOST": SERVICE_HOST,
|
||||||
|
"SERVICE_PORT": SERVICE_PORT
|
||||||
|
},
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主入口 ====================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
logger.info(f"启动 Echo Agent - {POD_NAME}")
|
||||||
|
logger.info(f"服务地址: {SERVICE_HOST}:{SERVICE_PORT}")
|
||||||
|
logger.info(f"回调功能: {'已启用' if CALLBACK_ENABLED else '未启用'}")
|
||||||
|
|
||||||
|
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi==0.109.0 \
|
||||||
|
uvicorn[standard]==0.27.0 \
|
||||||
|
pydantic==2.5.3 \
|
||||||
|
requests>=2.31.0 \
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
|
||||||
|
# 复制 common 模块(回调工具)
|
||||||
|
COPY common/agent_callback_utils.py /app/common/
|
||||||
|
RUN touch /app/common/__init__.py
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY agents/jina_search_agent/jina_search_agent.py /app/
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV SERVICE_HOST=0.0.0.0
|
||||||
|
ENV SERVICE_PORT=8080
|
||||||
|
|
||||||
|
# 默认 Jina API Key (硬编码)
|
||||||
|
ENV JINA_API_KEY=jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI
|
||||||
|
|
||||||
|
# 回调配置
|
||||||
|
ENV AGENT_CALLBACK_URL=http://mcp-server:8002/api/v1/billing/agent-callback
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health').read()" || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python3", "-u", "jina_search_agent.py"]
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
Jina Search Agent - 使用 Jina Reader API 提取网页内容
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import aiohttp
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# 添加 common 模块路径
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# 导入回调工具
|
||||||
|
try:
|
||||||
|
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||||
|
CALLBACK_ENABLED = True
|
||||||
|
except ImportError:
|
||||||
|
CALLBACK_ENABLED = False
|
||||||
|
AgentCallbackHandler = None
|
||||||
|
CallbackContextManager = None
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8080"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "jina-search-agent")
|
||||||
|
USER_ID = os.getenv("USER_ID", "")
|
||||||
|
JINA_API_KEY = os.getenv("JINA_API_KEY", "")
|
||||||
|
|
||||||
|
# Jina Reader API
|
||||||
|
JINA_READER_URL = "https://r.jina.ai/"
|
||||||
|
|
||||||
|
# FastAPI 应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="Jina Search Agent",
|
||||||
|
description="使用 Jina Reader API 提取网页内容",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 回调处理器
|
||||||
|
callback_handler: Optional[AgentCallbackHandler] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 请求/响应模型 ====================
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
"""搜索/提取请求"""
|
||||||
|
url: str = Field(..., description="要提取内容的 URL")
|
||||||
|
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
|
||||||
|
jina_api_key: Optional[str] = Field(None, description="Jina API 密钥(可选,覆盖环境变量)")
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
"""搜索/提取响应"""
|
||||||
|
url: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: str
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class BatchSearchRequest(BaseModel):
|
||||||
|
"""批量搜索请求"""
|
||||||
|
urls: List[str] = Field(..., description="要提取内容的 URL 列表")
|
||||||
|
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
|
||||||
|
jina_api_key: Optional[str] = Field(None, description="Jina API 密钥(可选)")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchSearchResponse(BaseModel):
|
||||||
|
"""批量搜索响应"""
|
||||||
|
results: List[SearchResponse]
|
||||||
|
success_count: int
|
||||||
|
failed_count: int
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""健康检查响应"""
|
||||||
|
status: str
|
||||||
|
pod_name: str
|
||||||
|
jina_api_configured: bool
|
||||||
|
callback_enabled: bool
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""应用启动时初始化回调处理器"""
|
||||||
|
global callback_handler
|
||||||
|
|
||||||
|
if CALLBACK_ENABLED:
|
||||||
|
callback_handler = AgentCallbackHandler(
|
||||||
|
agent_name=POD_NAME,
|
||||||
|
user_id=USER_ID
|
||||||
|
)
|
||||||
|
logger.info(f"回调处理器已初始化: callback_url={callback_handler.callback_url}")
|
||||||
|
else:
|
||||||
|
logger.warning("回调模块未加载,计费回调功能不可用")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 辅助函数 ====================
|
||||||
|
|
||||||
|
async def fetch_url_content(url: str, api_key: str) -> dict:
|
||||||
|
"""使用 Jina Reader API 提取 URL 内容"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
jina_url = f"{JINA_READER_URL}{url}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(jina_url, headers=headers, timeout=30) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
text = await response.text()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"url": url,
|
||||||
|
"content": text,
|
||||||
|
"title": None # Jina Reader 返回的是纯文本
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
logger.warning(f"Jina Reader 请求失败 [{response.status}]: {url}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"url": url,
|
||||||
|
"error": f"HTTP {response.status}: {error_text[:200]}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"提取内容失败: {url} - {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"url": url,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API 端点 ====================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
@app.get("/", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
pod_name=POD_NAME,
|
||||||
|
jina_api_configured=bool(JINA_API_KEY),
|
||||||
|
callback_enabled=CALLBACK_ENABLED,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/search", response_model=SearchResponse)
|
||||||
|
@app.post("/fetch", response_model=SearchResponse)
|
||||||
|
async def fetch_content(request: SearchRequest):
|
||||||
|
"""提取单个 URL 的内容"""
|
||||||
|
api_key = request.jina_api_key or JINA_API_KEY
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Jina API key 未设置。请在请求中传入 jina_api_key 或设置环境变量 JINA_API_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用回调上下文管理器
|
||||||
|
if CALLBACK_ENABLED and callback_handler and request.user_id:
|
||||||
|
with CallbackContextManager(
|
||||||
|
handler=callback_handler,
|
||||||
|
user_id=request.user_id,
|
||||||
|
request_id=f"jina-fetch-{int(datetime.utcnow().timestamp())}"
|
||||||
|
) as ctx:
|
||||||
|
ctx.add_tool("jina_reader")
|
||||||
|
ctx.add_tool("web_content_extraction")
|
||||||
|
|
||||||
|
result = await fetch_url_content(request.url, api_key)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"提取内容失败: {result.get('error', '未知错误')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(
|
||||||
|
url=result["url"],
|
||||||
|
title=result.get("title"),
|
||||||
|
content=result["content"],
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await fetch_url_content(request.url, api_key)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"提取内容失败: {result.get('error', '未知错误')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(
|
||||||
|
url=result["url"],
|
||||||
|
title=result.get("title"),
|
||||||
|
content=result["content"],
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/search")
|
||||||
|
@app.get("/fetch")
|
||||||
|
async def fetch_content_get(
|
||||||
|
url: str = Query(..., description="要提取内容的 URL"),
|
||||||
|
user_id: Optional[str] = Query(None, description="用户ID(用于计费回调)"),
|
||||||
|
jina_api_key: Optional[str] = Query(None, description="Jina API 密钥")
|
||||||
|
):
|
||||||
|
"""GET 方式提取内容"""
|
||||||
|
request = SearchRequest(url=url, user_id=user_id, jina_api_key=jina_api_key)
|
||||||
|
return await fetch_content(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/batch", response_model=BatchSearchResponse)
|
||||||
|
async def batch_fetch_content(request: BatchSearchRequest):
|
||||||
|
"""批量提取多个 URL 的内容"""
|
||||||
|
api_key = request.jina_api_key or JINA_API_KEY
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Jina API key 未设置"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用回调上下文管理器
|
||||||
|
if CALLBACK_ENABLED and callback_handler and request.user_id:
|
||||||
|
with CallbackContextManager(
|
||||||
|
handler=callback_handler,
|
||||||
|
user_id=request.user_id,
|
||||||
|
request_id=f"jina-batch-{int(datetime.utcnow().timestamp())}"
|
||||||
|
) as ctx:
|
||||||
|
ctx.add_tool("jina_reader")
|
||||||
|
ctx.add_tool("batch_web_extraction")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for url in request.urls:
|
||||||
|
result = await fetch_url_content(url, api_key)
|
||||||
|
if result["success"]:
|
||||||
|
results.append(SearchResponse(
|
||||||
|
url=result["url"],
|
||||||
|
title=result.get("title"),
|
||||||
|
content=result["content"],
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
))
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
return BatchSearchResponse(
|
||||||
|
results=results,
|
||||||
|
success_count=success_count,
|
||||||
|
failed_count=failed_count,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results = []
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for url in request.urls:
|
||||||
|
result = await fetch_url_content(url, api_key)
|
||||||
|
if result["success"]:
|
||||||
|
results.append(SearchResponse(
|
||||||
|
url=result["url"],
|
||||||
|
title=result.get("title"),
|
||||||
|
content=result["content"],
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
))
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
return BatchSearchResponse(
|
||||||
|
results=results,
|
||||||
|
success_count=success_count,
|
||||||
|
failed_count=failed_count,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主入口 ====================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
logger.info(f"启动 Jina Search Agent - {POD_NAME}")
|
||||||
|
logger.info(f"Jina API Key: {'已配置' if JINA_API_KEY else '未配置'}")
|
||||||
|
logger.info(f"回调功能: {'已启用' if CALLBACK_ENABLED else '未启用'}")
|
||||||
|
|
||||||
|
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi==0.109.0 \
|
||||||
|
uvicorn[standard]==0.27.0 \
|
||||||
|
pydantic==2.5.3 \
|
||||||
|
requests>=2.31.0 \
|
||||||
|
langchain>=0.1.0 \
|
||||||
|
langchain-community>=0.0.10 \
|
||||||
|
langchain-openai>=0.0.2 \
|
||||||
|
pymysql>=1.1.0 \
|
||||||
|
cryptography>=41.0.0
|
||||||
|
|
||||||
|
# 复制 common 模块(回调工具)
|
||||||
|
COPY common/agent_callback_utils.py /app/common/
|
||||||
|
RUN touch /app/common/__init__.py
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY agents/mysql_agent/mysql_agent.py /app/
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV SERVICE_HOST=0.0.0.0
|
||||||
|
ENV SERVICE_PORT=8000
|
||||||
|
|
||||||
|
# 回调配置
|
||||||
|
ENV AGENT_CALLBACK_URL=http://mcp-server:8002/api/v1/billing/agent-callback
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python3", "-u", "mysql_agent.py"]
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
MySQL Database Agent - 基于 LangChain 的 MySQL 数据库查询代理
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# 添加 common 模块路径
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# 导入回调工具
|
||||||
|
try:
|
||||||
|
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||||
|
CALLBACK_ENABLED = True
|
||||||
|
except ImportError:
|
||||||
|
CALLBACK_ENABLED = False
|
||||||
|
AgentCallbackHandler = None
|
||||||
|
CallbackContextManager = None
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "mysql-agent")
|
||||||
|
USER_ID = os.getenv("USER_ID", "")
|
||||||
|
|
||||||
|
# MySQL 配置
|
||||||
|
MYSQL_HOST = os.getenv("MYSQL_HOST", "")
|
||||||
|
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
|
||||||
|
MYSQL_USER = os.getenv("MYSQL_USER", "")
|
||||||
|
MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
|
||||||
|
MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "")
|
||||||
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
||||||
|
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-3.5-turbo")
|
||||||
|
|
||||||
|
# FastAPI 应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="MySQL Database Agent",
|
||||||
|
description="基于 LangChain 的 MySQL 数据库查询代理",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 请求/响应模型 ====================
|
||||||
|
|
||||||
|
class QueryRequest(BaseModel):
|
||||||
|
"""查询请求"""
|
||||||
|
query: str = Field(..., description="自然语言查询")
|
||||||
|
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
|
||||||
|
openai_api_key: Optional[str] = Field(None, description="OpenAI API 密钥(可选,覆盖环境变量)")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryResponse(BaseModel):
|
||||||
|
"""查询响应"""
|
||||||
|
query: str
|
||||||
|
result: str
|
||||||
|
sql: Optional[str] = None
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
"""连接请求"""
|
||||||
|
host: str = Field(..., description="MySQL 主机地址")
|
||||||
|
port: int = Field(default=3306, description="MySQL 端口")
|
||||||
|
user: str = Field(..., description="MySQL 用户名")
|
||||||
|
password: str = Field(..., description="MySQL 密码")
|
||||||
|
database: str = Field(..., description="数据库名")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""健康检查响应"""
|
||||||
|
status: str
|
||||||
|
pod_name: str
|
||||||
|
connected: bool
|
||||||
|
callback_enabled: bool
|
||||||
|
database: Optional[str] = None
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 全局变量 ====================
|
||||||
|
|
||||||
|
db_agent = None
|
||||||
|
db_connection_info = None
|
||||||
|
callback_handler: Optional[AgentCallbackHandler] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""应用启动时初始化"""
|
||||||
|
global callback_handler
|
||||||
|
|
||||||
|
if CALLBACK_ENABLED:
|
||||||
|
callback_handler = AgentCallbackHandler(
|
||||||
|
agent_name=POD_NAME,
|
||||||
|
user_id=USER_ID
|
||||||
|
)
|
||||||
|
logger.info(f"回调处理器已初始化: callback_url={callback_handler.callback_url}")
|
||||||
|
else:
|
||||||
|
logger.warning("回调模块未加载,计费回调功能不可用")
|
||||||
|
|
||||||
|
# 尝试自动连接
|
||||||
|
if all([MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE, OPENAI_API_KEY]):
|
||||||
|
try:
|
||||||
|
create_db_agent(
|
||||||
|
host=MYSQL_HOST,
|
||||||
|
port=MYSQL_PORT,
|
||||||
|
user=MYSQL_USER,
|
||||||
|
password=MYSQL_PASSWORD,
|
||||||
|
database=MYSQL_DATABASE,
|
||||||
|
api_key=OPENAI_API_KEY
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"自动连接失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 辅助函数 ====================
|
||||||
|
|
||||||
|
def create_db_agent(host: str, port: int, user: str, password: str, database: str, api_key: str):
|
||||||
|
"""创建数据库 Agent"""
|
||||||
|
global db_agent, db_connection_info
|
||||||
|
|
||||||
|
try:
|
||||||
|
from langchain_community.utilities import SQLDatabase
|
||||||
|
from langchain_community.agent_toolkits import create_sql_agent
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
# 创建数据库连接
|
||||||
|
db_uri = f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
|
||||||
|
db = SQLDatabase.from_uri(db_uri)
|
||||||
|
|
||||||
|
# 创建 LLM
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
model=MODEL_NAME,
|
||||||
|
temperature=0,
|
||||||
|
openai_api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建 SQL Agent
|
||||||
|
db_agent = create_sql_agent(llm, db=db, agent_type="openai-tools", verbose=True)
|
||||||
|
db_connection_info = {"host": host, "port": port, "database": database}
|
||||||
|
|
||||||
|
logger.info(f"MySQL Agent 连接成功: {host}:{port}/{database}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建 MySQL Agent 失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API 端点 ====================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
@app.get("/", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
pod_name=POD_NAME,
|
||||||
|
connected=db_agent is not None,
|
||||||
|
callback_enabled=CALLBACK_ENABLED,
|
||||||
|
database=db_connection_info.get("database") if db_connection_info else None,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/connect")
|
||||||
|
async def connect_database(request: ConnectRequest):
|
||||||
|
"""连接数据库"""
|
||||||
|
api_key = OPENAI_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=400, detail="OPENAI_API_KEY 未设置")
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_db_agent(
|
||||||
|
host=request.host,
|
||||||
|
port=request.port,
|
||||||
|
user=request.user,
|
||||||
|
password=request.password,
|
||||||
|
database=request.database,
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "connected",
|
||||||
|
"database": request.database,
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/query", response_model=QueryResponse)
|
||||||
|
async def query_database(request: QueryRequest):
|
||||||
|
"""执行自然语言查询"""
|
||||||
|
global db_agent
|
||||||
|
|
||||||
|
# 获取 API key - 优先使用请求中的
|
||||||
|
api_key = request.openai_api_key or OPENAI_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=400, detail="OPENAI_API_KEY 未设置,请在请求中传入 openai_api_key 或设置环境变量")
|
||||||
|
|
||||||
|
# 如果未连接,尝试使用环境变量连接
|
||||||
|
if db_agent is None:
|
||||||
|
if not all([MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="数据库未连接。请先调用 /connect 或设置环境变量"
|
||||||
|
)
|
||||||
|
|
||||||
|
create_db_agent(
|
||||||
|
host=MYSQL_HOST,
|
||||||
|
port=MYSQL_PORT,
|
||||||
|
user=MYSQL_USER,
|
||||||
|
password=MYSQL_PASSWORD,
|
||||||
|
database=MYSQL_DATABASE,
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
elif request.openai_api_key:
|
||||||
|
# 如果请求中提供了新的 API key,重新创建 agent
|
||||||
|
logger.info(f"使用请求中的 OpenAI API Key 重新初始化 Agent...")
|
||||||
|
create_db_agent(
|
||||||
|
host=db_connection_info["host"],
|
||||||
|
port=db_connection_info["port"],
|
||||||
|
user=MYSQL_USER,
|
||||||
|
password=MYSQL_PASSWORD,
|
||||||
|
database=db_connection_info["database"],
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用回调上下文管理器
|
||||||
|
if CALLBACK_ENABLED and callback_handler and request.user_id:
|
||||||
|
with CallbackContextManager(
|
||||||
|
handler=callback_handler,
|
||||||
|
user_id=request.user_id,
|
||||||
|
request_id=f"mysql-query-{int(datetime.utcnow().timestamp())}"
|
||||||
|
) as ctx:
|
||||||
|
ctx.add_tool("mysql_query")
|
||||||
|
ctx.add_tool("sql_agent")
|
||||||
|
|
||||||
|
result = db_agent.invoke({"input": request.query})
|
||||||
|
|
||||||
|
return QueryResponse(
|
||||||
|
query=request.query,
|
||||||
|
result=result.get("output", str(result)),
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = db_agent.invoke({"input": request.query})
|
||||||
|
return QueryResponse(
|
||||||
|
query=request.query,
|
||||||
|
result=result.get("output", str(result)),
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主入口 ====================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
logger.info(f"启动 MySQL Agent - {POD_NAME}")
|
||||||
|
logger.info(f"回调功能: {'已启用' if CALLBACK_ENABLED else '未启用'}")
|
||||||
|
|
||||||
|
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
libpq-dev \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi==0.109.0 \
|
||||||
|
uvicorn[standard]==0.27.0 \
|
||||||
|
pydantic==2.5.3 \
|
||||||
|
requests>=2.31.0 \
|
||||||
|
langchain>=0.1.0 \
|
||||||
|
langchain-community>=0.0.10 \
|
||||||
|
langchain-openai>=0.0.2 \
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
|
||||||
|
# 复制 common 模块(回调工具)
|
||||||
|
COPY common/agent_callback_utils.py /app/common/
|
||||||
|
RUN touch /app/common/__init__.py
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY agents/postgresql_agent/postgresql_agent.py /app/
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV SERVICE_HOST=0.0.0.0
|
||||||
|
ENV SERVICE_PORT=8000
|
||||||
|
|
||||||
|
# 回调配置
|
||||||
|
ENV AGENT_CALLBACK_URL=http://mcp-server:8002/api/v1/billing/agent-callback
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python3", "-u", "postgresql_agent.py"]
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
PostgreSQL Database Agent - 基于 LangChain 的 PostgreSQL 数据库查询代理
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# 添加 common 模块路径
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# 导入回调工具
|
||||||
|
try:
|
||||||
|
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||||
|
CALLBACK_ENABLED = True
|
||||||
|
except ImportError:
|
||||||
|
CALLBACK_ENABLED = False
|
||||||
|
AgentCallbackHandler = None
|
||||||
|
CallbackContextManager = None
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||||
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
|
||||||
|
POD_NAME = os.getenv("POD_NAME", "postgresql-agent")
|
||||||
|
USER_ID = os.getenv("USER_ID", "")
|
||||||
|
|
||||||
|
# PostgreSQL 配置
|
||||||
|
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "")
|
||||||
|
POSTGRES_PORT = int(os.getenv("POSTGRES_PORT", "5432"))
|
||||||
|
POSTGRES_USER = os.getenv("POSTGRES_USER", "")
|
||||||
|
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "")
|
||||||
|
POSTGRES_DATABASE = os.getenv("POSTGRES_DATABASE", "")
|
||||||
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
||||||
|
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-3.5-turbo")
|
||||||
|
|
||||||
|
# FastAPI 应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="PostgreSQL Database Agent",
|
||||||
|
description="基于 LangChain 的 PostgreSQL 数据库查询代理",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 请求/响应模型 ====================
|
||||||
|
|
||||||
|
class QueryRequest(BaseModel):
|
||||||
|
"""查询请求"""
|
||||||
|
query: str = Field(..., description="自然语言查询")
|
||||||
|
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
|
||||||
|
openai_api_key: Optional[str] = Field(None, description="OpenAI API 密钥(可选,覆盖环境变量)")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryResponse(BaseModel):
|
||||||
|
"""查询响应"""
|
||||||
|
query: str
|
||||||
|
result: str
|
||||||
|
sql: Optional[str] = None
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
"""连接请求"""
|
||||||
|
host: str = Field(..., description="PostgreSQL 主机地址")
|
||||||
|
port: int = Field(default=5432, description="PostgreSQL 端口")
|
||||||
|
user: str = Field(..., description="PostgreSQL 用户名")
|
||||||
|
password: str = Field(..., description="PostgreSQL 密码")
|
||||||
|
database: str = Field(..., description="数据库名")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""健康检查响应"""
|
||||||
|
status: str
|
||||||
|
pod_name: str
|
||||||
|
connected: bool
|
||||||
|
callback_enabled: bool
|
||||||
|
database: Optional[str] = None
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 全局变量 ====================
|
||||||
|
|
||||||
|
db_agent = None
|
||||||
|
db_connection_info = None
|
||||||
|
callback_handler: Optional[AgentCallbackHandler] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""应用启动时初始化"""
|
||||||
|
global callback_handler
|
||||||
|
|
||||||
|
if CALLBACK_ENABLED:
|
||||||
|
callback_handler = AgentCallbackHandler(
|
||||||
|
agent_name=POD_NAME,
|
||||||
|
user_id=USER_ID
|
||||||
|
)
|
||||||
|
logger.info(f"回调处理器已初始化: callback_url={callback_handler.callback_url}")
|
||||||
|
else:
|
||||||
|
logger.warning("回调模块未加载,计费回调功能不可用")
|
||||||
|
|
||||||
|
# 尝试自动连接
|
||||||
|
if all([POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE, OPENAI_API_KEY]):
|
||||||
|
try:
|
||||||
|
create_db_agent(
|
||||||
|
host=POSTGRES_HOST,
|
||||||
|
port=POSTGRES_PORT,
|
||||||
|
user=POSTGRES_USER,
|
||||||
|
password=POSTGRES_PASSWORD,
|
||||||
|
database=POSTGRES_DATABASE,
|
||||||
|
api_key=OPENAI_API_KEY
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"自动连接失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 辅助函数 ====================
|
||||||
|
|
||||||
|
def create_db_agent(host: str, port: int, user: str, password: str, database: str, api_key: str):
|
||||||
|
"""创建数据库 Agent"""
|
||||||
|
global db_agent, db_connection_info
|
||||||
|
|
||||||
|
try:
|
||||||
|
from langchain_community.utilities import SQLDatabase
|
||||||
|
from langchain_community.agent_toolkits import create_sql_agent
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
# 创建数据库连接
|
||||||
|
db_uri = f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{database}"
|
||||||
|
db = SQLDatabase.from_uri(db_uri)
|
||||||
|
|
||||||
|
# 创建 LLM
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
model=MODEL_NAME,
|
||||||
|
temperature=0,
|
||||||
|
openai_api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建 SQL Agent
|
||||||
|
db_agent = create_sql_agent(llm, db=db, agent_type="openai-tools", verbose=True)
|
||||||
|
db_connection_info = {"host": host, "port": port, "database": database}
|
||||||
|
|
||||||
|
logger.info(f"PostgreSQL Agent 连接成功: {host}:{port}/{database}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建 PostgreSQL Agent 失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API 端点 ====================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
@app.get("/", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
pod_name=POD_NAME,
|
||||||
|
connected=db_agent is not None,
|
||||||
|
callback_enabled=CALLBACK_ENABLED,
|
||||||
|
database=db_connection_info.get("database") if db_connection_info else None,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/connect")
|
||||||
|
async def connect_database(request: ConnectRequest):
|
||||||
|
"""连接数据库"""
|
||||||
|
api_key = OPENAI_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=400, detail="OPENAI_API_KEY 未设置")
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_db_agent(
|
||||||
|
host=request.host,
|
||||||
|
port=request.port,
|
||||||
|
user=request.user,
|
||||||
|
password=request.password,
|
||||||
|
database=request.database,
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "connected",
|
||||||
|
"database": request.database,
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/query", response_model=QueryResponse)
|
||||||
|
async def query_database(request: QueryRequest):
|
||||||
|
"""执行自然语言查询"""
|
||||||
|
global db_agent
|
||||||
|
|
||||||
|
# 获取 API key - 优先使用请求中的
|
||||||
|
api_key = request.openai_api_key or OPENAI_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=400, detail="OPENAI_API_KEY 未设置,请在请求中传入 openai_api_key 或设置环境变量")
|
||||||
|
|
||||||
|
# 如果未连接,尝试使用环境变量连接
|
||||||
|
if db_agent is None:
|
||||||
|
if not all([POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="数据库未连接。请先调用 /connect 或设置环境变量"
|
||||||
|
)
|
||||||
|
|
||||||
|
create_db_agent(
|
||||||
|
host=POSTGRES_HOST,
|
||||||
|
port=POSTGRES_PORT,
|
||||||
|
user=POSTGRES_USER,
|
||||||
|
password=POSTGRES_PASSWORD,
|
||||||
|
database=POSTGRES_DATABASE,
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
elif request.openai_api_key:
|
||||||
|
# 如果请求中提供了新的 API key,重新创建 agent
|
||||||
|
logger.info(f"使用请求中的 OpenAI API Key 重新初始化 Agent...")
|
||||||
|
create_db_agent(
|
||||||
|
host=db_connection_info["host"],
|
||||||
|
port=db_connection_info["port"],
|
||||||
|
user=POSTGRES_USER,
|
||||||
|
password=POSTGRES_PASSWORD,
|
||||||
|
database=db_connection_info["database"],
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用回调上下文管理器
|
||||||
|
if CALLBACK_ENABLED and callback_handler and request.user_id:
|
||||||
|
with CallbackContextManager(
|
||||||
|
handler=callback_handler,
|
||||||
|
user_id=request.user_id,
|
||||||
|
request_id=f"postgresql-query-{int(datetime.utcnow().timestamp())}"
|
||||||
|
) as ctx:
|
||||||
|
ctx.add_tool("postgresql_query")
|
||||||
|
ctx.add_tool("sql_agent")
|
||||||
|
|
||||||
|
result = db_agent.invoke({"input": request.query})
|
||||||
|
|
||||||
|
return QueryResponse(
|
||||||
|
query=request.query,
|
||||||
|
result=result.get("output", str(result)),
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = db_agent.invoke({"input": request.query})
|
||||||
|
return QueryResponse(
|
||||||
|
query=request.query,
|
||||||
|
result=result.get("output", str(result)),
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主入口 ====================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
logger.info(f"启动 PostgreSQL Agent - {POD_NAME}")
|
||||||
|
logger.info(f"回调功能: {'已启用' if CALLBACK_ENABLED else '未启用'}")
|
||||||
|
|
||||||
|
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# 智能搜索AI Agent API接口文档
|
||||||
|
|
||||||
|
## 基础信息
|
||||||
|
|
||||||
|
- **服务名称**: Intelligent Search AI Agent
|
||||||
|
- **版本**: 1.0.0
|
||||||
|
- **基础URL**: `域名`
|
||||||
|
- **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心接口
|
||||||
|
|
||||||
|
### 执行搜索
|
||||||
|
|
||||||
|
#### POST /search
|
||||||
|
|
||||||
|
执行智能搜索,根据查询返回答案和相关来源。
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| query | string | 是 | 搜索查询 |
|
||||||
|
| llm_api_key | string | 是 | LLM API密钥(用户的API密钥) |
|
||||||
|
| user_id | string | 否 | 用户ID(用于计费回调) |
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "什么是人工智能?",
|
||||||
|
"llm_api_key": "your-llm-api-key",
|
||||||
|
"user_id": "user123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "什么是人工智能?",
|
||||||
|
"answer": "人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统...",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"title": "人工智能 - 维基百科",
|
||||||
|
"url": "https://zh.wikipedia.org/wiki/人工智能"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"title": "什么是AI?",
|
||||||
|
"url": "https://example.com/ai-introduction"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"confidence": "high",
|
||||||
|
"iterations": 2,
|
||||||
|
"total_sources": 5,
|
||||||
|
"search_queries": [
|
||||||
|
"什么是人工智能",
|
||||||
|
"AI定义"
|
||||||
|
],
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| query | string | 原始查询 |
|
||||||
|
| answer | string | 生成的答案内容(Markdown格式) |
|
||||||
|
| sources | array | 来源列表 |
|
||||||
|
| sources[].index | integer | 来源索引 |
|
||||||
|
| sources[].title | string | 来源标题 |
|
||||||
|
| sources[].url | string | 来源URL |
|
||||||
|
| confidence | string | 置信度:"high" / "medium" / "low" |
|
||||||
|
| iterations | integer | 迭代次数 |
|
||||||
|
| total_sources | integer | 参考来源总数 |
|
||||||
|
| search_queries | array[string] | 使用的搜索查询列表 |
|
||||||
|
| timestamp | string | 时间戳(ISO格式) |
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
400 Bad Request(参数错误):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "llm_api_key 是必须的参数"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
500 Internal Server Error(服务器错误):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "搜索失败: {错误详情}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 聊天接口
|
||||||
|
|
||||||
|
#### POST /chat
|
||||||
|
|
||||||
|
聊天接口,是 `/search` 接口的别名,功能和参数完全相同。
|
||||||
|
|
||||||
|
**请求参数**: 与 `/search` 接口相同
|
||||||
|
|
||||||
|
**请求示例**: 与 `/search` 接口相同
|
||||||
|
|
||||||
|
**响应示例**: 与 `/search` 接口相同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 其他接口
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
|
||||||
|
#### GET /health
|
||||||
|
|
||||||
|
检查服务健康状态。
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"pod_name": "search-agent",
|
||||||
|
"template_type": "search_agent",
|
||||||
|
"configured": true,
|
||||||
|
"llm_base_url": "https://api.example.com",
|
||||||
|
"llm_model": "xchat52",
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 获取状态
|
||||||
|
|
||||||
|
#### GET /status
|
||||||
|
|
||||||
|
获取服务状态信息。
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```
|
||||||
|
GET /status
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "running",
|
||||||
|
"pod_name": "search-agent",
|
||||||
|
"template_type": "search_agent",
|
||||||
|
"configured": true,
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
**重要提示**:
|
||||||
|
- 用户只需要传递 `query` 和 `llm_api_key` 参数
|
||||||
|
- `llm_base_url`、`llm_model` 等配置参数已通过环境变量在部署时配置,**不需要**在请求中传递
|
||||||
|
- `user_id` 为可选参数,用于计费回调
|
||||||
|
|
||||||
|
**请求参数说明**:
|
||||||
|
- `query` - 搜索查询内容(必填)
|
||||||
|
- `llm_api_key` - 用户的LLM API密钥(必填)
|
||||||
|
- `user_id` - 用户ID(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| HTTP状态码 | 说明 |
|
||||||
|
|-----------|------|
|
||||||
|
| 200 | 请求成功 |
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: agent-search-test
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: search-agent-secrets
|
||||||
|
namespace: agent-search-test
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# LLM API Key 在请求中传入,环境变量可以留空
|
||||||
|
LLM_API_KEY: ""
|
||||||
|
# Serper 搜索 API Key (必须)
|
||||||
|
SERPER_API_KEY: "8253b4f240b520194065312f90e85f9be0fa205f"
|
||||||
|
# Jina Reader API Key (必须)
|
||||||
|
JINA_API_KEY: "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI"
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: search-agent
|
||||||
|
namespace: agent-search-test
|
||||||
|
labels:
|
||||||
|
app: search-agent
|
||||||
|
managed-by: manual-test
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: search-agent
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: search-agent
|
||||||
|
managed-by: manual-test
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/arch: arm64
|
||||||
|
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
|
||||||
|
containers:
|
||||||
|
- name: search-agent
|
||||||
|
image: agnettaiji.azurecr.io/ai-agents/search-agent:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: TEMPLATE_TYPE
|
||||||
|
value: "search_agent"
|
||||||
|
- name: SERVICE_HOST
|
||||||
|
value: "0.0.0.0"
|
||||||
|
- name: SERVICE_PORT
|
||||||
|
value: "8080"
|
||||||
|
- name: LOG_LEVEL
|
||||||
|
value: "INFO"
|
||||||
|
# LLM 配置 (必须)
|
||||||
|
- name: LLM_BASE_URL
|
||||||
|
value: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/"
|
||||||
|
- name: LLM_MODEL
|
||||||
|
value: "taiji/gpt-4o-mini"
|
||||||
|
# LLM_API_KEY 可选,在请求中传入
|
||||||
|
- name: LLM_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: search-agent-secrets
|
||||||
|
key: LLM_API_KEY
|
||||||
|
# Serper API Key (必须)
|
||||||
|
- name: SERPER_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: search-agent-secrets
|
||||||
|
key: SERPER_API_KEY
|
||||||
|
# Jina API Key (必须)
|
||||||
|
- name: JINA_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: search-agent-secrets
|
||||||
|
key: JINA_API_KEY
|
||||||
|
# 搜索配置
|
||||||
|
- name: MAX_ITERATIONS
|
||||||
|
value: "3"
|
||||||
|
- name: MAX_RESULTS_PER_QUERY
|
||||||
|
value: "10"
|
||||||
|
- name: CONTENT_MAX_LENGTH
|
||||||
|
value: "5000"
|
||||||
|
- name: TIMEOUT
|
||||||
|
value: "60"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "1000m"
|
||||||
|
memory: "1Gi"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 10
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: search-agent
|
||||||
|
namespace: agent-search-test
|
||||||
|
labels:
|
||||||
|
app: search-agent
|
||||||
|
managed-by: manual-test
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
selector:
|
||||||
|
app: search-agent
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制requirements文件
|
||||||
|
COPY agents/search_agent/search_agent/requirements.txt /app/search_agent_requirements.txt
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi==0.109.0 \
|
||||||
|
uvicorn[standard]==0.27.0 \
|
||||||
|
pydantic==2.5.3 \
|
||||||
|
&& pip install --no-cache-dir -r /app/search_agent_requirements.txt
|
||||||
|
|
||||||
|
# 复制search_agent目录
|
||||||
|
COPY agents/search_agent/search_agent/ /app/search_agent/
|
||||||
|
|
||||||
|
# 复制主agent文件和共享工具
|
||||||
|
COPY agents/search_agent/search_agent_main.py /app/
|
||||||
|
COPY common/agent_callback_utils.py /app/common/
|
||||||
|
COPY common/api_key_utils.py /app/common/
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV SERVICE_HOST=0.0.0.0
|
||||||
|
ENV SERVICE_PORT=8080
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
# 默认 API 密钥(硬编码)
|
||||||
|
ENV JINA_API_KEY=jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI
|
||||||
|
ENV SERPER_API_KEY=8253b4f240b520194065312f90e85f9be0fa205f
|
||||||
|
|
||||||
|
# 健康检查 - 使用Python避免僵尸进程
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health').read()" || exit 1
|
||||||
|
|
||||||
|
# 运行agent (直接使用Python,避免shell)
|
||||||
|
CMD ["python3", "-u", "search_agent_main.py"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# LLM配置 (xchat52)
|
||||||
|
LLM_BASE_URL=https://apis.openroutex.com/openai/deployments/xchat52
|
||||||
|
LLM_API_KEY=a76ef8d69da64ad99c4bf9739f09585b
|
||||||
|
LLM_MODEL=xchat52
|
||||||
|
|
||||||
|
# Serper配置
|
||||||
|
SERPER_API_KEY=8253b4f240b520194065312f90e85f9be0fa205f
|
||||||
|
|
||||||
|
# Jina配置
|
||||||
|
JINA_API_KEY=jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI
|
||||||
|
|
||||||
|
# Agent配置
|
||||||
|
MAX_ITERATIONS=3
|
||||||
|
MAX_RESULTS_PER_QUERY=10
|
||||||
|
CONTENT_MAX_LENGTH=5000
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
TIMEOUT=30
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Activate a Python virtual environment for the current PowerShell session.
|
||||||
|
|
||||||
|
.Description
|
||||||
|
Pushes the python executable for a virtual environment to the front of the
|
||||||
|
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||||
|
in a Python virtual environment. Makes use of the command line switches as
|
||||||
|
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||||
|
|
||||||
|
.Parameter VenvDir
|
||||||
|
Path to the directory that contains the virtual environment to activate. The
|
||||||
|
default value for this is the parent of the directory that the Activate.ps1
|
||||||
|
script is located within.
|
||||||
|
|
||||||
|
.Parameter Prompt
|
||||||
|
The prompt prefix to display when this virtual environment is activated. By
|
||||||
|
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||||
|
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Verbose
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and shows extra information about the activation as it executes.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||||
|
Activates the Python virtual environment located in the specified location.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Prompt "MyPython"
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and prefixes the current prompt with the specified string (surrounded in
|
||||||
|
parentheses) while the virtual environment is active.
|
||||||
|
|
||||||
|
.Notes
|
||||||
|
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||||
|
execution policy for the user. You can do this by issuing the following PowerShell
|
||||||
|
command:
|
||||||
|
|
||||||
|
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
For more information on Execution Policies:
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||||
|
|
||||||
|
#>
|
||||||
|
Param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$VenvDir,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$Prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
<# Function declarations --------------------------------------------------- #>
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Remove all shell session elements added by the Activate script, including the
|
||||||
|
addition of the virtual environment's Python executable from the beginning of
|
||||||
|
the PATH variable.
|
||||||
|
|
||||||
|
.Parameter NonDestructive
|
||||||
|
If present, do not remove this function from the global namespace for the
|
||||||
|
session.
|
||||||
|
|
||||||
|
#>
|
||||||
|
function global:deactivate ([switch]$NonDestructive) {
|
||||||
|
# Revert to original values
|
||||||
|
|
||||||
|
# The prior prompt:
|
||||||
|
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||||
|
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||||
|
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PYTHONHOME:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PATH:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the VIRTUAL_ENV altogether:
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||||
|
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||||
|
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Leave deactivate function in the global namespace if requested:
|
||||||
|
if (-not $NonDestructive) {
|
||||||
|
Remove-Item -Path function:deactivate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Description
|
||||||
|
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||||
|
given folder, and returns them in a map.
|
||||||
|
|
||||||
|
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||||
|
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||||
|
then it is considered a `key = value` line. The left hand string is the key,
|
||||||
|
the right hand is the value.
|
||||||
|
|
||||||
|
If the value starts with a `'` or a `"` then the first and last character is
|
||||||
|
stripped from the value before being captured.
|
||||||
|
|
||||||
|
.Parameter ConfigDir
|
||||||
|
Path to the directory that contains the `pyvenv.cfg` file.
|
||||||
|
#>
|
||||||
|
function Get-PyVenvConfig(
|
||||||
|
[String]
|
||||||
|
$ConfigDir
|
||||||
|
) {
|
||||||
|
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||||
|
|
||||||
|
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||||
|
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||||
|
|
||||||
|
# An empty map will be returned if no config file is found.
|
||||||
|
$pyvenvConfig = @{ }
|
||||||
|
|
||||||
|
if ($pyvenvConfigPath) {
|
||||||
|
|
||||||
|
Write-Verbose "File exists, parse `key = value` lines"
|
||||||
|
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||||
|
|
||||||
|
$pyvenvConfigContent | ForEach-Object {
|
||||||
|
$keyval = $PSItem -split "\s*=\s*", 2
|
||||||
|
if ($keyval[0] -and $keyval[1]) {
|
||||||
|
$val = $keyval[1]
|
||||||
|
|
||||||
|
# Remove extraneous quotations around a string value.
|
||||||
|
if ("'""".Contains($val.Substring(0, 1))) {
|
||||||
|
$val = $val.Substring(1, $val.Length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyvenvConfig[$keyval[0]] = $val
|
||||||
|
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $pyvenvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<# Begin Activate script --------------------------------------------------- #>
|
||||||
|
|
||||||
|
# Determine the containing directory of this script
|
||||||
|
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||||
|
|
||||||
|
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||||
|
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||||
|
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||||
|
|
||||||
|
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||||
|
# First, get the location of the virtual environment, it might not be
|
||||||
|
# VenvExecDir if specified on the command line.
|
||||||
|
if ($VenvDir) {
|
||||||
|
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||||
|
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||||
|
Write-Verbose "VenvDir=$VenvDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||||
|
# as `prompt`.
|
||||||
|
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||||
|
|
||||||
|
# Next, set the prompt from the command line, or the config file, or
|
||||||
|
# just use the name of the virtual environment folder.
|
||||||
|
if ($Prompt) {
|
||||||
|
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||||
|
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||||
|
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||||
|
$Prompt = $pyvenvCfg['prompt'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||||
|
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||||
|
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Prompt = '$Prompt'"
|
||||||
|
Write-Verbose "VenvDir='$VenvDir'"
|
||||||
|
|
||||||
|
# Deactivate any currently active virtual environment, but leave the
|
||||||
|
# deactivate function in place.
|
||||||
|
deactivate -nondestructive
|
||||||
|
|
||||||
|
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||||
|
# that there is an activated venv.
|
||||||
|
$env:VIRTUAL_ENV = $VenvDir
|
||||||
|
|
||||||
|
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||||
|
|
||||||
|
Write-Verbose "Setting prompt to '$Prompt'"
|
||||||
|
|
||||||
|
# Set the prompt to include the env name
|
||||||
|
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||||
|
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||||
|
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||||
|
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||||
|
|
||||||
|
function global:prompt {
|
||||||
|
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||||
|
_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear PYTHONHOME
|
||||||
|
if (Test-Path -Path Env:PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
Remove-Item -Path Env:PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the venv to the PATH
|
||||||
|
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||||
|
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# This file must be used with "source bin/activate" *from bash*
|
||||||
|
# You cannot run it directly
|
||||||
|
|
||||||
|
deactivate () {
|
||||||
|
# reset old environment variables
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||||
|
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||||
|
export PATH
|
||||||
|
unset _OLD_VIRTUAL_PATH
|
||||||
|
fi
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||||
|
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||||
|
export PYTHONHOME
|
||||||
|
unset _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Call hash to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||||
|
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||||
|
export PS1
|
||||||
|
unset _OLD_VIRTUAL_PS1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset VIRTUAL_ENV
|
||||||
|
unset VIRTUAL_ENV_PROMPT
|
||||||
|
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||||
|
# Self destruct!
|
||||||
|
unset -f deactivate
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# unset irrelevant variables
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||||
|
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||||
|
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||||
|
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||||
|
export VIRTUAL_ENV=$(cygpath /home/taiji/tools/aks_agent/.venv)
|
||||||
|
else
|
||||||
|
# use the path as-is
|
||||||
|
export VIRTUAL_ENV=/home/taiji/tools/aks_agent/.venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
_OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
# unset PYTHONHOME if set
|
||||||
|
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||||
|
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||||
|
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||||
|
unset PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||||
|
PS1='(.venv) '"${PS1:-}"
|
||||||
|
export PS1
|
||||||
|
VIRTUAL_ENV_PROMPT='(.venv) '
|
||||||
|
export VIRTUAL_ENV_PROMPT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Call hash to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
hash -r 2> /dev/null
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||||
|
# You cannot run it directly.
|
||||||
|
|
||||||
|
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||||
|
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||||
|
|
||||||
|
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
setenv VIRTUAL_ENV /home/taiji/tools/aks_agent/.venv
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||||
|
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||||
|
|
||||||
|
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||||
|
set prompt = '(.venv) '"$prompt"
|
||||||
|
setenv VIRTUAL_ENV_PROMPT '(.venv) '
|
||||||
|
endif
|
||||||
|
|
||||||
|
alias pydoc python -m pydoc
|
||||||
|
|
||||||
|
rehash
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||||
|
# (https://fishshell.com/). You cannot run it directly.
|
||||||
|
|
||||||
|
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||||
|
# reset old environment variables
|
||||||
|
if test -n "$_OLD_VIRTUAL_PATH"
|
||||||
|
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||||
|
set -e _OLD_VIRTUAL_PATH
|
||||||
|
end
|
||||||
|
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||||
|
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||||
|
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||||
|
# prevents error when using nested fish instances (Issue #93858)
|
||||||
|
if functions -q _old_fish_prompt
|
||||||
|
functions -e fish_prompt
|
||||||
|
functions -c _old_fish_prompt fish_prompt
|
||||||
|
functions -e _old_fish_prompt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
set -e VIRTUAL_ENV
|
||||||
|
set -e VIRTUAL_ENV_PROMPT
|
||||||
|
if test "$argv[1]" != "nondestructive"
|
||||||
|
# Self-destruct!
|
||||||
|
functions -e deactivate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
set -gx VIRTUAL_ENV /home/taiji/tools/aks_agent/.venv
|
||||||
|
|
||||||
|
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||||
|
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||||
|
|
||||||
|
# Unset PYTHONHOME if set.
|
||||||
|
if set -q PYTHONHOME
|
||||||
|
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||||
|
set -e PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||||
|
# fish uses a function instead of an env var to generate the prompt.
|
||||||
|
|
||||||
|
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||||
|
functions -c fish_prompt _old_fish_prompt
|
||||||
|
|
||||||
|
# With the original prompt function renamed, we can override with our own.
|
||||||
|
function fish_prompt
|
||||||
|
# Save the return status of the last command.
|
||||||
|
set -l old_status $status
|
||||||
|
|
||||||
|
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||||
|
printf "%s%s%s" (set_color 4B8BBE) '(.venv) ' (set_color normal)
|
||||||
|
|
||||||
|
# Restore the return status of the previous command.
|
||||||
|
echo "exit $old_status" | .
|
||||||
|
# Output the original/"old" prompt.
|
||||||
|
_old_fish_prompt
|
||||||
|
end
|
||||||
|
|
||||||
|
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||||
|
set -gx VIRTUAL_ENV_PROMPT '(.venv) '
|
||||||
|
end
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/taiji/tools/aks_agent/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dotenv.__main__ import cli
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli())
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/taiji/tools/aks_agent/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from charset_normalizer.cli import cli_detect
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli_detect())
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/taiji/tools/aks_agent/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/taiji/tools/aks_agent/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/taiji/tools/aks_agent/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/usr/bin/python3
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
A. HISTORY OF THE SOFTWARE
|
||||||
|
==========================
|
||||||
|
|
||||||
|
Python was created in the early 1990s by Guido van Rossum at Stichting
|
||||||
|
Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
|
||||||
|
as a successor of a language called ABC. Guido remains Python's
|
||||||
|
principal author, although it includes many contributions from others.
|
||||||
|
|
||||||
|
In 1995, Guido continued his work on Python at the Corporation for
|
||||||
|
National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
|
||||||
|
in Reston, Virginia where he released several versions of the
|
||||||
|
software.
|
||||||
|
|
||||||
|
In May 2000, Guido and the Python core development team moved to
|
||||||
|
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
|
||||||
|
year, the PythonLabs team moved to Digital Creations, which became
|
||||||
|
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
|
||||||
|
https://www.python.org/psf/) was formed, a non-profit organization
|
||||||
|
created specifically to own Python-related Intellectual Property.
|
||||||
|
Zope Corporation was a sponsoring member of the PSF.
|
||||||
|
|
||||||
|
All Python releases are Open Source (see https://opensource.org for
|
||||||
|
the Open Source Definition). Historically, most, but not all, Python
|
||||||
|
releases have also been GPL-compatible; the table below summarizes
|
||||||
|
the various releases.
|
||||||
|
|
||||||
|
Release Derived Year Owner GPL-
|
||||||
|
from compatible? (1)
|
||||||
|
|
||||||
|
0.9.0 thru 1.2 1991-1995 CWI yes
|
||||||
|
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
|
||||||
|
1.6 1.5.2 2000 CNRI no
|
||||||
|
2.0 1.6 2000 BeOpen.com no
|
||||||
|
1.6.1 1.6 2001 CNRI yes (2)
|
||||||
|
2.1 2.0+1.6.1 2001 PSF no
|
||||||
|
2.0.1 2.0+1.6.1 2001 PSF yes
|
||||||
|
2.1.1 2.1+2.0.1 2001 PSF yes
|
||||||
|
2.1.2 2.1.1 2002 PSF yes
|
||||||
|
2.1.3 2.1.2 2002 PSF yes
|
||||||
|
2.2 and above 2.1.1 2001-now PSF yes
|
||||||
|
|
||||||
|
Footnotes:
|
||||||
|
|
||||||
|
(1) GPL-compatible doesn't mean that we're distributing Python under
|
||||||
|
the GPL. All Python licenses, unlike the GPL, let you distribute
|
||||||
|
a modified version without making your changes open source. The
|
||||||
|
GPL-compatible licenses make it possible to combine Python with
|
||||||
|
other software that is released under the GPL; the others don't.
|
||||||
|
|
||||||
|
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
|
||||||
|
because its license has a choice of law clause. According to
|
||||||
|
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
|
||||||
|
is "not incompatible" with the GPL.
|
||||||
|
|
||||||
|
Thanks to the many outside volunteers who have worked under Guido's
|
||||||
|
direction to make these releases possible.
|
||||||
|
|
||||||
|
|
||||||
|
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
|
||||||
|
===============================================================
|
||||||
|
|
||||||
|
Python software and documentation are licensed under the
|
||||||
|
Python Software Foundation License Version 2.
|
||||||
|
|
||||||
|
Starting with Python 3.8.6, examples, recipes, and other code in
|
||||||
|
the documentation are dual licensed under the PSF License Version 2
|
||||||
|
and the Zero-Clause BSD license.
|
||||||
|
|
||||||
|
Some software incorporated into Python is under different licenses.
|
||||||
|
The licenses are listed with code falling under that license.
|
||||||
|
|
||||||
|
|
||||||
|
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
||||||
|
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
||||||
|
otherwise using this software ("Python") in source or binary form and
|
||||||
|
its associated documentation.
|
||||||
|
|
||||||
|
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
||||||
|
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
||||||
|
analyze, test, perform and/or display publicly, prepare derivative works,
|
||||||
|
distribute, and otherwise use Python alone or in any derivative version,
|
||||||
|
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
||||||
|
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
||||||
|
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
|
||||||
|
All Rights Reserved" are retained in Python alone or in any derivative version
|
||||||
|
prepared by Licensee.
|
||||||
|
|
||||||
|
3. In the event Licensee prepares a derivative work that is based on
|
||||||
|
or incorporates Python or any part thereof, and wants to make
|
||||||
|
the derivative work available to others as provided herein, then
|
||||||
|
Licensee hereby agrees to include in any such work a brief summary of
|
||||||
|
the changes made to Python.
|
||||||
|
|
||||||
|
4. PSF is making Python available to Licensee on an "AS IS"
|
||||||
|
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
||||||
|
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||||
|
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
||||||
|
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||||
|
|
||||||
|
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||||
|
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||||
|
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
||||||
|
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||||
|
|
||||||
|
6. This License Agreement will automatically terminate upon a material
|
||||||
|
breach of its terms and conditions.
|
||||||
|
|
||||||
|
7. Nothing in this License Agreement shall be deemed to create any
|
||||||
|
relationship of agency, partnership, or joint venture between PSF and
|
||||||
|
Licensee. This License Agreement does not grant permission to use PSF
|
||||||
|
trademarks or trade name in a trademark sense to endorse or promote
|
||||||
|
products or services of Licensee, or any third party.
|
||||||
|
|
||||||
|
8. By copying, installing or otherwise using Python, Licensee
|
||||||
|
agrees to be bound by the terms and conditions of this License
|
||||||
|
Agreement.
|
||||||
|
|
||||||
|
|
||||||
|
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
|
||||||
|
|
||||||
|
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
|
||||||
|
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
|
||||||
|
Individual or Organization ("Licensee") accessing and otherwise using
|
||||||
|
this software in source or binary form and its associated
|
||||||
|
documentation ("the Software").
|
||||||
|
|
||||||
|
2. Subject to the terms and conditions of this BeOpen Python License
|
||||||
|
Agreement, BeOpen hereby grants Licensee a non-exclusive,
|
||||||
|
royalty-free, world-wide license to reproduce, analyze, test, perform
|
||||||
|
and/or display publicly, prepare derivative works, distribute, and
|
||||||
|
otherwise use the Software alone or in any derivative version,
|
||||||
|
provided, however, that the BeOpen Python License is retained in the
|
||||||
|
Software, alone or in any derivative version prepared by Licensee.
|
||||||
|
|
||||||
|
3. BeOpen is making the Software available to Licensee on an "AS IS"
|
||||||
|
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
|
||||||
|
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||||
|
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
|
||||||
|
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||||
|
|
||||||
|
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
|
||||||
|
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
|
||||||
|
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
|
||||||
|
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||||
|
|
||||||
|
5. This License Agreement will automatically terminate upon a material
|
||||||
|
breach of its terms and conditions.
|
||||||
|
|
||||||
|
6. This License Agreement shall be governed by and interpreted in all
|
||||||
|
respects by the law of the State of California, excluding conflict of
|
||||||
|
law provisions. Nothing in this License Agreement shall be deemed to
|
||||||
|
create any relationship of agency, partnership, or joint venture
|
||||||
|
between BeOpen and Licensee. This License Agreement does not grant
|
||||||
|
permission to use BeOpen trademarks or trade names in a trademark
|
||||||
|
sense to endorse or promote products or services of Licensee, or any
|
||||||
|
third party. As an exception, the "BeOpen Python" logos available at
|
||||||
|
http://www.pythonlabs.com/logos.html may be used according to the
|
||||||
|
permissions granted on that web page.
|
||||||
|
|
||||||
|
7. By copying, installing or otherwise using the software, Licensee
|
||||||
|
agrees to be bound by the terms and conditions of this License
|
||||||
|
Agreement.
|
||||||
|
|
||||||
|
|
||||||
|
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
1. This LICENSE AGREEMENT is between the Corporation for National
|
||||||
|
Research Initiatives, having an office at 1895 Preston White Drive,
|
||||||
|
Reston, VA 20191 ("CNRI"), and the Individual or Organization
|
||||||
|
("Licensee") accessing and otherwise using Python 1.6.1 software in
|
||||||
|
source or binary form and its associated documentation.
|
||||||
|
|
||||||
|
2. Subject to the terms and conditions of this License Agreement, CNRI
|
||||||
|
hereby grants Licensee a nonexclusive, royalty-free, world-wide
|
||||||
|
license to reproduce, analyze, test, perform and/or display publicly,
|
||||||
|
prepare derivative works, distribute, and otherwise use Python 1.6.1
|
||||||
|
alone or in any derivative version, provided, however, that CNRI's
|
||||||
|
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
|
||||||
|
1995-2001 Corporation for National Research Initiatives; All Rights
|
||||||
|
Reserved" are retained in Python 1.6.1 alone or in any derivative
|
||||||
|
version prepared by Licensee. Alternately, in lieu of CNRI's License
|
||||||
|
Agreement, Licensee may substitute the following text (omitting the
|
||||||
|
quotes): "Python 1.6.1 is made available subject to the terms and
|
||||||
|
conditions in CNRI's License Agreement. This Agreement together with
|
||||||
|
Python 1.6.1 may be located on the internet using the following
|
||||||
|
unique, persistent identifier (known as a handle): 1895.22/1013. This
|
||||||
|
Agreement may also be obtained from a proxy server on the internet
|
||||||
|
using the following URL: http://hdl.handle.net/1895.22/1013".
|
||||||
|
|
||||||
|
3. In the event Licensee prepares a derivative work that is based on
|
||||||
|
or incorporates Python 1.6.1 or any part thereof, and wants to make
|
||||||
|
the derivative work available to others as provided herein, then
|
||||||
|
Licensee hereby agrees to include in any such work a brief summary of
|
||||||
|
the changes made to Python 1.6.1.
|
||||||
|
|
||||||
|
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
|
||||||
|
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
|
||||||
|
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||||
|
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
|
||||||
|
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||||
|
|
||||||
|
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||||
|
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||||
|
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
|
||||||
|
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||||
|
|
||||||
|
6. This License Agreement will automatically terminate upon a material
|
||||||
|
breach of its terms and conditions.
|
||||||
|
|
||||||
|
7. This License Agreement shall be governed by the federal
|
||||||
|
intellectual property law of the United States, including without
|
||||||
|
limitation the federal copyright law, and, to the extent such
|
||||||
|
U.S. federal law does not apply, by the law of the Commonwealth of
|
||||||
|
Virginia, excluding Virginia's conflict of law provisions.
|
||||||
|
Notwithstanding the foregoing, with regard to derivative works based
|
||||||
|
on Python 1.6.1 that incorporate non-separable material that was
|
||||||
|
previously distributed under the GNU General Public License (GPL), the
|
||||||
|
law of the Commonwealth of Virginia shall govern this License
|
||||||
|
Agreement only as to issues arising under or with respect to
|
||||||
|
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
|
||||||
|
License Agreement shall be deemed to create any relationship of
|
||||||
|
agency, partnership, or joint venture between CNRI and Licensee. This
|
||||||
|
License Agreement does not grant permission to use CNRI trademarks or
|
||||||
|
trade name in a trademark sense to endorse or promote products or
|
||||||
|
services of Licensee, or any third party.
|
||||||
|
|
||||||
|
8. By clicking on the "ACCEPT" button where indicated, or by copying,
|
||||||
|
installing or otherwise using Python 1.6.1, Licensee agrees to be
|
||||||
|
bound by the terms and conditions of this License Agreement.
|
||||||
|
|
||||||
|
ACCEPT
|
||||||
|
|
||||||
|
|
||||||
|
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
|
||||||
|
The Netherlands. All rights reserved.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and distribute this software and its
|
||||||
|
documentation for any purpose and without fee is hereby granted,
|
||||||
|
provided that the above copyright notice appear in all copies and that
|
||||||
|
both that copyright notice and this permission notice appear in
|
||||||
|
supporting documentation, and that the name of Stichting Mathematisch
|
||||||
|
Centrum or CWI not be used in advertising or publicity pertaining to
|
||||||
|
distribution of the software without specific, written prior
|
||||||
|
permission.
|
||||||
|
|
||||||
|
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||||
|
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||||
|
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
|
||||||
|
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||||
|
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
Metadata-Version: 2.3
|
||||||
|
Name: aiohappyeyeballs
|
||||||
|
Version: 2.6.1
|
||||||
|
Summary: Happy Eyeballs for asyncio
|
||||||
|
License: PSF-2.0
|
||||||
|
Author: J. Nick Koston
|
||||||
|
Author-email: nick@koston.org
|
||||||
|
Requires-Python: >=3.9
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: Natural Language :: English
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Topic :: Software Development :: Libraries
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: License :: OSI Approved :: Python Software Foundation License
|
||||||
|
Project-URL: Bug Tracker, https://github.com/aio-libs/aiohappyeyeballs/issues
|
||||||
|
Project-URL: Changelog, https://github.com/aio-libs/aiohappyeyeballs/blob/main/CHANGELOG.md
|
||||||
|
Project-URL: Documentation, https://aiohappyeyeballs.readthedocs.io
|
||||||
|
Project-URL: Repository, https://github.com/aio-libs/aiohappyeyeballs
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
|
||||||
|
# aiohappyeyeballs
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/aio-libs/aiohappyeyeballs/actions/workflows/ci.yml?query=branch%3Amain">
|
||||||
|
<img src="https://img.shields.io/github/actions/workflow/status/aio-libs/aiohappyeyeballs/ci-cd.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
|
||||||
|
</a>
|
||||||
|
<a href="https://aiohappyeyeballs.readthedocs.io">
|
||||||
|
<img src="https://img.shields.io/readthedocs/aiohappyeyeballs.svg?logo=read-the-docs&logoColor=fff&style=flat-square" alt="Documentation Status">
|
||||||
|
</a>
|
||||||
|
<a href="https://codecov.io/gh/aio-libs/aiohappyeyeballs">
|
||||||
|
<img src="https://img.shields.io/codecov/c/github/aio-libs/aiohappyeyeballs.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://python-poetry.org/">
|
||||||
|
<img src="https://img.shields.io/badge/packaging-poetry-299bd7?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAASCAYAAABrXO8xAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJJSURBVHgBfZLPa1NBEMe/s7tNXoxW1KJQKaUHkXhQvHgW6UHQQ09CBS/6V3hKc/AP8CqCrUcpmop3Cx48eDB4yEECjVQrlZb80CRN8t6OM/teagVxYZi38+Yz853dJbzoMV3MM8cJUcLMSUKIE8AzQ2PieZzFxEJOHMOgMQQ+dUgSAckNXhapU/NMhDSWLs1B24A8sO1xrN4NECkcAC9ASkiIJc6k5TRiUDPhnyMMdhKc+Zx19l6SgyeW76BEONY9exVQMzKExGKwwPsCzza7KGSSWRWEQhyEaDXp6ZHEr416ygbiKYOd7TEWvvcQIeusHYMJGhTwF9y7sGnSwaWyFAiyoxzqW0PM/RjghPxF2pWReAowTEXnDh0xgcLs8l2YQmOrj3N7ByiqEoH0cARs4u78WgAVkoEDIDoOi3AkcLOHU60RIg5wC4ZuTC7FaHKQm8Hq1fQuSOBvX/sodmNJSB5geaF5CPIkUeecdMxieoRO5jz9bheL6/tXjrwCyX/UYBUcjCaWHljx1xiX6z9xEjkYAzbGVnB8pvLmyXm9ep+W8CmsSHQQY77Zx1zboxAV0w7ybMhQmfqdmmw3nEp1I0Z+FGO6M8LZdoyZnuzzBdjISicKRnpxzI9fPb+0oYXsNdyi+d3h9bm9MWYHFtPeIZfLwzmFDKy1ai3p+PDls1Llz4yyFpferxjnyjJDSEy9CaCx5m2cJPerq6Xm34eTrZt3PqxYO1XOwDYZrFlH1fWnpU38Y9HRze3lj0vOujZcXKuuXm3jP+s3KbZVra7y2EAAAAAASUVORK5CYII=" alt="Poetry">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/astral-sh/ruff">
|
||||||
|
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/pre-commit/pre-commit">
|
||||||
|
<img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://pypi.org/project/aiohappyeyeballs/">
|
||||||
|
<img src="https://img.shields.io/pypi/v/aiohappyeyeballs.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
|
||||||
|
</a>
|
||||||
|
<img src="https://img.shields.io/pypi/pyversions/aiohappyeyeballs.svg?style=flat-square&logo=python&logoColor=fff" alt="Supported Python versions">
|
||||||
|
<img src="https://img.shields.io/pypi/l/aiohappyeyeballs.svg?style=flat-square" alt="License">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Documentation**: <a href="https://aiohappyeyeballs.readthedocs.io" target="_blank">https://aiohappyeyeballs.readthedocs.io </a>
|
||||||
|
|
||||||
|
**Source Code**: <a href="https://github.com/aio-libs/aiohappyeyeballs" target="_blank">https://github.com/aio-libs/aiohappyeyeballs </a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
|
||||||
|
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
|
||||||
|
|
||||||
|
## Use case
|
||||||
|
|
||||||
|
This library exists to allow connecting with
|
||||||
|
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
|
||||||
|
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
|
||||||
|
when you
|
||||||
|
already have a list of addrinfo and not a DNS name.
|
||||||
|
|
||||||
|
The stdlib version of `loop.create_connection()`
|
||||||
|
will only work when you pass in an unresolved name which
|
||||||
|
is not a good fit when using DNS caching or resolving
|
||||||
|
names via another method such as `zeroconf`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install this via pip (or your favourite package manager):
|
||||||
|
|
||||||
|
`pip install aiohappyeyeballs`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[aiohappyeyeballs is licensed under the same terms as cpython itself.](https://github.com/python/cpython/blob/main/LICENSE)
|
||||||
|
|
||||||
|
## Example usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
addr_infos = await loop.getaddrinfo("example.org", 80)
|
||||||
|
|
||||||
|
socket = await start_connection(addr_infos)
|
||||||
|
socket = await start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2)
|
||||||
|
|
||||||
|
transport, protocol = await loop.create_connection(
|
||||||
|
MyProtocol, sock=socket, ...)
|
||||||
|
|
||||||
|
# Remove the first address for each family from addr_info
|
||||||
|
pop_addr_infos_interleave(addr_info, 1)
|
||||||
|
|
||||||
|
# Remove all matching address from addr_info
|
||||||
|
remove_addr_infos(addr_info, "dead::beef::")
|
||||||
|
|
||||||
|
# Convert a local_addr to local_addr_infos
|
||||||
|
local_addr_infos = addr_to_addr_infos(("127.0.0.1",0))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
This package contains code from cpython and is licensed under the same terms as cpython itself.
|
||||||
|
|
||||||
|
This package was created with
|
||||||
|
[Copier](https://copier.readthedocs.io/) and the
|
||||||
|
[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
|
||||||
|
project template.
|
||||||
|
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
aiohappyeyeballs-2.6.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/LICENSE,sha256=Oy-B_iHRgcSZxZolbI4ZaEVdZonSaaqFNzv7avQdo78,13936
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/METADATA,sha256=NSXlhJwAfi380eEjAo7BQ4P_TVal9xi0qkyZWibMsVM,5915
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/RECORD,,
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
||||||
|
aiohappyeyeballs/__init__.py,sha256=x7kktHEtaD9quBcWDJPuLeKyjuVAI-Jj14S9B_5hcTs,361
|
||||||
|
aiohappyeyeballs/__pycache__/__init__.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/_staggered.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/impl.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/types.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/utils.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/_staggered.py,sha256=edfVowFx-P-ywJjIEF3MdPtEMVODujV6CeMYr65otac,6900
|
||||||
|
aiohappyeyeballs/impl.py,sha256=Dlcm2mTJ28ucrGnxkb_fo9CZzLAkOOBizOt7dreBbXE,9681
|
||||||
|
aiohappyeyeballs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
aiohappyeyeballs/types.py,sha256=YZJIAnyoV4Dz0WFtlaf_OyE4EW7Xus1z7aIfNI6tDDQ,425
|
||||||
|
aiohappyeyeballs/utils.py,sha256=on9GxIR0LhEfZu8P6Twi9hepX9zDanuZM20MWsb3xlQ,3028
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: poetry-core 2.1.1
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
__version__ = "2.6.1"
|
||||||
|
|
||||||
|
from .impl import start_connection
|
||||||
|
from .types import AddrInfoType, SocketFactoryType
|
||||||
|
from .utils import addr_to_addr_infos, pop_addr_infos_interleave, remove_addr_infos
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"AddrInfoType",
|
||||||
|
"SocketFactoryType",
|
||||||
|
"addr_to_addr_infos",
|
||||||
|
"pop_addr_infos_interleave",
|
||||||
|
"remove_addr_infos",
|
||||||
|
"start_connection",
|
||||||
|
)
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+207
@@ -0,0 +1,207 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
# PY3.9: Import Callable from typing until we drop Python 3.9 support
|
||||||
|
# https://github.com/python/cpython/issues/87131
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
Iterable,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Set,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
RE_RAISE_EXCEPTIONS = (SystemExit, KeyboardInterrupt)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_result(wait_next: "asyncio.Future[None]") -> None:
|
||||||
|
"""Set the result of a future if it is not already done."""
|
||||||
|
if not wait_next.done():
|
||||||
|
wait_next.set_result(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_one(
|
||||||
|
futures: "Iterable[asyncio.Future[Any]]",
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
) -> _T:
|
||||||
|
"""Wait for the first future to complete."""
|
||||||
|
wait_next = loop.create_future()
|
||||||
|
|
||||||
|
def _on_completion(fut: "asyncio.Future[Any]") -> None:
|
||||||
|
if not wait_next.done():
|
||||||
|
wait_next.set_result(fut)
|
||||||
|
|
||||||
|
for f in futures:
|
||||||
|
f.add_done_callback(_on_completion)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await wait_next
|
||||||
|
finally:
|
||||||
|
for f in futures:
|
||||||
|
f.remove_done_callback(_on_completion)
|
||||||
|
|
||||||
|
|
||||||
|
async def staggered_race(
|
||||||
|
coro_fns: Iterable[Callable[[], Awaitable[_T]]],
|
||||||
|
delay: Optional[float],
|
||||||
|
*,
|
||||||
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
|
) -> Tuple[Optional[_T], Optional[int], List[Optional[BaseException]]]:
|
||||||
|
"""
|
||||||
|
Run coroutines with staggered start times and take the first to finish.
|
||||||
|
|
||||||
|
This method takes an iterable of coroutine functions. The first one is
|
||||||
|
started immediately. From then on, whenever the immediately preceding one
|
||||||
|
fails (raises an exception), or when *delay* seconds has passed, the next
|
||||||
|
coroutine is started. This continues until one of the coroutines complete
|
||||||
|
successfully, in which case all others are cancelled, or until all
|
||||||
|
coroutines fail.
|
||||||
|
|
||||||
|
The coroutines provided should be well-behaved in the following way:
|
||||||
|
|
||||||
|
* They should only ``return`` if completed successfully.
|
||||||
|
|
||||||
|
* They should always raise an exception if they did not complete
|
||||||
|
successfully. In particular, if they handle cancellation, they should
|
||||||
|
probably reraise, like this::
|
||||||
|
|
||||||
|
try:
|
||||||
|
# do work
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# undo partially completed work
|
||||||
|
raise
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
coro_fns: an iterable of coroutine functions, i.e. callables that
|
||||||
|
return a coroutine object when called. Use ``functools.partial`` or
|
||||||
|
lambdas to pass arguments.
|
||||||
|
|
||||||
|
delay: amount of time, in seconds, between starting coroutines. If
|
||||||
|
``None``, the coroutines will run sequentially.
|
||||||
|
|
||||||
|
loop: the event loop to use. If ``None``, the running loop is used.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
-------
|
||||||
|
tuple *(winner_result, winner_index, exceptions)* where
|
||||||
|
|
||||||
|
- *winner_result*: the result of the winning coroutine, or ``None``
|
||||||
|
if no coroutines won.
|
||||||
|
|
||||||
|
- *winner_index*: the index of the winning coroutine in
|
||||||
|
``coro_fns``, or ``None`` if no coroutines won. If the winning
|
||||||
|
coroutine may return None on success, *winner_index* can be used
|
||||||
|
to definitively determine whether any coroutine won.
|
||||||
|
|
||||||
|
- *exceptions*: list of exceptions returned by the coroutines.
|
||||||
|
``len(exceptions)`` is equal to the number of coroutines actually
|
||||||
|
started, and the order is the same as in ``coro_fns``. The winning
|
||||||
|
coroutine's entry is ``None``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
loop = loop or asyncio.get_running_loop()
|
||||||
|
exceptions: List[Optional[BaseException]] = []
|
||||||
|
tasks: Set[asyncio.Task[Optional[Tuple[_T, int]]]] = set()
|
||||||
|
|
||||||
|
async def run_one_coro(
|
||||||
|
coro_fn: Callable[[], Awaitable[_T]],
|
||||||
|
this_index: int,
|
||||||
|
start_next: "asyncio.Future[None]",
|
||||||
|
) -> Optional[Tuple[_T, int]]:
|
||||||
|
"""
|
||||||
|
Run a single coroutine.
|
||||||
|
|
||||||
|
If the coroutine fails, set the exception in the exceptions list and
|
||||||
|
start the next coroutine by setting the result of the start_next.
|
||||||
|
|
||||||
|
If the coroutine succeeds, return the result and the index of the
|
||||||
|
coroutine in the coro_fns list.
|
||||||
|
|
||||||
|
If SystemExit or KeyboardInterrupt is raised, re-raise it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await coro_fn()
|
||||||
|
except RE_RAISE_EXCEPTIONS:
|
||||||
|
raise
|
||||||
|
except BaseException as e:
|
||||||
|
exceptions[this_index] = e
|
||||||
|
_set_result(start_next) # Kickstart the next coroutine
|
||||||
|
return None
|
||||||
|
|
||||||
|
return result, this_index
|
||||||
|
|
||||||
|
start_next_timer: Optional[asyncio.TimerHandle] = None
|
||||||
|
start_next: Optional[asyncio.Future[None]]
|
||||||
|
task: asyncio.Task[Optional[Tuple[_T, int]]]
|
||||||
|
done: Union[asyncio.Future[None], asyncio.Task[Optional[Tuple[_T, int]]]]
|
||||||
|
coro_iter = iter(coro_fns)
|
||||||
|
this_index = -1
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if coro_fn := next(coro_iter, None):
|
||||||
|
this_index += 1
|
||||||
|
exceptions.append(None)
|
||||||
|
start_next = loop.create_future()
|
||||||
|
task = loop.create_task(run_one_coro(coro_fn, this_index, start_next))
|
||||||
|
tasks.add(task)
|
||||||
|
start_next_timer = (
|
||||||
|
loop.call_later(delay, _set_result, start_next) if delay else None
|
||||||
|
)
|
||||||
|
elif not tasks:
|
||||||
|
# We exhausted the coro_fns list and no tasks are running
|
||||||
|
# so we have no winner and all coroutines failed.
|
||||||
|
break
|
||||||
|
|
||||||
|
while tasks or start_next:
|
||||||
|
done = await _wait_one(
|
||||||
|
(*tasks, start_next) if start_next else tasks, loop
|
||||||
|
)
|
||||||
|
if done is start_next:
|
||||||
|
# The current task has failed or the timer has expired
|
||||||
|
# so we need to start the next task.
|
||||||
|
start_next = None
|
||||||
|
if start_next_timer:
|
||||||
|
start_next_timer.cancel()
|
||||||
|
start_next_timer = None
|
||||||
|
|
||||||
|
# Break out of the task waiting loop to start the next
|
||||||
|
# task.
|
||||||
|
break
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert isinstance(done, asyncio.Task)
|
||||||
|
|
||||||
|
tasks.remove(done)
|
||||||
|
if winner := done.result():
|
||||||
|
return *winner, exceptions
|
||||||
|
finally:
|
||||||
|
# We either have:
|
||||||
|
# - a winner
|
||||||
|
# - all tasks failed
|
||||||
|
# - a KeyboardInterrupt or SystemExit.
|
||||||
|
|
||||||
|
#
|
||||||
|
# If the timer is still running, cancel it.
|
||||||
|
#
|
||||||
|
if start_next_timer:
|
||||||
|
start_next_timer.cancel()
|
||||||
|
|
||||||
|
#
|
||||||
|
# If there are any tasks left, cancel them and than
|
||||||
|
# wait them so they fill the exceptions list.
|
||||||
|
#
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
return None, None, exceptions
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
"""Base implementation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import contextlib
|
||||||
|
import functools
|
||||||
|
import itertools
|
||||||
|
import socket
|
||||||
|
from typing import List, Optional, Sequence, Set, Union
|
||||||
|
|
||||||
|
from . import _staggered
|
||||||
|
from .types import AddrInfoType, SocketFactoryType
|
||||||
|
|
||||||
|
|
||||||
|
async def start_connection(
|
||||||
|
addr_infos: Sequence[AddrInfoType],
|
||||||
|
*,
|
||||||
|
local_addr_infos: Optional[Sequence[AddrInfoType]] = None,
|
||||||
|
happy_eyeballs_delay: Optional[float] = None,
|
||||||
|
interleave: Optional[int] = None,
|
||||||
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
|
socket_factory: Optional[SocketFactoryType] = None,
|
||||||
|
) -> socket.socket:
|
||||||
|
"""
|
||||||
|
Connect to a TCP server.
|
||||||
|
|
||||||
|
Create a socket connection to a specified destination. The
|
||||||
|
destination is specified as a list of AddrInfoType tuples as
|
||||||
|
returned from getaddrinfo().
|
||||||
|
|
||||||
|
The arguments are, in order:
|
||||||
|
|
||||||
|
* ``family``: the address family, e.g. ``socket.AF_INET`` or
|
||||||
|
``socket.AF_INET6``.
|
||||||
|
* ``type``: the socket type, e.g. ``socket.SOCK_STREAM`` or
|
||||||
|
``socket.SOCK_DGRAM``.
|
||||||
|
* ``proto``: the protocol, e.g. ``socket.IPPROTO_TCP`` or
|
||||||
|
``socket.IPPROTO_UDP``.
|
||||||
|
* ``canonname``: the canonical name of the address, e.g.
|
||||||
|
``"www.python.org"``.
|
||||||
|
* ``sockaddr``: the socket address
|
||||||
|
|
||||||
|
This method is a coroutine which will try to establish the connection
|
||||||
|
in the background. When successful, the coroutine returns a
|
||||||
|
socket.
|
||||||
|
|
||||||
|
The expected use case is to use this method in conjunction with
|
||||||
|
loop.create_connection() to establish a connection to a server::
|
||||||
|
|
||||||
|
socket = await start_connection(addr_infos)
|
||||||
|
transport, protocol = await loop.create_connection(
|
||||||
|
MyProtocol, sock=socket, ...)
|
||||||
|
"""
|
||||||
|
if not (current_loop := loop):
|
||||||
|
current_loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
single_addr_info = len(addr_infos) == 1
|
||||||
|
|
||||||
|
if happy_eyeballs_delay is not None and interleave is None:
|
||||||
|
# If using happy eyeballs, default to interleave addresses by family
|
||||||
|
interleave = 1
|
||||||
|
|
||||||
|
if interleave and not single_addr_info:
|
||||||
|
addr_infos = _interleave_addrinfos(addr_infos, interleave)
|
||||||
|
|
||||||
|
sock: Optional[socket.socket] = None
|
||||||
|
# uvloop can raise RuntimeError instead of OSError
|
||||||
|
exceptions: List[List[Union[OSError, RuntimeError]]] = []
|
||||||
|
if happy_eyeballs_delay is None or single_addr_info:
|
||||||
|
# not using happy eyeballs
|
||||||
|
for addrinfo in addr_infos:
|
||||||
|
try:
|
||||||
|
sock = await _connect_sock(
|
||||||
|
current_loop,
|
||||||
|
exceptions,
|
||||||
|
addrinfo,
|
||||||
|
local_addr_infos,
|
||||||
|
None,
|
||||||
|
socket_factory,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except (RuntimeError, OSError):
|
||||||
|
continue
|
||||||
|
else: # using happy eyeballs
|
||||||
|
open_sockets: Set[socket.socket] = set()
|
||||||
|
try:
|
||||||
|
sock, _, _ = await _staggered.staggered_race(
|
||||||
|
(
|
||||||
|
functools.partial(
|
||||||
|
_connect_sock,
|
||||||
|
current_loop,
|
||||||
|
exceptions,
|
||||||
|
addrinfo,
|
||||||
|
local_addr_infos,
|
||||||
|
open_sockets,
|
||||||
|
socket_factory,
|
||||||
|
)
|
||||||
|
for addrinfo in addr_infos
|
||||||
|
),
|
||||||
|
happy_eyeballs_delay,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# If we have a winner, staggered_race will
|
||||||
|
# cancel the other tasks, however there is a
|
||||||
|
# small race window where any of the other tasks
|
||||||
|
# can be done before they are cancelled which
|
||||||
|
# will leave the socket open. To avoid this problem
|
||||||
|
# we pass a set to _connect_sock to keep track of
|
||||||
|
# the open sockets and close them here if there
|
||||||
|
# are any "runner up" sockets.
|
||||||
|
for s in open_sockets:
|
||||||
|
if s is not sock:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
s.close()
|
||||||
|
open_sockets = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
if sock is None:
|
||||||
|
all_exceptions = [exc for sub in exceptions for exc in sub]
|
||||||
|
try:
|
||||||
|
first_exception = all_exceptions[0]
|
||||||
|
if len(all_exceptions) == 1:
|
||||||
|
raise first_exception
|
||||||
|
else:
|
||||||
|
# If they all have the same str(), raise one.
|
||||||
|
model = str(first_exception)
|
||||||
|
if all(str(exc) == model for exc in all_exceptions):
|
||||||
|
raise first_exception
|
||||||
|
# Raise a combined exception so the user can see all
|
||||||
|
# the various error messages.
|
||||||
|
msg = "Multiple exceptions: {}".format(
|
||||||
|
", ".join(str(exc) for exc in all_exceptions)
|
||||||
|
)
|
||||||
|
# If the errno is the same for all exceptions, raise
|
||||||
|
# an OSError with that errno.
|
||||||
|
if isinstance(first_exception, OSError):
|
||||||
|
first_errno = first_exception.errno
|
||||||
|
if all(
|
||||||
|
isinstance(exc, OSError) and exc.errno == first_errno
|
||||||
|
for exc in all_exceptions
|
||||||
|
):
|
||||||
|
raise OSError(first_errno, msg)
|
||||||
|
elif isinstance(first_exception, RuntimeError) and all(
|
||||||
|
isinstance(exc, RuntimeError) for exc in all_exceptions
|
||||||
|
):
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
# We have a mix of OSError and RuntimeError
|
||||||
|
# so we have to pick which one to raise.
|
||||||
|
# and we raise OSError for compatibility
|
||||||
|
raise OSError(msg)
|
||||||
|
finally:
|
||||||
|
all_exceptions = None # type: ignore[assignment]
|
||||||
|
exceptions = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
return sock
|
||||||
|
|
||||||
|
|
||||||
|
async def _connect_sock(
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
exceptions: List[List[Union[OSError, RuntimeError]]],
|
||||||
|
addr_info: AddrInfoType,
|
||||||
|
local_addr_infos: Optional[Sequence[AddrInfoType]] = None,
|
||||||
|
open_sockets: Optional[Set[socket.socket]] = None,
|
||||||
|
socket_factory: Optional[SocketFactoryType] = None,
|
||||||
|
) -> socket.socket:
|
||||||
|
"""
|
||||||
|
Create, bind and connect one socket.
|
||||||
|
|
||||||
|
If open_sockets is passed, add the socket to the set of open sockets.
|
||||||
|
Any failure caught here will remove the socket from the set and close it.
|
||||||
|
|
||||||
|
Callers can use this set to close any sockets that are not the winner
|
||||||
|
of all staggered tasks in the result there are runner up sockets aka
|
||||||
|
multiple winners.
|
||||||
|
"""
|
||||||
|
my_exceptions: List[Union[OSError, RuntimeError]] = []
|
||||||
|
exceptions.append(my_exceptions)
|
||||||
|
family, type_, proto, _, address = addr_info
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
if socket_factory is not None:
|
||||||
|
sock = socket_factory(addr_info)
|
||||||
|
else:
|
||||||
|
sock = socket.socket(family=family, type=type_, proto=proto)
|
||||||
|
if open_sockets is not None:
|
||||||
|
open_sockets.add(sock)
|
||||||
|
sock.setblocking(False)
|
||||||
|
if local_addr_infos is not None:
|
||||||
|
for lfamily, _, _, _, laddr in local_addr_infos:
|
||||||
|
# skip local addresses of different family
|
||||||
|
if lfamily != family:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sock.bind(laddr)
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
msg = (
|
||||||
|
f"error while attempting to bind on "
|
||||||
|
f"address {laddr!r}: "
|
||||||
|
f"{(exc.strerror or '').lower()}"
|
||||||
|
)
|
||||||
|
exc = OSError(exc.errno, msg)
|
||||||
|
my_exceptions.append(exc)
|
||||||
|
else: # all bind attempts failed
|
||||||
|
if my_exceptions:
|
||||||
|
raise my_exceptions.pop()
|
||||||
|
else:
|
||||||
|
raise OSError(f"no matching local address with {family=} found")
|
||||||
|
await loop.sock_connect(sock, address)
|
||||||
|
return sock
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
my_exceptions.append(exc)
|
||||||
|
if sock is not None:
|
||||||
|
if open_sockets is not None:
|
||||||
|
open_sockets.remove(sock)
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except OSError as e:
|
||||||
|
my_exceptions.append(e)
|
||||||
|
raise
|
||||||
|
raise
|
||||||
|
except:
|
||||||
|
if sock is not None:
|
||||||
|
if open_sockets is not None:
|
||||||
|
open_sockets.remove(sock)
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except OSError as e:
|
||||||
|
my_exceptions.append(e)
|
||||||
|
raise
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
exceptions = my_exceptions = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
def _interleave_addrinfos(
|
||||||
|
addrinfos: Sequence[AddrInfoType], first_address_family_count: int = 1
|
||||||
|
) -> List[AddrInfoType]:
|
||||||
|
"""Interleave list of addrinfo tuples by family."""
|
||||||
|
# Group addresses by family
|
||||||
|
addrinfos_by_family: collections.OrderedDict[int, List[AddrInfoType]] = (
|
||||||
|
collections.OrderedDict()
|
||||||
|
)
|
||||||
|
for addr in addrinfos:
|
||||||
|
family = addr[0]
|
||||||
|
if family not in addrinfos_by_family:
|
||||||
|
addrinfos_by_family[family] = []
|
||||||
|
addrinfos_by_family[family].append(addr)
|
||||||
|
addrinfos_lists = list(addrinfos_by_family.values())
|
||||||
|
|
||||||
|
reordered: List[AddrInfoType] = []
|
||||||
|
if first_address_family_count > 1:
|
||||||
|
reordered.extend(addrinfos_lists[0][: first_address_family_count - 1])
|
||||||
|
del addrinfos_lists[0][: first_address_family_count - 1]
|
||||||
|
reordered.extend(
|
||||||
|
a
|
||||||
|
for a in itertools.chain.from_iterable(itertools.zip_longest(*addrinfos_lists))
|
||||||
|
if a is not None
|
||||||
|
)
|
||||||
|
return reordered
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
"""Types for aiohappyeyeballs."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
# PY3.9: Import Callable from typing until we drop Python 3.9 support
|
||||||
|
# https://github.com/python/cpython/issues/87131
|
||||||
|
from typing import Callable, Tuple, Union
|
||||||
|
|
||||||
|
AddrInfoType = Tuple[
|
||||||
|
Union[int, socket.AddressFamily],
|
||||||
|
Union[int, socket.SocketKind],
|
||||||
|
int,
|
||||||
|
str,
|
||||||
|
Tuple, # type: ignore[type-arg]
|
||||||
|
]
|
||||||
|
|
||||||
|
SocketFactoryType = Callable[[AddrInfoType], socket.socket]
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
"""Utility functions for aiohappyeyeballs."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from .types import AddrInfoType
|
||||||
|
|
||||||
|
|
||||||
|
def addr_to_addr_infos(
|
||||||
|
addr: Optional[
|
||||||
|
Union[Tuple[str, int, int, int], Tuple[str, int, int], Tuple[str, int]]
|
||||||
|
],
|
||||||
|
) -> Optional[List[AddrInfoType]]:
|
||||||
|
"""Convert an address tuple to a list of addr_info tuples."""
|
||||||
|
if addr is None:
|
||||||
|
return None
|
||||||
|
host = addr[0]
|
||||||
|
port = addr[1]
|
||||||
|
is_ipv6 = ":" in host
|
||||||
|
if is_ipv6:
|
||||||
|
flowinfo = 0
|
||||||
|
scopeid = 0
|
||||||
|
addr_len = len(addr)
|
||||||
|
if addr_len >= 4:
|
||||||
|
scopeid = addr[3] # type: ignore[misc]
|
||||||
|
if addr_len >= 3:
|
||||||
|
flowinfo = addr[2] # type: ignore[misc]
|
||||||
|
addr = (host, port, flowinfo, scopeid)
|
||||||
|
family = socket.AF_INET6
|
||||||
|
else:
|
||||||
|
addr = (host, port)
|
||||||
|
family = socket.AF_INET
|
||||||
|
return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", addr)]
|
||||||
|
|
||||||
|
|
||||||
|
def pop_addr_infos_interleave(
|
||||||
|
addr_infos: List[AddrInfoType], interleave: Optional[int] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Pop addr_info from the list of addr_infos by family up to interleave times.
|
||||||
|
|
||||||
|
The interleave parameter is used to know how many addr_infos for
|
||||||
|
each family should be popped of the top of the list.
|
||||||
|
"""
|
||||||
|
seen: Dict[int, int] = {}
|
||||||
|
if interleave is None:
|
||||||
|
interleave = 1
|
||||||
|
to_remove: List[AddrInfoType] = []
|
||||||
|
for addr_info in addr_infos:
|
||||||
|
family = addr_info[0]
|
||||||
|
if family not in seen:
|
||||||
|
seen[family] = 0
|
||||||
|
if seen[family] < interleave:
|
||||||
|
to_remove.append(addr_info)
|
||||||
|
seen[family] += 1
|
||||||
|
for addr_info in to_remove:
|
||||||
|
addr_infos.remove(addr_info)
|
||||||
|
|
||||||
|
|
||||||
|
def _addr_tuple_to_ip_address(
|
||||||
|
addr: Union[Tuple[str, int], Tuple[str, int, int, int]],
|
||||||
|
) -> Union[
|
||||||
|
Tuple[ipaddress.IPv4Address, int], Tuple[ipaddress.IPv6Address, int, int, int]
|
||||||
|
]:
|
||||||
|
"""Convert an address tuple to an IPv4Address."""
|
||||||
|
return (ipaddress.ip_address(addr[0]), *addr[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def remove_addr_infos(
|
||||||
|
addr_infos: List[AddrInfoType],
|
||||||
|
addr: Union[Tuple[str, int], Tuple[str, int, int, int]],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Remove an address from the list of addr_infos.
|
||||||
|
|
||||||
|
The addr value is typically the return value of
|
||||||
|
sock.getpeername().
|
||||||
|
"""
|
||||||
|
bad_addrs_infos: List[AddrInfoType] = []
|
||||||
|
for addr_info in addr_infos:
|
||||||
|
if addr_info[-1] == addr:
|
||||||
|
bad_addrs_infos.append(addr_info)
|
||||||
|
if bad_addrs_infos:
|
||||||
|
for bad_addr_info in bad_addrs_infos:
|
||||||
|
addr_infos.remove(bad_addr_info)
|
||||||
|
return
|
||||||
|
# Slow path in case addr is formatted differently
|
||||||
|
match_addr = _addr_tuple_to_ip_address(addr)
|
||||||
|
for addr_info in addr_infos:
|
||||||
|
if match_addr == _addr_tuple_to_ip_address(addr_info[-1]):
|
||||||
|
bad_addrs_infos.append(addr_info)
|
||||||
|
if bad_addrs_infos:
|
||||||
|
for bad_addr_info in bad_addrs_infos:
|
||||||
|
addr_infos.remove(bad_addr_info)
|
||||||
|
return
|
||||||
|
raise ValueError(f"Address {addr} not found in addr_infos")
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: aiohttp
|
||||||
|
Version: 3.13.3
|
||||||
|
Summary: Async http client/server framework (asyncio)
|
||||||
|
Maintainer-email: aiohttp team <team@aiohttp.org>
|
||||||
|
License: Apache-2.0 AND MIT
|
||||||
|
Project-URL: Homepage, https://github.com/aio-libs/aiohttp
|
||||||
|
Project-URL: Chat: Matrix, https://matrix.to/#/#aio-libs:matrix.org
|
||||||
|
Project-URL: Chat: Matrix Space, https://matrix.to/#/#aio-libs-space:matrix.org
|
||||||
|
Project-URL: CI: GitHub Actions, https://github.com/aio-libs/aiohttp/actions?query=workflow%3ACI
|
||||||
|
Project-URL: Coverage: codecov, https://codecov.io/github/aio-libs/aiohttp
|
||||||
|
Project-URL: Docs: Changelog, https://docs.aiohttp.org/en/stable/changes.html
|
||||||
|
Project-URL: Docs: RTD, https://docs.aiohttp.org
|
||||||
|
Project-URL: GitHub: issues, https://github.com/aio-libs/aiohttp/issues
|
||||||
|
Project-URL: GitHub: repo, https://github.com/aio-libs/aiohttp
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Framework :: AsyncIO
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: Operating System :: POSIX
|
||||||
|
Classifier: Operating System :: MacOS :: MacOS X
|
||||||
|
Classifier: Operating System :: Microsoft :: Windows
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Programming Language :: Python :: 3.14
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP
|
||||||
|
Requires-Python: >=3.9
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
License-File: LICENSE.txt
|
||||||
|
License-File: vendor/llhttp/LICENSE
|
||||||
|
Requires-Dist: aiohappyeyeballs>=2.5.0
|
||||||
|
Requires-Dist: aiosignal>=1.4.0
|
||||||
|
Requires-Dist: async-timeout<6.0,>=4.0; python_version < "3.11"
|
||||||
|
Requires-Dist: attrs>=17.3.0
|
||||||
|
Requires-Dist: frozenlist>=1.1.1
|
||||||
|
Requires-Dist: multidict<7.0,>=4.5
|
||||||
|
Requires-Dist: propcache>=0.2.0
|
||||||
|
Requires-Dist: yarl<2.0,>=1.17.0
|
||||||
|
Provides-Extra: speedups
|
||||||
|
Requires-Dist: aiodns>=3.3.0; extra == "speedups"
|
||||||
|
Requires-Dist: Brotli>=1.2; platform_python_implementation == "CPython" and extra == "speedups"
|
||||||
|
Requires-Dist: brotlicffi>=1.2; platform_python_implementation != "CPython" and extra == "speedups"
|
||||||
|
Requires-Dist: backports.zstd; (platform_python_implementation == "CPython" and python_version < "3.14") and extra == "speedups"
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
==================================
|
||||||
|
Async http client/server framework
|
||||||
|
==================================
|
||||||
|
|
||||||
|
.. image:: https://raw.githubusercontent.com/aio-libs/aiohttp/master/docs/aiohttp-plain.svg
|
||||||
|
:height: 64px
|
||||||
|
:width: 64px
|
||||||
|
:alt: aiohttp logo
|
||||||
|
|
||||||
|
|
|
||||||
|
|
||||||
|
.. image:: https://github.com/aio-libs/aiohttp/workflows/CI/badge.svg
|
||||||
|
:target: https://github.com/aio-libs/aiohttp/actions?query=workflow%3ACI
|
||||||
|
:alt: GitHub Actions status for master branch
|
||||||
|
|
||||||
|
.. image:: https://codecov.io/gh/aio-libs/aiohttp/branch/master/graph/badge.svg
|
||||||
|
:target: https://codecov.io/gh/aio-libs/aiohttp
|
||||||
|
:alt: codecov.io status for master branch
|
||||||
|
|
||||||
|
.. image:: https://badge.fury.io/py/aiohttp.svg
|
||||||
|
:target: https://pypi.org/project/aiohttp
|
||||||
|
:alt: Latest PyPI package version
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/dm/aiohttp
|
||||||
|
:target: https://pypistats.org/packages/aiohttp
|
||||||
|
:alt: Downloads count
|
||||||
|
|
||||||
|
.. image:: https://readthedocs.org/projects/aiohttp/badge/?version=latest
|
||||||
|
:target: https://docs.aiohttp.org/
|
||||||
|
:alt: Latest Read The Docs
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json
|
||||||
|
:target: https://codspeed.io/aio-libs/aiohttp
|
||||||
|
:alt: Codspeed.io status for aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
Key Features
|
||||||
|
============
|
||||||
|
|
||||||
|
- Supports both client and server side of HTTP protocol.
|
||||||
|
- Supports both client and server Web-Sockets out-of-the-box and avoids
|
||||||
|
Callback Hell.
|
||||||
|
- Provides Web-server with middleware and pluggable routing.
|
||||||
|
|
||||||
|
|
||||||
|
Getting started
|
||||||
|
===============
|
||||||
|
|
||||||
|
Client
|
||||||
|
------
|
||||||
|
|
||||||
|
To get something from the web:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get('http://python.org') as response:
|
||||||
|
|
||||||
|
print("Status:", response.status)
|
||||||
|
print("Content-type:", response.headers['content-type'])
|
||||||
|
|
||||||
|
html = await response.text()
|
||||||
|
print("Body:", html[:15], "...")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
This prints:
|
||||||
|
|
||||||
|
.. code-block::
|
||||||
|
|
||||||
|
Status: 200
|
||||||
|
Content-type: text/html; charset=utf-8
|
||||||
|
Body: <!doctype html> ...
|
||||||
|
|
||||||
|
Coming from `requests <https://requests.readthedocs.io/>`_ ? Read `why we need so many lines <https://aiohttp.readthedocs.io/en/latest/http_request_lifecycle.html>`_.
|
||||||
|
|
||||||
|
Server
|
||||||
|
------
|
||||||
|
|
||||||
|
An example using a simple server:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# examples/server_simple.py
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
async def handle(request):
|
||||||
|
name = request.match_info.get('name', "Anonymous")
|
||||||
|
text = "Hello, " + name
|
||||||
|
return web.Response(text=text)
|
||||||
|
|
||||||
|
async def wshandle(request):
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.type == web.WSMsgType.text:
|
||||||
|
await ws.send_str("Hello, {}".format(msg.data))
|
||||||
|
elif msg.type == web.WSMsgType.binary:
|
||||||
|
await ws.send_bytes(msg.data)
|
||||||
|
elif msg.type == web.WSMsgType.close:
|
||||||
|
break
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.add_routes([web.get('/', handle),
|
||||||
|
web.get('/echo', wshandle),
|
||||||
|
web.get('/{name}', handle)])
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
web.run_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
=============
|
||||||
|
|
||||||
|
https://aiohttp.readthedocs.io/
|
||||||
|
|
||||||
|
|
||||||
|
Demos
|
||||||
|
=====
|
||||||
|
|
||||||
|
https://github.com/aio-libs/aiohttp-demos
|
||||||
|
|
||||||
|
|
||||||
|
External links
|
||||||
|
==============
|
||||||
|
|
||||||
|
* `Third party libraries
|
||||||
|
<http://aiohttp.readthedocs.io/en/latest/third_party.html>`_
|
||||||
|
* `Built with aiohttp
|
||||||
|
<http://aiohttp.readthedocs.io/en/latest/built_with.html>`_
|
||||||
|
* `Powered by aiohttp
|
||||||
|
<http://aiohttp.readthedocs.io/en/latest/powered_by.html>`_
|
||||||
|
|
||||||
|
Feel free to make a Pull Request for adding your link to these pages!
|
||||||
|
|
||||||
|
|
||||||
|
Communication channels
|
||||||
|
======================
|
||||||
|
|
||||||
|
*aio-libs Discussions*: https://github.com/aio-libs/aiohttp/discussions
|
||||||
|
|
||||||
|
*Matrix*: `#aio-libs:matrix.org <https://matrix.to/#/#aio-libs:matrix.org>`_
|
||||||
|
|
||||||
|
We support `Stack Overflow
|
||||||
|
<https://stackoverflow.com/questions/tagged/aiohttp>`_.
|
||||||
|
Please add *aiohttp* tag to your question there.
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
============
|
||||||
|
|
||||||
|
- attrs_
|
||||||
|
- multidict_
|
||||||
|
- yarl_
|
||||||
|
- frozenlist_
|
||||||
|
|
||||||
|
Optionally you may install the aiodns_ library (highly recommended for sake of speed).
|
||||||
|
|
||||||
|
.. _aiodns: https://pypi.python.org/pypi/aiodns
|
||||||
|
.. _attrs: https://github.com/python-attrs/attrs
|
||||||
|
.. _multidict: https://pypi.python.org/pypi/multidict
|
||||||
|
.. _frozenlist: https://pypi.org/project/frozenlist/
|
||||||
|
.. _yarl: https://pypi.python.org/pypi/yarl
|
||||||
|
.. _async-timeout: https://pypi.python.org/pypi/async_timeout
|
||||||
|
|
||||||
|
License
|
||||||
|
=======
|
||||||
|
|
||||||
|
``aiohttp`` is offered under the Apache 2 license.
|
||||||
|
|
||||||
|
|
||||||
|
Keepsafe
|
||||||
|
========
|
||||||
|
|
||||||
|
The aiohttp community would like to thank Keepsafe
|
||||||
|
(https://www.getkeepsafe.com) for its support in the early days of
|
||||||
|
the project.
|
||||||
|
|
||||||
|
|
||||||
|
Source code
|
||||||
|
===========
|
||||||
|
|
||||||
|
The latest developer version is available in a GitHub repository:
|
||||||
|
https://github.com/aio-libs/aiohttp
|
||||||
|
|
||||||
|
Benchmarks
|
||||||
|
==========
|
||||||
|
|
||||||
|
If you are interested in efficiency, the AsyncIO community maintains a
|
||||||
|
list of benchmarks on the official wiki:
|
||||||
|
https://github.com/python/asyncio/wiki/Benchmarks
|
||||||
|
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/matrix/aio-libs:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat
|
||||||
|
:target: https://matrix.to/#/%23aio-libs:matrix.org
|
||||||
|
:alt: Matrix Room — #aio-libs:matrix.org
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/matrix/aio-libs-space:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs-space%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat
|
||||||
|
:target: https://matrix.to/#/%23aio-libs-space:matrix.org
|
||||||
|
:alt: Matrix Space — #aio-libs-space:matrix.org
|
||||||
|
|
||||||
|
.. image:: https://insights.linuxfoundation.org/api/badge/health-score?project=aiohttp
|
||||||
|
:target: https://insights.linuxfoundation.org/project/aiohttp
|
||||||
|
:alt: LFX Health Score
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
aiohttp-3.13.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
aiohttp-3.13.3.dist-info/METADATA,sha256=CQROZCStho-eb7xiFIuAzj30JuupEU_jHpYDFiG_HhM,8145
|
||||||
|
aiohttp-3.13.3.dist-info/RECORD,,
|
||||||
|
aiohttp-3.13.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
aiohttp-3.13.3.dist-info/WHEEL,sha256=DxRnWQz-Kp9-4a4hdDHsSv0KUC3H7sN9Nbef3-8RjXU,190
|
||||||
|
aiohttp-3.13.3.dist-info/licenses/LICENSE.txt,sha256=n4DQ2311WpQdtFchcsJw7L2PCCuiFd3QlZhZQu2Uqes,588
|
||||||
|
aiohttp-3.13.3.dist-info/licenses/vendor/llhttp/LICENSE,sha256=68qFTgE0zSVtZzYnwgSZ9CV363S6zwi58ltianPJEnc,1105
|
||||||
|
aiohttp-3.13.3.dist-info/top_level.txt,sha256=iv-JIaacmTl-hSho3QmphcKnbRRYx1st47yjz_178Ro,8
|
||||||
|
aiohttp/.hash/_cparser.pxd.hash,sha256=pjs-sEXNw_eijXGAedwG-BHnlFp8B7sOCgUagIWaU2A,121
|
||||||
|
aiohttp/.hash/_find_header.pxd.hash,sha256=_mbpD6vM-CVCKq3ulUvsOAz5Wdo88wrDzfpOsMQaMNA,125
|
||||||
|
aiohttp/.hash/_http_parser.pyx.hash,sha256=RKkD9x-EhXksvXrpCaTNWYtffb52urLvuTnxbTN2Lmw,125
|
||||||
|
aiohttp/.hash/_http_writer.pyx.hash,sha256=9txOh7t7c3y-vLmiuEY5dltmXvEo0CYyU4U853yyv9E,125
|
||||||
|
aiohttp/.hash/hdrs.py.hash,sha256=v6IaKbsxjsdQxBzhb5AjP0x_9G3rUe84D7avf7AI4cs,116
|
||||||
|
aiohttp/__init__.py,sha256=QWssFaD-DaFFcwP36lLUQzRmlSZ5KxivJBU-yg5C1wg,8302
|
||||||
|
aiohttp/__pycache__/__init__.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/_cookie_helpers.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/abc.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/base_protocol.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/client.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/client_exceptions.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/client_middleware_digest_auth.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/client_middlewares.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/client_proto.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/client_reqrep.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/client_ws.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/compression_utils.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/connector.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/cookiejar.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/formdata.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/hdrs.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/helpers.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/http.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/http_exceptions.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/http_parser.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/http_websocket.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/http_writer.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/log.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/multipart.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/payload.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/payload_streamer.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/pytest_plugin.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/resolver.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/streams.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/tcp_helpers.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/test_utils.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/tracing.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/typedefs.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_app.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_exceptions.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_fileresponse.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_log.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_middlewares.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_protocol.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_request.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_response.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_routedef.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_runner.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_server.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_urldispatcher.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/web_ws.cpython-312.pyc,,
|
||||||
|
aiohttp/__pycache__/worker.cpython-312.pyc,,
|
||||||
|
aiohttp/_cookie_helpers.py,sha256=_p7y-B8OCAk7FLjByiuwFIpDLGuNoJn3_vixzymAFnE,13659
|
||||||
|
aiohttp/_cparser.pxd,sha256=UnbUYCHg4NdXfgyRVYAMv2KTLWClB4P-xCrvtj_r7ew,4295
|
||||||
|
aiohttp/_find_header.pxd,sha256=0GfwFCPN2zxEKTO1_MA5sYq2UfzsG8kcV3aTqvwlz3g,68
|
||||||
|
aiohttp/_headers.pxi,sha256=n701k28dVPjwRnx5j6LpJhLTfj7dqu2vJt7f0O60Oyg,2007
|
||||||
|
aiohttp/_http_parser.cpython-312-x86_64-linux-gnu.so,sha256=WZP45rtTvKwOq_1uXO_1L84Kz6I0AnqYZn0b5L-6HkA,2833152
|
||||||
|
aiohttp/_http_parser.pyx,sha256=-YI8YIY4uKd_7Bwr0o3FwEPwjHdexZ5-Ji3XS067c4Q,28261
|
||||||
|
aiohttp/_http_writer.cpython-312-x86_64-linux-gnu.so,sha256=mWC-4rsbntVD1V5ZEKEpSW_sm63V0XRe1fDR0lygipo,539144
|
||||||
|
aiohttp/_http_writer.pyx,sha256=VlFEBM6HoVv8a0AAJtc6JwFlsv2-cDE8-gB94p3dfhQ,4664
|
||||||
|
aiohttp/_websocket/.hash/mask.pxd.hash,sha256=Y0zBddk_ck3pi9-BFzMcpkcvCKvwvZ4GTtZFb9u1nxQ,128
|
||||||
|
aiohttp/_websocket/.hash/mask.pyx.hash,sha256=90owpXYM8_kIma4KUcOxhWSk-Uv4NVMBoCYeFM1B3d0,128
|
||||||
|
aiohttp/_websocket/.hash/reader_c.pxd.hash,sha256=5xf3oobk6vx4xbJm-xtZ1_QufB8fYFtLQV2MNdqUc1w,132
|
||||||
|
aiohttp/_websocket/__init__.py,sha256=Mar3R9_vBN_Ea4lsW7iTAVXD7OKswKPGqF5xgSyt77k,44
|
||||||
|
aiohttp/_websocket/__pycache__/__init__.cpython-312.pyc,,
|
||||||
|
aiohttp/_websocket/__pycache__/helpers.cpython-312.pyc,,
|
||||||
|
aiohttp/_websocket/__pycache__/models.cpython-312.pyc,,
|
||||||
|
aiohttp/_websocket/__pycache__/reader.cpython-312.pyc,,
|
||||||
|
aiohttp/_websocket/__pycache__/reader_c.cpython-312.pyc,,
|
||||||
|
aiohttp/_websocket/__pycache__/reader_py.cpython-312.pyc,,
|
||||||
|
aiohttp/_websocket/__pycache__/writer.cpython-312.pyc,,
|
||||||
|
aiohttp/_websocket/helpers.py,sha256=P-XLv8IUaihKzDenVUqfKU5DJbWE5HvG8uhvUZK8Ic4,5038
|
||||||
|
aiohttp/_websocket/mask.cpython-312-x86_64-linux-gnu.so,sha256=EpRwPJm1K1yavMCd9llAWdT4AsqKx_QEN0rb0eJH_Kc,263512
|
||||||
|
aiohttp/_websocket/mask.pxd,sha256=sBmZ1Amym9kW4Ge8lj1fLZ7mPPya4LzLdpkQExQXv5M,112
|
||||||
|
aiohttp/_websocket/mask.pyx,sha256=BHjOtV0O0w7xp9p0LNADRJvGmgfPn9sGeJvSs0fL__4,1397
|
||||||
|
aiohttp/_websocket/models.py,sha256=XAzjs_8JYszWXIgZ6R3ZRrF-tX9Q_6LiD49WRYojopM,2121
|
||||||
|
aiohttp/_websocket/reader.py,sha256=eC4qS0c5sOeQ2ebAHLaBpIaTVFaSKX79pY2xvh3Pqyw,1030
|
||||||
|
aiohttp/_websocket/reader_c.cpython-312-x86_64-linux-gnu.so,sha256=GYd-y-IkGkOIp3vmma5BmZgmG9Py9fLTybcmMWyHNf0,1822128
|
||||||
|
aiohttp/_websocket/reader_c.pxd,sha256=nl_njtDrzlQU0rjgGGjZDB-swguE0tX_bCPobkShVa4,2625
|
||||||
|
aiohttp/_websocket/reader_c.py,sha256=V5YtZ2gj2BjE2Q-W9sR_MdAl1VAm1pB7ZjozVJcOpbg,18868
|
||||||
|
aiohttp/_websocket/reader_py.py,sha256=V5YtZ2gj2BjE2Q-W9sR_MdAl1VAm1pB7ZjozVJcOpbg,18868
|
||||||
|
aiohttp/_websocket/writer.py,sha256=2OvSktPmNh_g20h1cXJt2Xu8u6IvswnPjdur7OwBbJk,11261
|
||||||
|
aiohttp/abc.py,sha256=M66F4S6m00bIEn7y4ha_XLTMDmVQ9dPihfOVB0pGfOo,7149
|
||||||
|
aiohttp/base_protocol.py,sha256=Tp8cxUPQvv9kUPk3w6lAzk6d2MAzV3scwI_3Go3C47c,3025
|
||||||
|
aiohttp/client.py,sha256=fOQfwcIUL1NGAVRV4DDj6-wipBzeD8KZpmzhO-LLKp4,58357
|
||||||
|
aiohttp/client_exceptions.py,sha256=uyKbxI2peZhKl7lELBMx3UeusNkfpemPWpGFq0r6JeM,11367
|
||||||
|
aiohttp/client_middleware_digest_auth.py,sha256=G5JM9YtzL9AWklz6NP28xEOBeAvrAZgDzU657JqO4qs,17627
|
||||||
|
aiohttp/client_middlewares.py,sha256=kP5N9CMzQPMGPIEydeVUiLUTLsw8Vl8Gr4qAWYdu3vM,1918
|
||||||
|
aiohttp/client_proto.py,sha256=56_WtLStZGBFPYKzgEgY6v24JkhV1y6JEmmuxeJT2So,12110
|
||||||
|
aiohttp/client_reqrep.py,sha256=eEREDrZ0M8ZFTt1wjHduR-P8_sm40K65gNz-iMGYask,53391
|
||||||
|
aiohttp/client_ws.py,sha256=1CIjIXwyzOMIYw6AjUES4-qUwbyVHW1seJKQfg_Rta8,15109
|
||||||
|
aiohttp/compression_utils.py,sha256=hJ2LXhN2OWukFHm5b78TJFGKcAiL2kthi9Sf5PRYO-U,11738
|
||||||
|
aiohttp/connector.py,sha256=vT22BNuCDtbadE1Uq7HC7zpOWCHMxI4n3PtCz7zZZkw,69004
|
||||||
|
aiohttp/cookiejar.py,sha256=e28ZMQwJ5P0vbPX1OX4Se7-k3zeGvocFEqzGhwpG53k,18922
|
||||||
|
aiohttp/formdata.py,sha256=xqYMbUo1qoLYPuzY92XeR4pyEe-w-DNcToARDF3GUhA,6384
|
||||||
|
aiohttp/hdrs.py,sha256=2rj5MyA-6yRdYPhW5UKkW4iNWhEAlGIOSBH5D4FmKNE,5111
|
||||||
|
aiohttp/helpers.py,sha256=Q1307PCEnWz4RP8crUw8dk58c0YF2Ei3JywkKfRxz5E,30629
|
||||||
|
aiohttp/http.py,sha256=8o8j8xH70OWjnfTWA9V44NR785QPxEPrUtzMXiAVpwc,1842
|
||||||
|
aiohttp/http_exceptions.py,sha256=BjIxD4LtrQgytqoR5lOI9zAttNmSygRgksUsMRy7sss,3069
|
||||||
|
aiohttp/http_parser.py,sha256=z6djZDOUs7hdPzplTEsAVyz0of-rQAwT7xz8OpXhnuY,38177
|
||||||
|
aiohttp/http_websocket.py,sha256=8VXFKw6KQUEmPg48GtRMB37v0gTK7A0inoxXuDxMZEc,842
|
||||||
|
aiohttp/http_writer.py,sha256=fbRtKPYSqRbtAdr_gqpjF2-4sI1ESL8dPDF-xY_mAMY,12446
|
||||||
|
aiohttp/log.py,sha256=BbNKx9e3VMIm0xYjZI0IcBBoS7wjdeIeSaiJE7-qK2g,325
|
||||||
|
aiohttp/multipart.py,sha256=326npYdWxYI3raoRfmpBeUV_ef3-LRn8sV9WqcIOoPk,40482
|
||||||
|
aiohttp/payload.py,sha256=O6nsYNULL7AeM2cyJ6TYX73ncVnL5xJwt5AegxwMKqw,40874
|
||||||
|
aiohttp/payload_streamer.py,sha256=ZzEYyfzcjGWkVkK3XR2pBthSCSIykYvY3Wr5cGQ2eTc,2211
|
||||||
|
aiohttp/py.typed,sha256=sow9soTwP9T_gEAQSVh7Gb8855h04Nwmhs2We-JRgZM,7
|
||||||
|
aiohttp/pytest_plugin.py,sha256=z4XwqmsKdyJCKxbGiA5kFf90zcedvomqk4RqjZbhKNk,12901
|
||||||
|
aiohttp/resolver.py,sha256=gsrfUpFf8iHlcHfJvY-1fiBHW3PRvRVNb5lNZBg3zlY,10031
|
||||||
|
aiohttp/streams.py,sha256=rlwL7ek6CkMMYil_e_EokWv26uHmtzi3lKqlnLNrXCc,23666
|
||||||
|
aiohttp/tcp_helpers.py,sha256=BSadqVWaBpMFDRWnhaaR941N9MiDZ7bdTrxgCb0CW-M,961
|
||||||
|
aiohttp/test_utils.py,sha256=ZJSzZWjC76KSbtwddTKcP6vHpUl_ozfAf3F93ewmHRU,23016
|
||||||
|
aiohttp/tracing.py,sha256=-6aaW6l0J9uJD45LzR4cijYH0j62pt0U_nn_aVzFku4,14558
|
||||||
|
aiohttp/typedefs.py,sha256=wUlqwe9Mw9W8jT3HsYJcYk00qP3EMPz3nTkYXmeNN48,1657
|
||||||
|
aiohttp/web.py,sha256=JzSNmejg5G6YeFAnkIgZfytqbU86sNu844yYKmoUpqs,17852
|
||||||
|
aiohttp/web_app.py,sha256=lGU_aAMN-h3wy-LTTHi6SeKH8ydt1G51BXcCspgD5ZA,19452
|
||||||
|
aiohttp/web_exceptions.py,sha256=7nIuiwhZ39vJJ9KrWqArA5QcWbUdqkz2CLwEpJapeN8,10360
|
||||||
|
aiohttp/web_fileresponse.py,sha256=Xzau8EMrWNrFg3u46h4UEteg93G4zYq94CU6vy0HiqE,16362
|
||||||
|
aiohttp/web_log.py,sha256=rX5D7xLOX2B6BMdiZ-chme_KfJfW5IXEoFwLfkfkajs,7865
|
||||||
|
aiohttp/web_middlewares.py,sha256=sFI0AgeNjdyAjuz92QtMIpngmJSOxrqe2Jfbs4BNUu0,4165
|
||||||
|
aiohttp/web_protocol.py,sha256=6s9dMzmaqW77bzM1T111uGNSLFo6gNmfDg7XzYnA8xk,27010
|
||||||
|
aiohttp/web_request.py,sha256=KqrOp6AeWB5e6tKrG55Lo7Zbwq49DxdrKniuW2t2u04,29849
|
||||||
|
aiohttp/web_response.py,sha256=PKcziNU4LmftXqKVvoRMrAbOeVClpSN-iznHsiWezmU,29341
|
||||||
|
aiohttp/web_routedef.py,sha256=VT1GAx6BrawoDh5RwBwBu5wSABSqgWwAe74AUCyZAEo,6110
|
||||||
|
aiohttp/web_runner.py,sha256=v1G1nKiOOQgFnTSR4IMc6I9ReEFDMaHtMLvO_roDM-A,11786
|
||||||
|
aiohttp/web_server.py,sha256=-9WDKUAiR9ll-rSdwXSqG6YjaoW79d1R4y0BGSqgUMA,2888
|
||||||
|
aiohttp/web_urldispatcher.py,sha256=JM-TlriKCNbTLNL43Ra9sdZ0zChxZmIEYQM6ZpbyjI4,44290
|
||||||
|
aiohttp/web_ws.py,sha256=lItgmyatkXh0M6EY7JoZnSZkUl6R0wv8B88X4ILqQbU,22739
|
||||||
|
aiohttp/worker.py,sha256=zT0iWN5Xze194bO6_VjHou0x7lR_k0MviN6Kadnk22g,8152
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: setuptools (80.9.0)
|
||||||
|
Root-Is-Purelib: false
|
||||||
|
Tag: cp312-cp312-manylinux_2_17_x86_64
|
||||||
|
Tag: cp312-cp312-manylinux2014_x86_64
|
||||||
|
Tag: cp312-cp312-manylinux_2_28_x86_64
|
||||||
|
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
Copyright aio-libs contributors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
This software is licensed under the MIT License.
|
||||||
|
|
||||||
|
Copyright Fedor Indutny, 2018.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
copy of this software and associated documentation files (the
|
||||||
|
"Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||||
|
persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included
|
||||||
|
in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||||
|
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||||
|
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
aiohttp
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
5276d46021e0e0d7577e0c9155800cbf62932d60a50783fec42aefb63febedec /home/runner/work/aiohttp/aiohttp/aiohttp/_cparser.pxd
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
d067f01423cddb3c442933b5fcc039b18ab651fcec1bc91c577693aafc25cf78 /home/runner/work/aiohttp/aiohttp/aiohttp/_find_header.pxd
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
f9823c608638b8a77fec1c2bd28dc5c043f08c775ec59e7e262dd74b4ebb7384 /home/runner/work/aiohttp/aiohttp/aiohttp/_http_parser.pyx
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
56514404ce87a15bfc6b400026d73a270165b2fdbe70313cfa007de29ddd7e14 /home/runner/work/aiohttp/aiohttp/aiohttp/_http_writer.pyx
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
dab8f933203eeb245d60f856e542a45b888d5a110094620e4811f90f816628d1 /home/runner/work/aiohttp/aiohttp/aiohttp/hdrs.py
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
__version__ = "3.13.3"
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Tuple
|
||||||
|
|
||||||
|
from . import hdrs as hdrs
|
||||||
|
from .client import (
|
||||||
|
BaseConnector,
|
||||||
|
ClientConnectionError,
|
||||||
|
ClientConnectionResetError,
|
||||||
|
ClientConnectorCertificateError,
|
||||||
|
ClientConnectorDNSError,
|
||||||
|
ClientConnectorError,
|
||||||
|
ClientConnectorSSLError,
|
||||||
|
ClientError,
|
||||||
|
ClientHttpProxyError,
|
||||||
|
ClientOSError,
|
||||||
|
ClientPayloadError,
|
||||||
|
ClientProxyConnectionError,
|
||||||
|
ClientRequest,
|
||||||
|
ClientResponse,
|
||||||
|
ClientResponseError,
|
||||||
|
ClientSession,
|
||||||
|
ClientSSLError,
|
||||||
|
ClientTimeout,
|
||||||
|
ClientWebSocketResponse,
|
||||||
|
ClientWSTimeout,
|
||||||
|
ConnectionTimeoutError,
|
||||||
|
ContentTypeError,
|
||||||
|
Fingerprint,
|
||||||
|
InvalidURL,
|
||||||
|
InvalidUrlClientError,
|
||||||
|
InvalidUrlRedirectClientError,
|
||||||
|
NamedPipeConnector,
|
||||||
|
NonHttpUrlClientError,
|
||||||
|
NonHttpUrlRedirectClientError,
|
||||||
|
RedirectClientError,
|
||||||
|
RequestInfo,
|
||||||
|
ServerConnectionError,
|
||||||
|
ServerDisconnectedError,
|
||||||
|
ServerFingerprintMismatch,
|
||||||
|
ServerTimeoutError,
|
||||||
|
SocketTimeoutError,
|
||||||
|
TCPConnector,
|
||||||
|
TooManyRedirects,
|
||||||
|
UnixConnector,
|
||||||
|
WSMessageTypeError,
|
||||||
|
WSServerHandshakeError,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
from .client_middleware_digest_auth import DigestAuthMiddleware
|
||||||
|
from .client_middlewares import ClientHandlerType, ClientMiddlewareType
|
||||||
|
from .compression_utils import set_zlib_backend
|
||||||
|
from .connector import (
|
||||||
|
AddrInfoType as AddrInfoType,
|
||||||
|
SocketFactoryType as SocketFactoryType,
|
||||||
|
)
|
||||||
|
from .cookiejar import CookieJar as CookieJar, DummyCookieJar as DummyCookieJar
|
||||||
|
from .formdata import FormData as FormData
|
||||||
|
from .helpers import BasicAuth, ChainMapProxy, ETag
|
||||||
|
from .http import (
|
||||||
|
HttpVersion as HttpVersion,
|
||||||
|
HttpVersion10 as HttpVersion10,
|
||||||
|
HttpVersion11 as HttpVersion11,
|
||||||
|
WebSocketError as WebSocketError,
|
||||||
|
WSCloseCode as WSCloseCode,
|
||||||
|
WSMessage as WSMessage,
|
||||||
|
WSMsgType as WSMsgType,
|
||||||
|
)
|
||||||
|
from .multipart import (
|
||||||
|
BadContentDispositionHeader as BadContentDispositionHeader,
|
||||||
|
BadContentDispositionParam as BadContentDispositionParam,
|
||||||
|
BodyPartReader as BodyPartReader,
|
||||||
|
MultipartReader as MultipartReader,
|
||||||
|
MultipartWriter as MultipartWriter,
|
||||||
|
content_disposition_filename as content_disposition_filename,
|
||||||
|
parse_content_disposition as parse_content_disposition,
|
||||||
|
)
|
||||||
|
from .payload import (
|
||||||
|
PAYLOAD_REGISTRY as PAYLOAD_REGISTRY,
|
||||||
|
AsyncIterablePayload as AsyncIterablePayload,
|
||||||
|
BufferedReaderPayload as BufferedReaderPayload,
|
||||||
|
BytesIOPayload as BytesIOPayload,
|
||||||
|
BytesPayload as BytesPayload,
|
||||||
|
IOBasePayload as IOBasePayload,
|
||||||
|
JsonPayload as JsonPayload,
|
||||||
|
Payload as Payload,
|
||||||
|
StringIOPayload as StringIOPayload,
|
||||||
|
StringPayload as StringPayload,
|
||||||
|
TextIOPayload as TextIOPayload,
|
||||||
|
get_payload as get_payload,
|
||||||
|
payload_type as payload_type,
|
||||||
|
)
|
||||||
|
from .payload_streamer import streamer as streamer
|
||||||
|
from .resolver import (
|
||||||
|
AsyncResolver as AsyncResolver,
|
||||||
|
DefaultResolver as DefaultResolver,
|
||||||
|
ThreadedResolver as ThreadedResolver,
|
||||||
|
)
|
||||||
|
from .streams import (
|
||||||
|
EMPTY_PAYLOAD as EMPTY_PAYLOAD,
|
||||||
|
DataQueue as DataQueue,
|
||||||
|
EofStream as EofStream,
|
||||||
|
FlowControlDataQueue as FlowControlDataQueue,
|
||||||
|
StreamReader as StreamReader,
|
||||||
|
)
|
||||||
|
from .tracing import (
|
||||||
|
TraceConfig as TraceConfig,
|
||||||
|
TraceConnectionCreateEndParams as TraceConnectionCreateEndParams,
|
||||||
|
TraceConnectionCreateStartParams as TraceConnectionCreateStartParams,
|
||||||
|
TraceConnectionQueuedEndParams as TraceConnectionQueuedEndParams,
|
||||||
|
TraceConnectionQueuedStartParams as TraceConnectionQueuedStartParams,
|
||||||
|
TraceConnectionReuseconnParams as TraceConnectionReuseconnParams,
|
||||||
|
TraceDnsCacheHitParams as TraceDnsCacheHitParams,
|
||||||
|
TraceDnsCacheMissParams as TraceDnsCacheMissParams,
|
||||||
|
TraceDnsResolveHostEndParams as TraceDnsResolveHostEndParams,
|
||||||
|
TraceDnsResolveHostStartParams as TraceDnsResolveHostStartParams,
|
||||||
|
TraceRequestChunkSentParams as TraceRequestChunkSentParams,
|
||||||
|
TraceRequestEndParams as TraceRequestEndParams,
|
||||||
|
TraceRequestExceptionParams as TraceRequestExceptionParams,
|
||||||
|
TraceRequestHeadersSentParams as TraceRequestHeadersSentParams,
|
||||||
|
TraceRequestRedirectParams as TraceRequestRedirectParams,
|
||||||
|
TraceRequestStartParams as TraceRequestStartParams,
|
||||||
|
TraceResponseChunkReceivedParams as TraceResponseChunkReceivedParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# At runtime these are lazy-loaded at the bottom of the file.
|
||||||
|
from .worker import (
|
||||||
|
GunicornUVLoopWebWorker as GunicornUVLoopWebWorker,
|
||||||
|
GunicornWebWorker as GunicornWebWorker,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__: Tuple[str, ...] = (
|
||||||
|
"hdrs",
|
||||||
|
# client
|
||||||
|
"AddrInfoType",
|
||||||
|
"BaseConnector",
|
||||||
|
"ClientConnectionError",
|
||||||
|
"ClientConnectionResetError",
|
||||||
|
"ClientConnectorCertificateError",
|
||||||
|
"ClientConnectorDNSError",
|
||||||
|
"ClientConnectorError",
|
||||||
|
"ClientConnectorSSLError",
|
||||||
|
"ClientError",
|
||||||
|
"ClientHttpProxyError",
|
||||||
|
"ClientOSError",
|
||||||
|
"ClientPayloadError",
|
||||||
|
"ClientProxyConnectionError",
|
||||||
|
"ClientResponse",
|
||||||
|
"ClientRequest",
|
||||||
|
"ClientResponseError",
|
||||||
|
"ClientSSLError",
|
||||||
|
"ClientSession",
|
||||||
|
"ClientTimeout",
|
||||||
|
"ClientWebSocketResponse",
|
||||||
|
"ClientWSTimeout",
|
||||||
|
"ConnectionTimeoutError",
|
||||||
|
"ContentTypeError",
|
||||||
|
"Fingerprint",
|
||||||
|
"FlowControlDataQueue",
|
||||||
|
"InvalidURL",
|
||||||
|
"InvalidUrlClientError",
|
||||||
|
"InvalidUrlRedirectClientError",
|
||||||
|
"NonHttpUrlClientError",
|
||||||
|
"NonHttpUrlRedirectClientError",
|
||||||
|
"RedirectClientError",
|
||||||
|
"RequestInfo",
|
||||||
|
"ServerConnectionError",
|
||||||
|
"ServerDisconnectedError",
|
||||||
|
"ServerFingerprintMismatch",
|
||||||
|
"ServerTimeoutError",
|
||||||
|
"SocketFactoryType",
|
||||||
|
"SocketTimeoutError",
|
||||||
|
"TCPConnector",
|
||||||
|
"TooManyRedirects",
|
||||||
|
"UnixConnector",
|
||||||
|
"NamedPipeConnector",
|
||||||
|
"WSServerHandshakeError",
|
||||||
|
"request",
|
||||||
|
# client_middleware
|
||||||
|
"ClientMiddlewareType",
|
||||||
|
"ClientHandlerType",
|
||||||
|
# cookiejar
|
||||||
|
"CookieJar",
|
||||||
|
"DummyCookieJar",
|
||||||
|
# formdata
|
||||||
|
"FormData",
|
||||||
|
# helpers
|
||||||
|
"BasicAuth",
|
||||||
|
"ChainMapProxy",
|
||||||
|
"DigestAuthMiddleware",
|
||||||
|
"ETag",
|
||||||
|
"set_zlib_backend",
|
||||||
|
# http
|
||||||
|
"HttpVersion",
|
||||||
|
"HttpVersion10",
|
||||||
|
"HttpVersion11",
|
||||||
|
"WSMsgType",
|
||||||
|
"WSCloseCode",
|
||||||
|
"WSMessage",
|
||||||
|
"WebSocketError",
|
||||||
|
# multipart
|
||||||
|
"BadContentDispositionHeader",
|
||||||
|
"BadContentDispositionParam",
|
||||||
|
"BodyPartReader",
|
||||||
|
"MultipartReader",
|
||||||
|
"MultipartWriter",
|
||||||
|
"content_disposition_filename",
|
||||||
|
"parse_content_disposition",
|
||||||
|
# payload
|
||||||
|
"AsyncIterablePayload",
|
||||||
|
"BufferedReaderPayload",
|
||||||
|
"BytesIOPayload",
|
||||||
|
"BytesPayload",
|
||||||
|
"IOBasePayload",
|
||||||
|
"JsonPayload",
|
||||||
|
"PAYLOAD_REGISTRY",
|
||||||
|
"Payload",
|
||||||
|
"StringIOPayload",
|
||||||
|
"StringPayload",
|
||||||
|
"TextIOPayload",
|
||||||
|
"get_payload",
|
||||||
|
"payload_type",
|
||||||
|
# payload_streamer
|
||||||
|
"streamer",
|
||||||
|
# resolver
|
||||||
|
"AsyncResolver",
|
||||||
|
"DefaultResolver",
|
||||||
|
"ThreadedResolver",
|
||||||
|
# streams
|
||||||
|
"DataQueue",
|
||||||
|
"EMPTY_PAYLOAD",
|
||||||
|
"EofStream",
|
||||||
|
"StreamReader",
|
||||||
|
# tracing
|
||||||
|
"TraceConfig",
|
||||||
|
"TraceConnectionCreateEndParams",
|
||||||
|
"TraceConnectionCreateStartParams",
|
||||||
|
"TraceConnectionQueuedEndParams",
|
||||||
|
"TraceConnectionQueuedStartParams",
|
||||||
|
"TraceConnectionReuseconnParams",
|
||||||
|
"TraceDnsCacheHitParams",
|
||||||
|
"TraceDnsCacheMissParams",
|
||||||
|
"TraceDnsResolveHostEndParams",
|
||||||
|
"TraceDnsResolveHostStartParams",
|
||||||
|
"TraceRequestChunkSentParams",
|
||||||
|
"TraceRequestEndParams",
|
||||||
|
"TraceRequestExceptionParams",
|
||||||
|
"TraceRequestHeadersSentParams",
|
||||||
|
"TraceRequestRedirectParams",
|
||||||
|
"TraceRequestStartParams",
|
||||||
|
"TraceResponseChunkReceivedParams",
|
||||||
|
# workers (imported lazily with __getattr__)
|
||||||
|
"GunicornUVLoopWebWorker",
|
||||||
|
"GunicornWebWorker",
|
||||||
|
"WSMessageTypeError",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> Tuple[str, ...]:
|
||||||
|
return __all__ + ("__doc__",)
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> object:
|
||||||
|
global GunicornUVLoopWebWorker, GunicornWebWorker
|
||||||
|
|
||||||
|
# Importing gunicorn takes a long time (>100ms), so only import if actually needed.
|
||||||
|
if name in ("GunicornUVLoopWebWorker", "GunicornWebWorker"):
|
||||||
|
try:
|
||||||
|
from .worker import GunicornUVLoopWebWorker as guv, GunicornWebWorker as gw
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
GunicornUVLoopWebWorker = guv # type: ignore[misc]
|
||||||
|
GunicornWebWorker = gw # type: ignore[misc]
|
||||||
|
return guv if name == "GunicornUVLoopWebWorker" else gw
|
||||||
|
|
||||||
|
raise AttributeError(f"module {__name__} has no attribute {name}")
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user