Spaces:
Runtime error
Runtime error
| """ | |
| 统一API端点 | |
| 支持通过自定义key调用不同的AI服务,自动进行格式转换 | |
| """ | |
| import json | |
| import time | |
| from typing import Dict, Any, Optional, List | |
| import httpx | |
| from fastapi import APIRouter, HTTPException, Request, Response, Depends, Header | |
| from fastapi.responses import JSONResponse, StreamingResponse | |
| from pydantic import BaseModel, Field | |
| from channels.channel_manager import channel_manager, ChannelInfo | |
| from formats.converter_factory import ConverterFactory, convert_request, convert_response, convert_streaming_chunk | |
| from formats.base_converter import ConversionResult | |
| from utils.security import mask_api_key, safe_log_request, safe_log_response | |
| from src.utils.logger import setup_logger | |
| from src.utils.exceptions import ChannelNotFoundError, ConversionError, APIError, TimeoutError | |
| logger = setup_logger("unified_api") | |
| router = APIRouter() | |
| def extract_openai_api_key(authorization: Optional[str] = Header(None)) -> str: | |
| """从OpenAI格式的Authorization header中提取API key""" | |
| logger.debug(f"OpenAI auth - Received authorization header: {mask_api_key(authorization) if authorization else 'None'}") | |
| if not authorization: | |
| logger.error("Missing Authorization header") | |
| raise HTTPException(status_code=401, detail="Missing Authorization header") | |
| if not authorization.startswith("Bearer "): | |
| logger.error(f"Invalid Authorization header format: {mask_api_key(authorization)}") | |
| raise HTTPException(status_code=401, detail="Invalid Authorization header format") | |
| api_key = authorization[7:] # 移除 "Bearer " 前缀 | |
| logger.debug(f"Extracted OpenAI API key: {mask_api_key(api_key)}") | |
| return api_key | |
| def extract_anthropic_api_key(x_api_key: Optional[str] = Header(None, alias="x-api-key")) -> str: | |
| """从Anthropic格式的x-api-key header中提取API key""" | |
| logger.debug(f"Anthropic auth - Received x-api-key header: {mask_api_key(x_api_key) if x_api_key else 'None'}") | |
| if not x_api_key: | |
| logger.error("Missing x-api-key header") | |
| raise HTTPException(status_code=401, detail="Missing x-api-key header") | |
| logger.debug(f"Extracted Anthropic API key: {mask_api_key(x_api_key)}") | |
| return x_api_key | |
| def extract_gemini_api_key(request: Request) -> str: | |
| """从Gemini格式的URL参数或header中提取API key""" | |
| logger.info(f"Gemini auth - Request URL: {request.url}") | |
| logger.info(f"Gemini auth - Query params: {dict(request.query_params)}") | |
| logger.info(f"Gemini auth - Headers: {dict(request.headers)}") | |
| # Gemini API支持多种认证方式,按优先级检查: | |
| # 1. URL参数 ?key=your_api_key | |
| api_key = request.query_params.get("key") | |
| if api_key: | |
| logger.debug(f"Gemini auth - Extracted API key from URL parameter: {mask_api_key(api_key)}") | |
| return api_key | |
| # 2. Google官方SDK使用的 x-goog-api-key header | |
| x_goog_api_key = request.headers.get("x-goog-api-key") | |
| if x_goog_api_key: | |
| logger.debug(f"Gemini auth - Extracted API key from x-goog-api-key header: {mask_api_key(x_goog_api_key)}") | |
| return x_goog_api_key | |
| # 3. 标准的Authorization Bearer header | |
| authorization = request.headers.get("authorization") | |
| if authorization and authorization.startswith("Bearer "): | |
| api_key = authorization[7:] | |
| logger.debug(f"Gemini auth - Extracted API key from Authorization header: {mask_api_key(api_key)}") | |
| return api_key | |
| logger.error("Missing API key in URL parameter, x-goog-api-key header, and Authorization header") | |
| raise HTTPException(status_code=401, detail="Missing API key") | |
| async def forward_request_to_channel( | |
| channel: ChannelInfo, | |
| request_data: Dict[str, Any], | |
| source_format: str, | |
| headers: Optional[Dict[str, str]] = None | |
| ): | |
| """转发请求到目标渠道(统一处理流式和非流式)""" | |
| # 1. 检查是否为同格式透传 - 但对于Anthropic需要特殊处理图片排序 | |
| if source_format == channel.provider: | |
| # 对于Anthropic格式,即使是透传也需要应用图片优先的最佳实践 | |
| if channel.provider == "anthropic": | |
| # 检查是否包含图片内容 | |
| has_images = False | |
| messages = request_data.get("messages", []) | |
| for message in messages: | |
| if isinstance(message.get("content"), list): | |
| for content in message["content"]: | |
| if content.get("type") == "image": | |
| has_images = True | |
| break | |
| if has_images: | |
| break | |
| if has_images: | |
| logger.info(f"Anthropic-to-Anthropic with images detected, applying image ordering best practice") | |
| # 强制进行转换以应用图片排序最佳实践 | |
| conversion_result = convert_request(source_format, channel.provider, request_data, headers) | |
| else: | |
| logger.info(f"Anthropic-to-Anthropic without images, using passthrough") | |
| conversion_result = ConversionResult(success=True, data=request_data) | |
| else: | |
| logger.info(f"Same format detected, skipping request conversion: {source_format} -> {channel.provider}") | |
| conversion_result = ConversionResult(success=True, data=request_data) | |
| else: | |
| # 转换请求格式 | |
| conversion_result = convert_request(source_format, channel.provider, request_data, headers) | |
| if not conversion_result.success: | |
| raise ConversionError(f"Request conversion failed: {conversion_result.error}") | |
| # 2. 统一构建目标API的URL和headers(支持所有功能组合) | |
| target_headers = {"Content-Type": "application/json"} | |
| is_streaming = request_data.get("stream", False) | |
| if channel.provider == "openai": | |
| url = f"{channel.base_url.rstrip('/')}/chat/completions" | |
| target_headers["Authorization"] = f"Bearer {channel.api_key}" | |
| elif channel.provider == "anthropic": | |
| url = f"{channel.base_url.rstrip('/')}/v1/messages" | |
| target_headers["x-api-key"] = channel.api_key | |
| target_headers["anthropic-version"] = "2023-06-01" | |
| elif channel.provider == "gemini": | |
| model = request_data.get("model") | |
| if not model: | |
| raise ValueError("Model is required for Gemini API requests") | |
| # Gemini根据流式参数选择不同端点 | |
| if is_streaming: | |
| url = f"{channel.base_url.rstrip('/')}/models/{model}:streamGenerateContent?alt=sse&key={channel.api_key}" | |
| target_headers["Accept"] = "text/event-stream" | |
| else: | |
| url = f"{channel.base_url.rstrip('/')}/models/{model}:generateContent?key={channel.api_key}" | |
| else: | |
| raise ValueError(f"Unsupported provider: {channel.provider}") | |
| # 3. 统一请求处理 | |
| try: | |
| logger.debug(f"Sending {'streaming' if is_streaming else 'non-streaming'} request to {channel.provider}: {url}") | |
| logger.debug(f"Request data: {safe_log_request(conversion_result.data)}") | |
| if is_streaming: | |
| # 流式请求处理 - 创建独立的生成器函数 | |
| async def stream_generator(): | |
| try: | |
| async with httpx.AsyncClient(timeout=channel.timeout) as client: | |
| async with client.stream( | |
| "POST", | |
| url=url, | |
| json=conversion_result.data, | |
| headers=target_headers | |
| ) as response: | |
| async for chunk in handle_streaming_response(response, channel, request_data, source_format): | |
| yield chunk | |
| except httpx.TimeoutException: | |
| logger.error(f"Streaming request timeout after {channel.timeout} seconds") | |
| raise TimeoutError(f"Streaming request timeout after {channel.timeout} seconds") | |
| except Exception as e: | |
| error_msg = f"Streaming request failed: {str(e) if e else 'Unknown error'}" | |
| logger.error(error_msg) | |
| logger.exception("Streaming request exception details:") | |
| raise APIError(error_msg) | |
| return stream_generator() | |
| else: | |
| # 统一处理非流式请求:发送转换后的请求到目标渠道 | |
| logger.debug(f"Sending non-streaming request to {channel.provider}: {url}") | |
| async with httpx.AsyncClient(timeout=channel.timeout) as client: | |
| response = await client.post( | |
| url=url, | |
| json=conversion_result.data, | |
| headers=target_headers | |
| ) | |
| result = handle_non_streaming_response(response, channel, request_data, source_format) | |
| return result | |
| except httpx.TimeoutException: | |
| logger.error(f"Non-streaming request timeout after {channel.timeout} seconds") | |
| raise TimeoutError(f"Non-streaming request timeout after {channel.timeout} seconds") | |
| except Exception as e: | |
| logger.error(f"Non-streaming request failed: {e}") | |
| raise APIError(f"Non-streaming request failed: {e}") | |
| async def handle_streaming_response(response, channel, request_data, source_format): | |
| """处理流式响应""" | |
| logger.debug(f"Received streaming response from {channel.provider}: status={response.status_code}") | |
| if response.status_code == 200: | |
| # 流式处理响应 | |
| logger.debug("Starting to process streaming response") | |
| chunk_count = 0 | |
| # 根据客户端期望的格式选择合适的结束标记 | |
| if source_format == "openai": | |
| end_marker = "data: [DONE]\n\n" | |
| elif source_format == "gemini": | |
| # Gemini不需要特殊的结束标记,最后一个chunk包含finishReason即可 | |
| end_marker = "" | |
| elif source_format == "anthropic": | |
| # Anthropic使用event: message_stop | |
| end_marker = "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" | |
| else: | |
| end_marker = "data: [DONE]\n\n" | |
| # For same-format passthrough, we need to preserve the complete SSE structure | |
| if channel.provider == source_format: | |
| logger.info(f"PASSTHROUGH MODE ACTIVATED: {channel.provider} -> {source_format}") | |
| logger.info(f"PASSTHROUGH: Response status = {response.status_code}") | |
| logger.info(f"PASSTHROUGH: Response headers = {dict(response.headers)}") | |
| # Direct passthrough of complete SSE lines including event: lines | |
| # 完全按原样传输,不做任何修改 | |
| line_count = 0 | |
| try: | |
| async for line in response.aiter_lines(): | |
| line_count += 1 | |
| if line_count <= 5 or line_count % 10 == 0 or line.strip() == "data: [DONE]": | |
| logger.info(f"PASSTHROUGH Line {line_count}: '{line}'") | |
| # 完全按原样传输每一行 | |
| yield f"{line}\n" | |
| logger.info(f"PASSTHROUGH COMPLETED: Total lines processed: {line_count}") | |
| except Exception as e: | |
| logger.error(f"PASSTHROUGH ERROR: {e}") | |
| raise | |
| return | |
| async for line in response.aiter_lines(): | |
| # 记录所有接收到的行用于调试 | |
| logger.debug(f"Received SSE line: '{line}'") | |
| # 只处理以 "data: " 开头的行,其余 SSE 行(如 event: keep-alive)直接忽略 | |
| if not line.startswith("data: "): | |
| # 记录被忽略的行,特别关注思考模型可能的特殊格式 | |
| if line.strip(): # 只记录非空行 | |
| logger.debug(f"Ignored non-data SSE line: '{line}'") | |
| continue | |
| data_content = line[6:] # 移除 "data: " 前缀 | |
| chunk_count += 1 | |
| logger.debug(f"RAW CHUNK {chunk_count}: '{data_content}'") # 详细记录原始数据 | |
| # 处理结束哨兵或空数据 - 必须在JSON解析之前检查 | |
| if data_content.strip() in ("[DONE]", ""): | |
| logger.info(f"Stream ended with marker: '{data_content.strip()}'") | |
| logger.info(f"Sending end_marker to client: '{end_marker}'") | |
| if end_marker: # 只有非空的end_marker才发送 | |
| yield end_marker | |
| break | |
| try: | |
| # 解析JSON数据 | |
| chunk_data = json.loads(data_content) | |
| logger.debug(f"Parsed chunk data: {chunk_data}") | |
| # 通用的chunk处理逻辑:检查是否有内容和结束标记 | |
| # 这里不应该假设特定的格式结构,让转换器来处理格式差异 | |
| has_content = False | |
| is_finish_chunk = False | |
| # 简单检测是否包含内容或结束标记,具体格式由转换器处理 | |
| if chunk_data: | |
| # 检测不同格式的结束标记 | |
| if isinstance(chunk_data, dict): | |
| # OpenAI格式检测 | |
| if "choices" in chunk_data and chunk_data["choices"]: | |
| choice = chunk_data["choices"][0] | |
| if choice.get("finish_reason"): | |
| is_finish_chunk = True | |
| if choice.get("delta", {}).get("content"): | |
| has_content = True | |
| elif choice.get("delta", {}).get("tool_calls"): | |
| # 检查tool_calls是否有效,避免undefined错误 | |
| tool_calls = choice.get("delta", {}).get("tool_calls", []) | |
| if tool_calls and any(tc and tc.get("function") for tc in tool_calls): | |
| has_content = True # 工具调用也算作有内容 | |
| # Gemini格式检测 - 修复:允许同时有内容和结束标记 | |
| elif "candidates" in chunk_data and chunk_data["candidates"]: | |
| candidate = chunk_data["candidates"][0] | |
| # 检测是否有内容(文本或工具调用) | |
| if candidate.get("content", {}).get("parts"): | |
| parts = candidate["content"]["parts"] | |
| # 检查是否有文本内容或工具调用 | |
| if any("text" in part or "functionCall" in part for part in parts): | |
| has_content = True | |
| # 检测是否是结束chunk | |
| if candidate.get("finishReason"): | |
| is_finish_chunk = True | |
| # Anthropic格式检测 - 精确匹配需要处理的事件类型 | |
| elif chunk_data.get("type") == "content_block_delta": | |
| # 文本或工具参数增量,需要处理 | |
| has_content = True | |
| elif chunk_data.get("type") == "content_block_start": | |
| # content_block_start标志内容块开始,包含文本或工具调用 | |
| content_block = chunk_data.get("content_block", {}) | |
| block_type = content_block.get("type") | |
| if block_type in ["tool_use", "text"]: | |
| has_content = True | |
| elif chunk_data.get("type") == "content_block_stop": | |
| # content_block_stop标志工具调用完成,需要处理 | |
| has_content = True | |
| elif chunk_data.get("type") == "message_delta": | |
| # message_delta包含stop_reason等结束信息 | |
| delta = chunk_data.get("delta", {}) | |
| if "stop_reason" in delta: | |
| has_content = True | |
| elif chunk_data.get("type") == "message_stop": | |
| # message_stop标志流结束 | |
| is_finish_chunk = True | |
| # 明确排除不需要处理的事件类型 | |
| elif chunk_data.get("type") in ["message_start"]: | |
| # message_start只是流开始标记,不包含实际内容 | |
| has_content = False | |
| # 其他未知类型默认不处理 | |
| logger.debug(f"Chunk {chunk_count} analysis: has_content={has_content}, is_finish_chunk={is_finish_chunk}") | |
| # 如果有内容,转换并发送内容chunk(不管是否也是结束chunk) | |
| if has_content: | |
| original_model = request_data.get("model") | |
| # Fix parameter order: source_format=provider, target_format=client_format | |
| logger.debug(f"Calling convert_streaming_chunk: source={channel.provider}, target={source_format}") | |
| try: | |
| response_conversion = convert_streaming_chunk(channel.provider, source_format, chunk_data, original_model) | |
| logger.debug(f"Content chunk conversion result: success={response_conversion.success if response_conversion else 'None'}") | |
| except Exception as e: | |
| logger.error(f"Error in convert_streaming_chunk for content chunk: {e}") | |
| logger.error(f"Parameters: provider={channel.provider}, source={source_format}, chunk={chunk_data}") | |
| import traceback | |
| logger.error(f"Traceback: {traceback.format_exc()}") | |
| # 发送原始数据作为后备,然后继续处理下一个chunk | |
| yield f"data: {json.dumps(chunk_data, ensure_ascii=False)}\n\n" | |
| continue | |
| if response_conversion and response_conversion.success: | |
| converted_data = response_conversion.data | |
| if isinstance(converted_data, str): | |
| # 如果是SSE格式字符串(Anthropic),直接输出 | |
| if converted_data.strip(): # 只有非空字符串才输出 | |
| logger.debug(f"Sending SSE chunk {chunk_count}: {converted_data[:100]}...") | |
| yield converted_data | |
| elif isinstance(converted_data, list): | |
| # 多个事件,逐个发送保持事件边界 | |
| for ev in converted_data: | |
| if ev.strip(): | |
| logger.debug(f"Sending SSE chunk {chunk_count}: {ev[:100]}...") | |
| yield ev | |
| else: | |
| # 如果是JSON对象(OpenAI/Gemini),包装成data字段 | |
| logger.debug(f"Sending JSON chunk {chunk_count} to client: {json.dumps(converted_data, ensure_ascii=False)}") | |
| yield f"data: {json.dumps(converted_data, ensure_ascii=False)}\n\n" | |
| else: | |
| # 如果转换失败,返回原始数据 | |
| logger.warning(f"Conversion failed: {response_conversion.error}") | |
| yield f"data: {json.dumps(chunk_data, ensure_ascii=False)}\n\n" | |
| # 检查是否是结束chunk(各种格式的结束标记) | |
| # 注意:如果chunk既有内容又是结束,避免重复处理(内容处理时已经处理了结束逻辑) | |
| if is_finish_chunk: | |
| if has_content: | |
| logger.debug(f"Stream ending with content+finish chunk - already processed by content handler") | |
| else: | |
| logger.debug(f"Stream ending with finish-only chunk: {chunk_data}") | |
| if is_finish_chunk and not has_content: | |
| # 转换并发送结束chunk(可能包含最后的内容和结束事件) | |
| original_model = request_data.get("model") | |
| # Fix parameter order for finish event conversion as well | |
| logger.debug(f"Calling convert_streaming_chunk for finish: source={channel.provider}, target={source_format}") | |
| try: | |
| response_conversion = convert_streaming_chunk(channel.provider, source_format, chunk_data, original_model) | |
| logger.debug(f"Finish chunk conversion result: success={response_conversion.success if response_conversion else 'None'}") | |
| except Exception as e: | |
| logger.error(f"Error in convert_streaming_chunk for finish chunk: {e}") | |
| logger.error(f"Parameters: provider={channel.provider}, source={source_format}, chunk={chunk_data}") | |
| import traceback | |
| logger.error(f"Traceback: {traceback.format_exc()}") | |
| # 发送原始数据作为后备 | |
| yield f"data: {json.dumps(chunk_data, ensure_ascii=False)}\n\n" | |
| if end_marker: # 只有非空的end_marker才发送 | |
| yield end_marker | |
| break | |
| if response_conversion and response_conversion.success: | |
| converted_data = response_conversion.data | |
| if isinstance(converted_data, list): | |
| # 如果是事件列表(Anthropic),逐个发送每个完整事件 | |
| for event in converted_data: | |
| if event.strip(): | |
| logger.debug(f"Sending finish event: {event[:100]}...") | |
| yield event | |
| elif isinstance(converted_data, str): | |
| if converted_data.strip(): | |
| logger.debug(f"Sending finish chunk: {converted_data[:100]}...") | |
| yield converted_data | |
| else: | |
| logger.debug(f"Sending finish chunk to client: {json.dumps(converted_data, ensure_ascii=False)}") | |
| yield f"data: {json.dumps(converted_data, ensure_ascii=False)}\n\n" | |
| # 发送结束标记 | |
| if end_marker: # 只有非空的end_marker才发送 | |
| yield end_marker | |
| break | |
| except json.JSONDecodeError as e: | |
| # 详细记录JSON解析错误信息用于调试 | |
| logger.error(f"JSON decode error in streaming response:") | |
| logger.error(f" - Error: {e}") | |
| logger.error(f" - Data content: '{data_content}'") | |
| logger.error(f" - Data length: {len(data_content)}") | |
| logger.error(f" - Channel provider: {channel.provider}") | |
| logger.error(f" - Source format: {source_format}") | |
| logger.error(f" - Chunk count: {chunk_count}") | |
| # 特殊处理:如果数据内容看起来像[DONE]但被其他字符包围 | |
| if "[DONE]" in data_content: | |
| logger.warning(f"Found [DONE] in malformed chunk: '{data_content}', sending end marker") | |
| yield end_marker | |
| break | |
| # 对于其他非法JSON,尝试透传(保持连接) | |
| logger.warning(f"Attempting to pass through malformed chunk as-is") | |
| yield f"data: {data_content}\n\n" | |
| continue | |
| logger.debug(f"Streaming completed. Total chunks processed: {chunk_count}") | |
| # 如果没有处理任何chunks,发送错误响应 | |
| if chunk_count == 0: | |
| logger.warning("No chunks received from streaming response") | |
| # 发送一个错误响应 | |
| error_chunk = { | |
| "id": "chatcmpl-error", | |
| "object": "chat.completion.chunk", | |
| "created": int(__import__('time').time()), | |
| "model": request_data.get("model", "unknown"), | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {"content": "Error: No response received from AI service."}, | |
| "finish_reason": "stop" | |
| }] | |
| } | |
| yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n" | |
| if end_marker: # 只有非空的end_marker才发送 | |
| yield end_marker | |
| else: | |
| error_detail = f"Streaming request failed with status {response.status_code}: {await response.aread()}" | |
| logger.error(error_detail) | |
| raise APIError(error_detail) | |
| def handle_non_streaming_response(response, channel, request_data, source_format): | |
| """处理非流式响应""" | |
| logger.info(f"Received response from {channel.provider}: status={response.status_code}") | |
| # 处理非流式响应 | |
| if response.status_code == 200: | |
| response_data = response.json() | |
| logger.debug(f"Received response from {channel.provider}: {safe_log_response(response_data)}") | |
| # 检查是否为同格式透传 | |
| if channel.provider == source_format: | |
| logger.debug(f"Same format passthrough for non-streaming response: {channel.provider} -> {source_format}") | |
| # 同格式直接返回原始数据 | |
| return response_data | |
| else: | |
| # 转换响应格式 | |
| converter = ConverterFactory().get_converter(source_format) | |
| # 设置原始模型名称 | |
| original_model = request_data.get("model") | |
| if hasattr(converter, 'set_original_model') and original_model: | |
| converter.set_original_model(original_model) | |
| conversion_result = converter.convert_response( | |
| response_data, | |
| channel.provider, | |
| source_format | |
| ) | |
| if not conversion_result.success: | |
| raise ConversionError(f"Response conversion failed: {conversion_result.error}") | |
| logger.debug(f"Converted response: {safe_log_response(conversion_result.data)}") | |
| return conversion_result.data | |
| # 处理 429 限流错误,返回带重试建议的响应 | |
| elif response.status_code == 429: | |
| error_data = response.json() if response.content else {} | |
| retry_after = "20" # OpenAI 默认建议 20 秒 | |
| # 尝试从错误消息中提取具体等待时间 | |
| if "error" in error_data and "message" in error_data["error"]: | |
| import re | |
| match = re.search(r'try again in (\d+)s', error_data["error"]["message"]) | |
| if match: | |
| retry_after = match.group(1) | |
| # 抛出 HTTPException 由上层统一处理 | |
| raise HTTPException(status_code=429, detail="Rate limit exceeded", headers={"Retry-After": retry_after}) | |
| else: | |
| error_text = response.text | |
| logger.error(f"Target API request failed with status {response.status_code}: {error_text}") | |
| raise APIError(f"Target API request failed with status {response.status_code}: {error_text}") | |
| # -------------------------------------------------------------------- | |
| # 统一的OpenAI兼容端点 | |
| async def unified_openai_format_endpoint( | |
| request: Request, | |
| api_key: str = Depends(extract_openai_api_key) | |
| ): | |
| """OpenAI格式统一端点(使用标准OpenAI认证)""" | |
| return await handle_unified_request(request, api_key, source_format="openai") | |
| async def list_models(api_key: str = Depends(extract_openai_api_key)): | |
| """列出可用模型""" | |
| try: | |
| channel = channel_manager.get_channel_by_custom_key(api_key) | |
| if not channel: | |
| raise HTTPException(status_code=401, detail="Invalid API key") | |
| # 返回OpenAI格式的模型列表 | |
| models = [] | |
| if channel.models_mapping: | |
| for openai_model, target_model in channel.models_mapping.items(): | |
| models.append({ | |
| "id": openai_model, | |
| "object": "model", | |
| "created": int(time.time()), | |
| "owned_by": channel.provider | |
| }) | |
| else: | |
| # 默认模型映射 | |
| default_models = { | |
| "openai": ["gpt-4o", "gpt-4o-mini", "gpt-4", "gpt-3.5-turbo"], | |
| "anthropic": ["gpt-4o", "gpt-4", "gpt-3.5-turbo"], # 映射到OpenAI模型名 | |
| "gemini": ["gpt-4o", "gpt-4", "gpt-3.5-turbo"] | |
| } | |
| for model_name in default_models.get(channel.provider, []): | |
| models.append({ | |
| "id": model_name, | |
| "object": "model", | |
| "created": int(time.time()), | |
| "owned_by": channel.provider | |
| }) | |
| return { | |
| "object": "list", | |
| "data": models | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"List models failed: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # 统一端点:基于路径自动识别格式,基于key识别目标渠道 | |
| async def unified_anthropic_format_endpoint( | |
| request: Request, | |
| api_key: str = Depends(extract_anthropic_api_key) | |
| ): | |
| """Anthropic格式统一端点(使用标准Anthropic认证)""" | |
| return await handle_unified_request(request, api_key, source_format="anthropic") | |
| async def unified_gemini_format_endpoint( | |
| request: Request, | |
| model_id: str, | |
| api_key: str = Depends(extract_gemini_api_key) | |
| ): | |
| """Gemini格式统一端点(使用标准Gemini认证)""" | |
| # 检测是否为流式请求 (Gemini API特有的流式检测方式) | |
| is_streaming = False | |
| original_url = str(request.url) | |
| # Gemini API流式请求标准: 必须同时满足两个条件 | |
| # 1. URL路径包含 :streamGenerateContent | |
| # 2. URL参数包含 alt=sse | |
| if ":streamGenerateContent" in original_url and "alt=sse" in original_url: | |
| is_streaming = True | |
| logger.debug("Detected Gemini streaming request: :streamGenerateContent + alt=sse") | |
| # 清理模型ID,移除可能的后缀 | |
| clean_model_id = model_id | |
| if ':generateContent' in model_id: | |
| clean_model_id = model_id.replace(':generateContent', '') | |
| logger.debug(f"Cleaned model ID: {model_id} -> {clean_model_id}") | |
| elif ':streamGenerateContent' in model_id: | |
| clean_model_id = model_id.replace(':streamGenerateContent', '') | |
| logger.debug(f"Cleaned model ID: {model_id} -> {clean_model_id}") | |
| # 将清理后的模型ID和流式标识添加到请求数据中 | |
| request_data = await request.json() | |
| request_data["model"] = clean_model_id | |
| # 对于Gemini格式,如果检测到流式请求,设置stream参数 | |
| if is_streaming: | |
| request_data["stream"] = True | |
| logger.debug("Added stream=True to request data for Gemini streaming") | |
| # 重新构建请求对象 | |
| class ModifiedRequest: | |
| def __init__(self, original_request, modified_data): | |
| self.headers = original_request.headers | |
| self._json_data = modified_data | |
| async def json(self): | |
| return self._json_data | |
| modified_request = ModifiedRequest(request, request_data) | |
| return await handle_unified_request(modified_request, api_key, source_format="gemini") | |
| async def unified_gemini_count_tokens_endpoint( | |
| request: Request, | |
| model_id: str, | |
| api_key: str = Depends(extract_gemini_api_key) | |
| ): | |
| """Gemini格式countTokens端点(用于计算token数量)""" | |
| logger.info(f"Gemini countTokens request for model: {model_id}") | |
| try: | |
| # 清理模型ID,移除可能的countTokens后缀 | |
| clean_model_id = model_id | |
| if ':countTokens' in model_id: | |
| clean_model_id = model_id.replace(':countTokens', '') | |
| logger.info(f"Cleaned model ID: {model_id} -> {clean_model_id}") | |
| # 获取请求数据 | |
| request_data = await request.json() | |
| # 对于countTokens,只需要contents字段 | |
| count_request_data = { | |
| "model": clean_model_id, | |
| "contents": request_data.get("contents", []) | |
| } | |
| logger.debug(f"Count tokens request data: {safe_log_request(count_request_data)}") | |
| # 直接调用渠道进行token计数 | |
| # 这里需要找到合适的渠道来处理请求 | |
| logger.debug(f"Looking for channel with custom_key: {mask_api_key(api_key)}") | |
| channel = channel_manager.get_channel_by_custom_key(api_key) | |
| if not channel: | |
| logger.error(f"No available channel found for API key: {mask_api_key(api_key)}") | |
| # 列出所有可用的渠道用于调试 | |
| all_channels = channel_manager.get_all_channels() | |
| logger.info(f"Available channels: {[(ch.custom_key, ch.provider) for ch in all_channels]}") | |
| raise HTTPException(status_code=503, detail="No available channels") | |
| logger.info(f"Found channel: {channel.name} (provider: {channel.provider}, custom_key: {channel.custom_key})") | |
| # 根据渠道provider类型处理countTokens请求 | |
| if channel.provider == "gemini": | |
| # Gemini渠道:直接转发countTokens请求 | |
| return await handle_gemini_count_tokens(channel, clean_model_id, count_request_data) | |
| elif channel.provider == "openai": | |
| # OpenAI渠道:转换为OpenAI格式并使用tiktoken计算 | |
| return await handle_openai_count_tokens_for_gemini(channel, clean_model_id, count_request_data) | |
| elif channel.provider == "anthropic": | |
| # Anthropic渠道:转换为Anthropic格式并估算token数量 | |
| return await handle_anthropic_count_tokens_for_gemini(channel, clean_model_id, count_request_data) | |
| else: | |
| logger.error(f"Channel provider {channel.provider} does not support countTokens") | |
| raise HTTPException(status_code=400, detail=f"Channel provider {channel.provider} does not support countTokens") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Gemini countTokens request failed: {e}") | |
| raise HTTPException(status_code=500, detail=f"Internal server error: {e}") | |
| async def handle_unified_request(request, api_key: str, source_format: str): | |
| """统一请求处理逻辑""" | |
| try: | |
| logger.debug(f"Processing request: source_format={source_format}, api_key={mask_api_key(api_key)}") | |
| # 1. 根据key识别目标渠道 | |
| channel = channel_manager.get_channel_by_custom_key(api_key) | |
| if not channel: | |
| logger.error(f"No channel found for api_key: {mask_api_key(api_key)}") | |
| raise HTTPException(status_code=401, detail="Invalid API key") | |
| # 2. 获取请求数据 | |
| request_data = await request.json() | |
| # 3. 验证必须字段 | |
| if not request_data.get("model"): | |
| raise HTTPException(status_code=400, detail="Model name is required") | |
| # Anthropic格式需要max_tokens字段 | |
| if source_format == "anthropic" and not request_data.get("max_tokens"): | |
| raise HTTPException(status_code=400, detail="max_tokens is required for Anthropic format") | |
| logger.debug(f"Unified API: source_format={source_format}, key={mask_api_key(api_key)}, target_provider={channel.provider}") | |
| logger.debug(f"Request stream parameter: {request_data.get('stream', False)}") | |
| # 4. 根据流式参数选择处理方式 | |
| is_streaming = request_data.get("stream", False) | |
| if is_streaming: | |
| # 流式请求 | |
| logger.debug("Processing streaming request") | |
| stream_generator = await forward_request_to_channel( | |
| channel=channel, | |
| request_data=request_data, | |
| source_format=source_format, | |
| headers=dict(request.headers) | |
| ) | |
| return StreamingResponse( | |
| stream_generator, | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "Connection": "keep-alive", | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Headers": "*", | |
| "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", | |
| "X-Accel-Buffering": "no" # 禁用Nginx缓冲,确保实时流式传输 | |
| } | |
| ) | |
| else: | |
| # 非流式请求 | |
| logger.debug("Processing non-streaming request") | |
| response_data = await forward_request_to_channel( | |
| channel=channel, | |
| request_data=request_data, | |
| source_format=source_format, | |
| headers=dict(request.headers) | |
| ) | |
| logger.debug(f"Final response data type: {type(response_data)}") | |
| logger.debug(f"Final response data: {safe_log_response(response_data)}") | |
| # 使用JSONResponse确保正确的Content-Type和编码 | |
| return JSONResponse( | |
| content=response_data, | |
| status_code=200, | |
| headers={ | |
| "Content-Type": "application/json; charset=utf-8" | |
| } | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Unified {source_format} API request failed: {e}") | |
| raise HTTPException(status_code=500, detail=f"Internal server error: {e}") | |
| async def handle_gemini_count_tokens(channel: ChannelInfo, model_id: str, request_data: dict): | |
| """处理Gemini渠道的countTokens请求""" | |
| logger.info(f"Handling Gemini countTokens for model: {model_id}") | |
| # 构建countTokens的URL和请求 | |
| count_tokens_url = f"{channel.base_url.rstrip('/')}/models/{model_id}:countTokens" | |
| logger.info(f"Sending request to: {count_tokens_url}") | |
| logger.info(f"Channel base_url: {channel.base_url}") | |
| logger.debug(f"Using API key: {mask_api_key(channel.api_key)}") | |
| headers = { | |
| "Content-Type": "application/json" | |
| } | |
| # 添加API key到URL参数 | |
| if "?" in count_tokens_url: | |
| count_tokens_url += f"&key={channel.api_key}" | |
| else: | |
| count_tokens_url += f"?key={channel.api_key}" | |
| logger.info(f"Final URL with API key: {count_tokens_url}") | |
| # 发送请求到目标渠道 | |
| async with httpx.AsyncClient(timeout=30.0) as client: | |
| response = await client.post( | |
| count_tokens_url, | |
| json=request_data, | |
| headers=headers | |
| ) | |
| if response.status_code != 200: | |
| logger.error(f"Gemini count tokens request failed: {response.status_code} - {response.text}") | |
| raise HTTPException( | |
| status_code=response.status_code, | |
| detail=f"Count tokens request failed: {response.text}" | |
| ) | |
| result = response.json() | |
| logger.info(f"Gemini count tokens response: {result}") | |
| return JSONResponse( | |
| content=result, | |
| status_code=200, | |
| headers={"Content-Type": "application/json; charset=utf-8"} | |
| ) | |
| async def handle_openai_count_tokens_for_gemini(channel: ChannelInfo, model_id: str, request_data: dict): | |
| """处理OpenAI渠道的countTokens请求,转换为Gemini格式响应""" | |
| logger.info(f"Handling OpenAI countTokens for Gemini format request, model: {model_id}") | |
| try: | |
| # 从Gemini格式的contents提取文本用于token计数 | |
| contents = request_data.get("contents", []) | |
| text_to_count = "" | |
| for content in contents: | |
| if isinstance(content, dict): | |
| parts = content.get("parts", []) | |
| for part in parts: | |
| if isinstance(part, dict) and "text" in part: | |
| text_to_count += part["text"] + "\n" | |
| logger.info(f"Extracted text for token counting: {text_to_count[:200]}...") | |
| # 使用tiktoken计算token数量 | |
| import tiktoken | |
| # 根据模型选择正确的编码 | |
| if "gpt-4" in model_id.lower(): | |
| encoding = tiktoken.encoding_for_model("gpt-4") | |
| elif "gpt-3.5" in model_id.lower(): | |
| encoding = tiktoken.encoding_for_model("gpt-3.5-turbo") | |
| else: | |
| # 默认使用cl100k_base编码(适用于大多数现代模型) | |
| encoding = tiktoken.get_encoding("cl100k_base") | |
| # 计算token数量 | |
| token_count = len(encoding.encode(text_to_count)) | |
| logger.info(f"Calculated token count: {token_count}") | |
| # 构建Gemini格式的响应 | |
| gemini_response = { | |
| "totalTokens": token_count | |
| } | |
| return JSONResponse( | |
| content=gemini_response, | |
| status_code=200, | |
| headers={"Content-Type": "application/json; charset=utf-8"} | |
| ) | |
| except ImportError: | |
| # 如果tiktoken不可用,回退到简单的字符数估算 | |
| logger.warning("tiktoken not available, using character-based estimation") | |
| contents = request_data.get("contents", []) | |
| text_to_count = "" | |
| for content in contents: | |
| if isinstance(content, dict): | |
| parts = content.get("parts", []) | |
| for part in parts: | |
| if isinstance(part, dict) and "text" in part: | |
| text_to_count += part["text"] + "\n" | |
| # 简单估算:平均4个字符=1个token | |
| estimated_tokens = len(text_to_count) // 4 | |
| logger.info(f"Estimated token count (character-based): {estimated_tokens}") | |
| gemini_response = { | |
| "totalTokens": estimated_tokens | |
| } | |
| return JSONResponse( | |
| content=gemini_response, | |
| status_code=200, | |
| headers={"Content-Type": "application/json; charset=utf-8"} | |
| ) | |
| except Exception as e: | |
| logger.error(f"OpenAI countTokens conversion failed: {e}") | |
| raise HTTPException(status_code=500, detail=f"Token counting failed: {e}") | |
| async def handle_anthropic_count_tokens_for_gemini(channel: ChannelInfo, model_id: str, request_data: dict): | |
| """处理Anthropic渠道的countTokens请求,转换为Gemini格式响应""" | |
| logger.info(f"Handling Anthropic countTokens for Gemini format request, model: {model_id}") | |
| try: | |
| # 从Gemini格式的contents提取文本用于token计数 | |
| contents = request_data.get("contents", []) | |
| text_to_count = "" | |
| for content in contents: | |
| if isinstance(content, dict): | |
| parts = content.get("parts", []) | |
| for part in parts: | |
| if isinstance(part, dict) and "text" in part: | |
| text_to_count += part["text"] + "\n" | |
| logger.info(f"Extracted text for token counting (Anthropic): {text_to_count[:200]}...") | |
| # Anthropic API没有专门的token计数端点,我们使用估算方法 | |
| # Anthropic的token计算大致是:1 token ≈ 3.5个字符(英文) | |
| char_count = len(text_to_count) | |
| estimated_tokens = max(1, int(char_count / 3.5)) | |
| logger.info(f"Estimated token count for Anthropic (char-based): {estimated_tokens}") | |
| # 构建Gemini格式的响应 | |
| gemini_response = { | |
| "totalTokens": estimated_tokens | |
| } | |
| return JSONResponse( | |
| content=gemini_response, | |
| status_code=200, | |
| headers={"Content-Type": "application/json; charset=utf-8"} | |
| ) | |
| except Exception as e: | |
| logger.error(f"Anthropic countTokens conversion failed: {e}") | |
| raise HTTPException(status_code=500, detail=f"Token counting failed: {e}") | |
| # 健康检查端点 | |
| async def health_check(): | |
| """健康检查""" | |
| return { | |
| "status": "healthy", | |
| "timestamp": time.time(), | |
| "version": "1.0.0" | |
| } | |