Files
lianghua/src/analysis/llm_client.py
T

77 lines
3.1 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.
# -*- coding: utf-8 -*-
"""
LLM 客户端(OpenAI 兼容 /chat/completions)。
- base_url / model / key 由环境变量配置(默认智谱 GLM)
- 出站安全:仅 http/https、显式拒绝 localhost、解析 IP 拒绝环回/私有/保留段、
禁用重定向(防 DNS rebinding 绕过),值全部走 JSON 序列化,密钥不落日志
"""
import ipaddress
import json
import os
from urllib.parse import urlparse
import requests
class LlmError(Exception):
pass
def _validated_url(base_url: str) -> str:
u = urlparse(base_url)
if u.scheme not in ('http', 'https'):
raise LlmError('LLM base_url 仅允许 http/https')
host = u.hostname or ''
if not host or host.lower() in ('localhost', 'localhost.localdomain'):
raise LlmError('LLM base_url 拒绝 localhost')
port = u.port or (443 if u.scheme == 'https' else 80)
try:
infos = socket.getaddrinfo(host, port)
except socket.gaierror as e:
raise LlmError('LLM base_url 域名解析失败: {}'.format(host))
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved
or ip.is_multicast or ip.is_unspecified):
raise LlmError('LLM base_url 拒绝非公网地址: {}'.format(ip))
return '{}://{}{}'.format(u.scheme, u.netloc, u.path)
class LlmClient:
def __init__(self):
self.api_key = os.environ.get('JQUANT_LLM_API_KEY', '').strip()
self.base_url = (os.environ.get('JQUANT_LLM_BASE_URL', '').strip()
or 'https://open.bigmodel.cn/api/paas/v4')
self.model = os.environ.get('JQUANT_LLM_MODEL', '').strip() or 'glm-4-flash'
self.temperature = float(os.environ.get('JQUANT_LLM_TEMPERATURE', '0.2'))
@property
def enabled(self) -> bool:
return bool(self.api_key)
def chat(self, system_prompt: str, user_prompt: str) -> str:
if not self.enabled:
raise LlmError('未配置 LLM API KeyJQUANT_LLM_API_KEY),请在服务环境变量中设置')
url = _validated_url(self.base_url.rstrip('/')) + '/chat/completions'
body = {
'model': self.model,
'temperature': self.temperature,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
}
# 校验与请求紧邻;禁重定向防 DNS rebinding 绕过 IP 校验
r = requests.post(url, json=body, timeout=90, allow_redirects=False,
headers={'Authorization': 'Bearer ' + self.api_key,
'Content-Type': 'application/json'})
if r.status_code in (301, 302, 303, 307, 308):
raise LlmError('LLM 端点发生重定向,已拒绝(防 SSRF 绕过)')
if r.status_code != 200:
raise LlmError('LLM HTTP {}: {}'.format(r.status_code, r.text[:200]))
content = r.json().get('choices', [{}])[0].get('message', {}).get('content')
if not content:
raise LlmError('LLM 响应缺少 content')
return content