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.
Files changed (77) hide show
  1. coding/__init__.py +0 -0
  2. coding/proxy/__init__.py +3 -0
  3. coding/proxy/__main__.py +5 -0
  4. coding/proxy/auth/__init__.py +13 -0
  5. coding/proxy/auth/providers/__init__.py +6 -0
  6. coding/proxy/auth/providers/base.py +35 -0
  7. coding/proxy/auth/providers/github.py +133 -0
  8. coding/proxy/auth/providers/google.py +237 -0
  9. coding/proxy/auth/runtime.py +122 -0
  10. coding/proxy/auth/store.py +74 -0
  11. coding/proxy/cli/__init__.py +151 -0
  12. coding/proxy/cli/auth_commands.py +224 -0
  13. coding/proxy/compat/__init__.py +30 -0
  14. coding/proxy/compat/canonical.py +193 -0
  15. coding/proxy/compat/session_store.py +137 -0
  16. coding/proxy/config/__init__.py +6 -0
  17. coding/proxy/config/auth_schema.py +24 -0
  18. coding/proxy/config/loader.py +139 -0
  19. coding/proxy/config/resiliency.py +46 -0
  20. coding/proxy/config/routing.py +279 -0
  21. coding/proxy/config/schema.py +280 -0
  22. coding/proxy/config/server.py +23 -0
  23. coding/proxy/config/vendors.py +53 -0
  24. coding/proxy/convert/__init__.py +14 -0
  25. coding/proxy/convert/anthropic_to_gemini.py +352 -0
  26. coding/proxy/convert/anthropic_to_openai.py +352 -0
  27. coding/proxy/convert/gemini_sse_adapter.py +169 -0
  28. coding/proxy/convert/gemini_to_anthropic.py +98 -0
  29. coding/proxy/convert/openai_to_anthropic.py +88 -0
  30. coding/proxy/logging/__init__.py +49 -0
  31. coding/proxy/logging/db.py +308 -0
  32. coding/proxy/logging/stats.py +129 -0
  33. coding/proxy/model/__init__.py +93 -0
  34. coding/proxy/model/auth.py +32 -0
  35. coding/proxy/model/compat.py +153 -0
  36. coding/proxy/model/constants.py +21 -0
  37. coding/proxy/model/pricing.py +70 -0
  38. coding/proxy/model/token.py +64 -0
  39. coding/proxy/model/vendor.py +218 -0
  40. coding/proxy/pricing.py +100 -0
  41. coding/proxy/routing/__init__.py +47 -0
  42. coding/proxy/routing/circuit_breaker.py +152 -0
  43. coding/proxy/routing/error_classifier.py +67 -0
  44. coding/proxy/routing/executor.py +453 -0
  45. coding/proxy/routing/model_mapper.py +90 -0
  46. coding/proxy/routing/quota_guard.py +169 -0
  47. coding/proxy/routing/rate_limit.py +159 -0
  48. coding/proxy/routing/retry.py +82 -0
  49. coding/proxy/routing/router.py +84 -0
  50. coding/proxy/routing/session_manager.py +62 -0
  51. coding/proxy/routing/tier.py +171 -0
  52. coding/proxy/routing/usage_parser.py +193 -0
  53. coding/proxy/routing/usage_recorder.py +131 -0
  54. coding/proxy/server/__init__.py +1 -0
  55. coding/proxy/server/app.py +142 -0
  56. coding/proxy/server/factory.py +175 -0
  57. coding/proxy/server/request_normalizer.py +139 -0
  58. coding/proxy/server/responses.py +74 -0
  59. coding/proxy/server/routes.py +264 -0
  60. coding/proxy/streaming/__init__.py +1 -0
  61. coding/proxy/streaming/anthropic_compat.py +484 -0
  62. coding/proxy/vendors/__init__.py +29 -0
  63. coding/proxy/vendors/anthropic.py +44 -0
  64. coding/proxy/vendors/antigravity.py +328 -0
  65. coding/proxy/vendors/base.py +353 -0
  66. coding/proxy/vendors/copilot.py +702 -0
  67. coding/proxy/vendors/copilot_models.py +438 -0
  68. coding/proxy/vendors/copilot_token_manager.py +167 -0
  69. coding/proxy/vendors/copilot_urls.py +16 -0
  70. coding/proxy/vendors/mixins.py +71 -0
  71. coding/proxy/vendors/token_manager.py +128 -0
  72. coding/proxy/vendors/zhipu.py +243 -0
  73. coding_proxy-0.1.0.dist-info/METADATA +184 -0
  74. coding_proxy-0.1.0.dist-info/RECORD +77 -0
  75. coding_proxy-0.1.0.dist-info/WHEEL +4 -0
  76. coding_proxy-0.1.0.dist-info/entry_points.txt +2 -0
  77. 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