""" 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): """POST 本进程推理端点。返回上游响应 dict;失败抛 ValueError(消息真实可行动)。""" 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"} try: async with aiohttp.ClientSession() as session: async with session.post( base + "/chat/completions", headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=330, 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 [], }