ymq 0e5368a894 feat: cockpit agent 切原生 function calling
- llm_bridge 新增 llm_call_msgs_native 支持 tools 参数+解析 tool_calls
- agent_loop_v2._call_llm 优先走 native function calling,失败回退文本
- run loop 处理原生 tool_calls(role=tool 回填)
- _init_components 把 config.tools 注册进 ToolRegistry(修复 registry 空导致 tools_description 为空、schema 为空的 bug)
2026-08-13 17:05:26 +08:00

207 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
LLM bridge for pipeline handlers.
Provides a simple async interface for handlers to call LLM APIs.
Looks up model config from the llm database table first,
falls back to environment variables.
"""
import json
import logging
import os
logger = logging.getLogger("pipeline.llm_bridge")
# Cache: model_name -> {api_base, api_key, model_id}
_model_cache: dict = {}
def _decrypt_key(encrypted: str) -> str:
"""Decrypt api_key stored with password_encode. Falls back to plaintext."""
if not encrypted:
return ""
try:
from appPublic.rc4 import unpassword
from appPublic.jsonConfig import getConfig
config = getConfig()
key = config.password_key
return unpassword(key, encrypted)
except Exception:
return encrypted # already plaintext or decrypt failed
async def _get_model_config(model_name: str = None) -> dict:
"""Look up model config from llm table. Returns dict with api_base, api_key, model_id."""
global _model_cache
if model_name and model_name in _model_cache:
return _model_cache[model_name]
try:
from sqlor.dbpools import DBPools
db = DBPools()
dbname = "pipeline"
async with db.sqlorContext(dbname) as sor:
if model_name:
sql = "SELECT api_base, api_key, model_id FROM llm WHERE name=${name}$ AND status='active' LIMIT 1"
recs = await sor.sqlExe(sql, {"name": model_name})
else:
sql = "SELECT api_base, api_key, model_id, name FROM llm WHERE status='active' ORDER BY id LIMIT 1"
recs = await sor.sqlExe(sql, {})
if recs:
r = recs[0]
cfg = {
"api_base": getattr(r, "api_base", "") or "",
"api_key": _decrypt_key(getattr(r, "api_key", "") or ""),
"model_id": getattr(r, "model_id", "") or "",
}
cache_key = model_name or getattr(r, "name", "")
if cache_key:
_model_cache[cache_key] = cfg
return cfg
except Exception as e:
logger.warning("llm_bridge: DB lookup failed: %s", e)
return {}
async def llm_call(prompt: str, model: str = None, temperature: float = 0.7) -> str:
"""Call LLM and return text response.
Backend priority:
1. harnessed_agent.llm_chat (if loaded in ServerEnv)
2. DB llm table (api_base + api_key)
3. Environment variables (LLM_API_BASE, LLM_API_KEY, LLM_MODEL)
"""
# Priority 1: harnessed_agent
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
if hasattr(env, 'llm_chat'):
result = await env.llm_chat(prompt, model=model, temperature=temperature)
if isinstance(result, dict):
return result.get("content", result.get("text", str(result)))
return str(result)
except Exception:
pass
# Priority 2: DB llm table
cfg = await _get_model_config(model)
if cfg.get("api_key") and cfg.get("api_base"):
api_base = cfg["api_base"]
api_key = cfg["api_key"]
model_id = cfg.get("model_id") or model or "default"
logger.info("llm_bridge: using DB model config for %s -> %s", model, api_base)
else:
# Priority 3: Environment variables
api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
api_key = os.environ.get("LLM_API_KEY", "")
model_id = model or os.environ.get("LLM_MODEL", "gpt-4o-mini")
if not api_key:
raise ValueError("No LLM API configured. Please add a model in the llm table or set LLM_API_KEY env var.")
import aiohttp
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
}
url = api_base.rstrip("/") + "/chat/completions"
async with aiohttp.ClientSession() as session:
async with session.post(
url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=120)
) as resp:
if resp.status != 200:
text = await resp.text()
raise ValueError(f"LLM API error {resp.status}: {text[:300]}")
data = await resp.json()
return data["choices"][0]["message"]["content"]
async def call_llm(tenant_id: str, prompt: str, model: str = None, temperature: float = 0.7) -> str:
"""SDLC handler interface — delegates to llm_call."""
return await llm_call(prompt, model=model, temperature=temperature)
async def llm_call_msgs(messages: list, model: str = None, temperature: float = 0.7) -> str:
"""Call LLM with full message array (system/user/assistant)."""
import aiohttp
cfg = await _get_model_config(model)
if cfg.get("api_key") and cfg.get("api_base"):
api_base = cfg["api_base"]
api_key = cfg["api_key"]
model_id = cfg.get("model_id") or model or "default"
else:
api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
api_key = os.environ.get("LLM_API_KEY", "")
model_id = model or os.environ.get("LLM_MODEL", "gpt-4o-mini")
if not api_key:
raise ValueError("No LLM API configured")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": model_id, "messages": messages, "temperature": temperature}
url = api_base.rstrip("/") + "/chat/completions"
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=180)) as resp:
if resp.status != 200:
text = await resp.text()
raise ValueError(f"LLM API error {resp.status}: {text[:300]}")
data = await resp.json()
return data["choices"][0]["message"]["content"]
async def llm_call_msgs_native(messages: list, tools: list = None, model: str = None, temperature: float = 0.7) -> dict:
"""Native function calling. 传入 tools JSON schema,返回 message dict。
Returns:
{"content": str, "tool_calls": [{"id","type","function":{"name","arguments"}}]}
当模型返回 tool_calls 时,content 通常为空字符串。
"""
import aiohttp
cfg = await _get_model_config(model)
if cfg.get("api_key") and cfg.get("api_base"):
api_base = cfg["api_base"]
api_key = cfg["api_key"]
model_id = cfg.get("model_id") or model or "default"
else:
api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
api_key = os.environ.get("LLM_API_KEY", "")
model_id = model or os.environ.get("LLM_MODEL", "gpt-4o-mini")
if not api_key:
raise ValueError("No LLM API configured")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": model_id, "messages": messages, "temperature": temperature}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
url = api_base.rstrip("/") + "/chat/completions"
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=180)) as resp:
if resp.status != 200:
text = await resp.text()
raise ValueError(f"LLM API error {resp.status}: {text[:300]}")
data = await resp.json()
msg = data["choices"][0]["message"]
return {
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or [],
}