280 lines
14 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 asyncio
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, project_id=''):
"""取/发内部短期 token。失败抛 ValueError(消息真实可行动)。
project_id(2026-09-10 用户定夺):随 token 落 pipeline_llm_tokens.project_id,
推理端点透传到 llm_usage.project_id 支撑按项目统计费用。缓存键必须含
project_id——否则同 org/user/model 的不同项目会串用同一 token,费用记错项目。
"""
key = (org_id or '0', user_id or '', model_name or '', project_id 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=project_id or '', 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,
project_id: str = ''):
"""POST 本进程推理端点。返回上游响应 dict;失败抛 ValueError(消息真实可行动)。
timeout:客户端等待秒数(0=缺省 330)。异步生成模型(视频等)端点侧最长
等 900 秒,调用方须同步放大客户端超时,否则客户端先断连。
"""
import aiohttp
token = await _get_internal_token(org_id, user_id, model_name, project_id)
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:
# 如实区分错误类型(2026-09-14 pbls 事故教训):TimeoutError/ServerTimeoutError
# 的 str() 为空 → 旧文案「端点不可达:。」冒号后空白且把超时误报成不可达,
# 下游 _classify_failure 按「不可达」的中文查不到英文 hint → 误判永久错误
# 直接暂停任务链。超时就报超时(瞬时故障,应重试);连接类错误才报不可达。
import aiohttp as _ah
if isinstance(e, (asyncio.TimeoutError, _ah.ServerTimeoutError)):
raise ValueError(
'LLM 推理端点调用超时(%s/chat/completions,客户端上限 %ss):'
'请求已发出但未在上限内收到响应,属瞬时故障可重试。'
% (base, _total))
if isinstance(e, (_ah.ClientConnectionError, ConnectionError, OSError)):
raise ValueError(
'LLM 推理端点不可达(%s/chat/completions):%s: %s。'
'请检查本进程服务是否正常' % (base, type(e).__name__, e))
raise ValueError(
'LLM 推理端点调用失败(%s/chat/completions):%s: %s'
% (base, type(e).__name__, 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, project_id: str = '', session_id: str = '') -> str:
"""Call LLM and return text response.
统一走模型治理推理 API(门禁链 + 双维度记账)。
org_id 为空 = 系统级('0'),与旧语义(不过滤机构)等价。
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链
(机构策略配置辅助模型优先),经 payload 的 _purpose 键透传到端点。
session_id(2026-09-11 用户定夺):会话粘性键——同会话固定同一账号,
上游 KV/前缀缓存按账号隔离,固定账号命中缓存省钱;经 payload._session_id
透传到端点(不进 token 缓存键——token 与账号选择解耦)。
"""
# 兼容旧优先级: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)
if session_id:
payload["_session_id"] = session_id
data = await _http_chat(payload, org_id or '0', user_id or '', model or '',
project_id=project_id 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, project_id: str = '', session_id: str = '') -> str:
"""Call LLM with full message array (system/user/assistant).
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链。
timeout:单次上游调用超时秒数(0=用端点默认;上限 900,超长文本提取用)。
session_id(2026-09-11):会话粘性账号键,经 payload._session_id 透传。
"""
payload = {"model": model or '', "messages": messages, "temperature": temperature}
if purpose:
payload["_purpose"] = purpose
if timeout:
payload["_timeout"] = int(timeout)
if session_id:
payload["_session_id"] = session_id
# 客户端等待须覆盖端点侧预算 + 余量(同 llm_infer 模式),否则客户端先断连、
# 报模糊 TimeoutError 而非端点的结构化错误
data = await _http_chat(payload, org_id or '0', user_id or '', model or '',
timeout=(int(timeout) + 60 if timeout else 0),
project_id=project_id 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 = '',
project_id: str = '', session_id: str = '',
timeout: int = 0) -> dict:
"""Native function calling. 传入 tools JSON schema,返回 message dict。
Returns:
{"content": str, "tool_calls": [{"id","type","function":{"name","arguments"}}]}
当模型返回 tool_calls 时,content 通常为空字符串。
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链。
session_id(2026-09-11):会话粘性账号键,经 payload._session_id 透传。
"""
payload = {"model": model or '', "messages": messages, "temperature": temperature}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
if purpose:
payload["_purpose"] = purpose
if timeout:
# 端点侧上游预算必须随调用方透传(2026-09-15 pbls design 三连超时根因):
# 原来 native 只把 timeout 用作客户端等待,payload 不带 _timeout → 端点按
# 供应商端点配置(百炼 timeout=120)掐断上游,而 design 收尾大请求天然
# 72~115s,一半概率 120s 被杀 → 内部 3 重试全灭 → 任务 failed。
# 端点预算 = 客户端等待 - 60s 余量:让端点先掐并返回结构化错误(可分类
# 重试),而不是客户端 aiohttp 断连报模糊 TimeoutError。
payload["_timeout"] = max(int(timeout) - 60, 60)
if session_id:
payload["_session_id"] = session_id
data = await _http_chat(payload, org_id or '0', user_id or '', model or '',
timeout=timeout, project_id=project_id 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,
project_id: str = '', session_id: str = '') -> 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)
if session_id and not body.get('_session_id'):
body["_session_id"] = session_id
# 客户端等待须覆盖端点侧预算(_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, project_id=project_id or '')