修改external

This commit is contained in:
Von committed 2026-03-11 00:45:25 +08:00
1 parent 478b39c36d
commit e151aa8123
6 files changed
+248 -232

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ logs
.history
config.yaml
.record
+4 -2
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""外部服务层导出。"""
"""external 集成层导出。"""
from .llm_provider import LLMProvider
from .response_adapter import build_proxy_response
from .stream_probe import probe_stream_response
__all__ = ['LLMProvider']
__all__ = ["LLMProvider", "build_proxy_response", "probe_stream_response"]
+6 -167
View File
@@ -1,18 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""外部 LLM Provider 抽象。"""
"""LLM Provider 配置与请求阶段钩子封装。"""
import json
from typing import Any, Callable, Dict, Iterator, Optional
from typing import Any, Dict, Optional
from flask import Response
from ..application.app_context import Logger
from ..hooks import HookContext, HookModule
class LLMProvider:
"""封装 provider 配置与钩子处理流程。"""
"""封装 provider 配置与请求阶段钩子调用。"""
def __init__(
self,
@@ -35,168 +31,11 @@ class LLMProvider:
self.hook = hook
def apply_header_hook(self, ctx: HookContext, headers: Dict[str, str]) -> Dict[str, str]:
if self.hook and hasattr(self.hook, 'header_hook'):
if self.hook and hasattr(self.hook, "header_hook"):
return self.hook.header_hook(ctx, headers)
return headers
def apply_input_body_hook(self, ctx: HookContext, body: Dict[str, Any]) -> Dict[str, Any]:
if self.hook and hasattr(self.hook, 'input_body_hook'):
if self.hook and hasattr(self.hook, "input_body_hook"):
return self.hook.input_body_hook(ctx, body)
return body
def apply_output_body_hook(
self,
ctx: HookContext,
response: Any,
is_stream: bool,
filter_headers_func: Any,
stream_context_func: Any,
logger: Logger,
on_complete: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> Response:
hook_func = getattr(self.hook, 'output_body_hook', None) if self.hook else None
meta: Dict[str, Any] = {
'response_model': None,
'total_tokens': 0,
'prompt_tokens': 0,
'completion_tokens': 0,
}
completed = False
def update_meta_from_payload(payload: Dict[str, Any]) -> None:
model = payload.get('model')
if model:
meta['response_model'] = model
usage = payload.get('usage')
if isinstance(usage, dict):
if 'total_tokens' in usage and usage.get('total_tokens') is not None:
meta['total_tokens'] = int(usage.get('total_tokens') or 0)
if 'prompt_tokens' in usage and usage.get('prompt_tokens') is not None:
meta['prompt_tokens'] = int(usage.get('prompt_tokens') or 0)
if 'completion_tokens' in usage and usage.get('completion_tokens') is not None:
meta['completion_tokens'] = int(usage.get('completion_tokens') or 0)
def safe_on_complete() -> None:
nonlocal completed
if completed:
return
completed = True
if on_complete:
try:
on_complete(meta)
except Exception as exc:
logger.error(f'Error in on_complete callback: {exc}')
def parse_sse_line(line: str) -> Optional[str]:
"""解析单行 SSE data 内容。"""
stripped = line.strip()
if not stripped or not stripped.startswith('data:'):
return None
return stripped[5:].strip()
def process_sse_event(event_text: str) -> Iterator[bytes]:
"""处理单个 SSE 事件块并执行输出钩子。"""
data_lines = []
for raw_line in event_text.splitlines():
if raw_line.startswith('data:'):
data_lines.append(raw_line[5:].strip())
if not data_lines:
if event_text.strip():
yield (event_text + '\n\n').encode('utf-8')
return
data_str = '\n'.join(data_lines).strip()
if not data_str:
return
if data_str == '[DONE]':
yield b'data: [DONE]\n\n'
return
try:
data = json.loads(data_str)
except json.JSONDecodeError:
yield f'data: {data_str}\n\n'.encode('utf-8')
return
if isinstance(data, dict):
update_meta_from_payload(data)
if hook_func:
modified = hook_func(ctx, data)
if modified is not None:
yield f"data: {json.dumps(modified)}\n\n".encode('utf-8')
else:
yield f'data: {data_str}\n\n'.encode('utf-8')
else:
yield f'data: {data_str}\n\n'.encode('utf-8')
if not is_stream:
body = response.content
response_body = body
try:
payload = json.loads(body.decode('utf-8'))
if isinstance(payload, dict):
update_meta_from_payload(payload)
if hook_func:
modified = hook_func(ctx, payload)
response_body = json.dumps(modified).encode('utf-8')
except json.JSONDecodeError as exc:
logger.warning(
'Non-stream response is not valid JSON, skip output_body_hook: status=%s content_type=%s error=%s',
response.status_code,
response.headers.get('Content-Type'),
exc,
)
finally:
safe_on_complete()
return Response(
response_body,
status=response.status_code,
headers=filter_headers_func(response.headers),
mimetype=response.headers.get('Content-Type', 'application/json'),
)
def generate() -> Iterator[bytes]:
buffer = ''
try:
for chunk in response.iter_content(chunk_size=None):
if not chunk:
continue
try:
buffer += chunk.decode('utf-8')
except UnicodeDecodeError:
yield chunk
continue
while '\n\n' in buffer:
event_text, buffer = buffer.split('\n\n', 1)
yield from process_sse_event(event_text)
if buffer:
# 对不完整流做兜底:尽量解析已完整接收的 data 行。
for line in buffer.splitlines():
data_str = parse_sse_line(line)
if data_str is None:
continue
if data_str == '[DONE]':
yield b'data: [DONE]\n\n'
continue
try:
data = json.loads(data_str)
if isinstance(data, dict):
update_meta_from_payload(data)
except Exception:
continue
finally:
safe_on_complete()
return Response(
stream_context_func(generate()),
status=response.status_code,
headers=filter_headers_func(response.headers),
mimetype=response.headers.get('Content-Type', 'text/event-stream'),
)
return body
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""上游响应适配:执行 provider 输出钩子并构造 Flask 响应。"""
import json
from typing import Any, Callable, Dict, Iterator, Optional
from flask import Response
from ..application.app_context import Logger
from ..hooks import HookContext, HookModule
def build_proxy_response(
hook: Optional[HookModule],
ctx: HookContext,
response: Any,
is_stream: bool,
filter_headers_func: Any,
stream_context_func: Any,
logger: Logger,
on_complete: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> Response:
"""基于上游响应与输出钩子构建 Flask 响应。"""
hook_func = getattr(hook, "output_body_hook", None) if hook else None
meta: Dict[str, Any] = {
"response_model": None,
"total_tokens": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
}
completed = False
def update_meta_from_payload(payload: Dict[str, Any]) -> None:
model = payload.get("model")
if model:
meta["response_model"] = model
usage = payload.get("usage")
if isinstance(usage, dict):
if "total_tokens" in usage and usage.get("total_tokens") is not None:
meta["total_tokens"] = int(usage.get("total_tokens") or 0)
if "prompt_tokens" in usage and usage.get("prompt_tokens") is not None:
meta["prompt_tokens"] = int(usage.get("prompt_tokens") or 0)
if "completion_tokens" in usage and usage.get("completion_tokens") is not None:
meta["completion_tokens"] = int(usage.get("completion_tokens") or 0)
def safe_on_complete() -> None:
nonlocal completed
if completed:
return
completed = True
if on_complete:
try:
on_complete(meta)
except Exception as exc:
logger.error(f"Error in on_complete callback: {exc}")
def parse_sse_line(line: str) -> Optional[str]:
stripped = line.strip()
if not stripped or not stripped.startswith("data:"):
return None
return stripped[5:].strip()
def process_sse_event(event_text: str) -> Iterator[bytes]:
data_lines = []
for raw_line in event_text.splitlines():
if raw_line.startswith("data:"):
data_lines.append(raw_line[5:].strip())
if not data_lines:
if event_text.strip():
yield (event_text + "\n\n").encode("utf-8")
return
data_str = "\n".join(data_lines).strip()
if not data_str:
return
if data_str == "[DONE]":
yield b"data: [DONE]\n\n"
return
try:
data = json.loads(data_str)
except json.JSONDecodeError:
yield f"data: {data_str}\n\n".encode("utf-8")
return
if isinstance(data, dict):
update_meta_from_payload(data)
if hook_func:
modified = hook_func(ctx, data)
if modified is not None:
yield f"data: {json.dumps(modified)}\n\n".encode("utf-8")
else:
yield f"data: {data_str}\n\n".encode("utf-8")
else:
yield f"data: {data_str}\n\n".encode("utf-8")
if not is_stream:
body = response.content
response_body = body
try:
payload = json.loads(body.decode("utf-8"))
if isinstance(payload, dict):
update_meta_from_payload(payload)
if hook_func:
modified = hook_func(ctx, payload)
response_body = json.dumps(modified).encode("utf-8")
except json.JSONDecodeError as exc:
logger.warning(
"Non-stream response is not valid JSON, skip output_body_hook: status=%s content_type=%s error=%s",
response.status_code,
response.headers.get("Content-Type"),
exc,
)
finally:
safe_on_complete()
return Response(
response_body,
status=response.status_code,
headers=filter_headers_func(response.headers),
mimetype=response.headers.get("Content-Type", "application/json"),
)
def generate() -> Iterator[bytes]:
buffer = ""
try:
for chunk in response.iter_content(chunk_size=None):
if not chunk:
continue
try:
buffer += chunk.decode("utf-8")
except UnicodeDecodeError:
yield chunk
continue
while "\n\n" in buffer:
event_text, buffer = buffer.split("\n\n", 1)
yield from process_sse_event(event_text)
if buffer:
for line in buffer.splitlines():
data_str = parse_sse_line(line)
if data_str is None:
continue
if data_str == "[DONE]":
yield b"data: [DONE]\n\n"
continue
try:
data = json.loads(data_str)
if isinstance(data, dict):
update_meta_from_payload(data)
except Exception:
continue
finally:
safe_on_complete()
return Response(
stream_context_func(generate()),
status=response.status_code,
headers=filter_headers_func(response.headers),
mimetype=response.headers.get("Content-Type", "text/event-stream"),
)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""上游流式响应探测工具。"""
from typing import Any, Iterator, Optional, Tuple
import requests
class PrefetchedStreamResponse:
def __init__(self, response: requests.Response, first_chunk: bytes):
self._response = response
self._first_chunk = first_chunk
self.status_code = response.status_code
self.headers = response.headers
def iter_content(self, chunk_size: Optional[int] = None) -> Iterator[bytes]:
if self._first_chunk:
yield self._first_chunk
self._first_chunk = b""
yield from self._response.iter_content(chunk_size=chunk_size)
def close(self) -> None:
self._response.close()
class BufferedUpstreamResponse:
def __init__(self, response: requests.Response, body: bytes):
self._response = response
self.content = body
self.status_code = response.status_code
self.headers = response.headers
def close(self) -> None:
self._response.close()
def looks_like_sse_chunk(chunk: bytes) -> bool:
if not chunk:
return False
text = chunk.decode("utf-8", errors="ignore").lstrip()
return text.startswith("data:") or text.startswith("event:") or text.startswith(":")
def probe_stream_response(upstream_response: requests.Response) -> Tuple[Any, bool]:
chunk_iter = upstream_response.iter_content(chunk_size=None)
first_chunk = b""
for chunk in chunk_iter:
if chunk:
first_chunk = chunk
break
if not first_chunk:
return BufferedUpstreamResponse(upstream_response, b""), False
if looks_like_sse_chunk(first_chunk):
return PrefetchedStreamResponse(upstream_response, first_chunk), True
remaining = b"".join(chunk_iter)
return BufferedUpstreamResponse(upstream_response, first_chunk + remaining), False
+10 -63
View File
@@ -1,48 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""代理服务。"""
"""上游 LLM 代理服务。"""
import threading
from typing import Any, Callable, Dict, Iterator, Optional, Tuple
from typing import Any, Callable, Dict, Optional, Tuple
import requests
from requests.adapters import HTTPAdapter
from flask import Response, stream_with_context
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from ..application.app_context import AppContext
from ..external import LLMProvider
from ..external import LLMProvider, build_proxy_response, probe_stream_response
from ..hooks import HookContext
class _PrefetchedStreamResponse:
def __init__(self, response: requests.Response, first_chunk: bytes):
self._response = response
self._first_chunk = first_chunk
self.status_code = response.status_code
self.headers = response.headers
def iter_content(self, chunk_size: Optional[int] = None) -> Iterator[bytes]:
if self._first_chunk:
yield self._first_chunk
self._first_chunk = b""
yield from self._response.iter_content(chunk_size=chunk_size)
def close(self) -> None:
self._response.close()
class _BufferedUpstreamResponse:
def __init__(self, response: requests.Response, body: bytes):
self._response = response
self.content = body
self.status_code = response.status_code
self.headers = response.headers
def close(self) -> None:
self._response.close()
class ProxyService:
"""处理上游 LLM 代理请求。"""
@@ -58,7 +30,7 @@ class ProxyService:
request_headers: Dict[str, str],
on_complete: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> Tuple[Optional[Response], int]:
"""代理请求到指定 provider,并执行重试与输出钩子。"""
"""代理请求到目标 provider,并处理重试与输出钩子。"""
target_url = provider.api
model_name = request_data["model"]
@@ -67,7 +39,6 @@ class ProxyService:
verify_ssl = provider.verify_ssl
def build_request(attempt: int) -> Tuple[Dict[str, str], Dict[str, Any], HookContext]:
"""构建重试请求的 headers/body/context。"""
headers = dict(request_headers)
headers["content-type"] = "application/json"
@@ -85,7 +56,6 @@ class ProxyService:
for attempt in range(max_retries):
headers, body, request_ctx = build_request(attempt)
requested_stream = bool(body.get("stream", False))
http_session: Optional[requests.Session] = None
self._logger.info(
"Proxying upstream request: provider=%s model=%s attempt=%s/%s stream=%s",
provider.name,
@@ -123,7 +93,7 @@ class ProxyService:
upstream_stream = "text/event-stream" in content_type
response_for_hook: Any = upstream_response
if requested_stream and not upstream_stream:
response_for_hook, is_stream = self._probe_stream_response(upstream_response)
response_for_hook, is_stream = probe_stream_response(upstream_response)
self._logger.warning(
"Stream mode probed for mismatch: requested_stream=%s upstream_stream=%s probed_stream=%s content_type=%s",
requested_stream,
@@ -141,7 +111,8 @@ class ProxyService:
upstream_stream,
)
response = provider.apply_output_body_hook(
response = build_proxy_response(
provider.hook,
request_ctx,
response_for_hook,
is_stream,
@@ -157,6 +128,7 @@ class ProxyService:
is_stream,
)
if is_stream:
def _close_upstream_resources() -> None:
response_for_hook.close()
@@ -200,33 +172,8 @@ class ProxyService:
session.mount("https://", adapter)
return session
@staticmethod
def _looks_like_sse_chunk(chunk: bytes) -> bool:
if not chunk:
return False
text = chunk.decode("utf-8", errors="ignore").lstrip()
return text.startswith("data:") or text.startswith("event:") or text.startswith(":")
def _probe_stream_response(self, upstream_response: requests.Response) -> Tuple[Any, bool]:
chunk_iter = upstream_response.iter_content(chunk_size=None)
first_chunk = b""
for chunk in chunk_iter:
if chunk:
first_chunk = chunk
break
if not first_chunk:
return _BufferedUpstreamResponse(upstream_response, b""), False
if self._looks_like_sse_chunk(first_chunk):
return _PrefetchedStreamResponse(upstream_response, first_chunk), True
remaining = b"".join(chunk_iter)
return _BufferedUpstreamResponse(upstream_response, first_chunk + remaining), False
@staticmethod
def _filter_response_headers(headers: Any) -> Dict[str, str]:
"""过滤不应透传给客户端的 hop-by-hop 响应头。"""
excluded = {
"transfer-encoding",
"connection",
@@ -238,4 +185,4 @@ class ProxyService:
"upgrade",
"set-cookie",
}
return {k: v for k, v in headers.items() if k.lower() not in excluded}
return {k: v for k, v in headers.items() if k.lower() not in excluded}