初始化提交

This commit is contained in:
h88782481 2026-03-09 14:18:42 +08:00
commit 202731df74
28 changed files with 3140 additions and 0 deletions

14
routes/__init__.py Normal file
View file

@ -0,0 +1,14 @@
"""路由注册"""
def register_routes(app):
"""将所有路由蓝图注册到 Flask 应用"""
from routes.chat import bp as chat_bp
from routes.responses import bp as responses_bp
from routes.messages import bp as messages_bp
from routes.admin import bp as admin_bp
app.register_blueprint(chat_bp)
app.register_blueprint(responses_bp)
app.register_blueprint(messages_bp)
app.register_blueprint(admin_bp)

195
routes/admin.py Normal file
View file

@ -0,0 +1,195 @@
"""路由: 管理面板
提供 Web 管理界面和 API
- /admin 管理面板页面
- /v1/models 模型列表 Cursor 查询
- /api/admin/* 登录验证全局设置 CRUD模型映射 CRUD
"""
import os
import logging
from flask import Blueprint, request, jsonify, send_from_directory
import settings
from config import Config
logger = logging.getLogger(__name__)
_STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'static')
bp = Blueprint('admin', __name__)
# ─── 静态页面 ─────────────────────────────────────
@bp.route('/admin')
@bp.route('/admin/')
def admin_page():
return send_from_directory(_STATIC_DIR, 'admin.html')
@bp.route('/static/<path:filename>')
def static_files(filename):
return send_from_directory(_STATIC_DIR, filename)
# ─── 模型列表 ─────────────────────────────────────
@bp.route('/v1/models', methods=['GET'])
def list_models():
mappings = settings.get().get('model_mappings', {})
models = [{
'id': name,
'object': 'model',
'owned_by': info.get('backend', 'custom'),
} for name, info in mappings.items()]
if not models:
models.append({
'id': 'claude-sonnet-4-5-20250929',
'object': 'model',
'owned_by': 'anthropic',
})
return jsonify({'object': 'list', 'data': models})
# ─── 登录验证 ─────────────────────────────────────
@bp.route('/api/admin/login', methods=['POST'])
def admin_login():
data = request.get_json(force=True)
if not Config.ACCESS_API_KEY:
return jsonify({'ok': True, 'message': '未配置鉴权'})
if data.get('key', '') == Config.ACCESS_API_KEY:
return jsonify({'ok': True})
return jsonify({'ok': False, 'message': '密钥错误'}), 401
# ─── 全局设置 ─────────────────────────────────────
@bp.route('/api/admin/settings', methods=['GET'])
def get_settings():
err = _check_auth()
if err:
return err
s = settings.get()
return jsonify({
'proxy_target_url': s.get('proxy_target_url', ''),
'proxy_api_key': s.get('proxy_api_key', ''),
'env_target_url': Config.PROXY_TARGET_URL,
'env_api_key': '***' if Config.PROXY_API_KEY else '',
})
@bp.route('/api/admin/settings', methods=['PUT'])
def update_settings():
err = _check_auth()
if err:
return err
data = request.get_json(force=True)
s = settings.get()
for key in ('proxy_target_url', 'proxy_api_key'):
if key in data:
s[key] = data[key]
return _save_and_respond(s, '全局设置已更新')
# ─── 模型映射 CRUD ────────────────────────────────
@bp.route('/api/admin/mappings', methods=['GET'])
def list_mappings():
err = _check_auth()
if err:
return err
return jsonify(settings.get().get('model_mappings', {}))
@bp.route('/api/admin/mappings', methods=['POST'])
def add_mapping():
err = _check_auth()
if err:
return err
data = request.get_json(force=True)
name = data.get('name', '').strip()
if not name:
return jsonify({'error': '名称不能为空'}), 400
s = settings.get()
mappings = s.setdefault('model_mappings', {})
mappings[name] = {
'upstream_model': data.get('upstream_model', name),
'backend': data.get('backend', 'auto'),
'target_url': data.get('target_url', ''),
'api_key': data.get('api_key', ''),
}
return _save_and_respond(s, f'映射已添加: {name}')
@bp.route('/api/admin/mappings/<path:name>', methods=['PUT'])
def update_mapping(name):
err = _check_auth()
if err:
return err
data = request.get_json(force=True)
s = settings.get()
mappings = s.get('model_mappings', {})
if name not in mappings:
return jsonify({'error': '映射不存在'}), 404
new_name = data.get('name', name).strip()
entry = {
'upstream_model': data.get('upstream_model', name),
'backend': data.get('backend', 'auto'),
'target_url': data.get('target_url', ''),
'api_key': data.get('api_key', ''),
}
if new_name != name:
del mappings[name]
mappings[new_name] = entry
s['model_mappings'] = mappings
return _save_and_respond(s, f'映射已更新: {name}{new_name}')
@bp.route('/api/admin/mappings/<path:name>', methods=['DELETE'])
def delete_mapping(name):
err = _check_auth()
if err:
return err
s = settings.get()
mappings = s.get('model_mappings', {})
if name in mappings:
del mappings[name]
s['model_mappings'] = mappings
return _save_and_respond(s, f'映射已删除: {name}')
return jsonify({'ok': True})
# ─── 内部辅助 ─────────────────────────────────────
def _check_auth():
"""Admin API 鉴权,返回 None 表示通过"""
if not Config.ACCESS_API_KEY:
return None
auth = request.headers.get('Authorization', '')
token = auth[7:] if auth.startswith('Bearer ') else request.headers.get('x-api-key', '')
if token != Config.ACCESS_API_KEY:
return jsonify({'error': '未授权'}), 401
return None
def _save_and_respond(data, log_msg):
"""保存配置并返回响应"""
try:
settings.save(data)
except OSError as e:
logger.error(f'保存失败: {e}')
return jsonify({'error': {'message': f'保存失败: {e}', 'type': 'save_error'}}), 500
logger.info(log_msg)
return jsonify({'ok': True})

216
routes/chat.py Normal file
View file

@ -0,0 +1,216 @@
"""路由: /v1/chat/completions
处理 Cursor 发来的 OpenAI Chat Completions 格式请求
根据模型映射的 backend 字段分发到 OpenAI Anthropic 后端
"""
import json
import logging
from flask import Blueprint, request, jsonify
import settings
from config import Config
from adapters.openai_fixer import normalize_request, fix_response, fix_stream_chunk
from adapters.openai_anthropic import (
cc_to_messages_request, messages_to_cc_response, AnthropicStreamConverter,
)
from adapters.responses_adapter import responses_to_cc
from utils.http import (
build_openai_headers, build_anthropic_headers,
forward_request, sse_response,
iter_openai_sse, iter_anthropic_sse,
)
from utils.think_tag import ThinkTagExtractor
logger = logging.getLogger(__name__)
def _dbg(msg):
"""DEBUG 模式下输出详细日志"""
if Config.DEBUG:
logger.info(f'[调试] {msg}')
bp = Blueprint('chat', __name__)
@bp.route('/v1/chat/completions', methods=['POST'])
def chat_completions():
payload = request.get_json(force=True)
is_stream = payload.get('stream', False)
# 保留 Cursor 发送的原始模型名,响应时需要回填
cursor_model = payload.get('model', 'unknown')
msg_count = len(payload.get('messages', []))
# 容错Responses 格式误入 CC 端点
if msg_count == 0 and 'input' in payload:
logger.info('检测到 Responses 格式(有 input 无 messages自动转换')
payload = responses_to_cc(payload)
msg_count = len(payload.get('messages', []))
elif msg_count == 0:
logger.warning(f'messages 为空, payload keys: {list(payload.keys())}')
mapping = settings.resolve_model(cursor_model)
backend = mapping['backend']
upstream = mapping['upstream_model']
url_base = mapping['target_url']
api_key = mapping['api_key']
logger.info(
f'[CC] {cursor_model}{upstream} '
f'后端={backend} 流式={is_stream} 消息数={msg_count}'
)
_log_messages(payload)
if backend == 'openai':
return _via_openai(payload, upstream, url_base, api_key, is_stream, cursor_model)
else:
return _via_anthropic(payload, upstream, url_base, api_key, is_stream, cursor_model)
# ─── OpenAI 后端 ──────────────────────────────────
def _via_openai(payload, upstream, url_base, api_key, is_stream, cursor_model):
"""通过 OpenAI 兼容后端转发"""
_dbg(f'Cursor 原始请求 keys={list(payload.keys())} '
f'其他字段={json.dumps({k: v for k, v in payload.items() if k != "messages"}, ensure_ascii=False, default=str)[:500]}')
payload = normalize_request(payload, upstream)
_dbg(f'normalize 后 model={payload.get("model")} tools数={len(payload.get("tools", []))}')
headers = build_openai_headers(api_key)
url = f'{url_base.rstrip("/")}/v1/chat/completions'
if not is_stream:
payload['stream'] = False
resp, err = forward_request(url, headers, payload)
if err:
return err
raw = resp.json()
_dbg(f'上游原始响应={json.dumps(raw, ensure_ascii=False, default=str)[:1000]}')
data = fix_response(raw)
data['model'] = cursor_model
_dbg(f'修复后响应={json.dumps(data, ensure_ascii=False, default=str)[:1000]}')
usage = data.get('usage', {})
logger.info(
f'[CC] 完成 prompt={usage.get("prompt_tokens", 0)} '
f'completion={usage.get("completion_tokens", 0)}'
)
return jsonify(data)
# 流式处理
payload['stream'] = True
_n = [0]
def generate():
resp, err = forward_request(url, headers, payload, stream=True)
if err:
yield f'data: {json.dumps({"error": {"message": err, "type": "upstream_error"}})}\n\n'
return
think_ext = ThinkTagExtractor()
for chunk in iter_openai_sse(resp):
if chunk is None: # [DONE]
_dbg(f'流结束,共 {_n[0]} 个 chunk')
yield 'data: [DONE]\n\n'
return
if _n[0] < 10:
_dbg(f'上游原始 chunk#{_n[0]}={json.dumps(chunk, ensure_ascii=False, default=str)[:500]}')
chunk = fix_stream_chunk(chunk)
chunk['model'] = cursor_model
for out in think_ext.process_chunk(chunk):
if _n[0] < 10:
_dbg(f'发给Cursor chunk#{_n[0]}={json.dumps(out, ensure_ascii=False, default=str)[:500]}')
yield f'data: {json.dumps(out)}\n\n'
_n[0] += 1
return sse_response(generate())
# ─── Anthropic 后端 ───────────────────────────────
def _via_anthropic(payload, upstream, url_base, api_key, is_stream, cursor_model):
"""通过 Anthropic 后端转发CC → Messages → CC"""
payload['model'] = upstream
anthropic_payload = cc_to_messages_request(payload)
_dbg(f'CC→Messages 转换后 keys={list(anthropic_payload.keys())} '
f'messages数={len(anthropic_payload.get("messages", []))}')
headers = build_anthropic_headers(api_key)
url = f'{url_base.rstrip("/")}/v1/messages'
if not is_stream:
anthropic_payload['stream'] = False
resp, err = forward_request(url, headers, anthropic_payload)
if err:
return err
raw = resp.json()
_dbg(f'上游原始响应={json.dumps(raw, ensure_ascii=False, default=str)[:1000]}')
data = messages_to_cc_response(raw)
data['model'] = cursor_model
_dbg(f'Messages→CC 转换后={json.dumps(data, ensure_ascii=False, default=str)[:1000]}')
usage = data.get('usage', {})
logger.info(
f'[CC] 完成 prompt={usage.get("prompt_tokens", 0)} '
f'completion={usage.get("completion_tokens", 0)}'
)
return jsonify(data)
# 流式处理
anthropic_payload['stream'] = True
converter = AnthropicStreamConverter()
_n = [0]
def generate():
resp, err = forward_request(url, headers, anthropic_payload, stream=True)
if err:
yield f'data: {json.dumps({"error": {"message": err, "type": "upstream_error"}})}\n\n'
return
for event_type, event_data in iter_anthropic_sse(resp):
if _n[0] < 10:
_dbg(f'上游事件#{_n[0]} {event_type}={json.dumps(event_data, ensure_ascii=False, default=str)[:500]}')
for chunk_str in converter.process_event(event_type, event_data):
try:
chunk_obj = json.loads(chunk_str)
chunk_obj['model'] = cursor_model
chunk_str = json.dumps(chunk_obj)
except (json.JSONDecodeError, TypeError):
pass
if _n[0] < 10:
_dbg(f'发给Cursor chunk#{_n[0]}={chunk_str[:500]}')
yield f'data: {chunk_str}\n\n'
_n[0] += 1
_dbg(f'流结束,共 {_n[0]} 个事件')
yield 'data: [DONE]\n\n'
return sse_response(generate())
def _log_messages(payload):
"""记录请求中的消息摘要"""
for i, msg in enumerate(payload.get('messages', [])):
role = msg.get('role', '?')
content = msg.get('content')
extra = ''
if 'tool_calls' in msg:
extra += f' tool_calls={len(msg["tool_calls"])}'
if msg.get('tool_call_id'):
extra += f' tool_call_id={msg["tool_call_id"]}'
if isinstance(content, list):
info = f'list[{len(content)}]'
elif isinstance(content, str):
info = f'str[{len(content)}]'
else:
info = type(content).__name__
logger.info(f' 消息[{i}] {role} {info}{extra}')

147
routes/messages.py Normal file
View file

@ -0,0 +1,147 @@
"""路由: /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 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():
payload = request.get_json(force=True)
model = payload.get('model', 'unknown')
is_stream = payload.get('stream', False)
logger.info(f'[透传] model={model} 流式={is_stream}')
url_base = settings.get_url()
api_key = settings.get_key()
headers = build_anthropic_headers(api_key)
url = f'{url_base.rstrip("/")}/v1/messages'
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():
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):
"""生成 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'
)

126
routes/responses.py Normal file
View file

@ -0,0 +1,126 @@
"""路由: /v1/responses
处理 Cursor GPT/Claude-Opus 等模型发出的 Responses API 格式请求
转换为 CC 格式后分发到对应后端响应再转回 Responses 格式
"""
import json
import logging
from flask import Blueprint, request, jsonify
import settings
from adapters.responses_adapter import responses_to_cc, cc_to_responses, ResponsesStreamConverter
from adapters.openai_fixer import normalize_request, fix_response, fix_stream_chunk
from adapters.openai_anthropic import cc_to_messages_request, messages_to_cc_response
from utils.http import (
build_openai_headers, build_anthropic_headers,
forward_request, sse_response,
iter_openai_sse, iter_anthropic_sse,
)
from utils.think_tag import ThinkTagExtractor
logger = logging.getLogger(__name__)
bp = Blueprint('responses', __name__)
@bp.route('/v1/responses', methods=['POST'])
def responses_endpoint():
payload = request.get_json(force=True)
model = payload.get('model', 'unknown')
is_stream = payload.get('stream', False)
mapping = settings.resolve_model(model)
backend = mapping['backend']
upstream = mapping['upstream_model']
url_base = mapping['target_url']
api_key = mapping['api_key']
logger.info(f'[Responses] {model}{upstream} 后端={backend} 流式={is_stream}')
# Responses → CC
cc_payload = responses_to_cc(payload)
cc_payload['model'] = upstream
if backend == 'openai':
return _via_openai(cc_payload, url_base, api_key, is_stream, model)
else:
return _via_anthropic(cc_payload, url_base, api_key, is_stream, model)
# ─── OpenAI 后端 ──────────────────────────────────
def _via_openai(cc_payload, url_base, api_key, is_stream, display_model):
"""通过 OpenAI 后端处理"""
cc_payload = normalize_request(cc_payload)
headers = build_openai_headers(api_key)
url = f'{url_base.rstrip("/")}/v1/chat/completions'
if not is_stream:
cc_payload['stream'] = False
resp, err = forward_request(url, headers, cc_payload)
if err:
return err
return jsonify(cc_to_responses(fix_response(resp.json()), display_model))
# 流式处理
cc_payload['stream'] = True
converter = ResponsesStreamConverter(model=display_model)
def generate():
yield from converter.start_events()
resp, err = forward_request(url, headers, cc_payload, stream=True)
if err:
yield f'event: error\ndata: {json.dumps({"error": err})}\n\n'
return
think_ext = ThinkTagExtractor()
for chunk in iter_openai_sse(resp):
if chunk is None:
yield from converter.finalize()
return
chunk = fix_stream_chunk(chunk)
for out in think_ext.process_chunk(chunk):
yield from converter.process_cc_chunk(out)
return sse_response(generate())
# ─── Anthropic 后端 ───────────────────────────────
def _via_anthropic(cc_payload, url_base, api_key, is_stream, display_model):
"""通过 Anthropic 后端处理"""
anthropic_payload = cc_to_messages_request(cc_payload)
headers = build_anthropic_headers(api_key)
url = f'{url_base.rstrip("/")}/v1/messages'
if not is_stream:
anthropic_payload['stream'] = False
resp, err = forward_request(url, headers, anthropic_payload)
if err:
return err
cc_data = messages_to_cc_response(resp.json())
return jsonify(cc_to_responses(cc_data, display_model))
# 流式处理Anthropic SSE → Responses SSE跳过 CC 中间态)
anthropic_payload['stream'] = True
converter = ResponsesStreamConverter(model=display_model)
def generate():
yield from converter.start_events()
resp, err = forward_request(url, headers, anthropic_payload, stream=True)
if err:
yield f'event: error\ndata: {json.dumps({"error": err})}\n\n'
return
for event_type, event_data in iter_anthropic_sse(resp):
yield from converter.process_anthropic_event(event_type, event_data)
yield from converter.finalize()
return sse_response(generate())