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.
@@ -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()