重构代码

This commit is contained in:
h88782481 2026-03-22 08:24:19 +08:00
parent 56faf4fcf1
commit 70361242ab
9 changed files with 1195 additions and 1579 deletions

View file

@ -18,13 +18,21 @@ from __future__ import annotations
import json
from typing import Any
from adapters.helpers import (
build_cc_message,
build_cc_response,
build_cc_tool_call,
build_cc_usage,
extract_text,
make_cc_chunk,
parse_json_safe,
stringify_content,
)
from utils.http import gen_id
from utils.tool_fixer import fix_anthropic_tool_use, normalize_args, repair_str_replace_args
JsonDict = dict[str, Any]
# Anthropic stop_reason → OpenAI finish_reason
_STOP_REASON_MAP = {
'end_turn': 'stop',
'max_tokens': 'length',
@ -78,23 +86,18 @@ def messages_to_cc_response(data: JsonDict, request_id: str | None = None) -> Js
data = fix_anthropic_tool_use(data)
content_text, reasoning_text, tool_calls = _collect_response_parts(data.get('content', []))
message = _build_cc_message(content_text, reasoning_text, tool_calls)
usage = data.get('usage', {})
return {
'id': request_id,
'object': 'chat.completion',
'model': data.get('model', 'claude'),
'choices': [{
'index': 0,
'message': message,
'finish_reason': _STOP_REASON_MAP.get(data.get('stop_reason', 'end_turn'), 'stop'),
}],
'usage': _build_cc_usage(
return build_cc_response(
response_id=request_id,
message=build_cc_message(content_text, reasoning_text, tool_calls),
finish_reason=_STOP_REASON_MAP.get(data.get('stop_reason', 'end_turn'), 'stop'),
usage=build_cc_usage(
input_tokens=usage.get('input_tokens', 0),
output_tokens=usage.get('output_tokens', 0),
),
}
model=data.get('model', 'claude'),
)
# ═══════════════════════════════════════════════════════════
@ -124,12 +127,8 @@ class AnthropicStreamConverter:
self._input_tokens = 0
self._output_tokens = 0
def process_event(self, event_type: str, event_data: JsonDict) -> list[str]:
"""处理单个 Anthropic SSE 事件。
调用方会按事件顺序不断喂入 event/data这里根据事件类型拆成一个或多个 CC chunk
字符串交给上层直接作为 SSE data 发送给 Cursor
"""
def process_event(self, event_type: str, event_data: JsonDict) -> list[JsonDict]:
"""处理单个 Anthropic SSE 事件,返回 CC chunk dict 列表。"""
if event_type == 'message_start':
return self._handle_message_start(event_data)
if event_type == 'content_block_start':
@ -140,104 +139,64 @@ class AnthropicStreamConverter:
return self._handle_message_delta(event_data)
return []
def _handle_message_start(self, event_data: JsonDict) -> list[str]:
"""处理消息开始事件,产出 assistant 角色起始 chunk。
这个起始 chunk 很重要因为 Cursor 侧通常会依赖首个带 role chunk 来初始化
当前 assistant 消息
"""
def _handle_message_start(self, event_data: JsonDict) -> list[JsonDict]:
message = event_data.get('message', {})
self._input_tokens = message.get('usage', {}).get('input_tokens', 0)
chunk = self._make_chunk(delta={'role': 'assistant', 'content': ''})
if message.get('model'):
chunk['model'] = message['model']
return [self._dump_chunk(chunk)]
return [chunk]
def _handle_content_block_start(self, event_data: JsonDict) -> list[str]:
"""处理内容块开始事件。
目前这里只需要显式处理 `tool_use`因为文本和 thinking 的真正内容都在后续 delta
事件里 tool_use 需要先开一个空 arguments tool_call 槽位
"""
def _handle_content_block_start(self, event_data: JsonDict) -> list[JsonDict]:
block = event_data.get('content_block', {})
if block.get('type') != 'tool_use':
return []
self._tool_index += 1
return [self._dump_chunk(self._make_chunk(delta={
return [self._make_chunk(delta={
'tool_calls': [{
'index': self._tool_index,
'id': block.get('id', gen_id('toolu_')),
'type': 'function',
'function': {
'name': block.get('name', ''),
'arguments': '',
},
'function': {'name': block.get('name', ''), 'arguments': ''},
}]
}))]
})]
def _handle_content_block_delta(self, event_data: JsonDict) -> list[str]:
"""处理内容块增量事件。
Anthropic 会把文本思考内容工具参数拆成不同 delta 类型这里要分别映射成
OpenAI chunk 里的 `content``reasoning_content` `tool_calls.function.arguments`
"""
def _handle_content_block_delta(self, event_data: JsonDict) -> list[JsonDict]:
delta = event_data.get('delta', {})
delta_type = delta.get('type', '')
if delta_type == 'text_delta' and delta.get('text'):
return [self._dump_chunk(self._make_chunk(delta={'content': delta['text']}))]
return [self._make_chunk(delta={'content': delta['text']})]
if delta_type == 'thinking_delta' and delta.get('thinking'):
return [self._dump_chunk(self._make_chunk(delta={'reasoning_content': delta['thinking']}))]
return [self._make_chunk(delta={'reasoning_content': delta['thinking']})]
if delta_type == 'input_json_delta' and delta.get('partial_json'):
return [self._dump_chunk(self._make_chunk(delta={
return [self._make_chunk(delta={
'tool_calls': [{
'index': self._tool_index,
'function': {'arguments': delta['partial_json']},
}]
}))]
})]
return []
def _handle_message_delta(self, event_data: JsonDict) -> list[str]:
"""处理消息收尾事件,补出 finish_reason 和 usage。
Anthropic 发出 `message_delta` 说明这一轮 assistant 输出已经收束
这里会统一生成最后一个带 usage 的收尾 chunk
"""
def _handle_message_delta(self, event_data: JsonDict) -> list[JsonDict]:
delta = event_data.get('delta', {})
usage = event_data.get('usage', {})
self._output_tokens = usage.get('output_tokens', 0)
chunk = self._make_chunk(
chunk = make_cc_chunk(
self._id,
delta={},
finish_reason=_STOP_REASON_MAP.get(delta.get('stop_reason', ''), 'stop'),
model='claude',
)
chunk['usage'] = _build_cc_usage(
chunk['usage'] = build_cc_usage(
input_tokens=self._input_tokens,
output_tokens=self._output_tokens,
)
return [self._dump_chunk(chunk)]
return [chunk]
def _make_chunk(self, delta: JsonDict, finish_reason: str | None = None) -> JsonDict:
"""构造标准 OpenAI Chat Completions chunk 对象。"""
choice: JsonDict = {'index': 0, 'delta': delta}
if finish_reason:
choice['finish_reason'] = finish_reason
return {
'id': self._id,
'object': 'chat.completion.chunk',
'model': 'claude',
'choices': [choice],
}
@staticmethod
def _dump_chunk(chunk: JsonDict) -> str:
"""统一序列化 chunk方便上层直接写入 SSE data。"""
return json.dumps(chunk)
return make_cc_chunk(self._id, delta, finish_reason, model='claude')
# ═══════════════════════════════════════════════════════════
@ -254,7 +213,7 @@ def _convert_request_message(message: Any) -> tuple[JsonDict | None, str | None]
content = message.get('content', '')
if role == 'system':
return None, _flatten_text(content)
return None, extract_text(content)
if role == 'tool':
return _convert_tool_role_message(message), None
@ -301,7 +260,7 @@ def _append_tool_use_blocks(content: Any, tool_calls: list[Any]) -> list[JsonDic
'type': 'tool_use',
'id': tool_call.get('id', gen_id('toolu_')),
'name': function_data.get('name', ''),
'input': _parse_tool_arguments(function_data.get('arguments', '{}')),
'input': parse_json_safe(function_data.get('arguments', '{}')),
})
return blocks
@ -372,37 +331,12 @@ def _convert_tool_use_block(block: JsonDict, *, index: int) -> JsonDict:
else:
arguments_text = str(input_data)
return {
'index': index,
'id': block.get('id', gen_id('toolu_')),
'type': 'function',
'function': {
'name': tool_name,
'arguments': arguments_text,
},
}
def _build_cc_message(content_text: str, reasoning_text: str, tool_calls: list[JsonDict]) -> JsonDict:
"""构造 OpenAI CC 响应中的 assistant message。"""
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
return message
def _build_cc_usage(*, input_tokens: int, output_tokens: int) -> JsonDict:
"""将 Anthropic usage 字段映射为 OpenAI usage。"""
return {
'prompt_tokens': input_tokens,
'completion_tokens': output_tokens,
'total_tokens': input_tokens + output_tokens,
}
return build_cc_tool_call(
call_id=block.get('id', gen_id('toolu_')),
name=tool_name,
arguments=arguments_text,
index=index,
)
# ═══════════════════════════════════════════════════════════
@ -410,35 +344,6 @@ def _build_cc_usage(*, input_tokens: int, output_tokens: int) -> JsonDict:
# ═══════════════════════════════════════════════════════════
def _parse_tool_arguments(arguments: Any) -> Any:
"""将 tool_call.arguments 尽量解析为对象,供 Anthropic tool_use.input 使用。
Anthropic `tool_use.input` 天然期望对象结构如果这里直接保留原始字符串
后续上游会把它当普通文本而不是工具参数对象
"""
if not isinstance(arguments, str):
return arguments if arguments is not None else {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
def _flatten_text(content: Any) -> str:
"""将 content 扁平化为纯文本,主要用于 system 消息上提。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, str):
parts.append(part)
elif isinstance(part, dict) and part.get('type') == 'text':
parts.append(part.get('text', ''))
return '\n'.join(parts)
return str(content)
def _convert_content(message: JsonDict) -> Any:
"""将 OpenAI 消息的 content 字段转换为 Anthropic 内容格式。"""
content = message.get('content', '')
@ -708,3 +613,78 @@ def _pick_window_anchor(refs: list[JsonDict], target: int) -> int | None:
if 'cache_control' not in refs[i]:
return i
return None
# ═══════════════════════════════════════════════════════════
# OutboundTransformer 实现: Anthropic Messages
# ═══════════════════════════════════════════════════════════
class AnthropicOutbound:
"""Anthropic Messages 后端的出站转换器。
CC 格式转换为 Anthropic Messages 格式并处理响应
"""
def build_request(self, payload: JsonDict) -> JsonDict:
return cc_to_messages_request(payload)
def build_url(self, ctx) -> str:
return f'{ctx.target_url.rstrip("/")}/v1/messages'
def build_headers(self, ctx) -> dict[str, str]:
from utils.http import build_anthropic_headers
return build_anthropic_headers(ctx.api_key)
def parse_response(self, raw: JsonDict) -> JsonDict:
return messages_to_cc_response(raw)
def create_stream_processor(self) -> AnthropicStreamProcessor:
return AnthropicStreamProcessor()
class AnthropicStreamProcessor:
"""Anthropic SSE 流式处理器。
包装 iter_anthropic_sse + AnthropicStreamConverter
Anthropic 事件流转换为 CC chunk
"""
def __init__(self):
self._converter = AnthropicStreamConverter()
self._input_tokens = 0
self._output_tokens = 0
def iter_events(self, response) -> Iterator:
from utils.http import iter_anthropic_sse
yield from iter_anthropic_sse(response)
def process_event(self, event: tuple) -> list[JsonDict]:
event_type, event_data = event
return self._converter.process_event(event_type, event_data)
def extract_usage(self, event: tuple) -> JsonDict | None:
event_type, event_data = event
if event_type == 'message_start':
message_usage = event_data.get('message', {}).get('usage', {})
if isinstance(message_usage, dict):
self._input_tokens = message_usage.get('input_tokens', 0)
return {
'prompt_tokens': self._input_tokens,
'completion_tokens': 0,
'total_tokens': self._input_tokens,
}
elif event_type == 'message_delta':
delta_usage = event_data.get('usage', {})
if isinstance(delta_usage, dict):
completion = delta_usage.get('output_tokens', 0)
self._output_tokens = completion
return {
'prompt_tokens': self._input_tokens,
'completion_tokens': completion,
'total_tokens': self._input_tokens + completion,
}
return None
def finalize(self) -> list[JsonDict]:
return []

View file

@ -8,8 +8,17 @@ from __future__ import annotations
import json
import logging
from typing import Any
from typing import Any, Iterator
from adapters.helpers import (
build_cc_message,
build_cc_response,
build_cc_tool_call,
build_cc_usage,
extract_text,
make_cc_chunk,
parse_json_safe,
)
from utils.http import gen_id
JsonDict = dict[str, Any]
@ -38,7 +47,7 @@ def cc_to_gemini_request(payload: JsonDict) -> JsonDict:
for msg in messages:
role = msg.get('role', '')
if role in ('system', 'developer'):
system_parts.append(_flatten_text(msg.get('content', '')))
system_parts.append(extract_text(msg.get('content', '')))
continue
converted = _convert_message(msg)
if converted:
@ -84,21 +93,13 @@ def gemini_to_cc_response(data: JsonDict, request_id: str | None = None) -> Json
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,
}
return build_cc_response(
response_id=request_id,
message=build_cc_message(content_text, reasoning_text, tool_calls),
finish_reason=finish_reason,
usage=_convert_usage(data.get('usageMetadata', {})),
model=data.get('modelVersion', 'gemini'),
)
# ═══════════════════════════════════════════════════════════
@ -166,15 +167,7 @@ class GeminiStreamConverter:
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],
}
return make_cc_chunk(self._id, delta, finish_reason, model='gemini')
# ═══════════════════════════════════════════════════════════
@ -194,7 +187,7 @@ def _convert_message(msg: JsonDict) -> JsonDict | None:
'parts': [{
'functionResponse': {
'name': msg.get('name', msg.get('tool_call_id', '')),
'response': _parse_json_safe(msg.get('content', '')),
'response': parse_json_safe(msg.get('content', ''), fallback={'result': msg.get('content', '')} if msg.get('content', '') else {}),
},
}],
}
@ -221,7 +214,7 @@ def _convert_message(msg: JsonDict) -> JsonDict | None:
parts.append({
'functionCall': {
'name': func.get('name', ''),
'args': _parse_json_safe(func.get('arguments', '{}')),
'args': parse_json_safe(func.get('arguments', '{}'), fallback={}),
},
})
@ -304,15 +297,12 @@ def _extract_parts(parts: list[Any]) -> tuple[str, str, list[JsonDict]]:
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),
},
})
tool_calls.append(build_cc_tool_call(
call_id=fc.get('id') or gen_id('call_'),
name=fc.get('name', ''),
arguments=json.dumps(fc.get('args', {}), ensure_ascii=False),
index=len(tool_calls),
))
return text, reasoning, tool_calls
@ -322,12 +312,7 @@ def _convert_usage(meta: JsonDict) -> JsonDict:
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,
}
return build_cc_usage(prompt, candidates + thoughts)
def _merge_same_role(contents: list[JsonDict]) -> list[JsonDict]:
@ -343,21 +328,65 @@ def _merge_same_role(contents: list[JsonDict]) -> list[JsonDict]:
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 {}
# ═══════════════════════════════════════════════════════════
# OutboundTransformer 实现: Gemini Contents
# ═══════════════════════════════════════════════════════════
class GeminiOutbound:
"""Gemini Contents 后端的出站转换器。
CC 格式转换为 Gemini generateContent 格式并处理响应
"""
def build_request(self, payload: JsonDict) -> JsonDict:
return cc_to_gemini_request(payload)
def build_url(self, ctx) -> str:
base = ctx.target_url.rstrip('/')
model = ctx.upstream_model
if ctx.is_stream:
return f'{base}/v1/models/{model}:streamGenerateContent?alt=sse'
return f'{base}/v1/models/{model}:generateContent'
def build_headers(self, ctx) -> dict[str, str]:
from utils.http import build_gemini_headers
return build_gemini_headers(ctx.api_key)
def parse_response(self, raw: JsonDict) -> JsonDict:
return gemini_to_cc_response(raw)
def create_stream_processor(self) -> GeminiStreamProcessor:
return GeminiStreamProcessor()
class GeminiStreamProcessor:
"""Gemini SSE 流式处理器。
包装 iter_gemini_sse + GeminiStreamConverter
"""
def __init__(self):
self._converter = GeminiStreamConverter()
def iter_events(self, response) -> Iterator:
from utils.http import iter_gemini_sse
yield from iter_gemini_sse(response)
def process_event(self, event: JsonDict) -> list[JsonDict]:
return self._converter.process_chunk(event)
def extract_usage(self, event: JsonDict) -> JsonDict | None:
usage_meta = event.get('usageMetadata') if isinstance(event, dict) else None
if isinstance(usage_meta, dict):
return {
'prompt_tokens': usage_meta.get('promptTokenCount', 0),
'completion_tokens': usage_meta.get('candidatesTokenCount', 0),
'total_tokens': usage_meta.get('totalTokenCount', 0),
}
return None
def finalize(self) -> list[JsonDict]:
return []

155
adapters/helpers.py Normal file
View file

@ -0,0 +1,155 @@
"""适配器公共辅助函数
收敛多个适配器都在重复实现的 CC 格式构建逻辑
- CC 消息/Usage/Tool Call/Stream Chunk 的标准构造
- 内容扁平化JSON 安全解析工具输出序列化
"""
from __future__ import annotations
import json
from typing import Any
from utils.http import gen_id
JsonDict = dict[str, Any]
# ═══════════════════════════════════════════════════════════
# CC 格式标准构造
# ═══════════════════════════════════════════════════════════
def build_cc_message(
content_text: str,
reasoning_text: str = '',
tool_calls: list[JsonDict] | None = None,
) -> JsonDict:
"""构造标准的 CC assistant 消息。"""
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
return message
def build_cc_usage(input_tokens: int, output_tokens: int) -> JsonDict:
"""构造标准的 CC usage 字典。"""
return {
'prompt_tokens': input_tokens,
'completion_tokens': output_tokens,
'total_tokens': input_tokens + output_tokens,
}
def build_cc_tool_call(
call_id: str,
name: str,
arguments: str,
*,
index: int | None = None,
) -> JsonDict:
"""构造标准的 CC tool_call 结构。"""
tc: JsonDict = {
'id': call_id or gen_id('call_'),
'type': 'function',
'function': {
'name': name,
'arguments': arguments,
},
}
if index is not None:
tc['index'] = index
return tc
def make_cc_chunk(
chunk_id: str,
delta: JsonDict,
finish_reason: str | None = None,
model: str = '',
) -> JsonDict:
"""构造标准的 CC 流式 chunk。"""
choice: JsonDict = {'index': 0, 'delta': delta}
if finish_reason:
choice['finish_reason'] = finish_reason
return {
'id': chunk_id,
'object': 'chat.completion.chunk',
'model': model,
'choices': [choice],
}
def build_cc_response(
response_id: str,
message: JsonDict,
finish_reason: str,
usage: JsonDict,
model: str = '',
) -> JsonDict:
"""构造标准的 CC 非流式响应。"""
return {
'id': response_id,
'object': 'chat.completion',
'model': model,
'choices': [{
'index': 0,
'message': message,
'finish_reason': finish_reason,
}],
'usage': usage,
}
# ═══════════════════════════════════════════════════════════
# 通用文本/JSON 处理
# ═══════════════════════════════════════════════════════════
def extract_text(content: Any) -> str:
"""从多种内容格式中提取并拼接纯文本。
支持字符串内容块列表OpenAI/Anthropic/Responses 风格
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content) if content is not None else ''
parts: list[str] = []
for part in content:
if isinstance(part, str):
parts.append(part)
elif isinstance(part, dict):
part_type = part.get('type', '')
if part_type in ('text', 'output_text', 'input_text'):
parts.append(part.get('text', ''))
elif part_type == 'refusal':
parts.append(part.get('refusal', ''))
elif 'text' in part and not part_type:
parts.append(part['text'])
return '\n'.join(parts) if parts else ''
def parse_json_safe(text: Any, fallback: Any = None) -> Any:
"""安全解析 JSON失败时返回 fallback。"""
if not isinstance(text, str):
return text if text is not None else (fallback if fallback is not None else {})
try:
return json.loads(text)
except (json.JSONDecodeError, ValueError):
return fallback if fallback is not None else {}
def stringify_content(content: Any) -> str:
"""将任意内容序列化为字符串。"""
if isinstance(content, str):
return content
if content is None:
return ''
return json.dumps(content, ensure_ascii=False)

View file

@ -13,7 +13,7 @@ from __future__ import annotations
import json
import logging
from typing import Any
from typing import Any, Iterator
from utils.http import gen_id
from utils.think_tag import extract_from_text
@ -423,3 +423,60 @@ def _rewrite_function_call_finish_reason(choice: JsonDict) -> None:
"""将旧版 finish_reason=function_call 升级为 tool_calls。"""
if choice.get('finish_reason') == 'function_call':
choice['finish_reason'] = 'tool_calls'
# ═══════════════════════════════════════════════════════════
# OutboundTransformer 实现: OpenAI Chat
# ═══════════════════════════════════════════════════════════
class OpenAIChatOutbound:
"""OpenAI Chat Completions 后端的出站转换器。
由于 CC 本身就是 OpenAI Chat 格式请求/响应转换主要做兼容性修复
"""
def build_request(self, payload: JsonDict) -> JsonDict:
return normalize_request(payload)
def build_url(self, ctx) -> str:
return f'{ctx.target_url.rstrip("/")}/v1/chat/completions'
def build_headers(self, ctx) -> dict[str, str]:
from utils.http import build_openai_headers
return build_openai_headers(ctx.api_key)
def parse_response(self, raw: JsonDict) -> JsonDict:
return fix_response(raw)
def create_stream_processor(self) -> OpenAIChatStreamProcessor:
return OpenAIChatStreamProcessor()
class OpenAIChatStreamProcessor:
"""OpenAI Chat SSE 流式处理器。
包装 iter_openai_sse + fix_stream_chunk + ThinkTagExtractor
"""
def __init__(self):
from utils.think_tag import ThinkTagExtractor
self._think_extractor = ThinkTagExtractor()
def iter_events(self, response) -> Iterator:
from utils.http import iter_openai_sse
for chunk in iter_openai_sse(response):
if chunk is None:
return
yield chunk
def process_event(self, event: JsonDict) -> list[JsonDict]:
chunk = fix_stream_chunk(event)
return list(self._think_extractor.process_chunk(chunk))
def extract_usage(self, event: JsonDict) -> JsonDict | None:
return event.get('usage')
def finalize(self) -> list[JsonDict]:
close_chunk = self._think_extractor.finalize()
return [close_chunk] if close_chunk else []

View file

@ -15,8 +15,18 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from typing import Any, Iterator
from adapters.helpers import (
build_cc_message,
build_cc_response,
build_cc_tool_call,
build_cc_usage,
extract_text,
make_cc_chunk,
stringify_content,
)
from adapters.unified import UnifiedUsage
from utils.http import gen_id
JsonDict = dict[str, Any]
@ -85,7 +95,7 @@ def cc_to_responses(cc_resp: JsonDict, model: str = '') -> JsonDict:
'status': _response_status_from_finish_reason(finish_reason),
'model': model or cc_resp.get('model', ''),
'output': _build_responses_output(message),
'usage': _build_responses_usage(cc_resp.get('usage', {})),
'usage': UnifiedUsage.from_cc_dict(cc_resp.get('usage', {})).to_responses_dict(),
}
@ -94,31 +104,18 @@ def responses_to_cc_response(response_data: JsonDict, model: str = '') -> JsonDi
output_items = response_data.get('output', [])
content_text, reasoning_text, tool_calls = _collect_cc_parts_from_responses_output(output_items)
finish_reason = _cc_finish_reason_from_responses(response_data, tool_calls)
message = {
'role': 'assistant',
'content': content_text or None,
}
if reasoning_text:
message['reasoning_content'] = reasoning_text
if tool_calls:
message['tool_calls'] = tool_calls
usage = response_data.get('usage', {})
return {
'id': response_data.get('id', gen_id('chatcmpl-')),
'object': 'chat.completion',
'model': model or response_data.get('model', ''),
'choices': [{
'index': 0,
'message': message,
'finish_reason': finish_reason,
}],
'usage': {
'prompt_tokens': usage.get('input_tokens', 0),
'completion_tokens': usage.get('output_tokens', 0),
'total_tokens': usage.get('total_tokens', 0),
},
}
return build_cc_response(
response_id=response_data.get('id', gen_id('chatcmpl-')),
message=build_cc_message(content_text, reasoning_text, tool_calls),
finish_reason=finish_reason,
usage=build_cc_usage(
input_tokens=usage.get('input_tokens', 0),
output_tokens=usage.get('output_tokens', 0),
),
model=model or response_data.get('model', ''),
)
# ═══════════════════════════════════════════════════════════
@ -658,15 +655,7 @@ class ResponsesToCCStreamConverter:
def _make_chunk(self, delta: JsonDict, finish_reason: str | None = None) -> JsonDict:
"""构造标准 Chat Completions chunk。"""
choice: JsonDict = {'index': 0, 'delta': delta}
if finish_reason:
choice['finish_reason'] = finish_reason
return {
'id': self._id,
'object': 'chat.completion.chunk',
'model': self._model,
'choices': [choice],
}
return make_cc_chunk(self._id, delta, finish_reason, model=self._model)
# ═══════════════════════════════════════════════════════════
@ -715,7 +704,7 @@ def _append_responses_input_item(
content = message.get('content')
if role == 'system':
text = _content_to_text(content)
text = extract_text(content)
if text:
instructions.append(text)
return
@ -724,11 +713,11 @@ def _append_responses_input_item(
input_items.append({
'type': 'function_call_output',
'call_id': message.get('tool_call_id', ''),
'output': _stringify_output(content),
'output': stringify_content(content),
})
return
text = _content_to_text(content)
text = extract_text(content)
has_tool_calls = bool(message.get('tool_calls'))
if role == 'assistant' and has_tool_calls:
@ -771,7 +760,7 @@ def _convert_input_items(items: list[Any], messages: list[JsonDict]) -> None:
if role and not item_type:
msg: JsonDict = {
'role': role,
'content': _normalize_simple_content(item.get('content', '')),
'content': extract_text(item.get('content', '')),
}
if role == 'assistant' and pending_reasoning:
msg['reasoning_content'] = pending_reasoning
@ -810,7 +799,7 @@ def _append_message_item(items: list[Any], *, start: int, messages: list[JsonDic
"""将一个 message 项及其后续连续 function_call 项合并成一条消息。"""
item = items[start]
role = item.get('role', 'assistant')
content = _extract_text(item.get('content', []))
content = extract_text(item.get('content', []))
message: JsonDict = {'role': role, 'content': content or ''}
if role == 'assistant':
@ -828,7 +817,11 @@ def _append_message_item(items: list[Any], *, start: int, messages: list[JsonDic
def _append_function_call_item(item: JsonDict, messages: list[JsonDict]) -> None:
"""将独立的 Responses `function_call` 项挂接到最近的 assistant 消息上。"""
tool_call = _build_cc_tool_call(item)
tool_call = build_cc_tool_call(
call_id=item.get('call_id') or gen_id('call_'),
name=item.get('name', ''),
arguments=item.get('arguments', '{}'),
)
if messages and messages[-1]['role'] == 'assistant':
messages[-1].setdefault('tool_calls', []).append(tool_call)
@ -851,12 +844,6 @@ def _convert_function_call_output_item(item: JsonDict) -> JsonDict:
}
def _normalize_simple_content(content: Any) -> str:
"""将简单 content 载荷规范化为纯文本字符串。"""
if isinstance(content, list):
return _extract_text(content) or ''
return str(content) if content is not None else ''
def _collect_function_calls(items: list[Any], start: int) -> tuple[list[JsonDict], int]:
"""收集从指定位置开始连续出现的 `function_call` 项。"""
@ -865,24 +852,17 @@ def _collect_function_calls(items: list[Any], start: int) -> tuple[list[JsonDict
while index < len(items):
next_item = items[index]
if isinstance(next_item, dict) and next_item.get('type') == 'function_call':
tool_calls.append(_build_cc_tool_call(next_item))
tool_calls.append(build_cc_tool_call(
call_id=next_item.get('call_id') or gen_id('call_'),
name=next_item.get('name', ''),
arguments=next_item.get('arguments', '{}'),
))
index += 1
else:
break
return tool_calls, index - start
def _build_cc_tool_call(item: JsonDict) -> JsonDict:
"""将单个 Responses `function_call` 项转换为 CC `tool_call` 结构。"""
return {
'id': item.get('call_id') or gen_id('call_'),
'type': 'function',
'function': {
'name': item.get('name', ''),
'arguments': item.get('arguments', '{}'),
},
}
# ═══════════════════════════════════════════════════════════
# 非流式响应转换辅助
@ -936,14 +916,6 @@ def _make_function_call_output_item(tool_call: JsonDict) -> JsonDict:
}
def _build_responses_usage(usage: JsonDict) -> JsonDict:
"""将 Chat Completions 的 usage 字段映射为 Responses usage 结构。"""
return {
'input_tokens': usage.get('prompt_tokens', 0),
'output_tokens': usage.get('completion_tokens', 0),
'total_tokens': usage.get('total_tokens', 0),
}
def _collect_cc_parts_from_responses_output(output_items: Any) -> tuple[str, str, list[JsonDict]]:
"""从 Responses `output` 中提取文本、思考摘要和工具调用。"""
@ -959,11 +931,16 @@ def _collect_cc_parts_from_responses_output(output_items: Any) -> tuple[str, str
continue
item_type = item.get('type', '')
if item_type == 'message':
content_text += _extract_text(item.get('content', []))
content_text += extract_text(item.get('content', []))
elif item_type == 'reasoning':
reasoning_text += _extract_reasoning_text(item)
elif item_type == 'function_call':
tool_calls.append(_build_cc_tool_call_from_responses_output(item, index=len(tool_calls)))
tool_calls.append(build_cc_tool_call(
call_id=item.get('call_id') or gen_id('call_'),
name=item.get('name', ''),
arguments=item.get('arguments', '{}'),
index=len(tool_calls),
))
return content_text, reasoning_text, tool_calls
@ -980,18 +957,6 @@ def _extract_reasoning_text(item: JsonDict) -> str:
return ''.join(texts)
def _build_cc_tool_call_from_responses_output(item: JsonDict, *, index: int) -> JsonDict:
"""将 Responses `function_call` 输出项转换为 CC `tool_call`。"""
return {
'index': index,
'id': item.get('call_id') or gen_id('call_'),
'type': 'function',
'function': {
'name': item.get('name', ''),
'arguments': item.get('arguments', '{}'),
},
}
def _cc_finish_reason_from_responses(response_data: JsonDict, tool_calls: list[JsonDict]) -> str:
"""根据 Responses 完成状态推断聊天补全的 finish_reason。"""
@ -1017,57 +982,7 @@ def _map_anthropic_stop_reason(stop_reason: str) -> str:
# ═══════════════════════════════════════════════════════════
def _extract_text(content: Any) -> str:
"""从多种内容块结构中提取并拼接纯文本。"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content) if content else ''
texts: list[str] = []
for part in content:
if isinstance(part, str):
texts.append(part)
elif isinstance(part, dict):
part_type = part.get('type', '')
if part_type in ('output_text', 'input_text', 'text'):
texts.append(part.get('text', ''))
elif part_type == 'refusal':
texts.append(part.get('refusal', ''))
return '\n'.join(texts) if texts else ''
def _content_to_text(content: Any) -> str:
"""将任意 content 载荷转换为单个字符串。"""
if isinstance(content, str):
return content
if isinstance(content, list):
return _extract_text(content)
return str(content) if content is not None else ''
def _content_to_responses_parts(content: Any, role: str = 'user') -> list[JsonDict]:
"""将普通消息内容转换为 Responses 内容块数组。
assistant 消息使用 output_text其他角色使用 input_text
"""
if isinstance(content, list):
text = _extract_text(content)
else:
text = _content_to_text(content)
if not text:
return []
part_type = 'output_text' if role == 'assistant' else 'input_text'
return [{'type': part_type, 'text': text}]
def _stringify_output(content: Any) -> str:
"""将工具输出统一序列化为字符串,便于放入 `function_call_output`。"""
if isinstance(content, str):
return content
if content is None:
return ''
return json.dumps(content, ensure_ascii=False) if not isinstance(content, str) else content
def _build_responses_function_call_item(tool_call: JsonDict) -> JsonDict:
@ -1081,6 +996,165 @@ def _build_responses_function_call_item(tool_call: JsonDict) -> JsonDict:
}
# ═══════════════════════════════════════════════════════════
# OutboundTransformer 实现: Responses
# ═══════════════════════════════════════════════════════════
class ResponsesOutbound:
"""OpenAI Responses 后端的出站转换器。
CC 格式转换为 Responses 格式并处理响应
"""
def build_request(self, payload: JsonDict) -> JsonDict:
return cc_to_responses_request(payload)
def build_url(self, ctx) -> str:
return f'{ctx.target_url.rstrip("/")}/v1/responses'
def build_headers(self, ctx) -> dict[str, str]:
from utils.http import build_openai_headers
return build_openai_headers(ctx.api_key)
def parse_response(self, raw: JsonDict) -> JsonDict:
return responses_to_cc_response(raw)
def create_stream_processor(self) -> ResponsesStreamProcessorForCC:
return ResponsesStreamProcessorForCC()
class ResponsesStreamProcessorForCC:
"""Responses SSE → CC chunk 流式处理器。
用于 /v1/chat/completions -> /v1/responses 的桥接路径
"""
def __init__(self):
self._converter = ResponsesToCCStreamConverter()
def iter_events(self, response) -> Iterator:
from utils.http import iter_responses_sse
yield from iter_responses_sse(response)
def process_event(self, event: tuple) -> list[JsonDict]:
event_type, event_data = event
return self._converter.process_event(event_type, event_data)
def extract_usage(self, event: tuple) -> JsonDict | None:
from adapters.unified import extract_responses_usage
event_type, event_data = event
extracted = extract_responses_usage(event_data)
if extracted:
return {
'prompt_tokens': extracted.get('input_tokens', 0),
'completion_tokens': extracted.get('output_tokens', 0),
'total_tokens': extracted.get('total_tokens', 0),
}
return None
def finalize(self) -> list[JsonDict]:
return []
class ResponsesNativeOutbound:
"""Responses 后端原生透传的出站转换器。
/v1/responses /v1/responses 时直接透传不经过 CC 中间格式
"""
def build_request(self, payload: JsonDict) -> JsonDict:
return payload
def build_url(self, ctx) -> str:
return f'{ctx.target_url.rstrip("/")}/v1/responses'
def build_headers(self, ctx) -> dict[str, str]:
from utils.http import build_openai_headers
return build_openai_headers(ctx.api_key)
def parse_response(self, raw: JsonDict) -> JsonDict:
return raw
def create_stream_processor(self) -> ResponsesNativeStreamProcessor:
return ResponsesNativeStreamProcessor()
class ResponsesNativeStreamProcessor:
"""Responses 原生 SSE 透传流式处理器。
上游就是 Responses 格式只需透传事件并做轻量模型名改写
每个事件作为 SSE 字符串直接返回
"""
def iter_events(self, response) -> Iterator:
from utils.http import iter_responses_sse
yield from iter_responses_sse(response)
def process_event(self, event: tuple) -> list[JsonDict]:
event_type, event_data = event
return [{'_sse_event_type': event_type, **event_data}]
def extract_usage(self, event: tuple) -> JsonDict | None:
from adapters.unified import extract_responses_usage
_, event_data = event
return extract_responses_usage(event_data)
def finalize(self) -> list[JsonDict]:
return []
class AnthropicOutboundForResponses:
"""Anthropic 后端的出站转换器(用于 /v1/responses 路由)。
流式处理直接将 Anthropic SSE Responses SSE
跳过 CC 中间态以保留原始时序
"""
def build_request(self, payload: JsonDict) -> JsonDict:
from adapters.cc_anthropic_adapter import cc_to_messages_request
return cc_to_messages_request(payload)
def build_url(self, ctx) -> str:
return f'{ctx.target_url.rstrip("/")}/v1/messages'
def build_headers(self, ctx) -> dict[str, str]:
from utils.http import build_anthropic_headers
return build_anthropic_headers(ctx.api_key)
def parse_response(self, raw: JsonDict) -> JsonDict:
from adapters.cc_anthropic_adapter import messages_to_cc_response
return messages_to_cc_response(raw)
def create_stream_processor(self) -> AnthropicToResponsesStreamProcessor:
return AnthropicToResponsesStreamProcessor()
class AnthropicToResponsesStreamProcessor:
"""Anthropic SSE → Responses SSE 直接转换的流式处理器。
跳过 CC 中间态直接将 Anthropic 事件映射为 Responses 事件
返回的 chunk SSE 字符串
"""
def __init__(self):
self._converter = ResponsesStreamConverter()
def iter_events(self, response) -> Iterator:
from utils.http import iter_anthropic_sse
yield from iter_anthropic_sse(response)
def process_event(self, event: tuple) -> list[str]:
event_type, event_data = event
return self._converter.process_anthropic_event(event_type, event_data)
def extract_usage(self, event: tuple) -> JsonDict | None:
return None
def finalize(self) -> list[str]:
return self._converter.finalize()
def _convert_cc_tools_to_responses(tools: Any) -> list[JsonDict]:
"""将聊天补全风格的工具定义转换为 Responses `tools` 列表。"""
if not isinstance(tools, list):

354
adapters/unified.py Normal file
View file

@ -0,0 +1,354 @@
"""统一中间格式与转换器接口
定义项目中所有 API 格式共用的中间表示和转换器协议
- UnifiedRequest / UnifiedResponse: 统一的请求/响应数据结构
- InboundTransformer / OutboundTransformer: 入站/出站转换器接口
- StreamProcessor: 流式事件处理器接口
- ClientFormatter: 客户端响应格式化接口
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Iterator, Protocol
from flask import Response, jsonify
import settings
from utils.http import forward_request, gen_id, sse_response
from utils.request_logger import (
append_client_event,
append_upstream_event,
attach_client_response,
attach_error,
attach_upstream_request,
attach_upstream_response,
finalize_turn,
set_stream_summary,
)
from utils.usage_tracker import usage_tracker
logger = logging.getLogger(__name__)
JsonDict = dict[str, Any]
# ═══════════════════════════════════════════════════════════
# 统一数据模型
# ═══════════════════════════════════════════════════════════
@dataclass
class UnifiedUsage:
"""标准化的令牌用量统计。"""
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
def to_cc_dict(self) -> JsonDict:
return {
'prompt_tokens': self.input_tokens,
'completion_tokens': self.output_tokens,
'total_tokens': self.total_tokens,
}
def to_responses_dict(self) -> JsonDict:
return {
'input_tokens': self.input_tokens,
'output_tokens': self.output_tokens,
'total_tokens': self.total_tokens,
}
@classmethod
def from_cc_dict(cls, d: JsonDict) -> UnifiedUsage:
return cls(
input_tokens=d.get('prompt_tokens', 0),
output_tokens=d.get('completion_tokens', 0),
total_tokens=d.get('total_tokens', 0),
)
@classmethod
def from_responses_dict(cls, d: JsonDict) -> UnifiedUsage:
return cls(
input_tokens=d.get('input_tokens', 0),
output_tokens=d.get('output_tokens', 0),
total_tokens=d.get('total_tokens', 0),
)
# ═══════════════════════════════════════════════════════════
# 转换器接口
# ═══════════════════════════════════════════════════════════
class OutboundTransformer(Protocol):
"""出站转换器:将 CC 中间格式转换为上游后端格式。
所有后端OpenAI Chat / Responses / Anthropic / Gemini各实现一套
内部复用各自现有的适配器函数
"""
def build_request(self, payload: JsonDict) -> JsonDict:
"""将 CC 格式请求体转换为上游格式请求体。"""
...
def build_url(self, ctx: Any) -> str:
"""根据路由上下文构建上游请求 URL。"""
...
def build_headers(self, ctx: Any) -> JsonDict:
"""根据路由上下文构建上游请求头。"""
...
def parse_response(self, raw: JsonDict) -> JsonDict:
"""将上游非流式响应转换回 CC 格式。"""
...
def create_stream_processor(self) -> StreamProcessor:
"""创建该后端对应的流式事件处理器。"""
...
class StreamProcessor(Protocol):
"""流式事件处理器接口。
每个后端的 SSE 格式不同StreamProcessor 封装了具体的迭代与转换逻辑
让通用流式处理器不必关心后端差异
"""
def iter_events(self, response: Any) -> Iterator:
"""从上游 HTTP 响应中迭代原始事件。"""
...
def process_event(self, event: Any) -> list:
"""将单个上游事件转换为输出项列表。
返回值通常是 list[JsonDict]CC chunk
AnthropicResponses 路径返回 list[str]SSE 字符串
"""
...
def extract_usage(self, event: Any) -> JsonDict | None:
"""从上游事件中提取用量信息(如果有的话)。"""
...
def finalize(self) -> list:
"""流结束时产出的收尾项。"""
...
class ClientFormatter(Protocol):
"""客户端响应格式化器。
根据客户端期望的 API 格式CC Responses将通用的处理结果
格式化为最终返回给客户端的形态
"""
def format_response(self, cc_response: JsonDict, model: str) -> JsonDict:
"""格式化非流式响应。"""
...
def wrap_stream_item(self, item: Any) -> str:
"""将单个流式输出项包装为 SSE 字符串。"""
...
def format_error(self, message: str) -> str:
"""构造流式错误消息。"""
...
def format_done(self) -> str | None:
"""构造流结束标记CC 返回 [DONE]Responses 返回 None"""
...
def start_events(self) -> list[str]:
"""流开始前的初始事件Responses 返回 response.created"""
...
@property
def usage_input_key(self) -> str:
"""usage 中输入令牌的字段名。"""
...
@property
def usage_output_key(self) -> str:
"""usage 中输出令牌的字段名。"""
...
# ═══════════════════════════════════════════════════════════
# 通用请求/响应处理器
# ═══════════════════════════════════════════════════════════
def _dbg(message: str) -> None:
if settings.get_debug_mode() in ('simple', 'verbose'):
logger.info('[通用调试] %s', message)
def extract_responses_usage(event_data: JsonDict) -> JsonDict | None:
"""从原生 Responses 事件中提取 usage公共辅助"""
if not isinstance(event_data, dict):
return None
usage = event_data.get('usage')
if isinstance(usage, dict):
return usage
response_obj = event_data.get('response')
if isinstance(response_obj, dict):
nested_usage = response_obj.get('usage')
if isinstance(nested_usage, dict):
return nested_usage
return None
def handle_non_stream(
ctx: Any,
outbound: OutboundTransformer,
client_fmt: ClientFormatter,
payload: JsonDict,
turn: JsonDict | None,
) -> Response:
"""通用非流式处理器。
替代 chat.py responses.py 中的 8 _handle_xxx_non_stream 函数
"""
from routes.common import apply_body_modifications, apply_header_modifications, log_usage
upstream_payload = outbound.build_request(payload)
url = outbound.build_url(ctx)
headers = outbound.build_headers(ctx)
upstream_payload = apply_body_modifications(upstream_payload, ctx.body_modifications)
headers = apply_header_modifications(headers, ctx.header_modifications)
upstream_payload['stream'] = False
attach_upstream_request(turn, upstream_payload, headers)
resp, err = forward_request(url, headers, upstream_payload)
if err:
attach_error(turn, {'stage': 'forward_request', 'message': 'upstream request failed'})
finalize_turn(turn)
return err
raw = resp.json()
attach_upstream_response(turn, raw)
_dbg('上游原始响应=' + json.dumps(raw, ensure_ascii=False, default=str)[:1000])
cc_response = outbound.parse_response(raw)
result = client_fmt.format_response(cc_response, ctx.client_model)
_dbg('格式化后响应=' + json.dumps(result, ensure_ascii=False, default=str)[:1000])
usage_data = result.get('usage', {})
log_usage('通用', usage_data, input_key=client_fmt.usage_input_key, output_key=client_fmt.usage_output_key)
usage_tracker.record(
ctx.client_model,
usage_data,
input_key=client_fmt.usage_input_key,
output_key=client_fmt.usage_output_key,
)
attach_client_response(turn, result)
finalize_turn(turn, usage=usage_data)
return jsonify(result)
def handle_stream(
ctx: Any,
outbound: OutboundTransformer,
client_fmt: ClientFormatter,
payload: JsonDict,
turn: JsonDict | None,
) -> Response:
"""通用流式处理器。
替代 chat.py responses.py 中的 8 _handle_xxx_stream 函数
"""
from routes.common import apply_body_modifications, apply_header_modifications
upstream_payload = outbound.build_request(payload)
url = outbound.build_url(ctx)
headers = outbound.build_headers(ctx)
upstream_payload = apply_body_modifications(upstream_payload, ctx.body_modifications)
headers = apply_header_modifications(headers, ctx.header_modifications)
upstream_payload['stream'] = True
processor = outbound.create_stream_processor()
def generate():
for start_evt in client_fmt.start_events():
yield start_evt
attach_upstream_request(turn, upstream_payload, headers)
resp, err = forward_request(url, headers, upstream_payload, stream=True)
if err:
attach_error(turn, {'stage': 'forward_request', 'message': str(err)})
set_stream_summary(turn, {'status': 'error'})
finalize_turn(turn)
yield client_fmt.format_error(str(err))
return
event_count = 0
client_items: list[str] = []
last_usage: JsonDict | None = None
for event in processor.iter_events(resp):
append_upstream_event(turn, {'type': 'upstream_event', 'data': event})
extracted = processor.extract_usage(event)
if extracted is not None:
last_usage = extracted
if event_count < 10:
_dbg(
f'上游事件#{event_count}='
+ json.dumps(event, ensure_ascii=False, default=str)[:500]
)
for chunk in processor.process_event(event):
if isinstance(chunk, dict):
chunk['model'] = ctx.client_model
wrapped = client_fmt.wrap_stream_item(chunk)
client_items.append(wrapped)
append_client_event(turn, {'type': 'stream_item', 'data': chunk})
if event_count < 10:
_dbg(
f'返回片段#{event_count}='
+ json.dumps(chunk, ensure_ascii=False, default=str)[:500]
)
yield wrapped
event_count += 1
for chunk in processor.finalize():
if isinstance(chunk, dict):
chunk['model'] = ctx.client_model
wrapped = client_fmt.wrap_stream_item(chunk)
client_items.append(wrapped)
append_client_event(turn, {'type': 'stream_item', 'data': chunk})
yield wrapped
done = client_fmt.format_done()
if done:
append_client_event(turn, {'type': 'done'})
yield done
_dbg(f'流式响应结束,共 {event_count} 个事件')
usage_tracker.record(
ctx.client_model,
last_usage,
input_key=client_fmt.usage_input_key,
output_key=client_fmt.usage_output_key,
)
set_stream_summary(turn, {
'event_count': event_count,
'client_item_count': len(client_items),
'usage': last_usage,
})
attach_client_response(turn, {
'type': 'stream.summary',
'model': ctx.client_model,
'event_count': len(client_items),
'usage': last_usage,
})
finalize_turn(turn, usage=last_usage)
return sse_response(generate())