""" Token 计数工具模块 使用 Google Vcore AI CountTokens API 进行精确的 token 计数 """ import json from typing import Any, cast from src.utils.logger import get_logger from src.core.config import load_config from src.api.network import NetworkClient from src.api.model_config import ModelConfigBuilder logger = get_logger(__name__) class TokenCounter: """Token 计数器 - 使用 Google Vcore AI CountTokens API""" def __init__(self, network: NetworkClient | None = None) -> None: self.config = load_config() self.vcore_ai_anonymous_base_api = "https://cloudconsole-pa.clients6.google.com" self._api_key = "AIzaSyCI-zsRP85UVOi0DjtiCwWBwQ1djDy741g" self.network = network or NetworkClient() self.model_builder = ModelConfigBuilder() async def calculate_usage_metadata_async( self, prompt_contents: list[dict[str, Any]], response_parts: list[dict[str, Any]], model: str = "gemini-2.5-flash" ) -> dict[str, Any]: """ 异步计算完整的 usage metadata """ try: def clean_contents(contents: list[dict[str, Any]]) -> list[dict[str, Any]]: cleaned = [] for content in contents: new_content = content.copy() if "parts" in new_content: new_parts = [] for part in new_content["parts"]: new_part = {} if "text" in part: new_part["text"] = part["text"] if "inlineData" in part: new_part["inlineData"] = part["inlineData"] if "fileData" in part: new_part["fileData"] = part["fileData"] # 转换为文本 if "functionCall" in part: func_call = part["functionCall"] text_rep = f"Function Call: {func_call.get('name', 'unknown')}" if "args" in func_call: try: text_rep += f" Args: {json.dumps(func_call['args'])}" except: text_rep += f" Args: {str(func_call['args'])}" new_part["text"] = new_part.get("text", "") + "\n" + text_rep if "functionResponse" in part: func_resp = part["functionResponse"] text_rep = f"Function Response: {func_resp.get('name', 'unknown')}" if "response" in func_resp: try: text_rep += f" Result: {json.dumps(func_resp['response'])}" except: text_rep += f" Result: {str(func_resp['response'])}" new_part["text"] = new_part.get("text", "") + "\n" + text_rep if new_part: new_parts.append(new_part) if new_parts: new_content["parts"] = new_parts else: new_content["parts"] = [{"text": " "}] cleaned.append(new_content) # 合并连续角色 merged = [] for c in cleaned: if not merged: merged.append(c) elif merged[-1].get("role") == c.get("role"): merged[-1]["parts"].extend(c.get("parts", [])) else: merged.append(c) if merged and merged[0].get("role") == "model": merged.insert(0, {"role": "user", "parts": [{"text": " "}]}) return merged safe_prompt_contents = clean_contents(prompt_contents) safe_response_parts = response_parts return await self._calculate_usage_with_session(safe_prompt_contents, safe_response_parts, model, clean_contents) except Exception as e: logger.error(f"计算 usage metadata 失败: {e}") return {"promptTokenCount": 0, "candidatesTokenCount": 0, "totalTokenCount": 0} async def _calculate_usage_with_session( self, safe_prompt_contents: list[dict[str, Any]], response_parts: list[dict[str, Any]], model: str, clean_contents_fn: Any ) -> dict[str, Any]: prompt_token_count = await self._count_tokens_with_session(safe_prompt_contents, model) full_contents = list(safe_prompt_contents) if response_parts: # Deep copy to avoid modifying original safe_prompt_contents if extended import copy full_contents = copy.deepcopy(safe_prompt_contents) # response_parts should be correctly formatted. Make sure role is set to "model" # It might just be raw text dicts right now. model_reply = clean_contents_fn([{"parts": response_parts, "role": "model"}]) if model_reply: # model_reply might have been transformed to have user as well? No, clean_contents ensures it's clean. # Actually clean_contents ensures if first role is model it inserts user. We need to prevent that here for appending. pass # Let's write a simple append logic if full_contents and full_contents[-1].get("role") == "model": full_contents[-1]["parts"].extend(response_parts) else: full_contents.append({"role": "model", "parts": response_parts}) # Clean again to ensure all rules apply (like no empty parts, function calls formatted correctly) full_contents = clean_contents_fn(full_contents) total_token_count = await self._count_tokens_with_session(full_contents, model) if total_token_count < prompt_token_count: total_token_count = prompt_token_count candidates_token_count = total_token_count - prompt_token_count usage_metadata: dict[str, Any] = { "promptTokenCount": prompt_token_count, "candidatesTokenCount": candidates_token_count, "totalTokenCount": total_token_count } logger.debug(f"Token 计算结果: {usage_metadata}") return usage_metadata async def _count_tokens_with_session(self, contents: list[dict[str, Any]], model: str) -> int: try: target_model = self.model_builder.parse_model_name(model) url = f"{self.vcore_ai_anonymous_base_api}/v3/entityServices/AiplatformEntityService/schemas/AIPLATFORM_GRAPHQL:batchGraphql?key={self._api_key}&prettyPrint=false" async with self.network.create_session() as session: recaptcha_token = await self.network.fetch_recaptcha_token(session) if not recaptcha_token: return 0 # 移除 models/ 前缀以匹配示例 if target_model.startswith("models/"): target_model = target_model[7:] payload = { "requestContext": { "clientVersion": "boq_cloud-boq-clientweb-vcoreaistudio_20260402.09_p0", "pagePath": "/vcore-ai/studio/multimodal", "jurisdiction": "global", "localizationData": { "locale": "zh_CN", "timezone": "Asia/Shanghai" } }, "querySignature": "2/mENOSldfC+HZM+tGhVuJLrl8M6gEyK3HRjUKuA5AM58=", "operationName": "CountTokens", "variables": { "contents": contents, "endpoint": "", "model": target_model, "region": "global", "recaptchaToken": recaptcha_token } } headers = { "accept": "*/*", "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", "content-type": "application/json", "origin": "https://console.cloud.google.com", "referer": "https://console.cloud.google.com/vcore-ai/studio/multimodal", "x-goog-authuser": "0", } logger.debug_json("CountTokens 请求体", payload) response = await self.network.post_request(session, url, headers, payload) if response.status_code == 200: data = response.json() logger.debug_json("CountTokens 响应体", data) try: items = data if isinstance(data, list) else [data] for entry in items: if not isinstance(entry, dict): continue if "errors" in entry: logger.error(f"CountTokens 报错: {entry['errors']}") continue results = entry.get("results", []) for result in results: if "errors" in result: logger.error(f"CountTokens 报错: {result['errors']}") continue data_obj = result.get("data", {}) ui_data = data_obj.get("ui", {}) count_data = ui_data.get("countTokensV2") or data_obj.get("countTokensV2") or data_obj.get("countTokens") if count_data and "totalTokens" in count_data: return int(count_data["totalTokens"]) except Exception as e: logger.error(f"解析 CountTokens 响应失败: {e}") else: logger.error(f"CountTokens API 请求失败: {response.status_code}") return 0 except Exception as e: logger.error(f"远程 Token 计数失败: {e}") return 0 async def count_tokens_remote(self, contents: list[dict[str, Any]], model: str = "gemini-2.5-flash") -> int: return await self._count_tokens_with_session(contents, model) # 全局实例 _token_counter = TokenCounter() async def calculate_usage_metadata( prompt_contents: list[dict[str, Any]], response_parts: list[dict[str, Any]], request_context: dict[str, Any] | None = None ) -> dict[str, Any]: """便捷函数:计算完整的 usage metadata""" model = "gemini-2.5-flash" if request_context and isinstance(request_context, dict): downstream = request_context.get("downstream_payload", {}) if isinstance(downstream, dict): model = downstream.get("model", model) return await _token_counter.calculate_usage_metadata_async( prompt_contents, response_parts, model )