HouYunFei Codex commited on
Commit
c50f4e5
·
1 Parent(s): 7beb290

fix claude message tool streaming

Browse files

Co-Authored-By: Codex <noreply@openai.com>

services/anthropic_protocol.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import re
6
+ import time
7
+ import uuid
8
+ from collections.abc import Callable, Iterable, Iterator
9
+ from typing import Any
10
+
11
+ XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
12
+ <tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
13
+
14
+
15
+ def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
16
+ fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
17
+ name = str(tool.get("name") or fn.get("name") or "").strip()
18
+ desc = str(tool.get("description") or fn.get("description") or "").strip()
19
+ schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
20
+ return name, desc, schema
21
+
22
+
23
+ def build_tool_prompt(tools: object) -> str:
24
+ if not isinstance(tools, list):
25
+ return ""
26
+ blocks = []
27
+ for tool in tools:
28
+ if not isinstance(tool, dict):
29
+ continue
30
+ name, desc, schema = _tool_meta(tool)
31
+ if name:
32
+ blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
33
+ if not blocks:
34
+ return ""
35
+ return "Available tools:\n" + "\n".join(blocks) + """
36
+
37
+ Tool use rules:
38
+ - If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
39
+ - To call tools, output ONLY XML and no prose/markdown:
40
+ <tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>
41
+ - Put parameters under <parameters> using the exact schema names.
42
+ """.strip()
43
+
44
+
45
+ def merge_system(system: object, extra: str) -> object:
46
+ system = compact_system(system)
47
+ if _has_claude_code_system(system):
48
+ extra = XML_TOOL_RULE
49
+ if not extra:
50
+ return system
51
+ if isinstance(system, str) and system.strip():
52
+ return f"{system.strip()}\n\n{extra}"
53
+ if isinstance(system, list):
54
+ return [*system, {"type": "text", "text": extra}]
55
+ return extra
56
+
57
+
58
+ def _has_claude_code_system(system: object) -> bool:
59
+ if isinstance(system, str):
60
+ return "You are Claude Code" in system
61
+ if isinstance(system, list):
62
+ return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
63
+ return False
64
+
65
+
66
+ def compact_system(system: object) -> object:
67
+ if isinstance(system, str):
68
+ return _compact_system_text(system)
69
+ if isinstance(system, list):
70
+ result = []
71
+ for item in system:
72
+ if isinstance(item, dict) and str(item.get("type") or "") == "text":
73
+ copied = dict(item)
74
+ copied["text"] = _compact_system_text(str(item.get("text") or ""))
75
+ result.append(copied)
76
+ else:
77
+ result.append(item)
78
+ return result
79
+ return system
80
+
81
+
82
+ def _compact_system_text(text: str) -> str:
83
+ return text or ""
84
+
85
+
86
+ def _compact_message_text(text: str) -> str:
87
+ return text or ""
88
+
89
+
90
+ def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
91
+ payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
92
+ payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
93
+ return payload
94
+
95
+
96
+ def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
97
+ if not isinstance(messages, list):
98
+ return messages
99
+ mapper = text_mapper or (lambda text: text)
100
+ result = []
101
+ for message in messages:
102
+ if not isinstance(message, dict):
103
+ continue
104
+ item = dict(message)
105
+ content = item.get("content")
106
+ if isinstance(content, str):
107
+ item["content"] = _compact_message_text(mapper(content))
108
+ elif isinstance(content, list):
109
+ item["content"] = [_preprocess_block(block, mapper) for block in content]
110
+ result.append(item)
111
+ return result
112
+
113
+
114
+ def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
115
+ if not isinstance(block, dict):
116
+ return block
117
+ block_type = str(block.get("type") or "")
118
+ if block_type == "text":
119
+ item = dict(block)
120
+ item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
121
+ return item
122
+ if block_type == "tool_use":
123
+ return {"type": "text", "text": f"<tool_calls><tool_call><tool_name>{block.get('name') or ''}</tool_name><parameters>{json.dumps(block.get('input') or {}, ensure_ascii=False)}</parameters></tool_call></tool_calls>"}
124
+ if block_type == "tool_result":
125
+ return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
126
+ return block
127
+
128
+
129
+ def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
130
+ content, stop_reason = content_blocks(text, tools)
131
+ return {
132
+ "id": f"msg_{uuid.uuid4()}",
133
+ "type": "message",
134
+ "role": "assistant",
135
+ "model": model,
136
+ "content": content,
137
+ "stop_reason": stop_reason,
138
+ "stop_sequence": None,
139
+ "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
140
+ }
141
+
142
+
143
+ def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
144
+ calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
145
+ text = strip_tool_markup(text)
146
+ if calls:
147
+ content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
148
+ return content, "tool_use"
149
+ return [{"type": "text", "text": text}], "end_turn"
150
+
151
+
152
+ def strip_tool_markup(text: str) -> str:
153
+ return re.sub(r"(?is)<tool_calls\b[^>]*>.*?</tool_calls>|<tool_call\b[^>]*>.*?</tool_call>|<function_call\b[^>]*>.*?</function_call>|<invoke\b[^>]*>.*?</invoke>", "", text or "").strip()
154
+
155
+
156
+ def streamable_text(text: str) -> str:
157
+ text = text or ""
158
+ match = re.search(r"(?is)<tool_calls\b|<tool_call\b|<function_call\b|<invoke\b", text)
159
+ return text[:match.start()].rstrip() if match else text
160
+
161
+
162
+ def parse_tool_calls(text: str) -> list[tuple[str, dict[str, object]]]:
163
+ text = re.sub(r"(?is)```.*?```", "", text or "").strip()
164
+ blocks = re.findall(r"(?is)<tool_call\b[^>]*>(.*?)</tool_call>|<function_call\b[^>]*>(.*?)</function_call>|<invoke\b[^>]*>(.*?)</invoke>", text)
165
+ result = []
166
+ for block in (next((part for part in match if part), "") for match in blocks):
167
+ name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
168
+ params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
169
+ if name:
170
+ result.append((name, parse_tool_params(params)))
171
+ return result
172
+
173
+
174
+ def xml_value(text: str, tag: str) -> str:
175
+ match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)</{tag}>", text)
176
+ if not match:
177
+ return ""
178
+ value = match.group(1).strip()
179
+ cdata = re.fullmatch(r"(?is)<!\[CDATA\[(.*?)]]>", value)
180
+ return html.unescape(cdata.group(1) if cdata else value).strip()
181
+
182
+
183
+ def parse_tool_params(raw: str) -> dict[str, object]:
184
+ raw = raw.strip()
185
+ try:
186
+ parsed = json.loads(raw)
187
+ return parsed if isinstance(parsed, dict) else {}
188
+ except Exception:
189
+ return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)</\1>", raw)}
190
+
191
+
192
+ def parse_tool_value(raw: str) -> object:
193
+ value = xml_value(f"<x>{raw}</x>", "x")
194
+ try:
195
+ return json.loads(value)
196
+ except Exception:
197
+ return value
198
+
199
+
200
+ def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
201
+ message_id = f"msg_{uuid.uuid4()}"
202
+ created = int(time.time())
203
+ current_text = ""
204
+ streamed_text = ""
205
+ tool_mode = isinstance(tools, list) and bool(tools)
206
+ tool_started = False
207
+ text_open = False
208
+ yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
209
+ if not tool_mode:
210
+ text_open = True
211
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
212
+ for chunk in chunks:
213
+ choice = (chunk.get("choices") or [{}])[0]
214
+ delta = choice.get("delta") or {}
215
+ text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
216
+ if text_delta:
217
+ current_text += text_delta
218
+ if not tool_started:
219
+ visible_text = current_text if not tool_mode else streamable_text(current_text)
220
+ if visible_text.startswith(streamed_text):
221
+ text_delta = visible_text[len(streamed_text):]
222
+ if text_delta:
223
+ if not text_open:
224
+ text_open = True
225
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
226
+ streamed_text = visible_text
227
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
228
+ tool_started = tool_mode and visible_text != current_text
229
+ if choice.get("finish_reason"):
230
+ content, stop_reason = content_blocks(current_text, tools)
231
+ if text_open:
232
+ yield {"type": "content_block_stop", "index": 0}
233
+ if stop_reason == "tool_use":
234
+ start_index = 1 if text_open else 0
235
+ if content and content[0]["type"] == "text":
236
+ remaining = str(content[0].get("text") or "")[len(streamed_text):]
237
+ if remaining:
238
+ if not text_open:
239
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
240
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
241
+ if not text_open:
242
+ yield {"type": "content_block_stop", "index": 0}
243
+ start_index = 1
244
+ content = content[1:]
245
+ yield from _stream_buffered_blocks(content, start_index)
246
+ yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
247
+ break
248
+ yield {"type": "message_stop", "created": created}
249
+
250
+
251
+ def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
252
+ for offset, block in enumerate(content):
253
+ index = start_index + offset
254
+ if block["type"] == "tool_use":
255
+ start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
256
+ delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
257
+ else:
258
+ start = {"type": "text", "text": ""}
259
+ delta = {"type": "text_delta", "text": block.get("text") or ""}
260
+ yield {"type": "content_block_start", "index": index, "content_block": start}
261
+ yield {"type": "content_block_delta", "index": index, "delta": delta}
262
+ yield {"type": "content_block_stop", "index": index}
services/chatgpt_service.py CHANGED
@@ -11,6 +11,7 @@ from typing import Any, Iterable, Iterator
11
  from fastapi import HTTPException
12
 
13
  from services.account_service import AccountService
 
14
  from services.config import config
15
  from services.openai_backend_api import CODEX_IMAGE_MODEL, OpenAIBackendAPI
16
  from utils.helper import (
@@ -83,8 +84,14 @@ class ChatGPTService:
83
  return OpenAIBackendAPI(access_token=access_token)
84
 
85
  def _get_text_access_token(self) -> str:
86
- tokens = self.account_service.list_tokens()
87
- return tokens[0] if tokens else ""
 
 
 
 
 
 
88
 
89
  @staticmethod
90
  def _encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
@@ -1071,6 +1078,7 @@ class ChatGPTService:
1071
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1072
 
1073
  def create_message(self, body: dict[str, object]) -> dict[str, object]:
 
1074
  model = str(body.get("model") or "auto").strip() or "auto"
1075
  messages = self._chat_messages_from_body(body)
1076
  try:
@@ -1079,11 +1087,13 @@ class ChatGPTService:
1079
  model=model,
1080
  stream=False,
1081
  system=body.get("system"),
 
1082
  )
1083
  except Exception as exc:
1084
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1085
 
1086
  def stream_message(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
 
1087
  model = str(body.get("model") or "auto").strip() or "auto"
1088
  messages = self._chat_messages_from_body(body)
1089
  try:
@@ -1092,6 +1102,7 @@ class ChatGPTService:
1092
  model=model,
1093
  stream=True,
1094
  system=body.get("system"),
 
1095
  )
1096
  except Exception as exc:
1097
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
 
11
  from fastapi import HTTPException
12
 
13
  from services.account_service import AccountService
14
+ from services.anthropic_protocol import preprocess_payload
15
  from services.config import config
16
  from services.openai_backend_api import CODEX_IMAGE_MODEL, OpenAIBackendAPI
17
  from utils.helper import (
 
84
  return OpenAIBackendAPI(access_token=access_token)
85
 
86
  def _get_text_access_token(self) -> str:
87
+ try:
88
+ for access_token in self.account_service.list_tokens():
89
+ status = str((self.account_service.get_account(access_token) or {}).get("status") or "").strip()
90
+ if status not in {"禁用", "异常"}:
91
+ return access_token
92
+ except Exception:
93
+ pass
94
+ return ""
95
 
96
  @staticmethod
97
  def _encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
 
1078
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1079
 
1080
  def create_message(self, body: dict[str, object]) -> dict[str, object]:
1081
+ body = preprocess_payload(dict(body))
1082
  model = str(body.get("model") or "auto").strip() or "auto"
1083
  messages = self._chat_messages_from_body(body)
1084
  try:
 
1087
  model=model,
1088
  stream=False,
1089
  system=body.get("system"),
1090
+ tools=body.get("tools"),
1091
  )
1092
  except Exception as exc:
1093
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1094
 
1095
  def stream_message(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
1096
+ body = preprocess_payload(dict(body))
1097
  model = str(body.get("model") or "auto").strip() or "auto"
1098
  messages = self._chat_messages_from_body(body)
1099
  try:
 
1102
  model=model,
1103
  stream=True,
1104
  system=body.get("system"),
1105
+ tools=body.get("tools"),
1106
  )
1107
  except Exception as exc:
1108
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
services/openai_backend_api.py CHANGED
@@ -13,6 +13,7 @@ from curl_cffi import requests
13
  from PIL import Image
14
 
15
  from services.account_service import account_service
 
16
  from services.config import config
17
  from services.proxy_service import proxy_settings
18
  from utils.helper import build_chat_image_markdown_content, ensure_ok, new_uuid, parse_sse_lines
@@ -1231,9 +1232,22 @@ class OpenAIBackendAPI:
1231
  return event
1232
  return {}
1233
 
 
 
 
 
 
 
 
 
 
 
 
1234
  def _stream_events(self, path: str, requirements: ChatRequirements, payload: Dict[str, Any]) -> Iterator[
1235
  Dict[str, Any]]:
1236
  """向 conversation 接口发起请求,并逐条产出 SSE 事件。"""
 
 
1237
  response = self.session.post(
1238
  self.base_url + path,
1239
  headers=self._conversation_headers(path, requirements),
@@ -1241,6 +1255,8 @@ class OpenAIBackendAPI:
1241
  timeout=300,
1242
  stream=True,
1243
  )
 
 
1244
  ensure_ok(response, path)
1245
  yield from parse_sse_lines(response)
1246
 
@@ -1445,95 +1461,14 @@ class OpenAIBackendAPI:
1445
  }],
1446
  }
1447
 
1448
- def _anthropic_message_response(self, model: str, messages: list[Dict[str, Any]], text: str) -> Dict[str, Any]:
1449
  """把对话结果整理成 Anthropic `/v1/messages` 风格结构。"""
1450
- prompt_tokens = self._count_message_tokens(messages, model)
1451
- completion_tokens = self._count_text_tokens(text, model)
1452
- return {
1453
- "id": f"msg_{new_uuid()}",
1454
- "type": "message",
1455
- "role": "assistant",
1456
- "model": model,
1457
- "content": [{
1458
- "type": "text",
1459
- "text": text,
1460
- }],
1461
- "stop_reason": "end_turn",
1462
- "stop_sequence": None,
1463
- "usage": {
1464
- "input_tokens": prompt_tokens,
1465
- "output_tokens": completion_tokens,
1466
- },
1467
- }
1468
 
1469
- def _stream_anthropic_messages(self, messages: list[Dict[str, Any]], model: str = "auto") -> Iterator[
1470
  Dict[str, Any]]:
1471
  """返回 Anthropic `/v1/messages` 风格的流式事件。"""
1472
- message_id = f"msg_{new_uuid()}"
1473
- created = int(time.time())
1474
- current_text = ""
1475
-
1476
- yield {
1477
- "type": "message_start",
1478
- "message": {
1479
- "id": message_id,
1480
- "type": "message",
1481
- "role": "assistant",
1482
- "model": model,
1483
- "content": [],
1484
- "stop_reason": None,
1485
- "stop_sequence": None,
1486
- "usage": {
1487
- "input_tokens": self._count_message_tokens(messages, model),
1488
- "output_tokens": 0,
1489
- },
1490
- },
1491
- }
1492
- yield {
1493
- "type": "content_block_start",
1494
- "index": 0,
1495
- "content_block": {
1496
- "type": "text",
1497
- "text": "",
1498
- },
1499
- }
1500
-
1501
- for chunk in self._stream_chat_completions(messages, model):
1502
- choice = (chunk.get("choices") or [{}])[0]
1503
- delta = choice.get("delta") or {}
1504
- text_delta = delta.get("content", "")
1505
- if text_delta:
1506
- current_text += text_delta
1507
- yield {
1508
- "type": "content_block_delta",
1509
- "index": 0,
1510
- "delta": {
1511
- "type": "text_delta",
1512
- "text": text_delta,
1513
- },
1514
- }
1515
-
1516
- if choice.get("finish_reason"):
1517
- yield {
1518
- "type": "content_block_stop",
1519
- "index": 0,
1520
- }
1521
- yield {
1522
- "type": "message_delta",
1523
- "delta": {
1524
- "stop_reason": "end_turn",
1525
- "stop_sequence": None,
1526
- },
1527
- "usage": {
1528
- "output_tokens": self._count_text_tokens(current_text, model),
1529
- },
1530
- }
1531
- break
1532
-
1533
- yield {
1534
- "type": "message_stop",
1535
- "created": created,
1536
- }
1537
 
1538
  def _iter_response_events(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
1539
  """按 Responses 接口事件格式直通输出,不额外包装标准事件。"""
@@ -1633,10 +1568,10 @@ class OpenAIBackendAPI:
1633
  return self._chat_completion_response(model, normalized_messages, result["text"])
1634
 
1635
  def messages(self, messages: list[Dict[str, Any]], model: str = "auto", stream: bool = False,
1636
- system: Any = None) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
1637
  """返回 Anthropic `/v1/messages` 风格结果,支持 stream 模式。"""
1638
  normalized_messages = self._normalize_messages(messages, system)
1639
  if stream:
1640
- return self._stream_anthropic_messages(normalized_messages, model)
1641
  result = self._complete_chat(normalized_messages, model)
1642
- return self._anthropic_message_response(model, normalized_messages, result["text"])
 
13
  from PIL import Image
14
 
15
  from services.account_service import account_service
16
+ from services.anthropic_protocol import message_response, stream_events
17
  from services.config import config
18
  from services.proxy_service import proxy_settings
19
  from utils.helper import build_chat_image_markdown_content, ensure_ok, new_uuid, parse_sse_lines
 
1232
  return event
1233
  return {}
1234
 
1235
+ def _conversation_payload_debug(self, payload: Dict[str, Any]) -> Dict[str, Any]:
1236
+ raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
1237
+ messages = payload.get("messages") if isinstance(payload.get("messages"), list) else []
1238
+ details = []
1239
+ for item in messages:
1240
+ content = item.get("content") if isinstance(item, dict) else {}
1241
+ parts = content.get("parts") if isinstance(content, dict) else []
1242
+ text = "\n".join(str(part) for part in parts) if isinstance(parts, list) else ""
1243
+ details.append({"role": (item.get("author") or {}).get("role") if isinstance(item, dict) and isinstance(item.get("author"), dict) else "", "chars": len(text), "preview": text[:120]})
1244
+ return {"bytes": len(raw.encode("utf-8")), "chars": len(raw), "message_count": len(messages), "messages": details}
1245
+
1246
  def _stream_events(self, path: str, requirements: ChatRequirements, payload: Dict[str, Any]) -> Iterator[
1247
  Dict[str, Any]]:
1248
  """向 conversation 接口发起请求,并逐条产出 SSE 事件。"""
1249
+ payload_debug = self._conversation_payload_debug(payload)
1250
+ logger.info({"event": "conversation_payload_debug", "path": path, **payload_debug})
1251
  response = self.session.post(
1252
  self.base_url + path,
1253
  headers=self._conversation_headers(path, requirements),
 
1255
  timeout=300,
1256
  stream=True,
1257
  )
1258
+ if response.status_code >= 400:
1259
+ logger.warning({"event": "conversation_request_failed", "path": path, "status_code": response.status_code, "payload": payload_debug, "response_text": response.text[:500]})
1260
  ensure_ok(response, path)
1261
  yield from parse_sse_lines(response)
1262
 
 
1461
  }],
1462
  }
1463
 
1464
+ def _anthropic_message_response(self, model: str, messages: list[Dict[str, Any]], text: str, tools: Any = None) -> Dict[str, Any]:
1465
  """把对话结果整理成 Anthropic `/v1/messages` 风格结构。"""
1466
+ return message_response(model, text, self._count_message_tokens(messages, model), self._count_text_tokens(text, model), tools)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1467
 
1468
+ def _stream_anthropic_messages(self, messages: list[Dict[str, Any]], model: str = "auto", tools: Any = None) -> Iterator[
1469
  Dict[str, Any]]:
1470
  """返回 Anthropic `/v1/messages` 风格的流式事件。"""
1471
+ yield from stream_events(self._stream_chat_completions(messages, model), model, self._count_message_tokens(messages, model), lambda text: self._count_text_tokens(text, model), tools)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1472
 
1473
  def _iter_response_events(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
1474
  """按 Responses 接口事件格式直通输出,不额外包装标准事件。"""
 
1568
  return self._chat_completion_response(model, normalized_messages, result["text"])
1569
 
1570
  def messages(self, messages: list[Dict[str, Any]], model: str = "auto", stream: bool = False,
1571
+ system: Any = None, tools: Any = None) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
1572
  """返回 Anthropic `/v1/messages` 风格结果,支持 stream 模式。"""
1573
  normalized_messages = self._normalize_messages(messages, system)
1574
  if stream:
1575
+ return self._stream_anthropic_messages(normalized_messages, model, tools)
1576
  result = self._complete_chat(normalized_messages, model)
1577
+ return self._anthropic_message_response(model, normalized_messages, result["text"], tools)
test/test_account_image_capabilities.py CHANGED
@@ -86,7 +86,7 @@ class AuthServiceTests(unittest.TestCase):
86
  self.assertEqual(item["role"], "user")
87
  self.assertEqual(item["name"], "Alice")
88
  self.assertTrue(item["enabled"])
89
- self.assertTrue(raw_key.startswith("cg2a_user_"))
90
 
91
  authed = service.authenticate(raw_key)
92
  self.assertIsNotNone(authed)
 
86
  self.assertEqual(item["role"], "user")
87
  self.assertEqual(item["name"], "Alice")
88
  self.assertTrue(item["enabled"])
89
+ self.assertTrue(raw_key.startswith("sk-"))
90
 
91
  authed = service.authenticate(raw_key)
92
  self.assertIsNotNone(authed)