coding-proxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coding/__init__.py +0 -0
- coding/proxy/__init__.py +3 -0
- coding/proxy/__main__.py +5 -0
- coding/proxy/auth/__init__.py +13 -0
- coding/proxy/auth/providers/__init__.py +6 -0
- coding/proxy/auth/providers/base.py +35 -0
- coding/proxy/auth/providers/github.py +133 -0
- coding/proxy/auth/providers/google.py +237 -0
- coding/proxy/auth/runtime.py +122 -0
- coding/proxy/auth/store.py +74 -0
- coding/proxy/cli/__init__.py +151 -0
- coding/proxy/cli/auth_commands.py +224 -0
- coding/proxy/compat/__init__.py +30 -0
- coding/proxy/compat/canonical.py +193 -0
- coding/proxy/compat/session_store.py +137 -0
- coding/proxy/config/__init__.py +6 -0
- coding/proxy/config/auth_schema.py +24 -0
- coding/proxy/config/loader.py +139 -0
- coding/proxy/config/resiliency.py +46 -0
- coding/proxy/config/routing.py +279 -0
- coding/proxy/config/schema.py +280 -0
- coding/proxy/config/server.py +23 -0
- coding/proxy/config/vendors.py +53 -0
- coding/proxy/convert/__init__.py +14 -0
- coding/proxy/convert/anthropic_to_gemini.py +352 -0
- coding/proxy/convert/anthropic_to_openai.py +352 -0
- coding/proxy/convert/gemini_sse_adapter.py +169 -0
- coding/proxy/convert/gemini_to_anthropic.py +98 -0
- coding/proxy/convert/openai_to_anthropic.py +88 -0
- coding/proxy/logging/__init__.py +49 -0
- coding/proxy/logging/db.py +308 -0
- coding/proxy/logging/stats.py +129 -0
- coding/proxy/model/__init__.py +93 -0
- coding/proxy/model/auth.py +32 -0
- coding/proxy/model/compat.py +153 -0
- coding/proxy/model/constants.py +21 -0
- coding/proxy/model/pricing.py +70 -0
- coding/proxy/model/token.py +64 -0
- coding/proxy/model/vendor.py +218 -0
- coding/proxy/pricing.py +100 -0
- coding/proxy/routing/__init__.py +47 -0
- coding/proxy/routing/circuit_breaker.py +152 -0
- coding/proxy/routing/error_classifier.py +67 -0
- coding/proxy/routing/executor.py +453 -0
- coding/proxy/routing/model_mapper.py +90 -0
- coding/proxy/routing/quota_guard.py +169 -0
- coding/proxy/routing/rate_limit.py +159 -0
- coding/proxy/routing/retry.py +82 -0
- coding/proxy/routing/router.py +84 -0
- coding/proxy/routing/session_manager.py +62 -0
- coding/proxy/routing/tier.py +171 -0
- coding/proxy/routing/usage_parser.py +193 -0
- coding/proxy/routing/usage_recorder.py +131 -0
- coding/proxy/server/__init__.py +1 -0
- coding/proxy/server/app.py +142 -0
- coding/proxy/server/factory.py +175 -0
- coding/proxy/server/request_normalizer.py +139 -0
- coding/proxy/server/responses.py +74 -0
- coding/proxy/server/routes.py +264 -0
- coding/proxy/streaming/__init__.py +1 -0
- coding/proxy/streaming/anthropic_compat.py +484 -0
- coding/proxy/vendors/__init__.py +29 -0
- coding/proxy/vendors/anthropic.py +44 -0
- coding/proxy/vendors/antigravity.py +328 -0
- coding/proxy/vendors/base.py +353 -0
- coding/proxy/vendors/copilot.py +702 -0
- coding/proxy/vendors/copilot_models.py +438 -0
- coding/proxy/vendors/copilot_token_manager.py +167 -0
- coding/proxy/vendors/copilot_urls.py +16 -0
- coding/proxy/vendors/mixins.py +71 -0
- coding/proxy/vendors/token_manager.py +128 -0
- coding/proxy/vendors/zhipu.py +243 -0
- coding_proxy-0.1.0.dist-info/METADATA +184 -0
- coding_proxy-0.1.0.dist-info/RECORD +77 -0
- coding_proxy-0.1.0.dist-info/WHEEL +4 -0
- coding_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- coding_proxy-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Anthropic Messages → OpenAI Chat Completions 转换."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def convert_request(body: dict[str, Any]) -> dict[str, Any]:
|
|
14
|
+
"""转换 Anthropic Messages 请求为 OpenAI chat.completions 负载."""
|
|
15
|
+
result: dict[str, Any] = {
|
|
16
|
+
"model": _translate_model_name(body.get("model", "")),
|
|
17
|
+
"messages": _translate_messages(body.get("messages", []), body.get("system")),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
scalar_mappings = {
|
|
21
|
+
"max_tokens": "max_tokens",
|
|
22
|
+
"temperature": "temperature",
|
|
23
|
+
"top_p": "top_p",
|
|
24
|
+
"stream": "stream",
|
|
25
|
+
}
|
|
26
|
+
for source_key, target_key in scalar_mappings.items():
|
|
27
|
+
value = body.get(source_key)
|
|
28
|
+
if value is not None:
|
|
29
|
+
result[target_key] = value
|
|
30
|
+
|
|
31
|
+
stop_sequences = body.get("stop_sequences")
|
|
32
|
+
if stop_sequences is not None:
|
|
33
|
+
result["stop"] = stop_sequences
|
|
34
|
+
|
|
35
|
+
# Metadata:user_id 映射到 OpenAI user 字段,其余完整透传
|
|
36
|
+
metadata = body.get("metadata")
|
|
37
|
+
if isinstance(metadata, dict):
|
|
38
|
+
if metadata.get("user_id"):
|
|
39
|
+
result["user"] = metadata["user_id"]
|
|
40
|
+
extra_metadata = {k: v for k, v in metadata.items() if k != "user_id"}
|
|
41
|
+
if extra_metadata:
|
|
42
|
+
result["metadata"] = metadata
|
|
43
|
+
logger.debug(
|
|
44
|
+
"copilot: metadata forwarded with keys: %s",
|
|
45
|
+
list(metadata.keys()),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
request_id = body.get("request_id")
|
|
49
|
+
if isinstance(request_id, str) and request_id:
|
|
50
|
+
result["request_id"] = request_id
|
|
51
|
+
|
|
52
|
+
response_format = body.get("response_format")
|
|
53
|
+
if isinstance(response_format, dict) and response_format.get("type"):
|
|
54
|
+
result["response_format"] = response_format
|
|
55
|
+
|
|
56
|
+
# Thinking / Extended Thinking → reasoning_effort 映射
|
|
57
|
+
thinking_params = _translate_thinking(body)
|
|
58
|
+
if thinking_params:
|
|
59
|
+
result.update(thinking_params)
|
|
60
|
+
logger.debug("copilot: thinking params mapped: %s", thinking_params)
|
|
61
|
+
|
|
62
|
+
tools = body.get("tools")
|
|
63
|
+
if tools:
|
|
64
|
+
result["tools"] = [_translate_tool(tool) for tool in tools]
|
|
65
|
+
|
|
66
|
+
tool_choice = body.get("tool_choice")
|
|
67
|
+
translated_tool_choice = _translate_tool_choice(tool_choice)
|
|
68
|
+
if translated_tool_choice is not None:
|
|
69
|
+
result["tool_choice"] = translated_tool_choice
|
|
70
|
+
|
|
71
|
+
if body.get("stream"):
|
|
72
|
+
result["stream_options"] = {"include_usage": True}
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _translate_model_name(model: str) -> str:
|
|
78
|
+
"""精细化模型名映射.
|
|
79
|
+
|
|
80
|
+
Copilot 可用格式: claude-{family}-{major}[.{minor}]
|
|
81
|
+
Anthropic 请求格式: claude-{family}-{major}-YYYYMMDD 或 claude-{family}-{major}.{minor}-YYYYMMDD
|
|
82
|
+
"""
|
|
83
|
+
# 已是 Copilot 原生格式(含可选 minor 版本)直接透传
|
|
84
|
+
copilot_pattern = re.match(r"^claude-(sonnet|opus|haiku)-\d+(\.\d+)?$", model)
|
|
85
|
+
if copilot_pattern:
|
|
86
|
+
logger.debug("copilot: model name already in Copilot format: %s", model)
|
|
87
|
+
return model
|
|
88
|
+
|
|
89
|
+
# 现有逻辑:去除日期后缀(4.x 无 minor 版本)
|
|
90
|
+
if model.startswith("claude-sonnet-4-"):
|
|
91
|
+
return "claude-sonnet-4"
|
|
92
|
+
if model.startswith("claude-opus-4-"):
|
|
93
|
+
return "claude-opus-4"
|
|
94
|
+
if model.startswith("claude-haiku-4-"):
|
|
95
|
+
return "claude-haiku-4"
|
|
96
|
+
|
|
97
|
+
# 新增:处理带 minor 版本的 Anthropic 格式
|
|
98
|
+
# 例如 claude-sonnet-4.6-20250514 -> claude-sonnet-4.6
|
|
99
|
+
versioned_match = re.match(r"^(claude-(?:sonnet|opus|haiku))-(\d+\.\d+)-\d+$", model)
|
|
100
|
+
if versioned_match:
|
|
101
|
+
family = versioned_match.group(1)
|
|
102
|
+
version = versioned_match.group(2)
|
|
103
|
+
normalized = f"{family}-{version}"
|
|
104
|
+
logger.debug("copilot: model name normalized: %s -> %s", model, normalized)
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
return model
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _translate_thinking(body: dict[str, Any]) -> dict[str, Any] | None:
|
|
111
|
+
"""将 Anthropic thinking/extended_thinking 映射为 OpenAI 推理参数.
|
|
112
|
+
|
|
113
|
+
映射策略:
|
|
114
|
+
- extended_thinking.effort ("low"/"medium"/"high") → reasoning_effort 同值
|
|
115
|
+
- thinking: True / {type:"enabled"} → reasoning_effort "medium"
|
|
116
|
+
- budget_tokens → 记录 DEBUG 日志(OpenAI 无直接对应字段)
|
|
117
|
+
"""
|
|
118
|
+
# 优先检查 extended_thinking(Claude Code 主要使用方式)
|
|
119
|
+
extended = body.get("extended_thinking")
|
|
120
|
+
if isinstance(extended, dict):
|
|
121
|
+
effort = extended.get("effort", "")
|
|
122
|
+
budget = extended.get("budget_tokens")
|
|
123
|
+
result: dict[str, Any] = {}
|
|
124
|
+
if effort:
|
|
125
|
+
result["reasoning_effort"] = effort
|
|
126
|
+
if isinstance(budget, int) and budget > 0:
|
|
127
|
+
logger.debug(
|
|
128
|
+
"copilot: extended_thinking.budget_tokens=%d "
|
|
129
|
+
"(OpenAI 无直接对应字段, 记录供调试)",
|
|
130
|
+
budget,
|
|
131
|
+
)
|
|
132
|
+
return result if result else None
|
|
133
|
+
|
|
134
|
+
# 回退到简单 thinking 布尔标志 / 字典型式(任意非空 dict 均视为启用)
|
|
135
|
+
thinking = body.get("thinking")
|
|
136
|
+
if thinking is True or isinstance(thinking, dict):
|
|
137
|
+
return {"reasoning_effort": "medium"}
|
|
138
|
+
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _translate_messages(
|
|
143
|
+
messages: list[dict[str, Any]],
|
|
144
|
+
system: str | list[dict[str, Any]] | None,
|
|
145
|
+
) -> list[dict[str, Any]]:
|
|
146
|
+
translated: list[dict[str, Any]] = []
|
|
147
|
+
translated.extend(_translate_system(system))
|
|
148
|
+
for message in messages:
|
|
149
|
+
role = message.get("role")
|
|
150
|
+
if role == "user":
|
|
151
|
+
translated.extend(_translate_user_message(message))
|
|
152
|
+
elif role == "assistant":
|
|
153
|
+
translated.extend(_translate_assistant_message(message))
|
|
154
|
+
return translated
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _translate_system(system: str | list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
158
|
+
"""转换 system prompt,保留 cache_control 边界信息(通过 DEBUG 日志).
|
|
159
|
+
|
|
160
|
+
OpenAI 的 system role message 不原生支持 cache_control block。
|
|
161
|
+
策略:提取所有 text 内容并拼接,检测 cache_control 时记录日志供调试。
|
|
162
|
+
"""
|
|
163
|
+
if not system:
|
|
164
|
+
return []
|
|
165
|
+
if isinstance(system, str):
|
|
166
|
+
return [{"role": "system", "content": system}]
|
|
167
|
+
|
|
168
|
+
parts: list[str] = []
|
|
169
|
+
cache_control_count = 0
|
|
170
|
+
for block in system:
|
|
171
|
+
if not isinstance(block, dict):
|
|
172
|
+
continue
|
|
173
|
+
if block.get("type") == "text":
|
|
174
|
+
text = block.get("text", "")
|
|
175
|
+
if text:
|
|
176
|
+
parts.append(text)
|
|
177
|
+
if "cache_control" in block:
|
|
178
|
+
cache_control_count += 1
|
|
179
|
+
|
|
180
|
+
if cache_control_count > 0:
|
|
181
|
+
text = "\n\n".join(part for part in parts if part)
|
|
182
|
+
logger.debug(
|
|
183
|
+
"copilot: system prompt had %d cache_control block(s), "
|
|
184
|
+
"collapsed into single system message (%d chars)",
|
|
185
|
+
cache_control_count,
|
|
186
|
+
len(text),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
text = "\n\n".join(part for part in parts if part)
|
|
190
|
+
return [{"role": "system", "content": text}] if text else []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _translate_user_message(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
194
|
+
content = message.get("content")
|
|
195
|
+
if not isinstance(content, list):
|
|
196
|
+
return [{"role": "user", "content": content or ""}]
|
|
197
|
+
|
|
198
|
+
translated: list[dict[str, Any]] = []
|
|
199
|
+
tool_results = [block for block in content if isinstance(block, dict) and block.get("type") == "tool_result"]
|
|
200
|
+
other_blocks = [block for block in content if isinstance(block, dict) and block.get("type") != "tool_result"]
|
|
201
|
+
|
|
202
|
+
for block in tool_results:
|
|
203
|
+
tool_result_content = _map_block_content(block.get("content", ""))
|
|
204
|
+
is_error = block.get("is_error", False)
|
|
205
|
+
if is_error:
|
|
206
|
+
logger.debug(
|
|
207
|
+
"copilot: tool_result is_error=True for tool_use_id=%s "
|
|
208
|
+
"(OpenAI 不原生支持 is_error, 注入 [ERROR] 前缀到 content)",
|
|
209
|
+
block.get("tool_use_id", ""),
|
|
210
|
+
)
|
|
211
|
+
tool_result_content = f"[ERROR]\n{tool_result_content}"
|
|
212
|
+
translated.append({
|
|
213
|
+
"role": "tool",
|
|
214
|
+
"tool_call_id": block.get("tool_use_id", ""),
|
|
215
|
+
"content": tool_result_content,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
if other_blocks:
|
|
219
|
+
translated.append({
|
|
220
|
+
"role": "user",
|
|
221
|
+
"content": _map_block_content(other_blocks),
|
|
222
|
+
})
|
|
223
|
+
return translated
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _translate_assistant_message(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
227
|
+
content = message.get("content")
|
|
228
|
+
if not isinstance(content, list):
|
|
229
|
+
return [{"role": "assistant", "content": content or ""}]
|
|
230
|
+
|
|
231
|
+
tool_uses = [block for block in content if isinstance(block, dict) and block.get("type") == "tool_use"]
|
|
232
|
+
text_parts: list[str] = []
|
|
233
|
+
thinking_parts: list[str] = []
|
|
234
|
+
|
|
235
|
+
for block in content:
|
|
236
|
+
if not isinstance(block, dict):
|
|
237
|
+
continue
|
|
238
|
+
if block.get("type") == "text":
|
|
239
|
+
text_parts.append(block.get("text", ""))
|
|
240
|
+
elif block.get("type") == "thinking":
|
|
241
|
+
# 不再合并到文本,而是独立收集
|
|
242
|
+
thinking_content = block.get("thinking", "")
|
|
243
|
+
if thinking_content:
|
|
244
|
+
thinking_parts.append(thinking_content)
|
|
245
|
+
|
|
246
|
+
# 构建最终内容:根据 thinking 和 text 的组合情况决定策略
|
|
247
|
+
final_text_parts: list[str] = []
|
|
248
|
+
if thinking_parts and not text_parts and not tool_uses:
|
|
249
|
+
# 只有 thinking 没有 text 也没有工具调用时,用 thinking 作为 content(降级方案)
|
|
250
|
+
final_text_parts = thinking_parts
|
|
251
|
+
elif thinking_parts and text_parts:
|
|
252
|
+
# 同时存在时,在 text 前加上 thinking 标记(让模型知道上下文)
|
|
253
|
+
logger.debug(
|
|
254
|
+
"copilot: assistant message has both thinking (%d blocks) and text (%d blocks), "
|
|
255
|
+
"thinking will be prepended as [Thinking]...[/Thinking] context",
|
|
256
|
+
len(thinking_parts), len(text_parts),
|
|
257
|
+
)
|
|
258
|
+
final_text_parts = [
|
|
259
|
+
f"[Thinking]\n{''.join(thinking_parts)}\n[/Thinking]\n\n",
|
|
260
|
+
*text_parts,
|
|
261
|
+
]
|
|
262
|
+
else:
|
|
263
|
+
final_text_parts = text_parts
|
|
264
|
+
|
|
265
|
+
if tool_uses:
|
|
266
|
+
return [{
|
|
267
|
+
"role": "assistant",
|
|
268
|
+
"content": "\n\n".join(part for part in final_text_parts if part) or None,
|
|
269
|
+
"tool_calls": [
|
|
270
|
+
{
|
|
271
|
+
"id": block.get("id", ""),
|
|
272
|
+
"type": "function",
|
|
273
|
+
"function": {
|
|
274
|
+
"name": block.get("name", ""),
|
|
275
|
+
"arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
for block in tool_uses
|
|
279
|
+
],
|
|
280
|
+
}]
|
|
281
|
+
|
|
282
|
+
return [{
|
|
283
|
+
"role": "assistant",
|
|
284
|
+
"content": _map_block_content(content) if not thinking_parts and not tool_uses
|
|
285
|
+
else "\n\n".join(part for part in final_text_parts if part) or "",
|
|
286
|
+
}]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _map_block_content(content: Any) -> Any:
|
|
290
|
+
if isinstance(content, str):
|
|
291
|
+
return content
|
|
292
|
+
if not isinstance(content, list):
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
has_image = any(isinstance(block, dict) and block.get("type") == "image" for block in content)
|
|
296
|
+
if not has_image:
|
|
297
|
+
parts: list[str] = []
|
|
298
|
+
for block in content:
|
|
299
|
+
if not isinstance(block, dict):
|
|
300
|
+
continue
|
|
301
|
+
if block.get("type") == "text":
|
|
302
|
+
parts.append(block.get("text", ""))
|
|
303
|
+
elif block.get("type") == "thinking":
|
|
304
|
+
parts.append(block.get("thinking", ""))
|
|
305
|
+
return "\n\n".join(part for part in parts if part)
|
|
306
|
+
|
|
307
|
+
translated: list[dict[str, Any]] = []
|
|
308
|
+
for block in content:
|
|
309
|
+
if not isinstance(block, dict):
|
|
310
|
+
continue
|
|
311
|
+
if block.get("type") == "text":
|
|
312
|
+
translated.append({"type": "text", "text": block.get("text", "")})
|
|
313
|
+
elif block.get("type") == "thinking":
|
|
314
|
+
translated.append({"type": "text", "text": block.get("thinking", "")})
|
|
315
|
+
elif block.get("type") == "image":
|
|
316
|
+
source = block.get("source", {})
|
|
317
|
+
translated.append({
|
|
318
|
+
"type": "image_url",
|
|
319
|
+
"image_url": {
|
|
320
|
+
"url": f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}",
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
return translated
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _translate_tool(tool: dict[str, Any]) -> dict[str, Any]:
|
|
327
|
+
return {
|
|
328
|
+
"type": "function",
|
|
329
|
+
"function": {
|
|
330
|
+
"name": tool.get("name", ""),
|
|
331
|
+
"description": tool.get("description"),
|
|
332
|
+
"parameters": tool.get("input_schema", {}),
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _translate_tool_choice(tool_choice: dict[str, Any] | None) -> str | dict[str, Any] | None:
|
|
338
|
+
if not isinstance(tool_choice, dict):
|
|
339
|
+
return None
|
|
340
|
+
choice_type = tool_choice.get("type")
|
|
341
|
+
if choice_type == "auto":
|
|
342
|
+
return "auto"
|
|
343
|
+
if choice_type == "any":
|
|
344
|
+
return "required"
|
|
345
|
+
if choice_type == "none":
|
|
346
|
+
return "none"
|
|
347
|
+
if choice_type == "tool" and tool_choice.get("name"):
|
|
348
|
+
return {
|
|
349
|
+
"type": "function",
|
|
350
|
+
"function": {"name": tool_choice["name"]},
|
|
351
|
+
}
|
|
352
|
+
return None
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Gemini SSE 字节流 → Anthropic SSE 字节流适配器."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, AsyncIterator
|
|
9
|
+
|
|
10
|
+
from .gemini_to_anthropic import GEMINI_FINISH_REASON_MAP
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def adapt_sse_stream(
|
|
16
|
+
gemini_chunks: AsyncIterator[bytes],
|
|
17
|
+
model: str,
|
|
18
|
+
request_id: str | None = None,
|
|
19
|
+
) -> AsyncIterator[bytes]:
|
|
20
|
+
"""将 Gemini SSE 流转换为 Anthropic Messages SSE 流."""
|
|
21
|
+
msg_id = request_id or f"msg_{uuid.uuid4().hex[:24]}"
|
|
22
|
+
started = False
|
|
23
|
+
block_index = 0
|
|
24
|
+
current_block_type: str | None = None
|
|
25
|
+
total_output_tokens = 0
|
|
26
|
+
input_tokens = 0
|
|
27
|
+
used_tool = False
|
|
28
|
+
|
|
29
|
+
async for raw_chunk in gemini_chunks:
|
|
30
|
+
text = raw_chunk.decode("utf-8", errors="ignore")
|
|
31
|
+
|
|
32
|
+
for line in text.split("\n"):
|
|
33
|
+
line = line.strip()
|
|
34
|
+
if not line.startswith("data:"):
|
|
35
|
+
continue
|
|
36
|
+
payload = line[5:].strip()
|
|
37
|
+
if not payload or payload == "[DONE]":
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(payload)
|
|
42
|
+
except json.JSONDecodeError:
|
|
43
|
+
logger.debug("SSE chunk JSON 解析失败,跳过: %s", payload[:200])
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
meta = data.get("usageMetadata", {})
|
|
47
|
+
if "promptTokenCount" in meta:
|
|
48
|
+
input_tokens = meta["promptTokenCount"]
|
|
49
|
+
if "candidatesTokenCount" in meta:
|
|
50
|
+
total_output_tokens = meta["candidatesTokenCount"]
|
|
51
|
+
|
|
52
|
+
candidates = data.get("candidates", [])
|
|
53
|
+
if not candidates:
|
|
54
|
+
continue
|
|
55
|
+
candidate = candidates[0]
|
|
56
|
+
parts = candidate.get("content", {}).get("parts", [])
|
|
57
|
+
finish_reason = candidate.get("finishReason")
|
|
58
|
+
|
|
59
|
+
for part in parts:
|
|
60
|
+
block_type, start_block, delta = _part_to_events(part)
|
|
61
|
+
if delta is None:
|
|
62
|
+
continue
|
|
63
|
+
if not started:
|
|
64
|
+
started = True
|
|
65
|
+
yield _make_event("message_start", {
|
|
66
|
+
"type": "message_start",
|
|
67
|
+
"message": {
|
|
68
|
+
"id": msg_id,
|
|
69
|
+
"type": "message",
|
|
70
|
+
"role": "assistant",
|
|
71
|
+
"content": [],
|
|
72
|
+
"model": model,
|
|
73
|
+
"usage": {"input_tokens": input_tokens, "output_tokens": 0},
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if current_block_type != block_type:
|
|
78
|
+
if current_block_type is not None:
|
|
79
|
+
yield _make_event("content_block_stop", {
|
|
80
|
+
"type": "content_block_stop",
|
|
81
|
+
"index": block_index,
|
|
82
|
+
})
|
|
83
|
+
block_index += 1
|
|
84
|
+
yield _make_event("content_block_start", {
|
|
85
|
+
"type": "content_block_start",
|
|
86
|
+
"index": block_index,
|
|
87
|
+
"content_block": start_block,
|
|
88
|
+
})
|
|
89
|
+
current_block_type = block_type
|
|
90
|
+
|
|
91
|
+
yield _make_event("content_block_delta", {
|
|
92
|
+
"type": "content_block_delta",
|
|
93
|
+
"index": block_index,
|
|
94
|
+
"delta": delta,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if block_type == "tool_use":
|
|
98
|
+
used_tool = True
|
|
99
|
+
yield _make_event("content_block_stop", {
|
|
100
|
+
"type": "content_block_stop",
|
|
101
|
+
"index": block_index,
|
|
102
|
+
})
|
|
103
|
+
block_index += 1
|
|
104
|
+
current_block_type = None
|
|
105
|
+
|
|
106
|
+
if finish_reason and finish_reason != "FINISH_REASON_UNSPECIFIED":
|
|
107
|
+
if current_block_type is not None:
|
|
108
|
+
yield _make_event("content_block_stop", {
|
|
109
|
+
"type": "content_block_stop",
|
|
110
|
+
"index": block_index,
|
|
111
|
+
})
|
|
112
|
+
current_block_type = None
|
|
113
|
+
stop_reason = "tool_use" if used_tool else _map_finish_reason(finish_reason)
|
|
114
|
+
yield _make_event("message_delta", {
|
|
115
|
+
"type": "message_delta",
|
|
116
|
+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
|
117
|
+
"usage": {"output_tokens": total_output_tokens},
|
|
118
|
+
})
|
|
119
|
+
yield _make_event("message_stop", {"type": "message_stop"})
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
if current_block_type is not None:
|
|
123
|
+
yield _make_event("content_block_stop", {
|
|
124
|
+
"type": "content_block_stop",
|
|
125
|
+
"index": block_index,
|
|
126
|
+
})
|
|
127
|
+
yield _make_event("message_delta", {
|
|
128
|
+
"type": "message_delta",
|
|
129
|
+
"delta": {"stop_reason": "tool_use" if used_tool else "end_turn", "stop_sequence": None},
|
|
130
|
+
"usage": {"output_tokens": total_output_tokens},
|
|
131
|
+
})
|
|
132
|
+
yield _make_event("message_stop", {"type": "message_stop"})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _part_to_events(part: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any] | None]:
|
|
136
|
+
if part.get("functionCall"):
|
|
137
|
+
fc = part["functionCall"]
|
|
138
|
+
start_block = {
|
|
139
|
+
"type": "tool_use",
|
|
140
|
+
"id": fc.get("id") or f"toolu_{uuid.uuid4().hex[:24]}",
|
|
141
|
+
"name": fc.get("name", ""),
|
|
142
|
+
"input": {},
|
|
143
|
+
}
|
|
144
|
+
return "tool_use", start_block, {
|
|
145
|
+
"type": "input_json_delta",
|
|
146
|
+
"partial_json": json.dumps(fc.get("args", {}), ensure_ascii=False),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if part.get("text") is not None and part.get("thought"):
|
|
150
|
+
return "thinking", {"type": "thinking", "thinking": ""}, {
|
|
151
|
+
"type": "thinking_delta",
|
|
152
|
+
"thinking": part.get("text", ""),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if part.get("text"):
|
|
156
|
+
return "text", {"type": "text", "text": ""}, {
|
|
157
|
+
"type": "text_delta",
|
|
158
|
+
"text": part["text"],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return "text", {"type": "text", "text": ""}, None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _make_event(event_type: str, data: dict[str, Any]) -> bytes:
|
|
165
|
+
return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _map_finish_reason(reason: str) -> str:
|
|
169
|
+
return GEMINI_FINISH_REASON_MAP.get(reason, "end_turn")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Google Gemini 响应 → Anthropic Messages API 格式转换."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Gemini finishReason → Anthropic stop_reason 映射(SOT)
|
|
12
|
+
# 本映射为 Gemini→Anthropic 协议转换层中 finish reason 的唯一定义源,
|
|
13
|
+
# gemini_sse_adapter 通过导入本常量实现去重。
|
|
14
|
+
GEMINI_FINISH_REASON_MAP: dict[str, str] = {
|
|
15
|
+
"STOP": "end_turn",
|
|
16
|
+
"MAX_TOKENS": "max_tokens",
|
|
17
|
+
"SAFETY": "end_turn",
|
|
18
|
+
"RECITATION": "end_turn",
|
|
19
|
+
"OTHER": "end_turn",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def convert_response(
|
|
24
|
+
gemini_resp: dict[str, Any],
|
|
25
|
+
*,
|
|
26
|
+
model: str = "unknown",
|
|
27
|
+
request_id: str | None = None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""将 Gemini 非流式响应转换为 Anthropic Messages API 格式."""
|
|
30
|
+
candidates = gemini_resp.get("candidates", [])
|
|
31
|
+
candidate = candidates[0] if candidates else {}
|
|
32
|
+
|
|
33
|
+
content_parts = candidate.get("content", {}).get("parts", [])
|
|
34
|
+
content_blocks = _convert_parts(content_parts)
|
|
35
|
+
|
|
36
|
+
finish_reason = candidate.get("finishReason", "STOP")
|
|
37
|
+
stop_reason = "tool_use" if any(block.get("type") == "tool_use" for block in content_blocks) else (
|
|
38
|
+
GEMINI_FINISH_REASON_MAP.get(finish_reason, "end_turn")
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
usage = extract_usage(gemini_resp)
|
|
42
|
+
msg_id = request_id or gemini_resp.get("responseId") or f"msg_{uuid.uuid4().hex[:24]}"
|
|
43
|
+
|
|
44
|
+
result = {
|
|
45
|
+
"id": msg_id,
|
|
46
|
+
"type": "message",
|
|
47
|
+
"role": "assistant",
|
|
48
|
+
"content": content_blocks,
|
|
49
|
+
"model": model,
|
|
50
|
+
"stop_reason": stop_reason,
|
|
51
|
+
"stop_sequence": None,
|
|
52
|
+
"usage": usage,
|
|
53
|
+
}
|
|
54
|
+
logger.debug(
|
|
55
|
+
"convert_response: %d content blocks, stop_reason=%s",
|
|
56
|
+
len(content_blocks),
|
|
57
|
+
stop_reason,
|
|
58
|
+
)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_usage(gemini_resp: dict[str, Any]) -> dict[str, int]:
|
|
63
|
+
meta = gemini_resp.get("usageMetadata", {})
|
|
64
|
+
return {
|
|
65
|
+
"input_tokens": meta.get("promptTokenCount", 0),
|
|
66
|
+
"output_tokens": meta.get("candidatesTokenCount", 0),
|
|
67
|
+
"cache_creation_input_tokens": 0,
|
|
68
|
+
"cache_read_input_tokens": 0,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _convert_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
73
|
+
blocks: list[dict[str, Any]] = []
|
|
74
|
+
for part in parts:
|
|
75
|
+
signature = part.get("thoughtSignature")
|
|
76
|
+
if part.get("functionCall"):
|
|
77
|
+
fc = part["functionCall"]
|
|
78
|
+
blocks.append({
|
|
79
|
+
"type": "tool_use",
|
|
80
|
+
"id": fc.get("id") or f"toolu_{uuid.uuid4().hex[:24]}",
|
|
81
|
+
"name": fc.get("name", ""),
|
|
82
|
+
"input": fc.get("args", {}),
|
|
83
|
+
**({"signature": signature} if signature else {}),
|
|
84
|
+
})
|
|
85
|
+
continue
|
|
86
|
+
if part.get("text") is not None:
|
|
87
|
+
text = part.get("text", "")
|
|
88
|
+
if part.get("thought"):
|
|
89
|
+
blocks.append({
|
|
90
|
+
"type": "thinking",
|
|
91
|
+
"thinking": text,
|
|
92
|
+
**({"signature": signature} if signature else {}),
|
|
93
|
+
})
|
|
94
|
+
elif text:
|
|
95
|
+
blocks.append({"type": "text", "text": text})
|
|
96
|
+
elif signature:
|
|
97
|
+
blocks.append({"type": "thinking", "thinking": "", "signature": signature})
|
|
98
|
+
return blocks
|