workbuddy2api 2.0.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.
- codebuddy_proxy/__main__.py +1572 -0
- codebuddy_proxy/anthropic_adapter.py +439 -0
- codebuddy_proxy/codebuddy_client_demo.py +312 -0
- codebuddy_proxy/desensitize.py +532 -0
- codebuddy_proxy/dsml_parser.py +888 -0
- codebuddy_proxy/projection_metadata.py +410 -0
- codebuddy_proxy/responses_adapter.py +487 -0
- codebuddy_proxy/responses_projection.py +746 -0
- workbuddy2api-2.0.0.dist-info/METADATA +634 -0
- workbuddy2api-2.0.0.dist-info/RECORD +14 -0
- workbuddy2api-2.0.0.dist-info/WHEEL +5 -0
- workbuddy2api-2.0.0.dist-info/entry_points.txt +2 -0
- workbuddy2api-2.0.0.dist-info/licenses/LICENSE +21 -0
- workbuddy2api-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1572 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local OpenAI/Responses/Anthropic compatible proxy for CodeBuddy.
|
|
3
|
+
|
|
4
|
+
High-performance async implementation with FastAPI + httpx.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- High concurrency: 1000+ concurrent requests
|
|
8
|
+
- Low memory footprint: ~5KB per request
|
|
9
|
+
- Robust timeout handling with async iterators
|
|
10
|
+
|
|
11
|
+
Usage with uv:
|
|
12
|
+
uv run codebuddy_proxy.py
|
|
13
|
+
uv run codebuddy_proxy.py --desensitize
|
|
14
|
+
uv run codebuddy_proxy.py --host 0.0.0.0 --port 8787
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import logging.handlers
|
|
24
|
+
import os
|
|
25
|
+
import pathlib
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
33
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
34
|
+
import uvicorn
|
|
35
|
+
from codebuddy_proxy.dsml_parser import DSMLStreamBuffer, parse_all_tool_calls, remove_all_tool_call_markers
|
|
36
|
+
|
|
37
|
+
from codebuddy_proxy.codebuddy_client_demo import CodeBuddyClient, CodeBuddyError
|
|
38
|
+
|
|
39
|
+
# 尝试导入高级功能模块(可选)
|
|
40
|
+
try:
|
|
41
|
+
from codebuddy_proxy.desensitize import desensitize_body
|
|
42
|
+
HAS_DESENSITIZE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
HAS_DESENSITIZE = False
|
|
45
|
+
def desensitize_body(body, **kwargs):
|
|
46
|
+
return body
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from codebuddy_proxy.responses_projection import project_responses_chat_body
|
|
50
|
+
HAS_PROJECTION = True
|
|
51
|
+
except ImportError:
|
|
52
|
+
HAS_PROJECTION = False
|
|
53
|
+
def project_responses_chat_body(body):
|
|
54
|
+
return body, {}
|
|
55
|
+
|
|
56
|
+
# 导入协议转换器
|
|
57
|
+
try:
|
|
58
|
+
from codebuddy_proxy.responses_adapter import responses_request_to_chat, ResponsesStreamConverter
|
|
59
|
+
HAS_RESPONSES_ADAPTER = True
|
|
60
|
+
except ImportError:
|
|
61
|
+
HAS_RESPONSES_ADAPTER = False
|
|
62
|
+
def responses_request_to_chat(body):
|
|
63
|
+
raise RuntimeError("responses_adapter not available - cannot convert /v1/responses requests")
|
|
64
|
+
ResponsesStreamConverter = None
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from codebuddy_proxy.anthropic_adapter import anthropic_to_chat, AnthropicStreamConverter
|
|
68
|
+
HAS_ANTHROPIC_ADAPTER = True
|
|
69
|
+
except ImportError:
|
|
70
|
+
HAS_ANTHROPIC_ADAPTER = False
|
|
71
|
+
def anthropic_to_chat(body):
|
|
72
|
+
raise RuntimeError("anthropic_adapter not available - cannot convert /v1/messages requests")
|
|
73
|
+
AnthropicStreamConverter = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ============================================================================
|
|
78
|
+
# 日志配置
|
|
79
|
+
# ============================================================================
|
|
80
|
+
|
|
81
|
+
def setup_logging(log_dir: pathlib.Path) -> logging.Logger:
|
|
82
|
+
"""配置滚动日志:按天分片,保留30天。"""
|
|
83
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
log_file = log_dir / "proxy.log"
|
|
85
|
+
|
|
86
|
+
logger = logging.getLogger("codebuddy_proxy")
|
|
87
|
+
logger.setLevel(logging.INFO)
|
|
88
|
+
logger.propagate = False
|
|
89
|
+
logger.handlers.clear()
|
|
90
|
+
|
|
91
|
+
handler = logging.handlers.TimedRotatingFileHandler(
|
|
92
|
+
log_file, when='midnight', interval=1, backupCount=30, encoding='utf-8'
|
|
93
|
+
)
|
|
94
|
+
handler.suffix = "%Y-%m-%d"
|
|
95
|
+
formatter = logging.Formatter(
|
|
96
|
+
'%(asctime)s [%(levelname)s] %(message)s',
|
|
97
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
98
|
+
)
|
|
99
|
+
handler.setFormatter(formatter)
|
|
100
|
+
logger.addHandler(handler)
|
|
101
|
+
|
|
102
|
+
return logger
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def now_s() -> int:
|
|
106
|
+
return int(time.time())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ============================================================================
|
|
110
|
+
# 全局状态管理
|
|
111
|
+
# ============================================================================
|
|
112
|
+
|
|
113
|
+
class ProxyState:
|
|
114
|
+
"""管理 proxy 的全局状态:认证、日志、配置。"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
client: CodeBuddyClient,
|
|
119
|
+
mock_dir: pathlib.Path | None,
|
|
120
|
+
log_file: pathlib.Path | None,
|
|
121
|
+
enable_desensitize: bool = False,
|
|
122
|
+
enable_optimize_context: bool = False,
|
|
123
|
+
verbose_llm: bool = False,
|
|
124
|
+
logger: logging.Logger | None = None,
|
|
125
|
+
):
|
|
126
|
+
self.client = client
|
|
127
|
+
self.mock_dir = mock_dir
|
|
128
|
+
self.log_file = log_file
|
|
129
|
+
self.enable_desensitize = enable_desensitize
|
|
130
|
+
self.enable_optimize_context = enable_optimize_context
|
|
131
|
+
self.verbose_llm = verbose_llm
|
|
132
|
+
self.logger = logger
|
|
133
|
+
self.started_at = time.time()
|
|
134
|
+
|
|
135
|
+
def ensure_auth(self) -> None:
|
|
136
|
+
if self.mock_dir is None:
|
|
137
|
+
self.client.ensure_authenticated()
|
|
138
|
+
|
|
139
|
+
def write_log(self, event: str, **kwargs) -> None:
|
|
140
|
+
if self.log_file is None:
|
|
141
|
+
return
|
|
142
|
+
try:
|
|
143
|
+
record = {"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "event": event, **kwargs}
|
|
144
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
145
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def write_body_log(self, event: str, body: bytes, **kwargs) -> None:
|
|
150
|
+
if self.log_file is None:
|
|
151
|
+
return
|
|
152
|
+
try:
|
|
153
|
+
text = body.decode("utf-8", errors="replace")
|
|
154
|
+
record = {
|
|
155
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
156
|
+
"event": event,
|
|
157
|
+
"body_bytes": len(body),
|
|
158
|
+
"body_text": text,
|
|
159
|
+
**kwargs
|
|
160
|
+
}
|
|
161
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
162
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ============================================================================
|
|
168
|
+
# FastAPI 应用
|
|
169
|
+
# ============================================================================
|
|
170
|
+
|
|
171
|
+
app = FastAPI(title="CodeBuddy Proxy (FastAPI)", version="2.0")
|
|
172
|
+
|
|
173
|
+
# 全局状态(在 main() 中初始化)
|
|
174
|
+
proxy_state: ProxyState | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_state() -> ProxyState:
|
|
178
|
+
if proxy_state is None:
|
|
179
|
+
raise HTTPException(status_code=503, detail={"error": {"message": "proxy not initialized", "type": "internal_error"}})
|
|
180
|
+
return proxy_state
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ============================================================================
|
|
184
|
+
# 辅助函数
|
|
185
|
+
# ============================================================================
|
|
186
|
+
|
|
187
|
+
def body_summary(body: dict[str, Any]) -> dict[str, Any]:
|
|
188
|
+
messages = body.get("messages") or []
|
|
189
|
+
message_summary = []
|
|
190
|
+
for item in messages:
|
|
191
|
+
if not isinstance(item, dict):
|
|
192
|
+
continue
|
|
193
|
+
content = item.get("content", "")
|
|
194
|
+
if isinstance(content, str):
|
|
195
|
+
content_length = len(content)
|
|
196
|
+
content_type = "text"
|
|
197
|
+
elif isinstance(content, list):
|
|
198
|
+
content_length = sum(
|
|
199
|
+
len(str(part.get("text", ""))) for part in content if isinstance(part, dict)
|
|
200
|
+
)
|
|
201
|
+
content_type = "parts"
|
|
202
|
+
else:
|
|
203
|
+
content_length = 0
|
|
204
|
+
content_type = type(content).__name__
|
|
205
|
+
message_summary.append({
|
|
206
|
+
"role": item.get("role"),
|
|
207
|
+
"content_type": content_type,
|
|
208
|
+
"content_length": content_length,
|
|
209
|
+
})
|
|
210
|
+
return {
|
|
211
|
+
"model": body.get("model"),
|
|
212
|
+
"stream": bool(body.get("stream")),
|
|
213
|
+
"message_count": len(messages),
|
|
214
|
+
"messages": message_summary,
|
|
215
|
+
"tool_count": len(body.get("tools") or []),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# 安全词检测统一关键词(中英混合)
|
|
220
|
+
_SAFETY_KEYWORDS = ("sensitive", "cannot respond", "敏感内容", "无法响应", "unable to")
|
|
221
|
+
|
|
222
|
+
def is_policy_blocked(text: str) -> bool:
|
|
223
|
+
"""检测文本是否包含安全策略拦截标记"""
|
|
224
|
+
return any(marker in text.lower() for marker in _SAFETY_KEYWORDS)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def text_summary(value: str) -> dict[str, Any]:
|
|
228
|
+
return {
|
|
229
|
+
"content_length": len(value),
|
|
230
|
+
"content_sha256": hashlib.sha256(value.encode("utf-8")).hexdigest()[:16],
|
|
231
|
+
"safety_message_detected": is_policy_blocked(value),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def diagnostic(event: str, **kwargs) -> None:
|
|
236
|
+
"""输出诊断日志到 logger。"""
|
|
237
|
+
state = get_state()
|
|
238
|
+
if state.logger:
|
|
239
|
+
state.logger.info(f"{event}: {json.dumps(kwargs, ensure_ascii=False)}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ============================================================================
|
|
244
|
+
# 远程配置缓存
|
|
245
|
+
# ============================================================================
|
|
246
|
+
|
|
247
|
+
class RemoteConfigCache:
|
|
248
|
+
"""远程配置缓存(带 TTL)- 匹配 VS Code 插件的逻辑"""
|
|
249
|
+
|
|
250
|
+
def __init__(self, url: str, ttl: int = 300):
|
|
251
|
+
self.url = url
|
|
252
|
+
self.ttl = ttl
|
|
253
|
+
self._cache: Optional[dict[str, Any]] = None
|
|
254
|
+
self._last_fetch: float = 0
|
|
255
|
+
|
|
256
|
+
async def get_config(self) -> dict[str, Any]:
|
|
257
|
+
"""获取配置(带缓存)
|
|
258
|
+
|
|
259
|
+
匹配插件逻辑:
|
|
260
|
+
- 如果没有 enterpriseId,返回空配置
|
|
261
|
+
- 端点: /console/enterprises/{enterpriseId}/config/models
|
|
262
|
+
- 响应格式: { data: { data: [...models] } }
|
|
263
|
+
- 超时: 5 秒
|
|
264
|
+
- 失败返回空配置(不抛出异常)
|
|
265
|
+
"""
|
|
266
|
+
now = time.time()
|
|
267
|
+
|
|
268
|
+
# 缓存有效
|
|
269
|
+
if self._cache and (now - self._last_fetch) < self.ttl:
|
|
270
|
+
age = now - self._last_fetch
|
|
271
|
+
diagnostic("remote_config_cache_hit", age_seconds=age, ttl=self.ttl)
|
|
272
|
+
return self._cache
|
|
273
|
+
|
|
274
|
+
# 获取远程配置
|
|
275
|
+
state = get_state()
|
|
276
|
+
|
|
277
|
+
# 检查是否有 enterpriseId(匹配插件逻辑)
|
|
278
|
+
session = state.client.session
|
|
279
|
+
account = session.get("account", {})
|
|
280
|
+
enterprise_id = account.get("enterpriseId")
|
|
281
|
+
|
|
282
|
+
if not enterprise_id:
|
|
283
|
+
# 个人版用户:返回空配置(匹配插件的 if (!enterpriseId) return [])
|
|
284
|
+
diagnostic("remote_config_skipped", reason="no enterprise id", account_type=account.get("type", "unknown"))
|
|
285
|
+
return {}
|
|
286
|
+
|
|
287
|
+
# 企业版用户:调用动态 API
|
|
288
|
+
try:
|
|
289
|
+
# 构造 URL(匹配插件逻辑)
|
|
290
|
+
path = f"/console/enterprises/{enterprise_id}/config/models"
|
|
291
|
+
full_url = self.url.rstrip("/") + path
|
|
292
|
+
|
|
293
|
+
diagnostic("remote_config_fetch_attempt", url=full_url, enterprise_id=enterprise_id)
|
|
294
|
+
|
|
295
|
+
# 构造请求头(包含认证信息)
|
|
296
|
+
headers = state.client.auth_headers()
|
|
297
|
+
headers["User-Agent"] = "CodeBuddy-Proxy/2.0"
|
|
298
|
+
headers["Accept"] = "application/json"
|
|
299
|
+
|
|
300
|
+
# 发起请求(超时 5 秒,匹配插件)
|
|
301
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, read=5.0)) as client:
|
|
302
|
+
resp = await client.get(full_url, headers=headers)
|
|
303
|
+
|
|
304
|
+
if resp.status_code == 200:
|
|
305
|
+
# 解析响应(匹配插件:response.data.data)
|
|
306
|
+
response_data = resp.json()
|
|
307
|
+
|
|
308
|
+
# 提取模型列表
|
|
309
|
+
models = []
|
|
310
|
+
if isinstance(response_data, dict):
|
|
311
|
+
# 尝试 response.data.data 结构
|
|
312
|
+
data_field = response_data.get("data")
|
|
313
|
+
if isinstance(data_field, dict):
|
|
314
|
+
models = data_field.get("data", [])
|
|
315
|
+
elif isinstance(data_field, list):
|
|
316
|
+
models = data_field
|
|
317
|
+
|
|
318
|
+
# 标记模型类型(匹配插件逻辑)
|
|
319
|
+
if models:
|
|
320
|
+
models = [
|
|
321
|
+
{
|
|
322
|
+
**model,
|
|
323
|
+
"modelType": "enterprise" if (model.get("id") or "").startswith("custom:") else "built-in"
|
|
324
|
+
}
|
|
325
|
+
for model in models
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
config = {"models": models}
|
|
329
|
+
self._cache = config
|
|
330
|
+
self._last_fetch = now
|
|
331
|
+
|
|
332
|
+
diagnostic(
|
|
333
|
+
"remote_config_fetch_success",
|
|
334
|
+
url=full_url,
|
|
335
|
+
models_count=len(models),
|
|
336
|
+
enterprise_id=enterprise_id
|
|
337
|
+
)
|
|
338
|
+
return config
|
|
339
|
+
|
|
340
|
+
else:
|
|
341
|
+
diagnostic("remote_config_fetch_failed", url=full_url, status_code=resp.status_code)
|
|
342
|
+
return {}
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
# 匹配插件逻辑:捕获异常后返回空数组
|
|
346
|
+
diagnostic("remote_config_fetch_error", url=full_url, error=str(e))
|
|
347
|
+
return {}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# 全局实例(在 main() 中初始化)
|
|
351
|
+
remote_config_cache: Optional[RemoteConfigCache] = None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def build_model_list_static() -> list[dict[str, Any]]:
|
|
355
|
+
"""构建静态模型列表(兜底配置)"""
|
|
356
|
+
# 从 CodeBuddy 扩展 product.json 提取的真实模型列表(2026-07-13 版本)
|
|
357
|
+
# 共 25 个模型,涵盖 DeepSeek、GLM、Kimi、Hunyuan、Claude 等
|
|
358
|
+
return [
|
|
359
|
+
# 默认模型
|
|
360
|
+
{"id": "default", "name": "Default", "vendor": "codebuddy", "max_input": 168000, "max_output": 32000, "tool_call": True, "images": False},
|
|
361
|
+
|
|
362
|
+
# GLM 系列
|
|
363
|
+
{"id": "glm-4.7", "name": "GLM-4.7", "vendor": "zhipu", "max_input": 200000, "max_output": 48000, "tool_call": True, "images": False, "reasoning": True, "desc": "GLM-4.7 model, Well-rounded model for everyday use"},
|
|
364
|
+
{"id": "glm-4.6", "name": "GLM-4.6", "vendor": "zhipu", "max_input": 168000, "max_output": 32000, "tool_call": True, "images": False, "desc": "Advanced language model with strong reasoning capabilities"},
|
|
365
|
+
|
|
366
|
+
# DeepSeek 系列
|
|
367
|
+
{"id": "deepseek-v3-2-volc", "name": "DeepSeek-V3.2", "vendor": "deepseek", "max_input": 96000, "max_output": 32000, "tool_call": True, "images": False, "reasoning": True, "desc": "DeepSeek-V3.2, good for daily use"},
|
|
368
|
+
{"id": "deepseek-v3-1-volc", "name": "DeepSeek-V3-1-Terminus", "vendor": "deepseek", "max_input": 96000, "max_output": 32000, "tool_call": True, "images": False, "desc": "DeepSeek's flagship model, good for planning, debugging, coding, and more"},
|
|
369
|
+
{"id": "deepseek-v3-1-lkeap", "name": "DeepSeek-V3-1", "vendor": "deepseek", "max_input": 96000, "max_output": 32000, "tool_call": True, "images": False, "desc": "DeepSeek's flagship model. Good for planning, debugging, coding, and more"},
|
|
370
|
+
{"id": "deepseek-v3-1", "name": "DeepSeek-V3.1", "vendor": "deepseek", "max_input": 96000, "max_output": 32000, "tool_call": True, "images": False, "desc": "DeepSeek's flagship model. Good for planning, debugging, coding, and more"},
|
|
371
|
+
{"id": "deepseek-v3-0324-lkeap", "name": "DeepSeek-V3-0324", "vendor": "deepseek", "max_input": 112000, "max_output": 16000, "tool_call": True, "images": False, "desc": "DeepSeek's flagship model, good for planning, debugging, coding, and more"},
|
|
372
|
+
{"id": "deepseek-r1-0528-lkeap", "name": "DeepSeek-R1-0528", "vendor": "deepseek", "max_input": 96000, "max_output": 16000, "tool_call": True, "images": False, "desc": "Open-source reasoning model from DeepSeek, optimised for logic & math"},
|
|
373
|
+
|
|
374
|
+
# Kimi 系列
|
|
375
|
+
{"id": "kimi-k2-instruct-taiji", "name": "Kimi-K2", "vendor": "moonshot", "max_input": 31000, "max_output": 8192, "tool_call": True, "images": False},
|
|
376
|
+
|
|
377
|
+
# Hunyuan (混元) 系列 - 对话模型
|
|
378
|
+
{"id": "completion-gf", "name": "completion-gf", "vendor": "tencent", "max_input": 200000, "max_output": 8192, "tool_call": True, "images": False},
|
|
379
|
+
{"id": "hunyuan-chat", "name": "Hunyuan-Turbos", "vendor": "tencent", "max_input": 200000, "max_output": 8192, "tool_call": True, "images": False, "desc": "Tencent's lightweight, fast general-purpose model"},
|
|
380
|
+
{"id": "hunyuan-2.0-instruct", "name": "Hunyuan-2.0-Instruct", "vendor": "tencent", "max_input": 128000, "max_output": 16000, "tool_call": True, "images": False, "reasoning": True},
|
|
381
|
+
|
|
382
|
+
# Claude 系列
|
|
383
|
+
{"id": "default-1.1", "name": "Claude-3.7-Sonnet", "vendor": "anthropic", "max_input": None, "max_output": 8192, "tool_call": True, "images": True},
|
|
384
|
+
{"id": "default-1.2", "name": "Claude-4.0-Sonnet", "vendor": "anthropic", "max_input": 200000, "max_output": 24000, "tool_call": True, "images": True, "desc": "Great for daily use. Good at most things"},
|
|
385
|
+
|
|
386
|
+
# Hunyuan 视觉模型
|
|
387
|
+
{"id": "hunyuan-turbos-vision", "name": "hunyuan-turbos-vision", "vendor": "tencent", "max_input": 16000, "max_output": 16000, "tool_call": True, "images": True},
|
|
388
|
+
{"id": "hunyuan-t1-vision", "name": "hunyuan-turbos-vision", "vendor": "tencent", "max_input": 16000, "max_output": 24000, "tool_call": True, "images": True},
|
|
389
|
+
|
|
390
|
+
# 补全模型(仅用于代码补全,不适合对话)
|
|
391
|
+
{"id": "hunyuan-3b", "name": "hunyuan-3b", "vendor": "tencent", "max_input": None, "max_output": 256, "tool_call": False, "images": False},
|
|
392
|
+
{"id": "hunyuan-7b-dense", "name": "hunyuan-7b", "vendor": "tencent", "max_input": None, "max_output": 256, "tool_call": False, "images": False},
|
|
393
|
+
{"id": "codewise-7b-021", "name": "codewise-7b-021", "vendor": "anthropic", "max_input": None, "max_output": 256, "tool_call": False, "images": False},
|
|
394
|
+
{"id": "codewise-completions", "name": "codewise-completions", "vendor": "anthropic", "max_input": None, "max_output": 256, "tool_call": False, "images": False},
|
|
395
|
+
{"id": "deepseek-r1-0528", "name": "deepseek-r1", "vendor": "tencent", "max_input": None, "max_output": 256, "tool_call": False, "images": False},
|
|
396
|
+
{"id": "deepseek-v3-0324-taco-completion", "name": "deepseek-v3-0324", "vendor": "tencent", "max_input": None, "max_output": 256, "tool_call": False, "images": False},
|
|
397
|
+
{"id": "deepseek-v3-0324", "name": "deepseek-v3", "vendor": "tencent", "max_input": None, "max_output": 8192, "tool_call": False, "images": False},
|
|
398
|
+
{"id": "codewise-navi-v1-2-taco", "name": "codewise-navi-v1-2-taco", "vendor": "tencent", "max_input": None, "max_output": 256, "tool_call": False, "images": False},
|
|
399
|
+
]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def load_models_from_local_config() -> list[dict[str, Any]]:
|
|
404
|
+
"""从本地配置文件加载模型列表"""
|
|
405
|
+
config_file = pathlib.Path(__file__).parent / "models_config.json"
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
if not config_file.exists():
|
|
409
|
+
diagnostic("local_config_missing", path=str(config_file))
|
|
410
|
+
return build_model_list_static()
|
|
411
|
+
|
|
412
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
413
|
+
config_data = json.load(f)
|
|
414
|
+
|
|
415
|
+
models = config_data.get("models", [])
|
|
416
|
+
diagnostic("local_config_loaded", models_count=len(models))
|
|
417
|
+
|
|
418
|
+
# 规范化每个模型的格式
|
|
419
|
+
normalized_models = []
|
|
420
|
+
for model in models:
|
|
421
|
+
normalized = normalize_model_format(model)
|
|
422
|
+
normalized_models.append(normalized)
|
|
423
|
+
|
|
424
|
+
return normalized_models
|
|
425
|
+
|
|
426
|
+
except Exception as e:
|
|
427
|
+
diagnostic("local_config_error", error=str(e), path=str(config_file))
|
|
428
|
+
return build_model_list_static()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def normalize_model_format(remote_model: dict[str, Any]) -> dict[str, Any]:
|
|
432
|
+
"""规范化模型格式为 Codex 兼容的内部格式"""
|
|
433
|
+
model_id = remote_model.get("id", "unknown")
|
|
434
|
+
name = remote_model.get("name", model_id)
|
|
435
|
+
vendor = remote_model.get("vendor", "unknown")
|
|
436
|
+
|
|
437
|
+
# 提取 token 限制(支持多种字段名)
|
|
438
|
+
max_input = (
|
|
439
|
+
remote_model.get("max_input") or
|
|
440
|
+
remote_model.get("maxInputTokens") or
|
|
441
|
+
remote_model.get("context_window")
|
|
442
|
+
)
|
|
443
|
+
max_output = (
|
|
444
|
+
remote_model.get("max_output") or
|
|
445
|
+
remote_model.get("maxOutputTokens") or
|
|
446
|
+
remote_model.get("max_context_window")
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# 提取能力标志
|
|
450
|
+
tool_call = (
|
|
451
|
+
remote_model.get("tool_call", False) or
|
|
452
|
+
remote_model.get("supportsToolCall", False) or
|
|
453
|
+
remote_model.get("supportsTools", False) or
|
|
454
|
+
remote_model.get("supports_parallel_tool_calls", False)
|
|
455
|
+
)
|
|
456
|
+
images = (
|
|
457
|
+
remote_model.get("images", False) or
|
|
458
|
+
remote_model.get("supportsImages", False)
|
|
459
|
+
)
|
|
460
|
+
reasoning = (
|
|
461
|
+
remote_model.get("reasoning", False) or
|
|
462
|
+
remote_model.get("supportsReasoning", False)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# 提取描述(支持中英文)
|
|
466
|
+
description = (
|
|
467
|
+
remote_model.get("descriptionZh") or
|
|
468
|
+
remote_model.get("descriptionEn") or
|
|
469
|
+
remote_model.get("description") or
|
|
470
|
+
remote_model.get("desc")
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
"id": model_id,
|
|
475
|
+
"name": name,
|
|
476
|
+
"vendor": vendor,
|
|
477
|
+
"max_input": max_input,
|
|
478
|
+
"max_output": max_output,
|
|
479
|
+
"tool_call": tool_call,
|
|
480
|
+
"images": images,
|
|
481
|
+
"reasoning": reasoning,
|
|
482
|
+
"desc": description,
|
|
483
|
+
"tags": remote_model.get("tags", []),
|
|
484
|
+
"modelType": remote_model.get("modelType"),
|
|
485
|
+
"credits": remote_model.get("credits"),
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async def build_model_list_dynamic() -> list[dict[str, Any]]:
|
|
489
|
+
"""从远程配置构建模型列表(带降级)"""
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
# 获取远程配置
|
|
493
|
+
if not remote_config_cache:
|
|
494
|
+
diagnostic("dynamic_models_disabled", reason="remote_config_cache not initialized")
|
|
495
|
+
return build_model_list_static()
|
|
496
|
+
|
|
497
|
+
remote_config = await remote_config_cache.get_config()
|
|
498
|
+
|
|
499
|
+
if not remote_config:
|
|
500
|
+
diagnostic("dynamic_models_fallback", reason="empty remote config")
|
|
501
|
+
return build_model_list_static()
|
|
502
|
+
|
|
503
|
+
# 提取模型和白名单
|
|
504
|
+
remote_models = remote_config.get("models", [])
|
|
505
|
+
available_model_ids = set(remote_config.get("availableModels", []))
|
|
506
|
+
|
|
507
|
+
diagnostic(
|
|
508
|
+
"dynamic_models_fetched",
|
|
509
|
+
remote_models_count=len(remote_models),
|
|
510
|
+
whitelist_count=len(available_model_ids)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# 创建模型字典(id -> model)
|
|
514
|
+
static_models = build_model_list_static()
|
|
515
|
+
models_dict = {m["id"]: m for m in static_models}
|
|
516
|
+
|
|
517
|
+
# 合并远程模型(覆盖静态配置)
|
|
518
|
+
for remote_model in remote_models:
|
|
519
|
+
model_id = remote_model.get("id")
|
|
520
|
+
if model_id:
|
|
521
|
+
models_dict[model_id] = normalize_model_format(remote_model)
|
|
522
|
+
|
|
523
|
+
diagnostic("dynamic_models_merged", total_models=len(models_dict))
|
|
524
|
+
|
|
525
|
+
# 应用白名单过滤(如果有)
|
|
526
|
+
if available_model_ids:
|
|
527
|
+
filtered_models = []
|
|
528
|
+
for model_id, model in models_dict.items():
|
|
529
|
+
# 保留条件(参考 VS Code 插件逻辑):
|
|
530
|
+
# 1. 用户自定义模型
|
|
531
|
+
# 2. 企业模型
|
|
532
|
+
# 3. 在白名单中的模型
|
|
533
|
+
tags = model.get("tags", [])
|
|
534
|
+
vendor = model.get("vendor")
|
|
535
|
+
model_type = model.get("modelType")
|
|
536
|
+
|
|
537
|
+
if (
|
|
538
|
+
"custom" in tags or
|
|
539
|
+
vendor == "user" or
|
|
540
|
+
model_type == "enterprise" or
|
|
541
|
+
model_id in available_model_ids
|
|
542
|
+
):
|
|
543
|
+
filtered_models.append(model)
|
|
544
|
+
|
|
545
|
+
diagnostic(
|
|
546
|
+
"dynamic_models_filtered",
|
|
547
|
+
filtered_count=len(filtered_models),
|
|
548
|
+
total_count=len(models_dict)
|
|
549
|
+
)
|
|
550
|
+
return filtered_models
|
|
551
|
+
else:
|
|
552
|
+
# 无白名单,返回全部
|
|
553
|
+
diagnostic("dynamic_models_no_whitelist", total_count=len(models_dict))
|
|
554
|
+
return list(models_dict.values())
|
|
555
|
+
|
|
556
|
+
except Exception as e:
|
|
557
|
+
diagnostic("dynamic_models_error", error=str(e))
|
|
558
|
+
return build_model_list_static()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def model_to_codex_format(m: dict[str, Any]) -> dict[str, Any]:
|
|
563
|
+
"""将简化模型对象转换为完整的 Codex ModelInfo 格式"""
|
|
564
|
+
return {
|
|
565
|
+
# 基础标识
|
|
566
|
+
"id": m["id"],
|
|
567
|
+
"slug": m["id"],
|
|
568
|
+
"display_name": m.get("name", m["id"]),
|
|
569
|
+
"description": m.get("desc"),
|
|
570
|
+
"object": "model",
|
|
571
|
+
"created": 1720872952, # 2026-07-13 的时间戳
|
|
572
|
+
"owned_by": m.get("vendor", "codebuddy"),
|
|
573
|
+
|
|
574
|
+
# Reasoning 支持
|
|
575
|
+
"default_reasoning_level": None,
|
|
576
|
+
"supported_reasoning_levels": [{"effort": "High", "description": "High"}] if m.get("reasoning") else [],
|
|
577
|
+
"default_reasoning_summary": "auto",
|
|
578
|
+
"supports_reasoning_summary_parameter": True,
|
|
579
|
+
|
|
580
|
+
# Shell 和工具能力
|
|
581
|
+
"shell_type": "default",
|
|
582
|
+
"apply_patch_tool_type": None,
|
|
583
|
+
"web_search_tool_type": "text",
|
|
584
|
+
"experimental_supported_tools": [],
|
|
585
|
+
"supports_parallel_tool_calls": m.get("tool_call", False),
|
|
586
|
+
|
|
587
|
+
# 可见性和优先级
|
|
588
|
+
"visibility": "list",
|
|
589
|
+
"supported_in_api": True,
|
|
590
|
+
"priority": 1,
|
|
591
|
+
|
|
592
|
+
# 上下文窗口
|
|
593
|
+
"context_window": m.get("max_input"),
|
|
594
|
+
"max_context_window": m.get("max_output"),
|
|
595
|
+
"auto_compact_token_limit": None,
|
|
596
|
+
"effective_context_window_percent": 95,
|
|
597
|
+
|
|
598
|
+
# 输出截断策略
|
|
599
|
+
"truncation_policy": {
|
|
600
|
+
"mode": "bytes",
|
|
601
|
+
"limit": 10000
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
# 多模态支持
|
|
605
|
+
"input_modalities": ["text", "image"] if m.get("images") else ["text"],
|
|
606
|
+
"supports_image_detail_original": False,
|
|
607
|
+
|
|
608
|
+
# Verbosity
|
|
609
|
+
"support_verbosity": False,
|
|
610
|
+
"default_verbosity": None,
|
|
611
|
+
|
|
612
|
+
# 其他功能开关
|
|
613
|
+
"include_skills_usage_instructions": False,
|
|
614
|
+
"include_plugin_usage_instructions": False,
|
|
615
|
+
"include_apps_usage_instructions": True,
|
|
616
|
+
|
|
617
|
+
# 速度层级和服务层级
|
|
618
|
+
"additional_speed_tiers": [],
|
|
619
|
+
"service_tiers": [],
|
|
620
|
+
"default_service_tier": None,
|
|
621
|
+
|
|
622
|
+
# 可选元数据
|
|
623
|
+
"availability_nux": None,
|
|
624
|
+
"model_messages": None,
|
|
625
|
+
|
|
626
|
+
# 向后兼容的 base_instructions (遗留字段)
|
|
627
|
+
"base_instructions": "You are a helpful AI assistant.",
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def log_client_request(method: str, path: str, body: dict[str, Any] | None) -> None:
|
|
632
|
+
"""Log client request with verbosity control."""
|
|
633
|
+
state = get_state()
|
|
634
|
+
|
|
635
|
+
if state.verbose_llm:
|
|
636
|
+
state.write_log("client_request", method=method, path=path, body=body)
|
|
637
|
+
else:
|
|
638
|
+
if body:
|
|
639
|
+
summary = body_summary(body)
|
|
640
|
+
state.write_log("client_request_summary", method=method, path=path, **summary)
|
|
641
|
+
else:
|
|
642
|
+
state.write_log("client_request_summary", method=method, path=path)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def log_upstream_request(protocol: str, body: dict[str, Any]) -> None:
|
|
646
|
+
"""Log upstream request with verbosity control."""
|
|
647
|
+
state = get_state()
|
|
648
|
+
|
|
649
|
+
if state.verbose_llm:
|
|
650
|
+
state.write_log("upstream_request", protocol=protocol,
|
|
651
|
+
method="POST", path="/v2/chat/completions", body=body)
|
|
652
|
+
diagnostic("upstream_request", protocol=protocol, **body_summary(body))
|
|
653
|
+
else:
|
|
654
|
+
messages = body.get("messages", [])
|
|
655
|
+
total_chars = sum(
|
|
656
|
+
len(str(m.get("content", "")))
|
|
657
|
+
for m in messages
|
|
658
|
+
if isinstance(m, dict)
|
|
659
|
+
)
|
|
660
|
+
summary = {
|
|
661
|
+
"model": body.get("model"),
|
|
662
|
+
"message_count": len(messages),
|
|
663
|
+
"tool_count": len(body.get("tools", [])),
|
|
664
|
+
"stream": bool(body.get("stream")),
|
|
665
|
+
"total_chars": total_chars
|
|
666
|
+
}
|
|
667
|
+
state.write_log("upstream_request_summary", protocol=protocol, **summary)
|
|
668
|
+
diagnostic("upstream_request_summary", protocol=protocol, **summary)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def log_upstream_response(protocol: str, text: str, **stats) -> None:
|
|
672
|
+
"""Log upstream response with verbosity control."""
|
|
673
|
+
state = get_state()
|
|
674
|
+
|
|
675
|
+
common = {
|
|
676
|
+
"protocol": protocol,
|
|
677
|
+
"content_length": len(text),
|
|
678
|
+
"content_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest()[:16],
|
|
679
|
+
"safety_message_detected": is_policy_blocked(text),
|
|
680
|
+
**stats
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if state.verbose_llm:
|
|
684
|
+
common["content_preview"] = text[:200] if text else ""
|
|
685
|
+
|
|
686
|
+
diagnostic("response", **common)
|
|
687
|
+
state.write_log("stream_completed" if stats.get("stream") else "response",
|
|
688
|
+
**{k: v for k, v in common.items()
|
|
689
|
+
if k not in ("content_preview",)})
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# ============================================================================
|
|
693
|
+
# 端点:/health
|
|
694
|
+
# ============================================================================
|
|
695
|
+
|
|
696
|
+
@app.get("/health")
|
|
697
|
+
async def health():
|
|
698
|
+
state = get_state()
|
|
699
|
+
auth = {} if state.mock_dir is not None else (state.client.session.get("auth") or {})
|
|
700
|
+
expires = int(auth.get("expiresAt") or 0)
|
|
701
|
+
return {
|
|
702
|
+
"status": "ok",
|
|
703
|
+
"authenticated": bool(auth.get("accessToken")),
|
|
704
|
+
"token_valid": not expires or expires > int(time.time() * 1000),
|
|
705
|
+
"uptime_seconds": int(time.time() - state.started_at),
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ============================================================================
|
|
710
|
+
# 端点:/v1/models
|
|
711
|
+
# ============================================================================
|
|
712
|
+
|
|
713
|
+
@app.get("/v1/models")
|
|
714
|
+
async def list_models():
|
|
715
|
+
state = get_state()
|
|
716
|
+
# 从本地配置文件加载模型列表
|
|
717
|
+
data = load_models_from_local_config()
|
|
718
|
+
|
|
719
|
+
# 记录模型列表请求
|
|
720
|
+
diagnostic(
|
|
721
|
+
"models_list_request",
|
|
722
|
+
models_count=len(data),
|
|
723
|
+
source="local_config"
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# 转换为 Codex 格式
|
|
727
|
+
codex_models = [model_to_codex_format(m) for m in data]
|
|
728
|
+
|
|
729
|
+
return {"models": codex_models}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ============================================================================
|
|
733
|
+
# 端点:/v1/chat/completions
|
|
734
|
+
# ============================================================================
|
|
735
|
+
|
|
736
|
+
@app.post("/v1/chat/completions")
|
|
737
|
+
async def chat_completions(request: Request):
|
|
738
|
+
state = get_state()
|
|
739
|
+
body = await request.json()
|
|
740
|
+
|
|
741
|
+
log_client_request("POST", "/v1/chat/completions", body)
|
|
742
|
+
diagnostic("request", protocol="openai", **body_summary(body))
|
|
743
|
+
|
|
744
|
+
return await forward_chat(body, "openai")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# ============================================================================
|
|
748
|
+
# 端点:/v1/responses
|
|
749
|
+
# ============================================================================
|
|
750
|
+
|
|
751
|
+
@app.post("/v1/responses")
|
|
752
|
+
async def create_response(request: Request):
|
|
753
|
+
state = get_state()
|
|
754
|
+
body = await request.json()
|
|
755
|
+
|
|
756
|
+
log_client_request("POST", "/v1/responses", body)
|
|
757
|
+
|
|
758
|
+
# 转换 Responses → Chat
|
|
759
|
+
chat_body = responses_request_to_chat(body)
|
|
760
|
+
|
|
761
|
+
# 消息压缩优化(如果启用)
|
|
762
|
+
if state.enable_optimize_context and HAS_PROJECTION:
|
|
763
|
+
chat_body, proj_stats = project_responses_chat_body(chat_body)
|
|
764
|
+
diagnostic("projection_applied", protocol="responses", **proj_stats)
|
|
765
|
+
|
|
766
|
+
# 过滤无效的工具定义
|
|
767
|
+
tools = chat_body.get("tools", [])
|
|
768
|
+
if tools:
|
|
769
|
+
original_count = len(tools)
|
|
770
|
+
filtered_tools = []
|
|
771
|
+
filtered_names = []
|
|
772
|
+
|
|
773
|
+
for tool in tools:
|
|
774
|
+
# 1. 过滤非 function 类型
|
|
775
|
+
if tool.get("type") != "function":
|
|
776
|
+
filtered_names.append(f"{tool.get('type', 'unknown')} (非function类型)")
|
|
777
|
+
continue
|
|
778
|
+
|
|
779
|
+
# 2. 过滤空 parameters
|
|
780
|
+
func = tool.get("function", {})
|
|
781
|
+
params = func.get("parameters", {})
|
|
782
|
+
if not params or not isinstance(params, dict) or len(params) == 0:
|
|
783
|
+
filtered_names.append(f"{func.get('name', 'unknown')} (空parameters)")
|
|
784
|
+
continue
|
|
785
|
+
|
|
786
|
+
# 3. 检查 parameters 是否有 type 字段
|
|
787
|
+
if "type" not in params:
|
|
788
|
+
filtered_names.append(f"{func.get('name', 'unknown')} (缺少type)")
|
|
789
|
+
continue
|
|
790
|
+
|
|
791
|
+
filtered_tools.append(tool)
|
|
792
|
+
|
|
793
|
+
chat_body["tools"] = filtered_tools
|
|
794
|
+
|
|
795
|
+
if filtered_names:
|
|
796
|
+
diagnostic("tools_filtered",
|
|
797
|
+
original=original_count,
|
|
798
|
+
kept=len(filtered_tools),
|
|
799
|
+
filtered=filtered_names)
|
|
800
|
+
|
|
801
|
+
diagnostic("request", protocol="responses", **body_summary(chat_body))
|
|
802
|
+
return await forward_chat(chat_body, "responses", original=body)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
# ============================================================================
|
|
806
|
+
# 端点:/v1/messages
|
|
807
|
+
# ============================================================================
|
|
808
|
+
|
|
809
|
+
@app.post("/v1/messages")
|
|
810
|
+
async def create_message(request: Request):
|
|
811
|
+
state = get_state()
|
|
812
|
+
body = await request.json()
|
|
813
|
+
|
|
814
|
+
log_client_request("POST", "/v1/messages", body)
|
|
815
|
+
|
|
816
|
+
# 转换 Anthropic → Chat
|
|
817
|
+
chat_body = anthropic_to_chat(body)
|
|
818
|
+
diagnostic("request", protocol="anthropic", **body_summary(chat_body))
|
|
819
|
+
|
|
820
|
+
return await forward_chat(chat_body, "anthropic", original=body)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ============================================================================
|
|
824
|
+
# 核心:转发请求到上游
|
|
825
|
+
# ============================================================================
|
|
826
|
+
|
|
827
|
+
async def forward_chat(
|
|
828
|
+
body: dict[str, Any],
|
|
829
|
+
protocol: str,
|
|
830
|
+
original: dict[str, Any] | None = None
|
|
831
|
+
) -> StreamingResponse | JSONResponse:
|
|
832
|
+
"""转发 chat 请求到 CodeBuddy 上游,支持流式和非流式。"""
|
|
833
|
+
state = get_state()
|
|
834
|
+
state.ensure_auth()
|
|
835
|
+
|
|
836
|
+
diagnostic("upstream_request", protocol=protocol, **body_summary(body))
|
|
837
|
+
|
|
838
|
+
stream = bool(body.get("stream"))
|
|
839
|
+
upstream_body = dict(body)
|
|
840
|
+
|
|
841
|
+
# 限制 tools 数量防止上游拒绝 (CodeBuddy 限制约 30-50 个工具)
|
|
842
|
+
original_tool_count = len(upstream_body.get("tools", []))
|
|
843
|
+
MAX_TOOLS = 30
|
|
844
|
+
if original_tool_count > MAX_TOOLS:
|
|
845
|
+
upstream_body["tools"] = upstream_body["tools"][:MAX_TOOLS]
|
|
846
|
+
diagnostic("tools_truncated",
|
|
847
|
+
original_count=original_tool_count,
|
|
848
|
+
truncated_count=MAX_TOOLS,
|
|
849
|
+
reason="Upstream API tool limit")
|
|
850
|
+
|
|
851
|
+
# 应用脱敏处理
|
|
852
|
+
if state.enable_desensitize:
|
|
853
|
+
upstream_body = desensitize_body(upstream_body, compact_harness=True)
|
|
854
|
+
|
|
855
|
+
# 始终以流式方式请求上游(聚合或转发)
|
|
856
|
+
upstream_body["stream"] = True
|
|
857
|
+
upstream_body.setdefault("stream_options", {"include_usage": True})
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
url = state.client.endpoint + "/v2/chat/completions"
|
|
861
|
+
headers = {
|
|
862
|
+
**state.client.auth_headers(),
|
|
863
|
+
"Content-Type": "application/json",
|
|
864
|
+
"Accept": "text/event-stream",
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if stream:
|
|
868
|
+
# 流式:直接转发
|
|
869
|
+
return StreamingResponse(
|
|
870
|
+
stream_upstream(url, headers, upstream_body, protocol, original),
|
|
871
|
+
media_type="text/event-stream",
|
|
872
|
+
headers={"Cache-Control": "no-cache", "Connection": "close"}
|
|
873
|
+
)
|
|
874
|
+
else:
|
|
875
|
+
# 非流式:聚合后返回
|
|
876
|
+
collected = await collect_upstream(url, headers, upstream_body, protocol)
|
|
877
|
+
return JSONResponse(content=convert_nonstream(collected, protocol, original))
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# ============================================================================
|
|
881
|
+
# 异步流式转发(核心改进)
|
|
882
|
+
# ============================================================================
|
|
883
|
+
|
|
884
|
+
async def stream_upstream(
|
|
885
|
+
url: str,
|
|
886
|
+
headers: dict[str, str],
|
|
887
|
+
body: dict[str, Any],
|
|
888
|
+
protocol: str,
|
|
889
|
+
original: dict[str, Any] | None
|
|
890
|
+
):
|
|
891
|
+
"""异步流式转发上游响应到客户端。
|
|
892
|
+
|
|
893
|
+
关键改进:
|
|
894
|
+
1. 使用 httpx.AsyncClient 异步请求
|
|
895
|
+
2. aiter_lines() 自动处理行分割和超时
|
|
896
|
+
3. 记录流开始/进度/完成日志
|
|
897
|
+
"""
|
|
898
|
+
state = get_state()
|
|
899
|
+
stream_start_time = time.time()
|
|
900
|
+
|
|
901
|
+
# 【日志】流开始
|
|
902
|
+
state.write_log("stream_started", protocol=protocol, timestamp=stream_start_time)
|
|
903
|
+
diagnostic("stream_started", protocol=protocol)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
response_id = "resp_" + uuid.uuid4().hex
|
|
907
|
+
anthropic_state = AnthropicStreamConverter(
|
|
908
|
+
(original or {}).get("model", "default")
|
|
909
|
+
) if protocol == "anthropic" and AnthropicStreamConverter else None
|
|
910
|
+
|
|
911
|
+
# Responses 协议转换器(使用传入的 body 参数)
|
|
912
|
+
responses_state = ResponsesStreamConverter(
|
|
913
|
+
model=body.get("model", "auto")
|
|
914
|
+
) if protocol == "responses" and ResponsesStreamConverter else None
|
|
915
|
+
|
|
916
|
+
# DSML 缓冲区(用于处理可能的文本标记格式工具调用)
|
|
917
|
+
dsml_buffer = DSMLStreamBuffer()
|
|
918
|
+
|
|
919
|
+
emitted_response_created = False
|
|
920
|
+
response_text = ""
|
|
921
|
+
response_text_started = False
|
|
922
|
+
chunk_count = 0
|
|
923
|
+
done_seen = False
|
|
924
|
+
raw_chunks: list[bytes] = []
|
|
925
|
+
last_progress_log = stream_start_time
|
|
926
|
+
detected_tool_calls = []
|
|
927
|
+
try:
|
|
928
|
+
# 异步HTTP客户端:timeout=None 依赖TCP超时
|
|
929
|
+
# 使用合理的超时配置:连接超时30s,读取超时300s
|
|
930
|
+
timeout_config = httpx.Timeout(30.0, read=300.0)
|
|
931
|
+
async with httpx.AsyncClient(timeout=timeout_config) as client:
|
|
932
|
+
async with client.stream("POST", url, headers=headers, json=body) as resp:
|
|
933
|
+
if resp.status_code != 200:
|
|
934
|
+
error_body = await resp.aread()
|
|
935
|
+
error_text = error_body.decode("utf-8", "replace")
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
# 【诊断】记录 400 错误时的工具定义
|
|
939
|
+
if resp.status_code == 400:
|
|
940
|
+
tools = body.get("tools", [])
|
|
941
|
+
diagnostic("upstream_400_error",
|
|
942
|
+
status=resp.status_code,
|
|
943
|
+
error_preview=error_text[:200],
|
|
944
|
+
tool_count=len(tools),
|
|
945
|
+
sample_tools=tools[:2] if tools else [])
|
|
946
|
+
diagnostic("upstream_error", protocol=protocol,
|
|
947
|
+
status=resp.status_code,
|
|
948
|
+
detail=error_text[:500])
|
|
949
|
+
|
|
950
|
+
# 返回结构化错误(包含详细信息)
|
|
951
|
+
if protocol == "anthropic":
|
|
952
|
+
# Anthropic error format
|
|
953
|
+
error_event = {
|
|
954
|
+
"type": "error",
|
|
955
|
+
"error": {
|
|
956
|
+
"type": "api_error",
|
|
957
|
+
"message": f"Upstream API error (HTTP {resp.status_code}): {error_text[:200]}"
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
yield f"event: error\ndata: {json.dumps(error_event, ensure_ascii=False)}\n\n".encode()
|
|
961
|
+
else:
|
|
962
|
+
# OpenAI error format
|
|
963
|
+
error_chunk = {
|
|
964
|
+
"error": {
|
|
965
|
+
"message": f"Upstream API error (HTTP {resp.status_code})",
|
|
966
|
+
"type": "upstream_error",
|
|
967
|
+
"code": resp.status_code,
|
|
968
|
+
"details": error_text[:500]
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n".encode()
|
|
972
|
+
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
diagnostic("upstream_response", protocol=protocol, status=resp.status_code)
|
|
976
|
+
|
|
977
|
+
# 【配置】超时策略 - 双重超时机制
|
|
978
|
+
# 1. 空闲超时(idle timeout):连续N秒没有新chunk → 超时
|
|
979
|
+
# 2. 总时长上限(total duration):绝对时长限制,防止无限期占用
|
|
980
|
+
client_timeout = body.get("max_stream_duration")
|
|
981
|
+
client_idle_timeout = body.get("max_idle_duration")
|
|
982
|
+
|
|
983
|
+
# 空闲超时:默认60秒,客户端可配置(10-300秒)
|
|
984
|
+
if client_idle_timeout is not None:
|
|
985
|
+
MAX_IDLE_DURATION = min(max(int(client_idle_timeout), 10), 300)
|
|
986
|
+
else:
|
|
987
|
+
MAX_IDLE_DURATION = 60 # 60秒没有新数据则超时
|
|
988
|
+
|
|
989
|
+
# 总时长上限:默认30分钟,客户端可配置(最大2小时)
|
|
990
|
+
if client_timeout is not None:
|
|
991
|
+
MAX_TOTAL_DURATION = min(max(int(client_timeout), 60), 7200)
|
|
992
|
+
else:
|
|
993
|
+
MAX_TOTAL_DURATION = 1800 # 30分钟绝对上限
|
|
994
|
+
|
|
995
|
+
last_chunk_time = time.time() # 记录最后一次收到chunk的时间
|
|
996
|
+
|
|
997
|
+
# 异步迭代行(自动处理超时和分块)
|
|
998
|
+
async for line in resp.aiter_lines():
|
|
999
|
+
current_time = time.time()
|
|
1000
|
+
|
|
1001
|
+
# 【保护1】检查空闲超时:距离上次chunk超过N秒
|
|
1002
|
+
idle_time = current_time - last_chunk_time
|
|
1003
|
+
if idle_time > MAX_IDLE_DURATION:
|
|
1004
|
+
diagnostic("stream_idle_timeout", protocol=protocol,
|
|
1005
|
+
chunks=chunk_count,
|
|
1006
|
+
idle_time=round(idle_time, 2),
|
|
1007
|
+
max_idle=MAX_IDLE_DURATION)
|
|
1008
|
+
state.write_log("stream_idle_timeout", protocol=protocol,
|
|
1009
|
+
chunks=chunk_count, idle_time=round(idle_time, 2))
|
|
1010
|
+
break # 空闲超时,结束流
|
|
1011
|
+
|
|
1012
|
+
# 【保护2】检查总时长上限:防止无限期运行
|
|
1013
|
+
total_elapsed = current_time - stream_start_time
|
|
1014
|
+
if total_elapsed > MAX_TOTAL_DURATION:
|
|
1015
|
+
diagnostic("stream_total_duration_exceeded", protocol=protocol,
|
|
1016
|
+
chunks=chunk_count,
|
|
1017
|
+
elapsed=round(total_elapsed, 2),
|
|
1018
|
+
max_duration=MAX_TOTAL_DURATION)
|
|
1019
|
+
state.write_log("stream_total_duration_exceeded", protocol=protocol,
|
|
1020
|
+
chunks=chunk_count, elapsed=round(total_elapsed, 2))
|
|
1021
|
+
break # 总时长超限,结束流
|
|
1022
|
+
|
|
1023
|
+
# 更新最后chunk时间
|
|
1024
|
+
last_chunk_time = current_time
|
|
1025
|
+
|
|
1026
|
+
# 【日志】进度记录(每10个chunk且间隔5秒)
|
|
1027
|
+
if chunk_count > 0 and chunk_count % 10 == 0:
|
|
1028
|
+
now = time.time()
|
|
1029
|
+
if now - last_progress_log >= 5:
|
|
1030
|
+
diagnostic("stream_progress", protocol=protocol,
|
|
1031
|
+
chunks=chunk_count,
|
|
1032
|
+
elapsed=round(now - stream_start_time, 2))
|
|
1033
|
+
last_progress_log = now
|
|
1034
|
+
line = line.strip()
|
|
1035
|
+
raw_chunks.append(line.encode("utf-8"))
|
|
1036
|
+
|
|
1037
|
+
if not line.startswith("data:"):
|
|
1038
|
+
continue
|
|
1039
|
+
|
|
1040
|
+
data = line[5:].strip()
|
|
1041
|
+
if data == "[DONE]":
|
|
1042
|
+
done_seen = True
|
|
1043
|
+
break
|
|
1044
|
+
|
|
1045
|
+
try:
|
|
1046
|
+
chunk = json.loads(data)
|
|
1047
|
+
except json.JSONDecodeError:
|
|
1048
|
+
continue
|
|
1049
|
+
|
|
1050
|
+
chunk_count += 1
|
|
1051
|
+
|
|
1052
|
+
# 根据协议转换事件
|
|
1053
|
+
if protocol == "openai":
|
|
1054
|
+
# 提取 content 并通过 DSML 缓冲区处理
|
|
1055
|
+
chunk_content = str(
|
|
1056
|
+
((chunk.get("choices") or [{}])[0].get("delta") or {}).get("content") or ""
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
if chunk_content:
|
|
1060
|
+
# 使用 DSML 缓冲区处理(清理标记,检测工具调用)
|
|
1061
|
+
cleaned_content, chunk_tool_calls = dsml_buffer.add_chunk(chunk_content)
|
|
1062
|
+
|
|
1063
|
+
# 累积清理后的文本
|
|
1064
|
+
if cleaned_content:
|
|
1065
|
+
response_text += cleaned_content
|
|
1066
|
+
|
|
1067
|
+
# 记录检测到的工具调用
|
|
1068
|
+
if chunk_tool_calls:
|
|
1069
|
+
detected_tool_calls.extend(chunk_tool_calls)
|
|
1070
|
+
|
|
1071
|
+
# 修改 chunk 中的 content 为清理后的内容
|
|
1072
|
+
if "choices" in chunk and len(chunk["choices"]) > 0:
|
|
1073
|
+
if "delta" not in chunk["choices"][0]:
|
|
1074
|
+
chunk["choices"][0]["delta"] = {}
|
|
1075
|
+
chunk["choices"][0]["delta"]["content"] = cleaned_content
|
|
1076
|
+
|
|
1077
|
+
# 如果检测到工具调用,添加 tool_calls 字段
|
|
1078
|
+
if detected_tool_calls and dsml_buffer.should_emit_tool_calls():
|
|
1079
|
+
if "choices" in chunk and len(chunk["choices"]) > 0:
|
|
1080
|
+
chunk["choices"][0]["finish_reason"] = "tool_calls"
|
|
1081
|
+
# 将检测到的工具调用转换为 OpenAI 格式
|
|
1082
|
+
chunk["choices"][0]["delta"]["tool_calls"] = [
|
|
1083
|
+
{
|
|
1084
|
+
"index": idx,
|
|
1085
|
+
"id": f"call_{uuid.uuid4().hex[:24]}",
|
|
1086
|
+
"type": "function",
|
|
1087
|
+
"function": {
|
|
1088
|
+
"name": tc["name"],
|
|
1089
|
+
"arguments": json.dumps(tc["input"], ensure_ascii=False)
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
for idx, tc in enumerate(detected_tool_calls)
|
|
1093
|
+
]
|
|
1094
|
+
|
|
1095
|
+
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode()
|
|
1096
|
+
|
|
1097
|
+
elif protocol == "responses" and responses_state:
|
|
1098
|
+
# 【修复】提取 content 并通过 DSML 缓冲区处理
|
|
1099
|
+
chunk_content = str(
|
|
1100
|
+
((chunk.get("choices") or [{}])[0].get("delta") or {}).get("content") or ""
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
if chunk_content:
|
|
1104
|
+
# 使用 DSML 缓冲区处理(清理标记,检测工具调用)
|
|
1105
|
+
cleaned_content, chunk_tool_calls = dsml_buffer.add_chunk(chunk_content)
|
|
1106
|
+
|
|
1107
|
+
# 累积清理后的文本
|
|
1108
|
+
if cleaned_content:
|
|
1109
|
+
response_text += cleaned_content
|
|
1110
|
+
|
|
1111
|
+
# 记录检测到的工具调用
|
|
1112
|
+
if chunk_tool_calls:
|
|
1113
|
+
detected_tool_calls.extend(chunk_tool_calls)
|
|
1114
|
+
|
|
1115
|
+
# 修改 chunk 中的 content 为清理后的内容
|
|
1116
|
+
if "choices" in chunk and len(chunk["choices"]) > 0:
|
|
1117
|
+
if "delta" not in chunk["choices"][0]:
|
|
1118
|
+
chunk["choices"][0]["delta"] = {}
|
|
1119
|
+
chunk["choices"][0]["delta"]["content"] = cleaned_content
|
|
1120
|
+
|
|
1121
|
+
# 如果检测到工具调用,添加 tool_calls 字段
|
|
1122
|
+
if chunk_tool_calls and dsml_buffer.should_emit_tool_calls():
|
|
1123
|
+
if "choices" in chunk and len(chunk["choices"]) > 0:
|
|
1124
|
+
chunk["choices"][0]["finish_reason"] = "tool_calls"
|
|
1125
|
+
chunk["choices"][0]["delta"]["tool_calls"] = [
|
|
1126
|
+
{
|
|
1127
|
+
"index": idx,
|
|
1128
|
+
"id": f"call_{uuid.uuid4().hex[:24]}",
|
|
1129
|
+
"type": "function",
|
|
1130
|
+
"function": {
|
|
1131
|
+
"name": tc["name"],
|
|
1132
|
+
"arguments": json.dumps(tc["input"], ensure_ascii=False)
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
for idx, tc in enumerate(detected_tool_calls)
|
|
1136
|
+
]
|
|
1137
|
+
|
|
1138
|
+
# 使用 ResponsesStreamConverter 转换事件(此时 chunk 已经被清理)
|
|
1139
|
+
events = responses_state.feed_chunk(chunk)
|
|
1140
|
+
for event_name, event_data in events:
|
|
1141
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1142
|
+
elif protocol == "anthropic" and anthropic_state:
|
|
1143
|
+
# 提取 content 并通过 DSML 缓冲区处理
|
|
1144
|
+
chunk_content = str(
|
|
1145
|
+
((chunk.get("choices") or [{}])[0].get("delta") or {}).get("content") or ""
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
if chunk_content:
|
|
1149
|
+
# 使用 DSML 缓冲区处理(清理标记,检测工具调用)
|
|
1150
|
+
cleaned_content, chunk_tool_calls = dsml_buffer.add_chunk(chunk_content)
|
|
1151
|
+
|
|
1152
|
+
# 累积清理后的文本
|
|
1153
|
+
if cleaned_content:
|
|
1154
|
+
response_text += cleaned_content
|
|
1155
|
+
|
|
1156
|
+
# 记录检测到的工具调用
|
|
1157
|
+
if chunk_tool_calls:
|
|
1158
|
+
detected_tool_calls.extend(chunk_tool_calls)
|
|
1159
|
+
|
|
1160
|
+
# ✅ 关键修复:在传递给 AnthropicStreamConverter 之前,先修改 chunk
|
|
1161
|
+
if "choices" in chunk and len(chunk["choices"]) > 0:
|
|
1162
|
+
if "delta" not in chunk["choices"][0]:
|
|
1163
|
+
chunk["choices"][0]["delta"] = {}
|
|
1164
|
+
# 使用清理后的内容替换原始内容
|
|
1165
|
+
chunk["choices"][0]["delta"]["content"] = cleaned_content
|
|
1166
|
+
|
|
1167
|
+
# 如果检测到工具调用,添加 tool_calls 字段
|
|
1168
|
+
if chunk_tool_calls and dsml_buffer.should_emit_tool_calls():
|
|
1169
|
+
if "choices" in chunk and len(chunk["choices"]) > 0:
|
|
1170
|
+
chunk["choices"][0]["finish_reason"] = "tool_calls"
|
|
1171
|
+
chunk["choices"][0]["delta"]["tool_calls"] = [
|
|
1172
|
+
{
|
|
1173
|
+
"index": idx,
|
|
1174
|
+
"id": call.get("id", f"call_{uuid.uuid4().hex[:24]}"),
|
|
1175
|
+
"type": "function",
|
|
1176
|
+
"function": {
|
|
1177
|
+
"name": call["function"]["name"],
|
|
1178
|
+
"arguments": call["function"]["arguments"]
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
for idx, call in enumerate(chunk_tool_calls)
|
|
1182
|
+
]
|
|
1183
|
+
|
|
1184
|
+
# 转换为 Anthropic 事件(此时 chunk 已经被清理)
|
|
1185
|
+
events = anthropic_state.feed_chunk(chunk)
|
|
1186
|
+
for event_name, event_data in events:
|
|
1187
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1188
|
+
|
|
1189
|
+
# 发送结束事件
|
|
1190
|
+
if protocol == "responses" and responses_state:
|
|
1191
|
+
# 使用 ResponsesStreamConverter 的 finish() 方法发出完整事件序列
|
|
1192
|
+
for event_name, event_data in responses_state.finish():
|
|
1193
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1194
|
+
|
|
1195
|
+
# 【修复】发送SSE流结束标记,防止客户端持续等待
|
|
1196
|
+
yield b"data: [DONE]\n\n"
|
|
1197
|
+
|
|
1198
|
+
elif protocol == "anthropic" and anthropic_state:
|
|
1199
|
+
for event_name, event_data in anthropic_state.finish():
|
|
1200
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1201
|
+
|
|
1202
|
+
# 【修复】发送SSE流结束标记,防止客户端持续等待(与Responses协议保持一致)
|
|
1203
|
+
# 虽然Anthropic有message_stop事件,但明确的[DONE]标记能确保客户端立即处理最后的内容
|
|
1204
|
+
yield b"data: [DONE]\n\n"
|
|
1205
|
+
|
|
1206
|
+
elif protocol == "openai":
|
|
1207
|
+
yield b"data: [DONE]\n\n"
|
|
1208
|
+
|
|
1209
|
+
except httpx.TimeoutException as exc:
|
|
1210
|
+
# 【日志】超时
|
|
1211
|
+
diagnostic("stream_timeout", protocol=protocol, chunks=chunk_count,
|
|
1212
|
+
elapsed=round(time.time() - stream_start_time, 2), error=str(exc))
|
|
1213
|
+
state.write_log("stream_timeout", protocol=protocol, chunks=chunk_count, error=str(exc))
|
|
1214
|
+
|
|
1215
|
+
# 【防御性编程】确保发出完整的事件序列
|
|
1216
|
+
if protocol == "responses" and responses_state:
|
|
1217
|
+
for event_name, event_data in responses_state.finish():
|
|
1218
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1219
|
+
elif protocol == "anthropic" and anthropic_state:
|
|
1220
|
+
for event_name, event_data in anthropic_state.finish():
|
|
1221
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1222
|
+
|
|
1223
|
+
# 发送错误事件
|
|
1224
|
+
error_chunk = {
|
|
1225
|
+
"error": {
|
|
1226
|
+
"message": f"stream timeout after {chunk_count} chunks",
|
|
1227
|
+
"type": "timeout_error"
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n".encode()
|
|
1231
|
+
|
|
1232
|
+
except Exception as exc:
|
|
1233
|
+
# 【日志】其他错误
|
|
1234
|
+
diagnostic("stream_error", protocol=protocol, chunks=chunk_count,
|
|
1235
|
+
elapsed=round(time.time() - stream_start_time, 2), error=str(exc))
|
|
1236
|
+
state.write_log("stream_error", protocol=protocol, chunks=chunk_count, error=str(exc))
|
|
1237
|
+
|
|
1238
|
+
# 【防御性编程】确保发出完整的事件序列
|
|
1239
|
+
if protocol == "responses" and responses_state:
|
|
1240
|
+
for event_name, event_data in responses_state.finish():
|
|
1241
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1242
|
+
elif protocol == "anthropic" and anthropic_state:
|
|
1243
|
+
for event_name, event_data in anthropic_state.finish():
|
|
1244
|
+
yield f"event: {event_name}\ndata: {json.dumps(event_data, ensure_ascii=False)}\n\n".encode()
|
|
1245
|
+
|
|
1246
|
+
# 发送错误事件
|
|
1247
|
+
error_chunk = {
|
|
1248
|
+
"error": {
|
|
1249
|
+
"message": f"stream error: {exc}",
|
|
1250
|
+
"type": "internal_error"
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n".encode()
|
|
1254
|
+
|
|
1255
|
+
finally:
|
|
1256
|
+
# 【日志】流完成
|
|
1257
|
+
if state.verbose_llm:
|
|
1258
|
+
raw_response = b"\n".join(raw_chunks)
|
|
1259
|
+
state.write_body_log("upstream_response", raw_response, protocol=protocol,
|
|
1260
|
+
status=200, method="POST", path="/v2/chat/completions")
|
|
1261
|
+
|
|
1262
|
+
logged_text = (
|
|
1263
|
+
anthropic_state.text if anthropic_state
|
|
1264
|
+
else responses_state.text if responses_state
|
|
1265
|
+
else response_text
|
|
1266
|
+
)
|
|
1267
|
+
stream_duration = round(time.time() - stream_start_time, 2)
|
|
1268
|
+
|
|
1269
|
+
log_upstream_response(protocol, logged_text, stream=True,
|
|
1270
|
+
chunk_count=chunk_count, duration=stream_duration,
|
|
1271
|
+
upstream_done=done_seen)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
# ============================================================================
|
|
1275
|
+
# 异步聚合流式响应
|
|
1276
|
+
# ============================================================================
|
|
1277
|
+
|
|
1278
|
+
async def collect_upstream(
|
|
1279
|
+
url: str,
|
|
1280
|
+
headers: dict[str, str],
|
|
1281
|
+
body: dict[str, Any],
|
|
1282
|
+
protocol: str
|
|
1283
|
+
) -> dict[str, Any]:
|
|
1284
|
+
"""聚合上游流式响应为单个 JSON 对象(非流式场景)。"""
|
|
1285
|
+
state = get_state()
|
|
1286
|
+
|
|
1287
|
+
usage = None
|
|
1288
|
+
finish_reason = None
|
|
1289
|
+
content = ""
|
|
1290
|
+
tool_calls_dict: dict[int, dict] = {} # 使用 dict 按 index 累加
|
|
1291
|
+
# DSML 缓冲区
|
|
1292
|
+
dsml_buffer = DSMLStreamBuffer()
|
|
1293
|
+
|
|
1294
|
+
try:
|
|
1295
|
+
# 使用合理的超时配置:连接超时30s,读取超时300s
|
|
1296
|
+
timeout_config = httpx.Timeout(30.0, read=300.0)
|
|
1297
|
+
async with httpx.AsyncClient(timeout=timeout_config) as client:
|
|
1298
|
+
async with client.stream("POST", url, headers=headers, json=body) as resp:
|
|
1299
|
+
if resp.status_code != 200:
|
|
1300
|
+
error_body = await resp.aread()
|
|
1301
|
+
raise HTTPException(
|
|
1302
|
+
status_code=resp.status_code,
|
|
1303
|
+
detail={"error": {"message": error_body.decode("utf-8", "replace")[:500], "type": "upstream_error"}}
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
async for line in resp.aiter_lines():
|
|
1307
|
+
line = line.strip()
|
|
1308
|
+
if not line.startswith("data:"):
|
|
1309
|
+
continue
|
|
1310
|
+
|
|
1311
|
+
data = line[5:].strip()
|
|
1312
|
+
if data == "[DONE]":
|
|
1313
|
+
break
|
|
1314
|
+
|
|
1315
|
+
try:
|
|
1316
|
+
chunk = json.loads(data)
|
|
1317
|
+
except json.JSONDecodeError:
|
|
1318
|
+
continue
|
|
1319
|
+
|
|
1320
|
+
usage = chunk.get("usage") or usage
|
|
1321
|
+
|
|
1322
|
+
for choice in chunk.get("choices") or []:
|
|
1323
|
+
finish_reason = choice.get("finish_reason") or finish_reason
|
|
1324
|
+
delta = choice.get("delta") or {}
|
|
1325
|
+
|
|
1326
|
+
# 处理 content(可能包含 DSML)
|
|
1327
|
+
if delta.get("content"):
|
|
1328
|
+
chunk_content = delta["content"]
|
|
1329
|
+
|
|
1330
|
+
# 使用 DSML 缓冲区处理
|
|
1331
|
+
cleaned_content, detected_tool_calls = dsml_buffer.add_chunk(chunk_content)
|
|
1332
|
+
|
|
1333
|
+
# 调试日志:记录 DSML 解析结果
|
|
1334
|
+
if detected_tool_calls:
|
|
1335
|
+
diagnostic("dsml_detected",
|
|
1336
|
+
tool_count=len(detected_tool_calls),
|
|
1337
|
+
tools=[tc["function"]["name"] for tc in detected_tool_calls])
|
|
1338
|
+
|
|
1339
|
+
# 累积清理后的 content
|
|
1340
|
+
if cleaned_content:
|
|
1341
|
+
content += cleaned_content
|
|
1342
|
+
|
|
1343
|
+
# 如果检测到 tool_calls,添加到 dict 中
|
|
1344
|
+
if detected_tool_calls:
|
|
1345
|
+
for detected_call in detected_tool_calls:
|
|
1346
|
+
# 找到下一个可用的 index
|
|
1347
|
+
next_idx = len(tool_calls_dict)
|
|
1348
|
+
tool_calls_dict[next_idx] = detected_call
|
|
1349
|
+
|
|
1350
|
+
# 处理原生 tool_calls(使用 dict 累加,避免预填充)
|
|
1351
|
+
if delta.get("tool_calls"):
|
|
1352
|
+
for tc in delta["tool_calls"]:
|
|
1353
|
+
idx = tc.get("index", 0)
|
|
1354
|
+
if idx not in tool_calls_dict:
|
|
1355
|
+
tool_calls_dict[idx] = {
|
|
1356
|
+
"id": "",
|
|
1357
|
+
"type": "function",
|
|
1358
|
+
"function": {"name": "", "arguments": ""}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if tc.get("id"):
|
|
1362
|
+
tool_calls_dict[idx]["id"] = tc["id"]
|
|
1363
|
+
if tc.get("function", {}).get("name"):
|
|
1364
|
+
tool_calls_dict[idx]["function"]["name"] = tc["function"]["name"]
|
|
1365
|
+
if tc.get("function", {}).get("arguments"):
|
|
1366
|
+
tool_calls_dict[idx]["function"]["arguments"] += tc["function"]["arguments"]
|
|
1367
|
+
|
|
1368
|
+
except httpx.HTTPError as exc:
|
|
1369
|
+
diagnostic("upstream_error", protocol=protocol, error=str(exc))
|
|
1370
|
+
raise HTTPException(status_code=502, detail={"error": {"message": f"upstream error: {exc}", "type": "upstream_error"}})
|
|
1371
|
+
|
|
1372
|
+
# 转换为 list 并过滤掉无效的 tool_calls(name 为空的)
|
|
1373
|
+
tool_calls = [
|
|
1374
|
+
v for k, v in sorted(tool_calls_dict.items())
|
|
1375
|
+
if v["function"]["name"]
|
|
1376
|
+
]
|
|
1377
|
+
|
|
1378
|
+
# 如果检测到 DSML tool_calls,修改 finish_reason
|
|
1379
|
+
if tool_calls and dsml_buffer.should_emit_tool_calls():
|
|
1380
|
+
finish_reason = "tool_calls"
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
# 【日志】收集完成
|
|
1384
|
+
if state.verbose_llm:
|
|
1385
|
+
# collect_upstream 没有保存原始响应,只记录聚合后的内容
|
|
1386
|
+
pass
|
|
1387
|
+
|
|
1388
|
+
log_upstream_response(protocol, content, stream=False)
|
|
1389
|
+
return {
|
|
1390
|
+
"id": "chatcmpl-" + uuid.uuid4().hex,
|
|
1391
|
+
"object": "chat.completion",
|
|
1392
|
+
"created": now_s(),
|
|
1393
|
+
"model": body.get("model", "auto"),
|
|
1394
|
+
"choices": [{
|
|
1395
|
+
"index": 0,
|
|
1396
|
+
"message": {
|
|
1397
|
+
"role": "assistant",
|
|
1398
|
+
"content": content,
|
|
1399
|
+
"tool_calls": tool_calls if tool_calls else None
|
|
1400
|
+
},
|
|
1401
|
+
"finish_reason": finish_reason or "stop"
|
|
1402
|
+
}],
|
|
1403
|
+
"usage": usage or {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
# ============================================================================
|
|
1408
|
+
# 协议转换
|
|
1409
|
+
# ============================================================================
|
|
1410
|
+
|
|
1411
|
+
def convert_nonstream(data: dict[str, Any], protocol: str, original: dict[str, Any] | None) -> dict[str, Any]:
|
|
1412
|
+
"""将聚合的 OpenAI 格式转换为目标协议格式。"""
|
|
1413
|
+
if protocol == "openai":
|
|
1414
|
+
return data
|
|
1415
|
+
|
|
1416
|
+
choice = (data.get("choices") or [{}])[0]
|
|
1417
|
+
message = choice.get("message") or {}
|
|
1418
|
+
content = message.get("content", "")
|
|
1419
|
+
|
|
1420
|
+
if protocol == "anthropic":
|
|
1421
|
+
content_blocks = []
|
|
1422
|
+
if content:
|
|
1423
|
+
content_blocks.append({"type": "text", "text": content})
|
|
1424
|
+
for call in message.get("tool_calls") or []:
|
|
1425
|
+
fn = call.get("function") or {}
|
|
1426
|
+
try:
|
|
1427
|
+
arguments = json.loads(fn.get("arguments", "{}"))
|
|
1428
|
+
except json.JSONDecodeError:
|
|
1429
|
+
arguments = fn.get("arguments", "")
|
|
1430
|
+
content_blocks.append({
|
|
1431
|
+
"type": "tool_use",
|
|
1432
|
+
"id": call.get("id", ""),
|
|
1433
|
+
"name": fn.get("name", ""),
|
|
1434
|
+
"input": arguments
|
|
1435
|
+
})
|
|
1436
|
+
return {
|
|
1437
|
+
"id": "msg_" + uuid.uuid4().hex,
|
|
1438
|
+
"type": "message",
|
|
1439
|
+
"role": "assistant",
|
|
1440
|
+
"model": (original or {}).get("model", data.get("model", "default")),
|
|
1441
|
+
"content": content_blocks,
|
|
1442
|
+
"stop_reason": "tool_use" if message.get("tool_calls") else "end_turn",
|
|
1443
|
+
"stop_sequence": None,
|
|
1444
|
+
"usage": {
|
|
1445
|
+
"input_tokens": (data.get("usage") or {}).get("prompt_tokens", 0),
|
|
1446
|
+
"output_tokens": (data.get("usage") or {}).get("completion_tokens", 0)
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
elif protocol == "responses":
|
|
1451
|
+
# 构建 content 数组(文本 + 工具调用)
|
|
1452
|
+
content_parts = []
|
|
1453
|
+
if content:
|
|
1454
|
+
content_parts.append({"type": "output_text", "text": content})
|
|
1455
|
+
|
|
1456
|
+
# 处理工具调用
|
|
1457
|
+
for call in message.get("tool_calls") or []:
|
|
1458
|
+
fn = call.get("function") or {}
|
|
1459
|
+
try:
|
|
1460
|
+
arguments = json.loads(fn.get("arguments", "{}"))
|
|
1461
|
+
except json.JSONDecodeError:
|
|
1462
|
+
arguments = fn.get("arguments", "")
|
|
1463
|
+
|
|
1464
|
+
content_parts.append({
|
|
1465
|
+
"type": "function_call",
|
|
1466
|
+
"id": call.get("id", ""),
|
|
1467
|
+
"name": fn.get("name", ""),
|
|
1468
|
+
"arguments": arguments
|
|
1469
|
+
})
|
|
1470
|
+
|
|
1471
|
+
return {
|
|
1472
|
+
"id": "resp_" + uuid.uuid4().hex,
|
|
1473
|
+
"object": "response",
|
|
1474
|
+
"created_at": now_s(),
|
|
1475
|
+
"status": "completed",
|
|
1476
|
+
"output": [{
|
|
1477
|
+
"type": "message",
|
|
1478
|
+
"role": "assistant",
|
|
1479
|
+
"content": content_parts
|
|
1480
|
+
}]
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return data
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
# ============================================================================
|
|
1487
|
+
# 启动
|
|
1488
|
+
# ============================================================================
|
|
1489
|
+
|
|
1490
|
+
def main():
|
|
1491
|
+
global proxy_state
|
|
1492
|
+
|
|
1493
|
+
parser = argparse.ArgumentParser(description="CodeBuddy local API proxy")
|
|
1494
|
+
parser.add_argument("--host", default=os.getenv("CODEBUDDY_PROXY_HOST", "127.0.0.1"),
|
|
1495
|
+
help="监听地址")
|
|
1496
|
+
parser.add_argument("--port", type=int, default=int(os.getenv("CODEBUDDY_PROXY_PORT", "8787")),
|
|
1497
|
+
help="监听端口")
|
|
1498
|
+
parser.add_argument("--endpoint", default=os.getenv("CODEBUDDY_ENDPOINT", "https://copilot.tencent.com"),
|
|
1499
|
+
help="CodeBuddy 后端地址")
|
|
1500
|
+
parser.add_argument("--session-file", type=pathlib.Path,
|
|
1501
|
+
help="会话文件路径")
|
|
1502
|
+
parser.add_argument("--mock-dir", type=pathlib.Path,
|
|
1503
|
+
help="只使用指定目录中的真实响应 fixture,不访问 CodeBuddy 后端")
|
|
1504
|
+
parser.add_argument("--log-file", type=pathlib.Path,
|
|
1505
|
+
default=pathlib.Path(os.getenv("CODEBUDDY_PROXY_LOG_FILE", "logs/codebuddy-proxy.jsonl")),
|
|
1506
|
+
help="记录完整请求/响应的 JSONL 文件(默认 logs/codebuddy-proxy.jsonl)")
|
|
1507
|
+
parser.add_argument("--desensitize", action="store_true",
|
|
1508
|
+
help="启用脱敏处理,对 system 消息中的敏感词插入零宽空格(缓解审核误拦)")
|
|
1509
|
+
parser.add_argument("--optimize-context", action="store_true",
|
|
1510
|
+
help="启用消息压缩优化(仅 /v1/responses),大幅减少 token 使用(适用于 Codex CLI 等长上下文场景)")
|
|
1511
|
+
parser.add_argument("--login", action="store_true",
|
|
1512
|
+
help="启动时执行浏览器登录/账户查询")
|
|
1513
|
+
parser.add_argument("--no-browser", action="store_true",
|
|
1514
|
+
help="登录时不自动打开浏览器")
|
|
1515
|
+
parser.add_argument("--verbose-llm", action="store_true",
|
|
1516
|
+
help="log full LLM request/response content (default: summary only, saves 98%% space)")
|
|
1517
|
+
parser.add_argument("--static-models", action="store_true",
|
|
1518
|
+
help="使用静态模型列表(默认从远程 API 动态获取)")
|
|
1519
|
+
parser.add_argument("--config-cache-ttl", type=int, default=int(os.getenv("CODEBUDDY_CONFIG_CACHE_TTL", "300")),
|
|
1520
|
+
help="远程配置缓存 TTL(秒,默认 300)")
|
|
1521
|
+
args = parser.parse_args()
|
|
1522
|
+
|
|
1523
|
+
# 设置日志
|
|
1524
|
+
log_dir = args.log_file.parent if args.log_file else pathlib.Path("logs")
|
|
1525
|
+
logger = setup_logging(log_dir)
|
|
1526
|
+
|
|
1527
|
+
# 初始化客户端
|
|
1528
|
+
client = CodeBuddyClient(args.endpoint, session_file=args.session_file)
|
|
1529
|
+
|
|
1530
|
+
# 处理登录
|
|
1531
|
+
if args.login:
|
|
1532
|
+
client.login(open_browser=not args.no_browser)
|
|
1533
|
+
|
|
1534
|
+
# 创建全局状态
|
|
1535
|
+
proxy_state = ProxyState(
|
|
1536
|
+
client=client,
|
|
1537
|
+
mock_dir=args.mock_dir,
|
|
1538
|
+
log_file=args.log_file,
|
|
1539
|
+
enable_desensitize=args.desensitize,
|
|
1540
|
+
enable_optimize_context=args.optimize_context,
|
|
1541
|
+
verbose_llm=args.verbose_llm,
|
|
1542
|
+
logger=logger
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
# 初始化远程配置缓存(默认启用动态模型列表)
|
|
1546
|
+
global remote_config_cache
|
|
1547
|
+
if not args.static_models:
|
|
1548
|
+
# 默认:动态模式
|
|
1549
|
+
remote_config_cache = RemoteConfigCache(
|
|
1550
|
+
url=args.endpoint,
|
|
1551
|
+
ttl=args.config_cache_ttl
|
|
1552
|
+
)
|
|
1553
|
+
logger.info(f"Dynamic model list enabled: cache_url={args.endpoint}, ttl={args.config_cache_ttl}s")
|
|
1554
|
+
print(f"[Dynamic Models] Enabled (endpoint={args.endpoint}, TTL={args.config_cache_ttl}s)")
|
|
1555
|
+
else:
|
|
1556
|
+
# 显式禁用:静态模式
|
|
1557
|
+
logger.info("Using static model list (25 models)")
|
|
1558
|
+
print(f"[Static Models] Using 25 hardcoded models")
|
|
1559
|
+
|
|
1560
|
+
# 启动信息输出到 stdout
|
|
1561
|
+
print(f"CodeBuddy proxy listening on http://{args.host}:{args.port}")
|
|
1562
|
+
print("Endpoints: /v1/models /v1/chat/completions /v1/responses /v1/messages /health")
|
|
1563
|
+
|
|
1564
|
+
# 同时记录到日志
|
|
1565
|
+
logger.info(f"CodeBuddy proxy listening on http://{args.host}:{args.port}")
|
|
1566
|
+
logger.info("Endpoints: /v1/models /v1/chat/completions /v1/responses /v1/messages /health")
|
|
1567
|
+
|
|
1568
|
+
# 启动 uvicorn
|
|
1569
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
|
1570
|
+
|
|
1571
|
+
if __name__ == "__main__":
|
|
1572
|
+
main()
|