229 lines
10 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 — 统一收敛到模型治理模块(pipeline-llm)推理 API。
2026-09-04 改造:产线平台所有模型调用切换到 /pipeline-llm/api/v1(OpenAI 兼容,
分类照 llmage)。本模块不再直查旧 `llm` 表——签名保持不变,内部改为:
签发内部短期 token(机构隔离,真 key 不出进程)
→ HTTP 自调用本进程 /pipeline-llm/api/v1/chat/completions
→ 门禁链(限流/限额/主备容错/端点轮转/预授权)+ 双维度记账由端点侧执行
自调用走 127.0.0.1:<本进程端口>(web/worker 进程都起 HTTP,worker 端口 9090+N)。
每次自调用带机构/用户上下文,用量流水可归属到真实用户。
"""
import json
import logging
import time
logger = logging.getLogger("pipeline.llm_bridge")
# 内部自调用 token 缓存:(org_id, user_id, model_name) -> {token, calls, expires_at}
# 每次 LLM 调用都签发新 token 会让 tokens 表膨胀,故按上下文缓存复用;
# 接近调用上限或临近过期时换新(上限/过期由签发侧强制,本地计数只是提前量)。
_token_cache: dict = {}
_TOKEN_MAX_LOCAL_CALLS = 400 # token max_calls=500,留 100 余量防竞态
_TOKEN_REFRESH_BEFORE_EXPIRY = 600 # 距过期不足 10 分钟即换新
_TOKEN_TTL_HOURS = 8
async def _self_base_url():
"""本进程推理 API 基址(自调用,不出本机)。"""
port = 9090
try:
from ahserver.serverenv import ServerEnv
p = getattr(ServerEnv(), 'port', None)
if p:
port = int(p)
except (TypeError, ValueError):
port = 9090
except Exception:
port = 9090
return "http://127.0.0.1:%d/pipeline-llm/api/v1" % port
async def _get_internal_token(org_id, user_id, model_name):
"""取/发内部短期 token。失败抛 ValueError(消息真实可行动)。"""
key = (org_id or '0', user_id or '', model_name or '')
now = time.time()
ent = _token_cache.get(key)
if ent and ent['calls'] < _TOKEN_MAX_LOCAL_CALLS \
and ent['expires_at'] - now > _TOKEN_REFRESH_BEFORE_EXPIRY:
ent['calls'] += 1
return ent['token']
from .llm_proxy import create_llm_token
ok, token = await create_llm_token(
org_id or '0', project_id='', task_id='', model_name=model_name or '',
purpose='internal_bridge', ttl_hours=_TOKEN_TTL_HOURS,
max_calls=500, created_by=user_id or '')
if not ok:
raise ValueError('内部 LLM token 签发失败:%s(模型治理模块未就绪或机构标识缺失)' % token)
_token_cache[key] = {
'token': token, 'calls': 1,
'expires_at': now + _TOKEN_TTL_HOURS * 3600,
}
return token
async def _http_chat(payload, org_id, user_id, model_name, timeout: int = 0):
"""POST 本进程推理端点。返回上游响应 dict;失败抛 ValueError(消息真实可行动)。
timeout:客户端等待秒数(0=缺省 330)。异步生成模型(视频等)端点侧最长
等 900 秒,调用方须同步放大客户端超时,否则客户端先断连。
"""
import aiohttp
token = await _get_internal_token(org_id, user_id, model_name)
base = await _self_base_url()
# ⚠️ "Bearer " 前缀用拼接构造——字面量写在源码里会被脱敏工具替换成 ***
# (2026-09-04 实测:Authorization 头变成 "***plk-..." 致端点校验失败)
_BEARER = 'Bea' + 'rer '
headers = {"Authorization": _BEARER + token, "Content-Type": "application/json"}
_total = int(timeout) if timeout and int(timeout) > 0 else 330
try:
async with aiohttp.ClientSession() as session:
async with session.post(
base + "/chat/completions", headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=_total, connect=30),
) as resp:
text = await resp.text()
status = resp.status
except Exception as e:
raise ValueError(
'LLM 推理端点不可达(%s/chat/completions):%s。'
'请检查本进程服务是否正常' % (base, e))
try:
data = json.loads(text)
except Exception:
raise ValueError('LLM 推理端点返回非 JSON(HTTP %s):%s' % (status, text[:200]))
if isinstance(data, dict) and data.get('error'):
err = data['error']
msg = err.get('message', '') if isinstance(err, dict) else str(err)
raise ValueError(msg or 'LLM 推理失败(无详情)')
return data
def _no_llm_error(model_name=None, org_id=None) -> ValueError:
"""兜底错误(正常路径错误消息来自端点侧,这里只防解析异常)。"""
return ValueError(
"模型「%s」调用失败:机构 %s 未完成模型治理接入(无容错策略或模型未注册)。"
"请在模型治理→组织容错策略配置主/备模型。" % (model_name or '(缺省)', org_id or '(未指定)'))
async def llm_call(prompt: str, model: str = None, temperature: float = 0.7,
org_id: str = None, user_id: str = None, purpose: str = '',
timeout: int = 0) -> str:
"""Call LLM and return text response.
统一走模型治理推理 API(门禁链 + 双维度记账)。
org_id 为空 = 系统级('0'),与旧语义(不过滤机构)等价。
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链
(机构策略配置辅助模型优先),经 payload 的 _purpose 键透传到端点。
"""
# 兼容旧优先级:harnessed_agent(若宿主加载了独立推理后端)
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
fn = getattr(env, 'llm_chat', None)
if callable(fn):
result = await fn(prompt, model=model, temperature=temperature)
if isinstance(result, dict):
return result.get("content", result.get("text", str(result)))
return str(result)
except Exception:
pass
payload = {
"model": model or '',
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
}
if purpose:
payload["_purpose"] = purpose
if timeout:
payload["_timeout"] = int(timeout)
data = await _http_chat(payload, org_id or '0', user_id or '', model or '')
try:
return data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as e:
raise ValueError('LLM 响应缺 choices: %s' % (
json.dumps(data, ensure_ascii=False, default=str)[:300])) from e
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,
org_id: str = None, user_id: str = None, purpose: str = '',
timeout: int = 0) -> str:
"""Call LLM with full message array (system/user/assistant).
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链。
timeout:单次上游调用超时秒数(0=用端点默认;上限 900,超长文本提取用)。
"""
payload = {"model": model or '', "messages": messages, "temperature": temperature}
if purpose:
payload["_purpose"] = purpose
if timeout:
payload["_timeout"] = int(timeout)
data = await _http_chat(payload, org_id or '0', user_id or '', model or '')
try:
return data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as e:
raise ValueError('LLM 响应缺 choices: %s' % (
json.dumps(data, ensure_ascii=False, default=str)[:300])) from e
async def llm_call_msgs_native(messages: list, tools: list = None, model: str = None,
temperature: float = 0.7, org_id: str = None,
user_id: str = None, purpose: str = '') -> dict:
"""Native function calling. 传入 tools JSON schema,返回 message dict。
Returns:
{"content": str, "tool_calls": [{"id","type","function":{"name","arguments"}}]}
当模型返回 tool_calls 时,content 通常为空字符串。
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链。
"""
payload = {"model": model or '', "messages": messages, "temperature": temperature}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
if purpose:
payload["_purpose"] = purpose
data = await _http_chat(payload, org_id or '0', user_id or '', model or '')
try:
msg = data["choices"][0]["message"]
except (KeyError, IndexError, TypeError) as e:
raise ValueError('LLM 响应缺 choices: %s' % (
json.dumps(data, ensure_ascii=False, default=str)[:300])) from e
return {
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or [],
}
async def llm_infer(payload: dict, model: str = None, org_id: str = None,
user_id: str = None, timeout: int = 0) -> dict:
"""通用推理(全能力,2026-09-07):透传任意 payload 到统一推理端点,
返回上游响应 dict(OpenAI 兼容 choices;生成类另带 media/output/task_id)。
与 llm_call* 的区别:不假设 messages 结构,调用方自己组包——供 agent 的
invoke_model 工具调用非对话能力(t2i/t2v/i2v/tts/asr 等)。payload 里的
messages 由调用方按能力构造;媒体参数按三数组契约(image_files/audio_files/
video_files),inference 层的 _normalize_media_aliases 会归一旧别名。
model 空 = 端点按机构策略缺省模型;owner/机构归属硬校验在治理层执行。
timeout:单次调用超时秒数(生成类慢,可传大值;上限 900,端点侧封顶)。
失败抛 ValueError(消息真实可行动,直接展示给 agent/用户)。
"""
body = dict(payload or {})
if model:
body["model"] = model
if timeout:
body["_timeout"] = int(timeout)
# 客户端等待须覆盖端点侧预算(_timeout 端点封顶 900)+ 余量,否则客户端先断连
client_timeout = min(int(timeout or 0), 900) + 60 if timeout else 0
return await _http_chat(body, org_id or '0', user_id or '', model or '',
timeout=client_timeout)