支持gimini格式,优化debug日志
This commit is contained in:
parent
e726f11bad
commit
4de6db13f9
16 changed files with 1783 additions and 55 deletions
|
|
@ -62,7 +62,9 @@ def cc_to_messages_request(payload: JsonDict) -> JsonDict:
|
|||
anthropic_messages.append(converted)
|
||||
|
||||
anthropic_messages = _merge_same_role(anthropic_messages)
|
||||
return _build_messages_request(payload, anthropic_messages, system_parts)
|
||||
result = _build_messages_request(payload, anthropic_messages, system_parts)
|
||||
optimize_cache_control(result)
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
|
@ -560,3 +562,141 @@ def _merge_same_role(messages: list[JsonDict]) -> list[JsonDict]:
|
|||
else:
|
||||
merged.append(message)
|
||||
return merged
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Anthropic cache_control 优化
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
_MAX_BREAKPOINTS = 4
|
||||
_BLOCK_WINDOW = 20
|
||||
_EPHEMERAL = {'type': 'ephemeral'}
|
||||
|
||||
|
||||
def optimize_cache_control(request: JsonDict) -> None:
|
||||
"""自动设置最优的 Anthropic cache_control 断点。
|
||||
|
||||
算法移植自 CursorProxy 的 ensure_cache_control.go:
|
||||
1. 归一化所有消息 content 为数组格式
|
||||
2. 清空所有已有 cache_control
|
||||
3. 注入结构锚点(tools 末尾 + system 末尾)
|
||||
4. 注入消息锚点(最后一个可缓存块 + 窗口边界)
|
||||
5. 总断点数不超过 4 个
|
||||
"""
|
||||
_normalize_message_contents(request)
|
||||
_clear_all_cache_controls(request)
|
||||
|
||||
structural = _inject_structural_anchors(request)
|
||||
remaining = _MAX_BREAKPOINTS - structural
|
||||
if remaining <= 0:
|
||||
return
|
||||
|
||||
refs = _collect_cacheable_block_refs(request)
|
||||
if not refs:
|
||||
return
|
||||
|
||||
desired = 1 if len(refs) < _BLOCK_WINDOW else 2
|
||||
anchors = min(desired, remaining)
|
||||
|
||||
if anchors >= 1 and refs:
|
||||
refs[-1]['cache_control'] = _EPHEMERAL
|
||||
|
||||
if anchors >= 2 and len(refs) > 1:
|
||||
target = len(refs) - _BLOCK_WINDOW
|
||||
idx = _pick_window_anchor(refs, target)
|
||||
if idx is not None and idx != len(refs) - 1:
|
||||
refs[idx]['cache_control'] = _EPHEMERAL
|
||||
|
||||
|
||||
def _normalize_message_contents(request: JsonDict) -> None:
|
||||
"""将所有消息的 content 统一转为数组格式。"""
|
||||
for msg in request.get('messages', []):
|
||||
content = msg.get('content')
|
||||
if isinstance(content, str):
|
||||
msg['content'] = [{'type': 'text', 'text': content}]
|
||||
elif content is None:
|
||||
msg['content'] = []
|
||||
|
||||
|
||||
def _clear_all_cache_controls(request: JsonDict) -> None:
|
||||
"""清空所有已有的 cache_control 字段。"""
|
||||
for tool in request.get('tools', []):
|
||||
tool.pop('cache_control', None)
|
||||
|
||||
system = request.get('system')
|
||||
if isinstance(system, list):
|
||||
for block in system:
|
||||
if isinstance(block, dict):
|
||||
block.pop('cache_control', None)
|
||||
|
||||
for msg in request.get('messages', []):
|
||||
content = msg.get('content')
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
block.pop('cache_control', None)
|
||||
|
||||
|
||||
def _inject_structural_anchors(request: JsonDict) -> int:
|
||||
"""在 tools 末尾和 system 末尾注入结构锚点,返回注入数量。"""
|
||||
count = 0
|
||||
|
||||
tools = request.get('tools')
|
||||
if tools and isinstance(tools, list):
|
||||
tools[-1]['cache_control'] = _EPHEMERAL
|
||||
count += 1
|
||||
|
||||
system = request.get('system')
|
||||
if isinstance(system, list) and system:
|
||||
last = system[-1]
|
||||
if isinstance(last, dict):
|
||||
last['cache_control'] = _EPHEMERAL
|
||||
count += 1
|
||||
elif isinstance(system, str) and system:
|
||||
request['system'] = [
|
||||
{'type': 'text', 'text': system, 'cache_control': _EPHEMERAL}
|
||||
]
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def _is_cacheable_block(block: Any) -> bool:
|
||||
"""判断一个内容块是否可以设置 cache_control。"""
|
||||
if not isinstance(block, dict):
|
||||
return False
|
||||
block_type = block.get('type', '')
|
||||
if block_type in ('thinking', 'redacted_thinking'):
|
||||
return False
|
||||
if block_type == 'text' and not block.get('text'):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _collect_cacheable_block_refs(request: JsonDict) -> list[JsonDict]:
|
||||
"""收集所有消息中可缓存块的引用列表。"""
|
||||
refs: list[JsonDict] = []
|
||||
for msg in request.get('messages', []):
|
||||
content = msg.get('content')
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if _is_cacheable_block(block):
|
||||
refs.append(block)
|
||||
return refs
|
||||
|
||||
|
||||
def _pick_window_anchor(refs: list[JsonDict], target: int) -> int | None:
|
||||
"""在目标位置附近选择一个窗口锚点,优先左侧。"""
|
||||
if target < 0:
|
||||
target = 0
|
||||
if target >= len(refs):
|
||||
return None
|
||||
|
||||
for i in range(target, -1, -1):
|
||||
if 'cache_control' not in refs[i]:
|
||||
return i
|
||||
for i in range(target + 1, len(refs)):
|
||||
if 'cache_control' not in refs[i]:
|
||||
return i
|
||||
return None
|
||||
|
|
|
|||
363
adapters/cc_gemini_adapter.py
Normal file
363
adapters/cc_gemini_adapter.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
"""OpenAI Chat Completions ↔ Gemini Contents 格式转换
|
||||
|
||||
将 CC 格式请求转换为 Gemini generateContent 格式,
|
||||
并将 Gemini 响应转换回 CC 格式。仅支持出站方向(CC → Gemini → CC)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from utils.http import gen_id
|
||||
|
||||
JsonDict = dict[str, Any]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FINISH_REASON_MAP = {
|
||||
'STOP': 'stop',
|
||||
'MAX_TOKENS': 'length',
|
||||
'SAFETY': 'content_filter',
|
||||
'RECITATION': 'content_filter',
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 请求转换: CC → Gemini generateContent
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def cc_to_gemini_request(payload: JsonDict) -> JsonDict:
|
||||
"""将 CC 请求转换为 Gemini generateContent 请求。"""
|
||||
messages = payload.get('messages', [])
|
||||
system_parts: list[str] = []
|
||||
contents: list[JsonDict] = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get('role', '')
|
||||
if role in ('system', 'developer'):
|
||||
system_parts.append(_flatten_text(msg.get('content', '')))
|
||||
continue
|
||||
converted = _convert_message(msg)
|
||||
if converted:
|
||||
contents.append(converted)
|
||||
|
||||
contents = _merge_same_role(contents)
|
||||
|
||||
result: JsonDict = {
|
||||
'contents': contents,
|
||||
'generationConfig': _build_generation_config(payload),
|
||||
}
|
||||
|
||||
if system_parts:
|
||||
result['systemInstruction'] = {
|
||||
'parts': [{'text': '\n\n'.join(system_parts)}],
|
||||
}
|
||||
|
||||
tools = _convert_tools(payload.get('tools'))
|
||||
if tools:
|
||||
result['tools'] = tools
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 非流式响应转换: Gemini → CC
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def gemini_to_cc_response(data: JsonDict, request_id: str | None = None) -> JsonDict:
|
||||
"""将 Gemini generateContent 响应转换为 CC 响应。"""
|
||||
request_id = request_id or gen_id('chatcmpl-')
|
||||
candidates = data.get('candidates', [])
|
||||
candidate = candidates[0] if candidates else {}
|
||||
|
||||
content_text, reasoning_text, tool_calls = _extract_parts(
|
||||
candidate.get('content', {}).get('parts', [])
|
||||
)
|
||||
|
||||
finish = candidate.get('finishReason', 'STOP')
|
||||
if tool_calls and finish == 'STOP':
|
||||
finish_reason = 'tool_calls'
|
||||
else:
|
||||
finish_reason = _FINISH_REASON_MAP.get(finish, 'stop')
|
||||
|
||||
message: JsonDict = {'role': 'assistant', 'content': content_text or None}
|
||||
if reasoning_text:
|
||||
message['reasoning_content'] = reasoning_text
|
||||
if tool_calls:
|
||||
message['tool_calls'] = tool_calls
|
||||
|
||||
usage = _convert_usage(data.get('usageMetadata', {}))
|
||||
|
||||
return {
|
||||
'id': request_id,
|
||||
'object': 'chat.completion',
|
||||
'model': data.get('modelVersion', 'gemini'),
|
||||
'choices': [{'index': 0, 'message': message, 'finish_reason': finish_reason}],
|
||||
'usage': usage,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 流式转换: Gemini SSE → CC chunks
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class GeminiStreamConverter:
|
||||
"""将 Gemini SSE chunk 逐个转换为 CC chunk。
|
||||
|
||||
Gemini 流式每个 SSE data 是一个完整的 GenerateContentResponse,
|
||||
包含 candidates[0].content.parts。
|
||||
"""
|
||||
|
||||
def __init__(self, request_id: str | None = None):
|
||||
self._id = request_id or gen_id('chatcmpl-')
|
||||
self._tool_call_index = 0
|
||||
self._started = False
|
||||
|
||||
def process_chunk(self, data: JsonDict) -> list[JsonDict]:
|
||||
"""处理一个 Gemini SSE chunk,返回 CC chunk 列表。"""
|
||||
results: list[JsonDict] = []
|
||||
candidates = data.get('candidates', [])
|
||||
if not candidates:
|
||||
return results
|
||||
|
||||
candidate = candidates[0]
|
||||
parts = candidate.get('content', {}).get('parts', [])
|
||||
|
||||
if not self._started:
|
||||
self._started = True
|
||||
results.append(self._make_chunk({'role': 'assistant', 'content': ''}))
|
||||
|
||||
for part in parts:
|
||||
if part.get('thought') and part.get('text'):
|
||||
results.append(self._make_chunk({'reasoning_content': part['text']}))
|
||||
elif 'text' in part and not part.get('thought'):
|
||||
results.append(self._make_chunk({'content': part['text']}))
|
||||
elif 'functionCall' in part:
|
||||
fc = part['functionCall']
|
||||
results.append(self._make_chunk({'tool_calls': [{
|
||||
'index': self._tool_call_index,
|
||||
'id': fc.get('id') or gen_id('call_'),
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': fc.get('name', ''),
|
||||
'arguments': json.dumps(fc.get('args', {}), ensure_ascii=False),
|
||||
},
|
||||
}]}))
|
||||
self._tool_call_index += 1
|
||||
|
||||
finish = candidate.get('finishReason')
|
||||
if finish:
|
||||
has_tools = self._tool_call_index > 0
|
||||
if has_tools and finish == 'STOP':
|
||||
fr = 'tool_calls'
|
||||
else:
|
||||
fr = _FINISH_REASON_MAP.get(finish, 'stop')
|
||||
chunk = self._make_chunk({}, finish_reason=fr)
|
||||
usage_meta = data.get('usageMetadata')
|
||||
if usage_meta:
|
||||
chunk['usage'] = _convert_usage(usage_meta)
|
||||
results.append(chunk)
|
||||
|
||||
return results
|
||||
|
||||
def _make_chunk(self, delta: JsonDict, finish_reason: str | None = None) -> JsonDict:
|
||||
choice: JsonDict = {'index': 0, 'delta': delta}
|
||||
if finish_reason:
|
||||
choice['finish_reason'] = finish_reason
|
||||
return {
|
||||
'id': self._id,
|
||||
'object': 'chat.completion.chunk',
|
||||
'model': 'gemini',
|
||||
'choices': [choice],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 请求转换辅助
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _convert_message(msg: JsonDict) -> JsonDict | None:
|
||||
"""将单条 CC 消息转为 Gemini Content。"""
|
||||
role = msg.get('role', '')
|
||||
gemini_role = 'model' if role == 'assistant' else 'user'
|
||||
parts: list[JsonDict] = []
|
||||
|
||||
if role == 'tool':
|
||||
return {
|
||||
'role': 'user',
|
||||
'parts': [{
|
||||
'functionResponse': {
|
||||
'name': msg.get('name', msg.get('tool_call_id', '')),
|
||||
'response': _parse_json_safe(msg.get('content', '')),
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
if msg.get('reasoning_content'):
|
||||
parts.append({'text': msg['reasoning_content'], 'thought': True})
|
||||
|
||||
content = msg.get('content')
|
||||
if isinstance(content, str) and content:
|
||||
parts.append({'text': content})
|
||||
elif isinstance(content, list):
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get('type') == 'text':
|
||||
parts.append({'text': block.get('text', '')})
|
||||
elif block.get('type') == 'image_url':
|
||||
img = _convert_image_part(block)
|
||||
if img:
|
||||
parts.append(img)
|
||||
|
||||
for tc in msg.get('tool_calls', []):
|
||||
func = tc.get('function', {})
|
||||
parts.append({
|
||||
'functionCall': {
|
||||
'name': func.get('name', ''),
|
||||
'args': _parse_json_safe(func.get('arguments', '{}')),
|
||||
},
|
||||
})
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
return {'role': gemini_role, 'parts': parts}
|
||||
|
||||
|
||||
def _convert_image_part(block: JsonDict) -> JsonDict | None:
|
||||
"""将 OpenAI image_url 转为 Gemini inlineData。"""
|
||||
url_data = block.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 {'inlineData': {
|
||||
'mimeType': media_type.replace('data:', '') or 'image/png',
|
||||
'data': b64,
|
||||
}}
|
||||
return None
|
||||
|
||||
|
||||
def _build_generation_config(payload: JsonDict) -> JsonDict:
|
||||
"""从 CC payload 构建 Gemini generationConfig。"""
|
||||
config: JsonDict = {}
|
||||
if 'max_tokens' in payload:
|
||||
config['maxOutputTokens'] = payload['max_tokens']
|
||||
elif 'max_completion_tokens' in payload:
|
||||
config['maxOutputTokens'] = payload['max_completion_tokens']
|
||||
if 'temperature' in payload:
|
||||
config['temperature'] = payload['temperature']
|
||||
if 'top_p' in payload:
|
||||
config['topP'] = payload['top_p']
|
||||
stop = payload.get('stop')
|
||||
if stop:
|
||||
config['stopSequences'] = stop if isinstance(stop, list) else [stop]
|
||||
return config
|
||||
|
||||
|
||||
def _convert_tools(tools: Any) -> list[JsonDict] | None:
|
||||
"""将 CC tools 转为 Gemini functionDeclarations。"""
|
||||
if not isinstance(tools, list) or not tools:
|
||||
return None
|
||||
declarations: list[JsonDict] = []
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
func = tool.get('function', tool) if tool.get('type') == 'function' else tool
|
||||
if 'name' not in func:
|
||||
continue
|
||||
decl: JsonDict = {
|
||||
'name': func.get('name', ''),
|
||||
'description': func.get('description', ''),
|
||||
}
|
||||
params = func.get('parameters')
|
||||
if params:
|
||||
decl['parameters'] = params
|
||||
declarations.append(decl)
|
||||
if not declarations:
|
||||
return None
|
||||
return [{'functionDeclarations': declarations}]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 响应转换辅助
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _extract_parts(parts: list[Any]) -> tuple[str, str, list[JsonDict]]:
|
||||
"""从 Gemini parts 中提取文本、思考内容和工具调用。"""
|
||||
text = ''
|
||||
reasoning = ''
|
||||
tool_calls: list[JsonDict] = []
|
||||
|
||||
for part in parts:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get('thought') and 'text' in part:
|
||||
reasoning += part['text']
|
||||
elif 'text' in part:
|
||||
text += part['text']
|
||||
elif 'functionCall' in part:
|
||||
fc = part['functionCall']
|
||||
tool_calls.append({
|
||||
'index': len(tool_calls),
|
||||
'id': fc.get('id') or gen_id('call_'),
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': fc.get('name', ''),
|
||||
'arguments': json.dumps(fc.get('args', {}), ensure_ascii=False),
|
||||
},
|
||||
})
|
||||
|
||||
return text, reasoning, tool_calls
|
||||
|
||||
|
||||
def _convert_usage(meta: JsonDict) -> JsonDict:
|
||||
"""将 Gemini usageMetadata 转为 CC usage。"""
|
||||
prompt = meta.get('promptTokenCount', 0)
|
||||
candidates = meta.get('candidatesTokenCount', 0)
|
||||
thoughts = meta.get('thoughtsTokenCount', 0)
|
||||
completion = candidates + thoughts
|
||||
return {
|
||||
'prompt_tokens': prompt,
|
||||
'completion_tokens': completion,
|
||||
'total_tokens': prompt + completion,
|
||||
}
|
||||
|
||||
|
||||
def _merge_same_role(contents: list[JsonDict]) -> list[JsonDict]:
|
||||
"""合并相邻同角色的 Gemini contents。"""
|
||||
if not contents:
|
||||
return contents
|
||||
merged = [contents[0]]
|
||||
for c in contents[1:]:
|
||||
if c['role'] == merged[-1]['role']:
|
||||
merged[-1]['parts'].extend(c['parts'])
|
||||
else:
|
||||
merged.append(c)
|
||||
return merged
|
||||
|
||||
|
||||
def _flatten_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return '\n'.join(
|
||||
p.get('text', '') if isinstance(p, dict) else str(p)
|
||||
for p in content
|
||||
)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _parse_json_safe(text: Any) -> Any:
|
||||
if not isinstance(text, str):
|
||||
return text if text is not None else {}
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {'result': text} if text else {}
|
||||
|
|
@ -311,6 +311,7 @@ def _fix_stream_choice(choice: Any) -> None:
|
|||
|
||||
_promote_reasoning_field(delta)
|
||||
_convert_legacy_delta_function_call(delta, choice)
|
||||
_sanitize_tool_call_deltas(delta)
|
||||
_ensure_stream_tool_calls(delta)
|
||||
_rewrite_function_call_finish_reason(choice)
|
||||
|
||||
|
|
@ -332,6 +333,25 @@ def _convert_legacy_delta_function_call(delta: JsonDict, choice: JsonDict) -> No
|
|||
_rewrite_function_call_finish_reason(choice)
|
||||
|
||||
|
||||
def _sanitize_tool_call_deltas(delta: JsonDict) -> None:
|
||||
"""清理流式 tool_calls 中的空白字段。
|
||||
|
||||
某些 OpenAI 兼容提供商在后续 tool_calls chunk 中错误地发送空字符串的
|
||||
id/type/function.name,导致 Cursor 用空值覆盖真实值。
|
||||
不处理 function.arguments,因为空字符串是合法的增量拼接值。
|
||||
"""
|
||||
for tc in delta.get('tool_calls') or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
if 'id' in tc and not str(tc['id']).strip():
|
||||
del tc['id']
|
||||
if 'type' in tc and not str(tc['type']).strip():
|
||||
del tc['type']
|
||||
func = tc.get('function')
|
||||
if isinstance(func, dict) and 'name' in func and not str(func['name']).strip():
|
||||
del func['name']
|
||||
|
||||
|
||||
def _ensure_stream_tool_calls(delta: JsonDict) -> None:
|
||||
"""补全流式 tool_calls 的最小必需字段。
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue