初始化提交
This commit is contained in:
commit
202731df74
28 changed files with 3140 additions and 0 deletions
14
routes/__init__.py
Normal file
14
routes/__init__.py
Normal 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
195
routes/admin.py
Normal 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
216
routes/chat.py
Normal 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
147
routes/messages.py
Normal 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
126
routes/responses.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue