初始化提交
This commit is contained in:
commit
202731df74
28 changed files with 3140 additions and 0 deletions
0
adapters/__init__.py
Normal file
0
adapters/__init__.py
Normal file
350
adapters/openai_anthropic.py
Normal file
350
adapters/openai_anthropic.py
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"""OpenAI Chat Completions ↔ Anthropic Messages 格式转换
|
||||
|
||||
请求方向: CC → Messages(Cursor 的 CC 请求转为 Anthropic 格式发给上游)
|
||||
响应方向: Messages → CC(上游 Anthropic 响应转为 CC 格式返回给 Cursor)
|
||||
包含非流式和流式两种转换。
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from utils.tool_fixer import normalize_args, repair_str_replace_args, fix_anthropic_tool_use
|
||||
from utils.http import gen_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Anthropic stop_reason → OpenAI finish_reason
|
||||
_STOP_REASON_MAP = {
|
||||
'end_turn': 'stop',
|
||||
'max_tokens': 'length',
|
||||
'tool_use': 'tool_calls',
|
||||
'stop_sequence': 'stop',
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 请求转换: CC → Messages
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def cc_to_messages_request(payload):
|
||||
"""将 OpenAI CC 格式请求转换为 Anthropic Messages 格式"""
|
||||
messages = payload.get('messages', [])
|
||||
anthropic_msgs = []
|
||||
system_parts = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get('role', '')
|
||||
content = msg.get('content', '')
|
||||
|
||||
# system 消息提取到顶层
|
||||
if role == 'system':
|
||||
system_parts.append(_flatten_text(content))
|
||||
continue
|
||||
|
||||
anthropic_role = 'assistant' if role == 'assistant' else 'user'
|
||||
anthropic_content = _convert_content(msg)
|
||||
|
||||
# assistant 的 tool_calls → tool_use content blocks
|
||||
if role == 'assistant' and 'tool_calls' in msg:
|
||||
blocks = _to_blocks(anthropic_content)
|
||||
for tc in msg['tool_calls']:
|
||||
func = tc.get('function', {})
|
||||
arguments = func.get('arguments', '{}')
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
arguments = {}
|
||||
blocks.append({
|
||||
'type': 'tool_use',
|
||||
'id': tc.get('id', f'toolu_{uuid.uuid4().hex[:24]}'),
|
||||
'name': func.get('name', ''),
|
||||
'input': arguments,
|
||||
})
|
||||
anthropic_content = blocks
|
||||
|
||||
# tool 角色 → user + tool_result
|
||||
if role == 'tool':
|
||||
text = content if isinstance(content, str) else json.dumps(content)
|
||||
anthropic_content = [{
|
||||
'type': 'tool_result',
|
||||
'tool_use_id': msg.get('tool_call_id', ''),
|
||||
'content': text,
|
||||
}]
|
||||
anthropic_role = 'user'
|
||||
|
||||
if not anthropic_content and anthropic_content != 0:
|
||||
continue
|
||||
|
||||
anthropic_msgs.append({'role': anthropic_role, 'content': anthropic_content})
|
||||
|
||||
# Anthropic 要求角色必须交替
|
||||
anthropic_msgs = _merge_same_role(anthropic_msgs)
|
||||
|
||||
result = {
|
||||
'model': payload.get('model', 'claude-sonnet-4-20250514'),
|
||||
'messages': anthropic_msgs,
|
||||
'max_tokens': max(payload.get('max_tokens') or 8192, 8192),
|
||||
}
|
||||
|
||||
if system_parts:
|
||||
result['system'] = '\n\n'.join(system_parts)
|
||||
if 'tools' in payload:
|
||||
result['tools'] = _convert_tools(payload['tools'])
|
||||
for key in ('temperature', 'top_p', 'stream'):
|
||||
if key in payload:
|
||||
result[key] = payload[key]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 非流式响应转换: Messages → CC
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def messages_to_cc_response(data, request_id=None):
|
||||
"""将 Anthropic Messages 响应转换为 OpenAI CC 格式"""
|
||||
request_id = request_id or gen_id('chatcmpl-')
|
||||
data = fix_anthropic_tool_use(data)
|
||||
|
||||
content_text = ''
|
||||
reasoning = ''
|
||||
tool_calls = []
|
||||
|
||||
for block in data.get('content', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get('type', '')
|
||||
if btype == 'text':
|
||||
content_text += block.get('text', '')
|
||||
elif btype == 'thinking':
|
||||
reasoning += block.get('thinking', '')
|
||||
elif btype == 'tool_use':
|
||||
args = block.get('input', {})
|
||||
if isinstance(args, dict):
|
||||
args = normalize_args(args)
|
||||
args = repair_str_replace_args(block.get('name', ''), args)
|
||||
tool_calls.append({
|
||||
'index': len(tool_calls),
|
||||
'id': block.get('id', f'toolu_{uuid.uuid4().hex[:24]}'),
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': block.get('name', ''),
|
||||
'arguments': json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else str(args),
|
||||
},
|
||||
})
|
||||
|
||||
stop_reason = data.get('stop_reason', 'end_turn')
|
||||
message = {'role': 'assistant', 'content': content_text or None}
|
||||
if reasoning:
|
||||
message['reasoning_content'] = reasoning
|
||||
if tool_calls:
|
||||
message['tool_calls'] = tool_calls
|
||||
|
||||
usage = data.get('usage', {})
|
||||
return {
|
||||
'id': request_id,
|
||||
'object': 'chat.completion',
|
||||
'model': data.get('model', 'claude'),
|
||||
'choices': [{
|
||||
'index': 0,
|
||||
'message': message,
|
||||
'finish_reason': _STOP_REASON_MAP.get(stop_reason, 'stop'),
|
||||
}],
|
||||
'usage': {
|
||||
'prompt_tokens': usage.get('input_tokens', 0),
|
||||
'completion_tokens': usage.get('output_tokens', 0),
|
||||
'total_tokens': usage.get('input_tokens', 0) + usage.get('output_tokens', 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 流式响应转换: Anthropic SSE → CC chunks
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class AnthropicStreamConverter:
|
||||
"""将 Anthropic SSE 事件逐个转换为 OpenAI CC 流式 chunk"""
|
||||
|
||||
def __init__(self, request_id=None):
|
||||
self._id = request_id or gen_id('chatcmpl-')
|
||||
self._tool_index = -1
|
||||
self._input_tokens = 0
|
||||
self._output_tokens = 0
|
||||
|
||||
def process_event(self, event_type, event_data):
|
||||
"""处理一个 Anthropic SSE 事件,返回 CC chunk JSON 字符串列表"""
|
||||
chunks = []
|
||||
|
||||
if event_type == 'message_start':
|
||||
msg = event_data.get('message', {})
|
||||
self._input_tokens = msg.get('usage', {}).get('input_tokens', 0)
|
||||
chunk = self._make_chunk(delta={'role': 'assistant', 'content': ''})
|
||||
if msg.get('model'):
|
||||
chunk['model'] = msg['model']
|
||||
chunks.append(json.dumps(chunk))
|
||||
|
||||
elif event_type == 'content_block_start':
|
||||
block = event_data.get('content_block', {})
|
||||
if block.get('type') == 'tool_use':
|
||||
self._tool_index += 1
|
||||
chunks.append(json.dumps(self._make_chunk(delta={
|
||||
'tool_calls': [{
|
||||
'index': self._tool_index,
|
||||
'id': block.get('id', f'toolu_{uuid.uuid4().hex[:24]}'),
|
||||
'type': 'function',
|
||||
'function': {'name': block.get('name', ''), 'arguments': ''},
|
||||
}]
|
||||
})))
|
||||
|
||||
elif event_type == 'content_block_delta':
|
||||
delta = event_data.get('delta', {})
|
||||
dtype = delta.get('type', '')
|
||||
if dtype == 'text_delta' and delta.get('text'):
|
||||
chunks.append(json.dumps(self._make_chunk(
|
||||
delta={'content': delta['text']})))
|
||||
elif dtype == 'thinking_delta' and delta.get('thinking'):
|
||||
chunks.append(json.dumps(self._make_chunk(
|
||||
delta={'reasoning_content': delta['thinking']})))
|
||||
elif dtype == 'input_json_delta' and delta.get('partial_json'):
|
||||
chunks.append(json.dumps(self._make_chunk(delta={
|
||||
'tool_calls': [{
|
||||
'index': self._tool_index,
|
||||
'function': {'arguments': delta['partial_json']},
|
||||
}]
|
||||
})))
|
||||
|
||||
elif event_type == 'message_delta':
|
||||
delta = event_data.get('delta', {})
|
||||
usage = event_data.get('usage', {})
|
||||
self._output_tokens = usage.get('output_tokens', 0)
|
||||
finish = _STOP_REASON_MAP.get(delta.get('stop_reason', ''), 'stop')
|
||||
chunk = self._make_chunk(delta={}, finish_reason=finish)
|
||||
chunk['usage'] = {
|
||||
'prompt_tokens': self._input_tokens,
|
||||
'completion_tokens': self._output_tokens,
|
||||
'total_tokens': self._input_tokens + self._output_tokens,
|
||||
}
|
||||
chunks.append(json.dumps(chunk))
|
||||
|
||||
return chunks
|
||||
|
||||
def _make_chunk(self, delta, finish_reason=None):
|
||||
choice = {'index': 0, 'delta': delta}
|
||||
if finish_reason:
|
||||
choice['finish_reason'] = finish_reason
|
||||
return {
|
||||
'id': self._id,
|
||||
'object': 'chat.completion.chunk',
|
||||
'model': 'claude',
|
||||
'choices': [choice],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 内部辅助函数
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _flatten_text(content):
|
||||
"""将 content 扁平化为纯文本"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, str):
|
||||
parts.append(p)
|
||||
elif isinstance(p, dict) and p.get('type') == 'text':
|
||||
parts.append(p.get('text', ''))
|
||||
return '\n'.join(parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _convert_content(msg):
|
||||
"""将 OpenAI 消息的 content 字段转为 Anthropic 格式"""
|
||||
content = msg.get('content', '')
|
||||
if content is None:
|
||||
return ''
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
blocks = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
blocks.append({'type': 'text', 'text': part})
|
||||
elif isinstance(part, dict):
|
||||
ptype = part.get('type', '')
|
||||
if ptype == 'text':
|
||||
blocks.append({'type': 'text', 'text': part.get('text', '')})
|
||||
elif ptype == 'image_url':
|
||||
blocks.append(_convert_image(part))
|
||||
elif ptype in ('tool_use', 'tool_result'):
|
||||
blocks.append(part)
|
||||
return blocks
|
||||
return str(content)
|
||||
|
||||
|
||||
def _convert_image(part):
|
||||
"""将 OpenAI image_url 格式转为 Anthropic image 格式"""
|
||||
url_data = part.get('image_url', {})
|
||||
url = url_data.get('url', '') if isinstance(url_data, dict) else str(url_data)
|
||||
if url.startswith('data:'):
|
||||
media_type, _, b64 = url.partition(';base64,')
|
||||
return {
|
||||
'type': 'image',
|
||||
'source': {
|
||||
'type': 'base64',
|
||||
'media_type': media_type.replace('data:', '') or 'image/png',
|
||||
'data': b64,
|
||||
},
|
||||
}
|
||||
return {'type': 'image', 'source': {'type': 'url', 'url': url}}
|
||||
|
||||
|
||||
def _convert_tools(tools):
|
||||
"""将 OpenAI tools 转为 Anthropic tools 格式(兼容 Cursor 扁平格式)"""
|
||||
result = []
|
||||
for tool in tools:
|
||||
if tool.get('type') == 'function' and 'function' in tool:
|
||||
func = tool['function']
|
||||
result.append({
|
||||
'name': func.get('name', ''),
|
||||
'description': func.get('description', ''),
|
||||
'input_schema': func.get('parameters', {'type': 'object', 'properties': {}}),
|
||||
})
|
||||
elif 'name' in tool and 'input_schema' in tool:
|
||||
result.append({
|
||||
'name': tool.get('name', ''),
|
||||
'description': tool.get('description', ''),
|
||||
'input_schema': tool.get('input_schema', {'type': 'object', 'properties': {}}),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _to_blocks(content):
|
||||
"""将 content 统一转为 blocks 列表"""
|
||||
if isinstance(content, str):
|
||||
return [{'type': 'text', 'text': content}] if content else []
|
||||
if isinstance(content, list):
|
||||
return list(content)
|
||||
return [{'type': 'text', 'text': str(content)}] if content else []
|
||||
|
||||
|
||||
def _merge_same_role(messages):
|
||||
"""合并相邻同角色消息(Anthropic 要求角色必须交替)"""
|
||||
if not messages:
|
||||
return messages
|
||||
merged = [messages[0]]
|
||||
for msg in messages[1:]:
|
||||
if msg['role'] == merged[-1]['role']:
|
||||
prev = _to_blocks(merged[-1]['content'])
|
||||
curr = _to_blocks(msg['content'])
|
||||
merged[-1]['content'] = prev + curr
|
||||
else:
|
||||
merged.append(msg)
|
||||
return merged
|
||||
267
adapters/openai_fixer.py
Normal file
267
adapters/openai_fixer.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""OpenAI 格式修复
|
||||
|
||||
修复 Cursor 发出的 OpenAI 格式请求和上游返回的响应中的各种兼容性问题:
|
||||
请求修复: Cursor 扁平格式 tools → 标准嵌套格式, tool_choice 规范化
|
||||
响应修复: reasoningContent → reasoning_content, <think> 标签提取,
|
||||
function_call → tool_calls, tool_calls 字段补全, 参数修复
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from utils.http import gen_id
|
||||
from utils.tool_fixer import normalize_args, repair_str_replace_args
|
||||
from utils.think_tag import extract_from_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── 请求预处理 ───────────────────────────────────
|
||||
|
||||
|
||||
def normalize_request(payload, upstream_model=None):
|
||||
"""预处理 Cursor 发来的 OpenAI 格式请求"""
|
||||
if upstream_model:
|
||||
payload['model'] = upstream_model
|
||||
|
||||
# Cursor 可能在 CC 端点发送 Anthropic 格式的 tool_use/tool_result 消息
|
||||
if 'messages' in payload:
|
||||
payload['messages'] = _convert_anthropic_messages(payload['messages'])
|
||||
|
||||
if 'tools' not in payload:
|
||||
return payload
|
||||
|
||||
# 修复 Cursor 可能发出的扁平格式 tools
|
||||
normalized = []
|
||||
for tool in payload['tools']:
|
||||
if tool.get('type') == 'function' and 'function' in tool:
|
||||
normalized.append(tool)
|
||||
elif 'name' in tool:
|
||||
normalized.append({
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool.get('name', ''),
|
||||
'description': tool.get('description', ''),
|
||||
'parameters': tool.get('input_schema')
|
||||
or tool.get('parameters')
|
||||
or {'type': 'object', 'properties': {}},
|
||||
},
|
||||
})
|
||||
else:
|
||||
normalized.append(tool)
|
||||
payload['tools'] = normalized
|
||||
|
||||
# tool_choice 规范化
|
||||
tc = payload.get('tool_choice')
|
||||
if isinstance(tc, dict):
|
||||
if tc.get('type') == 'auto':
|
||||
payload['tool_choice'] = 'auto'
|
||||
elif tc.get('type') == 'any':
|
||||
payload['tool_choice'] = 'required'
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _convert_anthropic_messages(messages):
|
||||
"""将消息中的 Anthropic 格式 tool_use/tool_result 转为 OpenAI 格式
|
||||
|
||||
Cursor 有时在 CC 端点中发送 Anthropic 风格的内容块:
|
||||
assistant: [{"type":"tool_use", "id":"...", "name":"Read", "input":{...}}]
|
||||
user: [{"type":"tool_result", "tool_use_id":"...", "content":[...]}]
|
||||
OpenAI 格式应为:
|
||||
assistant: {"tool_calls":[{"id":"...", "function":{"name":"Read","arguments":"..."}}]}
|
||||
tool: {"tool_call_id":"...", "content":"..."}
|
||||
"""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
content = msg.get('content')
|
||||
if not isinstance(content, list):
|
||||
converted.append(msg)
|
||||
continue
|
||||
|
||||
has_tool_use = any(
|
||||
isinstance(b, dict) and b.get('type') == 'tool_use' for b in content
|
||||
)
|
||||
has_tool_result = any(
|
||||
isinstance(b, dict) and b.get('type') == 'tool_result' for b in content
|
||||
)
|
||||
|
||||
if not has_tool_use and not has_tool_result:
|
||||
converted.append(msg)
|
||||
continue
|
||||
|
||||
role = msg.get('role', '')
|
||||
|
||||
if role == 'assistant' and has_tool_use:
|
||||
text_parts = []
|
||||
tool_calls = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get('type') == 'text':
|
||||
text_parts.append(block.get('text', ''))
|
||||
elif block.get('type') == 'tool_use':
|
||||
tool_calls.append({
|
||||
'id': block.get('id', gen_id('call_')),
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': block.get('name', ''),
|
||||
'arguments': json.dumps(
|
||||
block.get('input', {}), ensure_ascii=False
|
||||
),
|
||||
},
|
||||
})
|
||||
new_msg = {'role': 'assistant'}
|
||||
new_msg['content'] = '\n'.join(text_parts) if text_parts else None
|
||||
if tool_calls:
|
||||
new_msg['tool_calls'] = tool_calls
|
||||
converted.append(new_msg)
|
||||
|
||||
elif has_tool_result:
|
||||
other_parts = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get('type') == 'tool_result':
|
||||
rc = block.get('content', '')
|
||||
if isinstance(rc, list):
|
||||
rc = '\n'.join(
|
||||
b.get('text', '') for b in rc
|
||||
if isinstance(b, dict) and b.get('type') == 'text'
|
||||
)
|
||||
elif not isinstance(rc, str):
|
||||
rc = str(rc)
|
||||
converted.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': block.get('tool_use_id', ''),
|
||||
'content': rc,
|
||||
})
|
||||
else:
|
||||
other_parts.append(block)
|
||||
if other_parts:
|
||||
converted.append({'role': role, 'content': other_parts})
|
||||
else:
|
||||
converted.append(msg)
|
||||
|
||||
return converted
|
||||
|
||||
|
||||
# ─── 非流式响应修复 ───────────────────────────────
|
||||
|
||||
|
||||
def fix_response(data):
|
||||
"""修复上游返回的非流式 OpenAI 响应"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
for choice in (data.get('choices') or []):
|
||||
msg = choice.get('message') or {}
|
||||
|
||||
# reasoningContent → reasoning_content
|
||||
if 'reasoningContent' in msg and 'reasoning_content' not in msg:
|
||||
msg['reasoning_content'] = msg.pop('reasoningContent')
|
||||
|
||||
# <think> 标签 → reasoning_content
|
||||
content = msg.get('content') or ''
|
||||
if isinstance(content, str) and '<think>' in content and not msg.get('reasoning_content'):
|
||||
cleaned, reasoning = extract_from_text(content)
|
||||
if reasoning:
|
||||
msg['reasoning_content'] = reasoning
|
||||
msg['content'] = cleaned
|
||||
logger.info(f'提取 <think> 标签 → reasoning_content ({len(reasoning)} 字符)')
|
||||
|
||||
# 旧版 function_call → 新版 tool_calls
|
||||
if 'function_call' in msg and 'tool_calls' not in msg:
|
||||
fc = msg.pop('function_call')
|
||||
msg['tool_calls'] = [{
|
||||
'id': gen_id('call_'),
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': fc.get('name', ''),
|
||||
'arguments': fc.get('arguments', '{}'),
|
||||
},
|
||||
}]
|
||||
if choice.get('finish_reason') == 'function_call':
|
||||
choice['finish_reason'] = 'tool_calls'
|
||||
|
||||
# 修复 tool_calls 字段
|
||||
_fix_tool_calls(msg, choice)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ─── 流式 chunk 修复 ──────────────────────────────
|
||||
|
||||
|
||||
def fix_stream_chunk(data):
|
||||
"""修复上游返回的流式 OpenAI chunk"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
for choice in (data.get('choices') or []):
|
||||
delta = choice.get('delta') or {}
|
||||
|
||||
# reasoningContent → reasoning_content
|
||||
if 'reasoningContent' in delta and 'reasoning_content' not in delta:
|
||||
delta['reasoning_content'] = delta.pop('reasoningContent')
|
||||
|
||||
# 旧版 function_call → tool_calls
|
||||
if 'function_call' in delta and 'tool_calls' not in delta:
|
||||
fc = delta.pop('function_call')
|
||||
tc = {'index': 0, 'type': 'function', 'function': {}}
|
||||
if 'name' in fc:
|
||||
tc['id'] = gen_id('call_')
|
||||
tc['function']['name'] = fc['name']
|
||||
if 'arguments' in fc:
|
||||
tc['function']['arguments'] = fc['arguments']
|
||||
delta['tool_calls'] = [tc]
|
||||
if choice.get('finish_reason') == 'function_call':
|
||||
choice['finish_reason'] = 'tool_calls'
|
||||
|
||||
# 补全 tool_calls 字段
|
||||
for tc in (delta.get('tool_calls') or []):
|
||||
if 'index' not in tc:
|
||||
tc['index'] = 0
|
||||
func = tc.get('function') or {}
|
||||
if 'id' in tc or 'name' in func:
|
||||
if not tc.get('id'):
|
||||
tc['id'] = gen_id('call_')
|
||||
if 'type' not in tc:
|
||||
tc['type'] = 'function'
|
||||
|
||||
if choice.get('finish_reason') == 'function_call':
|
||||
choice['finish_reason'] = 'tool_calls'
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ─── 内部辅助 ─────────────────────────────────────
|
||||
|
||||
|
||||
def _fix_tool_calls(msg, choice):
|
||||
"""修复消息中的 tool_calls 字段"""
|
||||
tool_calls = msg.get('tool_calls')
|
||||
if not tool_calls:
|
||||
return
|
||||
|
||||
for i, tc in enumerate(tool_calls):
|
||||
if not tc.get('id'):
|
||||
tc['id'] = gen_id('call_')
|
||||
if 'index' not in tc:
|
||||
tc['index'] = i
|
||||
if tc.get('type') != 'function':
|
||||
tc['type'] = 'function'
|
||||
|
||||
func = tc.get('function', {})
|
||||
args_raw = func.get('arguments', '{}')
|
||||
try:
|
||||
args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or {})
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
args = normalize_args(args)
|
||||
args = repair_str_replace_args(func.get('name', ''), args)
|
||||
func['arguments'] = json.dumps(args, ensure_ascii=False)
|
||||
|
||||
if choice.get('finish_reason') not in ('tool_calls', 'function_call'):
|
||||
choice['finish_reason'] = 'tool_calls'
|
||||
533
adapters/responses_adapter.py
Normal file
533
adapters/responses_adapter.py
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
"""Responses API 适配
|
||||
|
||||
Cursor 对 GPT/Claude-Opus 等模型使用 /v1/responses 格式。
|
||||
本模块将 Responses 格式与 Chat Completions 格式互相转换:
|
||||
请求: Responses → CC
|
||||
响应: CC → Responses(非流式 + 流式)
|
||||
流式: 支持从 CC chunks 或 Anthropic SSE 事件直接转换
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from utils.http import gen_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 请求转换: Responses → CC
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def responses_to_cc(payload):
|
||||
"""将 /v1/responses 请求转换为 /v1/chat/completions 格式"""
|
||||
messages = []
|
||||
|
||||
if payload.get('instructions'):
|
||||
messages.append({'role': 'system', 'content': payload['instructions']})
|
||||
|
||||
input_data = payload.get('input', [])
|
||||
if isinstance(input_data, str):
|
||||
messages.append({'role': 'user', 'content': input_data})
|
||||
elif isinstance(input_data, list):
|
||||
_convert_input_items(input_data, messages)
|
||||
|
||||
result = {
|
||||
'model': payload.get('model', ''),
|
||||
'messages': messages,
|
||||
'stream': payload.get('stream', False),
|
||||
}
|
||||
|
||||
if 'tools' in payload:
|
||||
result['tools'] = _convert_tools(payload['tools'])
|
||||
for key in ('temperature', 'top_p'):
|
||||
if key in payload:
|
||||
result[key] = payload[key]
|
||||
if 'max_output_tokens' in payload:
|
||||
result['max_tokens'] = payload['max_output_tokens']
|
||||
if 'tool_choice' in payload:
|
||||
result['tool_choice'] = payload['tool_choice']
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 非流式响应转换: CC → Responses
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def cc_to_responses(cc_resp, model=''):
|
||||
"""将 CC 响应转换为 Responses 格式"""
|
||||
choice = (cc_resp.get('choices') or [{}])[0]
|
||||
msg = choice.get('message') or {}
|
||||
finish = choice.get('finish_reason', 'stop')
|
||||
|
||||
output = []
|
||||
|
||||
if msg.get('reasoning_content'):
|
||||
output.append({
|
||||
'type': 'reasoning',
|
||||
'id': gen_id('rs_'),
|
||||
'summary': [{'type': 'summary_text', 'text': msg['reasoning_content']}],
|
||||
})
|
||||
|
||||
if msg.get('content'):
|
||||
output.append({
|
||||
'type': 'message',
|
||||
'id': gen_id('msg_'),
|
||||
'status': 'completed',
|
||||
'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': msg['content']}],
|
||||
})
|
||||
|
||||
for tc in (msg.get('tool_calls') or []):
|
||||
func = tc.get('function') or {}
|
||||
output.append({
|
||||
'type': 'function_call',
|
||||
'id': gen_id('fc_'),
|
||||
'status': 'completed',
|
||||
'call_id': tc.get('id', gen_id('call_')),
|
||||
'name': func.get('name', ''),
|
||||
'arguments': func.get('arguments', '{}'),
|
||||
})
|
||||
|
||||
usage = cc_resp.get('usage', {})
|
||||
return {
|
||||
'id': cc_resp.get('id', gen_id('resp_')),
|
||||
'object': 'response',
|
||||
'status': 'incomplete' if finish == 'length' else 'completed',
|
||||
'model': model or cc_resp.get('model', ''),
|
||||
'output': output,
|
||||
'usage': {
|
||||
'input_tokens': usage.get('prompt_tokens', 0),
|
||||
'output_tokens': usage.get('completion_tokens', 0),
|
||||
'total_tokens': usage.get('total_tokens', 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 流式转换器: CC chunks / Anthropic SSE → Responses SSE
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class ResponsesStreamConverter:
|
||||
"""有状态转换器:将 CC 流式 chunk 或 Anthropic SSE 事件转为 Responses SSE 事件"""
|
||||
|
||||
def __init__(self, response_id=None, model=''):
|
||||
self.resp_id = response_id or gen_id('resp_')
|
||||
self.model = model
|
||||
|
||||
# 思考内容缓冲
|
||||
self._rs_buf = ''
|
||||
self._rs_started = False
|
||||
self._rs_closed = False
|
||||
self._rs_id = gen_id('rs_')
|
||||
|
||||
# 文本内容缓冲
|
||||
self._text_buf = ''
|
||||
self._text_started = False
|
||||
self._text_closed = False
|
||||
self._msg_id = gen_id('msg_')
|
||||
|
||||
# 工具调用缓冲 {index: {name, args, call_id, fc_id}}
|
||||
self._tools = {}
|
||||
self._output_items = []
|
||||
self._finished = False
|
||||
self._input_tokens = 0
|
||||
|
||||
# ─── 公开接口 ─────────────────────────────────
|
||||
|
||||
def start_events(self):
|
||||
"""生成流开始事件"""
|
||||
return [self._sse('response.created', {
|
||||
'id': self.resp_id, 'object': 'response',
|
||||
'status': 'in_progress', 'model': self.model, 'output': [],
|
||||
})]
|
||||
|
||||
def process_cc_chunk(self, chunk):
|
||||
"""处理 CC 格式的流式 chunk,返回 Responses SSE 事件列表"""
|
||||
events = []
|
||||
for choice in (chunk.get('choices') or []):
|
||||
delta = choice.get('delta') or {}
|
||||
finish = choice.get('finish_reason')
|
||||
|
||||
if delta.get('reasoning_content'):
|
||||
events.extend(self._on_reasoning(delta['reasoning_content']))
|
||||
if delta.get('content') is not None and delta['content'] != '':
|
||||
events.extend(self._on_text(delta['content']))
|
||||
for tc in (delta.get('tool_calls') or []):
|
||||
events.extend(self._on_tool_call(tc))
|
||||
if finish and not self._finished:
|
||||
self._finished = True
|
||||
events.extend(self._do_finish(finish, chunk.get('usage')))
|
||||
|
||||
return events
|
||||
|
||||
def process_anthropic_event(self, event_type, event_data):
|
||||
"""直接处理 Anthropic SSE 事件(跳过 CC 中间转换,更高效)"""
|
||||
events = []
|
||||
|
||||
if event_type == 'message_start':
|
||||
usage = event_data.get('message', {}).get('usage', {})
|
||||
self._input_tokens = usage.get('input_tokens', 0)
|
||||
|
||||
elif event_type == 'content_block_start':
|
||||
block = event_data.get('content_block', {})
|
||||
btype = block.get('type', '')
|
||||
if btype == 'thinking' and not self._rs_started:
|
||||
self._rs_started = True
|
||||
events.append(self._sse('response.output_item.added', {
|
||||
'type': 'reasoning', 'id': self._rs_id, 'summary': [],
|
||||
}))
|
||||
elif btype == 'text':
|
||||
events.extend(self._ensure_text_started())
|
||||
elif btype == 'tool_use':
|
||||
events.extend(self._start_tool_from_block(block))
|
||||
|
||||
elif event_type == 'content_block_delta':
|
||||
delta = event_data.get('delta', {})
|
||||
dtype = delta.get('type', '')
|
||||
if dtype == 'thinking_delta' and delta.get('thinking'):
|
||||
self._rs_buf += delta['thinking']
|
||||
events.append(self._sse('response.reasoning_summary_text.delta', {
|
||||
'type': 'summary_text', 'delta': delta['thinking'],
|
||||
}))
|
||||
elif dtype == 'text_delta' and delta.get('text'):
|
||||
self._text_buf += delta['text']
|
||||
events.append(self._sse('response.output_text.delta', {
|
||||
'type': 'output_text', 'delta': delta['text'],
|
||||
}))
|
||||
elif dtype == 'input_json_delta' and delta.get('partial_json') and self._tools:
|
||||
idx = max(self._tools.keys())
|
||||
self._tools[idx]['args'] += delta['partial_json']
|
||||
events.append(self._sse('response.function_call_arguments.delta', {
|
||||
'type': 'function_call', 'delta': delta['partial_json'],
|
||||
}))
|
||||
|
||||
elif event_type == 'message_delta':
|
||||
delta = event_data.get('delta', {})
|
||||
stop = delta.get('stop_reason', 'end_turn')
|
||||
usage = event_data.get('usage', {})
|
||||
finish = {'tool_use': 'tool_calls', 'max_tokens': 'length'}.get(stop, 'stop')
|
||||
if not self._finished:
|
||||
self._finished = True
|
||||
u = {
|
||||
'input_tokens': self._input_tokens,
|
||||
'output_tokens': usage.get('output_tokens', 0),
|
||||
'total_tokens': self._input_tokens + usage.get('output_tokens', 0),
|
||||
}
|
||||
events.extend(self._do_finish(finish, u))
|
||||
|
||||
return events
|
||||
|
||||
def finalize(self):
|
||||
"""流结束时补发未关闭的事件"""
|
||||
if self._finished:
|
||||
return []
|
||||
self._finished = True
|
||||
return self._do_finish('stop', None)
|
||||
|
||||
# ─── 内部事件处理 ─────────────────────────────
|
||||
|
||||
def _on_reasoning(self, text):
|
||||
"""处理思考内容 delta"""
|
||||
events = []
|
||||
if not self._rs_started:
|
||||
self._rs_started = True
|
||||
events.append(self._sse('response.output_item.added', {
|
||||
'type': 'reasoning', 'id': self._rs_id, 'summary': [],
|
||||
}))
|
||||
self._rs_buf += text
|
||||
events.append(self._sse('response.reasoning_summary_text.delta', {
|
||||
'type': 'summary_text', 'delta': text,
|
||||
}))
|
||||
return events
|
||||
|
||||
def _on_text(self, text):
|
||||
"""处理文本内容 delta"""
|
||||
events = self._ensure_text_started()
|
||||
self._text_buf += text
|
||||
events.append(self._sse('response.output_text.delta', {
|
||||
'type': 'output_text', 'delta': text,
|
||||
}))
|
||||
return events
|
||||
|
||||
def _on_tool_call(self, tc):
|
||||
"""处理工具调用 delta"""
|
||||
events = []
|
||||
idx = tc.get('index', 0)
|
||||
func = tc.get('function') or {}
|
||||
|
||||
if idx not in self._tools:
|
||||
if self._rs_started and not self._rs_closed:
|
||||
events.extend(self._close_reasoning())
|
||||
if self._text_started and not self._text_closed:
|
||||
events.extend(self._close_text())
|
||||
call_id = tc.get('id', gen_id('call_'))
|
||||
name = func.get('name', '')
|
||||
fc_id = gen_id('fc_')
|
||||
self._tools[idx] = {'name': name, 'args': '', 'call_id': call_id, 'fc_id': fc_id}
|
||||
events.append(self._sse('response.output_item.added', {
|
||||
'type': 'function_call', 'id': fc_id,
|
||||
'status': 'in_progress', 'call_id': call_id,
|
||||
'name': name, 'arguments': '',
|
||||
}))
|
||||
|
||||
if func.get('name'):
|
||||
self._tools[idx]['name'] = func['name']
|
||||
if func.get('arguments', ''):
|
||||
self._tools[idx]['args'] += func['arguments']
|
||||
events.append(self._sse('response.function_call_arguments.delta', {
|
||||
'type': 'function_call', 'delta': func['arguments'],
|
||||
}))
|
||||
return events
|
||||
|
||||
def _ensure_text_started(self):
|
||||
"""确保文本输出项已开始"""
|
||||
events = []
|
||||
if self._rs_started and not self._rs_closed:
|
||||
events.extend(self._close_reasoning())
|
||||
if not self._text_started:
|
||||
self._text_started = True
|
||||
events.append(self._sse('response.output_item.added', {
|
||||
'type': 'message', 'id': self._msg_id,
|
||||
'status': 'in_progress', 'role': 'assistant', 'content': [],
|
||||
}))
|
||||
events.append(self._sse('response.content_part.added', {
|
||||
'type': 'output_text', 'text': '',
|
||||
}))
|
||||
return events
|
||||
|
||||
def _start_tool_from_block(self, block):
|
||||
"""从 Anthropic tool_use block 开始新的工具调用"""
|
||||
events = []
|
||||
if self._rs_started and not self._rs_closed:
|
||||
events.extend(self._close_reasoning())
|
||||
if self._text_started and not self._text_closed:
|
||||
events.extend(self._close_text())
|
||||
idx = len(self._tools)
|
||||
tool_id = block.get('id', gen_id('toolu_'))
|
||||
name = block.get('name', '')
|
||||
fc_id = gen_id('fc_')
|
||||
self._tools[idx] = {'name': name, 'args': '', 'call_id': tool_id, 'fc_id': fc_id}
|
||||
events.append(self._sse('response.output_item.added', {
|
||||
'type': 'function_call', 'id': fc_id,
|
||||
'status': 'in_progress', 'call_id': tool_id,
|
||||
'name': name, 'arguments': '',
|
||||
}))
|
||||
return events
|
||||
|
||||
# ─── 关闭/结束事件 ────────────────────────────
|
||||
|
||||
def _close_reasoning(self):
|
||||
if self._rs_closed:
|
||||
return []
|
||||
self._rs_closed = True
|
||||
rs = {
|
||||
'type': 'reasoning', 'id': self._rs_id,
|
||||
'summary': [{'type': 'summary_text', 'text': self._rs_buf}],
|
||||
}
|
||||
self._output_items.append(rs)
|
||||
return [
|
||||
self._sse('response.reasoning_summary_text.done', {
|
||||
'type': 'summary_text', 'text': self._rs_buf,
|
||||
}),
|
||||
self._sse('response.output_item.done', rs),
|
||||
]
|
||||
|
||||
def _close_text(self):
|
||||
if self._text_closed:
|
||||
return []
|
||||
self._text_closed = True
|
||||
msg = {
|
||||
'type': 'message', 'id': self._msg_id,
|
||||
'status': 'completed', 'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': self._text_buf}],
|
||||
}
|
||||
self._output_items.append(msg)
|
||||
return [
|
||||
self._sse('response.output_text.done', {'type': 'output_text', 'text': self._text_buf}),
|
||||
self._sse('response.output_item.done', msg),
|
||||
]
|
||||
|
||||
def _do_finish(self, finish_reason, usage):
|
||||
"""生成流结束的所有关闭事件"""
|
||||
events = []
|
||||
if self._rs_started and not self._rs_closed:
|
||||
events.extend(self._close_reasoning())
|
||||
if self._text_started and not self._text_closed:
|
||||
events.extend(self._close_text())
|
||||
|
||||
for idx in sorted(self._tools.keys()):
|
||||
buf = self._tools[idx]
|
||||
events.append(self._sse('response.function_call_arguments.done', {
|
||||
'type': 'function_call', 'arguments': buf['args'],
|
||||
}))
|
||||
fc = {
|
||||
'type': 'function_call', 'id': buf['fc_id'],
|
||||
'status': 'completed', 'call_id': buf['call_id'],
|
||||
'name': buf['name'], 'arguments': buf['args'],
|
||||
}
|
||||
events.append(self._sse('response.output_item.done', fc))
|
||||
self._output_items.append(fc)
|
||||
|
||||
usage_data = usage if isinstance(usage, dict) else {}
|
||||
events.append(self._sse('response.completed', {
|
||||
'id': self.resp_id, 'object': 'response',
|
||||
'status': 'incomplete' if finish_reason == 'length' else 'completed',
|
||||
'model': self.model, 'output': self._output_items, 'usage': usage_data,
|
||||
}))
|
||||
return events
|
||||
|
||||
def _sse(self, event_type, data):
|
||||
"""构建 SSE 事件字符串"""
|
||||
return f'event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 内部辅助函数
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _convert_input_items(items, messages):
|
||||
"""将 Responses input 数组转换为 CC messages"""
|
||||
i = 0
|
||||
while i < len(items):
|
||||
item = items[i]
|
||||
|
||||
if isinstance(item, str):
|
||||
messages.append({'role': 'user', 'content': item})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if not isinstance(item, dict):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
item_type = item.get('type', '')
|
||||
role = item.get('role', '')
|
||||
|
||||
# 简单角色消息(无 type 字段)
|
||||
if role and not item_type:
|
||||
content = item.get('content', '')
|
||||
if isinstance(content, list):
|
||||
content = _extract_text(content)
|
||||
messages.append({'role': role, 'content': content or ''})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Responses message 对象
|
||||
if item_type == 'message' or (role and not item_type):
|
||||
role = item.get('role', 'assistant')
|
||||
content = _extract_text(item.get('content', []))
|
||||
msg = {'role': role, 'content': content or ''}
|
||||
if role == 'assistant':
|
||||
tool_calls, consumed = _collect_function_calls(items, i + 1)
|
||||
if tool_calls:
|
||||
msg['tool_calls'] = tool_calls
|
||||
if not msg['content']:
|
||||
msg['content'] = None
|
||||
messages.append(msg)
|
||||
i += 1 + consumed
|
||||
continue
|
||||
messages.append(msg)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# function_call(工具调用)
|
||||
if item_type == 'function_call':
|
||||
tc = {
|
||||
'id': item.get('call_id') or gen_id('call_'),
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': item.get('name', ''),
|
||||
'arguments': item.get('arguments', '{}'),
|
||||
},
|
||||
}
|
||||
if messages and messages[-1]['role'] == 'assistant':
|
||||
messages[-1].setdefault('tool_calls', []).append(tc)
|
||||
if not messages[-1].get('content'):
|
||||
messages[-1]['content'] = None
|
||||
else:
|
||||
messages.append({'role': 'assistant', 'content': None, 'tool_calls': [tc]})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# function_call_output(工具结果)
|
||||
if item_type == 'function_call_output':
|
||||
output = item.get('output', '')
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output, ensure_ascii=False)
|
||||
messages.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': item.get('call_id', ''),
|
||||
'content': output,
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role:
|
||||
messages.append({'role': role, 'content': str(item.get('content', ''))})
|
||||
i += 1
|
||||
|
||||
|
||||
def _collect_function_calls(items, start):
|
||||
"""收集紧随 assistant message 之后的连续 function_call 项"""
|
||||
tool_calls = []
|
||||
j = start
|
||||
while j < len(items):
|
||||
nxt = items[j]
|
||||
if isinstance(nxt, dict) and nxt.get('type') == 'function_call':
|
||||
tool_calls.append({
|
||||
'id': nxt.get('call_id') or gen_id('call_'),
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': nxt.get('name', ''),
|
||||
'arguments': nxt.get('arguments', '{}'),
|
||||
},
|
||||
})
|
||||
j += 1
|
||||
else:
|
||||
break
|
||||
return tool_calls, j - start
|
||||
|
||||
|
||||
def _extract_text(content):
|
||||
"""从 content 中提取纯文本"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return str(content) if content else ''
|
||||
texts = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
texts.append(part)
|
||||
elif isinstance(part, dict):
|
||||
t = part.get('type', '')
|
||||
if t in ('output_text', 'input_text', 'text'):
|
||||
texts.append(part.get('text', ''))
|
||||
elif t == 'refusal':
|
||||
texts.append(part.get('refusal', ''))
|
||||
return '\n'.join(texts) if texts else ''
|
||||
|
||||
|
||||
def _convert_tools(tools):
|
||||
"""将 Responses tools 转为 CC tools 格式"""
|
||||
result = []
|
||||
for t in tools:
|
||||
if t.get('type') != 'function':
|
||||
continue
|
||||
if 'function' in t:
|
||||
result.append(t)
|
||||
else:
|
||||
result.append({
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': t.get('name', ''),
|
||||
'description': t.get('description', ''),
|
||||
'parameters': t.get('parameters', {'type': 'object', 'properties': {}}),
|
||||
},
|
||||
})
|
||||
return result
|
||||
Loading…
Add table
Add a link
Reference in a new issue