api2cursor/utils/http.py
2026-03-14 09:27:15 +08:00

179 lines
5.8 KiB
Python
Raw Permalink 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.

"""HTTP 工具 - 请求头构建、上游转发、SSE 流解析、响应构建"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any, Iterator
import requests
from flask import Response, jsonify
from config import Config
logger = logging.getLogger(__name__)
def gen_id(prefix: str = '') -> str:
"""生成带可选前缀的短随机 ID用于请求和工具调用标识。"""
return f'{prefix}{uuid.uuid4().hex[:24]}'
# ─── 请求头构建 ────────────────────────────────────
def build_openai_headers(api_key: str) -> dict[str, str]:
"""构建 OpenAI 兼容请求头"""
return {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
def build_anthropic_headers(api_key: str) -> dict[str, str]:
"""构建 Anthropic 请求头,根据密钥前缀自动选择鉴权方式"""
headers = {
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
}
if api_key.startswith('sk-'):
headers['x-api-key'] = api_key
else:
headers['Authorization'] = f'Bearer {api_key}'
return headers
def build_gemini_headers(api_key: str) -> dict[str, str]:
"""构建 Gemini 请求头,根据密钥前缀选择鉴权方式"""
headers = {'Content-Type': 'application/json'}
if api_key.startswith('AIza'):
headers['x-goog-api-key'] = api_key
else:
headers['Authorization'] = f'Bearer {api_key}'
return headers
# ─── 响应构建 ──────────────────────────────────────
def sse_response(generator):
"""将生成器包装为 SSE 流式响应"""
return Response(
generator,
content_type='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'},
)
def error_json(message, error_type='proxy_error', status=502):
"""构造统一的 JSON 错误响应,供非流式链路直接返回。"""
return jsonify({'error': {'message': str(message), 'type': error_type}}), status
# ─── 上游请求转发 ──────────────────────────────────
def forward_request(url, headers, payload, stream=False):
"""转发请求到上游 API
返回值:
成功: (response, None)
失败(流式): (None, error_body_str)
失败(非流式): (None, Flask Response)
"""
try:
resp = requests.post(
url, headers=headers, json=payload,
timeout=Config.API_TIMEOUT, stream=stream,
)
if resp.status_code != 200:
body = resp.content.decode('utf-8', errors='replace')
logger.warning(f'上游返回 {resp.status_code}: {body[:300]}')
if stream:
return None, f'上游错误 {resp.status_code}: {body}'
return None, Response(
resp.content, status=resp.status_code,
content_type=resp.headers.get('Content-Type', 'application/json'),
)
return resp, None
except requests.RequestException as e:
logger.error(f'请求上游失败: {e}')
if stream:
return None, str(e)
return None, error_json(str(e))
# ─── SSE 流解析 ───────────────────────────────────
def iter_openai_sse(response) -> Iterator[dict[str, Any] | None]:
"""解析 OpenAI SSE 流yield chunk 字典yield None 表示 [DONE]"""
for line in response.iter_lines():
if not line:
continue
decoded = line.decode('utf-8', errors='replace')
if not decoded.startswith('data:'):
continue
data_str = decoded[5:].strip()
if data_str == '[DONE]':
yield None
return
try:
yield json.loads(data_str)
except json.JSONDecodeError:
continue
def iter_anthropic_sse(response) -> Iterator[tuple[str, dict[str, Any]]]:
"""解析 Anthropic SSE 流yield (event_type, data_dict) 元组"""
yield from _iter_event_sse(response)
def iter_responses_sse(response) -> Iterator[tuple[str, dict[str, Any]]]:
"""解析 OpenAI Responses SSE 流yield (event_type, data_dict) 元组"""
yield from _iter_event_sse(response)
def iter_gemini_sse(response) -> Iterator[dict[str, Any]]:
"""解析 Gemini SSE 流yield 完整的 GenerateContentResponse 字典。
Gemini 流式使用 ?alt=sse每个 data: 行是一个完整的 JSON 响应。
"""
for line in response.iter_lines():
if not line:
continue
decoded = line.decode('utf-8', errors='replace')
if not decoded.startswith('data:'):
continue
data_str = decoded[5:].strip()
if not data_str:
continue
try:
yield json.loads(data_str)
except json.JSONDecodeError:
continue
def _iter_event_sse(response) -> Iterator[tuple[str, dict[str, Any]]]:
"""解析带 event/data 的通用 SSE 流。
SSE 规范中空行是事件分隔符,遇到空行时重置 event_type
避免前一个事件的类型泄漏到下一个事件。
"""
event_type = ''
for line in response.iter_lines():
if not line:
event_type = ''
continue
decoded = line.decode('utf-8', errors='replace')
if decoded.startswith('event:'):
event_type = decoded[6:].strip()
elif decoded.startswith('data:'):
data_str = decoded[5:].strip()
if not data_str:
continue
try:
yield event_type, json.loads(data_str)
except json.JSONDecodeError:
continue