补全注释
This commit is contained in:
parent
96fbc4da80
commit
f193c48ce1
13 changed files with 115 additions and 14 deletions
|
|
@ -116,6 +116,7 @@ class AnthropicStreamConverter:
|
|||
"""
|
||||
|
||||
def __init__(self, request_id: str | None = None):
|
||||
"""初始化流式转换所需的请求标识、工具索引和 usage 状态。"""
|
||||
self._id = request_id or gen_id('chatcmpl-')
|
||||
self._tool_index = -1
|
||||
self._input_tokens = 0
|
||||
|
|
@ -509,7 +510,7 @@ def _convert_tools(tools: Any) -> list[JsonDict]:
|
|||
|
||||
|
||||
def _convert_tool_definition(tool: Any) -> JsonDict | None:
|
||||
"""转换单个工具定义。"""
|
||||
"""将 OpenAI 或 Anthropic 风格的工具定义统一转换为 Anthropic `tools` 项。"""
|
||||
if not isinstance(tool, dict):
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,11 @@ def responses_to_cc_response(response_data: JsonDict, model: str = '') -> JsonDi
|
|||
|
||||
@dataclass
|
||||
class _ToolBuffer:
|
||||
"""缓存单个工具调用的流式状态。"""
|
||||
"""缓存单个工具调用的流式状态。
|
||||
|
||||
Responses 风格的 function_call 会把名称、参数增量和完成时机拆散在多个事件里,
|
||||
转换器需要用这个缓冲结构暂存工具标识与累计参数,便于后续按顺序补齐事件。
|
||||
"""
|
||||
|
||||
name: str
|
||||
args: str
|
||||
|
|
@ -155,6 +159,7 @@ class ResponsesStreamConverter:
|
|||
"""
|
||||
|
||||
def __init__(self, response_id: str | None = None, model: str = ''):
|
||||
"""初始化 Responses 流式状态机所需的各类缓冲区与标识。"""
|
||||
self.resp_id = response_id or gen_id('resp_')
|
||||
self.model = model
|
||||
|
||||
|
|
@ -366,6 +371,7 @@ class ResponsesStreamConverter:
|
|||
return events
|
||||
|
||||
def _on_tool_call(self, tool_call: JsonDict) -> list[str]:
|
||||
"""处理来自 CC chunk 的工具调用增量,并映射成 Responses function_call 事件。"""
|
||||
events: list[str] = []
|
||||
index = tool_call.get('index', 0)
|
||||
function_data = tool_call.get('function') or {}
|
||||
|
|
@ -382,6 +388,7 @@ class ResponsesStreamConverter:
|
|||
return events
|
||||
|
||||
def _ensure_text_started(self) -> list[str]:
|
||||
"""确保 assistant 文本输出项已开启,并在必要时先关闭 reasoning 项。"""
|
||||
events: list[str] = []
|
||||
if self._rs_started and not self._rs_closed:
|
||||
events.extend(self._close_reasoning())
|
||||
|
|
@ -401,6 +408,7 @@ class ResponsesStreamConverter:
|
|||
return events
|
||||
|
||||
def _start_tool_from_block(self, block: JsonDict) -> list[str]:
|
||||
"""根据 Anthropic `tool_use` block 创建对应的 function_call 输出项。"""
|
||||
return self._start_tool(
|
||||
index=len(self._tools),
|
||||
call_id=block.get('id', gen_id('toolu_')),
|
||||
|
|
@ -408,6 +416,7 @@ class ResponsesStreamConverter:
|
|||
)
|
||||
|
||||
def _start_tool(self, *, index: int, call_id: str, name: str) -> list[str]:
|
||||
"""开启一个新的 Responses function_call 输出项,并关闭前置输出段。"""
|
||||
events: list[str] = []
|
||||
if self._rs_started and not self._rs_closed:
|
||||
events.extend(self._close_reasoning())
|
||||
|
|
@ -432,6 +441,7 @@ class ResponsesStreamConverter:
|
|||
return events
|
||||
|
||||
def _append_tool_arguments(self, index: int, arguments_delta: str) -> list[str]:
|
||||
"""向指定 function_call 缓冲区追加参数增量,并发出对应 SSE 事件。"""
|
||||
buffer = self._tools[index]
|
||||
buffer.args += arguments_delta
|
||||
return [self._sse('response.function_call_arguments.delta', {
|
||||
|
|
@ -440,6 +450,7 @@ class ResponsesStreamConverter:
|
|||
})]
|
||||
|
||||
def _close_reasoning(self) -> list[str]:
|
||||
"""关闭 reasoning 输出项,并补发 summary 完成事件。"""
|
||||
if self._rs_closed:
|
||||
return []
|
||||
self._rs_closed = True
|
||||
|
|
@ -459,6 +470,7 @@ class ResponsesStreamConverter:
|
|||
]
|
||||
|
||||
def _close_text(self) -> list[str]:
|
||||
"""关闭 assistant 文本输出项,并补发文本完成事件。"""
|
||||
if self._text_closed:
|
||||
return []
|
||||
self._text_closed = True
|
||||
|
|
@ -525,6 +537,7 @@ class ResponsesStreamConverter:
|
|||
return events
|
||||
|
||||
def _sse(self, event_type: str, data: JsonDict) -> str:
|
||||
"""将事件类型与负载编码为标准 Responses SSE 字符串。"""
|
||||
return f'event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n'
|
||||
|
||||
def _rewrite_top_level_model(self, payload: JsonDict) -> JsonDict:
|
||||
|
|
@ -554,6 +567,7 @@ class ResponsesToCCStreamConverter:
|
|||
"""
|
||||
|
||||
def __init__(self, request_id: str | None = None, model: str = ''):
|
||||
"""初始化 Responses → Chat Completions 流式转换所需的状态。"""
|
||||
self._id = request_id or gen_id('chatcmpl-')
|
||||
self._model = model
|
||||
self._tool_index = 0
|
||||
|
|
@ -652,6 +666,7 @@ class ResponsesToCCStreamConverter:
|
|||
|
||||
|
||||
def _copy_request_options(payload: JsonDict, result: JsonDict) -> None:
|
||||
"""将 Responses 请求中的通用选项复制到 CC 请求体。"""
|
||||
if 'tools' in payload:
|
||||
result['tools'] = _convert_tools(payload['tools'])
|
||||
for key in ('temperature', 'top_p'):
|
||||
|
|
@ -664,6 +679,7 @@ def _copy_request_options(payload: JsonDict, result: JsonDict) -> None:
|
|||
|
||||
|
||||
def _copy_responses_request_options(payload: JsonDict, result: JsonDict) -> None:
|
||||
"""将聊天补全请求中的通用选项复制到原生 Responses 请求体。"""
|
||||
if 'tools' in payload:
|
||||
result['tools'] = _convert_cc_tools_to_responses(payload['tools'])
|
||||
for key in ('temperature', 'top_p', 'tool_choice'):
|
||||
|
|
@ -678,6 +694,7 @@ def _append_responses_input_item(
|
|||
instructions: list[str],
|
||||
input_items: list[JsonDict],
|
||||
) -> None:
|
||||
"""将单条 Chat Completions 消息追加为 Responses `input` 项。"""
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
|
||||
|
|
@ -711,6 +728,7 @@ def _append_responses_input_item(
|
|||
|
||||
|
||||
def _convert_input_items(items: list[Any], messages: list[JsonDict]) -> None:
|
||||
"""将 Responses `input` 数组重建为 Chat Completions `messages` 列表。"""
|
||||
index = 0
|
||||
while index < len(items):
|
||||
item = items[index]
|
||||
|
|
@ -756,6 +774,7 @@ def _convert_input_items(items: list[Any], messages: list[JsonDict]) -> None:
|
|||
|
||||
|
||||
def _append_message_item(items: list[Any], *, start: int, messages: list[JsonDict]) -> int:
|
||||
"""将一个 message 项及其后续连续 function_call 项合并成一条消息。"""
|
||||
item = items[start]
|
||||
role = item.get('role', 'assistant')
|
||||
content = _extract_text(item.get('content', []))
|
||||
|
|
@ -775,6 +794,7 @@ def _append_message_item(items: list[Any], *, start: int, messages: list[JsonDic
|
|||
|
||||
|
||||
def _append_function_call_item(item: JsonDict, messages: list[JsonDict]) -> None:
|
||||
"""将独立的 Responses `function_call` 项挂接到最近的 assistant 消息上。"""
|
||||
tool_call = _build_cc_tool_call(item)
|
||||
|
||||
if messages and messages[-1]['role'] == 'assistant':
|
||||
|
|
@ -787,6 +807,7 @@ def _append_function_call_item(item: JsonDict, messages: list[JsonDict]) -> None
|
|||
|
||||
|
||||
def _convert_function_call_output_item(item: JsonDict) -> JsonDict:
|
||||
"""将 Responses 的 `function_call_output` 项转换为 OpenAI `tool` 消息。"""
|
||||
output = item.get('output', '')
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output, ensure_ascii=False)
|
||||
|
|
@ -798,12 +819,14 @@ def _convert_function_call_output_item(item: JsonDict) -> JsonDict:
|
|||
|
||||
|
||||
def _normalize_simple_content(content: Any) -> str:
|
||||
"""将简单 content 载荷规范化为纯文本字符串。"""
|
||||
if isinstance(content, list):
|
||||
return _extract_text(content) or ''
|
||||
return str(content) if content is not None else ''
|
||||
|
||||
|
||||
def _collect_function_calls(items: list[Any], start: int) -> tuple[list[JsonDict], int]:
|
||||
"""收集从指定位置开始连续出现的 `function_call` 项。"""
|
||||
tool_calls: list[JsonDict] = []
|
||||
index = start
|
||||
while index < len(items):
|
||||
|
|
@ -817,6 +840,7 @@ def _collect_function_calls(items: list[Any], start: int) -> tuple[list[JsonDict
|
|||
|
||||
|
||||
def _build_cc_tool_call(item: JsonDict) -> JsonDict:
|
||||
"""将单个 Responses `function_call` 项转换为 CC `tool_call` 结构。"""
|
||||
return {
|
||||
'id': item.get('call_id') or gen_id('call_'),
|
||||
'type': 'function',
|
||||
|
|
@ -833,6 +857,7 @@ def _build_cc_tool_call(item: JsonDict) -> JsonDict:
|
|||
|
||||
|
||||
def _build_responses_output(message: JsonDict) -> list[JsonDict]:
|
||||
"""将单条聊天补全消息展开为 Responses `output` 数组。"""
|
||||
output: list[JsonDict] = []
|
||||
|
||||
if message.get('reasoning_content'):
|
||||
|
|
@ -846,6 +871,7 @@ def _build_responses_output(message: JsonDict) -> list[JsonDict]:
|
|||
|
||||
|
||||
def _make_reasoning_output_item(text: str) -> JsonDict:
|
||||
"""构造单个 Responses reasoning 输出项。"""
|
||||
return {
|
||||
'type': 'reasoning',
|
||||
'id': gen_id('rs_'),
|
||||
|
|
@ -854,6 +880,7 @@ def _make_reasoning_output_item(text: str) -> JsonDict:
|
|||
|
||||
|
||||
def _make_message_output_item(text: str) -> JsonDict:
|
||||
"""构造单个 Responses message 输出项。"""
|
||||
return {
|
||||
'type': 'message',
|
||||
'id': gen_id('msg_'),
|
||||
|
|
@ -864,6 +891,7 @@ def _make_message_output_item(text: str) -> JsonDict:
|
|||
|
||||
|
||||
def _make_function_call_output_item(tool_call: JsonDict) -> JsonDict:
|
||||
"""构造单个 Responses function_call 输出项。"""
|
||||
function_data = tool_call.get('function') or {}
|
||||
return {
|
||||
'type': 'function_call',
|
||||
|
|
@ -876,6 +904,7 @@ def _make_function_call_output_item(tool_call: JsonDict) -> JsonDict:
|
|||
|
||||
|
||||
def _build_responses_usage(usage: JsonDict) -> JsonDict:
|
||||
"""将 Chat Completions 的 usage 字段映射为 Responses usage 结构。"""
|
||||
return {
|
||||
'input_tokens': usage.get('prompt_tokens', 0),
|
||||
'output_tokens': usage.get('completion_tokens', 0),
|
||||
|
|
@ -884,6 +913,7 @@ def _build_responses_usage(usage: JsonDict) -> JsonDict:
|
|||
|
||||
|
||||
def _collect_cc_parts_from_responses_output(output_items: Any) -> tuple[str, str, list[JsonDict]]:
|
||||
"""从 Responses `output` 中提取文本、思考摘要和工具调用。"""
|
||||
content_text = ''
|
||||
reasoning_text = ''
|
||||
tool_calls: list[JsonDict] = []
|
||||
|
|
@ -906,6 +936,7 @@ def _collect_cc_parts_from_responses_output(output_items: Any) -> tuple[str, str
|
|||
|
||||
|
||||
def _extract_reasoning_text(item: JsonDict) -> str:
|
||||
"""从 Responses reasoning 项中拼接出完整的摘要文本。"""
|
||||
summary = item.get('summary', [])
|
||||
if not isinstance(summary, list):
|
||||
return ''
|
||||
|
|
@ -917,6 +948,7 @@ def _extract_reasoning_text(item: JsonDict) -> str:
|
|||
|
||||
|
||||
def _build_cc_tool_call_from_responses_output(item: JsonDict, *, index: int) -> JsonDict:
|
||||
"""将 Responses `function_call` 输出项转换为 CC `tool_call`。"""
|
||||
return {
|
||||
'index': index,
|
||||
'id': item.get('call_id') or gen_id('call_'),
|
||||
|
|
@ -929,6 +961,7 @@ def _build_cc_tool_call_from_responses_output(item: JsonDict, *, index: int) ->
|
|||
|
||||
|
||||
def _cc_finish_reason_from_responses(response_data: JsonDict, tool_calls: list[JsonDict]) -> str:
|
||||
"""根据 Responses 完成状态推断聊天补全的 finish_reason。"""
|
||||
if tool_calls:
|
||||
return 'tool_calls'
|
||||
if response_data.get('status') == 'incomplete':
|
||||
|
|
@ -937,10 +970,12 @@ def _cc_finish_reason_from_responses(response_data: JsonDict, tool_calls: list[J
|
|||
|
||||
|
||||
def _response_status_from_finish_reason(finish_reason: str) -> str:
|
||||
"""将聊天补全 finish_reason 映射为 Responses 顶层状态。"""
|
||||
return 'incomplete' if finish_reason == 'length' else 'completed'
|
||||
|
||||
|
||||
def _map_anthropic_stop_reason(stop_reason: str) -> str:
|
||||
"""将 Anthropic 的 stop_reason 映射为聊天补全风格的结束原因。"""
|
||||
return {'tool_use': 'tool_calls', 'max_tokens': 'length'}.get(stop_reason, 'stop')
|
||||
|
||||
|
||||
|
|
@ -950,6 +985,7 @@ def _map_anthropic_stop_reason(stop_reason: str) -> str:
|
|||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
"""从多种内容块结构中提取并拼接纯文本。"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
|
|
@ -969,6 +1005,7 @@ def _extract_text(content: Any) -> str:
|
|||
|
||||
|
||||
def _content_to_text(content: Any) -> str:
|
||||
"""将任意 content 载荷转换为单个字符串。"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
|
|
@ -977,6 +1014,7 @@ def _content_to_text(content: Any) -> str:
|
|||
|
||||
|
||||
def _content_to_responses_parts(content: Any) -> list[JsonDict]:
|
||||
"""将普通消息内容转换为 Responses `input_text` 数组。"""
|
||||
if isinstance(content, list):
|
||||
text = _extract_text(content)
|
||||
else:
|
||||
|
|
@ -985,6 +1023,7 @@ def _content_to_responses_parts(content: Any) -> list[JsonDict]:
|
|||
|
||||
|
||||
def _stringify_output(content: Any) -> str:
|
||||
"""将工具输出统一序列化为字符串,便于放入 `function_call_output`。"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if content is None:
|
||||
|
|
@ -993,6 +1032,7 @@ def _stringify_output(content: Any) -> str:
|
|||
|
||||
|
||||
def _build_responses_function_call_item(tool_call: JsonDict) -> JsonDict:
|
||||
"""将 CC `tool_call` 结构转换为 Responses `function_call` 输入项。"""
|
||||
function_data = tool_call.get('function') or {}
|
||||
return {
|
||||
'type': 'function_call',
|
||||
|
|
@ -1003,6 +1043,7 @@ def _build_responses_function_call_item(tool_call: JsonDict) -> JsonDict:
|
|||
|
||||
|
||||
def _convert_cc_tools_to_responses(tools: Any) -> list[JsonDict]:
|
||||
"""将聊天补全风格的工具定义转换为 Responses `tools` 列表。"""
|
||||
if not isinstance(tools, list):
|
||||
return []
|
||||
|
||||
|
|
@ -1024,6 +1065,7 @@ def _convert_cc_tools_to_responses(tools: Any) -> list[JsonDict]:
|
|||
|
||||
|
||||
def _convert_tools(tools: Any) -> list[JsonDict]:
|
||||
"""规范化 Responses 请求中的工具定义列表。"""
|
||||
if not isinstance(tools, list):
|
||||
return []
|
||||
|
||||
|
|
@ -1036,6 +1078,7 @@ def _convert_tools(tools: Any) -> list[JsonDict]:
|
|||
|
||||
|
||||
def _convert_tool_definition(tool: Any) -> JsonDict | None:
|
||||
"""将扁平工具定义补成标准 Chat Completions `function` 工具格式。"""
|
||||
if not isinstance(tool, dict):
|
||||
return None
|
||||
if tool.get('type') != 'function':
|
||||
|
|
|
|||
14
app.py
14
app.py
|
|
@ -19,6 +19,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def create_app():
|
||||
"""创建并配置 Flask 应用实例。
|
||||
|
||||
这里统一完成跨路由共享的初始化逻辑,包括配置加载、跨域、错误处理、
|
||||
访问鉴权、健康检查以及蓝图注册。
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
settings.load()
|
||||
|
|
@ -27,20 +32,28 @@ def create_app():
|
|||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
"""将未匹配到的路径统一转换为 JSON 404 响应。"""
|
||||
return jsonify({'error': {'message': '未找到', 'type': 'not_found'}}), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
"""将不支持的请求方法统一转换为 JSON 405 响应。"""
|
||||
return jsonify({'error': {'message': '方法不允许', 'type': 'method_not_allowed'}}), 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
"""将未捕获的服务端异常统一包装为 JSON 500 响应。"""
|
||||
return jsonify({'error': {'message': '服务器内部错误', 'type': 'server_error'}}), 500
|
||||
|
||||
# ─── 全局鉴权中间件 ──────────────────────────
|
||||
|
||||
@app.before_request
|
||||
def check_access():
|
||||
"""在进入业务路由前校验访问密钥。
|
||||
|
||||
当配置了 `ACCESS_API_KEY` 时,除健康检查和管理面板相关路径外,
|
||||
所有请求都必须携带正确的 Bearer Token 或 `x-api-key`。
|
||||
"""
|
||||
if not Config.ACCESS_API_KEY:
|
||||
return
|
||||
|
||||
|
|
@ -61,6 +74,7 @@ def create_app():
|
|||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""返回服务健康状态和当前生效的上游地址。"""
|
||||
return jsonify({'status': 'ok', 'target': settings.get_url()})
|
||||
|
||||
# ─── 注册路由蓝图 ────────────────────────────
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import os
|
|||
|
||||
|
||||
class Config:
|
||||
"""集中声明服务运行依赖的环境变量配置。
|
||||
|
||||
这个类不承担运行时逻辑,只作为模块级配置容器,统一暴露上游地址、
|
||||
鉴权密钥、端口、超时和调试开关,供应用启动、路由鉴权和请求转发层共享。
|
||||
"""
|
||||
|
||||
# 上游 API 地址
|
||||
PROXY_TARGET_URL = os.getenv('PROXY_TARGET_URL', 'https://api.anthropic.com')
|
||||
# 上游 API 密钥
|
||||
|
|
|
|||
|
|
@ -27,11 +27,13 @@ bp = Blueprint('admin', __name__)
|
|||
@bp.route('/admin')
|
||||
@bp.route('/admin/')
|
||||
def admin_page():
|
||||
"""返回管理面板首页 HTML 页面,供浏览器进入配置界面。"""
|
||||
return send_from_directory(_STATIC_DIR, 'admin.html')
|
||||
|
||||
|
||||
@bp.route('/static/<path:filename>')
|
||||
def static_files(filename):
|
||||
"""提供管理面板所需的静态资源文件。"""
|
||||
return send_from_directory(_STATIC_DIR, filename)
|
||||
|
||||
|
||||
|
|
@ -40,6 +42,7 @@ def static_files(filename):
|
|||
|
||||
@bp.route('/v1/models', methods=['GET'])
|
||||
def list_models():
|
||||
"""返回当前配置的模型列表,供 Cursor 拉取可用模型。"""
|
||||
mappings = settings.get().get('model_mappings', {})
|
||||
models = [{
|
||||
'id': name,
|
||||
|
|
@ -61,6 +64,7 @@ def list_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': '未配置鉴权'})
|
||||
|
|
@ -74,6 +78,7 @@ def admin_login():
|
|||
|
||||
@bp.route('/api/admin/settings', methods=['GET'])
|
||||
def get_settings():
|
||||
"""读取当前生效的全局代理配置。"""
|
||||
err = _check_auth()
|
||||
if err:
|
||||
return err
|
||||
|
|
@ -88,6 +93,7 @@ def get_settings():
|
|||
|
||||
@bp.route('/api/admin/settings', methods=['PUT'])
|
||||
def update_settings():
|
||||
"""更新全局上游地址与密钥配置。"""
|
||||
err = _check_auth()
|
||||
if err:
|
||||
return err
|
||||
|
|
@ -104,6 +110,7 @@ def update_settings():
|
|||
|
||||
@bp.route('/api/admin/mappings', methods=['GET'])
|
||||
def list_mappings():
|
||||
"""列出所有模型映射配置,供管理面板读取和展示。"""
|
||||
err = _check_auth()
|
||||
if err:
|
||||
return err
|
||||
|
|
@ -112,6 +119,7 @@ def list_mappings():
|
|||
|
||||
@bp.route('/api/admin/mappings', methods=['POST'])
|
||||
def add_mapping():
|
||||
"""新增一条模型映射,并写入持久化配置。"""
|
||||
err = _check_auth()
|
||||
if err:
|
||||
return err
|
||||
|
|
@ -133,6 +141,7 @@ def add_mapping():
|
|||
|
||||
@bp.route('/api/admin/mappings/<path:name>', methods=['PUT'])
|
||||
def update_mapping(name):
|
||||
"""更新指定名称的模型映射,必要时支持重命名。"""
|
||||
err = _check_auth()
|
||||
if err:
|
||||
return err
|
||||
|
|
@ -158,6 +167,7 @@ def update_mapping(name):
|
|||
|
||||
@bp.route('/api/admin/mappings/<path:name>', methods=['DELETE'])
|
||||
def delete_mapping(name):
|
||||
"""删除指定名称的模型映射,并在存在时同步保存配置。"""
|
||||
err = _check_auth()
|
||||
if err:
|
||||
return err
|
||||
|
|
@ -185,7 +195,10 @@ def _check_auth():
|
|||
|
||||
|
||||
def _save_and_respond(data, log_msg):
|
||||
"""保存配置并返回响应"""
|
||||
"""保存配置并返回统一成功响应。
|
||||
|
||||
当写盘失败时,这里也负责把异常转成结构化的 JSON 错误返回。
|
||||
"""
|
||||
try:
|
||||
settings.save(data)
|
||||
except OSError as e:
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ def _handle_openai_stream(
|
|||
payload['stream'] = True
|
||||
|
||||
def generate():
|
||||
"""消费上游 OpenAI SSE,并逐段产出给 Cursor 的聊天补全流。"""
|
||||
resp, err = forward_request(url, headers, payload, stream=True)
|
||||
if err:
|
||||
yield chat_error_chunk(str(err))
|
||||
|
|
@ -235,6 +236,7 @@ def _handle_responses_stream(
|
|||
converter = ResponsesToCCStreamConverter(model=ctx.client_model)
|
||||
|
||||
def generate():
|
||||
"""消费上游 Responses 事件,并实时转换成聊天补全 chunk。"""
|
||||
resp, err = forward_request(url, headers, payload, stream=True)
|
||||
if err:
|
||||
yield chat_error_chunk(str(err))
|
||||
|
|
@ -314,6 +316,7 @@ def _handle_anthropic_stream(
|
|||
converter = AnthropicStreamConverter()
|
||||
|
||||
def generate():
|
||||
"""消费上游 Anthropic 事件流,并逐步映射为聊天补全 SSE。"""
|
||||
resp, err = forward_request(url, headers, payload, stream=True)
|
||||
if err:
|
||||
yield chat_error_chunk(str(err))
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class RouteContext:
|
||||
"""数据面路由使用的标准请求上下文。"""
|
||||
"""数据面路由使用的标准请求上下文。
|
||||
|
||||
路由层会先根据客户端模型名解析出统一上下文,后续处理函数只需要关心
|
||||
上游模型、后端类型、目标地址、鉴权信息和流式标记,而不必重复访问配置层。
|
||||
"""
|
||||
|
||||
client_model: str
|
||||
upstream_model: str
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ 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)
|
||||
|
|
@ -43,6 +44,7 @@ def messages_passthrough():
|
|||
|
||||
# 流式透传
|
||||
def generate():
|
||||
"""建立上游流式连接并逐段回传处理后的 SSE 数据。"""
|
||||
try:
|
||||
resp = req_lib.post(
|
||||
url, headers=headers, json=payload,
|
||||
|
|
@ -132,7 +134,7 @@ def _process_stream(resp):
|
|||
|
||||
|
||||
def _emit_thinking_blocks(text):
|
||||
"""生成 thinking block 的 SSE 事件序列"""
|
||||
"""生成一组等价的 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'
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ def _handle_openai_stream(
|
|||
converter = ResponsesStreamConverter(model=ctx.client_model)
|
||||
|
||||
def generate():
|
||||
"""消费 OpenAI 聊天补全流,并实时改写为 Responses SSE。"""
|
||||
yield from converter.start_events()
|
||||
|
||||
resp, err = forward_request(url, headers, cc_payload, stream=True)
|
||||
|
|
@ -205,6 +206,7 @@ def _handle_responses_stream(
|
|||
converter = ResponsesStreamConverter(model=ctx.client_model)
|
||||
|
||||
def generate():
|
||||
"""透传上游原生 Responses 流,并做轻量模型名改写。"""
|
||||
resp, err = forward_request(url, headers, payload, stream=True)
|
||||
if err:
|
||||
yield responses_error_event(str(err))
|
||||
|
|
@ -275,6 +277,7 @@ def _handle_anthropic_stream(
|
|||
converter = ResponsesStreamConverter(model=ctx.client_model)
|
||||
|
||||
def generate():
|
||||
"""消费 Anthropic SSE,并直接映射为 Responses 事件序列。"""
|
||||
yield from converter.start_events()
|
||||
|
||||
resp, err = forward_request(url, headers, anthropic_payload, stream=True)
|
||||
|
|
|
|||
24
settings.py
24
settings.py
|
|
@ -27,7 +27,10 @@ _DEFAULTS = {
|
|||
|
||||
|
||||
def load():
|
||||
"""从文件加载配置"""
|
||||
"""从持久化文件读取配置并刷新内存缓存。
|
||||
|
||||
如果配置文件不存在或内容损坏,会回退到默认值,保证服务仍然可以正常启动。
|
||||
"""
|
||||
global _cache
|
||||
with _lock:
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
|
|
@ -42,7 +45,10 @@ def load():
|
|||
|
||||
|
||||
def save(data):
|
||||
"""保存配置到文件"""
|
||||
"""将当前配置写回到持久化文件并同步缓存。
|
||||
|
||||
保存前会确保数据目录存在,并始终以默认配置为基底合并缺失字段。
|
||||
"""
|
||||
global _cache
|
||||
with _lock:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
|
@ -52,24 +58,24 @@ def save(data):
|
|||
|
||||
|
||||
def get():
|
||||
"""获取当前配置(优先使用缓存)"""
|
||||
"""获取当前配置快照,优先返回内存缓存中的结果。"""
|
||||
if _cache is None:
|
||||
return load()
|
||||
return dict(_cache)
|
||||
|
||||
|
||||
def get_url():
|
||||
"""获取生效的上游 URL:配置文件优先,环境变量兜底"""
|
||||
"""获取当前生效的上游 URL,优先使用持久化配置。"""
|
||||
return get().get('proxy_target_url') or Config.PROXY_TARGET_URL
|
||||
|
||||
|
||||
def get_key():
|
||||
"""获取生效的 API 密钥:配置文件优先,环境变量兜底"""
|
||||
"""获取当前生效的 API 密钥,优先使用持久化配置。"""
|
||||
return get().get('proxy_api_key') or Config.PROXY_API_KEY
|
||||
|
||||
|
||||
def resolve_model(model_name):
|
||||
"""解析模型映射,返回 {upstream_model, backend, target_url, api_key}"""
|
||||
"""解析模型映射并返回完整的上游路由信息。"""
|
||||
settings = get()
|
||||
mappings = settings.get('model_mappings', {})
|
||||
base_url, base_key = get_url(), get_key()
|
||||
|
|
@ -95,6 +101,10 @@ def resolve_model(model_name):
|
|||
|
||||
|
||||
def _auto_detect(name):
|
||||
"""根据模型名自动判断后端类型"""
|
||||
"""根据模型名关键字推断默认后端协议类型。
|
||||
|
||||
当前规则较为保守:命中 `claude` 或 `anthropic` 走 Anthropic,
|
||||
其余模型默认视为 OpenAI 兼容后端。
|
||||
"""
|
||||
lower = (name or '').lower()
|
||||
return 'anthropic' if ('claude' in lower or 'anthropic' in lower) else 'openai'
|
||||
|
|
|
|||
1
start.py
1
start.py
|
|
@ -19,6 +19,7 @@ from app import create_app
|
|||
|
||||
|
||||
def main():
|
||||
"""加载应用并以 Waitress 方式启动代理服务。"""
|
||||
app = create_app()
|
||||
print(f'代理服务启动于 0.0.0.0:{Config.PROXY_PORT}')
|
||||
print(f'上游地址: {Config.PROXY_TARGET_URL}')
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def gen_id(prefix: str = '') -> str:
|
||||
"""生成唯一 ID"""
|
||||
"""生成带可选前缀的短随机 ID,用于请求和工具调用标识。"""
|
||||
return f'{prefix}{uuid.uuid4().hex[:24]}'
|
||||
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ def sse_response(generator):
|
|||
|
||||
|
||||
def error_json(message, error_type='proxy_error', status=502):
|
||||
"""构建 JSON 错误响应"""
|
||||
"""构造统一的 JSON 错误响应,供非流式链路直接返回。"""
|
||||
return jsonify({'error': {'message': str(message), 'type': error_type}}), status
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class ThinkTagExtractor:
|
|||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化跨 chunk 的 thinking 状态跟踪。"""
|
||||
self._in_thinking = False
|
||||
|
||||
def process_chunk(self, chunk):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue