api2cursor/routes/messages.py
2026-03-13 17:22:28 +08:00

155 lines
5.2 KiB
Python

"""路由: /v1/messages
Anthropic Messages API 透传。当 Cursor 直接发送 Anthropic 格式请求时,
直接转发到上游并原样返回。处理非标准的 reasoning_content 字段,
将其注入为标准的 thinking blocks。
"""
import json
import logging
import requests as req_lib
from flask import Blueprint, request, jsonify
import settings
from config import Config
from routes.common import inject_instructions_anthropic
from utils.http import build_anthropic_headers, forward_request, sse_response
logger = logging.getLogger(__name__)
bp = Blueprint('messages', __name__)
@bp.route('/v1/messages', methods=['POST'])
def messages_passthrough():
"""透传 Anthropic Messages 请求,并在必要时补齐 thinking 兼容层。"""
payload = request.get_json(force=True)
model = payload.get('model', 'unknown')
is_stream = payload.get('stream', False)
logger.info(f'[透传] model={model} 流式={is_stream}')
mapping = settings.resolve_model(model)
url_base = mapping['target_url']
api_key = mapping['api_key']
custom_instructions = mapping.get('custom_instructions', '')
instructions_position = mapping.get('instructions_position', 'prepend')
headers = build_anthropic_headers(api_key)
url = f'{url_base.rstrip("/")}/v1/messages'
payload = inject_instructions_anthropic(payload, custom_instructions, instructions_position)
if not is_stream:
resp, err = forward_request(url, headers, payload)
if err:
return err
data = resp.json()
_inject_thinking(data)
return jsonify(data)
# 流式透传
def generate():
"""建立上游流式连接并逐段回传处理后的 SSE 数据。"""
try:
resp = req_lib.post(
url, headers=headers, json=payload,
timeout=Config.API_TIMEOUT, stream=True,
)
if resp.status_code != 200:
body = resp.content.decode('utf-8', errors='replace')
logger.warning(f'上游返回 {resp.status_code}: {body[:300]}')
yield f'data: {json.dumps({"error": {"message": body, "type": "upstream_error"}})}\n\n'
return
yield from _process_stream(resp)
except req_lib.RequestException as e:
logger.error(f'请求上游失败: {e}')
yield f'data: {json.dumps({"error": {"message": str(e), "type": "proxy_error"}})}\n\n'
return sse_response(generate())
# ─── 内部辅助 ─────────────────────────────────────
def _inject_thinking(data):
"""将非标准 reasoning_content 字段注入为 Anthropic thinking block"""
rc = data.pop('reasoning_content', None) or data.pop('reasoningContent', None)
if not rc:
return
content = data.get('content')
if not isinstance(content, list):
content = []
# 避免重复注入
if any(isinstance(b, dict) and b.get('type') == 'thinking' for b in content):
return
content.insert(0, {'type': 'thinking', 'thinking': rc})
data['content'] = content
logger.info(f'已注入 thinking block ({len(rc)} 字符)')
def _process_stream(resp):
"""处理 /v1/messages 流式响应,检测并注入 thinking 事件"""
reasoning_buf = ''
injected = False
for line in resp.iter_lines():
if not line:
continue
decoded = line.decode('utf-8', errors='replace')
if not decoded.startswith('data:'):
yield decoded + '\n\n'
continue
data_str = decoded[5:].strip()
if not data_str:
yield decoded + '\n\n'
continue
try:
event_data = json.loads(data_str)
except json.JSONDecodeError:
yield decoded + '\n\n'
continue
modified = False
# 提取 reasoning_content
for container_key in ('message', 'delta'):
container = event_data.get(container_key)
if not container:
continue
rc = container.pop('reasoning_content', None) or container.pop('reasoningContent', None)
if rc:
reasoning_buf += rc
modified = True
# 在首个 text_delta 前注入 thinking blocks
if reasoning_buf and not injected:
if event_data.get('delta', {}).get('type') == 'text_delta':
injected = True
yield from _emit_thinking_blocks(reasoning_buf)
reasoning_buf = ''
yield f'data: {json.dumps(event_data)}\n\n' if modified else decoded + '\n\n'
def _emit_thinking_blocks(text):
"""生成一组等价的 Anthropic thinking block SSE 事件。"""
yield (
f'event: content_block_start\n'
f'data: {json.dumps({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}})}\n\n'
)
yield (
f'event: content_block_delta\n'
f'data: {json.dumps({"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": text}})}\n\n'
)
yield (
f'event: content_block_stop\n'
f'data: {json.dumps({"type": "content_block_stop", "index": 0})}\n\n'
)