feat: v2.2 - 简化版工具生成接口 + TOOL_API_KEY 自动注入

新增功能:
- 新增 /external-tools/generate-simple 简化版接口,默认使用 AI 辅助
- AuthConfig 支持 token 字段(与 key 等效)
- TOOL_API_KEY 环境变量自动注入到 K8s Deployment
- use_ai 参数默认改为 True

修复:
- 修复 gitee_manager.py 缩进错误

文档:
- 更新 EXTERNAL_TOOL_API.md 文档到 v2.2
This commit is contained in:
zhanggangyong
2026-01-30 17:41:12 +00:00
parent 5a2f9a7ffc
commit 703f4b4c21
6 changed files with 1007 additions and 57 deletions
+411 -19
View File
@@ -101,12 +101,111 @@ class AgentCodeGenerator:
}
return type_map.get(json_type, "Any")
def generate_tool_code(self, tool_config: dict) -> str:
async def generate_tool_code_with_ai(self, tool_config: dict, api_key: str = None) -> str:
"""
根据工具配置生成 Pydantic 工具代码
使用 AI 理解工具定义并生成代码(更智能,支持复杂场景如 URL 拼接)
Args:
tool_config: 包含 name, url, method, auth, request_params 等
tool_config: 工具配置
api_key: LLM API Key
Returns:
AI 生成的 Python 代码
"""
import httpx
# 使用实例配置或环境变量(默认使用 claude-sonnet 生成高质量代码)
llm_url = self.llm_base_url
llm_model = self.llm_model # 默认: taiji/claude-sonnet-4-5
llm_key = api_key or self.llm_api_key
if not llm_key:
# 如果没有 API Key,回退到模板生成
logger.warning("未提供 LLM API Key,使用模板生成")
return self.generate_tool_code(tool_config)
name = tool_config.get("name", "custom_tool")
func_name = self._convert_name_to_python(name)
# 构建 prompt
prompt = f"""你是一个 Python 代码生成专家。根据以下工具定义,生成一个异步 Python 函数。
## 工具定义
- 名称: {tool_config.get("name")}
- 描述: {tool_config.get("description")}
- API URL: {tool_config.get("url")}
- HTTP 方法: {tool_config.get("method", "GET")}
- 认证方式: {json.dumps(tool_config.get("auth", {}), ensure_ascii=False)}
- 请求参数: {json.dumps(tool_config.get("request_params", tool_config.get("input_schema", {})), ensure_ascii=False)}
- 请求体: {json.dumps(tool_config.get("request_body", {}), ensure_ascii=False)}
## 重要提示
1. 如果 URL 像 `https://r.jina.ai/` 这样需要将参数拼接到路径中(如 `https://r.jina.ai/{{target_url}}`),请正确处理 URL 拼接
2. 如果认证是 bearer token,使用环境变量 `TOOL_API_KEY` 获取
3. 函数必须是 async def,返回 JSON 字符串
4. 使用 httpx 作为 HTTP 客户端
5. 包含完整的错误处理
## 输出格式
只输出 Python 代码,不要其他解释。代码格式如下:
```python
\"\"\"
工具: {{name}}
描述: {{description}}
\"\"\"
import os
import json
from typing import Optional, Any
import httpx
async def {func_name}(...) -> str:
...
```"""
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{llm_url}/chat/completions",
headers={
"Authorization": f"Bearer {llm_key}",
"Content-Type": "application/json"
},
json={
"model": llm_model,
"messages": [
{"role": "system", "content": "你是一个专业的 Python 代码生成器,只输出代码,不要解释。"},
{"role": "user", "content": prompt}
],
"temperature": 0.2,
"max_tokens": 2000
}
)
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"]
# 提取代码块
if "```python" in content:
code = content.split("```python")[1].split("```")[0].strip()
elif "```" in content:
code = content.split("```")[1].split("```")[0].strip()
else:
code = content.strip()
logger.info(f"✅ AI 成功生成工具代码: {name}")
return code
except Exception as e:
logger.error(f"AI 生成代码失败: {e},回退到模板生成")
return self.generate_tool_code(tool_config)
def generate_tool_code(self, tool_config: dict) -> str:
"""
根据工具配置生成 Pydantic 工具代码(模板方式)
Args:
tool_config: 包含 name, url, method, auth, request_params/input_schema 等
Returns:
生成的 Python 代码字符串
@@ -117,20 +216,23 @@ class AgentCodeGenerator:
url = tool_config.get("url", "")
method = tool_config.get("method", "GET").upper()
auth = tool_config.get("auth", {})
request_params = tool_config.get("request_params", {})
# 兼容 request_params 和 input_schema 两种字段名
request_params = tool_config.get("request_params") or tool_config.get("input_schema") or {}
request_body = tool_config.get("request_body", {})
timeout = tool_config.get("timeout", 30)
# 构建参数
params = []
params_doc = []
required_params = []
# 支持两种格式:
# 1. {"properties": {"symbol": {...}}}
# 1. {"properties": {"symbol": {...}}, "required": ["symbol"]}
# 2. {"symbol": {...}} (直接参数格式)
param_props = request_params
if request_params and request_params.get("properties"):
param_props = request_params["properties"]
required_params = request_params.get("required", [])
elif request_params and not any(k in request_params for k in ["type", "required", "description"]):
param_props = request_params
else:
@@ -142,7 +244,8 @@ class AgentCodeGenerator:
continue
p_type = self._json_type_to_python(p_info.get("type", "string"))
p_desc = p_info.get("description", "")
is_required = p_info.get("required", False)
# 检查是否在 required 列表中
is_required = p_name in required_params or p_info.get("required", False)
default = p_info.get("default")
if is_required:
@@ -162,11 +265,14 @@ class AgentCodeGenerator:
# 构建参数字典代码
params_dict_code = ""
if param_props:
params_dict_code = "params = {"
params_dict_items = []
for p_name in param_props.keys():
if isinstance(param_props[p_name], dict):
params_dict_code += f'"{p_name}": {p_name}, '
params_dict_code = params_dict_code.rstrip(", ") + "}"
params_dict_items.append(f'"{p_name}": {p_name}')
if params_dict_items:
params_dict_code = "params = {" + ", ".join(params_dict_items) + "}"
else:
params_dict_code = "params = {}"
else:
params_dict_code = "params = {}"
@@ -175,6 +281,31 @@ class AgentCodeGenerator:
key_name = auth.get("name", "apikey")
params_dict_code += f'\n params["{key_name}"] = os.getenv("TOOL_API_KEY", "")'
# 生成 URL 代码(使用 api_url 避免与参数名冲突)
url_code = f'api_url = "{url}"'
# 根据 HTTP 方法决定参数传递方式
# POST/PUT/PATCH: 参数放到请求体 (json)
# GET/DELETE: 参数放到查询参数 (params)
if method in ["POST", "PUT", "PATCH"]:
# POST 请求:参数作为 JSON 请求体
request_code = f'''async with httpx.AsyncClient(timeout={timeout}) as client:
response = await client.request(
method="{method}",
url=api_url,
headers=headers,
json={{k: v for k, v in params.items() if v is not None}}
)'''
else:
# GET 请求:参数作为查询参数
request_code = f'''async with httpx.AsyncClient(timeout={timeout}) as client:
response = await client.request(
method="{method}",
url=api_url,
headers=headers,
params={{k: v for k, v in params.items() if v is not None}}
)'''
# 生成函数代码
code = f'''"""
工具: {name}
@@ -197,18 +328,12 @@ async def {func_name}({params_str}) -> str:
Returns:
API 响应结果 (JSON 格式)
"""
url = "{url}"
{url_code}
{auth_headers}
{params_dict_code}
try:
async with httpx.AsyncClient(timeout={timeout}) as client:
response = await client.request(
method="{method}",
url=url,
headers=headers,
params={{k: v for k, v in params.items() if v is not None}}
)
{request_code}
if response.status_code == 200:
return json.dumps({{
@@ -925,6 +1050,241 @@ async def batch_call_tools(request: MultiToolCallRequest, api_key: str = Depends
)
# ==================== 智能对话(Agent Chat)====================
# LLM 配置
LLM_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1")
LLM_MODEL = os.getenv("MODEL_NAME", "taiji/gpt-4o-mini")
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
conversation_id: Optional[str] = None
user_id: Optional[str] = None
stream: bool = False
class ChatResponse(BaseModel):
"""聊天响应"""
success: bool
message: str
tools_used: List[str] = []
conversation_id: Optional[str] = None
error: Optional[str] = None
# 对话历史存储
conversations: Dict[str, List[Dict]] = {{}}
def get_tools_description() -> str:
\"\"\"生成工具描述供 LLM 使用\"\"\"
tools_desc = []
for t in TOOL_LIST:
params = t.get("parameters", {{}})
param_desc = ", ".join([f"{{k}}: {{v.get('type', 'string')}}" for k, v in params.items()])
tools_desc.append(f"- {{t['name']}}: {{t['description']}}\\n 参数: {{param_desc or '无'}}")
return "\\n".join(tools_desc)
def build_system_prompt() -> str:
\"\"\"构建系统提示\"\"\"
tools_desc = get_tools_description()
json_example = '{{"action": "tool_call", "tool": "工具名称", "parameters": {{"参数名": "参数值"}}}}'
return f\"\"\"你是一个智能助手 {{SERVER_NAME}},可以使用以下工具来帮助用户:
{{tools_desc}}
当用户的问题需要使用工具时,请按以下 JSON 格式回复:
{{json_example}}
当不需要工具时,直接回复用户的问题。
重要规则:
1. 如果问题可以用工具解决,优先使用工具
2. 工具调用必须严格使用上述 JSON 格式
3. 参数名必须与工具定义匹配
4. 一次只调用一个工具\"\"\"
async def call_llm(messages: List[Dict], api_key: str) -> str:
\"\"\"调用 LLM - 使用请求传入的 API Key(用于计费)\"\"\"
import httpx
if not api_key or api_key in ("sk", "sk-test", "test"):
raise ValueError("请提供有效的 API Key(用于计费)")
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{{LLM_BASE_URL}}/chat/completions",
headers={{
"Authorization": f"Bearer {{api_key}}",
"Content-Type": "application/json"
}},
json={{
"model": LLM_MODEL,
"messages": messages,
"temperature": 0.3,
"max_tokens": 2000
}}
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
def parse_tool_call(response: str) -> Optional[Dict]:
\"\"\"解析 LLM 响应中的工具调用\"\"\"
# 方法1:尝试直接解析整个响应
try:
data = json.loads(response.strip())
if isinstance(data, dict) and data.get("action") == "tool_call":
return data
except json.JSONDecodeError:
pass
# 方法2:提取 JSON 块(处理 markdown 代码块)
import re
# 匹配 ```json ... ``` 或 ``` ... ```
code_block = re.search(r'```(?:json)?\\s*([\\s\\S]*?)```', response)
if code_block:
try:
data = json.loads(code_block.group(1).strip())
if isinstance(data, dict) and data.get("action") == "tool_call":
return data
except json.JSONDecodeError:
pass
# 方法3:查找 JSON 对象(从 {{ 到匹配的 }})
start = response.find('{{')
if start == -1:
start = response.find('{{"{{"') # 处理转义
if start == -1:
start = response.find('{{"action"')
if start != -1:
# 找到平衡的 }}
depth = 0
end = start
for i, c in enumerate(response[start:]):
if c == '{{':
depth += 1
elif c == '}}':
depth -= 1
if depth == 0:
end = start + i + 1
break
try:
data = json.loads(response[start:end])
if isinstance(data, dict) and data.get("action") == "tool_call":
return data
except json.JSONDecodeError:
pass
return None
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest, api_key: str = Depends(verify_api_key)):
\"\"\"
智能对话端点 - Agent 自动选择并调用工具
输入自然语言,Agent 会:
1. 理解用户意图
2. 自动选择合适的工具
3. 执行工具并返回结果
\"\"\"
effective_user_id = request.user_id or USER_ID
tools_used = []
# 获取或创建对话历史
conv_id = request.conversation_id or str(uuid.uuid4())
if conv_id not in conversations:
conversations[conv_id] = []
# 构建消息
messages = [
{{"role": "system", "content": build_system_prompt()}}
]
messages.extend(conversations[conv_id])
messages.append({{"role": "user", "content": request.message}})
try:
# 调用 LLM
llm_response = await call_llm(messages, api_key)
# 检查是否需要调用工具
tool_call = parse_tool_call(llm_response)
if tool_call and tool_call.get("tool") in TOOL_MAP:
tool_name = tool_call["tool"]
tool_params = tool_call.get("parameters", {{}})
tools_used.append(tool_name)
logger.info(f"🔧 调用工具: {{tool_name}}, 参数: {{tool_params}}")
# 执行工具调用
if effective_user_id:
handler = get_callback_handler()
with CallbackContextManager(
handler=handler,
user_id=effective_user_id,
request_id=f"chat-{{int(datetime.utcnow().timestamp())}}"
) as ctx:
ctx.add_tool(tool_name)
tool_result = await TOOL_MAP[tool_name](**tool_params)
else:
tool_result = await TOOL_MAP[tool_name](**tool_params)
# 将工具结果发送给 LLM 生成最终回复
messages.append({{"role": "assistant", "content": llm_response}})
messages.append({{"role": "user", "content": f"工具 {{tool_name}} 返回结果:{{tool_result}}\\n\\n请根据这个结果回答用户的问题。"}})
final_response = await call_llm(messages, api_key)
# 保存对话历史
conversations[conv_id].append({{"role": "user", "content": request.message}})
conversations[conv_id].append({{"role": "assistant", "content": final_response}})
return ChatResponse(
success=True,
message=final_response,
tools_used=tools_used,
conversation_id=conv_id
)
else:
# 不需要工具,直接返回 LLM 回复
conversations[conv_id].append({{"role": "user", "content": request.message}})
conversations[conv_id].append({{"role": "assistant", "content": llm_response}})
return ChatResponse(
success=True,
message=llm_response,
tools_used=[],
conversation_id=conv_id
)
except Exception as e:
logger.error(f"聊天失败: {{e}}")
import traceback
traceback.print_exc()
return ChatResponse(
success=False,
message="",
error=str(e),
conversation_id=conv_id
)
@app.get("/chat/history/{{conversation_id}}")
async def get_chat_history(conversation_id: str):
\"\"\"获取对话历史\"\"\"
if conversation_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
return {{"conversation_id": conversation_id, "messages": conversations[conversation_id]}}
@app.delete("/chat/history/{{conversation_id}}")
async def clear_chat_history(conversation_id: str):
\"\"\"清除对话历史\"\"\"
if conversation_id in conversations:
del conversations[conversation_id]
return {{"success": True, "message": "对话历史已清除"}}
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -1208,7 +1568,8 @@ class CallbackContextManager:
cpu_limit: str = "500m",
memory_request: str = "128Mi",
memory_limit: str = "512Mi",
replicas: int = 1
replicas: int = 1,
tool_api_keys: List[str] = None
) -> str:
"""
生成 Gitea Actions CI/CD 配置
@@ -1223,10 +1584,29 @@ class CallbackContextManager:
memory_request: 内存请求 (如 128Mi, 256Mi)
memory_limit: 内存限制 (如 512Mi, 1Gi)
replicas: 副本数量
tool_api_keys: 工具 API 密钥列表(将注入到容器环境变量)
"""
k8s_name = agent_name.lower().replace("_", "-").replace(" ", "-")
image_repo = f"{self.acr_namespace}/{k8s_name}"
# 生成工具 API Key 环境变量配置
tool_api_key_env = ""
if tool_api_keys:
# 如果只有一个 key,使用 TOOL_API_KEY
if len(tool_api_keys) == 1:
tool_api_key_env = f''' - name: TOOL_API_KEY
value: "{tool_api_keys[0]}"'''
else:
# 多个 key 时,使用编号
env_lines = []
for i, key in enumerate(tool_api_keys):
env_lines.append(f''' - name: TOOL_API_KEY_{i}
value: "{key}"''')
# 第一个 key 也设置为默认的 TOOL_API_KEY
env_lines.insert(0, f''' - name: TOOL_API_KEY
value: "{tool_api_keys[0]}"''')
tool_api_key_env = "\n".join(env_lines)
deploy_step = ""
if auto_deploy:
deploy_step = f'''
@@ -1301,6 +1681,7 @@ class CallbackContextManager:
value: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1"
- name: MODEL_NAME
value: "taiji/gpt-4o-mini"
{tool_api_key_env}
resources:
requests:
cpu: "{cpu_request}"
@@ -1642,6 +2023,16 @@ MIT License
# 生成 requirements.txt
files["requirements.txt"] = self.generate_requirements()
# 从工具配置中提取 API Keys
tool_api_keys = []
for tool in tools_config:
auth = tool.get("auth")
if auth:
# 兼容 token 和 key 字段
api_key = auth.get("token") or auth.get("key")
if api_key:
tool_api_keys.append(api_key)
# 生成 CI/CD 配置
files[".gitea/workflows/ci-cd.yaml"] = self.generate_gitea_action(
agent_name=k8s_name,
@@ -1650,7 +2041,8 @@ MIT License
cpu_limit=cpu_limit,
memory_request=memory_request,
memory_limit=memory_limit,
replicas=replicas
replicas=replicas,
tool_api_keys=tool_api_keys if tool_api_keys else None
)
# 生成 README
+160 -4
View File
@@ -1,6 +1,6 @@
# 外部工具 API 文档
> **版本**: 2026-01-30 v2.1
> **版本**: 2026-01-31 v2.2
> **服务地址**: http://20.212.121.126
> **规范参考**: [Agent-Manager外部工具接口规范](http://gitee.ath.cx:3000/xiaohei/taiji-AI-PAD/src/branch/feature/chenchen/Docs/Agent-Manager%E5%A4%96%E9%83%A8%E5%B7%A5%E5%85%B7%E6%8E%A5%E5%8F%A3%E8%A7%84%E8%8C%83.md)
@@ -43,6 +43,47 @@
---
## 🆕 v2.2 新增功能
### 1. 简化版工具生成接口 🚀
新增 `/external-tools/generate-simple` 接口,**默认使用 AI 辅助生成**,只需三个核心字段:
| 核心字段 | 说明 |
|----------|------|
| `url` | API 端点 URL |
| `auth` | 认证配置(支持 `token` 和 `key` 字段) |
| `request_body_schema` | 请求体 Schema(JSON Schema 格式) |
### 2. AuthConfig 增强
认证配置现在同时支持 `token` 和 `key` 字段(兼容更多使用习惯):
```json
{
"type": "bearer",
"token": "your-api-token" // 与 "key" 等效
}
```
### 3. TOOL_API_KEY 环境变量自动注入 ⭐
创建 Agent 时,系统会自动从工具配置中提取 API Key,并注入到 K8s Deployment 的环境变量中:
```yaml
env:
- name: TOOL_API_KEY
value: "your-extracted-api-key"
```
工具代码可以通过 `os.getenv("TOOL_API_KEY")` 获取。
### 4. use_ai 默认开启
`/external-tools/generate` 接口的 `use_ai` 参数现在默认为 `True`,AI 会更智能地生成工具代码。
---
## 🆕 v2.1 新增功能
### 1. 资源配置支持
@@ -127,7 +168,8 @@ X-User-ID: <user_id> # 可选,用于计费
| 序号 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 1 | `/external-tools/generate` | POST | 生成外部数据工具 |
| 1 | `/external-tools/generate` | POST | 生成外部数据工具(完整版) |
| **1.1** | **`/external-tools/generate-simple`** | **POST** | **🆕 简化版工具生成(AI 辅助,推荐)** |
| 2 | `/external-tools/{tool_ref_id}` | GET | 获取工具详情 |
| 3 | `/external-tools/{tool_ref_id}` | PUT | 更新外部数据工具 |
| 4 | `/external-tools/{tool_ref_id}` | DELETE | 删除外部数据工具 |
@@ -135,8 +177,8 @@ X-User-ID: <user_id> # 可选,用于计费
| 6 | `/external-tools/{tool_ref_id}/code` | GET | 获取生成的代码 |
| 7 | `/external-tools/` | GET | 列出所有工具 |
| 8 | `/external-tools/agents/create-with-tools` | POST | 创建带工具的 Agent |
| 9 | `/external-tools/agents/{agent_ref_id}/build-status` | GET | 🆕 查询构建状态 |
| 10 | `/external-tools/agents/{agent_ref_id}/deployment-info` | GET | 🆕 查询部署信息 |
| 9 | `/external-tools/agents/{agent_ref_id}/build-status` | GET | 查询构建状态 |
| 10 | `/external-tools/agents/{agent_ref_id}/deployment-info` | GET | 查询部署信息 |
| 11 | `/agents` | POST | 创建 Agent(支持 tool_refs 字段) |
---
@@ -253,6 +295,118 @@ curl -X POST http://20.212.121.126/external-tools/generate \
---
## 1.1️⃣ 🆕 简化版工具生成(推荐)
### 接口
```
POST /external-tools/generate-simple
```
### 功能描述
简化版工具生成接口,**默认使用 AI 辅助生成**,只需提供三个核心字段。AI 会智能理解您的配置并生成高质量的 Pydantic AI 工具代码。
### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | ✅ | 工具名称(1-100 字符) |
| url | string | ✅ | **核心字段** - API 端点 URL |
| method | string | ❌ | HTTP 方法,默认 `POST` |
| user_id | string | ❌ | 用户 ID,默认 `default` |
| auth | object | ❌ | **核心字段** - 认证配置(支持 `token` 或 `key`) |
| request_body_schema | object | ❌ | **核心字段** - 请求体 Schema(JSON Schema 格式) |
| request_params | object | ❌ | URL 查询参数定义 |
| headers | object | ❌ | 自定义请求头 |
| description | string | ❌ | 工具描述(可选,AI 会自动推断) |
| api_key | string | ❌ | LLM API Key(可选,使用系统默认) |
### 认证配置(支持两种字段名)
```json
// 方式1: 使用 token 字段
{
"type": "bearer",
"token": "your-api-token"
}
// 方式2: 使用 key 字段
{
"type": "bearer",
"key": "your-api-key"
}
```
### 请求示例
```bash
curl -X POST http://20.212.121.126/external-tools/generate-simple \
-H "Content-Type: application/json" \
-d '{
"name": "jina_reader",
"url": "https://r.jina.ai/",
"method": "POST",
"user_id": "test-user",
"auth": {
"type": "bearer",
"token": "jina_xxxxxxxxxxxxxx"
},
"request_body_schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "要爬取的网页URL"
}
},
"required": ["url"]
}
}'
```
### 响应示例
```json
{
"success": true,
"data": {
"tool_ref_id": "tool-jina_reader-d57dcc49",
"name": "jina_reader",
"description": "调用 jina_reader API,参数: url",
"url": "https://r.jina.ai/",
"method": "POST",
"has_auth": true,
"created_at": "2026-01-30T17:25:13.855295"
},
"message": "工具生成成功 (AI 辅助)"
}
```
### AI 生成的代码示例
AI 会智能理解 API 的调用方式,例如 Jina Reader API 需要将目标 URL 拼接到路径中:
```python
async def jina_reader(url: str) -> str:
"""调用 jina_reader API 爬取网页内容"""
api_key = os.getenv("TOOL_API_KEY", "default_key")
# AI 正确理解了 URL 拼接方式
api_url = f"https://r.jina.ai/{url}"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(api_url, headers=headers)
# ... 完整的错误处理
```
---
## 2️⃣ 更新外部数据工具
### 接口
@@ -778,3 +932,5 @@ curl "http://${DOMAIN}/"
|------|------|----------|
| 2026-01-29 | v1.0 | 初始版本,实现 MCP-Server 外部工具接口规范 |
| 2026-01-29 | v2.0 | 新增回调功能(计费)、多工具支持、构建状态查询、部署信息查询 |
| 2026-01-30 | v2.1 | 新增资源配置参数(cpu_request, cpu_limit, memory_request, memory_limit, replicas) |
| 2026-01-31 | v2.2 | 🆕 新增简化版工具生成接口 `/generate-simple`、AuthConfig 支持 `token` 字段、TOOL_API_KEY 环境变量自动注入、use_ai 默认开启 |
+284 -34
View File
@@ -36,6 +36,7 @@ class AuthConfig(BaseModel):
"""认证配置"""
type: str = Field(..., description="认证类型: api_key, bearer, basic")
key: Optional[str] = Field(None, description="API Key 或 Bearer Token")
token: Optional[str] = Field(None, description="Bearer Token(与 key 等效,兼容字段)")
username: Optional[str] = Field(None, description="Basic Auth 用户名")
password: Optional[str] = Field(None, description="Basic Auth 密码")
in_location: Optional[str] = Field("header", alias="in", description="API Key 位置: header, query")
@@ -43,6 +44,10 @@ class AuthConfig(BaseModel):
class Config:
populate_by_name = True
def get_token_or_key(self) -> Optional[str]:
"""获取 token 或 key(兼容两种字段名)"""
return self.token or self.key
class RetryConfig(BaseModel):
@@ -66,9 +71,38 @@ class GenerateToolRequest(BaseModel):
auth: Optional[AuthConfig] = Field(None, description="认证配置")
request_params: Optional[Dict] = Field(None, description="URL 查询参数定义(JSON Schema 格式)")
request_body: Optional[Dict] = Field(None, description="请求体定义(JSON Schema 格式)")
input_schema: Optional[Dict] = Field(None, description="输入参数定义(兼容字段,等同于 request_params)")
response_mapping: Optional[Dict] = Field(None, description="响应字段映射")
timeout: int = Field(30, description="超时时间(秒),默认 30")
retry: Optional[RetryConfig] = Field(None, description="重试配置")
use_ai: bool = Field(True, description="是否使用 AI 智能生成代码(默认开启,支持复杂场景如 URL 拼接)")
api_key: Optional[str] = Field(None, description="LLM API Key(use_ai=true 时需要)")
class SimpleGenerateToolRequest(BaseModel):
"""
简化版工具生成请求
只需要三个核心字段:url、auth、request_body_schema
默认使用 AI 辅助生成
"""
name: str = Field(..., min_length=1, max_length=100, description="工具名称")
description: Optional[str] = Field(None, description="工具描述(可选,AI 会自动推断)")
url: str = Field(..., description="API 端点 URL")
method: str = Field("POST", description="HTTP 方法,默认 POST")
user_id: str = Field("default", description="用户 ID")
# 核心三要素
auth: Optional[AuthConfig] = Field(None, description="认证配置")
request_body_schema: Optional[Dict] = Field(None, description="请求体 Schema (JSON Schema 格式)")
request_params: Optional[Dict] = Field(None, description="URL 查询参数定义(可选)")
headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头(可选)")
# AI 生成相关
api_key: Optional[str] = Field(None, description="LLM API Key(可选,使用系统默认)")
class Config:
# 允许额外字段,让 AI 可以处理任意用户输入
extra = "allow"
class UpdateToolRequest(BaseModel):
@@ -157,6 +191,17 @@ async def generate_tool(request: GenerateToolRequest):
# 生成唯一 tool_ref_id
tool_ref_id = f"tool-{request.name.lower().replace(' ', '-')}-{uuid.uuid4().hex[:8]}"
# 合并 request_params 和 input_schema(兼容两种字段名)
merged_params = request.request_params or request.input_schema
# 处理认证配置 - 兼容 token 和 key 字段
auth_config = None
if request.auth:
auth_config = request.auth.model_dump(by_alias=True)
# 兼容 token 字段:如果用户使用 token,将其映射到 key
if auth_config.get("token") and not auth_config.get("key"):
auth_config["key"] = auth_config["token"]
# 构建工具配置
tool_config = {
"name": request.name,
@@ -164,8 +209,9 @@ async def generate_tool(request: GenerateToolRequest):
"url": request.url,
"method": request.method.upper(),
"headers": request.headers,
"auth": request.auth.model_dump(by_alias=True) if request.auth else None,
"request_params": request.request_params,
"auth": auth_config,
"request_params": merged_params,
"input_schema": merged_params, # 保留两种格式供 AI 理解
"request_body": request.request_body,
"response_mapping": request.response_mapping,
"timeout": request.timeout,
@@ -173,7 +219,29 @@ async def generate_tool(request: GenerateToolRequest):
}
# 生成 Pydantic AI 工具代码
tool_code = agent_code_generator.generate_tool_code(tool_config)
if request.use_ai:
# 使用 AI 智能生成(支持复杂场景)
import asyncio
import concurrent.futures
logger.info(f"🤖 使用 AI 生成工具代码: {request.name}")
# 在新线程中运行异步代码
def run_async():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
agent_code_generator.generate_tool_code_with_ai(tool_config, request.api_key)
)
finally:
loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_async)
tool_code = future.result(timeout=90)
else:
# 使用模板生成
tool_code = agent_code_generator.generate_tool_code(tool_config)
# 存储工具配置和代码
save_result = tool_storage.save_tool(
@@ -217,6 +285,151 @@ async def generate_tool(request: GenerateToolRequest):
}
@router.post("/generate-simple")
async def generate_tool_simple(request: SimpleGenerateToolRequest):
"""
🚀 简化版工具生成接口 - 默认使用 AI 辅助
只需要提供三个核心字段:
1. url - API 端点 URL
2. auth - 认证配置(bearer/api_key/basic)
3. request_body_schema - 请求体 Schema
AI 会智能理解您的配置并生成高质量的 Pydantic AI 工具代码。
示例请求:
```json
{
"name": "jina_reader",
"url": "https://r.jina.ai/",
"method": "POST",
"user_id": "test-user",
"auth": {
"type": "bearer",
"token": "your-token-here"
},
"request_body_schema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "要爬取的网页URL"}
},
"required": ["url"]
}
}
```
"""
try:
# 验证 URL 格式
if not request.url.startswith(("http://", "https://")):
return {
"success": False,
"error": "invalid_url",
"message": "URL 必须以 http:// 或 https:// 开头"
}
# 生成唯一 tool_ref_id
tool_ref_id = f"tool-{request.name.lower().replace(' ', '-')}-{uuid.uuid4().hex[:8]}"
# 处理认证配置 - 兼容 token 和 key 字段
auth_config = None
if request.auth:
auth_config = request.auth.model_dump(by_alias=True)
# 兼容 token 字段:如果用户使用 token,将其映射到 key
if auth_config.get("token") and not auth_config.get("key"):
auth_config["key"] = auth_config["token"]
# 自动推断描述(如果未提供)
description = request.description
if not description:
description = f"调用 {request.name} API"
if request.request_body_schema:
props = request.request_body_schema.get("properties", {})
if props:
param_names = list(props.keys())[:3]
description += f",参数: {', '.join(param_names)}"
# 构建完整的工具配置 - 保留用户原始输入供 AI 理解
tool_config = {
"name": request.name,
"description": description,
"url": request.url,
"method": request.method.upper(),
"headers": request.headers,
"auth": auth_config,
# 支持两种参数格式
"request_body": request.request_body_schema,
"request_params": request.request_params,
"input_schema": request.request_body_schema or request.request_params,
"timeout": 30,
# 保存原始请求供 AI 参考(可能包含额外字段)
"_original_request": request.model_dump(exclude_unset=False)
}
# 🤖 默认使用 AI 生成(更智能、更灵活)
import asyncio
import concurrent.futures
logger.info(f"🤖 [简化模式] 使用 AI 生成工具代码: {request.name}")
def run_async():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
agent_code_generator.generate_tool_code_with_ai(tool_config, request.api_key)
)
finally:
loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_async)
tool_code = future.result(timeout=90)
# 存储工具配置和代码
save_result = tool_storage.save_tool(
tool_ref_id=tool_ref_id,
name=request.name,
description=description,
config=tool_config,
code=tool_code,
user_id=request.user_id,
tenant_id=None
)
if not save_result.get("success"):
return {
"success": False,
"error": "generation_failed",
"message": save_result.get("error", "工具保存失败")
}
logger.info(f"✅ [简化模式] 工具生成成功: {tool_ref_id}")
return {
"success": True,
"data": {
"tool_ref_id": tool_ref_id,
"name": request.name,
"description": description,
"url": request.url,
"method": request.method.upper(),
"has_auth": bool(request.auth),
"created_at": datetime.utcnow().isoformat()
},
"message": "工具生成成功 (AI 辅助)"
}
except Exception as e:
logger.error(f"[简化模式] 工具生成失败: {e}")
import traceback
logger.error(traceback.format_exc())
return {
"success": False,
"error": "generation_failed",
"message": str(e)
}
@router.put("/{tool_ref_id}")
async def update_tool(tool_ref_id: str, request: UpdateToolRequest):
"""
@@ -392,7 +605,8 @@ async def test_tool(tool_ref_id: str, request: TestToolRequest = None):
if auth_type == "api_key":
location = auth.get("in", "header")
key_name = auth.get("name", "X-API-Key")
key_value = auth.get("key", "")
# 兼容 token 和 key 字段
key_value = auth.get("token") or auth.get("key", "")
if location == "header":
headers[key_name] = key_value
@@ -400,7 +614,9 @@ async def test_tool(tool_ref_id: str, request: TestToolRequest = None):
params[key_name] = key_value
elif auth_type == "bearer":
headers["Authorization"] = f"Bearer {auth.get('key', '')}"
# 兼容 token 和 key 字段
token_value = auth.get("token") or auth.get("key", "")
headers["Authorization"] = f"Bearer {token_value}"
elif auth_type == "basic":
import base64
@@ -639,13 +855,19 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest):
"timeout": config.get("timeout", 30)
})
# 生成仓库名
repo_name = f"agent-{request.name.lower().replace('_', '-')}-{uuid.uuid4().hex[:6]}"
# 生成唯一后缀,确保不同用户创建同名 Agent 不会冲突
unique_suffix = uuid.uuid4().hex[:6]
base_name = request.name.lower().replace("_", "-").replace(" ", "-")
# k8s_name 带唯一后缀,避免域名/namespace 冲突
k8s_name = f"{base_name}-{unique_suffix}"
repo_name = f"agent-{k8s_name}"
agent_ref_id = f"agent-{repo_name}"
# 生成完整项目文件(传递资源配置)
# 使用 k8s_name 作为 agent_name,确保 CI/CD 中创建的 DNS/namespace 与 API 返回一致
project_files = agent_code_generator.generate_full_project(
agent_name=request.name,
agent_name=k8s_name,
description=f"Agent with {len(tools)} external tools",
tools_config=tools_config,
auto_deploy=True,
@@ -673,15 +895,8 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest):
# 获取仓库所有者
repo_owner = repo_result.get("owner", gitee_manager.gitee_username)
# 推送文件
push_result = gitee_manager.push_files(
repo_name=repo_name,
files=project_files,
commit_message=f"Initial commit: {request.name} with {len(tools)} tools",
owner=repo_owner
)
# 设置 CI/CD Secrets - 使用新生成的 Azure 凭证
# ⚠️ 重要:先设置 CI/CD Secrets,再推送文件
# 因为推送文件会触发 CI/CD,必须确保 secrets 已经就绪
cicd_secrets = {
"ACR_LOGIN_SERVER": "agnettaiji.azurecr.io",
"ACR_USERNAME": "agnettaiji",
@@ -696,29 +911,47 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest):
"AZURE_DNS_RG": "taiji-Ai-v0"
}
gitee_manager.set_repo_secrets(
logger.info(f"📝 设置 CI/CD Secrets(共 {len(cicd_secrets)} 个)...")
secrets_result = gitee_manager.set_repo_secrets(
repo_name=repo_name,
secrets=cicd_secrets,
owner=repo_owner
)
if not secrets_result.get("success"):
logger.warning(f"⚠️ 部分 Secrets 设置可能失败: {secrets_result}")
# 等待一小段时间确保 secrets 生效
import time
time.sleep(1)
# 推送文件(这会触发 CI/CD)
logger.info(f"📤 推送项目文件...")
push_result = gitee_manager.push_files(
repo_name=repo_name,
files=project_files,
commit_message=f"Initial commit: {request.name} with {len(tools)} tools",
owner=repo_owner
)
# 标记工具被使用
for ref in request.tool_refs:
tool_storage.mark_tool_in_use(ref, request.name)
# 计算 K8s 相关名称
k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-")
# 计算域名和 namespace(k8s_name 已在上面定义,带唯一后缀)
expected_domain = f"{k8s_name}.taijiagnet.com"
agent_ref_id = f"agent-{repo_name}"
namespace = f"agent-{k8s_name}"
# 存储 Agent 信息以便后续查询
AGENT_REFS[agent_ref_id] = {
"agent_ref_id": agent_ref_id,
"name": request.name,
"name": request.name, # 原始名称(用户输入)
"display_name": request.name, # 显示名称
"k8s_name": k8s_name, # K8s 名称(带唯一后缀,用于域名/namespace)
"repo_name": repo_name,
"repo_url": repo_result.get("html_url"),
"repo_owner": repo_owner,
"namespace": f"agent-{k8s_name}",
"namespace": namespace,
"domain": expected_domain,
"image_name": f"agnettaiji.azurecr.io/ai-agents/{repo_name}:latest",
"tools": [t["name"] for t in tools],
@@ -736,7 +969,7 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest):
"success": True,
"name": request.name,
"agent_ref_id": agent_ref_id,
"namespace": f"agent-{k8s_name}",
"namespace": namespace,
"status": "Building",
"created_at": datetime.utcnow().isoformat(),
"template": request.template,
@@ -862,9 +1095,19 @@ async def get_agent_build_status(agent_ref_id: str):
elif action_status.get("status") == "no_runs":
overall_status = "pending"
# 计算域名
k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-")
# 计算域名 - 优先使用存储的 k8s_name
if agent_info and agent_info.get("k8s_name"):
k8s_name = agent_info.get("k8s_name")
else:
# 无法从 repo_name 准确推断,使用 agent 名称
agent_name = agent_info.get("name") if agent_info else None
if agent_name:
k8s_name = agent_name.lower().replace("_", "-").replace(" ", "-")
else:
k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-")
expected_domain = f"{k8s_name}.taijiagnet.com"
namespace = f"agent-{k8s_name}"
return {
"success": True,
@@ -887,7 +1130,7 @@ async def get_agent_build_status(agent_ref_id: str):
"access_info": {
"expected_domain": expected_domain,
"expected_url": f"http://{expected_domain}",
"expected_namespace": f"agent-{k8s_name}"
"expected_namespace": namespace
}
}
}
@@ -913,14 +1156,21 @@ async def get_agent_deployment_info(agent_ref_id: str):
- 访问 URL
"""
try:
# 从 agent_ref_id 推断 repo_name
if agent_ref_id.startswith("agent-"):
repo_name = agent_ref_id.replace("agent-", "", 1)
else:
repo_name = agent_ref_id
# 检查是否有存储的 agent 信息
agent_info = AGENT_REFS.get(agent_ref_id)
k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-")
namespace = f"agent-{k8s_name}"
# 从 agent_ref_id 推断 repo_name
if agent_info:
repo_name = agent_info.get("repo_name")
k8s_name = agent_info.get("k8s_name")
namespace = agent_info.get("namespace")
else:
if agent_ref_id.startswith("agent-"):
repo_name = agent_ref_id.replace("agent-", "", 1)
else:
repo_name = agent_ref_id
k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-")
namespace = f"agent-{k8s_name}"
# 导入 K8sManager 查询实际状态
from k8s_manager import K8sManager
+1
View File
@@ -5,3 +5,4 @@ pydantic==2.5.0
python-dotenv==1.0.0
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
httpx>=0.25.0
@@ -0,0 +1,83 @@
{
"tool_ref_id": "tool-jina_reader_v5-d57dcc49",
"name": "jina_reader_v5",
"description": "调用 jina_reader_v5 API,参数: url",
"config": {
"name": "jina_reader_v5",
"description": "调用 jina_reader_v5 API,参数: url",
"url": "https://r.jina.ai/",
"method": "POST",
"headers": null,
"auth": {
"type": "bearer",
"key": "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI",
"token": "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI",
"username": null,
"password": null,
"in": "header",
"name": "X-API-Key"
},
"request_body": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "要爬取的网页URL"
}
},
"required": [
"url"
]
},
"request_params": null,
"input_schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "要爬取的网页URL"
}
},
"required": [
"url"
]
},
"timeout": 30,
"_original_request": {
"name": "jina_reader_v5",
"description": null,
"url": "https://r.jina.ai/",
"method": "POST",
"user_id": "test-user",
"auth": {
"type": "bearer",
"key": null,
"token": "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI",
"username": null,
"password": null,
"in_location": "header",
"name": "X-API-Key"
},
"request_body_schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "要爬取的网页URL"
}
},
"required": [
"url"
]
},
"request_params": null,
"headers": null,
"api_key": null
}
},
"user_id": "test-user",
"tenant_id": null,
"created_at": "2026-01-30T17:25:13.854736",
"updated_at": "2026-01-30T17:25:13.854746",
"status": "created"
}
@@ -0,0 +1,68 @@
"""
工具: jina_reader_v5
描述: 调用 jina_reader_v5 API,参数: url
"""
import os
import json
from typing import Optional, Any
import httpx
async def jina_reader_v5(url: str) -> str:
"""
调用 jina_reader_v5 API 爬取网页内容
Args:
url: 要爬取的网页URL
Returns:
JSON 字符串格式的响应结果
"""
try:
api_key = os.getenv("TOOL_API_KEY", "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI")
# 将目标URL拼接到API路径中
api_url = f"https://r.jina.ai/{url}"
headers = {
"Authorization": f"Bearer {api_key}",
"X-API-Key": api_key,
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
api_url,
headers=headers
)
response.raise_for_status()
# 尝试解析为JSON,如果失败则返回文本内容
try:
result = response.json()
except json.JSONDecodeError:
result = {"content": response.text, "status": "success"}
return json.dumps(result, ensure_ascii=False)
except httpx.HTTPStatusError as e:
error_detail = {
"error": "HTTP错误",
"status_code": e.response.status_code,
"message": str(e),
"response": e.response.text
}
return json.dumps(error_detail, ensure_ascii=False)
except httpx.RequestError as e:
error_detail = {
"error": "请求错误",
"message": str(e)
}
return json.dumps(error_detail, ensure_ascii=False)
except Exception as e:
error_detail = {
"error": "未知错误",
"message": str(e)
}
return json.dumps(error_detail, ensure_ascii=False)